commit 629ef0c8e8937b5719a4e6c2e9eaba5d94303da2
parent cf8d9259645824ff1bd5fd875b6e9eb2bd285baf
Author: David Eisinger <[email protected]>
Date: Thu, 9 Oct 2025 13:43:29 -0400
Start revamp
Diffstat:
8 files changed, 376 insertions(+), 119 deletions(-)
diff --git a/README.md b/README.md
@@ -1,6 +1,6 @@
# marky
-A simple web API for bookmarking websites, written in Go. It stores bookmarks in a local SQLite database at `~/.marky/bookmarks.sql`, archives a plaintext rendering of each page, and exposes HTTP endpoints for CRUD and full‑text search. A minimal Vue.js frontend is embedded and served at `/`.
+A simple web API for bookmarking websites, written in Go. It stores bookmarks in a local SQLite database at `~/.marky/bookmarks.sql`, archives a plaintext rendering of each page, and exposes HTTP endpoints for CRUD and full‑text search. A minimal Vue‑enhanced frontend is embedded and served as a few discrete pages (e.g., `/`, `/u:{username}`, `/settings`).
- Language: Go
- Storage: SQLite (via `modernc.org/sqlite`, pure Go)
@@ -184,9 +184,9 @@ Examples (include private when authenticated):
### Bookmarklet
-- Visit `/bookmarklet` while logged in to get a “Save to Marky” bookmarklet you can drag to your bookmarks bar.
+- Open `/settings` while logged in to get a “Save to Marky” bookmarklet you can drag to your bookmarks bar.
- Clicking the bookmarklet on any page opens a small popup with URL and title prefilled; you can adjust description and toggle public/private.
-- The popup submits to `/bookmarks` using your API token (embedded for your session).
+- The popup submits to `/bookmarks` using your session or API token (embedded when present).
### Public JSON listing
@@ -229,7 +229,7 @@ curl -s http://localhost:8080/bookmarks/1/archive
- Database and helpers: `internal/`
- Migrations: `internal/migrations/*.sql`
- Archiver: `internal/archiver.go` (pure-Go HTML-to-text)
-- Embedded web UI: `internal/web/` (served at `/`)
+- Embedded web UI: `internal/web/` (discrete pages)
### Reverse proxy / HTTPS
diff --git a/internal/api.go b/internal/api.go
@@ -5,7 +5,6 @@ import (
"encoding/json"
"encoding/xml"
"fmt"
- "io/fs"
"log"
"net/http"
"net/url"
@@ -131,33 +130,12 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
// Helpers for embedding JSON strings into HTML templates
enc := func(s string) string { b, _ := json.Marshal(s); return string(b) }
if pageURL == "" {
+ // No installer page anymore; redirect to settings (or root when unauth)
if !hasSession || user == nil {
- http.Error(w, "unauthorized", http.StatusUnauthorized)
- return
- }
- // Installer page: assemble from template with shared header
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- headTpl, err := fs.ReadFile(webAssets, "web/header.html")
- if err != nil {
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- headerHTML := string(headTpl)
- pageTpl, err := fs.ReadFile(webAssets, "web/bookmarklet.html")
- if err != nil {
- http.Error(w, "internal server error", http.StatusInternalServerError)
- return
- }
- page := string(pageTpl)
- page = strings.ReplaceAll(page, "{{HEADER}}", headerHTML)
- page = strings.ReplaceAll(page, "{{ORIGIN}}", origin)
- // Add auth query parameters for incognito usage (when token present)
- auth := ""
- if present {
- auth = "&user=" + url.QueryEscape(user.Username) + "&tok=" + url.QueryEscape(token)
+ http.Redirect(w, r, "/", http.StatusFound)
+ } else {
+ http.Redirect(w, r, "/settings", http.StatusFound)
}
- page = strings.ReplaceAll(page, "{{AUTH}}", auth)
- _, _ = w.Write([]byte(page))
return
}
// Popup form page
@@ -366,13 +344,56 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
}
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
+ // Root: show login page when unauthenticated; redirect to /u:{username} when logged in
+ if p == "" {
+ if uid, ok := userIDFromRequest(r, db); ok && uid > 0 {
+ if u, err := db.GetUserByID(r.Context(), uid); err == nil && u != nil {
+ http.Redirect(w, r, "/u:"+u.Username, http.StatusFound)
+ return
+ }
+ }
+ if html, err := RenderPage("login.html", cfg); err == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(html)
+ return
+ }
+ }
+ // Settings page (requires auth)
+ if p == "settings" {
+ if uid, ok := userIDFromRequest(r, db); !ok || uid == 0 {
+ http.Redirect(w, r, "/", http.StatusFound)
+ return
+ }
+ if html, err := RenderPage("settings.html", cfg); err == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(html)
+ return
+ }
+ }
if p != "" {
// If the path is a single segment ending with a trailing dot, treat it
- // as an invalid username route and return 404 instead of serving the SPA.
+ // as an invalid username route and return 404.
if !strings.Contains(p, "/") && strings.HasSuffix(p, ".") {
http.NotFound(w, r)
return
}
+ // New user page route: /u:{username}
+ if strings.HasPrefix(p, "u:") {
+ uname := strings.TrimPrefix(p, "u:")
+ if uname == "" || strings.Contains(uname, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ if u, err := db.GetUserByUsername(r.Context(), uname); err == nil && u != nil {
+ if html, err := RenderPage("user.html", cfg); err == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(html)
+ return
+ }
+ }
+ http.NotFound(w, r)
+ return
+ }
if strings.HasSuffix(p, ".json") || strings.HasSuffix(p, ".rss") {
// Extract username and format
format := "json"
@@ -414,14 +435,17 @@ func NewHandlerWithConfig(db *DB, cfg HandlerConfig) (http.Handler, error) {
}
}
}
- // If this looks like a username route (no dot/extension, no subpath),
- // return 404 when the user does not exist. Otherwise, serve the SPA.
+ // Legacy username route: "/{username}" → serve user page when user exists
if !strings.Contains(p, "/") && !strings.Contains(p, ".") {
- if u, err := db.GetUserByUsername(r.Context(), p); err != nil || u == nil {
- http.NotFound(w, r)
- return
+ if u, err := db.GetUserByUsername(r.Context(), p); err == nil && u != nil {
+ if html, err := RenderPage("user.html", cfg); err == nil {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(html)
+ return
+ }
}
- // user exists: fall through to SPA to render the page
+ http.NotFound(w, r)
+ return
}
}
static.ServeHTTP(w, r)
diff --git a/internal/static.go b/internal/static.go
@@ -1,12 +1,12 @@
package internal
import (
- "bytes"
- "embed"
- "io/fs"
- "net/http"
- "path"
- "strings"
+ "bytes"
+ "embed"
+ "io/fs"
+ "net/http"
+ "path"
+ "strings"
)
//go:embed web/*
@@ -18,49 +18,79 @@ func StaticHandler() (http.Handler, error) { return StaticHandlerWithConfig(Hand
// StaticHandlerWithConfig serves the embedded UI, injecting runtime config.
func StaticHandlerWithConfig(cfg HandlerConfig) (http.Handler, error) {
- sub, err := fs.Sub(webAssets, "web")
- if err != nil {
- return nil, err
- }
- fsys := http.FS(sub)
- fileServer := http.FileServer(fsys)
- indexBytes, err := fs.ReadFile(sub, "index.html")
- if err != nil {
- return nil, err
- }
- // Inject shared header into index.html placeholder
- if hb, err := fs.ReadFile(sub, "header.html"); err == nil {
- if bytes.Contains(indexBytes, []byte("{{HEADER}}")) {
- indexBytes = bytes.ReplaceAll(indexBytes, []byte("{{HEADER}}"), hb)
- }
- }
- // Inject a small config script when features are toggled.
- if cfg.DisableSignup {
- marker := []byte("<script src=\"/app.js\"></script>")
- if i := bytes.Index(indexBytes, marker); i >= 0 {
- inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n")
- nb := make([]byte, 0, len(indexBytes)+len(inject))
- nb = append(nb, indexBytes[:i]...)
- nb = append(nb, inject...)
- nb = append(nb, indexBytes[i:]...)
- indexBytes = nb
- }
- }
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- p := strings.TrimPrefix(r.URL.Path, "/")
- if p == "" {
- // Serve index directly to avoid redirects
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _, _ = w.Write(indexBytes)
- return
- }
- // Try to stat the requested file
- if f, err := fs.Stat(sub, path.Clean(p)); err == nil && !f.IsDir() {
- fileServer.ServeHTTP(w, r)
- return
- }
- // Fallback to index.html for SPA routes
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- _, _ = w.Write(indexBytes)
- }), nil
+ sub, err := fs.Sub(webAssets, "web")
+ if err != nil {
+ return nil, err
+ }
+ fsys := http.FS(sub)
+ fileServer := http.FileServer(fsys)
+ indexBytes, err := fs.ReadFile(sub, "index.html")
+ if err != nil {
+ return nil, err
+ }
+ // Inject shared header into index.html placeholder
+ if hb, err := fs.ReadFile(sub, "header.html"); err == nil {
+ if bytes.Contains(indexBytes, []byte("{{HEADER}}")) {
+ indexBytes = bytes.ReplaceAll(indexBytes, []byte("{{HEADER}}"), hb)
+ }
+ }
+ // Inject a small config script when features are toggled.
+ if cfg.DisableSignup {
+ marker := []byte("<script src=\"/app.js\"></script>")
+ if i := bytes.Index(indexBytes, marker); i >= 0 {
+ inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n")
+ nb := make([]byte, 0, len(indexBytes)+len(inject))
+ nb = append(nb, indexBytes[:i]...)
+ nb = append(nb, inject...)
+ nb = append(nb, indexBytes[i:]...)
+ indexBytes = nb
+ }
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ p := strings.TrimPrefix(r.URL.Path, "/")
+ if p == "" {
+ // Serve index directly to avoid redirects
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(indexBytes)
+ return
+ }
+ // Try to stat the requested file
+ if f, err := fs.Stat(sub, path.Clean(p)); err == nil && !f.IsDir() {
+ fileServer.ServeHTTP(w, r)
+ return
+ }
+ // Fallback to index.html for SPA routes
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write(indexBytes)
+ }), nil
+}
+
+// RenderPage reads an embedded HTML page under internal/web and injects the
+// shared header and optional runtime config.
+func RenderPage(name string, cfg HandlerConfig) ([]byte, error) {
+ sub, err := fs.Sub(webAssets, "web")
+ if err != nil {
+ return nil, err
+ }
+ pageBytes, err := fs.ReadFile(sub, name)
+ if err != nil {
+ return nil, err
+ }
+ if hb, err := fs.ReadFile(sub, "header.html"); err == nil {
+ if bytes.Contains(pageBytes, []byte("{{HEADER}}")) {
+ pageBytes = bytes.ReplaceAll(pageBytes, []byte("{{HEADER}}"), hb)
+ }
+ }
+ if cfg.DisableSignup {
+ marker := []byte("<script src=\"/app.js\"></script>")
+ if i := bytes.Index(pageBytes, marker); i >= 0 {
+ inject := []byte("<script>window.MARKY_CFG={disableSignup:true}</script>\n")
+ nb := make([]byte, 0, len(pageBytes)+len(inject))
+ nb = append(nb, pageBytes[:i]...)
+ nb = append(nb, inject...)
+ nb = append(nb, pageBytes[i:]...)
+ pageBytes = nb
+ }
+ }
+ return pageBytes, nil
}
diff --git a/internal/web/app.js b/internal/web/app.js
@@ -33,17 +33,29 @@ createApp({
const res = await fetch('/me');
if (res.ok) this.me = await res.json();
} catch {}
- const pathUser = location.pathname.replace(/^\/+/, '').split('/')[0] || '';
- if (!pathUser) {
+ const p = location.pathname.replace(/^\/+/, '');
+ const first = p.split('/')[0] || '';
+ // Root: show login/signup or redirect to user page
+ if (!first) {
if (this.me && this.me.username) {
- location.replace('/' + this.me.username);
+ location.replace('/u:' + this.me.username);
return;
}
this.view = 'login';
this.updateTitle();
return;
}
- this.viewUser = pathUser;
+ // Settings page
+ if (first === 'settings') {
+ if (!this.me || !this.me.username) { location.replace('/'); return; }
+ await this.fetchApiPresence();
+ this.updateTitle();
+ return;
+ }
+ // User page: support new "/u:{username}" and legacy "/{username}"
+ let u = '';
+ if (first.startsWith('u:')) u = first.slice(2); else u = first;
+ this.viewUser = u;
if (this.me && this.me.username === this.viewUser) {
this.view = 'self';
this.fetchApiPresence();
@@ -123,7 +135,7 @@ createApp({
const res = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.auth) });
if (!res.ok) throw new Error(await res.text());
const me = await res.json();
- location.replace('/' + me.username);
+ location.replace('/u:' + me.username);
} catch (e) {
this.error = e.message || String(e);
} finally { this.busy = false; }
@@ -135,7 +147,7 @@ createApp({
const res = await fetch('/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.auth) });
if (!res.ok) throw new Error(await res.text());
const me = await res.json();
- location.replace('/' + me.username);
+ location.replace('/u:' + me.username);
} catch (e) {
this.error = e.message || String(e);
} finally { this.busy = false; }
@@ -250,6 +262,15 @@ createApp({
this.results = arr.map((x)=>({ ...x, description: x.description ?? null, public: true }));
} catch (e) { console.error(e); }
},
+ bookmarkletHref() {
+ // Build the bookmarklet installer link dynamically
+ const origin = location.origin;
+ const auth = (this.api.present && this.api.value && this.me && this.me.username)
+ ? `&user=${encodeURIComponent(this.me.username)}&tok=${encodeURIComponent(this.api.value)}`
+ : '';
+ const tmpl = "javascript:(function(){try{var d=document,s=encodeURIComponent,u=location.href,t=document.title,sel='';var ae=d.activeElement;if(ae&&((ae.tagName==='TEXTAREA')||(ae.tagName==='INPUT'&&/^(?:text|search|url|tel|email)$/i.test(ae.type)))){var ss=ae.selectionStart||0,se=ae.selectionEnd||0;if(typeof ss==='number'&&typeof se==='number'&&se>ss){sel=ae.value.substring(ss,se)}}if(!sel&&d.getSelection){sel=d.getSelection().toString()}else if(!sel&&window.getSelection){sel=window.getSelection().toString()}else if(!sel&&d.selection){sel=d.selection.createRange().text}var win=window.open('{{ORIGIN}}/bookmarklet?u='+s(u)+'&t='+s(t)+'&d='+s(sel)+'{{AUTH}}','marky','width=520,height=520');if(win){win.focus()}}catch(e){alert('Could not open Marky: '+e)}})();";
+ return tmpl.replace('{{ORIGIN}}', origin).replace('{{AUTH}}', auth);
+ },
formatDate(iso) {
try { return new Date(iso).toLocaleString(); } catch { return iso; }
},
diff --git a/internal/web/header.html b/internal/web/header.html
@@ -8,33 +8,12 @@
<div class="user-line">
<span>Logged in as <b>{{ me.username }}</b></span>
<span class="sep">|</span>
+ <a :href="`/u:${me.username}`">My Bookmarks</a>
+ <span class="sep">|</span>
+ <a href="/settings">Settings</a>
+ <span class="sep">|</span>
<a href="#" @click.prevent="logout">Log out</a>
</div>
- <div class="api-key">
- <template v-if="api.present">
- API Token:
- <template v-if="!api.show">
- <a href="#" @click.prevent="revealApiKey">reveal</a>
- <span class="sep">|</span>
- <a href="#" @click.prevent="regenerateApiKey">regenerate</a>
- </template>
- <template v-else>
- <code class="token">{{ api.value }}</code>
- <span class="sep">|</span>
- <span v-if="api.copied">copied!</span>
- <a v-else href="#" @click.prevent="copyApiKey">copy</a>
- <span class="sep">|</span>
- <a href="#" @click.prevent="regenerateApiKey">regenerate</a>
- </template>
- </template>
- <template v-else>
- API Token: none
- <span class="sep">|</span><a href="#" @click.prevent="generateApiKey">generate</a>
- </template>
- </div>
- <div class="small-links">
- <a href="/bookmarklet" title="Install the Save to Marky bookmarklet">Bookmarklet</a>
- </div>
</template>
<template v-else>
<div class="user-line">
diff --git a/internal/web/login.html b/internal/web/login.html
@@ -0,0 +1,44 @@
+<!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="/vendor/vue.global.prod.js"></script>
+ <script>if(!window.Vue){var s=document.createElement('script');s.src='https://unpkg.com/vue@3/dist/vue.global.prod.js';document.head.appendChild(s)}</script>
+ </head>
+ <body>
+ <div id="app">
+ {{HEADER}}
+ <main class="container grid">
+ <section v-if="view==='login'">
+ <h2>Login</h2>
+ <form @submit.prevent="login">
+ <label>Username <input v-model.trim="auth.username" autocomplete="username" required /></label>
+ <label>Password <input v-model="auth.password" type="password" autocomplete="current-password" required /></label>
+ <div class="actions">
+ <button :disabled="busy">{{ busy ? 'Working…' : 'Login' }}</button>
+ </div>
+ <p v-if="error" class="error">{{ error }}</p>
+ <p class="auth-links" v-if="!disableSignup">No account? <a href="#" @click.prevent="view='signup'">Create one</a></p>
+ </form>
+ </section>
+ <section v-if="view==='signup' && !disableSignup">
+ <h2>Sign Up</h2>
+ <form @submit.prevent="signup">
+ <label>Username <input v-model.trim="auth.username" autocomplete="username" required /></label>
+ <label>Password <input v-model="auth.password" type="password" autocomplete="new-password" required /></label>
+ <div class="actions">
+ <button :disabled="busy">{{ busy ? 'Working…' : 'Create Account' }}</button>
+ </div>
+ <p v-if="error" class="error">{{ error }}</p>
+ <p class="auth-links">Have an account? <a href="#" @click.prevent="view='login'">Log in</a></p>
+ </form>
+ </section>
+ </main>
+ </div>
+ <script src="/app.js"></script>
+ </body>
+ </html>
+
diff --git a/internal/web/settings.html b/internal/web/settings.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>settings | marky</title>
+ <link rel="stylesheet" href="/styles.css" />
+ <script src="/vendor/vue.global.prod.js"></script>
+ </head>
+ <body>
+ <div id="app">
+ {{HEADER}}
+ <main class="container">
+ <section>
+ <h2>API Token</h2>
+ <div class="api-key">
+ <template v-if="api.present">
+ <template v-if="!api.show">
+ Token present. <a href="#" @click.prevent="revealApiKey">reveal</a>
+ <span class="sep">|</span>
+ <a href="#" @click.prevent="regenerateApiKey">regenerate</a>
+ </template>
+ <template v-else>
+ <div>
+ <code class="token">{{ api.value }}</code>
+ <span class="sep">|</span>
+ <span v-if="api.copied">copied!</span>
+ <a v-else href="#" @click.prevent="copyApiKey">copy</a>
+ <span class="sep">|</span>
+ <a href="#" @click.prevent="regenerateApiKey">regenerate</a>
+ </div>
+ </template>
+ </template>
+ <template v-else>
+ No token yet.
+ <span class="sep">|</span>
+ <a href="#" @click.prevent="generateApiKey">generate</a>
+ </template>
+ </div>
+ </section>
+
+ <section>
+ <h2>Bookmarklet</h2>
+ <p>Drag this to your bookmarks bar:</p>
+ <p>
+ <a id="bmlet" class="btn" :href="bookmarkletHref()">Save to Marky</a>
+ </p>
+ <p class="muted">When clicked on any page, this opens a small form prefilled with the page URL and title. On submit, it saves to your Marky account.</p>
+ </section>
+ </main>
+ </div>
+ <script src="/app.js"></script>
+ </body>
+ </html>
+
diff --git a/internal/web/user.html b/internal/web/user.html
@@ -0,0 +1,104 @@
+<!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="/vendor/vue.global.prod.js"></script>
+ <script>if(!window.Vue){var s=document.createElement('script');s.src='https://unpkg.com/vue@3/dist/vue.global.prod.js';document.head.appendChild(s)}</script>
+ <script src="/vendor/marked.min.js"></script>
+ <script>if(!window.marked){var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/marked/marked.min.js';document.head.appendChild(s)}</script>
+ <script src="/vendor/purify.min.js"></script>
+ <script>if(!window.DOMPurify){var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js';document.head.appendChild(s)}</script>
+ </head>
+ <body>
+ <div id="app">
+ {{HEADER}}
+ <main class="container grid">
+ <section v-if="view==='self'">
+ <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>
+ <label class="inline"><input type="checkbox" v-model="form.public" /> Public</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 v-if="view==='self'">
+ <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>
+ </form>
+ <div class="results">
+ <article v-for="b in results" :key="b.id" :class="['card', { private: !b.public }]">
+ <h3><a :href="b.url" target="_blank" rel="noopener noreferrer">{{ b.title || b.url }}</a></h3>
+ <p class="url">{{ b.url }}</p>
+ <div v-if="b.editing" class="edit-form">
+ <label>URL <input v-model.trim="b.editUrl" type="url" required /></label>
+ <label>Title <input v-model.trim="b.editTitle" type="text" required /></label>
+ <label>Description <textarea v-model.trim="b.editDescription" rows="2"></textarea></label>
+ <label class="inline"><input type="checkbox" v-model="b.editPublic" /> Public</label>
+ <div class="actions">
+ <button @click="saveEdit(b)" :disabled="b.saving">{{ b.saving ? 'Saving…' : 'Save' }}</button>
+ <button @click="cancelEdit(b)">Cancel</button>
+ </div>
+ </div>
+ <p v-else-if="b.excerpt" class="excerpt" v-html="formatExcerpt(b.excerpt)"></p>
+ <div v-if="!b.editing && !b.excerpt && b.description" class="desc" v-html="renderMarkdown(b.description)"></div>
+ <p class="action-links">
+ <span class="meta">{{ formatDate(b.updated_at) }}</span>
+ <span class="sep">|</span>
+ <template v-if="b.has_archive">
+ <a :href="`/bookmarks/${b.id}/archive`" target="_blank" title="View archived text">Archive</a>
+ <span class="sep">|</span>
+ </template>
+ <a href="#" @click.prevent="startEdit(b)">Edit</a>
+ <span class="sep">|</span>
+ <a href="#" @click.prevent="confirmDelete(b)">Delete</a>
+ </p>
+ </article>
+ </div>
+ </section>
+
+ <section v-if="view==='public'" class="span2">
+ <h2>
+ {{ viewUser }}
+ <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>
+ <p class="url">{{ b.url }}</p>
+ <div v-if="b.description" class="desc" v-html="renderMarkdown(b.description)"></div>
+ <p class="action-links">
+ <span class="meta">{{ formatDate(b.updated_at) }}</span>
+ <template v-if="b.has_archive">
+ <span class="sep">|</span>
+ <a :href="`/bookmarks/${b.id}/archive`" target="_blank">Archive</a>
+ </template>
+ </p>
+ </article>
+ </div>
+ </section>
+ </main>
+ </div>
+ <script src="/app.js"></script>
+ </body>
+ </html>
+