marky

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

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");