Vision éditable — contenu chargé depuis NocoDB

- Nouvelle table NocoDB Vision (9 sections : trois-mots, phrase, synopsis, tags, listes, inspirations)
- Routes API /api/vision GET + PATCH dans server.js
- Frontend : contenteditable sur les champs texte, sauvegarde auto au blur
- Tags, listes et inspirations : ajout/suppression/édition en place
- Indicateur "Enregistré" discret en bas à droite
- Variable NOCODB_VISION_TABLE_ID ajoutée

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-05-29 08:49:26 +02:00
parent 8f3635908d
commit 0574b9ab1e
2 changed files with 335 additions and 52 deletions
+304 -52
View File
@@ -245,6 +245,33 @@
.vision-quote { font-family: 'Playfair Display', serif; font-size: 17px; font-style: italic; color: var(--text-muted); line-height: 1.7; border-left: 3px solid var(--accent); padding-left: 16px; margin: 12px 0; }
/* ── Editable Vision ── */
[contenteditable] { outline: none; border-radius: 6px; transition: background 0.15s, box-shadow 0.15s; cursor: text; }
[contenteditable]:hover { background: rgba(138,112,96,0.06); }
[contenteditable]:focus { background: rgba(138,112,96,0.08); box-shadow: 0 0 0 2px var(--accent); }
.dark [contenteditable]:hover { background: rgba(196,168,130,0.08); }
.dark [contenteditable]:focus { background: rgba(196,168,130,0.1); }
[contenteditable].placeholder { color: var(--text-light); font-style: italic; }
.vision-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; }
.vision-saving.show { opacity: 1; }
.tag-remove { display: none; margin-left: 4px; cursor: pointer; font-size: 11px; opacity: 0.5; }
.tag:hover .tag-remove { display: inline; }
.tag-remove:hover { opacity: 1; }
.tag-add { padding: 8px 16px; border-radius: 20px; font-size: 14px; border: 1px dashed var(--border); background: transparent; color: var(--text-hint); cursor: pointer; font-family: 'Lora', serif; }
.tag-add:hover { background: var(--bg-alt); color: var(--text); }
.list-item-text { flex: 1; }
.list-item-remove { display: none; cursor: pointer; color: var(--text-hint); font-size: 14px; margin-left: 8px; }
.list-item:hover .list-item-remove { display: inline; }
.list-item-remove:hover { color: #8b3030; }
.list-add { padding: 6px 0; font-size: 14px; color: var(--text-hint); cursor: pointer; display: flex; align-items: center; gap: 6px; }
.list-add:hover { color: var(--accent); }
.inspi-card-remove { position: absolute; top: 4px; right: 4px; display: none; background: rgba(139,48,48,0.8); color: #fff; border: none; border-radius: 50%; width: 20px; height: 20px; font-size: 12px; cursor: pointer; align-items: center; justify-content: center; }
.inspi-card:hover .inspi-card-remove { display: flex; }
.inspi-card { position: relative; }
/* Responsive */
@media (max-width: 768px) {
.sidebar { display: none; }
@@ -313,44 +340,25 @@
<div class="hero-banner">
<div class="hero-overlay">
<div class="three-words" style="color:rgba(255,255,255,0.5); font-style:italic;">Les trois mots qui définissent votre univers...</div>
<div class="hero-sub">Image d'ambiance &mdash; cliquer pour remplacer</div>
<div class="three-words" id="v-trois-mots" contenteditable="true"></div>
<div class="hero-sub" id="v-hero-sub" contenteditable="true"></div>
</div>
</div>
<div class="v-card full">
<h3>L'histoire en une phrase</h3>
<div class="vision-phrase" style="color:var(--text-light); font-style:italic;">Cliquez pour écrire la phrase qui résume votre histoire...</div>
<div class="vision-detail" style="color:var(--text-light); font-style:italic;">Modifiable à tout moment par les deux auteurs</div>
<div class="vision-phrase" id="v-phrase" contenteditable="true"></div>
<div class="vision-detail" id="v-phrase-detail" contenteditable="true"></div>
</div>
<div class="v-card full">
<h3>Synopsis</h3>
<div class="vision-detail" style="color:var(--text-light); font-style:italic;">Développez ici le résumé de votre histoire en quelques paragraphes &mdash; les grandes lignes, les enjeux, l'arc principal...</div>
<div class="vision-detail" style="color:var(--text-light); font-style:italic; margin-top:8px;">Peut évoluer au fil de l'écriture</div>
<div class="vision-detail" id="v-synopsis" contenteditable="true" style="min-height:80px;"></div>
</div>
<div class="v-card">
<h3>Ambiance, époque, rythme</h3>
<div class="tag-group">
<span class="tag ambiance">Ambiance sombre</span>
<span class="tag epoque">Époque fictive</span>
<span class="tag rythme">Rythme lent puis rapide</span>
<span class="tag genre">Aventure</span>
<span class="tag genre">Drame</span>
<span class="tag ambiance">Suspense</span>
</div>
</div>
<div class="v-card">
<h3>Moodboard</h3>
<div style="display:grid; grid-template-columns:repeat(5,1fr); gap:10px;">
<div style="aspect-ratio:1; border-radius:10px; background:linear-gradient(135deg, var(--accent), var(--brown)); position:relative; overflow:hidden;"><span style="position:absolute;bottom:8px;left:8px;font-size:12px;color:#fff;background:rgba(44,24,16,0.5);padding:3px 8px;border-radius:4px;">Ambiance nuit</span></div>
<div style="aspect-ratio:1; border-radius:10px; background:linear-gradient(135deg, #a07850, #8a6840); position:relative; overflow:hidden;"><span style="position:absolute;bottom:8px;left:8px;font-size:12px;color:#fff;background:rgba(44,24,16,0.5);padding:3px 8px;border-radius:4px;">Architecture</span></div>
<div style="aspect-ratio:1; border-radius:10px; background:linear-gradient(135deg, #7a8a5a, #5a6a40); position:relative; overflow:hidden;"><span style="position:absolute;bottom:8px;left:8px;font-size:12px;color:#fff;background:rgba(44,24,16,0.5);padding:3px 8px;border-radius:4px;">Forêt</span></div>
<div style="aspect-ratio:1; border-radius:10px; background:linear-gradient(135deg, var(--terra), #a07050); position:relative; overflow:hidden;"><span style="position:absolute;bottom:8px;left:8px;font-size:12px;color:#fff;background:rgba(44,24,16,0.5);padding:3px 8px;border-radius:4px;">Costumes</span></div>
<div style="aspect-ratio:1; border-radius:10px; border:2px dashed var(--border); display:flex; align-items:center; justify-content:center;"><span style="color:var(--text-hint); font-size:24px;">+</span></div>
</div>
<div class="tag-group" id="v-tags"></div>
</div>
<div class="v-card">
@@ -358,47 +366,24 @@
<div class="two-cols">
<div class="list-block">
<h4 class="yes">On veut</h4>
<div class="list-item yes"><span class="bullet"></span> Des personnages profonds</div>
<div class="list-item yes"><span class="bullet"></span> Des retournements crédibles</div>
<div class="list-item yes"><span class="bullet"></span> Un univers immersif</div>
<div id="v-on-veut"></div>
</div>
<div class="list-block">
<h4 class="no">On ne veut pas</h4>
<div class="list-item no"><span class="bullet"></span> Un ton Young Adult</div>
<div class="list-item no"><span class="bullet"></span> Des coïncidences faciles</div>
<div class="list-item no"><span class="bullet"></span> De la violence gratuite</div>
<div id="v-on-ne-veut-pas"></div>
</div>
</div>
</div>
<div class="v-card">
<h3>Inspirations et références</h3>
<div class="inspi-grid">
<div class="inspi-card">
<div class="inspi-icon">&#128214;</div>
<div class="inspi-title">Roman A</div>
<div class="inspi-type">Livre</div>
</div>
<div class="inspi-card">
<div class="inspi-icon">&#127916;</div>
<div class="inspi-title">Film B</div>
<div class="inspi-type">Film</div>
</div>
<div class="inspi-card">
<div class="inspi-icon">&#127912;</div>
<div class="inspi-title">Tableau C</div>
<div class="inspi-type">Peinture</div>
</div>
<div class="inspi-card" style="border:1px dashed var(--border); background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center;">
<div style="font-size:24px; color:var(--text-hint);">+</div>
<div class="inspi-type">Ajouter</div>
</div>
</div>
<div class="inspi-grid" id="v-inspirations"></div>
</div>
</div>
</div>
</div>
<div class="vision-saving" id="vision-saving">Enregistré</div>
</div>
</div>
@@ -836,6 +821,273 @@ document.addEventListener('keydown', e => {
});
loadData();
// ══════════════════════════════════════
// ══ VISION — Editable sections ══
// ══════════════════════════════════════
let visionData = {}; // section -> {Id, Contenu}
const PLACEHOLDERS = {
'trois-mots': 'Les trois mots qui définissent votre univers...',
'hero-sous-titre': 'Sous-titre ou accroche...',
'phrase': 'Cliquez pour écrire la phrase qui résume votre histoire...',
'phrase-detail': 'Un détail, une nuance, une précision...',
'synopsis': 'Développez ici le résumé de votre histoire en quelques paragraphes...',
};
const FIELD_MAP = {
'trois-mots': 'v-trois-mots',
'hero-sous-titre': 'v-hero-sub',
'phrase': 'v-phrase',
'phrase-detail': 'v-phrase-detail',
'synopsis': 'v-synopsis',
};
function showSaved() {
const el = document.getElementById('vision-saving');
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 1200);
}
async function saveVisionSection(section, contenu) {
const row = visionData[section];
if (!row) return;
await fetch(`/api/vision/${row.Id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Contenu: contenu }),
});
row.Contenu = contenu;
showSaved();
}
function setupEditable(elId, section) {
const el = document.getElementById(elId);
if (!el) return;
const placeholder = PLACEHOLDERS[section] || '';
function updatePlaceholder() {
const text = el.textContent.trim();
if (!text || text === placeholder) {
el.classList.add('placeholder');
if (!text) el.textContent = placeholder;
} else {
el.classList.remove('placeholder');
}
}
el.addEventListener('focus', () => {
if (el.classList.contains('placeholder')) {
el.textContent = '';
el.classList.remove('placeholder');
}
});
el.addEventListener('blur', () => {
const text = el.textContent.trim();
if (text && text !== placeholder) {
const row = visionData[section];
if (row && row.Contenu !== text) {
saveVisionSection(section, text);
}
} else {
el.textContent = placeholder;
el.classList.add('placeholder');
const row = visionData[section];
if (row && row.Contenu) {
saveVisionSection(section, '');
}
}
});
// Prevent Enter creating divs in single-line fields
if (section !== 'synopsis') {
el.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); el.blur(); }
});
}
updatePlaceholder();
}
// ── Tags ──
const TAG_TYPES = ['ambiance', 'epoque', 'rythme', 'genre', 'theme'];
function renderTags() {
const container = document.getElementById('v-tags');
const row = visionData['tags'];
let tags = [];
try { tags = JSON.parse(row?.Contenu || '[]'); } catch { tags = []; }
container.innerHTML = '';
tags.forEach((t, i) => {
const span = document.createElement('span');
span.className = `tag ${t.type || 'ambiance'}`;
span.innerHTML = `${esc(t.label)}<span class="tag-remove" onclick="removeTag(${i})">&times;</span>`;
container.appendChild(span);
});
const addBtn = document.createElement('button');
addBtn.className = 'tag-add';
addBtn.textContent = '+ Ajouter';
addBtn.onclick = () => {
const label = prompt('Nouveau tag :');
if (!label) return;
const type = prompt('Type (ambiance, epoque, rythme, genre, theme) :', 'genre') || 'genre';
tags.push({ label, type });
saveVisionSection('tags', JSON.stringify(tags));
renderTags();
};
container.appendChild(addBtn);
}
function removeTag(index) {
const row = visionData['tags'];
let tags = [];
try { tags = JSON.parse(row?.Contenu || '[]'); } catch { return; }
tags.splice(index, 1);
saveVisionSection('tags', JSON.stringify(tags));
renderTags();
}
// ── Lists (on-veut / on-ne-veut-pas) ──
function renderList(section, containerId, cssClass) {
const container = document.getElementById(containerId);
const row = visionData[section];
let items = [];
try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; }
container.innerHTML = '';
items.forEach((text, i) => {
const div = document.createElement('div');
div.className = `list-item ${cssClass}`;
div.innerHTML = `<span class="bullet"></span><span class="list-item-text" contenteditable="true">${esc(text)}</span><span class="list-item-remove" onclick="removeListItem('${section}','${containerId}','${cssClass}',${i})">&times;</span>`;
const textEl = div.querySelector('.list-item-text');
textEl.addEventListener('blur', () => {
const newText = textEl.textContent.trim();
if (newText && newText !== items[i]) {
items[i] = newText;
saveVisionSection(section, JSON.stringify(items));
}
});
textEl.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); textEl.blur(); }
});
container.appendChild(div);
});
const addEl = document.createElement('div');
addEl.className = 'list-add';
addEl.innerHTML = '<span>+</span> Ajouter';
addEl.onclick = () => {
const text = prompt('Nouvel élément :');
if (!text) return;
items.push(text);
saveVisionSection(section, JSON.stringify(items));
renderList(section, containerId, cssClass);
};
container.appendChild(addEl);
}
function removeListItem(section, containerId, cssClass, index) {
const row = visionData[section];
let items = [];
try { items = JSON.parse(row?.Contenu || '[]'); } catch { return; }
items.splice(index, 1);
saveVisionSection(section, JSON.stringify(items));
renderList(section, containerId, cssClass);
}
// ── Inspirations ──
const INSPI_ICONS = { 'Livre': '\u{1F4D6}', 'Film': '\u{1F3AC}', 'Peinture': '\u{1F3A8}', 'Musique': '\u{1F3B5}', 'Série': '\u{1F4FA}', 'Jeu': '\u{1F3AE}', 'Autre': '\u{2728}' };
function renderInspirations() {
const container = document.getElementById('v-inspirations');
const row = visionData['inspirations'];
let items = [];
try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; }
container.innerHTML = '';
items.forEach((item, i) => {
const card = document.createElement('div');
card.className = 'inspi-card';
card.innerHTML = `
<button class="inspi-card-remove" onclick="event.stopPropagation();removeInspiration(${i})">&times;</button>
<div class="inspi-icon">${item.icon || INSPI_ICONS[item.type] || '\u{2728}'}</div>
<div class="inspi-title" contenteditable="true">${esc(item.title)}</div>
<div class="inspi-type">${esc(item.type)}</div>
`;
const titleEl = card.querySelector('.inspi-title');
titleEl.addEventListener('blur', () => {
const newTitle = titleEl.textContent.trim();
if (newTitle && newTitle !== items[i].title) {
items[i].title = newTitle;
saveVisionSection('inspirations', JSON.stringify(items));
}
});
titleEl.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); titleEl.blur(); }
});
container.appendChild(card);
});
const addCard = document.createElement('div');
addCard.className = 'inspi-card';
addCard.style.cssText = 'border:1px dashed var(--border); background:transparent; display:flex; flex-direction:column; align-items:center; justify-content:center; cursor:pointer;';
addCard.innerHTML = '<div style="font-size:24px; color:var(--text-hint);">+</div><div class="inspi-type">Ajouter</div>';
addCard.onclick = () => {
const title = prompt('Titre de la référence :');
if (!title) return;
const type = prompt('Type (Livre, Film, Peinture, Musique, Série, Jeu, Autre) :', 'Livre') || 'Livre';
items.push({ title, type, icon: INSPI_ICONS[type] || '\u{2728}' });
saveVisionSection('inspirations', JSON.stringify(items));
renderInspirations();
};
container.appendChild(addCard);
}
function removeInspiration(index) {
const row = visionData['inspirations'];
let items = [];
try { items = JSON.parse(row?.Contenu || '[]'); } catch { return; }
items.splice(index, 1);
saveVisionSection('inspirations', JSON.stringify(items));
renderInspirations();
}
// ── Load Vision data ──
async function loadVision() {
try {
const res = await fetch('/api/vision');
if (res.status === 401) return;
const data = await res.json();
const rows = data.list || [];
// Index by section
visionData = {};
rows.forEach(r => { visionData[r.Section] = r; });
// Populate text fields
for (const [section, elId] of Object.entries(FIELD_MAP)) {
const el = document.getElementById(elId);
if (!el) continue;
const contenu = visionData[section]?.Contenu || '';
el.textContent = contenu || PLACEHOLDERS[section] || '';
setupEditable(elId, section);
}
// Render structured sections
renderTags();
renderList('on-veut', 'v-on-veut', 'yes');
renderList('on-ne-veut-pas', 'v-on-ne-veut-pas', 'no');
renderInspirations();
} catch (e) {
console.error('Vision load error:', e);
}
}
loadVision();
</script>
</body>
</html>
+31
View File
@@ -14,6 +14,7 @@ const NOCODB_URL = process.env.NOCODB_URL || 'https://nocodb.hub.delvarre.net';
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 || '';
// Auth
function makeToken(ts) {
@@ -142,6 +143,36 @@ app.post('/api/reorder', requireAuth, async (req, res) => {
}
});
// ── Vision API ──
// List all vision sections
app.get('/api/vision', requireAuth, async (req, res) => {
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VISION_TABLE_ID}?limit=50&sort=Ordre`;
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 });
}
});
// Update a vision section
app.patch('/api/vision/:id', requireAuth, async (req, res) => {
try {
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_VISION_TABLE_ID}/${req.params.id}`;
const r = await fetch(url, {
method: 'PATCH',
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
});
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)) {