Personnages: portrait image, scene list moved to modal, card shows count only

- Add portrait upload (reuses ressource-upload endpoint)
- Card shows portrait image, truncated description, scene count badge
- Modal shows full detail + scene list grouped by acte with links
- Removes long scene list from card preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Etienne Delvarre
2026-06-03 13:33:52 +02:00
parent 5d987e1824
commit ff496d1d2d
+125 -63
View File
@@ -233,10 +233,6 @@ window.dispatchEvent(new Event('tiptap-loaded'));
.perso-card-text { font-size: 14px; color: var(--text-muted); line-height: 1.5; } .perso-card-text { font-size: 14px; color: var(--text-muted); line-height: 1.5; }
.perso-card-text p { margin: 0 0 4px; } .perso-card-text p { margin: 0 0 4px; }
.perso-card-text ul, .perso-card-text ol { margin: 0 0 4px; padding-left: 18px; } .perso-card-text ul, .perso-card-text ol { margin: 0 0 4px; padding-left: 18px; }
.perso-card-scenes { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border-light); }
.perso-card-scenes a { font-size: 13px; color: var(--accent); text-decoration: none; cursor: pointer; }
.perso-card-scenes a:hover { text-decoration: underline; color: var(--accent-hover); }
.perso-card-scenes-list { display: flex; flex-wrap: wrap; gap: 4px 10px; }
/* Tags input */ /* Tags input */
.tags-input-container { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-input); align-items: center; min-height: 36px; } .tags-input-container { display: flex; flex-wrap: wrap; gap: 6px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-input); align-items: center; min-height: 36px; }
@@ -491,13 +487,22 @@ window.dispatchEvent(new Event('tiptap-loaded'));
.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; } .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
.page-header h2 { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 600; margin: 0; } .page-header h2 { font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 600; margin: 0; }
.perso-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 20px; } .perso-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 20px; }
.perso-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; cursor: pointer; transition: box-shadow 0.15s; } .perso-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; cursor: pointer; transition: box-shadow 0.15s; }
.perso-card:hover { box-shadow: 0 4px 14px var(--shadow-hover); } .perso-card:hover { box-shadow: 0 4px 14px var(--shadow-hover); }
.perso-card-portrait { width: 100%; height: 200px; object-fit: cover; background: var(--bg-alt); }
.perso-card-body { padding: 16px 20px; }
.perso-card-name { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; margin-bottom: 4px; } .perso-card-name { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; margin-bottom: 4px; }
.perso-card-role { font-size: 14px; color: var(--accent); margin-bottom: 8px; font-weight: 500; } .perso-card-role { font-size: 14px; color: var(--accent); margin-bottom: 8px; font-weight: 500; }
.perso-card-desc { font-size: 14px; color: var(--text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; } .perso-card-desc { font-size: 14px; color: var(--text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.perso-card-tags { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; } .perso-card-tags { margin-top: 10px; display: flex; gap: 6px; flex-wrap: wrap; }
.perso-card-scene-count { margin-top: 10px; font-size: 12px; color: var(--text-hint); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
.perso-modal-scenes { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.perso-modal-scenes-title { font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-hint); font-weight: 600; margin-bottom: 8px; }
.perso-modal-scenes-acte { font-size: 11px; color: var(--accent); font-weight: 600; margin-top: 8px; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
.perso-modal-scenes-list { display: flex; flex-direction: column; gap: 2px; }
.perso-modal-scenes-list a { font-size: 13px; color: var(--text); text-decoration: none; cursor: pointer; padding: 2px 0; }
.perso-modal-scenes-list a:hover { color: var(--accent); }
.res-filters { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; flex-wrap: wrap; } .res-filters { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; flex-wrap: wrap; }
.res-filters select, .res-filters input { padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; color: var(--text); background: var(--bg-input); outline: none; } .res-filters select, .res-filters input { padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; color: var(--text); background: var(--bg-input); outline: none; }
@@ -797,10 +802,12 @@ window.dispatchEvent(new Event('tiptap-loaded'));
<div class="modal-body"> <div class="modal-body">
<div class="field field-title"><input type="text" id="fp-nom" placeholder="Nom du personnage..."></div> <div class="field field-title"><input type="text" id="fp-nom" placeholder="Nom du personnage..."></div>
<div class="field"><label>Rôle</label><input type="text" id="fp-role" placeholder="Héros, antagoniste, allié..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:'Lora',serif;color:var(--text);background:var(--bg-input);"></div> <div class="field"><label>Rôle</label><input type="text" id="fp-role" placeholder="Héros, antagoniste, allié..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:'Lora',serif;color:var(--text);background:var(--bg-input);"></div>
<div class="field"><label>Portrait</label><input type="file" id="fp-image-input" accept="image/*" onchange="previewPersoImage(this)" style="font-family:'Lora',serif;font-size:13px;"><div id="fp-image-preview" style="margin-top:8px;"></div></div>
<div class="field"><label>Description</label><textarea id="fp-desc" placeholder="Personnalité, arc, motivations..." rows="4"></textarea></div> <div class="field"><label>Description</label><textarea id="fp-desc" placeholder="Personnalité, arc, motivations..." rows="4"></textarea></div>
<div class="field"><label>Origines et influences</label><textarea id="fp-origines" placeholder="D'où vient le personnage, quels personnages de fiction l'ont inspiré..." rows="3"></textarea></div> <div class="field"><label>Origines et influences</label><textarea id="fp-origines" placeholder="D'où vient le personnage, quels personnages de fiction l'ont inspiré..." rows="3"></textarea></div>
<div class="field"><label>Backstory</label><textarea id="fp-backstory" placeholder="Son passé, ce qui l'a façonné avant le début de l'histoire..." rows="4"></textarea></div> <div class="field"><label>Backstory</label><textarea id="fp-backstory" placeholder="Son passé, ce qui l'a façonné avant le début de l'histoire..." rows="4"></textarea></div>
<div class="field"><label>Tags</label><input type="text" id="fp-tags" placeholder="Tags séparés par des virgules..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:'Lora',serif;color:var(--text);background:var(--bg-input);"></div> <div class="field"><label>Tags</label><input type="text" id="fp-tags" placeholder="Tags séparés par des virgules..." style="width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;font-size:14px;font-family:'Lora',serif;color:var(--text);background:var(--bg-input);"></div>
<div id="fp-scenes-container"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-delete" id="btn-delete-perso" onclick="deletePerso()" style="display:none;">Supprimer</button> <button class="btn-delete" id="btn-delete-perso" onclick="deletePerso()" style="display:none;">Supprimer</button>
<button class="btn-save" onclick="savePerso()">Enregistrer</button> <button class="btn-save" onclick="savePerso()">Enregistrer</button>
@@ -2049,6 +2056,7 @@ loadVision();
let personnages = []; let personnages = [];
let editingPersoId = null; let editingPersoId = null;
let pendingPersoImage = null;
async function loadPersonnages() { async function loadPersonnages() {
try { try {
@@ -2068,55 +2076,38 @@ function renderPersonnages() {
card.className = 'perso-card'; card.className = 'perso-card';
card.onclick = () => openPersoEdit(p.Id); card.onclick = () => openPersoEdit(p.Id);
// Header // 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'); const nameEl = document.createElement('div');
nameEl.className = 'perso-card-name'; nameEl.className = 'perso-card-name';
nameEl.textContent = p.Nom || ''; nameEl.textContent = p.Nom || '';
card.appendChild(nameEl); body.appendChild(nameEl);
// Rôle
if (p.Role) { if (p.Role) {
const roleEl = document.createElement('div'); const roleEl = document.createElement('div');
roleEl.className = 'perso-card-role'; roleEl.className = 'perso-card-role';
roleEl.textContent = p.Role; roleEl.textContent = p.Role;
card.appendChild(roleEl); body.appendChild(roleEl);
} }
// Description (markdown) // Description (tronquée)
if (p.Description) { if (p.Description) {
const descEl = document.createElement('div'); const descEl = document.createElement('div');
descEl.className = 'perso-card-desc perso-card-text'; descEl.className = 'perso-card-desc perso-card-text';
descEl.appendChild(createSanitizedFragment(renderMarkdown(p.Description))); descEl.appendChild(createSanitizedFragment(renderMarkdown(p.Description)));
card.appendChild(descEl); body.appendChild(descEl);
}
// Origines et influences (markdown)
if (p.Origines) {
const section = document.createElement('div');
section.className = 'perso-card-section';
const title = document.createElement('div');
title.className = 'perso-card-section-title';
title.textContent = 'Origines et influences';
section.appendChild(title);
const text = document.createElement('div');
text.className = 'perso-card-text';
text.appendChild(createSanitizedFragment(renderMarkdown(p.Origines)));
section.appendChild(text);
card.appendChild(section);
}
// Backstory (markdown)
if (p.Backstory) {
const section = document.createElement('div');
section.className = 'perso-card-section';
const title = document.createElement('div');
title.className = 'perso-card-section-title';
title.textContent = 'Backstory';
section.appendChild(title);
const text = document.createElement('div');
text.className = 'perso-card-text';
text.appendChild(createSanitizedFragment(renderMarkdown(p.Backstory)));
section.appendChild(text);
card.appendChild(section);
} }
// Tags // Tags
@@ -2130,42 +2121,79 @@ function renderPersonnages() {
badge.textContent = t.trim(); badge.textContent = t.trim();
tagsEl.appendChild(badge); tagsEl.appendChild(badge);
}); });
card.appendChild(tagsEl); body.appendChild(tagsEl);
} }
// Liens scènes automatiques (triés dans l'ordre narratif : acte puis ordre) // Compteur scènes
const linkedScenes = records.filter(r => const sceneCount = records.filter(r =>
r.Personnage && r.Personnage.split(',').map(s => s.trim()).includes(p.Nom) 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) => { ).sort((a, b) => {
const ai = ACTE_ORDER.indexOf(a.Acte), bi = ACTE_ORDER.indexOf(b.Acte); 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); if (ai !== bi) return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
return (a.Ordre || 0) - (b.Ordre || 0); return (a.Ordre || 0) - (b.Ordre || 0);
}); });
if (linkedScenes.length) {
const scenesEl = document.createElement('div');
scenesEl.className = 'perso-card-scenes';
const scenesTitle = document.createElement('div');
scenesTitle.className = 'perso-card-section-title';
scenesTitle.textContent = `Scènes (${linkedScenes.length})`;
scenesEl.appendChild(scenesTitle);
const list = document.createElement('div');
list.className = 'perso-card-scenes-list';
linkedScenes.forEach(scene => {
const link = document.createElement('a');
link.textContent = scene.Title;
link.onclick = (e) => { e.stopPropagation(); switchView('frise'); setTimeout(() => openEdit(scene.Id), 200); };
list.appendChild(link);
});
scenesEl.appendChild(list);
card.appendChild(scenesEl);
} }
grid.appendChild(card); 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() { function openPersoModal() {
editingPersoId = null; editingPersoId = null;
pendingPersoImage = null;
document.getElementById('perso-modal-label').textContent = 'Nouveau personnage'; document.getElementById('perso-modal-label').textContent = 'Nouveau personnage';
document.getElementById('fp-nom').value = ''; document.getElementById('fp-nom').value = '';
document.getElementById('fp-role').value = ''; document.getElementById('fp-role').value = '';
@@ -2173,6 +2201,9 @@ function openPersoModal() {
document.getElementById('fp-origines').value = ''; document.getElementById('fp-origines').value = '';
document.getElementById('fp-backstory').value = ''; document.getElementById('fp-backstory').value = '';
document.getElementById('fp-tags').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('btn-delete-perso').style.display = 'none';
document.getElementById('perso-modal').classList.add('open'); document.getElementById('perso-modal').classList.add('open');
} }
@@ -2181,6 +2212,7 @@ function openPersoEdit(id) {
const p = personnages.find(x => x.Id === id); const p = personnages.find(x => x.Id === id);
if (!p) return; if (!p) return;
editingPersoId = id; editingPersoId = id;
pendingPersoImage = null;
document.getElementById('perso-modal-label').textContent = 'Modifier'; document.getElementById('perso-modal-label').textContent = 'Modifier';
document.getElementById('fp-nom').value = p.Nom || ''; document.getElementById('fp-nom').value = p.Nom || '';
document.getElementById('fp-role').value = p.Role || ''; document.getElementById('fp-role').value = p.Role || '';
@@ -2188,6 +2220,9 @@ function openPersoEdit(id) {
document.getElementById('fp-origines').value = p.Origines || ''; document.getElementById('fp-origines').value = p.Origines || '';
document.getElementById('fp-backstory').value = p.Backstory || ''; document.getElementById('fp-backstory').value = p.Backstory || '';
document.getElementById('fp-tags').value = p.Tags || ''; 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('btn-delete-perso').style.display = 'block';
document.getElementById('perso-modal').classList.add('open'); document.getElementById('perso-modal').classList.add('open');
} }
@@ -2195,9 +2230,34 @@ function openPersoEdit(id) {
function closePersoModal() { function closePersoModal() {
document.getElementById('perso-modal').classList.remove('open'); document.getElementById('perso-modal').classList.remove('open');
editingPersoId = null; 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() { 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 = { const data = {
Nom: document.getElementById('fp-nom').value, Nom: document.getElementById('fp-nom').value,
Role: document.getElementById('fp-role').value || null, Role: document.getElementById('fp-role').value || null,
@@ -2206,6 +2266,8 @@ async function savePerso() {
Backstory: document.getElementById('fp-backstory').value || null, Backstory: document.getElementById('fp-backstory').value || null,
Tags: document.getElementById('fp-tags').value || null, Tags: document.getElementById('fp-tags').value || null,
}; };
if (imagePath) data.Image = imagePath;
if (editingPersoId) { if (editingPersoId) {
await fetch(`/api/personnages/${editingPersoId}`, { await fetch(`/api/personnages/${editingPersoId}`, {
method: 'PATCH', method: 'PATCH',