Add Rédaction view: Binder + TipTap editor

Phase 1 of Solution 3 (Scrivener-inspired writing environment):
- Binder panel: scenes grouped by act, status indicators, word counts
- TipTap rich text editor with toolbar (bold, italic, headings, lists, blockquote)
- Auto-save after 1.5s of inactivity
- New NocoDB table "Contenu" (SceneId, Texte, Mots)
- API routes: GET/PUT /api/contenu/:sceneId, GET /api/contenu (word counts)
- Total word count in binder footer
- Navigation: Vision / Frise / Rédaction

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-05-29 11:32:40 +02:00
parent 73dd0e0625
commit 709d9e0ce6
2 changed files with 367 additions and 0 deletions
+301
View File
@@ -6,6 +6,8 @@
<title>Tableau de bord — RdB</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/core@2.11.7/dist/index.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@tiptap/starter-kit@2.11.7/dist/index.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -295,6 +297,47 @@
.list-add:hover { color: var(--accent); }
/* ── Vue Rédaction ── */
.redaction-layout { display: flex; height: calc(100vh - 60px); }
.binder { width: 260px; flex-shrink: 0; background: var(--bg-alt); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 0; }
.binder-acte { padding: 0 12px; margin-bottom: 4px; }
.binder-acte-title { font-family: 'Playfair Display', serif; font-size: 13px; font-weight: 700; color: var(--text-hint); text-transform: uppercase; letter-spacing: 0.5px; padding: 10px 12px 6px; cursor: pointer; user-select: none; }
.binder-acte-title:hover { color: var(--text); }
.binder-scene { padding: 8px 12px 8px 24px; font-size: 14px; color: var(--text-muted); cursor: pointer; border-radius: 6px; margin: 1px 8px; display: flex; align-items: center; gap: 8px; line-height: 1.3; }
.binder-scene:hover { background: var(--border); color: var(--text); }
.binder-scene.active { background: var(--accent); color: #fff; }
.dark .binder-scene.active { color: var(--bg); }
.binder-scene .scene-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.binder-scene .scene-status.posee { background: #5a7a3a; }
.binder-scene .scene-status.a-etudier { background: #c4a030; }
.binder-scene .scene-status.germe { background: #a08a76; }
.binder-scene .scene-words { font-size: 11px; color: var(--text-light); margin-left: auto; white-space: nowrap; }
.binder-scene.active .scene-words { color: rgba(255,255,255,0.6); }
.editor-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.editor-header { padding: 20px 32px 12px; border-bottom: 1px solid var(--border-light); }
.editor-scene-title { font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 600; color: var(--text); }
.editor-scene-meta { font-size: 13px; color: var(--text-hint); margin-top: 4px; display: flex; gap: 16px; }
.editor-body { flex: 1; overflow-y: auto; padding: 24px 32px 80px; max-width: 720px; }
.editor-body .tiptap { outline: none; font-family: 'Lora', Georgia, serif; font-size: 16px; line-height: 1.8; color: var(--text); min-height: 300px; }
.editor-body .tiptap p { margin-bottom: 12px; }
.editor-body .tiptap h2 { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; margin: 24px 0 12px; }
.editor-body .tiptap h3 { font-family: 'Playfair Display', serif; font-size: 17px; font-weight: 600; margin: 20px 0 8px; }
.editor-body .tiptap strong { font-weight: 600; }
.editor-body .tiptap em { font-style: italic; }
.editor-body .tiptap ul, .editor-body .tiptap ol { margin: 8px 0 8px 24px; }
.editor-body .tiptap li { margin-bottom: 4px; }
.editor-body .tiptap hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
.editor-body .tiptap blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-muted); font-style: italic; margin: 16px 0; }
.editor-toolbar { display: flex; gap: 2px; padding: 8px 32px; border-bottom: 1px solid var(--border-light); background: var(--bg); }
.editor-toolbar button { background: none; border: none; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 14px; color: var(--text-muted); font-family: 'Lora', serif; }
.editor-toolbar button:hover { background: var(--bg-alt); color: var(--text); }
.editor-toolbar button.is-active { background: var(--border); color: var(--text); font-weight: 600; }
.editor-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-light); font-size: 16px; font-style: italic; }
.editor-saving { position: fixed; bottom: 20px; right: 20px; background: var(--accent); color: var(--bg); padding: 8px 18px; border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; opacity: 0; transition: opacity 0.3s; z-index: 200; pointer-events: none; }
.editor-saving.show { opacity: 1; }
.word-count { font-size: 12px; color: var(--text-hint); padding: 8px 32px; border-top: 1px solid var(--border-light); background: var(--bg); }
/* Responsive */
@media (max-width: 768px) {
.sidebar { display: none; }
@@ -302,6 +345,8 @@
.card { width: 150px; min-width: 150px; }
.vision-grid { grid-template-columns: 1fr; }
.two-cols { grid-template-columns: 1fr; }
.binder { width: 200px; }
.editor-body { padding: 16px; }
}
</style>
</head>
@@ -312,6 +357,7 @@
<div class="sidebar-project">Projet RdB</div>
<div class="nav-item active" data-view="vision" onclick="switchView('vision')"><span class="nav-icon">&#9733;</span> Vision</div>
<div class="nav-item" data-view="frise" onclick="switchView('frise')"><span class="nav-icon">&#9203;</span> Frise</div>
<div class="nav-item" data-view="redaction" onclick="switchView('redaction')"><span class="nav-icon">&#9998;</span> Rédaction</div>
<div class="sidebar-footer">Etienne & Myriam</div>
</div>
<div class="main">
@@ -415,6 +461,26 @@
</div>
</div>
</div>
<!-- ══ VUE RÉDACTION ══ -->
<div class="view" id="view-redaction">
<div class="redaction-layout">
<div class="binder" id="binder"></div>
<div class="editor-pane" id="editor-pane">
<div class="editor-empty" id="editor-empty">Choisir une scène dans le Binder</div>
<div id="editor-active" style="display:none; flex:1; display:none; flex-direction:column;">
<div class="editor-header">
<div class="editor-scene-title" id="editor-title"></div>
<div class="editor-scene-meta" id="editor-meta"></div>
</div>
<div class="editor-toolbar" id="editor-toolbar"></div>
<div class="editor-body"><div id="editor-content"></div></div>
<div class="word-count" id="word-count"></div>
</div>
</div>
</div>
</div>
<div class="editor-saving" id="editor-saving">Enregistré</div>
<div class="vision-saving" id="vision-saving">Enregistré</div>
</div>
@@ -495,6 +561,10 @@ function switchView(name) {
if (name === 'vision') {
topTitle.textContent = 'Vision';
if (btnNew) btnNew.style.display = 'none';
} else if (name === 'redaction') {
topTitle.textContent = 'Rédaction';
if (btnNew) btnNew.style.display = 'none';
buildBinder();
} else {
topTitle.textContent = 'Frise';
if (btnNew) btnNew.style.display = '';
@@ -1287,6 +1357,237 @@ async function loadVision() {
}
loadVision();
// ══════════════════════════════════════════════════════
// ══ RÉDACTION — Binder + TipTap Editor ══
// ══════════════════════════════════════════════════════
let tiptapEditor = null;
let currentSceneId = null;
let saveTimeout = null;
let wordCounts = {}; // sceneId → word count (cached)
const ACTE_ORDER = ["Avant l'histoire", 'Acte 1', 'Acte 2', 'Acte 3'];
function statusSlug(s) {
if (!s) return '';
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
}
function buildBinder() {
const binder = document.getElementById('binder');
// Group scenes by acte (exclude "En attente", only include Scène and Événement types relevant to writing)
const grouped = {};
ACTE_ORDER.forEach(a => { grouped[a] = []; });
(records || []).forEach(r => {
const acte = r.Acte;
if (!acte || acte === 'En attente') return;
if (grouped[acte]) grouped[acte].push(r);
});
binder.innerHTML = '';
let totalWords = 0;
ACTE_ORDER.forEach(acte => {
const scenes = grouped[acte];
if (!scenes || scenes.length === 0) return;
const acteDiv = document.createElement('div');
acteDiv.className = 'binder-acte';
const title = document.createElement('div');
title.className = 'binder-acte-title';
title.textContent = acte;
acteDiv.appendChild(title);
scenes.sort((a, b) => (a.Ordre || 0) - (b.Ordre || 0));
scenes.forEach(scene => {
const el = document.createElement('div');
el.className = 'binder-scene' + (scene.Id === currentSceneId ? ' active' : '');
const slug = statusSlug(scene.Statut);
const wc = wordCounts[scene.Id] || 0;
totalWords += wc;
el.innerHTML = `<span class="scene-status ${slug}"></span><span class="scene-name">${esc(scene.Title)}</span>${wc ? `<span class="scene-words">${wc}</span>` : ''}`;
el.onclick = () => openScene(scene);
acteDiv.appendChild(el);
});
binder.appendChild(acteDiv);
});
// Total word count at bottom
const totalDiv = document.createElement('div');
totalDiv.style.cssText = 'padding: 16px 20px; margin-top: 12px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-hint);';
totalDiv.textContent = totalWords > 0 ? `Total : ${totalWords.toLocaleString('fr-FR')} mots` : '';
binder.appendChild(totalDiv);
}
async function openScene(scene) {
currentSceneId = scene.Id;
// Update binder selection
document.querySelectorAll('.binder-scene').forEach(el => el.classList.remove('active'));
event?.target?.closest?.('.binder-scene')?.classList.add('active');
// Show editor
document.getElementById('editor-empty').style.display = 'none';
const active = document.getElementById('editor-active');
active.style.display = 'flex';
// Header
document.getElementById('editor-title').textContent = scene.Title;
const meta = [];
if (scene.Acte) meta.push(scene.Acte);
if (scene.Type) meta.push(scene.Type);
if (scene.Statut) meta.push(scene.Statut);
if (scene.Personnage) meta.push(scene.Personnage);
document.getElementById('editor-meta').textContent = meta.join(' · ');
// Load content from API
try {
const res = await fetch(`/api/contenu/${scene.Id}`);
const row = await res.json();
const html = row?.Texte || '';
initEditor(html);
updateWordCount();
} catch (e) {
console.error('Load content error:', e);
initEditor('');
}
}
function initEditor(html) {
// Destroy previous editor
if (tiptapEditor) { tiptapEditor.destroy(); tiptapEditor = null; }
const { Editor } = window.TiptapCore || {};
const { StarterKit } = window.TiptapStarterKit || {};
if (!Editor || !StarterKit) {
// Fallback: contenteditable div if TipTap CDN not loaded
const el = document.getElementById('editor-content');
el.innerHTML = html || '<p></p>';
el.contentEditable = 'true';
el.classList.add('tiptap');
el.addEventListener('input', () => { clearTimeout(saveTimeout); saveTimeout = setTimeout(saveContent, 1500); updateWordCount(); });
buildToolbarFallback();
return;
}
tiptapEditor = new Editor({
element: document.getElementById('editor-content'),
extensions: [StarterKit],
content: html || '<p></p>',
onUpdate: () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(saveContent, 1500);
updateWordCount();
},
});
buildToolbar();
}
function buildToolbar() {
const bar = document.getElementById('editor-toolbar');
if (!tiptapEditor) { buildToolbarFallback(); return; }
const btns = [
{ label: 'G', cmd: () => tiptapEditor.chain().focus().toggleBold().run(), active: () => tiptapEditor.isActive('bold') },
{ label: 'I', cmd: () => tiptapEditor.chain().focus().toggleItalic().run(), active: () => tiptapEditor.isActive('italic'), style: 'font-style:italic;' },
{ label: 'H2', cmd: () => tiptapEditor.chain().focus().toggleHeading({ level: 2 }).run(), active: () => tiptapEditor.isActive('heading', { level: 2 }) },
{ label: 'H3', cmd: () => tiptapEditor.chain().focus().toggleHeading({ level: 3 }).run(), active: () => tiptapEditor.isActive('heading', { level: 3 }) },
{ label: '•', cmd: () => tiptapEditor.chain().focus().toggleBulletList().run(), active: () => tiptapEditor.isActive('bulletList') },
{ label: '1.', cmd: () => tiptapEditor.chain().focus().toggleOrderedList().run(), active: () => tiptapEditor.isActive('orderedList') },
{ label: '❝', cmd: () => tiptapEditor.chain().focus().toggleBlockquote().run(), active: () => tiptapEditor.isActive('blockquote') },
{ label: '—', cmd: () => tiptapEditor.chain().focus().setHorizontalRule().run(), active: () => false },
];
bar.innerHTML = '';
btns.forEach(b => {
const btn = document.createElement('button');
btn.innerHTML = b.label;
if (b.style) btn.style.cssText = b.style;
btn.onclick = b.cmd;
bar.appendChild(btn);
});
// Update active states on selection change
tiptapEditor.on('selectionUpdate', () => {
bar.querySelectorAll('button').forEach((btn, i) => {
btn.classList.toggle('is-active', btns[i].active());
});
});
tiptapEditor.on('update', () => {
bar.querySelectorAll('button').forEach((btn, i) => {
btn.classList.toggle('is-active', btns[i].active());
});
});
}
function buildToolbarFallback() {
const bar = document.getElementById('editor-toolbar');
bar.innerHTML = '<span style="font-size:12px;color:var(--text-hint);padding:6px;">Éditeur simplifié — TipTap non chargé</span>';
}
function updateWordCount() {
let text = '';
if (tiptapEditor) {
text = tiptapEditor.getText();
} else {
const el = document.getElementById('editor-content');
text = el?.textContent || '';
}
const count = text.trim() ? text.trim().split(/\s+/).length : 0;
document.getElementById('word-count').textContent = `${count.toLocaleString('fr-FR')} mots`;
if (currentSceneId) wordCounts[currentSceneId] = count;
}
async function saveContent() {
if (!currentSceneId) return;
let html = '';
let wordCount = 0;
if (tiptapEditor) {
html = tiptapEditor.getHTML();
const text = tiptapEditor.getText();
wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
} else {
const el = document.getElementById('editor-content');
html = el?.innerHTML || '';
const text = el?.textContent || '';
wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
}
try {
await fetch(`/api/contenu/${currentSceneId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Texte: html, Mots: wordCount }),
});
wordCounts[currentSceneId] = wordCount;
// Show saved indicator
const el = document.getElementById('editor-saving');
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 1500);
// Update binder word count
buildBinder();
} catch (e) {
console.error('Save error:', e);
}
}
// Load word counts on init (for binder display)
async function loadWordCounts() {
try {
const res = await fetch('/api/contenu');
if (res.status === 401) return;
const data = await res.json();
(data.list || []).forEach(r => {
if (r.SceneId && r.Mots) wordCounts[r.SceneId] = r.Mots;
});
} catch (e) { /* silent */ }
}
loadWordCounts();
</script>
</body>
</html>