Compare commits

...

2 Commits

Author SHA1 Message Date
daef2bbf1a del file useless 2025-10-14 17:07:19 +02:00
21f165afb0 enchace app 2025-10-14 17:05:19 +02:00
9 changed files with 569 additions and 5 deletions

3
.gitignore vendored
View File

@ -112,3 +112,6 @@ Thumbs.db
# Claude Code # Claude Code
.claude .claude
# Data
data/*

View File

@ -114,7 +114,8 @@ body.dark-theme .markdown-preview {
.markdown-preview ol { .markdown-preview ol {
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
padding-left: 2em; padding-left: 32px;
margin-left: 0;
} }
.markdown-preview li { .markdown-preview li {

View File

@ -534,12 +534,25 @@ main {
min-height: calc(100vh - 140px); min-height: calc(100vh - 140px);
} }
/* Preview mode - TOC is hidden, journal takes more space */
main.preview-mode {
grid-template-columns: 1fr 400px;
}
main.preview-mode #table-of-contents {
display: none;
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
main { main {
grid-template-columns: 250px 1fr; grid-template-columns: 250px 1fr;
gap: 1.5rem; gap: 1.5rem;
} }
main.preview-mode {
grid-template-columns: 1fr;
}
#ai-assistant { #ai-assistant {
grid-column: 1 / -1; grid-column: 1 / -1;
margin-top: 2rem; margin-top: 2rem;
@ -552,6 +565,10 @@ main {
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
} }
main.preview-mode {
grid-template-columns: 1fr;
}
} }
/* Sections */ /* Sections */

View File

