diff --git a/public/index.html b/public/index.html index d0efa8d..d443296 100644 --- a/public/index.html +++ b/public/index.html @@ -6,6 +6,8 @@ Tableau de bord — RdB + + @@ -312,6 +357,7 @@ +
@@ -415,6 +461,26 @@
+ +
+
+
+
+
Choisir une scène dans le Binder
+ +
+
+
+
Enregistré
+
Enregistré
@@ -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 = `${esc(scene.Title)}${wc ? `${wc}` : ''}`; + 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 || '

'; + 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 || '

', + 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 = 'Éditeur simplifié — TipTap non chargé'; +} + +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(); diff --git a/server.js b/server.js index f6b6ca6..0a7cfdf 100644 --- a/server.js +++ b/server.js @@ -15,6 +15,7 @@ const NOCODB_TOKEN = process.env.NOCODB_TOKEN || ''; const NOCODB_BASE_ID = process.env.NOCODB_BASE_ID || ''; const NOCODB_TABLE_ID = process.env.NOCODB_TABLE_ID || ''; const NOCODB_VISION_TABLE_ID = process.env.NOCODB_VISION_TABLE_ID || ''; +const NOCODB_CONTENU_TABLE_ID = process.env.NOCODB_CONTENU_TABLE_ID || ''; // Auth function makeToken(ts) { @@ -173,6 +174,71 @@ app.patch('/api/vision/:id', requireAuth, async (req, res) => { } }); +// ── Contenu API (scene text for Rédaction view) ── + +// List all word counts (for binder display) +app.get('/api/contenu', requireAuth, async (req, res) => { + try { + const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?limit=200&fields=SceneId,Mots`; + const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); + const data = await r.json(); + res.json(data); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// Get content for a scene +app.get('/api/contenu/:sceneId', requireAuth, async (req, res) => { + try { + const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${req.params.sceneId})&limit=1`; + const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } }); + const data = await r.json(); + const row = data.list?.[0] || null; + res.json(row); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// Save content for a scene (create or update) +app.put('/api/contenu/:sceneId', requireAuth, async (req, res) => { + try { + const sceneId = Number(req.params.sceneId); + // Check if row exists + const findUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${sceneId})&limit=1`; + const findR = await fetch(findUrl, { headers: { 'xc-token': NOCODB_TOKEN } }); + const findData = await findR.json(); + const existing = findData.list?.[0]; + + const payload = { SceneId: sceneId, Texte: req.body.Texte, Mots: req.body.Mots || 0 }; + + if (existing) { + // Update + const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}/${existing.Id}`; + const r = await fetch(url, { + method: 'PATCH', + headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await r.json(); + res.json(data); + } else { + // Create + const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}`; + const r = await fetch(url, { + method: 'POST', + headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await r.json(); + res.status(r.status).json(data); + } + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + // Auth gate — redirect to login page if not authenticated app.get('/', (req, res) => { if (isAuth(req)) {