Files
tdb-rdb/public/index.html
T
Etienne Delvarre 2c170f9ef2 Archives — panneau des fiches supprimées avec restauration
Bouton Archives dans la barre de filtres de la frise.
Panneau latéral listant les fiches supprimées avec aperçu complet.
Restauration recrée la fiche dans la frise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-03 15:34:27 +02:00

3312 lines
146 KiB
HTML

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tableau de bord — RdB</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@15.0.0/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
<script type="module">
import { Editor, Mark } from 'https://esm.sh/@tiptap/core@2.11.7';
import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.11.7';
const Comment = Mark.create({
name: 'comment',
addAttributes() {
return {
text: { default: '', parseHTML: el => el.getAttribute('data-comment') || '' },
author: { default: 'E', parseHTML: el => el.getAttribute('data-author') || 'E' },
date: { default: '', parseHTML: el => el.getAttribute('data-date') || '' },
};
},
parseHTML() {
return [{ tag: 'span[data-comment]' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', {
'data-comment': HTMLAttributes.text,
'data-author': HTMLAttributes.author,
'data-date': HTMLAttributes.date,
class: `comment-mark comment-${(HTMLAttributes.author || 'E').toLowerCase()}`,
}, 0];
},
});
window.TiptapEditor = Editor;
window.TiptapStarterKit = StarterKit;
window.TiptapComment = Comment;
window.dispatchEvent(new Event('tiptap-loaded'));
</script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
/* ── Light theme (default) ── */
:root {
--bg: #e8e0d4;
--bg-alt: #ddd5c8;
--bg-card: #f0e9de;
--bg-input: #e8e0d4;
--border: #cdc0b0;
--border-light: #ddd6ca;
--text: #2c1810;
--text-muted: #6e5d4e;
--text-hint: #8a7562;
--text-light: #a89888;
--accent: #7a6050;
--accent-hover: #5c3d2e;
--brown: #5c3d2e;
--terra: #b85c3a;
--olive: #6a7a42;
--blue-grey: #6a7a8a;
--plum: #8a6a8a;
--shadow: rgba(92,61,46,0.10);
--shadow-hover: rgba(92,61,46,0.18);
--overlay: rgba(44,24,16,0.5);
}
/* ── Dark theme ── */
.dark {
--bg: #1a1614;
--bg-alt: #231f1c;
--bg-card: #2a2420;
--bg-input: #1a1614;
--border: #3d3530;
--border-light: #332c27;
--text: #e8ddd0;
--text-muted: #a09080;
--text-hint: #807060;
--text-light: #5a4e44;
--accent: #c4a882;
--accent-hover: #e0c8a8;
--brown: #c4a882;
--terra: #d4845a;
--olive: #8aaa5a;
--blue-grey: #8a9aaa;
--plum: #aa8aaa;
--shadow: rgba(0,0,0,0.2);
--shadow-hover: rgba(0,0,0,0.35);
--overlay: rgba(0,0,0,0.6);
}
body { font-family: 'Lora', Georgia, serif; background: var(--bg); color: var(--text); min-height: 100vh; transition: background 0.3s, color 0.3s; }
/* Layout */
.app { display: flex; min-height: 100vh; }
/* Sidebar */
.sidebar { width: 220px; background: var(--bg-alt); border-right: 1px solid var(--border); padding: 24px 0; flex-shrink: 0; display: flex; flex-direction: column; }
.sidebar-logo { padding: 0 22px 20px; font-family: 'Playfair Display', serif; font-size: 22px; font-weight: 700; color: var(--brown); }
.sidebar-project { padding: 12px 16px; margin: 0 14px 18px; background: var(--bg-card); border-radius: 10px; font-size: 16px; color: var(--brown); border: 1px solid var(--border); }
.nav-item { padding: 12px 18px; margin: 2px 14px; border-radius: 10px; font-size: 17px; color: var(--text-muted); cursor: pointer; display: flex; align-items: center; gap: 10px; }
.nav-item:hover { background: var(--border); color: var(--text); }
.nav-item.active { background: var(--border); color: var(--text); font-weight: 500; }
.nav-icon { width: 34px; height: 34px; background: var(--border); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
.nav-icon svg { width: 16px; height: 16px; stroke: var(--text-muted); fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.nav-item.active .nav-icon svg { stroke: var(--text); }
.nav-icon svg.fill-icon { fill: var(--text-muted); stroke: none; }
.nav-item.active .nav-icon svg.fill-icon { fill: var(--text); }
.nav-item.active .nav-icon .frise-dot { fill: var(--text); }
.sidebar-footer { margin-top: auto; padding: 16px 22px; font-size: 13px; color: var(--text-hint); }
/* Main */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* Topbar */
.topbar { height: 58px; background: var(--bg-card); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; padding: 0 28px; flex-shrink: 0; }
.topbar-title { font-family: 'Playfair Display', serif; font-size: 26px; font-weight: 600; }
.topbar-right { display: flex; align-items: center; gap: 14px; }
.btn-new { padding: 10px 22px; background: var(--accent); color: var(--bg); border: none; border-radius: 8px; font-size: 16px; font-weight: 600; font-family: 'Lora', serif; cursor: pointer; }
.btn-new:hover { background: var(--accent-hover); }
.dark-toggle { width: 36px; height: 20px; background: var(--border); border-radius: 10px; position: relative; cursor: pointer; border: none; flex-shrink: 0; }
.dark-toggle::after { content: '☀'; font-size: 12px; position: absolute; top: 2px; left: 3px; width: 16px; height: 16px; background: var(--bg-card); border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: left 0.2s; }
.dark .dark-toggle::after { content: '☾'; left: 17px; }
.avatar { width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 600; color: #fff; }
.avatar.e { background: #5c3d2e; }
.avatar.m { background: #b85c3a; }
/* ── Filter bar ── */
.filter-bar { background: var(--bg-alt); border-bottom: 1px solid var(--border); padding: 10px 28px; display: flex; align-items: center; gap: 12px; flex-shrink: 0; flex-wrap: wrap; }
.filter-bar select, .filter-bar input { padding: 7px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; color: var(--text); background: var(--bg-input); outline: none; }
.filter-bar select:focus, .filter-bar input:focus { border-color: var(--accent); }
.filter-bar label { font-size: 12px; color: var(--text-hint); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
.filter-group { display: flex; align-items: center; gap: 6px; }
.btn-clear-filters { padding: 6px 14px; background: transparent; border: 1px solid var(--border); border-radius: 8px; font-size: 12px; font-family: 'Lora', serif; color: var(--text-muted); cursor: pointer; }
.btn-clear-filters:hover { background: var(--border); color: var(--text); }
.card.filtered-out { display: none; }
/* ── Frise container ── */
.frise-wrapper { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* Timeline area */
.timeline-area { flex: 1; overflow-x: auto; overflow-y: auto; padding: 20px 24px 12px; position: relative; }
/* Acte markers row */
.acte-markers { display: flex; gap: 0; min-width: min-content; margin-bottom: 0; position: relative; }
.acte-section { min-width: 200px; flex-shrink: 0; position: relative; }
.acte-header { text-align: center; padding-bottom: 12px; }
.acte-label { font-family: 'Playfair Display', serif; font-size: 18px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; }
.acte-section[data-acte="Avant l'histoire"] .acte-label { color: var(--blue-grey); }
.acte-section[data-acte="Acte 1"] .acte-label { color: var(--brown); }
.acte-section[data-acte="Acte 2"] .acte-label { color: var(--terra); }
.acte-section[data-acte="Acte 3"] .acte-label { color: var(--olive); }
.acte-section[data-acte="Après l'histoire"] .acte-label { color: var(--plum); }
/* Timeline line */
.timeline-line { height: 3px; background: var(--border); display: flex; min-width: min-content; border-radius: 2px; }
.timeline-segment { flex-shrink: 0; height: 3px; }
.acte-section[data-acte="Avant l'histoire"] .timeline-segment { background: var(--blue-grey); }
.acte-section[data-acte="Acte 1"] .timeline-segment { background: var(--brown); }
.acte-section[data-acte="Acte 2"] .timeline-segment { background: var(--terra); }
.acte-section[data-acte="Acte 3"] .timeline-segment { background: var(--olive); }
.acte-section[data-acte="Après l'histoire"] .timeline-segment { background: var(--plum); }
/* Cards row under each acte */
.cards-row { display: flex; gap: 14px; padding: 16px 10px 10px; min-height: 140px; align-items: flex-start; }
/* Cards */
.card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 18px 20px; cursor: grab; border-top: 5px solid var(--border); width: 260px; min-width: 260px; flex-shrink: 0; box-shadow: 0 1px 4px var(--shadow); transition: box-shadow 0.15s; }
.card:hover { box-shadow: 0 4px 14px var(--shadow-hover); }
.card.sortable-ghost { opacity: 0.3; }
.card.sortable-chosen { box-shadow: 0 6px 20px var(--shadow-hover); }
.card-title { font-family: 'Playfair Display', serif; font-size: 18px; font-weight: 600; margin-bottom: 8px; color: var(--text); }
.card-summary { font-size: 15px; color: var(--text-muted); line-height: 1.5; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 8; -webkit-box-orient: vertical; overflow: hidden; }
.card-summary p { margin: 0 0 4px; }
.card-summary ul, .card-summary ol { margin: 0 0 4px; padding-left: 18px; }
.card-summary li { margin-bottom: 2px; }
.card-meta { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.card-badge { font-size: 13px; padding: 4px 10px; border-radius: 8px; font-weight: 500; }
.card-likes { display: flex; gap: 4px; margin-left: auto; }
.like-btn { background: none; border: none; cursor: pointer; padding: 3px 6px; border-radius: 6px; font-size: 14px; color: var(--text-light); transition: color 0.15s, transform 0.15s; display: flex; align-items: center; gap: 2px; }
.like-btn:hover { color: var(--accent); transform: scale(1.15); }
.like-btn.liked { color: var(--terra); }
.like-btn .like-who { font-size: 10px; font-weight: 700; letter-spacing: 0.3px; }
.card-comment-count { font-size: 12px; color: var(--text-light); display: flex; align-items: center; gap: 3px; margin-left: 4px; }
.card-comment-count span { font-size: 13px; }
/* Comments in modal */
.modal-comments { border-top: 1px solid var(--border); padding-top: 16px; margin-top: 8px; }
.modal-comments h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-hint); font-weight: 600; margin-bottom: 12px; }
.comment-list { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; max-height: 240px; overflow-y: auto; }
.comment-item { background: var(--bg); border-radius: 8px; padding: 10px 14px; }
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.comment-author { font-size: 12px; font-weight: 700; }
.comment-author.e { color: var(--brown); }
.comment-author.m { color: var(--plum); }
.comment-date { font-size: 11px; color: var(--text-light); }
.comment-delete { margin-left: auto; background: none; border: none; font-size: 13px; color: var(--text-light); cursor: pointer; padding: 0 4px; }
.comment-delete:hover { color: #8b3030; }
.comment-text { font-size: 14px; color: var(--text); line-height: 1.5; }
.comment-add { display: flex; flex-direction: column; gap: 8px; }
.comment-add textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; color: var(--text); background: var(--bg-input); resize: none; min-height: 36px; max-height: 80px; outline: none; }
.comment-add textarea:focus { border-color: var(--accent); }
.comment-add-btns { display: flex; gap: 8px; justify-content: flex-end; }
.comment-send { padding: 6px 14px; background: var(--accent); color: var(--bg); border: none; border-radius: 6px; font-size: 12px; font-weight: 600; font-family: 'Lora', serif; cursor: pointer; white-space: nowrap; }
.comment-send:hover { background: var(--accent-hover); }
.comment-author-toggle { padding: 5px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; font-size: 11px; font-weight: 700; cursor: pointer; font-family: 'Lora', serif; color: var(--text-muted); }
.comment-author-toggle:hover { border-color: var(--accent); }
/* Acte colors on card top border */
.card[data-acte="Avant l'histoire"] { border-top-color: var(--blue-grey); }
.card[data-acte="Acte 1"] { border-top-color: var(--brown); }
.card[data-acte="Acte 2"] { border-top-color: var(--terra); }
.card[data-acte="Acte 3"] { border-top-color: var(--olive); }
.card[data-acte="Après l'histoire"] { border-top-color: var(--plum); }
.card[data-acte="En attente"] { border-top-color: var(--border); }
/* Status badge colors (now applied to tags) */
.badge-validee { background: #eef3e8; color: #5a7a3a; }
.badge-etudier { background: #f5f0e4; color: #a07850; }
.badge-abandonnee { background: var(--border); color: var(--text-hint); text-decoration: line-through; }
.badge-conflit { background: #f5e4e4; color: #8b3030; }
.dark .badge-etudier { background: #3a3020; color: #c49860; }
.dark .badge-validee { background: #2a3520; color: #8aaa6a; }
.dark .badge-conflit { background: #3a2020; color: #c06060; }
.badge-perso { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
.badge-tag { background: #ede8e0; color: #6b5a4a; border: 1px solid #d4c9ba; font-size: 12px; }
.dark .badge-tag { background: #3a3020; color: #c4a870; border-color: #5a4a30; }
/* Personnages enrichis */
.perso-card-section { margin-top: 10px; }
.perso-card-section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-hint); font-weight: 600; margin-bottom: 4px; }
.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 ul, .perso-card-text ol { margin: 0 0 4px; padding-left: 18px; }
/* 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:focus-within { border-color: var(--accent); }
.tags-input { border: none; outline: none; font-size: 13px; font-family: 'Lora', serif; color: var(--text); background: transparent; flex: 1; min-width: 80px; padding: 2px 0; }
.tag-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; background: #ede8e0; color: #6b5a4a; border-radius: 6px; font-size: 12px; font-family: 'Lora', serif; }
.dark .tag-chip { background: #3a3020; color: #c4a870; }
.tag-chip-remove { cursor: pointer; font-size: 14px; line-height: 1; opacity: 0.6; }
.tag-chip-remove:hover { opacity: 1; }
/* ── Waiting zone (bottom) ── */
.waiting-zone { border-top: 2px dashed var(--border); padding: 14px 24px; background: var(--bg-alt); flex-shrink: 0; }
.waiting-header { font-size: 13px; color: var(--text-hint); text-transform: uppercase; letter-spacing: 0.8px; margin-bottom: 10px; font-weight: 600; }
.waiting-cards { display: flex; gap: 12px; overflow-x: auto; min-height: 100px; padding: 4px 0; align-items: flex-start; }
.waiting-cards .card { border-style: dashed; border-top: 4px dashed var(--border); }
/* ── Modal ── */
.modal-overlay { display: none; position: fixed; inset: 0; background: var(--overlay); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.open { display: flex; }
.modal { background: var(--bg-card); border-radius: 16px; width: 580px; max-width: 92vw; max-height: 88vh; overflow-y: auto; box-shadow: 0 12px 40px var(--shadow-hover); }
.modal-header { padding: 22px 26px 0; display: flex; justify-content: space-between; align-items: start; }
.modal-close { background: none; border: none; font-size: 22px; color: var(--text-hint); cursor: pointer; padding: 4px 8px; }
.modal-close:hover { color: var(--text); }
.modal-body { padding: 14px 26px 26px; }
.modal-body .field { margin-bottom: 16px; }
.modal-body .field label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-hint); font-weight: 600; margin-bottom: 5px; }
.modal-body .field input,
.modal-body .field textarea,
.modal-body .field select { width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: 14px; font-family: 'Lora', serif; color: var(--text); outline: none; background: var(--bg-input); }
.modal-body .field input:focus,
.modal-body .field textarea:focus,
.modal-body .field select:focus { border-color: var(--accent); }
.modal-body .field textarea { min-height: 70px; resize: vertical; line-height: 1.6; }
.modal-body .field textarea#f-resume { min-height: 140px; }
.modal-body .field-title input { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; border: none; background: transparent; padding: 0; }
.modal-body .field-title input:focus { border-bottom: 2px solid var(--accent); }
.modal-body .field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.multi-select-container { display: flex; flex-wrap: wrap; gap: 5px; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-input); min-height: 38px; }
.multi-select-container .ms-tag { font-size: 12px; padding: 3px 9px; border-radius: 6px; background: var(--border); color: var(--text-muted); cursor: pointer; }
.multi-select-container .ms-tag.selected { background: var(--accent); color: var(--bg); }
.multi-select-container .ms-add { font-size: 12px; padding: 3px 9px; border-radius: 6px; background: transparent; border: 1px dashed var(--border); color: var(--text-hint); cursor: pointer; }
.multi-select-container .ms-add:hover { border-color: var(--accent); color: var(--accent); }
.modal-actions { display: flex; gap: 10px; align-items: center; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-light); }
.modal-actions-spacer { flex: 1; }
.btn-save { padding: 9px 22px; background: var(--accent); color: var(--bg); border: none; border-radius: 8px; font-size: 14px; font-weight: 600; font-family: 'Lora', serif; cursor: pointer; }
.btn-save:hover { background: var(--accent-hover); }
.btn-delete { padding: 9px 14px; background: transparent; color: #8b3030; border: 1px solid #e8c4c0; border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; cursor: pointer; }
.btn-delete:hover { background: #fdf0ef; }
.dark .btn-delete { border-color: #5a3030; }
.dark .btn-delete:hover { background: #3a2020; }
.loading { text-align: center; padding: 60px; color: var(--text-hint); font-style: italic; }
/* ── Vision page ── */
.view { display: none; }
.view.active { display: flex; flex-direction: column; flex: 1; overflow: hidden; }
.vision-content { flex: 1; overflow-y: auto; padding: 28px 36px; }
.vision-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
.v-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; box-shadow: 0 1px 4px var(--shadow); }
.v-card.full { grid-column: 1 / -1; }
.v-card h3 { font-family: 'Playfair Display', serif; font-size: 14px; font-weight: 600; color: var(--text-hint); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px; }
.hero-banner { grid-column: 1 / -1; border-radius: 12px; height: 400px; background-image: linear-gradient(135deg, #d4a574 0%, #c4956a 30%, #a07850 60%, #7a5c40 100%); background-size: cover; background-position: center; background-repeat: no-repeat; position: relative; overflow: hidden; display: flex; align-items: flex-end; }
.dark .hero-banner { background-image: linear-gradient(135deg, #5a4030 0%, #4a3528 30%, #3a2a20 60%, #2a1e18 100%); }
.hero-banner.has-image { background-color: #1a1614; cursor: grab; }
.hero-banner.has-image.dragging { cursor: grabbing; }
.hero-banner::after { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 30%, rgba(44,24,16,0.55)); pointer-events: none; }
.hero-overlay { position: relative; z-index: 1; padding: 24px 32px; width: 100%; pointer-events: none; }
.hero-overlay [contenteditable] { pointer-events: auto; }
.hero-controls { position: absolute; top: 10px; right: 12px; z-index: 3; display: flex; gap: 8px; align-items: center; opacity: 0; transition: opacity 0.2s; }
.hero-banner:hover .hero-controls { opacity: 1; }
.hero-btn { font-size: 12px; color: rgba(255,255,255,0.8); background: rgba(44,24,16,0.5); padding: 4px 12px; border-radius: 6px; border: none; cursor: pointer; backdrop-filter: blur(4px); }
.hero-btn:hover { background: rgba(44,24,16,0.7); color: #fff; }
.hero-overlay .three-words { font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; color: rgba(255,255,255,0.7); letter-spacing: 2px; }
.hero-overlay .hero-sub { font-size: 14px; color: rgba(255,255,255,0.75); margin-top: 6px; font-style: italic; }
.vision-phrase { font-family: 'Playfair Display', serif; font-size: 22px; font-weight: 400; font-style: italic; line-height: 1.7; color: var(--text); }
.vision-detail { font-size: 15px; color: var(--text-muted); line-height: 1.8; margin-top: 12px; }
.tag-group { display: flex; flex-wrap: wrap; gap: 8px; }
.tag { padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 500; }
.tag.ambiance { background: #f5ede4; color: #8b5e3c; border: 1px solid #ddc9b4; }
.tag.epoque { background: #eef3e8; color: #5a7a3a; border: 1px solid #c8d8b4; }
.tag.rythme { background: #f5e8e4; color: #a0523d; border: 1px solid #ddc0b4; }
.tag.genre { background: #e8ecf3; color: #4a5a7a; border: 1px solid #b4c0d8; }
.tag.theme { background: #f0eaf5; color: #6a4a7a; border: 1px solid #d0c0e0; }
.dark .tag.ambiance { background: #3a2e20; color: #c49060; border-color: #5a4530; }
.dark .tag.epoque { background: #2a3520; color: #8aaa6a; border-color: #3a4a30; }
.dark .tag.rythme { background: #3a2520; color: #c07050; border-color: #5a3530; }
.dark .tag.genre { background: #20283a; color: #7a8aaa; border-color: #303a50; }
.dark .tag.theme { background: #2a2035; color: #a080b0; border-color: #403050; }
.two-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.list-block h4 { font-family: 'Playfair Display', serif; font-size: 15px; font-weight: 600; margin-bottom: 12px; }
.list-block h4.yes, .v-card h3.yes { color: #5a7a3a; }
.list-block h4.no, .v-card h3.no { color: #a0523d; }
.dark .list-block h4.yes, .dark .v-card h3.yes { color: #8aaa6a; }
.dark .list-block h4.no, .dark .v-card h3.no { color: #c07050; }
.list-item { padding: 10px 0; font-size: 15px; color: var(--text); display: flex; align-items: flex-start; gap: 8px; }
.list-item .bullet { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 8px; }
.list-item.yes .bullet { background: #5a7a3a; }
.list-item.no .bullet { background: #a0523d; }
.dark .list-item.yes .bullet { background: #8aaa6a; }
.dark .list-item.no .bullet { background: #c07050; }
.list-item-title { font-weight: 600; }
.list-item-desc { font-size: 14px; color: var(--text-muted); line-height: 1.6; margin-top: 4px; }
.list-item.expanded { background: var(--bg-alt); border-radius: 8px; padding: 12px; border-left: 3px solid; margin: 4px 0; }
.list-item.expanded.yes { border-left-color: #5a7a3a; }
.list-item.expanded.no { border-left-color: #a0523d; }
.dark .list-item.expanded.yes { border-left-color: #8aaa6a; }
.dark .list-item.expanded.no { border-left-color: #c07050; }
.list-item-expand { font-size: 12px; color: var(--text-hint); cursor: pointer; margin-left: 8px; white-space: nowrap; }
.list-item-expand:hover { color: var(--accent); }
.mood-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 18px; }
.mood-card { position: relative; border-radius: 10px; overflow: hidden; background: var(--bg-alt); border: 1px solid var(--border); }
.mood-card-img { width: 100%; aspect-ratio: 1; background-size: cover; background-position: center; background-repeat: no-repeat; cursor: pointer; background-color: var(--bg-alt); display: flex; align-items: center; justify-content: center; user-select: none; }
.mood-card-img.has-image { cursor: grab; }
.mood-card-img.has-image.dragging { cursor: grabbing; }
.mood-card-img.empty { border: 1px dashed var(--border); border-radius: 10px 10px 0 0; }
.mood-card-img.empty::after { content: '+'; font-size: 28px; color: var(--text-hint); }
.mood-card-caption { padding: 8px 10px; font-size: 13px; color: var(--text); min-height: 32px; }
.mood-card-remove { position: absolute; top: 4px; right: 4px; display: none; background: rgba(139,48,48,0.8); color: #fff; border: none; border-radius: 50%; width: 20px; height: 20px; font-size: 12px; cursor: pointer; align-items: center; justify-content: center; z-index: 2; }
.mood-card:hover .mood-card-remove { display: flex; }
.mood-card-handle { position: absolute; top: 4px; left: 4px; display: none; background: rgba(0,0,0,0.5); color: #fff; border: none; border-radius: 4px; width: 22px; height: 22px; font-size: 14px; cursor: grab; align-items: center; justify-content: center; z-index: 2; user-select: none; }
.mood-card:hover .mood-card-handle { display: flex; }
.mood-card-handle:active { cursor: grabbing; }
.mood-card.drag-over { outline: 2px solid var(--accent); outline-offset: -2px; }
.mood-card.dragging-card { opacity: 0.4; }
.mood-add { border: 1px dashed var(--border); background: transparent; display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer; min-height: 120px; border-radius: 10px; }
.mood-add:hover { background: var(--bg-alt); }
.mood-add-icon { font-size: 24px; color: var(--text-hint); }
.vision-quote { font-family: 'Playfair Display', serif; font-size: 17px; font-style: italic; color: var(--text-muted); line-height: 1.7; border-left: 3px solid var(--accent); padding-left: 16px; margin: 12px 0; }
/* ── Editable Vision ── */
[contenteditable] { outline: none; border-radius: 6px; transition: background 0.15s, box-shadow 0.15s; cursor: text; }
[contenteditable]:hover { background: rgba(138,112,96,0.06); }
[contenteditable]:focus { background: rgba(138,112,96,0.08); box-shadow: 0 0 0 2px var(--accent); }
.dark [contenteditable]:hover { background: rgba(196,168,130,0.08); }
.dark [contenteditable]:focus { background: rgba(196,168,130,0.1); }
[contenteditable].placeholder { color: var(--text-light); font-style: italic; }
.vision-saving { position: fixed; bottom: 20px; right: 20px; background: var(--accent); color: var(--bg); padding: 8px 18px; border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; opacity: 0; transition: opacity 0.3s; z-index: 200; pointer-events: none; }
.vision-saving.show { opacity: 1; }
.tag-remove { display: none; margin-left: 4px; cursor: pointer; font-size: 11px; opacity: 0.5; }
.tag:hover .tag-remove { display: inline; }
.tag-remove:hover { opacity: 1; }
.tag-add { padding: 8px 16px; border-radius: 20px; font-size: 14px; border: 1px dashed var(--border); background: transparent; color: var(--text-hint); cursor: pointer; font-family: 'Lora', serif; }
.tag-add:hover { background: var(--bg-alt); color: var(--text); }
.list-item-desc-preview { font-size: 14px; color: var(--text-muted); line-height: 1.6; margin-top: 4px; }
.list-item-desc-preview ul, .list-item-desc-preview ol { margin: 4px 0 4px 20px; }
.list-item-desc-preview li { margin: 2px 0; }
.list-item-desc-preview p { margin: 4px 0; }
.list-item-desc-preview p:first-child { margin-top: 0; }
.list-item-desc-preview p:last-child { margin-bottom: 0; }
.list-item-text { flex: 1; }
.list-item-remove { visibility: hidden; cursor: pointer; color: var(--text-hint); font-size: 14px; margin-left: 8px; flex-shrink: 0; }
.list-item:hover .list-item-remove { visibility: visible; }
.list-item-remove:hover { color: #8b3030; }
.list-add { padding: 6px 0; font-size: 14px; color: var(--text-hint); cursor: pointer; display: flex; align-items: center; gap: 6px; }
.list-add:hover { color: var(--accent); }
/* ── Vue Rédaction ── */
.redaction-filters { background: var(--bg-alt); border-bottom: 1px solid var(--border); padding: 8px 28px; display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.redaction-filters select, .redaction-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; }
.redaction-filters label { font-size: 12px; color: var(--text-hint); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
.redaction-layout { display: flex; height: calc(100vh - 100px); }
.binder { width: 260px; flex-shrink: 0; background: var(--bg-alt); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px 0; display: flex; flex-direction: column; }
.binder-acte { padding: 0 12px; margin-bottom: 4px; }
.binder-acte-title { font-family: 'Playfair Display', serif; font-size: 13px; font-weight: 700; color: var(--text-hint); text-transform: uppercase; letter-spacing: 0.5px; padding: 10px 12px 6px; cursor: pointer; user-select: none; }
.binder-acte-title:hover { color: var(--text); }
.binder-scene { padding: 8px 12px 8px 24px; font-size: 14px; color: var(--text-muted); cursor: pointer; border-radius: 6px; margin: 1px 8px; display: flex; align-items: center; gap: 8px; line-height: 1.3; }
.binder-scene:hover { background: var(--border); color: var(--text); }
.binder-scene.active { background: var(--accent); color: #fff; }
.dark .binder-scene.active { color: var(--bg); }
.binder-scene .scene-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.binder-scene .scene-words { font-size: 11px; color: var(--text-light); margin-left: auto; white-space: nowrap; }
.binder-scene.active .scene-words { color: rgba(255,255,255,0.6); }
.editor-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.editor-header { padding: 20px 32px 12px; border-bottom: 1px solid var(--border-light); display: flex; align-items: flex-start; gap: 12px; }
.editor-scene-title { font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 600; color: var(--text); }
.editor-scene-meta { font-size: 13px; color: var(--text-hint); margin-top: 4px; display: flex; gap: 16px; }
.editor-body { flex: 1; overflow-y: auto; padding: 24px 32px 80px; max-width: 720px; }
.editor-body .tiptap { outline: none; font-family: 'Lora', Georgia, serif; font-size: 16px; line-height: 1.8; color: var(--text); min-height: 300px; }
.editor-body .tiptap p { margin-bottom: 12px; }
.editor-body .tiptap h2 { font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600; margin: 24px 0 12px; }
.editor-body .tiptap h3 { font-family: 'Playfair Display', serif; font-size: 17px; font-weight: 600; margin: 20px 0 8px; }
.editor-body .tiptap strong { font-weight: 600; }
.editor-body .tiptap em { font-style: italic; }
.editor-body .tiptap ul, .editor-body .tiptap ol { margin: 8px 0 8px 24px; }
.editor-body .tiptap li { margin-bottom: 4px; }
.editor-body .tiptap hr { border: none; border-top: 1px solid var(--border); margin: 24px 0; }
.editor-body .tiptap blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-muted); font-style: italic; margin: 16px 0; }
/* Panneau fiche (intention) — sticky en haut de l'éditeur */
.editor-fiche { background: var(--bg-alt); border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); line-height: 1.5; }
.editor-fiche-toggle { display: flex; align-items: center; gap: 8px; padding: 8px 32px; cursor: pointer; user-select: none; }
.editor-fiche-toggle:hover { background: var(--border-light); }
.editor-fiche-toggle-icon { font-size: 12px; transition: transform 0.2s; color: var(--text-hint); }
.editor-fiche-toggle-icon.open { transform: rotate(90deg); }
.editor-fiche-toggle-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-hint); font-weight: 600; }
.editor-fiche-content { padding: 0 32px 14px; display: none; }
.editor-fiche-content.open { display: block; }
.editor-fiche-field { margin-bottom: 10px; }
.editor-fiche-field-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-hint); font-weight: 600; margin-bottom: 3px; }
.editor-fiche-field-value { color: var(--text); }
.editor-fiche-field-value p { margin: 0 0 4px; }
.editor-fiche-field-value ul, .editor-fiche-field-value ol { margin: 0 0 4px; padding-left: 18px; }
.editor-fiche-tags { display: flex; gap: 4px; flex-wrap: wrap; }
.editor-fiche-persos { display: flex; gap: 6px; flex-wrap: wrap; }
.editor-toolbar { display: flex; gap: 2px; padding: 8px 32px; border-bottom: 1px solid var(--border-light); background: var(--bg); }
.editor-toolbar button { background: none; border: none; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 14px; color: var(--text-muted); font-family: 'Lora', serif; }
.editor-toolbar button:hover { background: var(--bg-alt); color: var(--text); }
.editor-toolbar button.is-active { background: var(--border); color: var(--text); font-weight: 600; }
.editor-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-light); font-size: 16px; font-style: italic; }
.editor-saving { position: fixed; bottom: 20px; right: 20px; background: var(--accent); color: var(--bg); padding: 8px 18px; border-radius: 8px; font-size: 13px; font-family: 'Lora', serif; opacity: 0; transition: opacity 0.3s; z-index: 200; pointer-events: none; }
.editor-saving.show { opacity: 1; }
.word-count { font-size: 12px; color: var(--text-hint); padding: 8px 32px; border-top: 1px solid var(--border-light); background: var(--bg); }
.binder-legend { padding: 12px 20px; border-bottom: 1px solid var(--border); }
.binder-legend-title { font-size: 11px; color: var(--text-hint); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.binder-legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); margin-bottom: 3px; }
.binder-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
/* Comments */
.comment-mark { cursor: pointer; border-radius: 2px; padding: 1px 0; }
.comment-mark.comment-e { background: rgba(106,122,66,0.15); border-bottom: 2px solid rgba(106,122,66,0.4); }
.comment-mark.comment-m { background: rgba(184,92,58,0.15); border-bottom: 2px solid rgba(184,92,58,0.4); }
.dark .comment-mark.comment-e { background: rgba(138,170,90,0.2); border-bottom-color: rgba(138,170,90,0.4); }
.dark .comment-mark.comment-m { background: rgba(212,132,90,0.2); border-bottom-color: rgba(212,132,90,0.4); }
/* ── Version history panel ── */
.version-panel { position: fixed; top: 0; right: -420px; width: 400px; height: 100vh; background: var(--bg-card); border-left: 1px solid var(--border); z-index: 250; transition: right 0.3s ease; display: flex; flex-direction: column; box-shadow: -4px 0 20px var(--shadow); }
.version-panel.open { right: 0; }
.version-panel-header { padding: 20px 24px 16px; border-bottom: 1px solid var(--border-light); display: flex; justify-content: space-between; align-items: center; }
.version-panel-header h3 { font-family: 'Playfair Display', serif; font-size: 16px; font-weight: 600; }
.version-panel-list { flex: 1; overflow-y: auto; padding: 12px; }
.version-item { padding: 14px 16px; border: 1px solid var(--border-light); border-radius: 10px; margin-bottom: 10px; cursor: pointer; transition: border-color 0.2s; }
.version-item:hover { border-color: var(--accent); }
.version-item.selected { border-color: var(--accent); background: var(--bg-alt); }
.version-item-date { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.version-item-label { font-size: 14px; font-weight: 500; margin-bottom: 6px; }
.version-item-preview { font-size: 12px; color: var(--text-hint); line-height: 1.5; max-height: 60px; overflow: hidden; }
.version-preview-pane { padding: 16px 24px; border-top: 1px solid var(--border-light); max-height: 40vh; overflow-y: auto; }
.version-preview-pane h4 { font-family: 'Playfair Display', serif; font-size: 14px; margin-bottom: 10px; color: var(--text-muted); }
.version-preview-content { font-size: 13px; line-height: 1.6; }
.version-actions { padding: 12px 24px 20px; border-top: 1px solid var(--border-light); display: flex; gap: 10px; }
.version-actions button { flex: 1; }
.btn-history { background: none; border: 1px solid var(--border); padding: 6px 14px; border-radius: 8px; font-family: 'Lora', serif; font-size: 13px; color: var(--text-muted); cursor: pointer; transition: all 0.2s; }
.btn-history:hover { border-color: var(--accent); color: var(--text); }
.comment-popup { position: fixed; z-index: 300; background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 14px 18px; box-shadow: 0 4px 20px var(--shadow-hover); max-width: 320px; min-width: 200px; }
.comment-popup-author { font-size: 12px; font-weight: 600; margin-bottom: 4px; }
.comment-popup-author.e { color: var(--olive); }
.comment-popup-author.m { color: var(--terra); }
.comment-popup-date { font-size: 11px; color: var(--text-light); margin-bottom: 8px; }
.comment-popup-text { font-size: 14px; color: var(--text); line-height: 1.5; }
.comment-popup-actions { margin-top: 10px; display: flex; gap: 8px; justify-content: flex-end; }
.comment-popup-actions button { font-size: 12px; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text-muted); cursor: pointer; font-family: 'Lora', serif; }
.comment-popup-actions button:hover { background: var(--bg-alt); color: var(--text); }
.comment-popup-actions button.delete { border-color: #8b3030; color: #8b3030; }
.comment-popup-actions button.delete:hover { background: #8b3030; color: #fff; }
.toolbar-sep { width: 1px; background: var(--border); margin: 4px 6px; }
/* ── Pages Personnages & Ressources ── */
.page-content { padding: 28px; overflow-y: auto; height: calc(100vh - 60px); }
.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; }
.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; overflow: hidden; cursor: pointer; transition: box-shadow 0.15s; }
.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-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: 3; -webkit-box-orient: vertical; overflow: hidden; }
.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 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 label { font-size: 12px; color: var(--text-hint); text-transform: uppercase; letter-spacing: 0.5px; font-weight: 600; }
.res-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.res-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; cursor: pointer; transition: box-shadow 0.15s; }
.res-card:hover { box-shadow: 0 4px 14px var(--shadow-hover); }
.res-card-img { width: 100%; height: 160px; object-fit: cover; background: var(--bg-alt); }
.res-card-body { padding: 14px 16px; }
.res-card-title { font-family: 'Playfair Display', serif; font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.res-card-cat { font-size: 12px; color: var(--accent); font-weight: 500; margin-bottom: 6px; }
.res-card-desc { font-size: 13px; color: var(--text-muted); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.res-card-tags { margin-top: 8px; display: flex; gap: 4px; flex-wrap: wrap; }
.res-card-link { margin-top: 8px; font-size: 12px; display: flex; align-items: center; gap: 4px; }
.res-card-link a { color: var(--accent); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.res-card-link a:hover { text-decoration: underline; }
.res-card-link svg { width: 12px; height: 12px; flex-shrink: 0; stroke: var(--accent); fill: none; stroke-width: 2; }
.res-modal-link { margin-top: 4px; }
.res-modal-link a { color: var(--accent); text-decoration: none; font-size: 14px; word-break: break-all; }
.res-modal-link a:hover { text-decoration: underline; }
/* Responsive */
@media (max-width: 768px) {
.sidebar { display: none; }
.modal { width: 100%; max-width: 100%; border-radius: 12px 12px 0 0; }
.card { width: 150px; min-width: 150px; }
.vision-grid { grid-template-columns: 1fr; }
.two-cols { grid-template-columns: 1fr; }
.binder { width: 200px; }
.editor-body { padding: 16px; }
}
</style>
</head>
<body>
<div class="app">
<div class="sidebar">
<div class="sidebar-logo">Tableau de bord</div>
<div class="sidebar-project">Projet RdB</div>
<div class="nav-item" data-view="vision" onclick="switchView('vision')"><span class="nav-icon"><svg viewBox="0 0 24 24"><polygon points="12,2 15.1,8.3 22,9.2 17,14.1 18.2,21 12,17.8 5.8,21 7,14.1 2,9.2 8.9,8.3" stroke-width="1.6" fill="none"/></svg></span> Vision</div>
<div class="nav-item" data-view="frise" onclick="switchView('frise')"><span class="nav-icon"><svg viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><circle cx="3" cy="12" r="3"/><circle cx="12" cy="12" r="3"/><circle cx="21" cy="12" r="3"/></svg></span> Frise</div>
<div class="nav-item" data-view="redaction" onclick="switchView('redaction')"><span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M17 3l4 4L7 21H3v-4L17 3z"/><line x1="14" y1="6" x2="18" y2="10"/></svg></span> Rédaction</div>
<div class="nav-item" data-view="personnages" onclick="switchView('personnages')"><span class="nav-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg></span> Personnages</div>
<div class="nav-item" data-view="ressources" onclick="switchView('ressources')"><span class="nav-icon"><svg viewBox="0 0 24 24"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></span> Ressources</div>
<div class="sidebar-footer">Etienne & Myriam</div>
</div>
<div class="main">
<div class="topbar">
<div class="topbar-title">Vision</div>
<div class="topbar-right">
<button class="btn-new" onclick="openNew()" style="display:none;">+ Nouvelle idee</button>
<button class="dark-toggle" onclick="toggleDark()" title="Mode sombre"></button>
<div class="avatar e">E</div>
<div class="avatar m">M</div>
</div>
</div>
<!-- ══ VUE FRISE ══ -->
<div class="view" id="view-frise">
<div class="filter-bar" id="filter-bar">
<div class="filter-group">
<label>Personnage</label>
<select id="filter-perso" onchange="applyFilters()"><option value="">Tous</option></select>
</div>
<div class="filter-group">
<label>Tag</label>
<select id="filter-tag" onchange="applyFilters()"><option value="">Tous</option></select>
</div>
<div class="filter-group">
<label>Recherche</label>
<input type="text" id="filter-search" placeholder="Mot-cle..." oninput="applyFilters()">
</div>
<div class="filter-group">
<label>Tri</label>
<select id="filter-sort" onchange="applyFilters()">
<option value="ordre">Ordre (défaut)</option>
<option value="date-desc">Plus récentes</option>
<option value="date-asc">Plus anciennes</option>
<option value="alpha">Alphabétique</option>
</select>
</div>
<button class="btn-clear-filters" onclick="clearFilters()">Effacer</button>
<button class="btn-history" onclick="openArchives()" style="margin-left:auto;">Archives</button>
</div>
<div class="frise-wrapper">
<div class="timeline-area" id="timeline-area">
<div class="loading" id="loading">Chargement...</div>
</div>
<div class="waiting-zone">
<div class="waiting-header">En attente — glisser sur la frise</div>
<div class="waiting-cards" id="waiting-cards"></div>
</div>
</div>
</div>
<!-- ══ VUE VISION ══ -->
<div class="view active" id="view-vision">
<div class="vision-content">
<div class="vision-grid">
<div class="hero-banner" id="hero-banner">
<div class="hero-controls">
<button class="hero-btn" onclick="document.getElementById('hero-file-input').click()">Changer l'image</button>
</div>
<div class="hero-overlay">
<div class="three-words" id="v-trois-mots" contenteditable="true" onclick="event.stopPropagation()"></div>
<div class="hero-sub" id="v-hero-sub" contenteditable="true" onclick="event.stopPropagation()"></div>
</div>
</div>
<input type="file" id="hero-file-input" accept="image/*" style="display:none;" onchange="uploadHeroImage(this)">
<div class="v-card">
<h3>L'histoire en une phrase</h3>
<div class="vision-phrase" id="v-phrase" contenteditable="true"></div>
<div class="vision-detail" id="v-phrase-detail" contenteditable="true"></div>
</div>
<div class="v-card">
<h3>Ambiance, époque, rythme</h3>
<div class="tag-group" id="v-tags"></div>
</div>
<div class="v-card full">
<h3>Moodboard</h3>
<div class="mood-grid" id="v-moodboard"></div>
<input type="file" id="mood-file-input" accept="image/*" style="display:none;">
</div>
<div class="v-card full">
<h3>Synopsis</h3>
<div class="vision-detail" id="v-synopsis" contenteditable="true" style="min-height:80px;"></div>
</div>
<div class="v-card">
<h3 class="yes">Ce qu'on veut</h3>
<div id="v-on-veut"></div>
</div>
<div class="v-card">
<h3 class="no">Ce qu'on ne veut pas</h3>
<div id="v-on-ne-veut-pas"></div>
</div>
</div>
</div>
</div>
<!-- ══ VUE RÉDACTION ══ -->
<div class="view" id="view-redaction">
<div class="redaction-filters">
<div class="filter-group">
<label>Personnage</label>
<select id="red-filter-perso" onchange="buildBinder()"><option value="">Tous</option></select>
</div>
<div class="filter-group">
<label>Tag</label>
<select id="red-filter-tag" onchange="buildBinder()"><option value="">Tous</option></select>
</div>
<div class="filter-group">
<label>Recherche</label>
<input type="text" id="red-filter-search" placeholder="Mot-clé..." oninput="buildBinder()">
</div>
</div>
<div class="redaction-layout">
<div class="binder" id="binder"></div>
<div class="editor-pane" id="editor-pane">
<div class="editor-empty" id="editor-empty">Choisir une scène dans le Binder</div>
<div id="editor-active" style="display:none; flex:1; display:none; flex-direction:column;">
<div class="editor-header">
<div style="flex:1;">
<div class="editor-scene-title" id="editor-title"></div>
<div class="editor-scene-meta" id="editor-meta"></div>
</div>
<button class="btn-history" id="btn-history-redaction" onclick="openVersionPanel('redaction', currentSceneId)" style="display:none;">Historique</button>
</div>
<div class="editor-fiche" id="editor-fiche">
<div class="editor-fiche-toggle" onclick="toggleFiche()">
<span class="editor-fiche-toggle-icon" id="fiche-toggle-icon">&#9656;</span>
<span class="editor-fiche-toggle-label">Fiche</span>
</div>
<div class="editor-fiche-content" id="editor-fiche-content"></div>
</div>
<div class="editor-toolbar" id="editor-toolbar"></div>
<div class="editor-body"><div id="editor-content"></div></div>
<div class="word-count" id="word-count"></div>
</div>
</div>
</div>
</div>
<!-- ══ VUE PERSONNAGES ══ -->
<div class="view" id="view-personnages">
<div class="page-content">
<div class="page-header">
<h2>Personnages</h2>
<button class="btn-new" onclick="openPersoModal()">+ Nouveau personnage</button>
</div>
<div class="perso-grid" id="perso-grid"></div>
</div>
</div>
<!-- ══ VUE RESSOURCES ══ -->
<div class="view" id="view-ressources">
<div class="page-content">
<div class="page-header">
<h2>Ressources</h2>
<button class="btn-new" onclick="openResModal()">+ Nouvelle ressource</button>
</div>
<div class="res-filters">
<div class="filter-group">
<label>Catégorie</label>
<select id="res-filter-cat" onchange="renderRessources()"><option value="">Toutes</option></select>
</div>
<div class="filter-group">
<label>Recherche</label>
<input type="text" id="res-filter-search" placeholder="Mot-clé..." oninput="renderRessources()">
</div>
</div>
<div class="res-grid" id="res-grid"></div>
</div>
</div>
<div class="editor-saving" id="editor-saving">Enregistré</div>
<div class="comment-popup" id="comment-popup" style="display:none;">
<div class="comment-popup-author" id="comment-popup-author"></div>
<div class="comment-popup-date" id="comment-popup-date"></div>
<div class="comment-popup-text" id="comment-popup-text"></div>
<div class="comment-popup-actions">
<button onclick="closeCommentPopup()">Fermer</button>
<button class="delete" onclick="deleteComment()">Supprimer</button>
</div>
</div>
<div class="vision-saving" id="vision-saving">Enregistré</div>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
<span style="font-family:'Playfair Display',serif;font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--text-hint);" id="modal-label">Nouvelle idee</span>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body">
<div class="field field-title">
<input type="text" id="f-title" placeholder="Titre de l'idee...">
</div>
<div class="field">
<label>Tags</label>
<div class="tags-input-container" id="f-tags">
<input type="text" class="tags-input" id="f-tags-input" placeholder="Ajouter un tag..." onkeydown="if(event.key==='Enter'){event.preventDefault();addTag()}">
</div>
</div>
<div class="field-row">
<div class="field">
<label>Acte</label>
<select id="f-acte"><option value=""></option></select>
</div>
<div class="field">
<label>Personnages</label>
<div class="multi-select-container" id="f-perso"></div>
</div>
</div>
<div class="field">
<label>Resume</label>
<textarea id="f-resume" placeholder="Description, resume de la scene ou de l'idee..."></textarea>
</div>
<div class="field">
<label>Interet narratif</label>
<textarea id="f-interet" placeholder="Pourquoi cette idee est importante pour l'histoire..."></textarea>
</div>
<div class="field">
<label>Implications</label>
<textarea id="f-implications" placeholder="Consequences dans l'histoire..."></textarea>
</div>
<div class="field">
<label>Origine</label>
<textarea id="f-origine" placeholder="D'ou vient cette idee..."></textarea>
</div>
<div class="field">
<label>Lien redaction</label>
<textarea id="f-lien" placeholder="Notes pour la redaction..."></textarea>
</div>
<div class="modal-comments" id="card-comments-section" style="display:none;">
<h4>Commentaires</h4>
<div class="comment-list" id="card-comment-list"></div>
<div class="comment-add">
<textarea id="card-comment-input" placeholder="Ajouter un commentaire..." rows="1" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();addCardComment()}"></textarea>
<div class="comment-add-btns">
<button class="comment-author-toggle" id="comment-author-btn" onclick="toggleCommentAuthor()">Etienne</button>
<button class="comment-send" onclick="addCardComment()">Envoyer</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-delete" id="btn-delete" onclick="deleteCard()" style="display:none;">Supprimer</button>
<div class="modal-actions-spacer"></div>
<button class="btn-history" id="btn-history-frise" onclick="openVersionPanel('frise', editingId)" style="display:none;">Historique</button>
<button class="btn-history" id="btn-rediger" onclick="goToRedaction(editingId)" style="display:none;">Rédiger</button>
<button class="btn-save" onclick="saveCard()">Enregistrer</button>
</div>
</div>
</div>
</div>
<!-- Modale Personnage -->
<div class="modal-overlay" id="perso-modal">
<div class="modal">
<div class="modal-header">
<span style="font-family:'Playfair Display',serif;font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--text-hint);" id="perso-modal-label">Nouveau personnage</span>
<button class="modal-close" onclick="closePersoModal()">&times;</button>
</div>
<div class="modal-body">
<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>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>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>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">
<button class="btn-delete" id="btn-delete-perso" onclick="deletePerso()" style="display:none;">Supprimer</button>
<div class="modal-actions-spacer"></div>
<button class="btn-save" onclick="savePerso()">Enregistrer</button>
</div>
</div>
</div>
</div>
<!-- Modale Ressource -->
<div class="modal-overlay" id="res-modal">
<div class="modal">
<div class="modal-header">
<span style="font-family:'Playfair Display',serif;font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--text-hint);" id="res-modal-label">Nouvelle ressource</span>
<button class="modal-close" onclick="closeResModal()">&times;</button>
</div>
<div class="modal-body">
<div class="field field-title"><input type="text" id="fr-titre" placeholder="Titre de la ressource..."></div>
<div class="field-row">
<div class="field"><label>Catégorie</label><select id="fr-cat"><option value=""></option><option>Image</option><option>Lien</option><option>Carte</option><option>Note</option><option>Référence</option></select></div>
<div class="field"><label>Tags</label><input type="text" id="fr-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>
<div class="field"><label>URL</label><input type="text" id="fr-url" placeholder="https://..." 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 id="fr-url-link" class="res-modal-link" style="margin-top:4px;"></div></div>
<div class="field"><label>Image</label><input type="file" id="fr-image-input" accept="image/*" onchange="previewResImage(this)" style="font-family:'Lora',serif;font-size:13px;"><div id="fr-image-preview" style="margin-top:8px;"></div></div>
<div class="field"><label>Description</label><textarea id="fr-desc" placeholder="Notes, contexte..." rows="4"></textarea></div>
<div class="modal-actions">
<button class="btn-delete" id="btn-delete-res" onclick="deleteRes()" style="display:none;">Supprimer</button>
<div class="modal-actions-spacer"></div>
<button class="btn-save" onclick="saveRes()">Enregistrer</button>
</div>
</div>
</div>
</div>
<script>
const ACTES_FRISE = ["Avant l'histoire", "Acte 1", "Acte 2", "Acte 3", "Après l'histoire"];
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 = { 'À étudier': '#c4a030', 'Validée': '#5a7a3a', 'Abandonnée': '#a08a76', 'En conflit': '#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>&#9993;</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>&thinsp;&#9829;</button>
<button class="like-btn ${likeM}" onclick="toggleLike(event,${r.Id},'M')" title="Myriam"><span class="like-who">M</span>&thinsp;&#9829;</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">&times;</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;
// Width adapts to content
const minW = Math.max(280, acteRecords.length * 274 + 20);
section.style.width = minW + 'px';
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
const waitingCards = document.getElementById('waiting-cards');
waitingCards.innerHTML = '';
const waitingRecords = records
.filter(r => !r.Acte || r.Acte === 'En attente')
.sort((a, b) => sortRecords(a, b, sortMode));
waitingRecords.forEach(r => waitingCards.appendChild(makeCard(r)));
new Sortable(waitingCards, {
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;
while (sel.options.length > 1) sel.remove(1);
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']);
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));
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);
}
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()">&times;</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 = '';
}
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();
}
});
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})">&times;</span>`;
container.appendChild(span);
});
const addBtn = document.createElement('button');
addBtn.className = 'tag-add';
addBtn.textContent = '+ Ajouter';
addBtn.onclick = () => {
const label = prompt('Nouveau tag :');
if (!label) return;
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 = '&times;';
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 = '&times;';
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 = ["Avant l'histoire", 'Acte 1', 'Acte 2', 'Acte 3', "Après l'histoire"];
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: 'Lien rédaction', 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, '&lt;')}</div>` : '') +
(preview ? `<div class="version-item-preview">${preview.replace(/</g, '&lt;').substring(0, 200)}</div>` : '');
div.onclick = () => selectVersion(v.Id);
container.appendChild(div);
}
}
function selectVersion(id) {
selectedVersionId = id;
document.querySelectorAll('.version-item').forEach(el => el.classList.toggle('selected', Number(el.dataset.id) === id));
const v = versionList.find(x => x.Id === id);
if (!v) return;
try {
const snap = JSON.parse(v.Snapshot);
const previewPane = document.getElementById('version-preview');
const content = document.getElementById('version-preview-content');
if (versionType === 'frise') {
const parts = [];
if (snap.Title) parts.push(`<strong>${snap.Title.replace(/</g, '&lt;')}</strong>`);
if (snap.Acte) parts.push(`<em>${snap.Acte}</em>`);
if (snap['Résumé']) parts.push(snap['Résumé'].replace(/</g, '&lt;'));
if (snap['Intérêt narratif']) parts.push('<br><strong>Intérêt :</strong> ' + snap['Intérêt narratif'].replace(/</g, '&lt;'));
if (snap.Tags) parts.push('<br><em>Tags : ' + snap.Tags.replace(/</g, '&lt;') + '</em>');
content.innerHTML = parts.join('<br>');
} else {
content.innerHTML = DOMPurify.sanitize(snap.Texte || '<em>Texte vide</em>');
}
previewPane.style.display = '';
document.getElementById('version-actions').style.display = '';
} catch (e) {
document.getElementById('version-preview').style.display = 'none';
}
}
async function restoreVersion() {
if (!selectedVersionId) return;
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: Acte || 'En attente', Ordre: 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, '&lt;')}</div>` +
(preview ? `<div class="version-item-preview">${preview.replace(/</g, '&lt;').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, '&lt;')}</strong>`);
if (snap.Acte) parts.push(`<em>${snap.Acte}</em>`);
if (snap.Personnage) parts.push('Personnages : ' + snap.Personnage.replace(/</g, '&lt;'));
if (snap['Résumé']) parts.push(snap['Résumé'].replace(/</g, '&lt;'));
if (snap['Intérêt narratif']) parts.push('<strong>Intérêt :</strong> ' + snap['Intérêt narratif'].replace(/</g, '&lt;'));
if (snap.Tags) parts.push('<em>Tags : ' + snap.Tags.replace(/</g, '&lt;') + '</em>');
if (snap.Notes) parts.push('<strong>Notes :</strong> ' + snap.Notes.replace(/</g, '&lt;'));
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();
}
});
</script>
<!-- Panneau Historique des versions -->
<div class="version-panel" id="version-panel">
<div class="version-panel-header">
<h3 id="version-panel-title">Historique</h3>
<button class="modal-close" onclick="closeVersionPanel()">&times;</button>
</div>
<div class="version-panel-list" id="version-list"></div>
<div class="version-preview-pane" id="version-preview" style="display:none;">
<h4>Aperçu</h4>
<div class="version-preview-content" id="version-preview-content"></div>
</div>
<div class="version-actions" id="version-actions" style="display:none;">
<button class="btn-save" onclick="restoreVersion()">Restaurer cette version</button>
</div>
</div>
</body>
</html>