2477 lines
88 KiB
JavaScript
2477 lines
88 KiB
JavaScript
const ACTES_FRISE = ["Prologue", "Début", "Milieu", "Fin", "Épilogue"];
|
|
const STATUT_TAGS = ['À revoir', 'À garder', 'À étudier'];
|
|
|
|
function getStatutTag(r) {
|
|
const tags = (r.Tags || '').split(',').map(s => s.trim());
|
|
return STATUT_TAGS.find(s => tags.includes(s)) || 'À étudier';
|
|
}
|
|
|
|
let records = [];
|
|
let columns = {};
|
|
let editingId = null;
|
|
|
|
// ── View switching ──
|
|
function switchView(name) {
|
|
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
document.getElementById('view-' + name).classList.add('active');
|
|
document.querySelectorAll('.nav-item[data-view]').forEach(n => n.classList.toggle('active', n.dataset.view === name));
|
|
try { localStorage.setItem('tdb-rdb-view', name); } catch(e) {}
|
|
const topTitle = document.querySelector('.topbar-title');
|
|
const btnNew = document.querySelector('.btn-new');
|
|
// Hide topbar new button (each page has its own)
|
|
const topBtnNew = document.querySelector('.topbar-right > .btn-new');
|
|
if (topBtnNew) topBtnNew.style.display = 'none';
|
|
|
|
if (name === 'vision') {
|
|
topTitle.textContent = 'Vision';
|
|
} else if (name === 'redaction') {
|
|
topTitle.textContent = 'Rédaction';
|
|
buildBinder();
|
|
} else if (name === 'personnages') {
|
|
topTitle.textContent = 'Personnages';
|
|
loadPersonnages();
|
|
} else if (name === 'ressources') {
|
|
topTitle.textContent = 'Ressources';
|
|
loadRessources();
|
|
} else {
|
|
topTitle.textContent = 'Frise';
|
|
if (topBtnNew) topBtnNew.style.display = '';
|
|
}
|
|
}
|
|
|
|
// ── Dark mode ──
|
|
function toggleDark() {
|
|
document.body.classList.toggle('dark');
|
|
localStorage.setItem('tdb-dark', document.body.classList.contains('dark') ? '1' : '0');
|
|
}
|
|
if (localStorage.getItem('tdb-dark') === '1') document.body.classList.add('dark');
|
|
|
|
// ── Filters ──
|
|
function populateFilterOptions() {
|
|
// Collect unique personnages from data
|
|
const persoSet = new Set();
|
|
records.forEach(r => {
|
|
if (r.Personnage) r.Personnage.split(',').forEach(p => persoSet.add(p.trim()));
|
|
});
|
|
const persoSel = document.getElementById('filter-perso');
|
|
const curPerso = persoSel.value;
|
|
persoSel.innerHTML = '<option value="">Tous</option>';
|
|
[...persoSet].sort().forEach(p => {
|
|
const opt = document.createElement('option');
|
|
opt.value = p; opt.textContent = p;
|
|
persoSel.appendChild(opt);
|
|
});
|
|
persoSel.value = curPerso;
|
|
|
|
// Tags from data (free text, not from column options)
|
|
const tagSet = new Set();
|
|
records.forEach(r => {
|
|
if (r.Tags) r.Tags.split(',').forEach(t => { const trimmed = t.trim(); if (trimmed) tagSet.add(trimmed); });
|
|
});
|
|
const tagSel = document.getElementById('filter-tag');
|
|
const curTag = tagSel.value;
|
|
tagSel.innerHTML = '<option value="">Tous</option>';
|
|
[...tagSet].sort((a, b) => a.localeCompare(b, 'fr')).forEach(t => {
|
|
const opt = document.createElement('option');
|
|
opt.value = t; opt.textContent = t;
|
|
tagSel.appendChild(opt);
|
|
});
|
|
tagSel.value = curTag;
|
|
|
|
// Populate Rédaction filters (same data)
|
|
const redPersoSel = document.getElementById('red-filter-perso');
|
|
if (redPersoSel) {
|
|
const curRP = redPersoSel.value;
|
|
while (redPersoSel.options.length > 1) redPersoSel.remove(1);
|
|
[...persoSet].sort().forEach(p => {
|
|
const opt = document.createElement('option');
|
|
opt.value = p; opt.textContent = p;
|
|
redPersoSel.appendChild(opt);
|
|
});
|
|
redPersoSel.value = curRP;
|
|
}
|
|
const redTagSel = document.getElementById('red-filter-tag');
|
|
if (redTagSel) {
|
|
const curRT = redTagSel.value;
|
|
while (redTagSel.options.length > 1) redTagSel.remove(1);
|
|
[...tagSet].sort((a, b) => a.localeCompare(b, 'fr')).forEach(t => {
|
|
const opt = document.createElement('option');
|
|
opt.value = t; opt.textContent = t;
|
|
redTagSel.appendChild(opt);
|
|
});
|
|
redTagSel.value = curRT;
|
|
}
|
|
}
|
|
|
|
function applyFilters() {
|
|
// Re-render frise if sort changed (sort is applied during render)
|
|
renderFrise();
|
|
|
|
const perso = document.getElementById('filter-perso').value;
|
|
const tag = document.getElementById('filter-tag').value;
|
|
const search = document.getElementById('filter-search').value.toLowerCase().trim();
|
|
|
|
document.querySelectorAll('.card').forEach(card => {
|
|
const id = Number(card.dataset.id);
|
|
const r = records.find(rec => rec.Id === id);
|
|
if (!r) return;
|
|
|
|
let visible = true;
|
|
if (perso && !(r.Personnage || '').split(',').map(s => s.trim()).includes(perso)) visible = false;
|
|
if (tag && !(r.Tags || '').split(',').map(s => s.trim()).includes(tag)) visible = false;
|
|
if (search) {
|
|
const haystack = [r.Title, r['Résumé'], r.Personnage, r['Intérêt narratif'], r.Implications, r.Origine].filter(Boolean).join(' ').toLowerCase();
|
|
if (!haystack.includes(search)) visible = false;
|
|
}
|
|
|
|
card.classList.toggle('filtered-out', !visible);
|
|
});
|
|
|
|
// Resize acte sections based on visible cards
|
|
document.querySelectorAll('.acte-section').forEach(section => {
|
|
const visibleCards = section.querySelectorAll('.card:not(.filtered-out)').length;
|
|
const minW = Math.max(280, visibleCards * 274 + 20);
|
|
section.style.width = minW + 'px';
|
|
});
|
|
}
|
|
|
|
function clearFilters() {
|
|
document.getElementById('filter-perso').value = '';
|
|
document.getElementById('filter-tag').value = '';
|
|
document.getElementById('filter-search').value = '';
|
|
document.getElementById('filter-sort').value = 'ordre';
|
|
applyFilters();
|
|
}
|
|
|
|
const STATUS_TAGS = { 'À étudier': 'badge-etudier', 'Validée': 'badge-validee', 'Abandonnée': 'badge-abandonnee', 'En conflit': 'badge-conflit' };
|
|
const STATUS_COLORS = { 'À rédiger': '#c4a030', 'À compléter': '#3a7a9a', 'À revoir': '#8b3030' };
|
|
function tagBadgeClass(tagName) {
|
|
return STATUS_TAGS[tagName] || 'badge-tag';
|
|
}
|
|
|
|
function makeCard(r) {
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
card.dataset.id = r.Id;
|
|
card.dataset.acte = r.Acte || 'En attente';
|
|
card.onclick = () => openEdit(r.Id);
|
|
|
|
const persos = r.Personnage ? r.Personnage.split(',').map(p =>
|
|
`<span class="card-badge badge-perso">${esc(p.trim())}</span>`
|
|
).join('') : '';
|
|
|
|
const likeE = r.LikeE ? 'liked' : '';
|
|
const likeM = r.LikeM ? 'liked' : '';
|
|
const comments = parseComments(r.Commentaires);
|
|
const commentBadge = comments.length > 0 ? `<span class="card-comment-count"><span>✉</span>${comments.length}</span>` : '';
|
|
|
|
card.innerHTML = `
|
|
<div class="card-title">${esc(r.Title || '')}</div>
|
|
${r['Résumé'] ? `<div class="card-summary">${renderMarkdown(r['Résumé'])}</div>` : ''}
|
|
<div class="card-meta">
|
|
${(r.Tags || '').split(',').filter(t => t.trim()).map(t => `<span class="card-badge ${tagBadgeClass(t.trim())}">${esc(t.trim())}</span>`).join('')}
|
|
${persos}
|
|
${commentBadge}
|
|
<span class="card-likes">
|
|
<button class="like-btn ${likeE}" onclick="toggleLike(event,${r.Id},'E')" title="Etienne"><span class="like-who">E</span> ♥</button>
|
|
<button class="like-btn ${likeM}" onclick="toggleLike(event,${r.Id},'M')" title="Myriam"><span class="like-who">M</span> ♥</button>
|
|
</span>
|
|
</div>
|
|
`;
|
|
return card;
|
|
}
|
|
|
|
async function toggleLike(e, id, who) {
|
|
e.stopPropagation();
|
|
const r = records.find(r => r.Id === id);
|
|
if (!r) return;
|
|
const field = who === 'E' ? 'LikeE' : 'LikeM';
|
|
const newVal = r[field] ? 0 : 1;
|
|
r[field] = newVal;
|
|
renderFrise();
|
|
await fetch(`/api/records/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ [field]: newVal }),
|
|
});
|
|
}
|
|
|
|
// ── Card comments ──
|
|
let commentAuthor = 'E';
|
|
|
|
function parseComments(raw) {
|
|
if (!raw) return [];
|
|
try { return JSON.parse(raw); } catch { return []; }
|
|
}
|
|
|
|
function renderCardComments(comments) {
|
|
const list = document.getElementById('card-comment-list');
|
|
list.innerHTML = comments.map((c, i) => `
|
|
<div class="comment-item">
|
|
<div class="comment-header">
|
|
<span class="comment-author ${(c.auteur || 'E').toLowerCase()}">${c.auteur === 'M' ? 'Myriam' : 'Etienne'}</span>
|
|
<span class="comment-date">${esc(c.date || '')}</span>
|
|
<button class="comment-delete" onclick="deleteCardComment(${i})" title="Supprimer">×</button>
|
|
</div>
|
|
<div class="comment-text">${esc(c.texte || '')}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function addCardComment() {
|
|
if (!editingId) return;
|
|
const input = document.getElementById('card-comment-input');
|
|
const texte = input.value.trim();
|
|
if (!texte) return;
|
|
|
|
const r = records.find(r => r.Id === editingId);
|
|
if (!r) return;
|
|
|
|
const comments = parseComments(r.Commentaires);
|
|
const now = new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
comments.push({ auteur: commentAuthor, date: now, texte });
|
|
|
|
const json = JSON.stringify(comments);
|
|
r.Commentaires = json;
|
|
renderCardComments(comments);
|
|
input.value = '';
|
|
|
|
await fetch(`/api/records/${editingId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Commentaires: json }),
|
|
});
|
|
renderFrise();
|
|
}
|
|
|
|
async function deleteCardComment(index) {
|
|
if (!editingId) return;
|
|
const r = records.find(r => r.Id === editingId);
|
|
if (!r) return;
|
|
|
|
const comments = parseComments(r.Commentaires);
|
|
comments.splice(index, 1);
|
|
|
|
const json = comments.length > 0 ? JSON.stringify(comments) : null;
|
|
r.Commentaires = json;
|
|
renderCardComments(comments);
|
|
|
|
await fetch(`/api/records/${editingId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Commentaires: json || '' }),
|
|
});
|
|
renderFrise();
|
|
}
|
|
|
|
function toggleCommentAuthor() {
|
|
commentAuthor = commentAuthor === 'E' ? 'M' : 'E';
|
|
const btn = document.getElementById('comment-author-btn');
|
|
btn.textContent = commentAuthor === 'E' ? 'Etienne' : 'Myriam';
|
|
}
|
|
|
|
function sortRecords(a, b, mode) {
|
|
if (mode === 'date-desc') return new Date(b.CreatedAt || 0) - new Date(a.CreatedAt || 0);
|
|
if (mode === 'date-asc') return new Date(a.CreatedAt || 0) - new Date(b.CreatedAt || 0);
|
|
if (mode === 'alpha') return (a.Title || '').localeCompare(b.Title || '', 'fr');
|
|
return (a.Ordre || 0) - (b.Ordre || 0);
|
|
}
|
|
|
|
function renderFrise() {
|
|
const area = document.getElementById('timeline-area');
|
|
area.innerHTML = '';
|
|
|
|
// Build horizontal timeline
|
|
const container = document.createElement('div');
|
|
container.style.cssText = 'display:flex; min-width:min-content;';
|
|
|
|
const sortMode = document.getElementById('filter-sort')?.value || 'ordre';
|
|
|
|
ACTES_FRISE.forEach(acte => {
|
|
const acteRecords = records
|
|
.filter(r => (r.Acte || 'En attente') === acte)
|
|
.sort((a, b) => sortRecords(a, b, sortMode));
|
|
|
|
const section = document.createElement('div');
|
|
section.className = 'acte-section';
|
|
section.dataset.acte = acte;
|
|
|
|
section.innerHTML = `
|
|
<div class="acte-header"><span class="acte-label">${esc(acte)}</span></div>
|
|
<div class="timeline-segment" style="width:100%;height:3px;border-radius:2px;"></div>
|
|
`;
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'cards-row';
|
|
row.dataset.acte = acte;
|
|
|
|
acteRecords.forEach(r => row.appendChild(makeCard(r)));
|
|
|
|
section.appendChild(row);
|
|
container.appendChild(section);
|
|
|
|
// SortableJS on horizontal row
|
|
new Sortable(row, {
|
|
group: 'frise',
|
|
animation: 200,
|
|
direction: 'horizontal',
|
|
ghostClass: 'sortable-ghost',
|
|
chosenClass: 'sortable-chosen',
|
|
onEnd: handleDrop,
|
|
});
|
|
});
|
|
|
|
area.appendChild(container);
|
|
|
|
// Waiting zone — pipeline de stades
|
|
const waitingRecords = records.filter(r => !r.Acte || r.Acte === 'En attente');
|
|
|
|
STATUT_TAGS.forEach(statut => {
|
|
const slug = statut === 'À revoir' ? 'a-revoir' : statut === 'À garder' ? 'a-garder' : 'a-etudier';
|
|
const container = document.getElementById(`cards-${slug}`);
|
|
const counter = document.getElementById(`count-${slug}`);
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
|
|
const forStatut = waitingRecords
|
|
.filter(r => getStatutTag(r) === statut)
|
|
.sort((a, b) => sortRecords(a, b, sortMode));
|
|
|
|
forStatut.forEach(r => container.appendChild(makeCard(r)));
|
|
if (counter) counter.textContent = forStatut.length || '';
|
|
|
|
new Sortable(container, {
|
|
group: 'frise',
|
|
animation: 200,
|
|
direction: 'horizontal',
|
|
ghostClass: 'sortable-ghost',
|
|
chosenClass: 'sortable-chosen',
|
|
onEnd: handleDrop,
|
|
});
|
|
});
|
|
}
|
|
|
|
async function handleDrop(evt) {
|
|
const toContainer = evt.to;
|
|
const newActe = toContainer.dataset.acte || 'En attente';
|
|
|
|
// Collect new order for target
|
|
const updates = [];
|
|
const cards = toContainer.querySelectorAll('.card');
|
|
cards.forEach((c, i) => {
|
|
updates.push({ id: Number(c.dataset.id), Acte: newActe, Ordre: i + 1 });
|
|
});
|
|
|
|
// Reorder source if different
|
|
if (evt.from !== evt.to) {
|
|
const srcActe = evt.from.dataset.acte || 'En attente';
|
|
const srcCards = evt.from.querySelectorAll('.card');
|
|
srcCards.forEach((c, i) => {
|
|
updates.push({ id: Number(c.dataset.id), Acte: srcActe, 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; }
|
|
});
|
|
|
|
await fetch('/api/reorder', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates),
|
|
});
|
|
}
|
|
|
|
function populateSelects() {
|
|
['Acte'].forEach(name => {
|
|
const sel = document.getElementById(`f-${name.toLowerCase()}`);
|
|
if (!sel || !columns[name]) return;
|
|
sel.innerHTML = '';
|
|
columns[name].options.forEach(o => {
|
|
const opt = document.createElement('option');
|
|
opt.value = o; opt.textContent = o;
|
|
sel.appendChild(opt);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|
|
const addBtn = document.createElement('span');
|
|
addBtn.className = 'ms-add';
|
|
addBtn.textContent = '+';
|
|
addBtn.title = 'Ajouter un personnage';
|
|
addBtn.onclick = async () => {
|
|
const name = prompt('Nom du personnage :');
|
|
if (!name || !name.trim()) return;
|
|
const trimmed = name.trim();
|
|
if (columns.Personnage && columns.Personnage.options.includes(trimmed)) {
|
|
alert('Ce personnage existe déjà.');
|
|
return;
|
|
}
|
|
const res = await fetch('/api/columns/personnage', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: trimmed }),
|
|
});
|
|
if (res.ok) {
|
|
if (!columns.Personnage) columns.Personnage = { type: 'MultiSelect', options: [] };
|
|
columns.Personnage.options.push(trimmed);
|
|
const tag = document.createElement('span');
|
|
tag.className = 'ms-tag selected';
|
|
tag.textContent = trimmed;
|
|
tag.onclick = () => tag.classList.toggle('selected');
|
|
container.insertBefore(tag, addBtn);
|
|
} else {
|
|
alert('Erreur lors de l\'ajout.');
|
|
}
|
|
};
|
|
container.appendChild(addBtn);
|
|
}
|
|
|
|
function openNew() {
|
|
editingId = null;
|
|
document.getElementById('modal-label').textContent = 'Nouvelle idee';
|
|
document.getElementById('f-title').value = '';
|
|
renderModalTags(['À étudier']);
|
|
updateTagSuggestions();
|
|
document.getElementById('f-tags-input').value = '';
|
|
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('btn-history-frise').style.display = 'none';
|
|
document.getElementById('btn-rediger').style.display = 'none';
|
|
document.getElementById('card-comments-section').style.display = 'none';
|
|
document.getElementById('card-comment-list').innerHTML = '';
|
|
document.getElementById('card-comment-input').value = '';
|
|
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 || '';
|
|
renderModalTags((r.Tags || '').split(',').map(s => s.trim()).filter(Boolean));
|
|
updateTagSuggestions();
|
|
document.getElementById('f-tags-input').value = '';
|
|
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('btn-history-frise').style.display = '';
|
|
document.getElementById('btn-rediger').style.display = '';
|
|
|
|
// Comments
|
|
const comments = parseComments(r.Commentaires);
|
|
document.getElementById('card-comments-section').style.display = 'block';
|
|
renderCardComments(comments);
|
|
document.getElementById('card-comment-input').value = '';
|
|
|
|
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,
|
|
Tags: getModalTags().join(', ') || 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();
|
|
}
|
|
|
|
function goToRedaction(id) {
|
|
const scene = records.find(r => r.Id === id);
|
|
if (!scene) return;
|
|
closeModal();
|
|
switchView('redaction');
|
|
setTimeout(() => openScene(scene), 300);
|
|
}
|
|
|
|
async function loadData() {
|
|
const [recRes, colRes] = await Promise.all([
|
|
fetch('/api/records'),
|
|
fetch('/api/columns'),
|
|
]);
|
|
if (recRes.status === 401) { window.location.reload(); return; }
|
|
const recData = await recRes.json();
|
|
records = recData.list || [];
|
|
columns = await colRes.json();
|
|
populateSelects();
|
|
populateFilterOptions();
|
|
applyFilters();
|
|
// Restore last active view
|
|
try {
|
|
const saved = localStorage.getItem('tdb-rdb-view');
|
|
if (saved && document.getElementById('view-' + saved)) switchView(saved);
|
|
} catch(e) {}
|
|
}
|
|
|
|
function esc(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// Sanitize HTML string and return a DocumentFragment (safe for appendChild)
|
|
function createSanitizedFragment(html) {
|
|
const clean = typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(html) : esc(html);
|
|
const tpl = document.createElement('template');
|
|
tpl.innerHTML = clean;
|
|
return tpl.content;
|
|
}
|
|
|
|
// Render Markdown to sanitized HTML string
|
|
function renderMarkdown(text) {
|
|
if (!text) return '';
|
|
const raw = typeof marked !== 'undefined' ? marked.parse(text) : esc(text);
|
|
return typeof DOMPurify !== 'undefined' ? DOMPurify.sanitize(raw) : esc(text);
|
|
}
|
|
|
|
// ── Tags input ──
|
|
function getModalTags() {
|
|
return [...document.querySelectorAll('#f-tags .tag-chip')].map(c => c.dataset.tag);
|
|
}
|
|
|
|
const PINNED_TAGS = ['À revoir', 'À garder', 'À étudier', 'À rédiger', 'À compléter'];
|
|
|
|
function collectExistingTags() {
|
|
const tagSet = new Set(PINNED_TAGS);
|
|
records.forEach(r => {
|
|
if (r.Tags) r.Tags.split(',').forEach(t => { const trimmed = t.trim(); if (trimmed) tagSet.add(trimmed); });
|
|
});
|
|
return [...tagSet].sort((a, b) => a.localeCompare(b, 'fr'));
|
|
}
|
|
|
|
function updateTagSuggestions() {
|
|
const select = document.getElementById('f-tags-select');
|
|
const current = getModalTags();
|
|
const existing = collectExistingTags().filter(t => !current.includes(t));
|
|
select.innerHTML = '<option value="">Tags existants...</option>';
|
|
existing.forEach(t => {
|
|
const opt = document.createElement('option');
|
|
opt.value = t; opt.textContent = t;
|
|
select.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function addExistingTag() {
|
|
const select = document.getElementById('f-tags-select');
|
|
const val = select.value;
|
|
if (!val) return;
|
|
const current = getModalTags();
|
|
if (!current.includes(val)) {
|
|
renderModalTags([...current, val]);
|
|
}
|
|
select.value = '';
|
|
updateTagSuggestions();
|
|
}
|
|
|
|
function renderModalTags(tags) {
|
|
const container = document.getElementById('f-tags');
|
|
container.querySelectorAll('.tag-chip').forEach(c => c.remove());
|
|
const input = document.getElementById('f-tags-input');
|
|
(tags || []).forEach(tag => {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'tag-chip';
|
|
chip.dataset.tag = tag;
|
|
chip.innerHTML = `${esc(tag)}<span class="tag-chip-remove" onclick="this.parentElement.remove(); updateTagSuggestions();">×</span>`;
|
|
container.insertBefore(chip, input);
|
|
});
|
|
}
|
|
|
|
function addTag() {
|
|
const input = document.getElementById('f-tags-input');
|
|
const val = input.value.trim();
|
|
if (!val) return;
|
|
const current = getModalTags();
|
|
if (!current.includes(val)) {
|
|
renderModalTags([...current, val]);
|
|
}
|
|
input.value = '';
|
|
updateTagSuggestions();
|
|
}
|
|
|
|
document.getElementById('modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('modal')) closeModal();
|
|
});
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') {
|
|
closeModal();
|
|
if (typeof closePersoModal === 'function') closePersoModal();
|
|
if (typeof closeResModal === 'function') closeResModal();
|
|
}
|
|
});
|
|
|
|
// Zone En attente — toggles collapsibles par section
|
|
(function initWaitingToggles() {
|
|
['a-revoir', 'a-garder', 'a-etudier'].forEach(slug => {
|
|
const toggle = document.getElementById(`toggle-${slug}`);
|
|
const section = document.getElementById(`section-${slug}`);
|
|
if (!toggle || !section) return;
|
|
const key = `tdb-${slug}-collapsed`;
|
|
try { if (localStorage.getItem(key) === '1') section.classList.add('collapsed'); } catch(e) {}
|
|
toggle.addEventListener('click', () => {
|
|
section.classList.toggle('collapsed');
|
|
try { localStorage.setItem(key, section.classList.contains('collapsed') ? '1' : '0'); } catch(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;
|
|
row.Contenu = contenu; // update local cache immediately
|
|
fetch(`/api/vision/${row.Id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Contenu: contenu }),
|
|
}).then(() => 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 ──
|
|
function renderTags() {
|
|
const container = document.getElementById('v-tags');
|
|
const row = visionData['tags'];
|
|
let tags = [];
|
|
try { tags = JSON.parse(row?.Contenu || '[]'); } catch { tags = []; }
|
|
// Normalize: old format {label, type} → just strings
|
|
tags = tags.map(t => typeof t === 'string' ? t : t.label || '');
|
|
|
|
container.innerHTML = '';
|
|
tags.forEach((label, i) => {
|
|
const span = document.createElement('span');
|
|
span.className = 'tag ambiance';
|
|
span.innerHTML = `${esc(label)}<span class="tag-remove" onclick="removeTag(${i})">×</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;
|
|
tags.push(label);
|
|
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 = tags.map(t => typeof t === 'string' ? t : t.label || '');
|
|
tags.splice(index, 1);
|
|
saveVisionSection('tags', JSON.stringify(tags));
|
|
renderTags();
|
|
}
|
|
|
|
// ── Lists (on-veut / on-ne-veut-pas) — items with title + optional description ──
|
|
function renderList(section, containerId, cssClass) {
|
|
const container = document.getElementById(containerId);
|
|
const row = visionData[section];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; }
|
|
// Normalize: old format was plain strings, new format is {title, desc}
|
|
items = items.map(it => typeof it === 'string' ? { title: it, desc: '' } : it);
|
|
|
|
container.innerHTML = '';
|
|
items.forEach((item, i) => {
|
|
const hasDesc = !!item.desc;
|
|
const div = document.createElement('div');
|
|
div.className = `list-item ${cssClass}${hasDesc ? ' expanded' : ''}`;
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'list-item-text';
|
|
content.style.flex = '1';
|
|
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'list-item-title';
|
|
titleEl.contentEditable = 'true';
|
|
titleEl.textContent = item.title;
|
|
titleEl.addEventListener('blur', () => {
|
|
const v = titleEl.textContent.trim();
|
|
if (v && v !== items[i].title) { items[i].title = v; saveVisionSection(section, JSON.stringify(items)); }
|
|
});
|
|
titleEl.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); titleEl.blur(); } });
|
|
content.appendChild(titleEl);
|
|
|
|
const descEl = document.createElement('div');
|
|
descEl.className = 'list-item-desc';
|
|
descEl.contentEditable = 'true';
|
|
descEl.setAttribute('data-placeholder', 'Développer... (Markdown : - item pour les listes)');
|
|
descEl.style.display = 'none';
|
|
// Render Markdown preview (sanitized)
|
|
const descPreview = document.createElement('div');
|
|
descPreview.className = 'list-item-desc-preview';
|
|
descPreview.style.display = 'none';
|
|
if (hasDesc) {
|
|
const rawHtml = typeof marked !== 'undefined' ? marked.parse(item.desc) : esc(item.desc);
|
|
descPreview.textContent = '';
|
|
descPreview.appendChild(createSanitizedFragment(rawHtml));
|
|
descPreview.style.display = 'block';
|
|
}
|
|
descPreview.onclick = () => {
|
|
descPreview.style.display = 'none';
|
|
descEl.style.display = 'block';
|
|
descEl.textContent = items[i].desc || '';
|
|
descEl.focus();
|
|
};
|
|
descEl.addEventListener('blur', () => {
|
|
const v = descEl.innerText.trim();
|
|
if (v !== (items[i].desc || '')) { items[i].desc = v; saveVisionSection(section, JSON.stringify(items)); }
|
|
descEl.style.display = 'none';
|
|
if (v) {
|
|
const rawHtml = typeof marked !== 'undefined' ? marked.parse(v) : esc(v);
|
|
descPreview.textContent = '';
|
|
descPreview.appendChild(createSanitizedFragment(rawHtml));
|
|
descPreview.style.display = 'block';
|
|
} else {
|
|
descPreview.style.display = 'none';
|
|
div.classList.remove('expanded');
|
|
expandBtn.textContent = '▸';
|
|
expandBtn.title = 'Développer';
|
|
}
|
|
});
|
|
content.appendChild(descPreview);
|
|
content.appendChild(descEl);
|
|
|
|
const bullet = document.createElement('span');
|
|
bullet.className = 'bullet';
|
|
|
|
const actions = document.createElement('span');
|
|
actions.style.cssText = 'display:flex; gap:6px; align-items:center;';
|
|
|
|
const expandBtn = document.createElement('span');
|
|
expandBtn.className = 'list-item-expand';
|
|
expandBtn.textContent = hasDesc ? '▾' : '▸';
|
|
expandBtn.title = hasDesc ? 'Réduire' : 'Développer';
|
|
expandBtn.style.cursor = 'pointer';
|
|
expandBtn.onclick = () => {
|
|
if (div.classList.contains('expanded')) {
|
|
div.classList.remove('expanded');
|
|
descPreview.style.display = 'none';
|
|
descEl.style.display = 'none';
|
|
expandBtn.textContent = '▸';
|
|
expandBtn.title = 'Développer';
|
|
} else {
|
|
div.classList.add('expanded');
|
|
if (items[i].desc) {
|
|
const rawHtml = typeof marked !== 'undefined' ? marked.parse(items[i].desc) : esc(items[i].desc);
|
|
descPreview.textContent = '';
|
|
descPreview.appendChild(createSanitizedFragment(rawHtml));
|
|
descPreview.style.display = 'block';
|
|
} else {
|
|
descEl.style.display = 'block';
|
|
descEl.textContent = '';
|
|
descEl.focus();
|
|
}
|
|
expandBtn.textContent = '▾';
|
|
expandBtn.title = 'Réduire';
|
|
}
|
|
};
|
|
if (hasDesc) div.classList.add('expanded');
|
|
|
|
const removeBtn = document.createElement('span');
|
|
removeBtn.className = 'list-item-remove';
|
|
removeBtn.innerHTML = '×';
|
|
removeBtn.onclick = () => { items.splice(i, 1); saveVisionSection(section, JSON.stringify(items)); renderList(section, containerId, cssClass); };
|
|
|
|
actions.appendChild(expandBtn);
|
|
actions.appendChild(removeBtn);
|
|
|
|
div.appendChild(bullet);
|
|
div.appendChild(content);
|
|
div.appendChild(actions);
|
|
container.appendChild(div);
|
|
});
|
|
|
|
const addEl = document.createElement('div');
|
|
addEl.className = 'list-add';
|
|
addEl.innerHTML = '<span>+</span> Ajouter un élément';
|
|
addEl.onclick = () => {
|
|
const text = prompt('Nouvel élément :');
|
|
if (!text) return;
|
|
items.push({ title: text, desc: '' });
|
|
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);
|
|
}
|
|
|
|
// ── Moodboard (images + free text captions) ──
|
|
function renderMoodboard() {
|
|
const container = document.getElementById('v-moodboard');
|
|
const row = visionData['moodboard'];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; }
|
|
|
|
container.innerHTML = '';
|
|
items.forEach((item, i) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'mood-card';
|
|
|
|
const imgDiv = document.createElement('div');
|
|
const hasImg = !!item.image;
|
|
imgDiv.className = 'mood-card-img' + (hasImg ? ' has-image' : ' empty');
|
|
if (hasImg) {
|
|
const zoom = item.zoom ?? 100;
|
|
imgDiv.style.backgroundImage = `url(${item.image})`;
|
|
imgDiv.style.backgroundSize = `${zoom}%`;
|
|
imgDiv.style.backgroundPosition = `${item.posX ?? 50}% ${item.posY ?? 50}%`;
|
|
}
|
|
if (!hasImg) {
|
|
imgDiv.onclick = () => { moodUploadTarget = i; document.getElementById('mood-file-input').click(); };
|
|
} else {
|
|
// Drag to reposition (2D) — double-click to replace image
|
|
let startX, startY, startPosX, startPosY, didDrag;
|
|
const onMove = (e) => {
|
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
|
if (!didDrag && Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
|
|
didDrag = true;
|
|
const rect = imgDiv.getBoundingClientRect();
|
|
const scale = (item.zoom ?? 100) / 100;
|
|
item.posX = Math.max(0, Math.min(100, startPosX + (dx / rect.width) * -80 / scale));
|
|
item.posY = Math.max(0, Math.min(100, startPosY + (dy / rect.height) * -80 / scale));
|
|
imgDiv.style.backgroundPosition = `${item.posX}% ${item.posY}%`;
|
|
};
|
|
const onUp = () => {
|
|
imgDiv.classList.remove('dragging');
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseup', onUp);
|
|
if (didDrag) saveVisionSection('moodboard', JSON.stringify(items));
|
|
};
|
|
imgDiv.addEventListener('mousedown', (e) => {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
didDrag = false;
|
|
imgDiv.classList.add('dragging');
|
|
startX = e.clientX; startY = e.clientY;
|
|
startPosX = item.posX ?? 50; startPosY = item.posY ?? 50;
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
});
|
|
// Ctrl+scroll to zoom (prevents accidental zoom)
|
|
imgDiv.addEventListener('wheel', (e) => {
|
|
if (!e.ctrlKey && !e.metaKey) return;
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? -10 : 10;
|
|
item.zoom = Math.max(100, Math.min(400, (item.zoom ?? 100) + delta));
|
|
imgDiv.style.backgroundSize = `${item.zoom}%`;
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
}, { passive: false });
|
|
imgDiv.addEventListener('dblclick', () => { moodUploadTarget = i; document.getElementById('mood-file-input').click(); });
|
|
}
|
|
|
|
const caption = document.createElement('div');
|
|
caption.className = 'mood-card-caption';
|
|
caption.contentEditable = 'true';
|
|
caption.textContent = item.caption || '';
|
|
caption.setAttribute('placeholder', 'Légende...');
|
|
caption.addEventListener('blur', () => {
|
|
const v = caption.textContent.trim();
|
|
if (v !== (items[i].caption || '')) {
|
|
items[i].caption = v;
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
}
|
|
});
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'mood-card-remove';
|
|
removeBtn.innerHTML = '×';
|
|
removeBtn.onclick = (e) => { e.stopPropagation(); removeMoodItem(i); };
|
|
|
|
// Drag handle for reordering
|
|
const handle = document.createElement('div');
|
|
handle.className = 'mood-card-handle';
|
|
handle.innerHTML = '⠿';
|
|
if (hasImg) {
|
|
card.draggable = false;
|
|
handle.addEventListener('mousedown', () => { card.draggable = true; });
|
|
window.addEventListener('mouseup', () => { card.draggable = false; });
|
|
card.addEventListener('dragstart', (e) => {
|
|
moodDragIndex = i;
|
|
card.classList.add('dragging-card');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
card.addEventListener('dragend', () => {
|
|
card.classList.remove('dragging-card');
|
|
card.draggable = false;
|
|
moodDragIndex = -1;
|
|
container.querySelectorAll('.mood-card').forEach(c => c.classList.remove('drag-over'));
|
|
});
|
|
card.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
card.classList.add('drag-over');
|
|
});
|
|
card.addEventListener('dragleave', () => { card.classList.remove('drag-over'); });
|
|
card.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
card.classList.remove('drag-over');
|
|
if (moodDragIndex < 0 || moodDragIndex === i) return;
|
|
const moved = items.splice(moodDragIndex, 1)[0];
|
|
items.splice(i, 0, moved);
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
renderMoodboard();
|
|
});
|
|
}
|
|
|
|
card.appendChild(removeBtn);
|
|
card.appendChild(handle);
|
|
card.appendChild(imgDiv);
|
|
card.appendChild(caption);
|
|
container.appendChild(card);
|
|
});
|
|
|
|
const addCard = document.createElement('div');
|
|
addCard.className = 'mood-add';
|
|
addCard.innerHTML = '<div class="mood-add-icon">+</div>';
|
|
addCard.onclick = () => {
|
|
items.push({ caption: '', image: '' });
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
renderMoodboard();
|
|
};
|
|
container.appendChild(addCard);
|
|
}
|
|
|
|
let moodUploadTarget = -1;
|
|
let moodDragIndex = -1;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const moodInput = document.getElementById('mood-file-input');
|
|
if (moodInput) moodInput.addEventListener('change', function() {
|
|
const file = this.files[0];
|
|
if (!file || moodUploadTarget < 0) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = new Image();
|
|
img.onload = async () => {
|
|
const maxW = 1200;
|
|
let w = img.width, h = img.height;
|
|
if (w > maxW) { h = Math.round(h * maxW / w); w = maxW; }
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w; canvas.height = h;
|
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
|
|
|
try {
|
|
const res = await fetch('/api/mood-upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dataUrl, index: moodUploadTarget }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.path) {
|
|
const row = visionData['moodboard'];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; }
|
|
if (items[moodUploadTarget]) {
|
|
items[moodUploadTarget].image = data.path;
|
|
items[moodUploadTarget].posX = 50;
|
|
items[moodUploadTarget].posY = 50;
|
|
items[moodUploadTarget].zoom = 100;
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
renderMoodboard();
|
|
}
|
|
}
|
|
} catch (err) { console.error('Mood upload error:', err); }
|
|
};
|
|
img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
this.value = '';
|
|
});
|
|
});
|
|
|
|
function removeMoodItem(index) {
|
|
const row = visionData['moodboard'];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { return; }
|
|
items.splice(index, 1);
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
renderMoodboard();
|
|
}
|
|
|
|
// ── Hero image with drag-to-reposition ──
|
|
let heroMeta = { posY: 50, height: 400 }; // posY in %, height in px
|
|
|
|
function applyHeroImage(dataUrl) {
|
|
const banner = document.getElementById('hero-banner');
|
|
if (dataUrl) {
|
|
banner.style.backgroundImage = `url(${dataUrl})`;
|
|
banner.classList.add('has-image');
|
|
} else {
|
|
banner.style.backgroundImage = '';
|
|
banner.classList.remove('has-image');
|
|
}
|
|
}
|
|
|
|
function applyHeroMeta() {
|
|
const banner = document.getElementById('hero-banner');
|
|
banner.style.backgroundPosition = `center ${heroMeta.posY}%`;
|
|
banner.style.height = heroMeta.height + 'px';
|
|
}
|
|
|
|
function saveHeroMeta() {
|
|
saveVisionSection('hero-meta', JSON.stringify({ posY: heroMeta.posY, height: heroMeta.height }));
|
|
}
|
|
|
|
// Drag to reposition
|
|
(function() {
|
|
const banner = document.getElementById('hero-banner');
|
|
let dragging = false, startY = 0, startPos = 0;
|
|
|
|
banner.addEventListener('mousedown', (e) => {
|
|
if (!banner.classList.contains('has-image')) return;
|
|
if (e.target.closest('[contenteditable]') || e.target.closest('.hero-controls') || e.target.closest('button')) return;
|
|
dragging = true;
|
|
startY = e.clientY;
|
|
startPos = heroMeta.posY;
|
|
banner.classList.add('dragging');
|
|
e.preventDefault();
|
|
});
|
|
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (!dragging) return;
|
|
const delta = e.clientY - startY;
|
|
const sensitivity = 0.15;
|
|
heroMeta.posY = Math.max(0, Math.min(100, startPos + delta * sensitivity));
|
|
banner.style.backgroundPosition = `center ${heroMeta.posY}%`;
|
|
});
|
|
|
|
window.addEventListener('mouseup', () => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
banner.classList.remove('dragging');
|
|
saveHeroMeta();
|
|
});
|
|
})();
|
|
|
|
async function uploadHeroImage(input) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
const img = new Image();
|
|
img.onload = async () => {
|
|
const maxW = 1600;
|
|
let w = img.width, h = img.height;
|
|
if (w > maxW) { h = Math.round(h * maxW / w); w = maxW; }
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w; canvas.height = h;
|
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
|
|
|
// Upload file to server (too large for NocoDB LongText)
|
|
try {
|
|
const res = await fetch('/api/hero-upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dataUrl }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.path) {
|
|
applyHeroImage(data.path);
|
|
heroMeta.posY = 50;
|
|
applyHeroMeta();
|
|
saveVisionSection('hero-image', data.path);
|
|
saveHeroMeta();
|
|
}
|
|
} catch (err) {
|
|
console.error('Hero upload error:', err);
|
|
}
|
|
};
|
|
img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
input.value = '';
|
|
}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// Hero image (base64 brut) + meta (position/hauteur séparées)
|
|
applyHeroImage(visionData['hero-image']?.Contenu || '');
|
|
try {
|
|
const meta = JSON.parse(visionData['hero-meta']?.Contenu || '{}');
|
|
heroMeta.posY = meta.posY ?? 50;
|
|
heroMeta.height = meta.height ?? 400;
|
|
} catch { /* defaults */ }
|
|
applyHeroMeta();
|
|
|
|
// Render structured sections
|
|
renderTags();
|
|
renderList('on-veut', 'v-on-veut', 'yes');
|
|
renderList('on-ne-veut-pas', 'v-on-ne-veut-pas', 'no');
|
|
renderMoodboard();
|
|
} catch (e) {
|
|
console.error('Vision load error:', e);
|
|
}
|
|
}
|
|
|
|
loadVision();
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// ══ PERSONNAGES ══
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
let personnages = [];
|
|
let editingPersoId = null;
|
|
let pendingPersoImage = null;
|
|
|
|
async function loadPersonnages() {
|
|
try {
|
|
const res = await fetch('/api/personnages');
|
|
if (res.status === 401) return;
|
|
const data = await res.json();
|
|
personnages = data.list || [];
|
|
renderPersonnages();
|
|
} catch (e) { console.error('Personnages load error:', e); }
|
|
}
|
|
|
|
function renderPersonnages() {
|
|
const grid = document.getElementById('perso-grid');
|
|
grid.textContent = '';
|
|
personnages.forEach(p => {
|
|
const card = document.createElement('div');
|
|
card.className = 'perso-card';
|
|
card.onclick = () => openPersoEdit(p.Id);
|
|
|
|
// Portrait
|
|
if (p.Image) {
|
|
const img = document.createElement('img');
|
|
img.className = 'perso-card-portrait';
|
|
img.src = p.Image;
|
|
img.alt = p.Nom || '';
|
|
card.appendChild(img);
|
|
}
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'perso-card-body';
|
|
|
|
// Nom
|
|
const nameEl = document.createElement('div');
|
|
nameEl.className = 'perso-card-name';
|
|
nameEl.textContent = p.Nom || '';
|
|
body.appendChild(nameEl);
|
|
|
|
// Rôle
|
|
if (p.Role) {
|
|
const roleEl = document.createElement('div');
|
|
roleEl.className = 'perso-card-role';
|
|
roleEl.textContent = p.Role;
|
|
body.appendChild(roleEl);
|
|
}
|
|
|
|
// Description (tronquée)
|
|
if (p.Description) {
|
|
const descEl = document.createElement('div');
|
|
descEl.className = 'perso-card-desc perso-card-text';
|
|
descEl.appendChild(createSanitizedFragment(renderMarkdown(p.Description)));
|
|
body.appendChild(descEl);
|
|
}
|
|
|
|
// Tags
|
|
const tagList = (p.Tags || '').split(',').filter(t => t.trim());
|
|
if (tagList.length) {
|
|
const tagsEl = document.createElement('div');
|
|
tagsEl.className = 'perso-card-tags';
|
|
tagList.forEach(t => {
|
|
const badge = document.createElement('span');
|
|
badge.className = `card-badge ${tagBadgeClass(t.trim())}`;
|
|
badge.textContent = t.trim();
|
|
tagsEl.appendChild(badge);
|
|
});
|
|
body.appendChild(tagsEl);
|
|
}
|
|
|
|
// Compteur scènes
|
|
const sceneCount = records.filter(r =>
|
|
r.Personnage && r.Personnage.split(',').map(s => s.trim()).includes(p.Nom)
|
|
).length;
|
|
if (sceneCount) {
|
|
const countEl = document.createElement('div');
|
|
countEl.className = 'perso-card-scene-count';
|
|
countEl.textContent = `${sceneCount} scène${sceneCount > 1 ? 's' : ''}`;
|
|
body.appendChild(countEl);
|
|
}
|
|
|
|
card.appendChild(body);
|
|
grid.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function getLinkedScenes(nom) {
|
|
return records.filter(r =>
|
|
r.Personnage && r.Personnage.split(',').map(s => s.trim()).includes(nom)
|
|
).sort((a, b) => {
|
|
const ai = ACTE_ORDER.indexOf(a.Acte), bi = ACTE_ORDER.indexOf(b.Acte);
|
|
if (ai !== bi) return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
return (a.Ordre || 0) - (b.Ordre || 0);
|
|
});
|
|
}
|
|
|
|
function buildModalScenes(nom) {
|
|
const container = document.getElementById('fp-scenes-container');
|
|
container.textContent = '';
|
|
const scenes = getLinkedScenes(nom);
|
|
if (!scenes.length) return;
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'perso-modal-scenes';
|
|
const title = document.createElement('div');
|
|
title.className = 'perso-modal-scenes-title';
|
|
title.textContent = `Scènes (${scenes.length})`;
|
|
wrapper.appendChild(title);
|
|
|
|
// Group by acte
|
|
const grouped = {};
|
|
scenes.forEach(s => {
|
|
const acte = s.Acte || 'Sans acte';
|
|
if (!grouped[acte]) grouped[acte] = [];
|
|
grouped[acte].push(s);
|
|
});
|
|
|
|
ACTE_ORDER.forEach(acte => {
|
|
if (!grouped[acte] || !grouped[acte].length) return;
|
|
const acteTitle = document.createElement('div');
|
|
acteTitle.className = 'perso-modal-scenes-acte';
|
|
acteTitle.textContent = acte;
|
|
wrapper.appendChild(acteTitle);
|
|
const list = document.createElement('div');
|
|
list.className = 'perso-modal-scenes-list';
|
|
grouped[acte].forEach(scene => {
|
|
const link = document.createElement('a');
|
|
link.textContent = scene.Title;
|
|
link.onclick = (e) => { e.stopPropagation(); closePersoModal(); switchView('frise'); setTimeout(() => openEdit(scene.Id), 200); };
|
|
list.appendChild(link);
|
|
});
|
|
wrapper.appendChild(list);
|
|
});
|
|
|
|
container.appendChild(wrapper);
|
|
}
|
|
|
|
function openPersoModal() {
|
|
editingPersoId = null;
|
|
pendingPersoImage = null;
|
|
document.getElementById('perso-modal-label').textContent = 'Nouveau personnage';
|
|
document.getElementById('fp-nom').value = '';
|
|
document.getElementById('fp-role').value = '';
|
|
document.getElementById('fp-desc').value = '';
|
|
document.getElementById('fp-origines').value = '';
|
|
document.getElementById('fp-backstory').value = '';
|
|
document.getElementById('fp-tags').value = '';
|
|
document.getElementById('fp-image-input').value = '';
|
|
document.getElementById('fp-image-preview').innerHTML = '';
|
|
document.getElementById('fp-scenes-container').textContent = '';
|
|
document.getElementById('btn-delete-perso').style.display = 'none';
|
|
document.getElementById('perso-modal').classList.add('open');
|
|
}
|
|
|
|
function openPersoEdit(id) {
|
|
const p = personnages.find(x => x.Id === id);
|
|
if (!p) return;
|
|
editingPersoId = id;
|
|
pendingPersoImage = null;
|
|
document.getElementById('perso-modal-label').textContent = 'Modifier';
|
|
document.getElementById('fp-nom').value = p.Nom || '';
|
|
document.getElementById('fp-role').value = p.Role || '';
|
|
document.getElementById('fp-desc').value = p.Description || '';
|
|
document.getElementById('fp-origines').value = p.Origines || '';
|
|
document.getElementById('fp-backstory').value = p.Backstory || '';
|
|
document.getElementById('fp-tags').value = p.Tags || '';
|
|
document.getElementById('fp-image-input').value = '';
|
|
document.getElementById('fp-image-preview').innerHTML = p.Image ? `<img src="${esc(p.Image)}" style="max-width:200px;max-height:150px;border-radius:8px;">` : '';
|
|
buildModalScenes(p.Nom);
|
|
document.getElementById('btn-delete-perso').style.display = 'block';
|
|
document.getElementById('perso-modal').classList.add('open');
|
|
}
|
|
|
|
function closePersoModal() {
|
|
document.getElementById('perso-modal').classList.remove('open');
|
|
editingPersoId = null;
|
|
pendingPersoImage = null;
|
|
}
|
|
|
|
function previewPersoImage(input) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
pendingPersoImage = e.target.result;
|
|
document.getElementById('fp-image-preview').innerHTML = `<img src="${e.target.result}" style="max-width:200px;max-height:150px;border-radius:8px;">`;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
async function savePerso() {
|
|
let imagePath = null;
|
|
if (pendingPersoImage) {
|
|
const upRes = await fetch('/api/ressource-upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dataUrl: pendingPersoImage }),
|
|
});
|
|
if (upRes.ok) {
|
|
const upData = await upRes.json();
|
|
imagePath = upData.path;
|
|
}
|
|
}
|
|
|
|
const data = {
|
|
Nom: document.getElementById('fp-nom').value,
|
|
Role: document.getElementById('fp-role').value || null,
|
|
Description: document.getElementById('fp-desc').value || null,
|
|
Origines: document.getElementById('fp-origines').value || null,
|
|
Backstory: document.getElementById('fp-backstory').value || null,
|
|
Tags: document.getElementById('fp-tags').value || null,
|
|
};
|
|
if (imagePath) data.Image = imagePath;
|
|
|
|
if (editingPersoId) {
|
|
await fetch(`/api/personnages/${editingPersoId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
} else {
|
|
await fetch('/api/personnages', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
closePersoModal();
|
|
await loadPersonnages();
|
|
}
|
|
|
|
async function deletePerso() {
|
|
if (!editingPersoId || !confirm('Supprimer ce personnage ?')) return;
|
|
await fetch(`/api/personnages/${editingPersoId}`, { method: 'DELETE' });
|
|
closePersoModal();
|
|
await loadPersonnages();
|
|
}
|
|
|
|
document.getElementById('perso-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('perso-modal')) closePersoModal();
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// ══ RESSOURCES ══
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
let ressources = [];
|
|
let editingResId = null;
|
|
let pendingResImage = null; // dataUrl for new upload
|
|
|
|
function videoThumbnail(url) {
|
|
if (!url) return null;
|
|
// YouTube: various URL formats
|
|
let m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/);
|
|
if (m) return `https://img.youtube.com/vi/${m[1]}/hqdefault.jpg`;
|
|
// Vimeo
|
|
m = url.match(/vimeo\.com\/(\d+)/);
|
|
if (m) return `https://vumbnail.com/${m[1]}.jpg`;
|
|
return null;
|
|
}
|
|
|
|
function extractDomain(url) {
|
|
try { return new URL(url).hostname.replace('www.', ''); } catch { return url; }
|
|
}
|
|
|
|
async function loadRessources() {
|
|
try {
|
|
const res = await fetch('/api/ressources');
|
|
if (res.status === 401) return;
|
|
const data = await res.json();
|
|
ressources = data.list || [];
|
|
populateResCatFilter();
|
|
renderRessources();
|
|
} catch (e) { console.error('Ressources load error:', e); }
|
|
}
|
|
|
|
function populateResCatFilter() {
|
|
const catSet = new Set();
|
|
ressources.forEach(r => { if (r.Categorie) catSet.add(r.Categorie); });
|
|
const sel = document.getElementById('res-filter-cat');
|
|
const cur = sel.value;
|
|
sel.innerHTML = '<option value="">Toutes</option>';
|
|
[...catSet].sort().forEach(c => {
|
|
const opt = document.createElement('option');
|
|
opt.value = c; opt.textContent = c;
|
|
sel.appendChild(opt);
|
|
});
|
|
sel.value = cur;
|
|
}
|
|
|
|
function renderRessources() {
|
|
const grid = document.getElementById('res-grid');
|
|
grid.innerHTML = '';
|
|
const filterCat = document.getElementById('res-filter-cat')?.value || '';
|
|
const filterSearch = (document.getElementById('res-filter-search')?.value || '').toLowerCase().trim();
|
|
|
|
ressources.forEach(r => {
|
|
if (filterCat && r.Categorie !== filterCat) return;
|
|
if (filterSearch) {
|
|
const hay = [r.Titre, r.Description, r.Tags, r.Categorie, r.Lien].filter(Boolean).join(' ').toLowerCase();
|
|
if (!hay.includes(filterSearch)) return;
|
|
}
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'res-card';
|
|
card.onclick = () => openResEdit(r.Id);
|
|
|
|
// Image: uploaded > video thumbnail > nothing
|
|
const imgSrc = r.Image || videoThumbnail(r.Lien);
|
|
if (imgSrc) {
|
|
const img = document.createElement('img');
|
|
img.className = 'res-card-img';
|
|
img.src = imgSrc;
|
|
img.alt = '';
|
|
card.appendChild(img);
|
|
}
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'res-card-body';
|
|
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'res-card-title';
|
|
titleEl.textContent = r.Titre || '';
|
|
body.appendChild(titleEl);
|
|
|
|
if (r.Categorie) {
|
|
const catEl = document.createElement('div');
|
|
catEl.className = 'res-card-cat';
|
|
catEl.textContent = r.Categorie;
|
|
body.appendChild(catEl);
|
|
}
|
|
|
|
if (r.Description) {
|
|
const descEl = document.createElement('div');
|
|
descEl.className = 'res-card-desc';
|
|
descEl.textContent = r.Description;
|
|
body.appendChild(descEl);
|
|
}
|
|
|
|
// Lien cliquable
|
|
if (r.Lien) {
|
|
const linkDiv = document.createElement('div');
|
|
linkDiv.className = 'res-card-link';
|
|
linkDiv.innerHTML = '<svg viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
|
const a = document.createElement('a');
|
|
a.href = r.Lien;
|
|
a.target = '_blank';
|
|
a.rel = 'noopener';
|
|
a.textContent = extractDomain(r.Lien);
|
|
a.onclick = (e) => e.stopPropagation();
|
|
linkDiv.appendChild(a);
|
|
body.appendChild(linkDiv);
|
|
}
|
|
|
|
const tags = (r.Tags || '').split(',').filter(t => t.trim());
|
|
if (tags.length) {
|
|
const tagsEl = document.createElement('div');
|
|
tagsEl.className = 'res-card-tags';
|
|
tags.forEach(t => {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'card-badge badge-tag';
|
|
badge.style.cssText = 'font-size:11px;padding:2px 6px;';
|
|
badge.textContent = t.trim();
|
|
tagsEl.appendChild(badge);
|
|
});
|
|
body.appendChild(tagsEl);
|
|
}
|
|
|
|
card.appendChild(body);
|
|
grid.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function updateResUrlLink(url) {
|
|
const container = document.getElementById('fr-url-link');
|
|
container.innerHTML = '';
|
|
if (url && url.startsWith('http')) {
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.target = '_blank';
|
|
a.rel = 'noopener';
|
|
a.textContent = 'Ouvrir le lien';
|
|
container.appendChild(a);
|
|
}
|
|
}
|
|
|
|
function openResModal() {
|
|
editingResId = null;
|
|
pendingResImage = null;
|
|
document.getElementById('res-modal-label').textContent = 'Nouvelle ressource';
|
|
document.getElementById('fr-titre').value = '';
|
|
document.getElementById('fr-cat').value = '';
|
|
document.getElementById('fr-tags').value = '';
|
|
document.getElementById('fr-url').value = '';
|
|
document.getElementById('fr-desc').value = '';
|
|
document.getElementById('fr-image-input').value = '';
|
|
document.getElementById('fr-image-preview').innerHTML = '';
|
|
updateResUrlLink('');
|
|
document.getElementById('btn-delete-res').style.display = 'none';
|
|
document.getElementById('res-modal').classList.add('open');
|
|
}
|
|
|
|
function openResEdit(id) {
|
|
const r = ressources.find(x => x.Id === id);
|
|
if (!r) return;
|
|
editingResId = id;
|
|
pendingResImage = null;
|
|
document.getElementById('res-modal-label').textContent = 'Modifier';
|
|
document.getElementById('fr-titre').value = r.Titre || '';
|
|
document.getElementById('fr-cat').value = r.Categorie || '';
|
|
document.getElementById('fr-tags').value = r.Tags || '';
|
|
document.getElementById('fr-url').value = r.Lien || '';
|
|
document.getElementById('fr-desc').value = r.Description || '';
|
|
document.getElementById('fr-image-input').value = '';
|
|
document.getElementById('fr-image-preview').innerHTML = r.Image ? `<img src="${esc(r.Image)}" style="max-width:200px;max-height:120px;border-radius:8px;">` : '';
|
|
updateResUrlLink(r.Lien || '');
|
|
document.getElementById('btn-delete-res').style.display = 'block';
|
|
document.getElementById('res-modal').classList.add('open');
|
|
}
|
|
|
|
function closeResModal() {
|
|
document.getElementById('res-modal').classList.remove('open');
|
|
editingResId = null;
|
|
pendingResImage = null;
|
|
}
|
|
|
|
function previewResImage(input) {
|
|
const file = input.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
pendingResImage = e.target.result;
|
|
document.getElementById('fr-image-preview').innerHTML = `<img src="${e.target.result}" style="max-width:200px;max-height:120px;border-radius:8px;">`;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
async function saveRes() {
|
|
let imagePath = null;
|
|
|
|
// Upload image if new one selected
|
|
if (pendingResImage) {
|
|
const upRes = await fetch('/api/ressource-upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dataUrl: pendingResImage }),
|
|
});
|
|
if (upRes.ok) {
|
|
const upData = await upRes.json();
|
|
imagePath = upData.path;
|
|
}
|
|
}
|
|
|
|
const data = {
|
|
Titre: document.getElementById('fr-titre').value,
|
|
Categorie: document.getElementById('fr-cat').value || null,
|
|
Tags: document.getElementById('fr-tags').value || null,
|
|
Lien: document.getElementById('fr-url').value || null,
|
|
Description: document.getElementById('fr-desc').value || null,
|
|
};
|
|
if (imagePath) data.Image = imagePath;
|
|
|
|
if (editingResId) {
|
|
await fetch(`/api/ressources/${editingResId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
} else {
|
|
await fetch('/api/ressources', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
closeResModal();
|
|
await loadRessources();
|
|
}
|
|
|
|
async function deleteRes() {
|
|
if (!editingResId || !confirm('Supprimer cette ressource ?')) return;
|
|
await fetch(`/api/ressources/${editingResId}`, { method: 'DELETE' });
|
|
closeResModal();
|
|
await loadRessources();
|
|
}
|
|
|
|
document.getElementById('res-modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('res-modal')) closeResModal();
|
|
});
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// ══ RÉDACTION — Binder + TipTap Editor ══
|
|
// ══════════════════════════════════════════════════════
|
|
|
|
let tiptapEditor = null;
|
|
let currentSceneId = null;
|
|
let saveTimeout = null;
|
|
let wordCounts = {}; // sceneId → word count (cached)
|
|
|
|
const ACTE_ORDER = ["Prologue", 'Début', 'Milieu', 'Fin', "Épilogue"];
|
|
|
|
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] = []; });
|
|
|
|
const redPerso = document.getElementById('red-filter-perso')?.value || '';
|
|
const redTag = document.getElementById('red-filter-tag')?.value || '';
|
|
const redSearch = (document.getElementById('red-filter-search')?.value || '').toLowerCase().trim();
|
|
|
|
(records || []).forEach(r => {
|
|
const acte = r.Acte;
|
|
if (!acte || acte === 'En attente') return;
|
|
if (redPerso && !(r.Personnage || '').split(',').map(s => s.trim()).includes(redPerso)) return;
|
|
if (redTag && !(r.Tags || '').split(',').map(s => s.trim()).includes(redTag)) return;
|
|
if (redSearch) {
|
|
const haystack = [r.Title, r['Résumé'], r.Personnage].filter(Boolean).join(' ').toLowerCase();
|
|
if (!haystack.includes(redSearch)) return;
|
|
}
|
|
if (grouped[acte]) grouped[acte].push(r);
|
|
});
|
|
|
|
binder.innerHTML = '';
|
|
|
|
// Legend at top
|
|
const legend = document.createElement('div');
|
|
legend.className = 'binder-legend';
|
|
const legendTitle = document.createElement('div');
|
|
legendTitle.className = 'binder-legend-title';
|
|
legendTitle.textContent = 'Tags statut';
|
|
legend.appendChild(legendTitle);
|
|
Object.entries(STATUS_COLORS).forEach(([label, color]) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'binder-legend-item';
|
|
const dot = document.createElement('span');
|
|
dot.className = 'binder-legend-dot';
|
|
dot.style.background = color;
|
|
const txt = document.createElement('span');
|
|
txt.textContent = label;
|
|
item.appendChild(dot);
|
|
item.appendChild(txt);
|
|
legend.appendChild(item);
|
|
});
|
|
binder.appendChild(legend);
|
|
|
|
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 sceneTags = (scene.Tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
|
const statusDots = sceneTags
|
|
.filter(t => STATUS_COLORS[t])
|
|
.map(t => `<span class="scene-status" style="background:${STATUS_COLORS[t]}"></span>`)
|
|
.join('');
|
|
const wc = wordCounts[scene.Id] || 0;
|
|
totalWords += wc;
|
|
el.innerHTML = `${statusDots || '<span class="scene-status"></span>'}<span class="scene-name">${esc(scene.Title)}</span>${wc ? `<span class="scene-words">${wc}</span>` : ''}`;
|
|
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.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);
|
|
|
|
// 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('');
|
|
}
|
|
}
|
|
|
|
// ── Panneau fiche (intention) ──
|
|
let ficheOpen = false;
|
|
try { ficheOpen = localStorage.getItem('tdb-fiche-open') === '1'; } catch(e) {}
|
|
|
|
function toggleFiche() {
|
|
ficheOpen = !ficheOpen;
|
|
document.getElementById('editor-fiche-content').classList.toggle('open', ficheOpen);
|
|
document.getElementById('fiche-toggle-icon').classList.toggle('open', ficheOpen);
|
|
try { localStorage.setItem('tdb-fiche-open', ficheOpen ? '1' : '0'); } catch(e) {}
|
|
}
|
|
|
|
function buildFichePanel(scene) {
|
|
const container = document.getElementById('editor-fiche-content');
|
|
container.textContent = '';
|
|
|
|
const fields = [
|
|
{ label: 'Résumé', value: scene['Résumé'] },
|
|
{ label: 'Intérêt narratif', value: scene['Intérêt narratif'] },
|
|
{ label: 'Implications', value: scene.Implications },
|
|
{ label: 'Origine', value: scene.Origine },
|
|
{ label: 'Notes', value: scene['Lien rédaction'] },
|
|
];
|
|
|
|
let hasContent = false;
|
|
fields.forEach(f => {
|
|
if (!f.value) return;
|
|
hasContent = true;
|
|
const field = document.createElement('div');
|
|
field.className = 'editor-fiche-field';
|
|
const label = document.createElement('div');
|
|
label.className = 'editor-fiche-field-label';
|
|
label.textContent = f.label;
|
|
field.appendChild(label);
|
|
const val = document.createElement('div');
|
|
val.className = 'editor-fiche-field-value';
|
|
val.appendChild(createSanitizedFragment(renderMarkdown(f.value)));
|
|
field.appendChild(val);
|
|
container.appendChild(field);
|
|
});
|
|
|
|
// Tags
|
|
const tagList = (scene.Tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
|
if (tagList.length) {
|
|
hasContent = true;
|
|
const field = document.createElement('div');
|
|
field.className = 'editor-fiche-field';
|
|
const label = document.createElement('div');
|
|
label.className = 'editor-fiche-field-label';
|
|
label.textContent = 'Tags';
|
|
field.appendChild(label);
|
|
const tagsEl = document.createElement('div');
|
|
tagsEl.className = 'editor-fiche-tags';
|
|
tagList.forEach(t => {
|
|
const badge = document.createElement('span');
|
|
badge.className = `card-badge ${tagBadgeClass(t)}`;
|
|
badge.textContent = t;
|
|
tagsEl.appendChild(badge);
|
|
});
|
|
field.appendChild(tagsEl);
|
|
container.appendChild(field);
|
|
}
|
|
|
|
// Personnages
|
|
const persos = (scene.Personnage || '').split(',').map(p => p.trim()).filter(Boolean);
|
|
if (persos.length) {
|
|
hasContent = true;
|
|
const field = document.createElement('div');
|
|
field.className = 'editor-fiche-field';
|
|
const label = document.createElement('div');
|
|
label.className = 'editor-fiche-field-label';
|
|
label.textContent = 'Personnages';
|
|
field.appendChild(label);
|
|
const persosEl = document.createElement('div');
|
|
persosEl.className = 'editor-fiche-persos';
|
|
persos.forEach(p => {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'card-badge badge-perso';
|
|
badge.textContent = p;
|
|
persosEl.appendChild(badge);
|
|
});
|
|
field.appendChild(persosEl);
|
|
container.appendChild(field);
|
|
}
|
|
|
|
// Commentaires de la fiche
|
|
const comments = parseComments(scene.Commentaires);
|
|
if (comments.length) {
|
|
hasContent = true;
|
|
const field = document.createElement('div');
|
|
field.className = 'editor-fiche-field';
|
|
const label = document.createElement('div');
|
|
label.className = 'editor-fiche-field-label';
|
|
label.textContent = `Commentaires (${comments.length})`;
|
|
field.appendChild(label);
|
|
const list = document.createElement('div');
|
|
comments.forEach(c => {
|
|
const item = document.createElement('div');
|
|
item.style.cssText = 'margin-bottom:6px;';
|
|
const author = document.createElement('span');
|
|
author.style.cssText = 'font-weight:600;font-size:12px;';
|
|
author.textContent = (c.auteur === 'M' ? 'Myriam' : 'Etienne') + ' — ';
|
|
item.appendChild(author);
|
|
const text = document.createElement('span');
|
|
text.textContent = c.texte || '';
|
|
item.appendChild(text);
|
|
list.appendChild(item);
|
|
});
|
|
field.appendChild(list);
|
|
container.appendChild(field);
|
|
}
|
|
|
|
// Afficher/masquer le panneau selon l'état mémorisé
|
|
const ficheEl = document.getElementById('editor-fiche');
|
|
ficheEl.style.display = hasContent ? '' : 'none';
|
|
container.classList.toggle('open', ficheOpen && hasContent);
|
|
document.getElementById('fiche-toggle-icon').classList.toggle('open', ficheOpen && hasContent);
|
|
}
|
|
|
|
function initEditor(html) {
|
|
// Destroy previous editor
|
|
if (tiptapEditor) { tiptapEditor.destroy(); tiptapEditor = null; }
|
|
|
|
const Editor = window.TiptapEditor;
|
|
const StarterKit = window.TiptapStarterKit;
|
|
const CommentMark = window.TiptapComment;
|
|
|
|
if (!Editor || !StarterKit) {
|
|
// Fallback: contenteditable div if TipTap CDN not loaded
|
|
const el = document.getElementById('editor-content');
|
|
el.innerHTML = html || '<p></p>';
|
|
el.contentEditable = 'true';
|
|
el.classList.add('tiptap');
|
|
el.addEventListener('input', () => { clearTimeout(saveTimeout); saveTimeout = setTimeout(saveContent, 1500); updateWordCount(); });
|
|
buildToolbarFallback();
|
|
return;
|
|
}
|
|
|
|
const extensions = [StarterKit];
|
|
if (CommentMark) extensions.push(CommentMark);
|
|
|
|
tiptapEditor = new Editor({
|
|
element: document.getElementById('editor-content'),
|
|
extensions,
|
|
content: html || '<p></p>',
|
|
onUpdate: () => {
|
|
clearTimeout(saveTimeout);
|
|
saveTimeout = setTimeout(saveContent, 1500);
|
|
updateWordCount();
|
|
},
|
|
});
|
|
|
|
// Listen for clicks on comment marks
|
|
document.getElementById('editor-content').addEventListener('click', (e) => {
|
|
const mark = e.target.closest('.comment-mark');
|
|
if (mark) showCommentPopup(mark, e);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
// Separator + Comment button
|
|
const sep = document.createElement('span');
|
|
sep.className = 'toolbar-sep';
|
|
bar.appendChild(sep);
|
|
|
|
const commentBtn = document.createElement('button');
|
|
commentBtn.innerHTML = 'Commenter';
|
|
commentBtn.title = 'Ajouter un commentaire sur la sélection';
|
|
commentBtn.style.cssText = 'font-size:13px;';
|
|
commentBtn.onclick = () => addComment();
|
|
bar.appendChild(commentBtn);
|
|
|
|
// 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 = '<span style="font-size:12px;color:var(--text-hint);padding:6px;">Éditeur simplifié — TipTap non chargé</span>';
|
|
}
|
|
|
|
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();
|
|
|
|
// ── Comments ──
|
|
function addComment() {
|
|
if (!tiptapEditor) return;
|
|
const { from, to } = tiptapEditor.state.selection;
|
|
if (from === to) { alert('Sélectionnez du texte pour commenter.'); return; }
|
|
|
|
const author = prompt('Auteur (E ou M) :', 'E');
|
|
if (!author) return;
|
|
const text = prompt('Commentaire :');
|
|
if (!text) return;
|
|
|
|
const now = new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
|
|
tiptapEditor.chain().focus()
|
|
.setMark('comment', { text, author: author.toUpperCase(), date: now })
|
|
.run();
|
|
}
|
|
|
|
let activeCommentMark = null;
|
|
|
|
function showCommentPopup(markEl, event) {
|
|
activeCommentMark = markEl;
|
|
const popup = document.getElementById('comment-popup');
|
|
const author = markEl.getAttribute('data-author') || 'E';
|
|
const authorName = author === 'M' ? 'Myriam' : 'Etienne';
|
|
|
|
document.getElementById('comment-popup-author').textContent = authorName;
|
|
document.getElementById('comment-popup-author').className = `comment-popup-author ${author.toLowerCase()}`;
|
|
document.getElementById('comment-popup-date').textContent = markEl.getAttribute('data-date') || '';
|
|
document.getElementById('comment-popup-text').textContent = markEl.getAttribute('data-comment') || '';
|
|
|
|
// Position near click
|
|
const rect = markEl.getBoundingClientRect();
|
|
popup.style.display = 'block';
|
|
popup.style.top = (rect.bottom + 8) + 'px';
|
|
popup.style.left = Math.min(rect.left, window.innerWidth - 340) + 'px';
|
|
}
|
|
|
|
function closeCommentPopup() {
|
|
document.getElementById('comment-popup').style.display = 'none';
|
|
activeCommentMark = null;
|
|
}
|
|
|
|
function deleteComment() {
|
|
if (!tiptapEditor || !activeCommentMark) return;
|
|
// Find the position of this comment span in the document
|
|
const commentText = activeCommentMark.getAttribute('data-comment');
|
|
const commentDate = activeCommentMark.getAttribute('data-date');
|
|
const doc = tiptapEditor.state.doc;
|
|
let markFrom = null, markTo = null;
|
|
doc.descendants((node, pos) => {
|
|
if (markFrom !== null) return false;
|
|
if (node.isText) {
|
|
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.text === commentText && m.attrs.date === commentDate);
|
|
if (mark) {
|
|
markFrom = pos;
|
|
markTo = pos + node.nodeSize;
|
|
}
|
|
}
|
|
});
|
|
if (markFrom !== null) {
|
|
tiptapEditor.chain().focus().setTextSelection({ from: markFrom, to: markTo }).unsetMark('comment').run();
|
|
}
|
|
closeCommentPopup();
|
|
clearTimeout(saveTimeout);
|
|
saveTimeout = setTimeout(saveContent, 500);
|
|
}
|
|
|
|
// Close popup on click outside
|
|
document.addEventListener('click', (e) => {
|
|
const popup = document.getElementById('comment-popup');
|
|
if (popup.style.display === 'block' && !popup.contains(e.target) && !e.target.closest('.comment-mark')) {
|
|
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, '<')}</div>` : '') +
|
|
(preview ? `<div class="version-item-preview">${preview.replace(/</g, '<').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, '<')}</strong>`);
|
|
if (snap.Acte) parts.push(`<em>${snap.Acte}</em>`);
|
|
if (snap['Résumé']) parts.push(snap['Résumé'].replace(/</g, '<'));
|
|
if (snap['Intérêt narratif']) parts.push('<br><strong>Intérêt :</strong> ' + snap['Intérêt narratif'].replace(/</g, '<'));
|
|
if (snap.Tags) parts.push('<br><em>Tags : ' + snap.Tags.replace(/</g, '<') + '</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;
|
|
const isArchive = versionType === 'archive';
|
|
const msg = isArchive
|
|
? 'Restaurer cette fiche supprimée ? Elle sera recréée dans la frise.'
|
|
: 'Restaurer cette version ? L\u2019état actuel sera sauvegardé avant la restauration.';
|
|
if (!confirm(msg)) return;
|
|
try {
|
|
if (isArchive) {
|
|
// Re-create the card from snapshot
|
|
const v = versionList.find(x => x.Id === selectedVersionId);
|
|
if (!v) return;
|
|
const snap = JSON.parse(v.Snapshot);
|
|
const { Title, Acte, Ordre, Personnage, Tags, Notes } = snap;
|
|
const data = { Title, Acte: 'En attente', Ordre: 999, Personnage, Tags, Notes, 'Résumé': snap['Résumé'], 'Intérêt narratif': snap['Intérêt narratif'], Implications: snap.Implications, Origine: snap.Origine, 'Lien rédaction': snap['Lien rédaction'] };
|
|
const r = await fetch('/api/records', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
|
if (r.ok) {
|
|
closeVersionPanel();
|
|
await loadData();
|
|
} else {
|
|
alert('Erreur lors de la restauration');
|
|
}
|
|
} else {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ── Archives (deleted cards) ──
|
|
async function openArchives() {
|
|
versionType = 'archive';
|
|
versionRecordId = null;
|
|
selectedVersionId = null;
|
|
document.getElementById('version-panel-title').textContent = 'Archives — Fiches supprimées';
|
|
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/archives');
|
|
const data = await r.json();
|
|
versionList = data.list || [];
|
|
renderArchiveList();
|
|
} catch (e) {
|
|
document.getElementById('version-list').innerHTML = '<div style="padding:20px;color:var(--text-muted);text-align:center;">Erreur de chargement</div>';
|
|
}
|
|
}
|
|
|
|
function renderArchiveList() {
|
|
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 fiche supprimée.</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 title = '', preview = '';
|
|
try {
|
|
const snap = JSON.parse(v.Snapshot);
|
|
title = snap.Title || snap.Titre || 'Sans titre';
|
|
preview = [snap.Acte, snap['Résumé']].filter(Boolean).join(' — ');
|
|
} catch (e) { title = 'Fiche'; }
|
|
div.innerHTML = `<div class="version-item-date">${dateStr}</div>` +
|
|
`<div class="version-item-label">${title.replace(/</g, '<')}</div>` +
|
|
(preview ? `<div class="version-item-preview">${preview.replace(/</g, '<').substring(0, 200)}</div>` : '');
|
|
div.onclick = () => selectArchive(v.Id);
|
|
container.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function selectArchive(id) {
|
|
selectedVersionId = id;
|
|
versionType = 'archive';
|
|
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 content = document.getElementById('version-preview-content');
|
|
const parts = [];
|
|
if (snap.Title) parts.push(`<strong>${snap.Title.replace(/</g, '<')}</strong>`);
|
|
if (snap.Acte) parts.push(`<em>${snap.Acte}</em>`);
|
|
if (snap.Personnage) parts.push('Personnages : ' + snap.Personnage.replace(/</g, '<'));
|
|
if (snap['Résumé']) parts.push(snap['Résumé'].replace(/</g, '<'));
|
|
if (snap['Intérêt narratif']) parts.push('<strong>Intérêt :</strong> ' + snap['Intérêt narratif'].replace(/</g, '<'));
|
|
if (snap.Tags) parts.push('<em>Tags : ' + snap.Tags.replace(/</g, '<') + '</em>');
|
|
if (snap.Notes) parts.push('<strong>Notes :</strong> ' + snap.Notes.replace(/</g, '<'));
|
|
content.innerHTML = parts.join('<br>');
|
|
document.getElementById('version-preview').style.display = '';
|
|
document.getElementById('version-actions').style.display = '';
|
|
} catch (e) {
|
|
document.getElementById('version-preview').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
});
|