marky

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

commit 629ef0c8e8937b5719a4e6c2e9eaba5d94303da2
parent cf8d9259645824ff1bd5fd875b6e9eb2bd285baf
Author: David Eisinger <[email protected]>
Date:   Thu,  9 Oct 2025 13:43:29 -0400

Start revamp

Diffstat:
MREADME.md | 8++++----
Minternal/api.go | 90++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Minternal/static.go | 132++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Minternal/web/app.js | 33+++++++++++++++++++++++++++------
Minternal/web/header.html | 29++++-------------------------
Ainternal/web/login.html | 44++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/settings.html | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/web/user.html | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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> +