Create New Item
Item Type
File
Folder
Item Name
Search file in folder and subfolders...
Are you sure want to rename?
File Manager
/
portableapps
/
assets
/
admin
:
editor-20261026170707.js
Advanced Search
Upload
New Item
Settings
Back
Back Up
Advanced Editor
Save
// Post editor controller: wires Themestrap plugins to the blog's save, upload and render endpoints. (function ($) { 'use strict'; var cfg = JSON.parse(document.getElementById('ts-config').textContent); var $title = $('#f-title'), $body = $('#f-body'), $rich = $('#f-rich'); var isNew = !cfg.original; var state = { mode: 'markdown', dirty: false, saving: false, status: cfg.status, original: cfg.original, rich: null }; // Helpers function post(url, data) { var fd = data instanceof FormData ? data : (function () { var f = new FormData(); Object.keys(data).forEach(function (k) { f.append(k, data[k]); }); return f; })(); fd.append('_csrf', cfg.csrf); return fetch(url, { method: 'POST', body: fd, credentials: 'same-origin', headers: { 'X-CSRF-Token': cfg.csrf } }) .then(function (r) { return r.json().catch(function () { return { ok: false, errors: ['Unexpected response from the server.'] }; }); }); } var toastTimer; function toast(msg, bad) { var t = document.getElementById('toast'); t.textContent = msg; t.hidden = false; t.className = 'toast' + (bad ? ' bad' : ''); clearTimeout(toastTimer); toastTimer = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2200); } function setState(text) { document.getElementById('state').textContent = text; } function markDirty() { if (!state.dirty) { state.dirty = true; setState('Unsaved changes'); } scheduleLocal(); } // Phone photos are often 5 MB or more. Shrink them here so uploads work on any server. function shrinkImage(file) { if (!/^image\/(jpeg|png|webp)$/.test(file.type) || !window.createImageBitmap) return Promise.resolve(file); return createImageBitmap(file).then(function (bmp) { var w = bmp.width, h = bmp.height, scale = Math.min(1, 2000 / Math.max(w, h)); if (file.size <= 1048576 && scale === 1) { if (bmp.close) bmp.close(); return file; } var c = document.createElement('canvas'); c.width = Math.round(w * scale); c.height = Math.round(h * scale); c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height); if (bmp.close) bmp.close(); function encode(type, q) { return new Promise(function (res) { c.toBlob(res, type, q); }); } return encode(file.type === 'image/png' ? 'image/png' : 'image/jpeg', 0.85).then(function (b) { // A large PNG photo is smaller as JPEG; keep PNG only when transparency may matter and it is small enough if (b && file.type === 'image/png' && b.size > Math.min(cfg.limit, 1572864)) return encode('image/jpeg', 0.85); return b; }).then(function (b) { if (!b || b.size >= file.size) return file; var ext = b.type === 'image/png' ? '.png' : '.jpg'; return new File([b], file.name.replace(/\.\w+$/, '') + ext, { type: b.type }); }); }).catch(function () { return file; }); } function uploadImage(file) { return shrinkImage(file).then(function (f) { if (f.size > cfg.limit) { var mb = (cfg.limit / 1048576).toFixed(1).replace(/\.0$/, ''); var e = new Error('That picture is too big for this server (the most it accepts is ' + mb + ' MB).'); toast(e.message, true); throw e; } var fd = new FormData(); fd.append('file', f); return post(cfg.upload, fd).then(function (r) { if (!r.ok) throw new Error((r.errors || ['Upload failed'])[0]); return r.url; }); }).catch(function (e) { toast(e.message, true); throw e; }); } function renderServer(md) { return post(cfg.render, { body: md }).then(function (r) { if (!r.ok) throw new Error('render'); return r.html; }); } // Plugins $body.val(cfg.body); var md = $body.themestrapPluginMarkdownEditor({ view: 'write', minHeight: 'calc(100vh - 280px)', imageUpload: uploadImage, renderer: renderServer, previewDelay: 300, placeholder: 'Write your story. Markdown works here.' }) && $body.data('__pluginMarkdownEditor'); // These mirror what the server keeps (TSStore::parseTags), so the chips show exactly what will be saved: // special characters dropped, spaces collapsed, "Web" and "web" the same tag, 20 at most var MAX_TAGS = 20; var tags = $('#f-tags').themestrapPluginTagsInput({ placeholder: 'Type or paste tags', validateEmail: false, maxTags: MAX_TAGS, ignoreCase: true, normalize: function (t) { return t.replace(/["'<>,\/\\?#%]/g, '').replace(/\s+/g, ' ').trim(); } }) && $('#f-tags').data('__tagsinput'); // Selects and the time picker (the dialogs are PluginDialog prompts, see prompts.js) $('#details [data-plugin-select]').each(function () { var $w = $(this); $w.themestrapPluginSelect(); var $label = $w.closest('.field').find('.lbl').first(), trigger = $w.find('.ts-select-trigger')[0]; if ($label.length && trigger && trigger.id) trigger.setAttribute('aria-labelledby', $label.attr('id') + ' ' + trigger.id); }); // Entry format: pictures for a slider or gallery, one address for video, audio and embeds var FORMAT_HINTS = { slider: ['Slider pictures', 'One picture address per line. They appear as a slider above the text.'], gallery: ['Gallery pictures', 'One picture address per line. They appear as a grid that opens in a lightbox.'], video: ['Video address', 'A YouTube or Vimeo link, or a link to an mp4 or webm file.'], audio: ['Audio address', 'A SoundCloud or Spotify link, or a link to an mp3 or ogg file.'], embed: ['Embed address', 'A CodePen link (or any YouTube, Vimeo, SoundCloud or Spotify link).'] }; function syncFormat() { var page = $('#f-type').val() === 'page', f = $('#f-format').val(), h = FORMAT_HINTS[f]; $('#format-field').prop('hidden', page); $('#media-field').prop('hidden', page || !h); if (h) { $('#media-label').text(h[0]); $('#media-hint').text(h[1]); $('#f-media').attr('rows', f === 'slider' || f === 'gallery' ? 5 : 2).attr('placeholder', f === 'slider' || f === 'gallery' ? 'https://…/one.jpg' : 'https://'); $('#media-upload').prop('hidden', f !== 'slider' && f !== 'gallery'); } } $('#f-type, #f-format').on('change', function () { syncFormat(); markDirty(); }); $('#f-media').on('input', markDirty); $('#media-upload').on('click', function () { $('#media-input').trigger('click'); }); $('#media-input').on('change', function () { var files = Array.prototype.slice.call(this.files); this.value = ''; // Upload one after another so the order matches what was picked files.reduce(function (chain, file) { return chain.then(function () { return uploadImage(file).then(function (url) { var $m = $('#f-media'), v = $m.val().replace(/\s+$/, ''); $m.val((v ? v + '\n' : '') + url + '\n'); markDirty(); }); }); }, Promise.resolve()).catch(function () {}); }); syncFormat(); // Auto growing title function fitTitle() { var t = $title[0]; t.style.height = 'auto'; t.style.height = t.scrollHeight + 'px'; } $title.on('input', function () { this.value = this.value.replace(/\n/g, ' '); fitTitle(); markDirty(); }).on('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); (state.mode === 'markdown' ? md.focus() : state.rich.focus()); } }); fitTitle(); // Mode switching: Markdown is the source of truth, rich text converts to and from it function currentMarkdown() { return state.mode === 'rich' ? state.rich.getMarkdown() : md.getValue(); } function ensureRich() { if (state.rich) return state.rich; $rich.themestrapPluginRichEditor({ toolbar: 'blog', markdownShortcuts: true, statusBar: true, minHeight: 'calc(100vh - 300px)', placeholder: 'Write your story. Select text for formatting, or type Markdown.', imageUpload: uploadImage, pastePlainText: true, linkTarget: '' }); state.rich = $rich.data('__pluginRichEditor'); $rich[0].addEventListener('ts.richeditor.change', markDirty); $rich[0].addEventListener('ts.richeditor.save', function () { save(); }); return state.rich; } function setMode(mode) { if (mode === state.mode) return; var src = currentMarkdown(); $('[data-mode]').removeClass('on').filter('[data-mode="' + mode + '"]').addClass('on'); if (mode === 'rich') { ensureRich(); $('.ts-mdeditor').hide(); $rich.prop('hidden', false); state.mode = 'rich'; renderServer(src).then(function (html) { state.rich.setContent(html); state.rich.focus(); }) .catch(function () { state.rich.setMarkdown(src); }); } else { md.setValue(src, true); $rich.prop('hidden', true); $('.ts-mdeditor').show(); state.mode = 'markdown'; md.focus(); } if (!risky) { try { localStorage.setItem('tsblog-mode', mode); } catch (e) {} } } $('[data-mode]').on('click', function () { setMode(this.getAttribute('data-mode')); }); // Dirty tracking and local safety copy var localTimer; function scheduleLocal() { clearTimeout(localTimer); localTimer = setTimeout(function () { try { localStorage.setItem(cfg.key, JSON.stringify({ t: Date.now(), title: $title.val(), body: currentMarkdown() })); } catch (e) {} }, 800); } $body[0].addEventListener('ts.markdowneditor.change', markDirty); $body[0].addEventListener('ts.markdowneditor.save', function () { save(); }); $('#details').on('input change', 'input, textarea, select', markDirty); tags && $('#f-tags')[0].addEventListener('ts.tagsinput.add', markDirty); tags && $('#f-tags')[0].addEventListener('ts.tagsinput.remove', markDirty); // After pasting a list, say what was left out tags && $('#f-tags')[0].addEventListener('ts.tagsinput.paste', function (e) { var d = e.detail, dup = 0, over = 0; d.skipped.forEach(function (k) { if (k.reason === 'max') over++; else dup++; }); if (!dup && !over) return; var parts = []; if (dup) parts.push(dup + (dup === 1 ? ' was already there' : ' were already there')); if (over) parts.push(over + (over === 1 ? ' was left out' : ' were left out') + ' because a post can have ' + MAX_TAGS + ' tags'); toast('Added ' + d.added.length + (d.added.length === 1 ? ' tag' : ' tags') + '. ' + parts.join(', ') + '.', !!over); }); // Offer to restore a newer local copy (for example after a lost connection) try { var saved = JSON.parse(localStorage.getItem(cfg.key) || 'null'); if (saved && (saved.body !== cfg.body || saved.title !== $title.val()) && saved.body && saved.t > Date.now() - 7 * 864e5) { var bar = $('<div class="restore" role="alert">A newer unsaved copy from this browser was found. <button type="button" class="link-btn" id="restore-yes">Restore it</button> <button type="button" class="link-btn" id="restore-no">Discard</button></div>').prependTo('.ed-main'); bar.on('click', '#restore-yes', function () { $title.val(saved.title); fitTitle(); md.setValue(saved.body, true); markDirty(); bar.remove(); }); bar.on('click', '#restore-no', function () { localStorage.removeItem(cfg.key); bar.remove(); }); } } catch (e) {} // Publish date: PluginCalendar for the day, a time field for the hour var cal = null; (function () { var parts = $('#f-date').val().split('T')[0].split('-'); var start = new Date(+parts[0], +parts[1] - 1, +parts[2]); $('#f-cal').themestrapPluginCalendar({ mode: 'single', selected: start, initialMonth: start, showFooter: true, fixedWeeks: true }); cal = $('#f-cal').data('__pluginCalendar'); function pad(n) { return ('0' + n).slice(-2); } function sync(fromUser) { var d = cal.getValue(), t = $('#f-time').val() || '09:00'; if (!d) return; var iso = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + t; $('#f-date').val(iso); var when = new Date(d.getFullYear(), d.getMonth(), d.getDate(), +t.split(':')[0], +t.split(':')[1]); $('#date-label').text(when.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })); var future = when.getTime() > Date.now(); $('#date-hint').text(future ? 'In the future, so a published post goes live on its own at this time.' : 'In the past or now, so a published post shows straight away.'); if (fromUser) markDirty(); } $('#f-time').themestrapPluginTimePicker({ minuteStep: 5, placeholder: 'Time', defaultTime: '09:00', block: true, icon: false }); var tpicker = $('#f-time').data('__pluginTimePicker'); sync(false); $('#f-cal')[0].addEventListener('ts.calendar.select', function () { sync(true); }); $('#f-time')[0].addEventListener('ts.timepicker.change', function () { sync(true); }); $('#date-now').on('click', function () { var n = new Date(); cal.setValue(n); cal.goToToday(); tpicker.setValue(pad(n.getHours()) + ':' + pad(n.getMinutes()), true); sync(true); }); function pop(open) { $('#date-pop').prop('hidden', !open); $('#date-toggle').attr('aria-expanded', open ? 'true' : 'false'); } $('#date-toggle').on('click', function () { pop($('#date-pop').prop('hidden')); }); $('#date-close').on('click', function () { pop(false); $('#date-toggle').trigger('focus'); }); $('#date-pop').on('keydown', function (e) { if (e.key === 'Escape') { e.stopPropagation(); pop(false); $('#date-toggle').trigger('focus'); } }); })(); // Settings panel, description counter, cover image $('#btn-details').on('click', function () { var open = $('#details').prop('hidden'); $('#details').prop('hidden', !open); $(this).attr('aria-expanded', open); document.body.classList.toggle('details-open', open); }); function descCount() { $('#desc-count').text($('#f-desc').val().length); } $('#f-desc').on('input', descCount); descCount(); function showCover() { var v = $('#f-image').val().trim(); $('#cover-preview').prop('hidden', !v).find('img').attr('src', v || ''); } $('#f-image').on('input', showCover); showCover(); $('#cover-clear').on('click', function () { $('#f-image').val(''); showCover(); markDirty(); }); $('#cover-upload').on('click', function () { $('#file-input').trigger('click'); }); $('#file-input').on('change', function () { var f = this.files[0]; this.value = ''; if (f) uploadImage(f).then(function (url) { $('#f-image').val(url); showCover(); markDirty(); }); }); // Slug follows the title until the author edits it var slugTouched = !!cfg.original; $('#f-slug').on('input', function () { slugTouched = true; }); $title.on('input', function () { if (slugTouched) return; $('#f-slug').val(this.value.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)); }); // Focus mode function focusMode(on) { document.body.classList.toggle('focus-mode', on); if (on) { $('#details').prop('hidden', true); document.body.classList.remove('details-open'); } } $('#btn-focus').on('click', function () { focusMode(!document.body.classList.contains('focus-mode')); }); $(document).on('keydown', function (e) { if (e.key === 'Escape' && document.body.classList.contains('focus-mode')) focusMode(false); }); // Save and publish function collect(status) { return { original: state.original, title: $title.val(), slug: $('#f-slug').val(), body: currentMarkdown(), date: $('#f-date').val().replace('T', ' '), type: $('#f-type').val(), status: status, tags: tags ? tags.getTags().join(',') : $('#f-tags').val(), description: $('#f-desc').val(), image: $('#f-image').val(), format: $('#f-format').val(), media: $('#f-media').val() }; } function save(status) { if (state.saving) return; status = status || state.status; if (!$title.val().trim()) { toast('Add a title first.', true); $title.focus(); return; } state.saving = true; $('#btn-save, #btn-publish').prop('disabled', true); setState('Saving…'); post(cfg.save, collect(status)).then(function (r) { if (!r.ok) { toast((r.errors || ['Could not save.']).join(' '), true); setState('Not saved'); return; } state.status = r.status; state.original = r.slug; state.dirty = false; $('#f-slug').val(r.slug); try { localStorage.removeItem(cfg.key); } catch (e) {} $('#btn-save').text(r.status === 'published' ? 'Update' : 'Save draft'); $('#btn-publish').text(r.status === 'published' ? 'Unpublish' : 'Publish'); $('#danger').prop('hidden', false); $('#view-link').attr('href', r.url); setState(r.status === 'published' ? 'Published' : 'Draft saved'); toast(r.message); cfg.key = 'tsblog-draft-' + r.slug; if (isNew) { isNew = false; history.replaceState(null, '', r.edit); } }).catch(function () { toast('Could not reach the server. Your text is kept in this browser.', true); setState('Not saved'); }) .then(function () { state.saving = false; $('#btn-save, #btn-publish').prop('disabled', false); }); } $('#btn-save').on('click', function () { save(); }); $('#btn-publish').on('click', function () { save(state.status === 'published' ? 'draft' : 'published'); }); $(document).on('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 's') { e.preventDefault(); save(); } }); window.addEventListener('beforeunload', function (e) { if (state.dirty) { e.preventDefault(); e.returnValue = ''; } }); // Delete, and leaving with unsaved changes, both ask through PluginDialog $('#btn-delete').on('click', function () { window.tsPrompt.confirm({ title: 'Delete this post?', message: 'This removes it from your site for good. It cannot be undone.', confirmLabel: 'Delete', cancelLabel: 'Keep it', danger: true }).then(function (yes) { if (!yes) return; post(cfg.delete, { slug: state.original }).then(function (r) { if (r.ok) { state.dirty = false; try { localStorage.removeItem(cfg.key); } catch (e) {} location.href = r.redirect; } else toast((r.errors || ['Could not delete.']).join(' '), true); }); }); }); // A link clicked while there are unsaved changes: ask first (the browser's own warning still covers closing the tab) $(document).on('click', 'a[href]', function (e) { var a = this, href = a.getAttribute('href') || ''; if (!state.dirty || e.isDefaultPrevented() || e.button || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; if (a.target === '_blank' || a.hasAttribute('download') || /^(#|javascript:|mailto:|tel:)/i.test(href)) return; e.preventDefault(); window.tsPrompt.confirm({ title: 'Leave without saving?', message: 'You have changes that have not been saved. They will be lost if you leave.', confirmLabel: 'Leave without saving', cancelLabel: 'Keep editing', danger: true }).then(function (yes) { if (yes) { state.dirty = false; location.href = a.href; } }); }); // Rich text is the friendly default. Posts that contain raw HTML stay in Markdown so nothing is lost. var risky = cfg.allowHtml && /<[a-z!][^>]*>/i.test(cfg.body); var pref = null; try { pref = localStorage.getItem('tsblog-mode'); } catch (e) {} if (risky) toast('This post contains HTML, so it opens in Markdown mode to keep it intact.'); else if (pref !== 'markdown') setMode('rich'); if (!$title.val()) $title.focus(); else if (state.mode === 'markdown') md.focus(); state.dirty = false; })(jQuery);