@ -16,6 +16,7 @@ class ConceptionAssistant {
this.setupEventListeners(); this.setupEventListeners();
await this.loadJournalList(); await this.loadJournalList();
this.generateTOC(); this.generateTOC();
this.updateStatistics();
} }
setupEditor() { setupEditor() {
@ -24,6 +25,7 @@ class ConceptionAssistant {
// Generate TOC with debounce to avoid interrupting typing // Generate TOC with debounce to avoid interrupting typing
this.editor.addEventListener('input', () => { this.editor.addEventListener('input', () => {
this.debounceTOC(); this.debounceTOC();
this.updateStatistics();
}); });
// Keyboard shortcut handling // Keyboard shortcut handling
@ -69,12 +71,14 @@ class ConceptionAssistant {
document.getElementById('save-journal')?.addEventListener('click', () => this.saveJournal()); document.getElementById('save-journal')?.addEventListener('click', () => this.saveJournal());
document.getElementById('load-journal')?.addEventListener('click', () => this.showJournalSelector()); document.getElementById('load-journal')?.addEventListener('click', () => this.showJournalSelector());
document.getElementById('preview-toggle')?.addEventListener('click', async () => await this.togglePreview()); document.getElementById('preview-toggle')?.addEventListener('click', async () => await this.togglePreview());
document.getElementById('new-blank-document')?.addEventListener('click', () => this.createBlankDocument());
// Table of contents // Table of contents
document.getElementById('refresh-toc')?.addEventListener('click', () => this.generateTOC()); document.getElementById('refresh-toc')?.addEventListener('click', () => this.generateTOC());
// Export/Import // Export/Import
document.getElementById('export-md')?.addEventListener('click', () => this.exportMarkdown()); document.getElementById('export-md')?.addEventListener('click', () => this.exportMarkdown());
document.getElementById('export-web')?.addEventListener('click', () => this.exportWeb());
document.getElementById('import-md')?.addEventListener('change', (e) => this.importMarkdown(e)); document.getElementById('import-md')?.addEventListener('change', (e) => this.importMarkdown(e));
// Theme // Theme
@ -87,6 +91,12 @@ class ConceptionAssistant {
document.getElementById('give-advice')?.addEventListener('click', () => this.handleAI('advice')); document.getElementById('give-advice')?.addEventListener('click', () => this.handleAI('advice'));
document.getElementById('liberty-mode')?.addEventListener('click', () => this.handleAI('liberty')); document.getElementById('liberty-mode')?.addEventListener('click', () => this.handleAI('liberty'));
// Present mode
document.getElementById('present-mode')?.addEventListener('click', () => this.openPresentMode());
// Shortcuts help
document.getElementById('show-shortcuts')?.addEventListener('click', () => this.showShortcutsModal());
// Loading modal // Loading modal
document.getElementById('close-journal-modal')?.addEventListener('click', () => this.closeModal()); document.getElementById('close-journal-modal')?.addEventListener('click', () => this.closeModal());
@ -365,6 +375,33 @@ class ConceptionAssistant {
this.showNotification('New journal created', 'success'); this.showNotification('New journal created', 'success');
} }
createBlankDocument() {
// Clear the editor completely
this.editor.innerText = '';
// Reset currentJournalId to null
this.currentJournalId = null;
// Reset undo/redo stacks
this.undoStack = [''];
this.redoStack = [];
// Generate TOC
this.generateTOC();
// Update statistics
this.updateStatistics();
// Show notification
this.showNotification('New blank document created', 'success');
// Close all panels
closeAllPanels();
// Ensure editor is in edit mode
this.ensureEditMode();
}
debounceTOC() { debounceTOC() {
if (this.tocTimer) { if (this.tocTimer) {
clearTimeout(this.tocTimer); clearTimeout(this.tocTimer);
@ -617,6 +654,43 @@ class ConceptionAssistant {
this.showNotification('Markdown file exported', 'success'); this.showNotification('Markdown file exported', 'success');
} }
async exportWeb() {
const content = this.editor.innerText;
if (!content.trim()) {
this.showNotification('No content to export', 'warning');
return;
}
try {
this.showNotification('Generating web export...', 'success');
const response = await fetch('/api/export/web', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, title: 'Design Journal' })
});
if (!response.ok) {
throw new Error('Export failed');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'design-journal-web.zip';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showNotification('Web export (ZIP) downloaded', 'success');
} catch (error) {
console.error('Export error:', error);
this.showNotification('Error exporting web: ' + error.message, 'error');
}
}
importMarkdown(event) { importMarkdown(event) {
@ -649,6 +723,140 @@ class ConceptionAssistant {
this.showNotification(`${isDark ? 'Dark' : 'Light'} mode activated`, 'success'); this.showNotification(`${isDark ? 'Dark' : 'Light'} mode activated`, 'success');
} }
updateStatistics() {
const content = this.editor.innerText.trim();
// Count words
const words = content.split(/\s+/).filter(word => word.length > 0);
const wordCount = words.length;
// Count characters
const charCount = content.length;
// Calculate reading time (200 words per minute)
const readingTime = Math.ceil(wordCount / 200);
// Update DOM elements
const wordCountEl = document.getElementById('word-count');
const charCountEl = document.getElementById('char-count');
const readingTimeEl = document.getElementById('reading-time');
if (wordCountEl) wordCountEl.textContent = wordCount;
if (charCountEl) charCountEl.textContent = charCount;
if (readingTimeEl) readingTimeEl.textContent = `${readingTime} min`;
}
async openPresentMode() {
const content = this.editor.innerText;
if (!content.trim()) {
this.showNotification('No content to present', 'warning');
return;
}
try {
this.showNotification('Opening presentation mode...', 'success');
// Create a form to POST the content
const form = document.createElement('form');
form.method = 'POST';
form.action = '/present';
form.target = '_blank';
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'content';
input.value = content;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
} catch (error) {
console.error('Present mode error:', error);
this.showNotification('Error opening presentation mode', 'error');
}
}
showShortcutsModal() {
const modalBody = document.getElementById('journal-modal-body');
const modalHeader = document.querySelector('#journal-modal .modal-header h3');
if (modalHeader) {
modalHeader.textContent = 'Keyboard Shortcuts';
}
modalBody.innerHTML = `
<div style="padding: 1rem;">
<div style="margin-bottom: 1.5rem;">
<h4 style="color: var(--primary-color); margin-bottom: 0.75rem;">Document Management</h4>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-weight: bold;">Ctrl+S</td>
<td style="padding: 0.5rem;">Save journal</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-weight: bold;">Ctrl+Z</td>
<td style="padding: 0.5rem;">Undo</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-weight: bold;">Ctrl+Y</td>
<td style="padding: 0.5rem;">Redo</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-weight: bold;">Tab</td>
<td style="padding: 0.5rem;">Indent text</td>
</tr>
<tr>
<td style="padding: 0.5rem; font-weight: bold;">Esc</td>
<td style="padding: 0.5rem;">Close side panels</td>
</tr>
</table>
</div>
<div style="margin-top: 1.5rem;">
<h4 style="color: var(--primary-color); margin-bottom: 0.75rem;">Markdown Formatting</h4>
<table style="width: 100%; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-family: monospace;"># Title</td>
<td style="padding: 0.5rem;">Heading level 1</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-family: monospace;">## Title</td>
<td style="padding: 0.5rem;">Heading level 2</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-family: monospace;">**bold**</td>
<td style="padding: 0.5rem;">Bold text</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-family: monospace;">*italic*</td>
<td style="padding: 0.5rem;">Italic text</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 0.5rem; font-family: monospace;">- item</td>
<td style="padding: 0.5rem;">Bullet list</td>
</tr>
<tr>
<td style="padding: 0.5rem; font-family: monospace;">\`code\`</td>
<td style="padding: 0.5rem;">Inline code</td>
</tr>
</table>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button class="btn primary" onclick="app.closeModal()">Close</button>
</div>
</div>
`;
// Show modal
const modal = document.getElementById('journal-modal');
modal.style.display = 'flex';
setTimeout(() => modal.classList.add('show'), 10);
}
async handleAI(action) { async handleAI(action) {
const selection = window.getSelection().toString().trim(); const selection = window.getSelection().toString().trim();
const fullContent = this.editor.innerText; const fullContent = this.editor.innerText;
@ -1086,12 +1294,18 @@ class ConceptionAssistant {
async togglePreview() { async togglePreview() {
const previewBtn = document.getElementById('preview-toggle'); const previewBtn = document.getElementById('preview-toggle');
const mainElement = document.querySelector('main');
if (!this.isPreviewMode) { if (!this.isPreviewMode) {
// Switch to preview mode // Switch to preview mode
this.originalContent = this.editor.innerHTML; this.originalContent = this.editor.innerHTML;
const markdownContent = this.editor.innerText; const markdownContent = this.editor.innerText;
// Add preview-mode class to main to adjust grid layout
if (mainElement) {
mainElement.classList.add('preview-mode');
}
// Configure Marked for GitHub-compatible rendering // Configure Marked for GitHub-compatible rendering
if (typeof marked !== 'undefined') { if (typeof marked !== 'undefined') {
marked.setOptions({ marked.setOptions({
@ -1185,6 +1399,11 @@ class ConceptionAssistant {
this.editor.style.border = ''; this.editor.style.border = '';
this.editor.style.borderRadius = ''; this.editor.style.borderRadius = '';
// Remove preview-mode class to restore normal grid layout
if (mainElement) {
mainElement.classList.remove('preview-mode');
}
// Change button // Change button
previewBtn.innerHTML = 'Preview'; previewBtn.innerHTML = 'Preview';
previewBtn.classList.remove('secondary'); previewBtn.classList.remove('secondary');

View File

@ -25,6 +25,7 @@
"node": ">=16.0.0" "node": ">=16.0.0"
}, },
"dependencies": { "dependencies": {
"archiver": "^7.0.1",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"express": "^4.18.2", "express": "^4.18.2",
"puppeteer": "^24.22.3", "puppeteer": "^24.22.3",

View File

@ -2,6 +2,8 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const puppeteer = require('puppeteer'); const puppeteer = require('puppeteer');
const path = require('path'); const path = require('path');
const archiver = require('archiver');
const fs = require('fs');
// POST /api/export/pdf - Generate PDF from markdown content // POST /api/export/pdf - Generate PDF from markdown content
router.post('/pdf', async (req, res) => { router.post('/pdf', async (req, res) => {
@ -509,4 +511,163 @@ function sanitizeFilename(filename) {
.substring(0, 100); // Limit length .substring(0, 100); // Limit length
} }
// POST /api/export/web - Generate ZIP with HTML + CSS
router.post('/web', async (req, res) => {
const { content, title = 'Design Journal' } = req.body;
if (!content || content.trim() === '') {
return res.status(400).json({
success: false,
error: 'Content required to generate web export'
});
}
try {
// Read CSS files
const styleCssPath = path.join(__dirname, '../assets/css/style.css');
const githubCssPath = path.join(__dirname, '../assets/css/github-preview.css');
let styleCSS = '';
let githubCSS = '';
try {
styleCSS = fs.readFileSync(styleCssPath, 'utf8');
githubCSS = fs.readFileSync(githubCssPath, 'utf8');
} catch (err) {
console.error('Error reading CSS files:', err);
}
// Generate HTML content
const htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${title}</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
</head>
<body>
<div id="presentation-container">
<div class="presentation-header">
<h1>${title}</h1>
<div class="date">${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</div>
</div>
<div class="presentation-content markdown-body markdown-preview" id="content"></div>
</div>
<script>
// Render markdown content first
const content = ${JSON.stringify(content)};
const contentDiv = document.getElementById('content');
contentDiv.innerHTML = marked.parse(content);
// Initialize Mermaid
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
});
// Process Mermaid diagrams
setTimeout(() => {
const mermaidBlocks = contentDiv.querySelectorAll('code.language-mermaid, pre code.language-mermaid');
mermaidBlocks.forEach((block, index) => {
try {
const mermaidCode = block.textContent;
const uniqueId = 'mermaid-' + Date.now() + '-' + index;
const mermaidDiv = document.createElement('div');
mermaidDiv.id = uniqueId;
mermaidDiv.className = 'mermaid';
mermaidDiv.textContent = mermaidCode;
const pre = block.closest('pre') || block;
pre.parentNode.replaceChild(mermaidDiv, pre);
mermaid.render(uniqueId + '-svg', mermaidCode).then(function(result) {
mermaidDiv.innerHTML = result.svg;
}).catch(function(err) {
console.warn('Mermaid rendering error:', err);
mermaidDiv.innerHTML = '<pre style="color: red;">Mermaid error: ' + err.message + '</pre>';
});
} catch (error) {
console.warn('Mermaid processing error:', error);
}
});
}, 200);
</script>
</body>
</html>`;
// Combine CSS with additional presentation styles
const combinedCSS = `
${styleCSS}
${githubCSS}
/* Additional presentation styles */
body {
margin: 0;
padding: 0;
}
#presentation-container {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
min-height: 100vh;
}
.presentation-header {
text-align: center;
padding: 2rem 0;
border-bottom: 2px solid var(--border-color);
margin-bottom: 3rem;
}
.presentation-header h1 {
margin: 0 0 0.5rem 0;
color: var(--primary-color);
}
.presentation-header .date {
color: var(--text-light);
font-size: 0.9rem;
}
.presentation-content {
line-height: 1.8;
}
`;
// Create ZIP archive
const archive = archiver('zip', {
zlib: { level: 9 } // Maximum compression
});
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(title)}-web.zip"`);
// Pipe archive to response
archive.pipe(res);
// Add files to archive
archive.append(htmlContent, { name: 'index.html' });
archive.append(combinedCSS, { name: 'style.css' });
// Finalize archive
await archive.finalize();
} catch (error) {
console.error('Web export error:', error);
res.status(500).json({
success: false,
error: 'Error during web export'
});
}
});
module.exports = router; module.exports = router;

View File

@ -7,6 +7,141 @@ router.get('/', (req, res) => {
res.send(getPage()); res.send(getPage());
}); });
// Present route - Display document in presentation mode
router.post('/present', (req, res) => {
const { content } = req.body;
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Presentation - Design Journal</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/assets/css/style.css">
<link rel="stylesheet" href="/assets/css/github-preview.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
<style>
body {
margin: 0;
padding: 0;
background: var(--bg-color);
}
#presentation-container {
max-width: 900px;
margin: 0 auto;
padding: 3rem 2rem;
min-height: 100vh;
}
.presentation-header {
text-align: center;
padding: 2rem 0;
border-bottom: 2px solid var(--border-color);
margin-bottom: 3rem;
}
.presentation-header h1 {
margin: 0 0 0.5rem 0;
color: var(--primary-color);
}
.presentation-header .date {
color: var(--text-light);
font-size: 0.9rem;
}
.presentation-content {
line-height: 1.8;
}
.print-button {
position: fixed;
top: 1rem;
right: 1rem;
padding: 0.5rem 1rem;
background: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: all 0.3s ease;
}
.print-button:hover {
background: var(--primary-hover);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
@media print {
.print-button {
display: none;
}
#presentation-container {
padding: 1rem;
}
}
</style>
</head>
<body>
<button class="print-button" onclick="window.print()">Print / Save PDF</button>
<div id="presentation-container">
<div class="presentation-header">
<h1>Design Journal</h1>
<div class="date">${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</div>
</div>
<div class="presentation-content markdown-body" id="content"></div>
</div>
<script>
// Render markdown content first
const content = ${JSON.stringify(content)};
const contentDiv = document.getElementById('content');
contentDiv.innerHTML = marked.parse(content);
// Initialize Mermaid
mermaid.initialize({
startOnLoad: false,
theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default',
securityLevel: 'loose'
});
// Process Mermaid diagrams
setTimeout(() => {
// Find all code blocks with 'mermaid' language
const mermaidBlocks = contentDiv.querySelectorAll('code.language-mermaid, pre code.language-mermaid');
mermaidBlocks.forEach((block, index) => {
try {
const mermaidCode = block.textContent;
const uniqueId = 'mermaid-' + Date.now() + '-' + index;
// Create div with unique ID for Mermaid
const mermaidDiv = document.createElement('div');
mermaidDiv.id = uniqueId;
mermaidDiv.className = 'mermaid';
mermaidDiv.textContent = mermaidCode;
// Replace code block with Mermaid div
const pre = block.closest('pre') || block;
pre.parentNode.replaceChild(mermaidDiv, pre);
// Render diagram
mermaid.render(uniqueId + '-svg', mermaidCode).then(function(result) {
mermaidDiv.innerHTML = result.svg;
}).catch(function(err) {
console.warn('Mermaid rendering error:', err);
mermaidDiv.innerHTML = '<pre style="color: red; padding: 1rem; background: #ffe6e6; border-radius: 6px;">Mermaid rendering error: ' + err.message + '</pre>';
});
} catch (error) {
console.warn('Mermaid processing error:', error);
}
});
}, 200);
</script>
</body>
</html>
`);
});
// About route // About route
router.get('/about', (req, res) => { router.get('/about', (req, res) => {
const { getHeader } = require('../views/header'); const { getHeader } = require('../views/header');

View File

@ -19,10 +19,13 @@ function getLeftPanel() {
<div class="template-form"> <div class="template-form">
<div class="form-group"> <div class="form-group">
<p style="margin-bottom: 1rem; color: var(--text-light);">Start with a default template for your design journal.</p> <p style="margin-bottom: 1rem; color: var(--text-light); font-size: 0.9rem;">Create a new document or start with a template.</p>
</div> </div>
<div class="form-actions"> <div class="form-actions" style="display: flex; flex-direction: column; gap: 0.75rem;">
<button id="new-blank-document" class="btn" title="Start with a blank document">
New Blank Document
</button>
<button id="load-template" class="btn success" title="Load default template"> <button id="load-template" class="btn success" title="Load default template">
Load Default Template Load Default Template
</button> </button>
@ -57,7 +60,11 @@ function getRightPanel() {
<div class="nav-buttons"> <div class="nav-buttons">
<button class="nav-btn" id="export-md" title="Export as Markdown"> <button class="nav-btn" id="export-md" title="Export as Markdown">
<span class="icon"></span> <span class="icon"></span>
<span>Export as Markdown</span> <span>Markdown</span>
</button>
<button class="nav-btn" id="export-web" title="Export as Web (HTML + CSS in ZIP)">
<span class="icon"></span>
<span>Web (ZIP)</span>
</button> </button>
</div> </div>
</div> </div>
@ -72,6 +79,25 @@ function getRightPanel() {
</label> </label>
</div> </div>
</div> </div>
<div class="nav-section">
<h4>Statistics</h4>
<div class="stats-display" style="padding: 0.75rem; background: var(--surface-color); border-radius: 8px; font-size: 0.85rem;">
<div style="margin-bottom: 0.5rem;"><strong>Words:</strong> <span id="word-count">0</span></div>
<div style="margin-bottom: 0.5rem;"><strong>Characters:</strong> <span id="char-count">0</span></div>
<div><strong>Reading time:</strong> <span id="reading-time">0 min</span></div>
</div>
</div>
<div class="nav-section">
<h4>Help</h4>
<div class="nav-buttons">
<button class="nav-btn" id="show-shortcuts" title="Keyboard shortcuts">
<span class="icon">?</span>
<span>Shortcuts</span>
</button>
</div>
</div>
</div> </div>
</div> </div>
`; `;

View File

@ -17,6 +17,7 @@ function getMain() {
<button id="save-journal" class="btn success">Save</button> <button id="save-journal" class="btn success">Save</button>
<button id="load-journal" class="btn">Load</button> <button id="load-journal" class="btn">Load</button>
<button id="preview-toggle" class="btn primary">Preview</button> <button id="preview-toggle" class="btn primary">Preview</button>
<button id="present-mode" class="btn" title="Open in presentation mode (new window)">Present</button>
<span id="save-status" class="text-light"></span> <span id="save-status" class="text-light"></span>
</div> </div>