Initial commit — TdB RdB frise narrative

Express.js + NocoDB + SortableJS drag-and-drop
Design V2 (Playfair Display + Lora, palette chaude)
Auth cookie, proxy NocoDB, édition en modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-05-28 13:39:47 +02:00
commit ce95a02012
5 changed files with 675 additions and 0 deletions
+454
View File
@@ -0,0 +1,454 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Lora', Georgia, serif; background: #faf6f0; color: #2c1810; min-height: 100vh; }
/* Layout */
.app { display: flex; min-height: 100vh; }
/* Sidebar */
.sidebar { width: 240px; background: #f3ece2; border-right: 1px solid #e0d5c7; padding: 24px 0; flex-shrink: 0; display: flex; flex-direction: column; }
.sidebar-logo { padding: 0 22px 20px; font-family: 'Playfair Display', serif; font-size: 19px; font-weight: 700; color: #5c3d2e; }
.sidebar-project { padding: 10px 16px; margin: 0 14px 18px; background: #fff; border-radius: 10px; font-size: 14px; color: #5c3d2e; border: 1px solid #e0d5c7; }
.nav-item { padding: 10px 18px; margin: 2px 14px; border-radius: 10px; font-size: 15px; color: #8a7968; cursor: pointer; display: flex; align-items: center; gap: 10px; }
.nav-item:hover { background: #ebe3d7; color: #5c3d2e; }
.nav-item.active { background: #e8ddd0; color: #5c3d2e; font-weight: 500; }
.nav-icon { width: 32px; height: 32px; font-size: 16px; background: #e8ddd0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sidebar-footer { margin-top: auto; padding: 16px 22px; font-size: 11px; color: #a08a76; }
/* Main */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* Topbar */
.topbar { height: 54px; background: #fff; border-bottom: 1px solid #e0d5c7; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; flex-shrink: 0; }
.topbar-title { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.btn-new { padding: 7px 16px; background: #8a7060; color: #faf6f0; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; font-family: 'Lora', serif; cursor: pointer; }
.btn-new:hover { background: #5c3d2e; }
.avatar { width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600; color: #fff; }
.avatar.e { background: #5c3d2e; }
.avatar.m { background: #b85c3a; }
/* Frise kanban */
.frise { flex: 1; display: flex; gap: 0; overflow-x: auto; padding: 20px 16px; }
.column { flex: 1; min-width: 220px; max-width: 320px; display: flex; flex-direction: column; }
.column-header { font-family: 'Playfair Display', serif; font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; padding: 10px 12px; text-align: center; border-bottom: 3px solid; margin-bottom: 8px; }
.column[data-acte="Avant l'histoire"] .column-header { color: #6a7a8a; border-color: #6a7a8a; }
.column[data-acte="Acte 1"] .column-header { color: #5c3d2e; border-color: #5c3d2e; }
.column[data-acte="Acte 2"] .column-header { color: #b85c3a; border-color: #b85c3a; }
.column[data-acte="Acte 3"] .column-header { color: #7a5c40; border-color: #7a5c40; }
.column[data-acte="En attente"] .column-header { color: #a08a76; border-color: #e0d5c7; border-style: dashed; }
.card-list { flex: 1; min-height: 60px; padding: 4px 6px; }
/* Cards */
.card { background: #fff; border: 1px solid #e0d5c7; border-radius: 10px; padding: 14px 16px; margin-bottom: 8px; cursor: grab; border-left: 4px solid #e0d5c7; box-shadow: 0 1px 3px rgba(92,61,46,0.05); transition: box-shadow 0.15s; }
.card:hover { box-shadow: 0 3px 12px rgba(92,61,46,0.1); }
.card.sortable-ghost { opacity: 0.4; }
.card.sortable-chosen { box-shadow: 0 4px 16px rgba(92,61,46,0.15); }
.card-title { font-family: 'Playfair Display', serif; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
.card-summary { font-size: 12px; color: #6b5a4a; line-height: 1.4; margin-bottom: 8px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.card-meta { display: flex; gap: 6px; flex-wrap: wrap; }
.card-badge { font-size: 10px; padding: 2px 7px; border-radius: 8px; font-weight: 500; }
/* Type colors on card border */
.card[data-type="Scène"] { border-left-color: #5c3d2e; }
.card[data-type="Événement"] { border-left-color: #b85c3a; }
.card[data-type="Personnage"] { border-left-color: #7a8a5a; }
.card[data-type="Lieu"] { border-left-color: #6a7a8a; }
.card[data-type="Thème"] { border-left-color: #8a6a7a; }
.card[data-type="Élément d'univers"] { border-left-color: #c49060; }
/* Status badge colors */
.badge-germe { background: #f0ebe3; color: #8a7968; }
.badge-etudier { background: #f5f0e4; color: #a07850; }
.badge-posee { background: #eef3e8; color: #5a7a3a; }
.badge-abandonnee { background: #f5f2ef; color: #a08a76; text-decoration: line-through; }
.badge-conflit { background: #f5e4e4; color: #8b3030; }
/* Personnage badges */
.badge-perso { background: #faf6f0; color: #5c3d2e; border: 1px solid #e0d5c7; }
/* Modal */
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(44,24,16,0.4); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
.modal { background: #fff; border-radius: 16px; width: 600px; max-width: 92vw; max-height: 88vh; overflow-y: auto; box-shadow: 0 12px 40px rgba(44,24,16,0.2); }
.modal-header { padding: 24px 28px 0; display: flex; justify-content: space-between; align-items: start; }
.modal-close { background: none; border: none; font-size: 22px; color: #a08a76; cursor: pointer; padding: 4px 8px; }
.modal-close:hover { color: #5c3d2e; }
.modal-body { padding: 16px 28px 28px; }
.modal-body .field { margin-bottom: 18px; }
.modal-body .field label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: #a08a76; font-weight: 600; margin-bottom: 6px; }
.modal-body .field input,
.modal-body .field textarea,
.modal-body .field select { width: 100%; padding: 10px 14px; border: 1px solid #e0d5c7; border-radius: 8px; font-size: 14px; font-family: 'Lora', serif; color: #2c1810; outline: none; background: #faf6f0; }
.modal-body .field input:focus,
.modal-body .field textarea:focus,
.modal-body .field select:focus { border-color: #8a7060; }
.modal-body .field textarea { min-height: 80px; resize: vertical; line-height: 1.6; }
.modal-body .field-title input { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; border: none; background: transparent; padding: 0; }
.modal-body .field-title input:focus { border-bottom: 2px solid #8a7060; }
.modal-body .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.multi-select-container { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px; border: 1px solid #e0d5c7; border-radius: 8px; background: #faf6f0; min-height: 40px; }
.multi-select-container .ms-tag { font-size: 12px; padding: 4px 10px; border-radius: 6px; background: #e8ddd0; color: #5c3d2e; cursor: pointer; }
.multi-select-container .ms-tag.selected { background: #5c3d2e; color: #faf6f0; }
.modal-actions { display: flex; gap: 10px; justify-content: space-between; margin-top: 8px; }
.btn-save { padding: 10px 24px; background: #8a7060; color: #faf6f0; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; font-family: 'Lora', serif; cursor: pointer; }
.btn-save:hover { background: #5c3d2e; }
.btn-delete { padding: 10px 16px; background: transparent; color: #8b3030; border: 1px solid #e8c4c0; border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; cursor: pointer; }
.btn-delete:hover { background: #fdf0ef; }
/* Loading */
.loading { text-align: center; padding: 60px; color: #a08a76; font-style: italic; }
/* Responsive */
@media (max-width: 768px) {
.sidebar { display: none; }
.frise { padding: 12px 8px; }
.column { min-width: 180px; }
.modal { width: 100%; max-width: 100%; border-radius: 12px 12px 0 0; max-height: 92vh; }
}
</style>
</head>
<body>
<div class="app">
<div class="sidebar">
<div class="sidebar-logo">Tableau de bord</div>
<div class="sidebar-project">Projet RdB</div>
<div class="nav-item active"><span class="nav-icon">&#9203;</span> Frise</div>
<div class="sidebar-footer">Etienne & Myriam</div>
</div>
<div class="main">
<div class="topbar">
<div class="topbar-title">Frise</div>
<div class="topbar-right">
<button class="btn-new" onclick="openNew()">+ Nouvelle idee</button>
<div class="avatar e">E</div>
<div class="avatar m">M</div>
</div>
</div>
<div class="frise" id="frise">
<div class="loading" id="loading">Chargement...</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
<span style="font-family:'Playfair Display',serif;font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#a08a76;" id="modal-label">Nouvelle idee</span>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="field field-title">
<input type="text" id="f-title" placeholder="Titre de l'idee...">
</div>
<div class="field-row">
<div class="field">
<label>Type</label>
<select id="f-type"><option value=""></option></select>
</div>
<div class="field">
<label>Statut</label>
<select id="f-statut"><option value=""></option></select>
</div>
</div>
<div class="field-row">
<div class="field">
<label>Acte</label>
<select id="f-acte"><option value=""></option></select>
</div>
<div class="field">
<label>Personnages</label>
<div class="multi-select-container" id="f-perso"></div>
</div>
</div>
<div class="field">
<label>Resume</label>
<textarea id="f-resume" placeholder="Description, resume de la scene ou de l'idee..."></textarea>
</div>
<div class="field">
<label>Interet narratif</label>
<textarea id="f-interet" placeholder="Pourquoi cette idee est importante pour l'histoire..."></textarea>
</div>
<div class="field">
<label>Implications</label>
<textarea id="f-implications" placeholder="Consequences dans l'histoire..."></textarea>
</div>
<div class="field">
<label>Origine</label>
<textarea id="f-origine" placeholder="D'ou vient cette idee..."></textarea>
</div>
<div class="field">
<label>Lien redaction</label>
<textarea id="f-lien" placeholder="Notes pour la redaction..."></textarea>
</div>
<div class="modal-actions">
<button class="btn-delete" id="btn-delete" onclick="deleteCard()" style="display:none;">Supprimer</button>
<button class="btn-save" onclick="saveCard()">Enregistrer</button>
</div>
</div>
</div>
</div>
<script>
const ACTES = ["Avant l'histoire", "Acte 1", "Acte 2", "Acte 3", "En attente"];
let records = [];
let columns = {};
let editingId = null;
const statusClass = s => {
if (!s) return '';
const map = { 'Germe': 'badge-germe', 'À étudier': 'badge-etudier', 'Posée': 'badge-posee', 'Abandonnée': 'badge-abandonnee', 'En conflit': 'badge-conflit' };
return map[s] || 'badge-germe';
};
function renderFrise() {
const frise = document.getElementById('frise');
frise.innerHTML = '';
ACTES.forEach(acte => {
const col = document.createElement('div');
col.className = 'column';
col.dataset.acte = acte;
const header = document.createElement('div');
header.className = 'column-header';
header.textContent = acte;
col.appendChild(header);
const list = document.createElement('div');
list.className = 'card-list';
list.dataset.acte = acte;
const acteRecords = records
.filter(r => (r.Acte || 'En attente') === acte)
.sort((a, b) => (a.Ordre || 0) - (b.Ordre || 0));
acteRecords.forEach(r => {
const card = document.createElement('div');
card.className = 'card';
card.dataset.id = r.Id;
card.dataset.type = r.Type || '';
card.onclick = () => openEdit(r.Id);
const persos = r.Personnage ? r.Personnage.split(',').map(p =>
`<span class="card-badge badge-perso">${p.trim()}</span>`
).join('') : '';
card.innerHTML = `
<div class="card-title">${esc(r.Title || '')}</div>
${r['Résumé'] ? `<div class="card-summary">${esc(r['Résumé'])}</div>` : ''}
<div class="card-meta">
${r.Statut ? `<span class="card-badge ${statusClass(r.Statut)}">${esc(r.Statut)}</span>` : ''}
${r.Type ? `<span class="card-badge" style="background:#faf6f0;color:#5c3d2e;border:1px solid #e0d5c7;">${esc(r.Type)}</span>` : ''}
${persos}
</div>
`;
list.appendChild(card);
});
col.appendChild(list);
frise.appendChild(col);
// SortableJS
new Sortable(list, {
group: 'frise',
animation: 200,
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
onEnd: handleDrop,
});
});
}
async function handleDrop(evt) {
const cardEl = evt.item;
const newActe = evt.to.dataset.acte;
const cardId = Number(cardEl.dataset.id);
// Collect new order for all cards in the target column
const updates = [];
const cards = evt.to.querySelectorAll('.card');
cards.forEach((c, i) => {
updates.push({ id: Number(c.dataset.id), Acte: newActe, Ordre: i + 1 });
});
// Also reorder source column if different
if (evt.from !== evt.to) {
const srcCards = evt.from.querySelectorAll('.card');
srcCards.forEach((c, i) => {
updates.push({ id: Number(c.dataset.id), Acte: evt.from.dataset.acte, Ordre: i + 1 });
});
}
// Update local state
updates.forEach(u => {
const rec = records.find(r => r.Id === u.id);
if (rec) { rec.Acte = u.Acte; rec.Ordre = u.Ordre; }
});
// Persist
await fetch('/api/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
}
function populateSelects() {
['Type', 'Statut', 'Acte'].forEach(name => {
const sel = document.getElementById(`f-${name.toLowerCase()}`);
if (!sel || !columns[name]) return;
sel.innerHTML = '<option value="">—</option>';
columns[name].options.forEach(o => {
const opt = document.createElement('option');
opt.value = o; opt.textContent = o;
sel.appendChild(opt);
});
});
// Multi-select personnages
const container = document.getElementById('f-perso');
container.innerHTML = '';
if (columns.Personnage) {
columns.Personnage.options.forEach(p => {
const tag = document.createElement('span');
tag.className = 'ms-tag';
tag.textContent = p;
tag.onclick = () => tag.classList.toggle('selected');
container.appendChild(tag);
});
}
}
function openNew() {
editingId = null;
document.getElementById('modal-label').textContent = 'Nouvelle idee';
document.getElementById('f-title').value = '';
document.getElementById('f-type').value = '';
document.getElementById('f-statut').value = 'Germe';
document.getElementById('f-acte').value = 'En attente';
document.getElementById('f-resume').value = '';
document.getElementById('f-interet').value = '';
document.getElementById('f-implications').value = '';
document.getElementById('f-origine').value = '';
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('modal').classList.add('open');
}
function openEdit(id) {
const r = records.find(rec => rec.Id === id);
if (!r) return;
editingId = id;
document.getElementById('modal-label').textContent = 'Modifier';
document.getElementById('f-title').value = r.Title || '';
document.getElementById('f-type').value = r.Type || '';
document.getElementById('f-statut').value = r.Statut || '';
document.getElementById('f-acte').value = r.Acte || '';
document.getElementById('f-resume').value = r['Résumé'] || '';
document.getElementById('f-interet').value = r['Intérêt narratif'] || '';
document.getElementById('f-implications').value = r.Implications || '';
document.getElementById('f-origine').value = r.Origine || '';
document.getElementById('f-lien').value = r['Lien rédaction'] || '';
const selected = r.Personnage ? r.Personnage.split(',').map(s => s.trim()) : [];
document.querySelectorAll('#f-perso .ms-tag').forEach(t => {
t.classList.toggle('selected', selected.includes(t.textContent));
});
document.getElementById('btn-delete').style.display = 'block';
document.getElementById('modal').classList.add('open');
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
editingId = null;
}
async function saveCard() {
const persos = [];
document.querySelectorAll('#f-perso .ms-tag.selected').forEach(t => persos.push(t.textContent));
const data = {
Title: document.getElementById('f-title').value,
Type: document.getElementById('f-type').value || null,
Statut: document.getElementById('f-statut').value || null,
Acte: document.getElementById('f-acte').value || 'En attente',
Personnage: persos.join(',') || null,
'Résumé': document.getElementById('f-resume').value || null,
'Intérêt narratif': document.getElementById('f-interet').value || null,
Implications: document.getElementById('f-implications').value || null,
Origine: document.getElementById('f-origine').value || null,
'Lien rédaction': document.getElementById('f-lien').value || null,
};
if (editingId) {
await fetch(`/api/records/${editingId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
} else {
data.Ordre = records.filter(r => r.Acte === data.Acte).length + 1;
await fetch('/api/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
closeModal();
await loadData();
}
async function deleteCard() {
if (!editingId) return;
if (!confirm('Supprimer cette idee ?')) return;
await fetch(`/api/records/${editingId}`, { method: 'DELETE' });
closeModal();
await loadData();
}
async function loadData() {
const [recRes, colRes] = await Promise.all([
fetch('/api/records'),
fetch('/api/columns'),
]);
const recData = await recRes.json();
records = recData.list || [];
columns = await colRes.json();
populateSelects();
renderFrise();
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// Close modal on overlay click
document.getElementById('modal').addEventListener('click', e => {
if (e.target === document.getElementById('modal')) closeModal();
});
// Close modal on Escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
// Init
loadData();
</script>
</body>
</html>