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
+66
View File
@@ -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)) {