Files
flowdeck/app/templates/page_editor.html
T
bruno 1d2c2c0919
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
fix: context menu implémenté + page editor CSRF + workspace context
- Context menu: toutes les actions fonctionnelles (favorite, copyLink, duplicate, rename, move, delete, openTab)
- Page editor: CSRF token ajouté dans save() et saveTitle()
- Page editor: workspace context restauré (owner/repo extrait de pages.workspace)
- Route /pages/{id}: utilise board._sidebar_data pour préserver le contexte projet
2026-07-08 15:51:24 -04:00

200 lines
10 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">&lt;/&gt;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// 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] : '';
fetch(`/board/api/pages/{{ page.id }}?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();
});
},
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 }
});
},
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: '![alt](url)',
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 %}