- sidebar items: attribut data-page-id ajouté pour ciblage DOM - save(): après PUT réussi, met à jour .page-name dans le sidebar - saveTitle(): reload la page (changement de titre = reload ok)
207 lines
11 KiB
HTML
207 lines
11 KiB
HTML
{% extends "base.html" %}
|
|
{% block page_icon %}📄{% endblock %}
|
|
{% block page_title %}{{ page.title }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-title-area">
|
|
<div class="page-title">
|
|
<span class="page-icon-lg">📄</span>
|
|
<h1 contenteditable="true"
|
|
@blur="saveTitle($event.target.textContent)"
|
|
x-data>{{ page.title }}</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-data="pageEditor('{{ page.content|e }}')" style="padding: 0 24px 48px; max-width: 900px;">
|
|
<!-- Toolbar -->
|
|
<div style="display:flex; gap:4px; margin-bottom:12px; flex-wrap:wrap;">
|
|
<button class="toolbar-btn" @click="insertMd('**', '**')" title="Bold">B</button>
|
|
<button class="toolbar-btn" @click="insertMd('*', '*')" title="Italic"><i>I</i></button>
|
|
<button class="toolbar-btn" @click="insertMd('# ', '')" title="Heading 1">H1</button>
|
|
<button class="toolbar-btn" @click="insertMd('## ', '')" title="Heading 2">H2</button>
|
|
<button class="toolbar-btn" @click="insertMd('- ', '')" title="Bullet list">•</button>
|
|
<button class="toolbar-btn" @click="insertMd('1. ', '')" title="Numbered list">1.</button>
|
|
<button class="toolbar-btn" @click="insertMd('> ', '')" title="Quote">"</button>
|
|
<button class="toolbar-btn" @click="insertMd('`', '`')" title="Code"></></button>
|
|
<button class="toolbar-btn" @click="insertMd('```\n', '\n```')" title="Code block">```</button>
|
|
<button class="toolbar-btn" @click="insertMd('---\n', '')" title="Divider">—</button>
|
|
<button class="toolbar-btn" @click="insertMd('[', '](url)')" title="Link">🔗</button>
|
|
<button class="toolbar-btn" @click="insertMd('- [ ] ', '')" title="Todo">☐</button>
|
|
</div>
|
|
|
|
<!-- Editor -->
|
|
<div style="display:flex; gap:0; min-height:400px; border:1px solid var(--border); border-radius:8px; overflow:hidden;">
|
|
<!-- Markdown source -->
|
|
<div style="flex:1; display:flex; flex-direction:column;">
|
|
<div style="padding:4px 12px; background:var(--bg-tertiary); font-size:12px; color:var(--text-dim);">Markdown</div>
|
|
<textarea x-ref="editor" x-model="content"
|
|
@input="autoSave()"
|
|
@keydown="handleSlash($event)"
|
|
style="flex:1; width:100%; min-height:400px; background:var(--bg-secondary);
|
|
border:none; padding:12px; color:var(--text-primary); font-family:var(--font-mono);
|
|
font-size:13px; line-height:1.6; resize:none; outline:none; tab-size:2;"
|
|
placeholder="Type '/' for commands, or start writing in Markdown..."></textarea>
|
|
</div>
|
|
<!-- Preview -->
|
|
<div style="flex:1; border-left:1px solid var(--border); display:flex; flex-direction:column;">
|
|
<div style="padding:4px 12px; background:var(--bg-tertiary); font-size:12px; color:var(--text-dim);">Preview</div>
|
|
<div style="flex:1; padding:12px; overflow-y:auto; font-size:14px; line-height:1.7; color:var(--text-primary);"
|
|
x-html="renderedContent()"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div style="display:flex; align-items:center; gap:8px; margin-top:8px; font-size:12px; color:var(--text-dim);">
|
|
<span x-show="saved">✓ Saved</span>
|
|
<span x-show="dirty && !saving">● Unsaved</span>
|
|
<span x-text="wordCount + ' words'"></span>
|
|
<span style="margin-left:auto;" x-text="'Last edited: ' + lastSaved"></span>
|
|
</div>
|
|
|
|
<!-- Get started block (shown for empty pages) -->
|
|
<div x-show="!content.trim()" style="margin-top:48px; text-align:center; color:var(--text-dim);">
|
|
<div style="font-size:48px; margin-bottom:16px;">📝</div>
|
|
<h3 style="color:var(--text-secondary); margin-bottom:8px;">Get started with</h3>
|
|
<div style="display:flex; gap:12px; justify-content:center; flex-wrap:wrap;">
|
|
<button class="sidebar-new-chat" style="width:auto; padding:8px 16px;" @click="insertMd('# ', '')">📄 Empty page</button>
|
|
<button class="sidebar-new-chat" style="width:auto; padding:8px 16px;" @click="insertMd('- [ ] Task 1\n- [ ] Task 2\n', '')">✅ To-do list</button>
|
|
<button class="sidebar-new-chat" style="width:auto; padding:8px 16px;" @click="insertMd('| Col 1 | Col 2 |\n|-------|-------|\n| | |\n', '')">📊 Table</button>
|
|
<button class="sidebar-new-chat" style="width:auto; padding:8px 16px;" @click="insertMd('# Meeting Notes\n\n**Date:** \n**Attendees:** \n\n## Agenda\n\n- \n\n## Notes\n\n- \n\n## Action Items\n\n- [ ] \n', '')">🎤 Meeting Notes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// Simple Markdown-to-HTML renderer
|
|
function renderMarkdown(md) {
|
|
let html = md
|
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
// Headers
|
|
.replace(/^### (.+)$/gm, '<h3 style="font-size:16px; margin:12px 0 4px;">$1</h3>')
|
|
.replace(/^## (.+)$/gm, '<h2 style="font-size:18px; margin:16px 0 4px;">$1</h2>')
|
|
.replace(/^# (.+)$/gm, '<h1 style="font-size:22px; margin:20px 0 6px;">$1</h1>')
|
|
// Bold/italic
|
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
// Inline code
|
|
.replace(/`([^`]+)`/g, '<code style="background:var(--bg-tertiary); padding:1px 4px; border-radius:3px; font-size:12px;">$1</code>')
|
|
// Code blocks
|
|
.replace(/```[\s\S]*?```/g, (m) => '<pre style="background:var(--bg-tertiary); padding:12px; border-radius:4px; overflow-x:auto; font-size:12px;"><code>' + m.slice(3, -3).trim() + '</code></pre>')
|
|
// Links
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" style="color:var(--blue);">$1</a>')
|
|
// Horizontal rules
|
|
.replace(/^---$/gm, '<hr style="border:none; border-top:1px solid var(--border); margin:16px 0;">')
|
|
// Blockquotes
|
|
.replace(/^> (.+)$/gm, '<blockquote style="border-left:2px solid var(--blue); padding-left:12px; margin:8px 0; color:var(--text-secondary);">$1</blockquote>')
|
|
// Lists
|
|
.replace(/^- (.+)$/gm, '<li style="margin-left:20px;">$1</li>')
|
|
.replace(/^\d+\. (.+)$/gm, '<li style="margin-left:20px;">$1</li>')
|
|
// Checkboxes
|
|
.replace(/^- \[ \] (.+)$/gm, '<div style="display:flex; align-items:center; gap:8px; margin:2px 0;"><input type="checkbox" style="accent-color:var(--blue);">$1</div>')
|
|
.replace(/^- \[x\] (.+)$/gm, '<div style="display:flex; align-items:center; gap:8px; margin:2px 0;"><input type="checkbox" checked style="accent-color:var(--blue);"><span style="text-decoration:line-through; color:var(--text-dim);">$1</span></div>')
|
|
// Paragraphs
|
|
.replace(/\n\n/g, '<br><br>');
|
|
return html;
|
|
}
|
|
|
|
function pageEditor(initialContent) {
|
|
return {
|
|
content: initialContent || '',
|
|
saved: true,
|
|
dirty: false,
|
|
saving: false,
|
|
lastSaved: 'now',
|
|
saveTimeout: null,
|
|
renderedContent() { return renderMarkdown(this.content); },
|
|
get wordCount() { return this.content.trim() ? this.content.trim().split(/\s+/).length : 0; },
|
|
insertMd(before, after) {
|
|
const el = this.$refs.editor;
|
|
const start = el.selectionStart;
|
|
const end = el.selectionEnd;
|
|
const selected = this.content.substring(start, end);
|
|
const replacement = before + selected + after;
|
|
this.content = this.content.substring(0, start) + replacement + this.content.substring(end);
|
|
this.autoSave();
|
|
el.focus();
|
|
el.setSelectionRange(start + before.length, start + before.length + selected.length);
|
|
},
|
|
autoSave() {
|
|
this.dirty = true;
|
|
this.saved = false;
|
|
clearTimeout(this.saveTimeout);
|
|
this.saveTimeout = setTimeout(() => this.save(), 1500);
|
|
},
|
|
save() {
|
|
this.saving = true;
|
|
const title = document.querySelector('.page-title h1')?.textContent || 'New page';
|
|
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
const token = csrf ? csrf[1] : '';
|
|
const pageId = '{{ page.id }}';
|
|
fetch(`/board/api/pages/${pageId}?title=${encodeURIComponent(title)}&content=${encodeURIComponent(this.content)}`, {
|
|
method: 'PUT',
|
|
headers: { 'X-CSRF-Token': token }
|
|
})
|
|
.then(() => {
|
|
this.saved = true;
|
|
this.dirty = false;
|
|
this.saving = false;
|
|
this.lastSaved = new Date().toLocaleTimeString();
|
|
// Update sidebar title without reloading
|
|
const sidebarItem = document.querySelector(`.sidebar-item[data-page-id="page/${pageId}"]`);
|
|
if (sidebarItem) {
|
|
const nameEl = sidebarItem.querySelector('.page-name');
|
|
if (nameEl) nameEl.textContent = title;
|
|
}
|
|
});
|
|
},
|
|
saveTitle(newTitle) {
|
|
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
|
const token = csrf ? csrf[1] : '';
|
|
fetch(`/board/api/pages/{{ page.id }}?title=${encodeURIComponent(newTitle)}`, {
|
|
method: 'PUT',
|
|
headers: { 'X-CSRF-Token': token }
|
|
}).then(() => { location.reload(); });
|
|
},
|
|
handleSlash(e) {
|
|
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
|
const el = this.$refs.editor;
|
|
const cursor = el.selectionStart;
|
|
const lineStart = this.content.lastIndexOf('\n', cursor - 1) + 1;
|
|
const charsBefore = this.content.substring(lineStart, cursor);
|
|
// Only trigger at start of line or after whitespace
|
|
if (charsBefore.trim() === '') {
|
|
e.preventDefault();
|
|
this.content = this.content.substring(0, cursor) + '/';
|
|
// Simple menu: insert template after short delay
|
|
const cmd = prompt('Slash command:\n /h1 - Heading 1\n /h2 - Heading 2\n /h3 - Heading 3\n /bold - Bold\n /list - Bullet list\n /num - Numbered list\n /code - Code block\n /quote - Quote\n /todo - To-do list\n /table - Table\n /divider - Divider\n /link - Link\n /img - Image\n /date - Date');
|
|
if (!cmd) { this.content = this.content.substring(0, cursor); return; }
|
|
const templates = {
|
|
h1: '# ',
|
|
h2: '## ',
|
|
h3: '### ',
|
|
bold: '**bold text**',
|
|
list: '- ',
|
|
num: '1. ',
|
|
code: '```\ncode\n```',
|
|
quote: '> ',
|
|
todo: '- [ ] ',
|
|
table: '| Col 1 | Col 2 |\n|-------|-------|\n| | |',
|
|
divider: '---\n',
|
|
link: '[text](url)',
|
|
img: '',
|
|
date: new Date().toISOString().slice(0, 10),
|
|
};
|
|
const insert = templates[cmd.replace('/','')] || '';
|
|
const before = this.content.substring(0, cursor);
|
|
this.content = before + insert + this.content.substring(cursor + 1);
|
|
setTimeout(() => { el.focus(); el.selectionStart = el.selectionEnd = before.length + insert.length; }, 50);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|