app.js (11570B)
1 const { createApp } = Vue; 2 3 createApp({ 4 data() { 5 return { 6 form: { url: "", title: "", description: "", referrer: "", public: false }, 7 busy: false, 8 error: "", 9 message: "", 10 11 results: [], 12 view: 'login', // 'login' | 'signup' | 'self' | 'public' 13 disableSignup: !!(window.MARKY_CFG && window.MARKY_CFG.disableSignup), 14 me: null, 15 viewUser: '', 16 pager: null, 17 auth: { username: '', password: '' }, 18 api: { value: '', busy: false, copied: false, show: false }, 19 bmlet: { copied: false }, 20 }; 21 }, 22 mounted() { 23 this.initView(); 24 }, 25 computed: { 26 pagerPages() { 27 const pager = this.pager; 28 if (!pager || !pager.total || pager.total <= 1) return []; 29 const total = Number(pager.total) || 0; 30 const current = Number(pager.page) || 1; 31 if (total <= 1) return []; 32 const pages = new Set(); 33 const add = (n) => { 34 if (n >= 1 && n <= total) pages.add(n); 35 }; 36 add(1); 37 add(2); 38 add(total - 1); 39 add(total); 40 add(current - 1); 41 add(current); 42 add(current + 1); 43 const sorted = Array.from(pages).sort((a, b) => a - b); 44 const items = []; 45 let prev = null; 46 for (const n of sorted) { 47 if (prev !== null) { 48 const gap = n - prev; 49 if (gap === 2) { 50 items.push({ type: 'page', n: prev + 1, key: `p${prev + 1}` }); 51 } else if (gap > 2) { 52 items.push({ type: 'ellipsis', key: `g${prev}-${n}` }); 53 } 54 } 55 items.push({ type: 'page', n, key: `p${n}` }); 56 prev = n; 57 } 58 return items; 59 }, 60 }, 61 methods: { 62 normalizeResults(arr) { 63 return arr.map((x) => ({ 64 ...x, 65 description: x.description ?? null, 66 public: !!x.public, 67 urlTrunc: true, 68 })); 69 }, 70 async initView() { 71 // Determine logged-in user from server-embedded data 72 if (window.MARKY_ME) this.me = { username: window.MARKY_ME }; 73 const p = location.pathname.replace(/^\/+/, ''); 74 const first = p.split('/')[0] || ''; 75 // Root: show login/signup or redirect to user page 76 if (!first) { 77 // Server redirects logged-in users to /u:{username}; render login/signup otherwise 78 this.view = 'login'; 79 return; 80 } 81 // Settings page 82 if (first === 'settings') { 83 if (!this.me || !this.me.username) { location.replace('/'); return; } 84 // Token embedded by server 85 this.api.value = (window.MARKY_TOKEN || ''); 86 return; 87 } 88 // User page: support new "/u:{username}" and legacy "/{username}" 89 let u = ''; 90 if (first.startsWith('u:')) u = first.slice(2); else u = first; 91 this.viewUser = u; 92 if (this.me && this.me.username === this.viewUser) { 93 this.view = 'self'; 94 // no token fetch needed here 95 // Initialize from server-embedded data when available 96 try { 97 if (window.MARKY_INITIAL && window.MARKY_INITIAL.view === 'self' && window.MARKY_INITIAL.user === this.me.username) { 98 const r = Array.isArray(window.MARKY_INITIAL.results) ? window.MARKY_INITIAL.results : []; 99 this.results = this.normalizeResults(r); 100 } 101 if (window.MARKY_PAGINATION) { this.pager = window.MARKY_PAGINATION; } 102 } catch {} 103 // Do not auto-fetch all bookmarks on initial load 104 } else { 105 this.view = 'public'; 106 // Initialize from server-embedded data when available; no network fallback 107 try { 108 if (window.MARKY_INITIAL && window.MARKY_INITIAL.view === 'public' && window.MARKY_INITIAL.user === this.viewUser) { 109 const r = Array.isArray(window.MARKY_INITIAL.results) ? window.MARKY_INITIAL.results : []; 110 this.results = this.normalizeResults(r); 111 } 112 if (window.MARKY_PAGINATION) { this.pager = window.MARKY_PAGINATION; } 113 } catch {} 114 } 115 }, 116 117 renderMarkdown(text) { 118 try { 119 if (!text) return ''; 120 const html = marked.parse(String(text), { breaks: true }); 121 return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }); 122 } catch { return String(text || ''); } 123 }, 124 async regenerateApiKey() { 125 this.api.busy = true; 126 try { 127 const res = await fetch('/api/token', { method: 'POST' }); 128 if (!res.ok) throw new Error('Failed to regenerate'); 129 const data = await res.json(); 130 this.api.value = data.api_token || ''; 131 this.api.copied = false; 132 } catch {} 133 this.api.busy = false; 134 }, 135 async copyApiKey() { 136 try { 137 await navigator.clipboard.writeText(this.api.value || ''); 138 this.api.copied = true; 139 setTimeout(() => { this.api.copied = false; }, 1500); 140 } catch (e) { 141 alert('Failed to copy token'); 142 } 143 }, 144 async login() { 145 this.error = ''; 146 this.busy = true; 147 try { 148 const res = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.auth) }); 149 if (!res.ok) throw new Error(await res.text()); 150 const me = await res.json(); 151 location.replace('/u:' + me.username); 152 } catch (e) { 153 this.error = e.message || String(e); 154 } finally { this.busy = false; } 155 }, 156 async signup() { 157 this.error = ''; 158 this.busy = true; 159 try { 160 const res = await fetch('/signup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.auth) }); 161 if (!res.ok) throw new Error(await res.text()); 162 const me = await res.json(); 163 location.replace('/u:' + me.username); 164 } catch (e) { 165 this.error = e.message || String(e); 166 } finally { this.busy = false; } 167 }, 168 async logout() { 169 await fetch('/logout', { method: 'POST' }).catch(()=>{}); 170 location.replace('/'); 171 }, 172 startEdit(b) { 173 b.editing = true; 174 b.editUrl = b.url; 175 b.editTitle = b.title; 176 b.editDescription = b.description || ""; 177 b.editReferrer = b.referrer || ""; 178 b.editPublic = !!b.public; 179 b.saving = false; 180 }, 181 cancelEdit(b) { 182 b.editing = false; 183 }, 184 async saveEdit(b) { 185 b.saving = true; 186 try { 187 const payload = { 188 url: b.editUrl, 189 title: b.editTitle, 190 description: b.editDescription, 191 referrer: b.editReferrer, 192 public: !!b.editPublic, 193 }; 194 const res = await fetch(`/bookmarks/${b.id}`, { 195 method: 'PUT', 196 headers: { 'Content-Type': 'application/json' }, 197 body: JSON.stringify(payload), 198 }); 199 if (!res.ok) throw new Error(await res.text()); 200 const data = await res.json(); 201 Object.assign(b, data, { editing: false }); 202 } catch (e) { 203 alert(`Update failed: ${e.message || e}`); 204 } finally { 205 b.saving = false; 206 } 207 }, 208 async confirmDelete(b) { 209 if (!confirm('Delete this bookmark?')) return; 210 try { 211 const res = await fetch(`/bookmarks/${b.id}`, { method: 'DELETE' }); 212 if (res.status === 204) { 213 // Remove from local results 214 this.results = this.results.filter(x => x.id !== b.id); 215 } else if (!res.ok) { 216 throw new Error(await res.text()); 217 } 218 } catch (e) { 219 alert(`Delete failed: ${e.message || e}`); 220 } 221 }, 222 async createBookmark() { 223 this.error = ""; 224 this.message = ""; 225 this.busy = true; 226 try { 227 const payload = { url: this.form.url }; 228 if (this.form.title) payload.title = this.form.title; 229 if (this.form.description) payload.description = this.form.description; 230 if (this.form.referrer) payload.referrer = this.form.referrer; 231 if (this.form.public) payload.public = true; 232 const res = await fetch("/bookmarks", { 233 method: "POST", 234 headers: { "Content-Type": "application/json" }, 235 body: JSON.stringify(payload), 236 }); 237 if (!res.ok) { 238 const text = await res.text(); 239 throw new Error(text || `HTTP ${res.status}`); 240 } 241 const data = await res.json(); 242 this.message = `Saved: ${data.title || data.url}`; 243 this.form.url = ""; 244 this.form.title = ""; 245 this.form.description = ""; 246 this.form.referrer = ""; 247 this.form.public = false; 248 // refresh the page (preserves any current query params) 249 location.reload(); 250 } catch (e) { 251 this.error = e.message || String(e); 252 } finally { 253 this.busy = false; 254 } 255 }, 256 257 // loadPublic removed; server embeds data for public view 258 bookmarkletHref() { 259 // Build the bookmarklet installer link dynamically 260 const origin = location.origin; 261 const auth = (this.api.value && this.me && this.me.username) 262 ? `&user=${encodeURIComponent(this.me.username)}&tok=${encodeURIComponent(this.api.value)}` 263 : ''; 264 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 ref=d.referrer||'';try{var ch=location.hostname||'';var rh=ref?new URL(ref).hostname:'';if(rh&&ch&&rh===ch){ref='';}}catch(e){}var win=window.open('{{ORIGIN}}/bookmarklet?u='+s(u)+'&t='+s(t)+'&d='+s(sel)+'&r='+s(ref)+'{{AUTH}}','marky','width=520,height=520');if(win){win.focus()}}catch(e){alert('Could not open Marky: '+e)}})();"; 265 return tmpl.replace('{{ORIGIN}}', origin).replace('{{AUTH}}', auth); 266 }, 267 async copyBookmarklet() { 268 const js = this.bookmarkletHref(); 269 try { 270 await navigator.clipboard.writeText(js); 271 this.bmlet.copied = true; 272 setTimeout(()=>{ this.bmlet.copied = false; }, 1500); 273 } catch (e) { 274 // Fallback for browsers without Clipboard API (including some iOS versions) 275 try { 276 const ta = document.createElement('textarea'); 277 ta.value = js; 278 ta.style.position = 'fixed'; 279 ta.style.opacity = '0'; 280 document.body.appendChild(ta); 281 ta.focus(); 282 ta.select(); 283 document.execCommand('copy'); 284 document.body.removeChild(ta); 285 this.bmlet.copied = true; 286 setTimeout(()=>{ this.bmlet.copied = false; }, 1500); 287 } catch { 288 alert('Copy failed. Long‑press the Save to Marky button, copy the link, then paste into a new bookmark.'); 289 } 290 } 291 }, 292 formatDate(iso) { 293 try { return new Date(iso).toLocaleString(); } catch { return iso; } 294 }, 295 formatExcerpt(ex) { 296 // Our API wraps matches with guillemets « » — convert to <mark> 297 return ex.replace(/«/g, '<mark>').replace(/»/g, '</mark>'); 298 }, 299 toggleURL(b) { 300 b.urlTrunc = !b.urlTrunc; 301 }, 302 submitOnClear(e) { 303 try { 304 const el = e && e.target; 305 if (el && typeof el.value === 'string' && el.value === '' && el.form) { 306 el.form.submit(); 307 } 308 } catch {} 309 }, 310 }, 311 }).mount("#app");