Historique des versions — frise + rédaction

Table NocoDB Versions (Type, RecordId, Snapshot, Label).
Snapshots auto avant chaque modification de fiche frise et de texte rédigé.
Archive avant suppression de fiche. Panneau latéral avec aperçu et restauration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-06-03 14:34:17 +02:00
parent e52878d599
commit 754bc47abd
2 changed files with 291 additions and 7 deletions
+168 -1
View File
@@ -417,7 +417,7 @@ window.dispatchEvent(new Event('tiptap-loaded'));
.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-header { padding: 20px 32px 12px; border-bottom: 1px solid var(--border-light); display: flex; align-items: flex-start; gap: 12px; }
.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; }
@@ -468,6 +468,26 @@ window.dispatchEvent(new Event('tiptap-loaded'));
.dark .comment-mark.comment-e { background: rgba(138,170,90,0.2); border-bottom-color: rgba(138,170,90,0.4); }
.dark .comment-mark.comment-m { background: rgba(212,132,90,0.2); border-bottom-color: rgba(212,132,90,0.4); }
/* ── Version history panel ── */
.version-panel { position: fixed; top: 0; right: -420px; width: 400px; height: 100vh; background: var(--bg-card); border-left: 1px solid var(--border); z-index: 250; transition: right 0.3s ease; display: flex; flex-direction: column; box-shadow: -4px 0 20px var(--shadow); }
.version-panel.open { right: 0; }
.version-panel-header { padding: 20px 24px 16px; border-bottom: 1px solid var(--border-light); display: flex; justify-content: space-between; align-items: center; }
.version-panel-header h3 { font-family: 'Playfair Display', serif; font-size: 16px; font-weight: 600; }
.version-panel-list { flex: 1; overflow-y: auto; padding: 12px; }
.version-item { padding: 14px 16px; border: 1px solid var(--border-light); border-radius: 10px; margin-bottom: 10px; cursor: pointer; transition: border-color 0.2s; }
.version-item:hover { border-color: var(--accent); }
.version-item.selected { border-color: var(--accent); background: var(--bg-alt); }
.version-item-date { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.version-item-label { font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.version-item-preview { font-size: 12px; color: var(--text-hint); line-height: 1.5; max-height: 60px; overflow: hidden; }
.version-preview-pane { padding: 16px 24px; border-top: 1px solid var(--border-light); max-height: 40vh; overflow-y: auto; }
.version-preview-pane h4 { font-family: 'Playfair Display', serif; font-size: 14px; margin-bottom: 10px; color: var(--text-muted); }
.version-preview-content { font-size: 13px; line-height: 1.6; }
.version-actions { padding: 12px 24px 20px; border-top: 1px solid var(--border-light); display: flex; gap: 10px; }
.version-actions button { flex: 1; }
.btn-history { background: none; border: 1px solid var(--border); padding: 6px 14px; border-radius: 8px; font-family: 'Lora', serif; font-size: 13px; color: var(--text-muted); cursor: pointer; transition: all 0.2s; }
.btn-history:hover { border-color: var(--accent); color: var(--text); }
.comment-popup { position: fixed; z-index: 300; background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 18px; box-shadow: 0 4px 20px var(--shadow-hover); max-width: 320px; min-width: 200px; }
.comment-popup-author { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.comment-popup-author.e { color: var(--olive); }
@@ -668,9 +688,12 @@ window.dispatchEvent(new Event('tiptap-loaded'));
<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 style="flex:1;">
<div class="editor-scene-title" id="editor-title"></div>
<div class="editor-scene-meta" id="editor-meta"></div>
</div>
<button class="btn-history" id="btn-history-redaction" onclick="openVersionPanel('redaction', currentSceneId)" style="display:none;">Historique</button>
</div>
<div class="editor-fiche" id="editor-fiche">
<div class="editor-fiche-toggle" onclick="toggleFiche()">
<span class="editor-fiche-toggle-icon" id="fiche-toggle-icon">&#9656;</span>
@@ -793,6 +816,7 @@ window.dispatchEvent(new Event('tiptap-loaded'));
</div>
<div class="modal-actions">
<button class="btn-delete" id="btn-delete" onclick="deleteCard()" style="display:none;">Supprimer</button>
<button class="btn-history" id="btn-history-frise" onclick="openVersionPanel('frise', editingId)" style="display:none;">Historique</button>
<button class="btn-save" onclick="saveCard()">Enregistrer</button>
</div>
</div>
@@ -1290,6 +1314,7 @@ function openNew() {
document.getElementById('f-lien').value = '';
document.querySelectorAll('#f-perso .ms-tag').forEach(t => t.classList.remove('selected'));
document.getElementById('btn-delete').style.display = 'none';
document.getElementById('btn-history-frise').style.display = 'none';
document.getElementById('card-comments-section').style.display = 'none';
document.getElementById('card-comment-list').innerHTML = '';
document.getElementById('card-comment-input').value = '';
@@ -1317,6 +1342,7 @@ function openEdit(id) {
});
document.getElementById('btn-delete').style.display = 'block';
document.getElementById('btn-history-frise').style.display = '';
// Comments
const comments = parseComments(r.Commentaires);
@@ -2665,6 +2691,7 @@ async function openScene(scene) {
if (scene.Tags) meta.push(scene.Tags);
if (scene.Personnage) meta.push(scene.Personnage);
document.getElementById('editor-meta').textContent = meta.join(' · ');
document.getElementById('btn-history-redaction').style.display = '';
// Panneau fiche (intention)
buildFichePanel(scene);
@@ -3030,6 +3057,146 @@ document.addEventListener('click', (e) => {
closeCommentPopup();
}
});
// ── Version history ──
let versionType = null;
let versionRecordId = null;
let versionList = [];
let selectedVersionId = null;
async function openVersionPanel(type, recordId) {
if (!recordId) return;
versionType = type;
versionRecordId = recordId;
selectedVersionId = null;
document.getElementById('version-panel-title').textContent = type === 'frise' ? 'Historique — Fiche' : 'Historique — Texte';
document.getElementById('version-list').innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center;">Chargement...</div>';
document.getElementById('version-preview').style.display = 'none';
document.getElementById('version-actions').style.display = 'none';
document.getElementById('version-panel').classList.add('open');
try {
const r = await fetch(`/api/versions/${type}/${recordId}`);
const data = await r.json();
versionList = data.list || [];
renderVersionList();
} catch (e) {
document.getElementById('version-list').innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center;">Erreur de chargement</div>';
}
}
function closeVersionPanel() {
document.getElementById('version-panel').classList.remove('open');
versionType = null;
versionRecordId = null;
selectedVersionId = null;
}
function renderVersionList() {
const container = document.getElementById('version-list');
if (versionList.length === 0) {
container.innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center;font-style:italic;">Aucune version enregistrée pour le moment.<br>Les versions seront créées automatiquement à chaque modification.</div>';
return;
}
container.innerHTML = '';
for (const v of versionList) {
const div = document.createElement('div');
div.className = 'version-item';
div.dataset.id = v.Id;
const date = new Date(v.CreatedAt);
const dateStr = date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
let preview = '';
try {
const snap = JSON.parse(v.Snapshot);
if (versionType === 'frise') {
preview = [snap.Title, snap['Résumé']].filter(Boolean).join(' — ');
} else {
const tmp = document.createElement('div');
tmp.innerHTML = snap.Texte || '';
preview = tmp.textContent.substring(0, 150);
}
} catch (e) { preview = ''; }
const label = v.Label || '';
div.innerHTML = `<div class="version-item-date">${dateStr}</div>` +
(label ? `<div class="version-item-label">${label.replace(/</g, '&lt;')}</div>` : '') +
(preview ? `<div class="version-item-preview">${preview.replace(/</g, '&lt;').substring(0, 200)}</div>` : '');
div.onclick = () => selectVersion(v.Id);
container.appendChild(div);
}
}
function selectVersion(id) {
selectedVersionId = id;
document.querySelectorAll('.version-item').forEach(el => el.classList.toggle('selected', Number(el.dataset.id) === id));
const v = versionList.find(x => x.Id === id);
if (!v) return;
try {
const snap = JSON.parse(v.Snapshot);
const previewPane = document.getElementById('version-preview');
const content = document.getElementById('version-preview-content');
if (versionType === 'frise') {
const parts = [];
if (snap.Title) parts.push(`<strong>${snap.Title.replace(/</g, '&lt;')}</strong>`);
if (snap.Acte) parts.push(`<em>${snap.Acte}</em>`);
if (snap['Résumé']) parts.push(snap['Résumé'].replace(/</g, '&lt;'));
if (snap['Intérêt narratif']) parts.push('<br><strong>Intérêt :</strong> ' + snap['Intérêt narratif'].replace(/</g, '&lt;'));
if (snap.Tags) parts.push('<br><em>Tags : ' + snap.Tags.replace(/</g, '&lt;') + '</em>');
content.innerHTML = parts.join('<br>');
} else {
content.innerHTML = DOMPurify.sanitize(snap.Texte || '<em>Texte vide</em>');
}
previewPane.style.display = '';
document.getElementById('version-actions').style.display = '';
} catch (e) {
document.getElementById('version-preview').style.display = 'none';
}
}
async function restoreVersion() {
if (!selectedVersionId) return;
if (!confirm('Restaurer cette version ? L\u2019état actuel sera sauvegardé avant la restauration.')) return;
try {
const r = await fetch(`/api/versions/${selectedVersionId}/restore`, { method: 'POST' });
if (r.ok) {
closeVersionPanel();
if (versionType === 'frise') {
await loadData();
if (editingId) openEdit(editingId);
} else if (versionType === 'redaction' && currentSceneId) {
const scene = records.find(rec => rec.Id === currentSceneId);
if (scene) openScene(scene);
}
} else {
alert('Erreur lors de la restauration');
}
} catch (e) {
alert('Erreur : ' + e.message);
}
}
// Close popup on version panel click outside
document.addEventListener('click', (e) => {
const panel = document.getElementById('version-panel');
if (panel.classList.contains('open') && !panel.contains(e.target) && !e.target.closest('.btn-history')) {
closeVersionPanel();
}
});
</script>
<!-- Panneau Historique des versions -->
<div class="version-panel" id="version-panel">
<div class="version-panel-header">
<h3 id="version-panel-title">Historique</h3>
<button class="modal-close" onclick="closeVersionPanel()">&times;</button>
</div>
<div class="version-panel-list" id="version-list"></div>
<div class="version-preview-pane" id="version-preview" style="display:none;">
<h4>Aperçu</h4>
<div class="version-preview-content" id="version-preview-content"></div>
</div>
<div class="version-actions" id="version-actions" style="display:none;">
<button class="btn-save" onclick="restoreVersion()">Restaurer cette version</button>
</div>
</div>
</body>
</html>
+121 -4
View File
@@ -18,6 +18,27 @@ const NOCODB_VISION_TABLE_ID = process.env.NOCODB_VISION_TABLE_ID || '';
const NOCODB_CONTENU_TABLE_ID = process.env.NOCODB_CONTENU_TABLE_ID || '';
const NOCODB_PERSONNAGES_TABLE_ID = process.env.NOCODB_PERSONNAGES_TABLE_ID || '';
const NOCODB_RESSOURCES_TABLE_ID = process.env.NOCODB_RESSOURCES_TABLE_ID || '';
const NOCODB_VERSIONS_TABLE_ID = process.env.NOCODB_VERSIONS_TABLE_ID || '';
// ── Version history helper ──
async function saveVersion(type, recordId, snapshot, label) {
if (!NOCODB_VERSIONS_TABLE_ID) return;
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}`;
await fetch(url, {
method: 'POST',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({
Type: type,
RecordId: recordId,
Snapshot: JSON.stringify(snapshot),
Label: label || '',
}),
});
} catch (e) {
console.error('saveVersion error:', e.message);
}
}
// Auth
function makeToken(ts) {
@@ -77,10 +98,19 @@ app.post('/api/records', requireAuth, async (req, res) => {
}
});
// Proxy NocoDB — update record
// Proxy NocoDB — update record (with version snapshot)
app.patch('/api/records/:id', requireAuth, async (req, res) => {
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${req.params.id}`;
const recordId = Number(req.params.id);
// Snapshot current state before update
const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`;
const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
if (getR.ok) {
const current = await getR.json();
await saveVersion('frise', recordId, current, current.Titre || '');
}
// Proceed with update
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`;
const r = await fetch(url, {
method: 'PATCH',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
@@ -93,10 +123,18 @@ app.patch('/api/records/:id', requireAuth, async (req, res) => {
}
});
// Proxy NocoDB — delete record
// Proxy NocoDB — delete record (archive before deletion)
app.delete('/api/records/:id', requireAuth, async (req, res) => {
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${req.params.id}`;
const recordId = Number(req.params.id);
// Archive before delete
const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`;
const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
if (getR.ok) {
const current = await getR.json();
await saveVersion('frise', recordId, current, `[supprimé] ${current.Titre || ''}`);
}
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${recordId}`;
const r = await fetch(url, {
method: 'DELETE',
headers: { 'xc-token': NOCODB_TOKEN },
@@ -243,6 +281,8 @@ app.put('/api/contenu/:sceneId', requireAuth, async (req, res) => {
const payload = { SceneId: sceneId, Texte: req.body.Texte, Mots: req.body.Mots || 0 };
if (existing) {
// Snapshot before update
await saveVersion('redaction', sceneId, { Texte: existing.Texte, Mots: existing.Mots, SceneId: sceneId }, `Scène #${sceneId}`);
// Update
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}/${existing.Id}`;
const r = await fetch(url, {
@@ -268,6 +308,83 @@ app.put('/api/contenu/:sceneId', requireAuth, async (req, res) => {
}
});
// ── Versions API ──
// List versions for a record
app.get('/api/versions/:type/:recordId', requireAuth, async (req, res) => {
if (!NOCODB_VERSIONS_TABLE_ID) return res.json({ list: [] });
try {
const { type, recordId } = req.params;
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}?where=(Type,eq,${type})~and(RecordId,eq,${recordId})&sort=-CreatedAt&limit=50`;
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 });
}
});
// Restore a frise version
app.post('/api/versions/:id/restore', requireAuth, async (req, res) => {
if (!NOCODB_VERSIONS_TABLE_ID) return res.status(400).json({ error: 'Versions non configurées' });
try {
// Get the version
const vUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VERSIONS_TABLE_ID}/${req.params.id}`;
const vR = await fetch(vUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
if (!vR.ok) return res.status(404).json({ error: 'Version introuvable' });
const version = await vR.json();
const snapshot = JSON.parse(version.Snapshot);
if (version.Type === 'frise') {
// Snapshot current before restoring
const getUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${version.RecordId}`;
const getR = await fetch(getUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
if (getR.ok) {
const current = await getR.json();
await saveVersion('frise', version.RecordId, current, `[avant restauration] ${current.Titre || ''}`);
}
// Restore — only user-editable fields
const { Titre, Resume, Personnage, Tags, Notes, Acte, Ordre } = snapshot;
const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_TABLE_ID}/${version.RecordId}`;
const r = await fetch(patchUrl, {
method: 'PATCH',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ Titre, Resume, Personnage, Tags, Notes, Acte, Ordre }),
});
res.json(await r.json());
} else if (version.Type === 'redaction') {
// Snapshot current text before restoring
const findUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${version.RecordId})&limit=1`;
const findR = await fetch(findUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
const findData = await findR.json();
const existing = findData.list?.[0];
if (existing) {
await saveVersion('redaction', version.RecordId, { Texte: existing.Texte, Mots: existing.Mots, SceneId: version.RecordId }, `[avant restauration] Scène #${version.RecordId}`);
const patchUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}/${existing.Id}`;
const r = await fetch(patchUrl, {
method: 'PATCH',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ Texte: snapshot.Texte, Mots: snapshot.Mots || 0 }),
});
res.json(await r.json());
} else {
// Re-create content row
const createUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}`;
const r = await fetch(createUrl, {
method: 'POST',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ SceneId: version.RecordId, Texte: snapshot.Texte, Mots: snapshot.Mots || 0 }),
});
res.json(await r.json());
}
} else {
res.status(400).json({ error: 'Type inconnu' });
}
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ── Personnages API ──
function personnagesProxy(tableIdVar) {