Add Rédaction view: Binder + TipTap editor
Phase 1 of Solution 3 (Scrivener-inspired writing environment): - Binder panel: scenes grouped by act, status indicators, word counts - TipTap rich text editor with toolbar (bold, italic, headings, lists, blockquote) - Auto-save after 1.5s of inactivity - New NocoDB table "Contenu" (SceneId, Texte, Mots) - API routes: GET/PUT /api/contenu/:sceneId, GET /api/contenu (word counts) - Total word count in binder footer - Navigation: Vision / Frise / Rédaction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
<title>Tableau de bord — RdB</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400&family=Lora:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tiptap/core@2.11.7/dist/index.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tiptap/starter-kit@2.11.7/dist/index.umd.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
@@ -295,6 +297,47 @@
|
||||
.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; }
|
||||
.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.posee { background: #5a7a3a; }
|
||||
.binder-scene .scene-status.a-etudier { background: #c4a030; }
|
||||
.binder-scene .scene-status.germe { background: #a08a76; }
|
||||
.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); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
@@ -302,6 +345,8 @@
|
||||
.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>
|
||||
@@ -312,6 +357,7 @@
|
||||
<div class="sidebar-project">Projet RdB</div>
|
||||
<div class="nav-item active" 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">
|
||||
@@ -415,6 +461,26 @@
|
||||
</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="vision-saving" id="vision-saving">Enregistré</div>
|
||||
|
||||
</div>
|
||||
@@ -495,6 +561,10 @@ function switchView(name) {
|
||||
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 = '';
|
||||
@@ -1287,6 +1357,237 @@ async function loadVision() {
|
||||
}
|
||||
|
||||
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'];
|
||||
|
||||
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 = '';
|
||||
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.TiptapCore || {};
|
||||
const { StarterKit } = window.TiptapStarterKit || {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
tiptapEditor = new Editor({
|
||||
element: document.getElementById('editor-content'),
|
||||
extensions: [StarterKit],
|
||||
content: html || '<p></p>',
|
||||
onUpdate: () => {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(saveContent, 1500);
|
||||
updateWordCount();
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// 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();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,6 +15,7 @@ const NOCODB_TOKEN = process.env.NOCODB_TOKEN || '';
|
||||
const NOCODB_BASE_ID = process.env.NOCODB_BASE_ID || '';
|
||||
const NOCODB_TABLE_ID = process.env.NOCODB_TABLE_ID || '';
|
||||
const NOCODB_VISION_TABLE_ID = process.env.NOCODB_VISION_TABLE_ID || '';
|
||||
const NOCODB_CONTENU_TABLE_ID = process.env.NOCODB_CONTENU_TABLE_ID || '';
|
||||
|
||||
// Auth
|
||||
function makeToken(ts) {
|
||||
@@ -173,6 +174,71 @@ app.patch('/api/vision/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Contenu API (scene text for Rédaction view) ──
|
||||
|
||||
// List all word counts (for binder display)
|
||||
app.get('/api/contenu', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?limit=200&fields=SceneId,Mots`;
|
||||
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||
const data = await r.json();
|
||||
res.json(data);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get content for a scene
|
||||
app.get('/api/contenu/:sceneId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${req.params.sceneId})&limit=1`;
|
||||
const r = await fetch(url, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||
const data = await r.json();
|
||||
const row = data.list?.[0] || null;
|
||||
res.json(row);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Save content for a scene (create or update)
|
||||
app.put('/api/contenu/:sceneId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sceneId = Number(req.params.sceneId);
|
||||
// Check if row exists
|
||||
const findUrl = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}?where=(SceneId,eq,${sceneId})&limit=1`;
|
||||
const findR = await fetch(findUrl, { headers: { 'xc-token': NOCODB_TOKEN } });
|
||||
const findData = await findR.json();
|
||||
const existing = findData.list?.[0];
|
||||
|
||||
const payload = { SceneId: sceneId, Texte: req.body.Texte, Mots: req.body.Mots || 0 };
|
||||
|
||||
if (existing) {
|
||||
// Update
|
||||
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}/${existing.Id}`;
|
||||
const r = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json();
|
||||
res.json(data);
|
||||
} else {
|
||||
// Create
|
||||
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE_ID}/${NOCODB_CONTENU_TABLE_ID}`;
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json();
|
||||
res.status(r.status).json(data);
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Auth gate — redirect to login page if not authenticated
|
||||
app.get('/', (req, res) => {
|
||||
if (isAuth(req)) {
|
||||
|
||||
Reference in New Issue
Block a user