| 1 |
<div class="text-editor" id="text-editor"> |
| 2 |
<div class="editor-tabs"> |
| 3 |
<button type="button" class="editor-tab is-selected" data-tab="write" onclick="switchEditorTab('write')">Write</button> |
| 4 |
<button type="button" class="editor-tab" data-tab="preview" onclick="switchEditorTab('preview')">Preview</button> |
| 5 |
</div> |
| 6 |
|
| 7 |
<div class="editor-panel active" id="write-panel"> |
| 8 |
<textarea id="text-body" name="body" placeholder="Write your content in Markdown...">{{ body.as_deref().unwrap_or("") }}</textarea> |
| 9 |
<button type="button" class="btn-secondary text-editor-insert-btn" onclick="mediaPickerOpen('text-body')">Insert Image</button> |
| 10 |
<div class="editor-footer"> |
| 11 |
<span class="word-count" id="word-count">{{ word_count.unwrap_or(0) }} words</span> |
| 12 |
<span class="reading-time">{{ reading_time_minutes.unwrap_or(1) }} min read</span> |
| 13 |
</div> |
| 14 |
</div> |
| 15 |
|
| 16 |
<div class="editor-panel" id="preview-panel"> |
| 17 |
<div class="markdown-preview" id="markdown-preview"> |
| 18 |
<p class="text-editor-placeholder">Preview will appear here...</p> |
| 19 |
</div> |
| 20 |
</div> |
| 21 |
|
| 22 |
<div class="text-editor-actions"> |
| 23 |
<button type="button" class="btn-primary" id="save-text-btn" onclick="saveTextContent()"> |
| 24 |
Save Content |
| 25 |
<span id="text-spinner" class="htmx-indicator"> ...</span> |
| 26 |
</button> |
| 27 |
<span id="text-save-status"></span> |
| 28 |
</div> |
| 29 |
</div> |
| 30 |
|
| 31 |
<script> |
| 32 |
function switchEditorTab(tab) { |
| 33 |
document.querySelectorAll('.editor-tab').forEach(t => t.classList.remove('is-selected')); |
| 34 |
document.querySelector('.editor-tab[data-tab="' + tab + '"]').classList.add('is-selected'); |
| 35 |
|
| 36 |
document.querySelectorAll('.editor-panel').forEach(p => p.classList.remove('active')); |
| 37 |
document.getElementById(tab + '-panel').classList.add('active'); |
| 38 |
|
| 39 |
if (tab === 'preview') { |
| 40 |
renderMarkdownPreview(); |
| 41 |
} |
| 42 |
} |
| 43 |
|
| 44 |
function renderMarkdownPreview() { |
| 45 |
const body = document.getElementById('text-body').value; |
| 46 |
const preview = document.getElementById('markdown-preview'); |
| 47 |
|
| 48 |
|
| 49 |
let html = body |
| 50 |
.split('\n\n').map(p => p.trim()).filter(p => p).map(p => { |
| 51 |
|
| 52 |
if (p.startsWith('### ')) return '<h3>' + escapeHtml(p.slice(4)) + '</h3>'; |
| 53 |
if (p.startsWith('## ')) return '<h2>' + escapeHtml(p.slice(3)) + '</h2>'; |
| 54 |
if (p.startsWith('# ')) return '<h1>' + escapeHtml(p.slice(2)) + '</h1>'; |
| 55 |
|
| 56 |
let text = escapeHtml(p); |
| 57 |
text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); |
| 58 |
text = text.replace(/\*(.+?)\*/g, '<em>$1</em>'); |
| 59 |
text = text.replace(/`(.+?)`/g, '<code>$1</code>'); |
| 60 |
return '<p>' + text + '</p>'; |
| 61 |
}).join('\n'); |
| 62 |
|
| 63 |
preview.innerHTML = html || '<p class="text-editor-placeholder">Nothing to preview...</p>'; |
| 64 |
} |
| 65 |
|
| 66 |
function escapeHtml(text) { |
| 67 |
const div = document.createElement('div'); |
| 68 |
div.textContent = text; |
| 69 |
return div.innerHTML; |
| 70 |
} |
| 71 |
|
| 72 |
function updateWordCount() { |
| 73 |
const body = document.getElementById('text-body').value; |
| 74 |
const words = body.trim().split(/\s+/).filter(w => w).length; |
| 75 |
const readingTime = Math.max(1, Math.ceil(words / 200)); |
| 76 |
document.getElementById('word-count').textContent = words + ' words'; |
| 77 |
document.querySelector('.reading-time').textContent = readingTime + ' min read'; |
| 78 |
} |
| 79 |
|
| 80 |
function saveTextContent() { |
| 81 |
const body = document.getElementById('text-body').value; |
| 82 |
const btn = document.getElementById('save-text-btn'); |
| 83 |
const status = document.getElementById('text-save-status'); |
| 84 |
const itemId = '{{ item.id }}'; |
| 85 |
|
| 86 |
btn.disabled = true; |
| 87 |
status.innerHTML = ''; |
| 88 |
|
| 89 |
fetch('/api/items/' + itemId + '/text', { |
| 90 |
method: 'PUT', |
| 91 |
headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, |
| 92 |
body: JSON.stringify({ body: body }) |
| 93 |
}) |
| 94 |
.then(res => res.json()) |
| 95 |
.then(data => { |
| 96 |
status.innerHTML = '<span class="save-status success">Saved</span>'; |
| 97 |
if (data.word_count !== undefined) { |
| 98 |
document.getElementById('word-count').textContent = data.word_count + ' words'; |
| 99 |
} |
| 100 |
}) |
| 101 |
.catch(err => { |
| 102 |
status.innerHTML = '<span class="save-status error">Error saving</span>'; |
| 103 |
}) |
| 104 |
.finally(() => { |
| 105 |
btn.disabled = false; |
| 106 |
}); |
| 107 |
} |
| 108 |
|
| 109 |
document.getElementById('text-body').addEventListener('input', updateWordCount); |
| 110 |
|
| 111 |
|
| 112 |
var autoSaveTimer = null; |
| 113 |
var autoSaveStatus = document.getElementById('text-save-status'); |
| 114 |
|
| 115 |
document.getElementById('text-body').addEventListener('input', function() { |
| 116 |
clearTimeout(autoSaveTimer); |
| 117 |
autoSaveTimer = setTimeout(function() { |
| 118 |
autoSaveStatus.innerHTML = '<span class="save-status saving">Saving...</span>'; |
| 119 |
var body = document.getElementById('text-body').value; |
| 120 |
fetch('/api/items/{{ item.id }}/text', { |
| 121 |
method: 'PUT', |
| 122 |
headers: { 'Content-Type': 'application/json', ...csrfHeaders() }, |
| 123 |
body: JSON.stringify({ body: body }) |
| 124 |
}) |
| 125 |
.then(function(res) { return res.json(); }) |
| 126 |
.then(function(data) { |
| 127 |
autoSaveStatus.innerHTML = '<span class="save-status success">Auto-saved</span>'; |
| 128 |
if (data.word_count !== undefined) { |
| 129 |
document.getElementById('word-count').textContent = data.word_count + ' words'; |
| 130 |
} |
| 131 |
setTimeout(function() { |
| 132 |
if (autoSaveStatus.textContent === 'Auto-saved') autoSaveStatus.innerHTML = ''; |
| 133 |
}, 3000); |
| 134 |
}) |
| 135 |
.catch(function() { |
| 136 |
autoSaveStatus.innerHTML = '<span class="save-status error">Auto-save failed</span>'; |
| 137 |
}); |
| 138 |
}, 30000); |
| 139 |
}); |
| 140 |
</script> |
| 141 |
|