diff --git a/assets/css/style.css b/assets/css/style.css index 91e4239..c337b0e 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -485,20 +485,24 @@ button.secondary:hover { border-color: var(--secondary-color); } -button.danger { +button.danger, +.btn.danger { background: var(--accent-color); } -button.danger:hover { +button.danger:hover, +.btn.danger:hover { background: #c0392b; } -button.success { +button.success, +.btn.success { background: var(--success-color); } -button.success:hover { +button.success:hover, +.btn.success:hover { background: #229954; } @@ -1176,6 +1180,36 @@ select:focus-visible { box-shadow: var(--shadow-hover); } +.journal-item.active { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(41, 128, 185, 0.15); +} + +.journal-item-actions { + display: flex; + align-items: stretch; + gap: 0.5rem; +} + +.journal-item-actions .journal-item { + flex: 1; +} + +.journal-item-actions .journal-delete { + flex-shrink: 0; + padding: 0.6rem 0.9rem; + text-transform: none; +} + +.journal-list.empty { + display: flex; + flex-direction: column; + gap: 1.5rem; + align-items: center; + text-align: center; + color: var(--text-light); +} + .journal-preview { display: flex; flex-direction: column; @@ -1317,4 +1351,4 @@ select:focus-visible { .mt-1 { margin-top: 0.5rem; } .mt-2 { margin-top: 1rem; } -.mt-3 { margin-top: 1.5rem; } \ No newline at end of file +.mt-3 { margin-top: 1.5rem; } diff --git a/assets/js/app.js b/assets/js/app.js index b8c31df..400f834 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -8,6 +8,9 @@ class ConceptionAssistant { this.tocTimer = null; this.isPreviewMode = false; this.originalContent = ''; + this.localBackupKey = 'design-journal-autosave'; + this.localBackupTimer = null; + this.localStorageSupported = this.detectLocalStorageSupport(); this.init(); } @@ -15,6 +18,7 @@ class ConceptionAssistant { this.setupEditor(); this.setupEventListeners(); await this.loadJournalList(); + this.checkForLocalBackup(); this.generateTOC(); this.updateStatistics(); } @@ -26,6 +30,7 @@ class ConceptionAssistant { this.editor.addEventListener('input', () => { this.debounceTOC(); this.updateStatistics(); + this.scheduleLocalBackup(); }); // Keyboard shortcut handling @@ -171,6 +176,134 @@ class ConceptionAssistant { this.redoStack = []; } + detectLocalStorageSupport() { + if (typeof window === 'undefined') { + return false; + } + + try { + const testKey = '__design_journal_test__'; + window.localStorage.setItem(testKey, '1'); + window.localStorage.removeItem(testKey); + return true; + } catch (error) { + console.warn('Local storage unavailable, disabling local backups.', error); + return false; + } + } + + scheduleLocalBackup() { + if (!this.localStorageSupported) return; + + if (this.localBackupTimer) { + clearTimeout(this.localBackupTimer); + } + + this.localBackupTimer = setTimeout(() => { + this.saveLocalBackup(); + }, 1500); + } + + saveLocalBackup(contentOverride = null) { + if (!this.localStorageSupported || typeof window === 'undefined') return; + + try { + const content = typeof contentOverride === 'string' + ? contentOverride + : (this.editor ? this.editor.innerText : ''); + + const normalizedContent = content || ''; + + if (!normalizedContent.trim()) { + window.localStorage.removeItem(this.localBackupKey); + return; + } + + const backupPayload = { + content: normalizedContent, + timestamp: new Date().toISOString(), + journalId: this.currentJournalId + }; + + window.localStorage.setItem(this.localBackupKey, JSON.stringify(backupPayload)); + } catch (error) { + console.warn('Unable to persist local backup.', error); + } + } + + discardLocalBackup() { + if (!this.localStorageSupported || typeof window === 'undefined') return; + try { + window.localStorage.removeItem(this.localBackupKey); + } catch (error) { + console.warn('Unable to discard local backup.', error); + } + } + + checkForLocalBackup() { + if (!this.localStorageSupported || typeof window === 'undefined') return; + + let rawBackup; + try { + rawBackup = window.localStorage.getItem(this.localBackupKey); + } catch (error) { + console.warn('Unable to access local backup.', error); + return; + } + + if (!rawBackup) return; + + let backup; + try { + backup = JSON.parse(rawBackup); + } catch (error) { + console.warn('Invalid local backup payload, clearing it.', error); + this.discardLocalBackup(); + return; + } + + if (!backup || typeof backup.content !== 'string' || !backup.content.trim()) { + this.discardLocalBackup(); + return; + } + + const currentContent = this.editor ? this.editor.innerText : ''; + if (currentContent.trim() === backup.content.trim()) { + return; + } + + const savedAt = backup.timestamp ? new Date(backup.timestamp) : null; + const formattedDate = savedAt && !Number.isNaN(savedAt.getTime()) + ? savedAt.toLocaleString() + : 'a previous session'; + + const shouldRestore = window.confirm( + `A local draft saved on ${formattedDate} was found. Do you want to restore it?` + ); + + if (!shouldRestore) { + this.discardLocalBackup(); + return; + } + + this.editor.innerText = backup.content; + this.currentJournalId = backup.journalId ?? this.currentJournalId; + this.undoStack = [backup.content]; + this.redoStack = []; + this.generateTOC(); + this.updateStatistics(); + this.saveState(true); + this.saveLocalBackup(backup.content); + + const statusEl = document.getElementById('save-status'); + if (statusEl) { + statusEl.textContent = 'Local draft restored'; + setTimeout(() => statusEl.textContent = '', 4000); + } + + this.showNotification('Local draft restored', 'success'); + } + undo() { if (this.undoStack.length > 1) { const currentContent = this.undoStack.pop(); @@ -183,6 +316,7 @@ class ConceptionAssistant { this.generateTOC(); this.showNotification('Undo completed', 'success'); + this.saveLocalBackup(previousContent); } else { this.showNotification('Nothing to undo', 'warning'); } @@ -199,6 +333,7 @@ class ConceptionAssistant { this.generateTOC(); this.showNotification('Redo completed', 'success'); + this.saveLocalBackup(nextContent); } else { this.showNotification('Nothing to redo', 'warning'); } @@ -241,6 +376,8 @@ class ConceptionAssistant { this.currentJournalId = result.data.id; } + this.saveLocalBackup(content); + statusEl.textContent = 'Saved'; this.showNotification('Journal saved successfully', 'success'); setTimeout(() => statusEl.textContent = '', 3000); @@ -282,6 +419,8 @@ class ConceptionAssistant { this.currentJournalId = id; this.editor.innerText = journal.markdownContent; this.generateTOC(); + this.updateStatistics(); + this.saveLocalBackup(journal.markdownContent); // Reset history for the new journal this.undoStack = [journal.markdownContent]; @@ -304,46 +443,12 @@ class ConceptionAssistant { const result = await response.json(); if (result.success) { - const journalList = result.data.map(journal => { - // Extract first lines for preview - const lines = journal.markdownContent.split('\n'); - const firstLines = lines.slice(0, 3).join('\n'); - const preview = firstLines.length > 150 ? firstLines.substring(0, 150) + '...' : firstLines; - - return ` -
- -
- `; - }).join(''); - - const modalBody = document.getElementById('journal-modal-body'); - modalBody.innerHTML = ` -
- - ${journalList} -
- `; + this.renderJournalSelector(result.data); // Show modal with animation const modal = document.getElementById('journal-modal'); modal.style.display = 'flex'; setTimeout(() => modal.classList.add('show'), 10); - - // Add event listeners - document.querySelectorAll('.journal-item').forEach(btn => { - btn.addEventListener('click', () => { - this.loadJournal(btn.dataset.id); - this.closeModal(); - }); - }); } } catch (error) { console.error('Error:', error); @@ -359,11 +464,133 @@ class ConceptionAssistant { }, 300); } + renderJournalSelector(journals = []) { + const modalBody = document.getElementById('journal-modal-body'); + if (!modalBody) return; + + if (!Array.isArray(journals) || journals.length === 0) { + modalBody.innerHTML = ` +
+

No journals saved yet.

+ +
+ `; + return; + } + + const journalList = journals.map(journal => { + const lines = journal.markdownContent.split('\n'); + const firstLines = lines.slice(0, 3).join('\n'); + const preview = firstLines.length > 150 ? firstLines.substring(0, 150) + '...' : firstLines; + + const isCurrent = String(this.currentJournalId) === String(journal.id); + + return ` +
+
+ + +
+
+ `; + }).join(''); + + modalBody.innerHTML = ` +
+ + ${journalList} +
+ `; + + // Add load listeners + modalBody.querySelectorAll('.journal-item').forEach(btn => { + btn.addEventListener('click', () => { + this.loadJournal(btn.dataset.id); + this.closeModal(); + }); + }); + + // Add delete listeners + modalBody.querySelectorAll('.journal-delete').forEach(btn => { + btn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + this.deleteJournal(btn.dataset.id, btn); + }); + }); + } + + async deleteJournal(id, buttonEl) { + if (!id) return; + + const confirmation = window.confirm(`Are you sure you want to delete journal ${id}? This action cannot be undone.`); + if (!confirmation) return; + + if (buttonEl) { + buttonEl.disabled = true; + buttonEl.textContent = 'Deleting...'; + } + + try { + const response = await fetch(`/api/journals/${id}`, { + method: 'DELETE' + }); + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Deletion failed'); + } + + this.showNotification(`Journal ${id} deleted`, 'success'); + + // Refresh list of journals + const refreshResponse = await fetch('/api/journals'); + const refreshResult = await refreshResponse.json(); + + if (refreshResult.success) { + const remaining = refreshResult.data; + + // If the current journal was deleted, load another available journal or clear editor + if (String(this.currentJournalId) === String(id)) { + if (remaining.length > 0) { + const lastJournal = remaining[remaining.length - 1]; + await this.loadJournal(lastJournal.id); + } else { + this.createNewJournal(); + } + } + + this.renderJournalSelector(remaining); + } + } catch (error) { + console.error('Error deleting journal:', error); + this.showNotification(`Error deleting journal: ${error.message}`, 'error'); + } finally { + if (buttonEl) { + buttonEl.disabled = false; + buttonEl.textContent = 'Delete'; + } + } + } + createNewJournal() { this.currentJournalId = null; this.editor.innerText = ''; this.generateTOC(); + this.updateStatistics(); this.clearFeedback(); + this.saveLocalBackup(''); // Reset history for new journal this.undoStack = ['']; @@ -391,6 +618,7 @@ class ConceptionAssistant { // Update statistics this.updateStatistics(); + this.saveLocalBackup(''); // Show notification this.showNotification('New blank document created', 'success'); @@ -750,6 +978,8 @@ class ConceptionAssistant { const importedContent = e.target.result; this.editor.innerText = importedContent; this.generateTOC(); + this.updateStatistics(); + this.saveLocalBackup(importedContent); this.currentJournalId = null; // New journal // Reset history for imported file @@ -1162,6 +1392,8 @@ class ConceptionAssistant { if (data.markdown && data.markdown !== this.editor.innerText) { this.editor.innerText = data.markdown; this.generateTOC(); + this.updateStatistics(); + this.saveLocalBackup(data.markdown); } // Scroll to bottom of feedback to see new iteration @@ -1179,6 +1411,8 @@ class ConceptionAssistant { // Ensure final content is in editor this.editor.innerText = data.finalMarkdown; this.generateTOC(); + this.updateStatistics(); + this.saveLocalBackup(data.finalMarkdown); // Save final state this.saveState(true); } @@ -1649,6 +1883,8 @@ function initializeTemplateForm() { // Load template into editor app.editor.innerText = result.data.content; app.generateTOC(); + app.updateStatistics(); + app.saveLocalBackup(result.data.content); app.currentJournalId = null; // New journal // Reset history for new template @@ -1674,4 +1910,4 @@ function initializeTemplateForm() { } // Ensure togglePanel is globally accessible -window.togglePanel = togglePanel; \ No newline at end of file +window.togglePanel = togglePanel; diff --git a/routes/api.js b/routes/api.js index 521b9fb..b66baa3 100644 --- a/routes/api.js +++ b/routes/api.js @@ -53,23 +53,30 @@ function createMd(markdownContent = "# Title\nContent...") { const mdPath = path.join(dataDir, `${uuid}.md`); const mapPath = path.join(dataDir, 'uuid_map.json'); + // Guarantee storage directory exists (defensive in case server init didn't run) + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + logger.info('STORAGE', 'Created data directory on demand', { path: dataDir }); + } + fs.writeFileSync(mdPath, markdownContent, { encoding: 'utf8', flag: 'w' }); let map = {}; if (fs.existsSync(mapPath)) { map = JSON.parse(fs.readFileSync(mapPath, 'utf8')); } - let id = Object.keys(map).at(-1); - if (id == undefined){ - map[0] = uuid; - } else { - id = +id+1 - map[id] = uuid; - } + + const existingIds = Object.keys(map) + .map((key) => Number.parseInt(key, 10)) + .filter((value) => Number.isInteger(value) && value >= 0); + + const nextIdNumber = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 0; + const id = String(nextIdNumber); + map[id] = uuid; fs.writeFileSync(mapPath, JSON.stringify(map, null, 2), 'utf8'); - return { id, uuid, path: mdPath, markdownContent}; + return { id, uuid, path: mdPath, markdownContent }; } function readMd(id = undefined) { @@ -259,4 +266,4 @@ router.delete('/journals/:id', (req, res) => { // Integrate export routes router.use('/export', exportRouter); -module.exports = router; \ No newline at end of file +module.exports = router;