2098 lines
91 KiB
HTML
2098 lines
91 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 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; font-size: 17px; background: var(--border); border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
.sidebar-footer { margin-top: auto; padding: 16px 22px; font-size: 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-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; gap: 8px; align-items: flex-end; }
|
|
.comment-add textarea { flex: 1; 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; flex-direction: column; gap: 4px; }
|
|
.comment-send { padding: 6px 12px; 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: 4px 10px; 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 */
|
|
.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); }
|
|
|
|
/* ── 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; justify-content: space-between; margin-top: 8px; }
|
|
.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-height-control { position: absolute; bottom: -28px; left: 50%; transform: translateX(-50%); z-index: 3; display: flex; align-items: center; gap: 8px; opacity: 0; transition: opacity 0.2s; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: 4px 12px; }
|
|
.hero-banner:hover + .hero-height-control, .hero-height-control:hover { opacity: 1; }
|
|
.hero-height-control label { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
|
|
.hero-height-control input[type="range"] { width: 120px; accent-color: var(--accent); }
|
|
.hero-height-control + input[type="file"] { margin-bottom: 8px; }
|
|
.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-text { flex: 1; }
|
|
.list-item-remove { display: none; cursor: pointer; color: var(--text-hint); font-size: 14px; margin-left: 8px; }
|
|
.list-item:hover .list-item-remove { display: inline; }
|
|
.list-item-remove:hover { color: #8b3030; }
|
|
.list-add { padding: 6px 0; font-size: 14px; color: var(--text-hint); cursor: pointer; display: flex; align-items: center; gap: 6px; }
|
|
.list-add:hover { color: var(--accent); }
|
|
|
|
|
|
/* ── Vue Rédaction ── */
|
|
.redaction-layout { display: flex; height: calc(100vh - 60px); }
|
|
.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-status.validee { background: #5a7a3a; }
|
|
.binder-scene .scene-status.a-etudier { background: #c4a030; }
|
|
.binder-scene .scene-status.abandonnee { background: #a08a76; }
|
|
.binder-scene .scene-status.en-conflit { background: #8b3030; }
|
|
.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); }
|
|
.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; }
|
|
.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); }
|
|
|
|
.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; }
|
|
|
|
/* 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">★</span> Vision</div>
|
|
<div class="nav-item" data-view="frise" onclick="switchView('frise')"><span class="nav-icon">⚌</span> Frise</div>
|
|
<div class="nav-item" data-view="redaction" onclick="switchView('redaction')"><span class="nav-icon">✎</span> Rédaction</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>Statut</label>
|
|
<select id="filter-statut" onchange="applyFilters()"><option value="">Tous</option></select>
|
|
</div>
|
|
<div class="filter-group">
|
|
<label>Type</label>
|
|
<select id="filter-type" 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>
|
|
<button class="btn-clear-filters" onclick="clearFilters()">Effacer</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>
|
|
<div class="hero-height-control" id="hero-height-control">
|
|
<label>Hauteur</label>
|
|
<input type="range" min="150" max="600" value="400" id="hero-height-slider" oninput="setHeroHeight(this.value)" onchange="saveHeroMeta()">
|
|
</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-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 class="editor-scene-title" id="editor-title"></div>
|
|
<div class="editor-scene-meta" id="editor-meta"></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>
|
|
<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()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="field field-title">
|
|
<input type="text" id="f-title" placeholder="Titre de l'idee...">
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Type</label>
|
|
<select id="f-type"><option value="">—</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Statut</label>
|
|
<select id="f-statut"><option value="">—</option></select>
|
|
</div>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Acte</label>
|
|
<select id="f-acte"><option value="">—</option></select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Personnages</label>
|
|
<div class="multi-select-container" id="f-perso"></div>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>Resume</label>
|
|
<textarea id="f-resume" placeholder="Description, resume de la scene ou de l'idee..."></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Interet narratif</label>
|
|
<textarea id="f-interet" placeholder="Pourquoi cette idee est importante pour l'histoire..."></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Implications</label>
|
|
<textarea id="f-implications" placeholder="Consequences dans l'histoire..."></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Origine</label>
|
|
<textarea id="f-origine" placeholder="D'ou vient cette idee..."></textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Lien redaction</label>
|
|
<textarea id="f-lien" placeholder="Notes pour la redaction..."></textarea>
|
|
</div>
|
|
<div class="modal-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-send" onclick="addCardComment()">Envoyer</button>
|
|
<button class="comment-author-toggle" id="comment-author-btn" onclick="toggleCommentAuthor()">Etienne</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn-delete" id="btn-delete" onclick="deleteCard()" style="display:none;">Supprimer</button>
|
|
<button class="btn-save" onclick="saveCard()">Enregistrer</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const ACTES_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');
|
|
if (name === 'vision') {
|
|
topTitle.textContent = 'Vision';
|
|
if (btnNew) btnNew.style.display = 'none';
|
|
} else if (name === 'redaction') {
|
|
topTitle.textContent = 'Rédaction';
|
|
if (btnNew) btnNew.style.display = 'none';
|
|
buildBinder();
|
|
} else {
|
|
topTitle.textContent = 'Frise';
|
|
if (btnNew) btnNew.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;
|
|
|
|
// Statut and Type from column options
|
|
['Statut', 'Type'].forEach(name => {
|
|
const sel = document.getElementById(`filter-${name.toLowerCase()}`);
|
|
const cur = sel.value;
|
|
sel.innerHTML = '<option value="">Tous</option>';
|
|
if (columns[name]) {
|
|
columns[name].options.forEach(o => {
|
|
const opt = document.createElement('option');
|
|
opt.value = o; opt.textContent = o;
|
|
sel.appendChild(opt);
|
|
});
|
|
}
|
|
sel.value = cur;
|
|
});
|
|
}
|
|
|
|
function applyFilters() {
|
|
const perso = document.getElementById('filter-perso').value;
|
|
const statut = document.getElementById('filter-statut').value;
|
|
const type = document.getElementById('filter-type').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 (statut && r.Statut !== statut) visible = false;
|
|
if (type && r.Type !== type) 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-statut').value = '';
|
|
document.getElementById('filter-type').value = '';
|
|
document.getElementById('filter-search').value = '';
|
|
applyFilters();
|
|
}
|
|
|
|
const statusClass = s => {
|
|
if (!s) return '';
|
|
const map = { 'À étudier': 'badge-etudier', 'Validée': 'badge-validee', 'Abandonnée': 'badge-abandonnee', 'En conflit': 'badge-conflit' };
|
|
return map[s] || 'badge-etudier';
|
|
};
|
|
|
|
function makeCard(r) {
|
|
const card = document.createElement('div');
|
|
card.className = 'card';
|
|
card.dataset.id = r.Id;
|
|
card.dataset.type = r.Type || '';
|
|
card.dataset.acte = r.Acte || 'En attente';
|
|
card.onclick = () => openEdit(r.Id);
|
|
|
|
const persos = r.Personnage ? r.Personnage.split(',').map(p =>
|
|
`<span class="card-badge badge-perso">${esc(p.trim())}</span>`
|
|
).join('') : '';
|
|
|
|
const likeE = r.LikeE ? 'liked' : '';
|
|
const likeM = r.LikeM ? 'liked' : '';
|
|
const comments = parseComments(r.Commentaires);
|
|
const commentBadge = comments.length > 0 ? `<span class="card-comment-count"><span>✉</span>${comments.length}</span>` : '';
|
|
|
|
card.innerHTML = `
|
|
<div class="card-title">${esc(r.Title || '')}</div>
|
|
${r['Résumé'] ? `<div class="card-summary">${esc(r['Résumé'])}</div>` : ''}
|
|
<div class="card-meta">
|
|
${r.Statut ? `<span class="card-badge ${statusClass(r.Statut)}">${esc(r.Statut)}</span>` : ''}
|
|
${r.Type ? `<span class="card-badge badge-perso">${esc(r.Type)}</span>` : ''}
|
|
${persos}
|
|
${commentBadge}
|
|
<span class="card-likes">
|
|
<button class="like-btn ${likeE}" onclick="toggleLike(event,${r.Id},'E')" title="Etienne"><span class="like-who">E</span> ♥</button>
|
|
<button class="like-btn ${likeM}" onclick="toggleLike(event,${r.Id},'M')" title="Myriam"><span class="like-who">M</span> ♥</button>
|
|
</span>
|
|
</div>
|
|
`;
|
|
return card;
|
|
}
|
|
|
|
async function toggleLike(e, id, who) {
|
|
e.stopPropagation();
|
|
const r = records.find(r => r.Id === id);
|
|
if (!r) return;
|
|
const field = who === 'E' ? 'LikeE' : 'LikeM';
|
|
const newVal = r[field] ? 0 : 1;
|
|
r[field] = newVal;
|
|
renderFrise();
|
|
await fetch(`/api/records/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ [field]: newVal }),
|
|
});
|
|
}
|
|
|
|
// ── Card comments ──
|
|
let commentAuthor = 'E';
|
|
|
|
function parseComments(raw) {
|
|
if (!raw) return [];
|
|
try { return JSON.parse(raw); } catch { return []; }
|
|
}
|
|
|
|
function renderCardComments(comments) {
|
|
const list = document.getElementById('card-comment-list');
|
|
list.innerHTML = comments.map((c, i) => `
|
|
<div class="comment-item">
|
|
<div class="comment-header">
|
|
<span class="comment-author ${(c.auteur || 'E').toLowerCase()}">${c.auteur === 'M' ? 'Myriam' : 'Etienne'}</span>
|
|
<span class="comment-date">${esc(c.date || '')}</span>
|
|
<button class="comment-delete" onclick="deleteCardComment(${i})" title="Supprimer">×</button>
|
|
</div>
|
|
<div class="comment-text">${esc(c.texte || '')}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function addCardComment() {
|
|
if (!editingId) return;
|
|
const input = document.getElementById('card-comment-input');
|
|
const texte = input.value.trim();
|
|
if (!texte) return;
|
|
|
|
const r = records.find(r => r.Id === editingId);
|
|
if (!r) return;
|
|
|
|
const comments = parseComments(r.Commentaires);
|
|
const now = new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' });
|
|
comments.push({ auteur: commentAuthor, date: now, texte });
|
|
|
|
const json = JSON.stringify(comments);
|
|
r.Commentaires = json;
|
|
renderCardComments(comments);
|
|
input.value = '';
|
|
|
|
await fetch(`/api/records/${editingId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Commentaires: json }),
|
|
});
|
|
renderFrise();
|
|
}
|
|
|
|
async function deleteCardComment(index) {
|
|
if (!editingId) return;
|
|
const r = records.find(r => r.Id === editingId);
|
|
if (!r) return;
|
|
|
|
const comments = parseComments(r.Commentaires);
|
|
comments.splice(index, 1);
|
|
|
|
const json = comments.length > 0 ? JSON.stringify(comments) : null;
|
|
r.Commentaires = json;
|
|
renderCardComments(comments);
|
|
|
|
await fetch(`/api/records/${editingId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Commentaires: json || '' }),
|
|
});
|
|
renderFrise();
|
|
}
|
|
|
|
function toggleCommentAuthor() {
|
|
commentAuthor = commentAuthor === 'E' ? 'M' : 'E';
|
|
const btn = document.getElementById('comment-author-btn');
|
|
btn.textContent = commentAuthor === 'E' ? 'Etienne' : 'Myriam';
|
|
}
|
|
|
|
function 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;';
|
|
|
|
ACTES_FRISE.forEach(acte => {
|
|
const acteRecords = records
|
|
.filter(r => (r.Acte || 'En attente') === acte)
|
|
.sort((a, b) => (a.Ordre || 0) - (b.Ordre || 0));
|
|
|
|
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) => (a.Ordre || 0) - (b.Ordre || 0));
|
|
|
|
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() {
|
|
['Type', 'Statut', 'Acte'].forEach(name => {
|
|
const sel = document.getElementById(`f-${name.toLowerCase()}`);
|
|
if (!sel || !columns[name]) return;
|
|
sel.innerHTML = '<option value="">—</option>';
|
|
columns[name].options.forEach(o => {
|
|
const opt = document.createElement('option');
|
|
opt.value = o; opt.textContent = o;
|
|
sel.appendChild(opt);
|
|
});
|
|
});
|
|
|
|
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 = '';
|
|
document.getElementById('f-type').value = '';
|
|
document.getElementById('f-statut').value = 'À étudier';
|
|
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('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 || '';
|
|
document.getElementById('f-type').value = r.Type || '';
|
|
document.getElementById('f-statut').value = r.Statut || '';
|
|
document.getElementById('f-acte').value = r.Acte || '';
|
|
document.getElementById('f-resume').value = r['Résumé'] || '';
|
|
document.getElementById('f-interet').value = r['Intérêt narratif'] || '';
|
|
document.getElementById('f-implications').value = r.Implications || '';
|
|
document.getElementById('f-origine').value = r.Origine || '';
|
|
document.getElementById('f-lien').value = r['Lien rédaction'] || '';
|
|
|
|
const selected = r.Personnage ? r.Personnage.split(',').map(s => s.trim()) : [];
|
|
document.querySelectorAll('#f-perso .ms-tag').forEach(t => {
|
|
t.classList.toggle('selected', selected.includes(t.textContent));
|
|
});
|
|
|
|
document.getElementById('btn-delete').style.display = 'block';
|
|
|
|
// 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,
|
|
Type: document.getElementById('f-type').value || null,
|
|
Statut: document.getElementById('f-statut').value || null,
|
|
Acte: document.getElementById('f-acte').value || 'En attente',
|
|
Personnage: persos.join(',') || null,
|
|
'Résumé': document.getElementById('f-resume').value || null,
|
|
'Intérêt narratif': document.getElementById('f-interet').value || null,
|
|
Implications: document.getElementById('f-implications').value || null,
|
|
Origine: document.getElementById('f-origine').value || null,
|
|
'Lien rédaction': document.getElementById('f-lien').value || null,
|
|
};
|
|
|
|
if (editingId) {
|
|
await fetch(`/api/records/${editingId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
} else {
|
|
data.Ordre = records.filter(r => r.Acte === data.Acte).length + 1;
|
|
await fetch('/api/records', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
closeModal();
|
|
await loadData();
|
|
}
|
|
|
|
async function deleteCard() {
|
|
if (!editingId) return;
|
|
if (!confirm('Supprimer cette idee ?')) return;
|
|
await fetch(`/api/records/${editingId}`, { method: 'DELETE' });
|
|
closeModal();
|
|
await loadData();
|
|
}
|
|
|
|
async function loadData() {
|
|
const [recRes, colRes] = await Promise.all([
|
|
fetch('/api/records'),
|
|
fetch('/api/columns'),
|
|
]);
|
|
if (recRes.status === 401) { window.location.reload(); return; }
|
|
const recData = await recRes.json();
|
|
records = recData.list || [];
|
|
columns = await colRes.json();
|
|
populateSelects();
|
|
populateFilterOptions();
|
|
renderFrise();
|
|
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;
|
|
}
|
|
|
|
document.getElementById('modal').addEventListener('click', e => {
|
|
if (e.target === document.getElementById('modal')) closeModal();
|
|
});
|
|
document.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape') closeModal();
|
|
});
|
|
|
|
loadData();
|
|
|
|
// ══════════════════════════════════════
|
|
// ══ VISION — Editable sections ══
|
|
// ══════════════════════════════════════
|
|
|
|
let visionData = {}; // section -> {Id, Contenu}
|
|
|
|
const PLACEHOLDERS = {
|
|
'trois-mots': 'Les trois mots qui définissent votre univers...',
|
|
'hero-sous-titre': 'Sous-titre ou accroche...',
|
|
'phrase': 'Cliquez pour écrire la phrase qui résume votre histoire...',
|
|
'phrase-detail': 'Un détail, une nuance, une précision...',
|
|
'synopsis': 'Développez ici le résumé de votre histoire en quelques paragraphes...',
|
|
};
|
|
|
|
const FIELD_MAP = {
|
|
'trois-mots': 'v-trois-mots',
|
|
'hero-sous-titre': 'v-hero-sub',
|
|
'phrase': 'v-phrase',
|
|
'phrase-detail': 'v-phrase-detail',
|
|
'synopsis': 'v-synopsis',
|
|
};
|
|
|
|
function showSaved() {
|
|
const el = document.getElementById('vision-saving');
|
|
el.classList.add('show');
|
|
setTimeout(() => el.classList.remove('show'), 1200);
|
|
}
|
|
|
|
async function saveVisionSection(section, contenu) {
|
|
const row = visionData[section];
|
|
if (!row) return;
|
|
row.Contenu = contenu; // update local cache immediately
|
|
fetch(`/api/vision/${row.Id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ Contenu: contenu }),
|
|
}).then(() => showSaved());
|
|
}
|
|
|
|
function setupEditable(elId, section) {
|
|
const el = document.getElementById(elId);
|
|
if (!el) return;
|
|
const placeholder = PLACEHOLDERS[section] || '';
|
|
|
|
function updatePlaceholder() {
|
|
const text = el.textContent.trim();
|
|
if (!text || text === placeholder) {
|
|
el.classList.add('placeholder');
|
|
if (!text) el.textContent = placeholder;
|
|
} else {
|
|
el.classList.remove('placeholder');
|
|
}
|
|
}
|
|
|
|
el.addEventListener('focus', () => {
|
|
if (el.classList.contains('placeholder')) {
|
|
el.textContent = '';
|
|
el.classList.remove('placeholder');
|
|
}
|
|
});
|
|
|
|
el.addEventListener('blur', () => {
|
|
const text = el.textContent.trim();
|
|
if (text && text !== placeholder) {
|
|
const row = visionData[section];
|
|
if (row && row.Contenu !== text) {
|
|
saveVisionSection(section, text);
|
|
}
|
|
} else {
|
|
el.textContent = placeholder;
|
|
el.classList.add('placeholder');
|
|
const row = visionData[section];
|
|
if (row && row.Contenu) {
|
|
saveVisionSection(section, '');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Prevent Enter creating divs in single-line fields
|
|
if (section !== 'synopsis') {
|
|
el.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') { e.preventDefault(); el.blur(); }
|
|
});
|
|
}
|
|
|
|
updatePlaceholder();
|
|
}
|
|
|
|
// ── Tags ──
|
|
function renderTags() {
|
|
const container = document.getElementById('v-tags');
|
|
const row = visionData['tags'];
|
|
let tags = [];
|
|
try { tags = JSON.parse(row?.Contenu || '[]'); } catch { tags = []; }
|
|
// Normalize: old format {label, type} → just strings
|
|
tags = tags.map(t => typeof t === 'string' ? t : t.label || '');
|
|
|
|
container.innerHTML = '';
|
|
tags.forEach((label, i) => {
|
|
const span = document.createElement('span');
|
|
span.className = 'tag ambiance';
|
|
span.innerHTML = `${esc(label)}<span class="tag-remove" onclick="removeTag(${i})">×</span>`;
|
|
container.appendChild(span);
|
|
});
|
|
|
|
const addBtn = document.createElement('button');
|
|
addBtn.className = 'tag-add';
|
|
addBtn.textContent = '+ Ajouter';
|
|
addBtn.onclick = () => {
|
|
const label = prompt('Nouveau tag :');
|
|
if (!label) return;
|
|
tags.push(label);
|
|
saveVisionSection('tags', JSON.stringify(tags));
|
|
renderTags();
|
|
};
|
|
container.appendChild(addBtn);
|
|
}
|
|
|
|
function removeTag(index) {
|
|
const row = visionData['tags'];
|
|
let tags = [];
|
|
try { tags = JSON.parse(row?.Contenu || '[]'); } catch { return; }
|
|
tags = tags.map(t => typeof t === 'string' ? t : t.label || '');
|
|
tags.splice(index, 1);
|
|
saveVisionSection('tags', JSON.stringify(tags));
|
|
renderTags();
|
|
}
|
|
|
|
// ── Lists (on-veut / on-ne-veut-pas) — items with title + optional description ──
|
|
function renderList(section, containerId, cssClass) {
|
|
const container = document.getElementById(containerId);
|
|
const row = visionData[section];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; }
|
|
// Normalize: old format was plain strings, new format is {title, desc}
|
|
items = items.map(it => typeof it === 'string' ? { title: it, desc: '' } : it);
|
|
|
|
container.innerHTML = '';
|
|
items.forEach((item, i) => {
|
|
const hasDesc = !!item.desc;
|
|
const div = document.createElement('div');
|
|
div.className = `list-item ${cssClass}${hasDesc ? ' expanded' : ''}`;
|
|
|
|
const content = document.createElement('div');
|
|
content.className = 'list-item-text';
|
|
content.style.flex = '1';
|
|
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'list-item-title';
|
|
titleEl.contentEditable = 'true';
|
|
titleEl.textContent = item.title;
|
|
titleEl.addEventListener('blur', () => {
|
|
const v = titleEl.textContent.trim();
|
|
if (v && v !== items[i].title) { items[i].title = v; saveVisionSection(section, JSON.stringify(items)); }
|
|
});
|
|
titleEl.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); titleEl.blur(); } });
|
|
content.appendChild(titleEl);
|
|
|
|
const descEl = document.createElement('div');
|
|
descEl.className = 'list-item-desc';
|
|
descEl.contentEditable = 'true';
|
|
descEl.textContent = item.desc || '';
|
|
descEl.style.display = hasDesc ? 'block' : 'none';
|
|
descEl.setAttribute('data-placeholder', 'Développer...');
|
|
descEl.addEventListener('blur', () => {
|
|
const v = descEl.textContent.trim();
|
|
if (v !== (items[i].desc || '')) { items[i].desc = v; saveVisionSection(section, JSON.stringify(items)); }
|
|
if (!v) { descEl.style.display = 'none'; div.classList.remove('expanded'); }
|
|
});
|
|
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 ? '' : 'développer →';
|
|
expandBtn.style.display = hasDesc ? 'none' : '';
|
|
expandBtn.onclick = () => {
|
|
descEl.style.display = 'block';
|
|
div.classList.add('expanded');
|
|
expandBtn.style.display = 'none';
|
|
descEl.focus();
|
|
};
|
|
|
|
const removeBtn = document.createElement('span');
|
|
removeBtn.className = 'list-item-remove';
|
|
removeBtn.innerHTML = '×';
|
|
removeBtn.onclick = () => { items.splice(i, 1); saveVisionSection(section, JSON.stringify(items)); renderList(section, containerId, cssClass); };
|
|
|
|
actions.appendChild(expandBtn);
|
|
actions.appendChild(removeBtn);
|
|
|
|
div.appendChild(bullet);
|
|
div.appendChild(content);
|
|
div.appendChild(actions);
|
|
container.appendChild(div);
|
|
});
|
|
|
|
const addEl = document.createElement('div');
|
|
addEl.className = 'list-add';
|
|
addEl.innerHTML = '<span>+</span> Ajouter un élément';
|
|
addEl.onclick = () => {
|
|
const text = prompt('Nouvel élément :');
|
|
if (!text) return;
|
|
items.push({ title: text, desc: '' });
|
|
saveVisionSection(section, JSON.stringify(items));
|
|
renderList(section, containerId, cssClass);
|
|
};
|
|
container.appendChild(addEl);
|
|
}
|
|
|
|
function removeListItem(section, containerId, cssClass, index) {
|
|
const row = visionData[section];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { return; }
|
|
items.splice(index, 1);
|
|
saveVisionSection(section, JSON.stringify(items));
|
|
renderList(section, containerId, cssClass);
|
|
}
|
|
|
|
// ── Moodboard (images + free text captions) ──
|
|
function renderMoodboard() {
|
|
const container = document.getElementById('v-moodboard');
|
|
const row = visionData['moodboard'];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; }
|
|
|
|
container.innerHTML = '';
|
|
items.forEach((item, i) => {
|
|
const card = document.createElement('div');
|
|
card.className = 'mood-card';
|
|
|
|
const imgDiv = document.createElement('div');
|
|
const hasImg = !!item.image;
|
|
imgDiv.className = 'mood-card-img' + (hasImg ? ' has-image' : ' empty');
|
|
if (hasImg) {
|
|
const zoom = item.zoom ?? 100;
|
|
imgDiv.style.backgroundImage = `url(${item.image})`;
|
|
imgDiv.style.backgroundSize = `${zoom}%`;
|
|
imgDiv.style.backgroundPosition = `${item.posX ?? 50}% ${item.posY ?? 50}%`;
|
|
}
|
|
if (!hasImg) {
|
|
imgDiv.onclick = () => { moodUploadTarget = i; document.getElementById('mood-file-input').click(); };
|
|
} else {
|
|
// Drag to reposition (2D) — double-click to replace image
|
|
let startX, startY, startPosX, startPosY, didDrag;
|
|
const onMove = (e) => {
|
|
const dx = e.clientX - startX, dy = e.clientY - startY;
|
|
if (!didDrag && Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
|
|
didDrag = true;
|
|
const rect = imgDiv.getBoundingClientRect();
|
|
const scale = (item.zoom ?? 100) / 100;
|
|
item.posX = Math.max(0, Math.min(100, startPosX + (dx / rect.width) * -80 / scale));
|
|
item.posY = Math.max(0, Math.min(100, startPosY + (dy / rect.height) * -80 / scale));
|
|
imgDiv.style.backgroundPosition = `${item.posX}% ${item.posY}%`;
|
|
};
|
|
const onUp = () => {
|
|
imgDiv.classList.remove('dragging');
|
|
window.removeEventListener('mousemove', onMove);
|
|
window.removeEventListener('mouseup', onUp);
|
|
if (didDrag) saveVisionSection('moodboard', JSON.stringify(items));
|
|
};
|
|
imgDiv.addEventListener('mousedown', (e) => {
|
|
if (e.button !== 0) return;
|
|
e.preventDefault();
|
|
didDrag = false;
|
|
imgDiv.classList.add('dragging');
|
|
startX = e.clientX; startY = e.clientY;
|
|
startPosX = item.posX ?? 50; startPosY = item.posY ?? 50;
|
|
window.addEventListener('mousemove', onMove);
|
|
window.addEventListener('mouseup', onUp);
|
|
});
|
|
// Scroll to zoom
|
|
imgDiv.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? -10 : 10;
|
|
item.zoom = Math.max(100, Math.min(400, (item.zoom ?? 100) + delta));
|
|
imgDiv.style.backgroundSize = `${item.zoom}%`;
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
}, { passive: false });
|
|
imgDiv.addEventListener('dblclick', () => { moodUploadTarget = i; document.getElementById('mood-file-input').click(); });
|
|
}
|
|
|
|
const caption = document.createElement('div');
|
|
caption.className = 'mood-card-caption';
|
|
caption.contentEditable = 'true';
|
|
caption.textContent = item.caption || '';
|
|
caption.setAttribute('placeholder', 'Légende...');
|
|
caption.addEventListener('blur', () => {
|
|
const v = caption.textContent.trim();
|
|
if (v !== (items[i].caption || '')) {
|
|
items[i].caption = v;
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
}
|
|
});
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'mood-card-remove';
|
|
removeBtn.innerHTML = '×';
|
|
removeBtn.onclick = (e) => { e.stopPropagation(); removeMoodItem(i); };
|
|
|
|
// Drag handle for reordering
|
|
const handle = document.createElement('div');
|
|
handle.className = 'mood-card-handle';
|
|
handle.innerHTML = '⠿';
|
|
if (hasImg) {
|
|
card.draggable = false;
|
|
handle.addEventListener('mousedown', () => { card.draggable = true; });
|
|
window.addEventListener('mouseup', () => { card.draggable = false; });
|
|
card.addEventListener('dragstart', (e) => {
|
|
moodDragIndex = i;
|
|
card.classList.add('dragging-card');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
card.addEventListener('dragend', () => {
|
|
card.classList.remove('dragging-card');
|
|
card.draggable = false;
|
|
moodDragIndex = -1;
|
|
container.querySelectorAll('.mood-card').forEach(c => c.classList.remove('drag-over'));
|
|
});
|
|
card.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
card.classList.add('drag-over');
|
|
});
|
|
card.addEventListener('dragleave', () => { card.classList.remove('drag-over'); });
|
|
card.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
card.classList.remove('drag-over');
|
|
if (moodDragIndex < 0 || moodDragIndex === i) return;
|
|
const moved = items.splice(moodDragIndex, 1)[0];
|
|
items.splice(i, 0, moved);
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
renderMoodboard();
|
|
});
|
|
}
|
|
|
|
card.appendChild(removeBtn);
|
|
card.appendChild(handle);
|
|
card.appendChild(imgDiv);
|
|
card.appendChild(caption);
|
|
container.appendChild(card);
|
|
});
|
|
|
|
const addCard = document.createElement('div');
|
|
addCard.className = 'mood-add';
|
|
addCard.innerHTML = '<div class="mood-add-icon">+</div>';
|
|
addCard.onclick = () => {
|
|
items.push({ caption: '', image: '' });
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
renderMoodboard();
|
|
};
|
|
container.appendChild(addCard);
|
|
}
|
|
|
|
let moodUploadTarget = -1;
|
|
let moodDragIndex = -1;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const moodInput = document.getElementById('mood-file-input');
|
|
if (moodInput) moodInput.addEventListener('change', function() {
|
|
const file = this.files[0];
|
|
if (!file || moodUploadTarget < 0) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = new Image();
|
|
img.onload = async () => {
|
|
const maxW = 1200;
|
|
let w = img.width, h = img.height;
|
|
if (w > maxW) { h = Math.round(h * maxW / w); w = maxW; }
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w; canvas.height = h;
|
|
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
|
|
|
try {
|
|
const res = await fetch('/api/mood-upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dataUrl, index: moodUploadTarget }),
|
|
});
|
|
const data = await res.json();
|
|
if (data.path) {
|
|
const row = visionData['moodboard'];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { items = []; }
|
|
if (items[moodUploadTarget]) {
|
|
items[moodUploadTarget].image = data.path;
|
|
items[moodUploadTarget].posX = 50;
|
|
items[moodUploadTarget].posY = 50;
|
|
items[moodUploadTarget].zoom = 100;
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
renderMoodboard();
|
|
}
|
|
}
|
|
} catch (err) { console.error('Mood upload error:', err); }
|
|
};
|
|
img.src = e.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
this.value = '';
|
|
});
|
|
});
|
|
|
|
function removeMoodItem(index) {
|
|
const row = visionData['moodboard'];
|
|
let items = [];
|
|
try { items = JSON.parse(row?.Contenu || '[]'); } catch { return; }
|
|
items.splice(index, 1);
|
|
saveVisionSection('moodboard', JSON.stringify(items));
|
|
renderMoodboard();
|
|
}
|
|
|
|
// ── Hero image with drag-to-reposition ──
|
|
let heroMeta = { posY: 50, height: 400 }; // posY in %, height in px
|
|
|
|
function applyHeroImage(dataUrl) {
|
|
const banner = document.getElementById('hero-banner');
|
|
if (dataUrl) {
|
|
banner.style.backgroundImage = `url(${dataUrl})`;
|
|
banner.classList.add('has-image');
|
|
} else {
|
|
banner.style.backgroundImage = '';
|
|
banner.classList.remove('has-image');
|
|
}
|
|
}
|
|
|
|
function applyHeroMeta() {
|
|
const banner = document.getElementById('hero-banner');
|
|
banner.style.backgroundPosition = `center ${heroMeta.posY}%`;
|
|
banner.style.height = heroMeta.height + 'px';
|
|
document.getElementById('hero-height-slider').value = heroMeta.height;
|
|
}
|
|
|
|
function setHeroHeight(val) {
|
|
heroMeta.height = parseInt(val);
|
|
document.getElementById('hero-banner').style.height = val + '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();
|
|
|
|
// ══════════════════════════════════════════════════════
|
|
// ══ 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 statusSlug(s) {
|
|
if (!s) return '';
|
|
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
|
}
|
|
|
|
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] = []; });
|
|
|
|
(records || []).forEach(r => {
|
|
const acte = r.Acte;
|
|
if (!acte || acte === 'En attente') 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 = 'Statuts';
|
|
legend.appendChild(legendTitle);
|
|
[['#c4a030', 'À étudier'], ['#5a7a3a', 'Validée'], ['#a08a76', 'Abandonnée'], ['#8b3030', 'En conflit']].forEach(([color, label]) => {
|
|
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 slug = statusSlug(scene.Statut);
|
|
const wc = wordCounts[scene.Id] || 0;
|
|
totalWords += wc;
|
|
el.innerHTML = `<span class="scene-status ${slug}"></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.Type) meta.push(scene.Type);
|
|
if (scene.Statut) meta.push(scene.Statut);
|
|
if (scene.Personnage) meta.push(scene.Personnage);
|
|
document.getElementById('editor-meta').textContent = meta.join(' · ');
|
|
|
|
// 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('');
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|