diff --git a/dwitter/static/js/drafts.js b/dwitter/static/js/drafts.js new file mode 100644 index 00000000..0698e3ff --- /dev/null +++ b/dwitter/static/js/drafts.js @@ -0,0 +1,305 @@ +(function() { + var STORAGE_AUTOSAVE = 'dwitter:draft:current'; + var STORAGE_DRAFTS = 'dwitter:drafts'; + var AUTOSAVE_DEBOUNCE_MS = 500; + + function readJson(key, fallback) { + try { + var raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : fallback; + } catch (e) { + return fallback; + } + } + + function writeJson(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (e) { + // localStorage full or disabled - ignore + } + } + + function removeKey(key) { + try { + localStorage.removeItem(key); + } catch (e) { + // ignore + } + } + + function debounce(fn, ms) { + var timer = null; + return function() { + var args = arguments; + var ctx = this; + if (timer) clearTimeout(timer); + timer = setTimeout(function() { + fn.apply(ctx, args); + }, ms); + }; + } + + function formatTimestamp(ms) { + return new Date(ms).toLocaleString(); + } + + function findForm(el) { + var node = el; + while (node && node.tagName !== 'FORM') { + node = node.parentNode; + } + return node; + } + + // Programmatic value updates don't fire keyup, so dispatch one to reuse + // scrolling.js's existing handler (updates iframe preview + textarea size, + // and toggles submit-button enabled state). + function dispatchKeyup(el) { + el.dispatchEvent(new Event('keyup', { bubbles: true })); + } + + function loadCodeIntoEditor(editor, code) { + editor.value = code; + editor.style.height = 'auto'; + editor.style.height = editor.scrollHeight + 'px'; + dispatchKeyup(editor); + } + + function makeId() { + return 'd' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + } + + function init() { + var editor = document.getElementById('editor'); + if (!editor) return; + + var statusEl = document.getElementById('draft-status'); + var listEl = document.getElementById('drafts-list'); + var saveButton = document.getElementById('draft-save-button'); + var restorePrompt = document.getElementById('draft-restore-prompt'); + var restoreTimeEl = restorePrompt && restorePrompt.querySelector('.draft-restore-time'); + var restoreButton = document.getElementById('draft-restore-button'); + var discardButton = document.getElementById('draft-discard-button'); + if (!statusEl || !listEl || !saveButton || !restorePrompt + || !restoreTimeEl || !restoreButton || !discardButton) { + return; + } + + var defaultCode = editor.value; + var form = findForm(editor); + + function setStatus(text) { + statusEl.textContent = text; + } + + function hideRestorePrompt() { + restorePrompt.style.display = 'none'; + } + + function showRestorePrompt(savedAt) { + restoreTimeEl.textContent = formatTimestamp(savedAt); + restorePrompt.style.display = ''; + } + + function renameDraft(id, newName) { + var trimmed = (newName || '').trim(); + if (!trimmed) return; + var current = readJson(STORAGE_DRAFTS, []); + var changed = false; + var next = current.map(function(d) { + if (d.id !== id || d.name === trimmed) return d; + changed = true; + return { id: d.id, name: trimmed, code: d.code, savedAt: d.savedAt }; + }); + if (!changed) return; + writeJson(STORAGE_DRAFTS, next); + renderDrafts(); + } + + function makeNameButton(draft) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'draft-name'; + btn.textContent = draft.name; + btn.title = 'Click to rename'; + btn.addEventListener('click', function() { + startRename(btn, draft); + }); + return btn; + } + + function startRename(nameEl, draft) { + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'draft-name-input'; + input.value = draft.name; + input.setAttribute('aria-label', 'Rename draft'); + + var settled = false; + function settle(applyRename) { + if (settled) return; + settled = true; + var trimmed = (input.value || '').trim(); + if (applyRename && trimmed && trimmed !== draft.name) { + // Triggers a full re-render with the new name. + renameDraft(draft.id, trimmed); + return; + } + // No change — restore the name button in-place rather than + // re-rendering, so a click on another draft's name still lands. + if (input.parentNode) { + input.replaceWith(makeNameButton(draft)); + } + } + + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + settle(true); + } else if (e.key === 'Escape') { + e.preventDefault(); + settle(false); + } + }); + input.addEventListener('blur', function() { settle(true); }); + + nameEl.replaceWith(input); + input.focus(); + input.select(); + } + + function renderDrafts() { + var drafts = readJson(STORAGE_DRAFTS, []); + listEl.innerHTML = ''; + + if (!drafts.length) { + var empty = document.createElement('li'); + empty.className = 'drafts-empty'; + empty.textContent = 'No saved drafts yet.'; + listEl.appendChild(empty); + return; + } + + drafts.forEach(function(draft) { + var li = document.createElement('li'); + li.className = 'draft-item'; + + var nameEl = makeNameButton(draft); + + var ts = document.createElement('span'); + ts.className = 'draft-timestamp'; + ts.textContent = formatTimestamp(draft.savedAt); + + var loadBtn = document.createElement('button'); + loadBtn.type = 'button'; + loadBtn.className = 'draft-load'; + loadBtn.textContent = 'Load'; + loadBtn.title = 'Load this draft into the editor'; + loadBtn.addEventListener('click', function() { + loadCodeIntoEditor(editor, draft.code); + setStatus('Loaded "' + draft.name + '"'); + }); + + var deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'draft-delete'; + deleteBtn.textContent = 'x'; + deleteBtn.title = 'Delete draft'; + deleteBtn.addEventListener('click', function() { + if (!window.confirm('Delete draft "' + draft.name + '"?')) return; + var current = readJson(STORAGE_DRAFTS, []); + var next = current.filter(function(d) { return d.id !== draft.id; }); + writeJson(STORAGE_DRAFTS, next); + renderDrafts(); + }); + + li.appendChild(nameEl); + li.appendChild(ts); + li.appendChild(loadBtn); + li.appendChild(deleteBtn); + listEl.appendChild(li); + }); + } + + function saveAsDraft() { + var code = editor.value; + if (!code.trim()) { + setStatus('Nothing to save.'); + return; + } + + var savedAt = Date.now(); + var draft = { + id: makeId(), + name: 'Untitled draft ' + formatTimestamp(savedAt), + code: code, + savedAt: savedAt, + }; + var drafts = readJson(STORAGE_DRAFTS, []); + drafts.unshift(draft); + writeJson(STORAGE_DRAFTS, drafts); + setStatus('Saved as draft (click name to rename)'); + renderDrafts(); + + // Open inline rename on the just-added item so the user can name it + // without breaking flow. + var firstItem = listEl.querySelector('.draft-item .draft-name'); + if (firstItem) { + startRename(firstItem, draft); + } + } + + var autoSave = debounce(function() { + var code = editor.value; + if (code === defaultCode) { + removeKey(STORAGE_AUTOSAVE); + setStatus(''); + return; + } + writeJson(STORAGE_AUTOSAVE, { code: code, savedAt: Date.now() }); + setStatus('Autosaved ' + new Date().toLocaleTimeString()); + }, AUTOSAVE_DEBOUNCE_MS); + + editor.addEventListener('input', function() { + // Once the user starts editing, the previous autosave is no longer + // "the thing they left behind" — it'll be overwritten as they type. + hideRestorePrompt(); + autoSave(); + }); + saveButton.addEventListener('click', saveAsDraft); + + restoreButton.addEventListener('click', function() { + var stored = readJson(STORAGE_AUTOSAVE, null); + if (!stored || !stored.code) return; + loadCodeIntoEditor(editor, stored.code); + hideRestorePrompt(); + setStatus('Restored autosaved draft'); + }); + + discardButton.addEventListener('click', function() { + removeKey(STORAGE_AUTOSAVE); + hideRestorePrompt(); + setStatus('Autosaved draft discarded'); + }); + + if (form) { + form.addEventListener('submit', function() { + removeKey(STORAGE_AUTOSAVE); + }); + } + + var stored = readJson(STORAGE_AUTOSAVE, null); + if (stored && stored.code && stored.code !== defaultCode) { + showRestorePrompt(stored.savedAt || Date.now()); + } + + renderDrafts(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/dwitter/static/main.css b/dwitter/static/main.css index d57e5955..49bc9db2 100644 --- a/dwitter/static/main.css +++ b/dwitter/static/main.css @@ -1016,3 +1016,160 @@ form.accounts input[type=submit] { .report-button:hover .fa-flag { opacity: 1; } + +.drafts-panel { + margin-top: 8px; + margin-bottom: 6px; + padding-top: 8px; + padding-bottom: 6px; + border-top: 1px solid #444; + border-bottom: 1px solid #444; + text-align: left; +} + +.draft-restore-prompt { + background: rgba(69, 153, 202, 0.15); + border: 1px solid #4599ca; + border-radius: 3px; + padding: 6px 10px; + margin-bottom: 8px; + color: #ddd; + font-family: 'Roboto', sans-serif; + font-size: 13px; +} + +.draft-link-button { + background: transparent; + border: 0; + color: #4599ca; + font-family: 'Roboto', sans-serif; + font-size: 13px; + cursor: pointer; + padding: 0 4px; + text-decoration: underline; +} + +.draft-link-button:hover { + filter: brightness(1.2); +} + +.drafts-controls { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.draft-save-button { + background: #4599ca; + color: white; + border: 0; + padding: 4px 10px; + font-family: 'Roboto', sans-serif; + font-size: 13px; + cursor: pointer; + border-radius: 3px; +} + +.draft-save-button:hover { + filter: brightness(1.1); +} + +.draft-status { + color: #aaa; + font-size: 12px; + font-family: 'Roboto', sans-serif; +} + +.drafts-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 160px; + overflow-y: auto; +} + +.drafts-empty { + color: #888; + font-size: 12px; + font-family: 'Roboto', sans-serif; + padding: 4px 0; +} + +.draft-item { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 0; +} + +.draft-name { + flex: 1; + text-align: left; + background: transparent; + color: white; + border: 1px solid transparent; + padding: 3px 8px; + font-family: 'Roboto', sans-serif; + font-size: 13px; + cursor: text; + border-radius: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.draft-name:hover { + border-color: #555; + background: rgba(255, 255, 255, 0.05); +} + +.draft-name-input { + flex: 1; + background: white; + color: black; + border: 1px solid #4599ca; + padding: 3px 8px; + font-family: 'Roboto', sans-serif; + font-size: 13px; + border-radius: 3px; + outline: none; + min-width: 0; +} + +.draft-load { + background: transparent; + color: #4599ca; + border: 1px solid #4599ca; + padding: 3px 10px; + font-family: 'Roboto', sans-serif; + font-size: 12px; + cursor: pointer; + border-radius: 3px; +} + +.draft-load:hover { + background: #4599ca; + color: white; +} + +.draft-timestamp { + color: #888; + font-size: 11px; + font-family: 'Roboto', sans-serif; + white-space: nowrap; +} + +.draft-delete { + background: transparent; + color: #888; + border: 0; + font-size: 16px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.draft-delete:hover { + color: red; +} diff --git a/dwitter/templates/feed.html b/dwitter/templates/feed.html index fbf08415..5bb3ffe6 100644 --- a/dwitter/templates/feed.html +++ b/dwitter/templates/feed.html @@ -79,6 +79,7 @@ + {% endcompress %} diff --git a/dwitter/templates/snippets/new_dweet_card.html b/dwitter/templates/snippets/new_dweet_card.html index a906c27b..374cdb6e 100644 --- a/dwitter/templates/snippets/new_dweet_card.html +++ b/dwitter/templates/snippets/new_dweet_card.html @@ -34,6 +34,18 @@
+