diff --git a/README.md b/README.md index 9b40dfe..fc0d909 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,12 @@ Admin-Passwort für dich, und pro Freund ein frei wählbares Freigabe-Passwort z Buch-Import kann optional vorher alles Bestehende gelöscht werden. - **Freundesbuch-Ansicht**: verspieltes Scrapbook-Layout mit schräg angeordneten Polaroid-Fotos, Washi-Tape-Optik usw. – seitenweise durchblätterbar. -- **PDF-Export** des gesamten Freundesbuchs (serverseitig über Puppeteer/Chromium gerendert). +- **Automatisches Deckblatt**: Erste Seite im Viewer und im PDF, mit frei wählbarem Titel + (Admin → Einstellungen → "Titel des Freundesbuchs") und einer "sticker-gebombten" Foto-Collage + aus allen Profilbildern und Bild-Blöcken der Freunde. +- **Eigene Reihenfolge**: Im Admin-Bereich lässt sich die Reihenfolge der Freunde per Pfeil-Buttons + festlegen – sie bestimmt die Seitenfolge im Freundesbuch (nach dem Deckblatt) sowie im PDF. +- **PDF-Export** des gesamten Freundesbuchs inkl. Deckblatt (serverseitig über Puppeteer/Chromium gerendert). ## Setup @@ -69,9 +74,9 @@ Admin-Passwort für dich, und pro Freund ein frei wählbares Freigabe-Passwort z vergebenen Freigabe-Passwort an und kann **nur** dort etwas eintragen. - **/book** – die verspielte Freundesbuch-Ansicht mit Blätterfunktion und PDF-Export (Button oben rechts). -- Im Admin-Bereich (`/admin`) findest du außerdem die Bereiche **Einstellungen** - (öffentliche URL) und **Sichern & Wiederherstellen** (Export/Import für einzelne Freunde - und für das gesamte Buch als ZIP-Datei) sowie einen Export-Button auf jeder Freundeskarte. +- Im Admin-Bereich gibt es oben zwei Reiter: **Freunde** (`/admin`, Standardansicht) und + **Einstellungen & Sichern** (`/admin/settings`) mit dem Buch-Titel, der öffentlichen URL + sowie Export/Import für einzelne Freunde und das gesamte Buch. ## Hinweise diff --git a/backend/public/admin/admin.css b/backend/public/admin/admin.css index cf0c1fe..05d7240 100644 --- a/backend/public/admin/admin.css +++ b/backend/public/admin/admin.css @@ -1,6 +1,18 @@ .inline-form { display: flex; gap: 0.6em; flex-wrap: wrap; margin-top: 0.6em; } .inline-form input { flex: 1; min-width: 220px; } +.tab-nav { + display: flex; gap: 0.4em; padding: 0.6em 1.5em 0; + background: var(--paper-2); border-bottom: 2px solid var(--line); +} +.tab-link { + font-family: 'Nunito', sans-serif; font-weight: 700; text-decoration: none; + color: var(--ink-soft); padding: 0.6em 1.1em; border-radius: 10px 10px 0 0; + border: 2px solid transparent; border-bottom: none; margin-bottom: -2px; +} +.tab-link.active { background: var(--paper); border-color: var(--line); color: var(--rust); } +.tab-link:hover:not(.active) { background: rgba(232,184,75,0.15); } + .friend-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); @@ -18,6 +30,13 @@ } .friend-card .tape { top: -10px; left: 50%; transform: translateX(-50%) rotate(-3deg); } +.order-controls { + display: flex; align-items: center; justify-content: center; gap: 0.5em; + margin-bottom: 0.4em; +} +.order-controls button { font-size: 1rem; padding: 0.15em 0.5em; line-height: 1; } +.order-num { font-family: 'Caveat', cursive; font-size: 1.2rem; color: var(--ink-soft); min-width: 1.4em; } + .friend-thumb { width: 84px; height: 84px; border-radius: 50%; object-fit: cover; border: 4px solid #fff; box-shadow: var(--shadow); diff --git a/backend/public/admin/admin.js b/backend/public/admin/admin.js index 546d359..52b94a2 100644 --- a/backend/public/admin/admin.js +++ b/backend/public/admin/admin.js @@ -10,31 +10,15 @@ async function checkSession() { } } +// Einstellungen werden im Hintergrund geladen (für den Freigabe-Link), +// bearbeitet werden sie im separaten "Einstellungen"-Tab. async function loadSettings() { const res = await fetch('/api/settings'); if (!res.ok) return; const data = await res.json(); - settings = data.settings || { publicBaseUrl: '' }; - document.getElementById('publicBaseUrl').value = settings.publicBaseUrl || ''; + settings = data.settings || { publicBaseUrl: '', bookTitle: '' }; } -document.getElementById('saveSettingsBtn').addEventListener('click', async () => { - const publicBaseUrl = document.getElementById('publicBaseUrl').value.trim(); - const res = await fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ publicBaseUrl }), - }); - const data = await res.json(); - if (res.ok) { - settings = data.settings; - const msg = document.getElementById('settingsSaved'); - msg.textContent = 'Gespeichert ✓'; - msg.classList.add('show'); - setTimeout(() => msg.classList.remove('show'), 1800); - } -}); - function baseUrl() { return settings.publicBaseUrl && settings.publicBaseUrl.length ? settings.publicBaseUrl @@ -55,7 +39,7 @@ function renderFriends() { grid.innerHTML = ''; emptyMsg.style.display = friends.length ? 'none' : 'block'; - friends.forEach((f) => { + friends.forEach((f, idx) => { const el = document.createElement('div'); el.className = 'friend-card'; const thumbHtml = f.profileImage @@ -63,6 +47,11 @@ function renderFriends() { : `
${(f.name || '?')[0].toUpperCase()}
`; el.innerHTML = `
+
+ + ${idx + 1} + +
${thumbHtml}

${escapeHtml(f.name)}

${f.shareEnabled ? 'Freigegeben' : 'Nicht freigegeben'} @@ -85,6 +74,27 @@ function renderFriends() { grid.querySelectorAll('[data-export]').forEach((btn) => { btn.addEventListener('click', () => window.location.href = `/api/friends/${btn.dataset.export}/export`); }); + grid.querySelectorAll('[data-moveup]').forEach((btn) => { + btn.addEventListener('click', () => moveFriend(btn.dataset.moveup, -1)); + }); + grid.querySelectorAll('[data-movedown]').forEach((btn) => { + btn.addEventListener('click', () => moveFriend(btn.dataset.movedown, 1)); + }); +} + +async function moveFriend(id, dir) { + const idx = friends.findIndex((f) => f.id === id); + const swapIdx = idx + dir; + if (idx === -1 || swapIdx < 0 || swapIdx >= friends.length) return; + const newOrder = [...friends]; + [newOrder[idx], newOrder[swapIdx]] = [newOrder[swapIdx], newOrder[idx]]; + const order = newOrder.map((f) => f.id); + const res = await fetch('/api/friends/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }), + }); + if (res.ok) loadFriends(); } // wir brauchen den vollen Ordnernamen für Bild-URLs; da wir ihn nicht separat liefern, @@ -190,56 +200,6 @@ document.getElementById('logoutBtn').addEventListener('click', async () => { window.location.href = '/'; }); -// --- Sichern & Wiederherstellen --- -function showTransferMsg(text, isError) { - const box = document.getElementById('transferMsg'); - box.innerHTML = isError ? `

${text}

` : `

${text}

`; -} - -document.getElementById('bookExportBtn').addEventListener('click', () => { - window.location.href = '/api/book/export'; -}); - -document.getElementById('bookImportBtn').addEventListener('click', async () => { - const fileInput = document.getElementById('bookImportFile'); - const file = fileInput.files[0]; - if (!file) { showTransferMsg('Bitte zuerst eine ZIP-Datei auswählen.', true); return; } - const replaceAll = document.getElementById('bookImportReplace').checked; - if (replaceAll && !confirm('Wirklich ALLE bestehenden Freunde vor dem Import löschen?')) return; - - const fd = new FormData(); - fd.append('file', file); - fd.append('replaceAll', replaceAll ? 'true' : 'false'); - showTransferMsg('Importiere...', false); - const res = await fetch('/api/book/import', { method: 'POST', body: fd }); - const data = await res.json(); - if (res.ok) { - showTransferMsg(`${data.imported.length} Freund(e) importiert: ${data.imported.join(', ') || '–'}`, false); - fileInput.value = ''; - loadFriends(); - } else { - showTransferMsg(data.error || 'Import fehlgeschlagen.', true); - } -}); - -document.getElementById('friendImportBtn').addEventListener('click', async () => { - const fileInput = document.getElementById('friendImportFile'); - const file = fileInput.files[0]; - if (!file) { showTransferMsg('Bitte zuerst eine ZIP-Datei auswählen.', true); return; } - const fd = new FormData(); - fd.append('file', file); - showTransferMsg('Importiere...', false); - const res = await fetch('/api/friends/import', { method: 'POST', body: fd }); - const data = await res.json(); - if (res.ok) { - showTransferMsg(`"${data.friend.name}" wurde importiert.`, false); - fileInput.value = ''; - loadFriends(); - } else { - showTransferMsg(data.error || 'Import fehlgeschlagen.', true); - } -}); - checkSession(); loadSettings(); loadFriends(); diff --git a/backend/public/admin/index.html b/backend/public/admin/index.html index 9902c7d..3ecc3a4 100644 --- a/backend/public/admin/index.html +++ b/backend/public/admin/index.html @@ -16,19 +16,13 @@ -
-
-

Einstellungen

- -
- - -
-

Leer lassen, um automatisch die aktuell aufgerufene Adresse zu verwenden.

-
-
+ -
+
+

Neuen Freund anlegen

@@ -37,35 +31,9 @@
-
-

Sichern & Wiederherstellen

-
-
-

Ganzes Buch

- -
-
- - - - -
-
-
-
-

Einzelnen Freund importieren

-

Legt einen neuen Freund aus einer zuvor exportierten ZIP-Datei an.

- - -
-
-
-

Deine Freunde

+

Die Reihenfolge hier bestimmt die Seitenfolge im Freundesbuch (nach dem Deckblatt). Mit den Pfeilen verschieben.

diff --git a/backend/public/admin/settings.html b/backend/public/admin/settings.html new file mode 100644 index 0000000..d5b0bcf --- /dev/null +++ b/backend/public/admin/settings.html @@ -0,0 +1,68 @@ + + + + + +Freundesbuch – Einstellungen + + + + +
+

📖 Freundesbuch

+
+ 🎠 Freundesbuch anschauen + +
+
+ + + +
+
+

Einstellungen

+ + + +
+ + +
+

Leer lassen, um automatisch die aktuell aufgerufene Adresse zu verwenden.

+
+
+ +
+

Sichern & Wiederherstellen

+
+
+

Ganzes Buch

+ +
+
+ + + + +
+
+
+
+

Einzelnen Freund importieren

+

Legt einen neuen Freund aus einer zuvor exportierten ZIP-Datei an.

+ + +
+
+
+
+ + + + diff --git a/backend/public/admin/settings.js b/backend/public/admin/settings.js new file mode 100644 index 0000000..302b82e --- /dev/null +++ b/backend/public/admin/settings.js @@ -0,0 +1,92 @@ +let settings = { publicBaseUrl: '', bookTitle: '' }; + +async function checkSession() { + const res = await fetch('/api/session'); + const data = await res.json(); + if (data.role !== 'admin') { + window.location.href = '/'; + } +} + +async function loadSettings() { + const res = await fetch('/api/settings'); + if (!res.ok) return; + const data = await res.json(); + settings = data.settings || { publicBaseUrl: '', bookTitle: '' }; + document.getElementById('publicBaseUrl').value = settings.publicBaseUrl || ''; + document.getElementById('bookTitle').value = settings.bookTitle || ''; +} + +document.getElementById('saveSettingsBtn').addEventListener('click', async () => { + const publicBaseUrl = document.getElementById('publicBaseUrl').value.trim(); + const bookTitle = document.getElementById('bookTitle').value.trim(); + const res = await fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicBaseUrl, bookTitle }), + }); + const data = await res.json(); + if (res.ok) { + settings = data.settings; + const msg = document.getElementById('settingsSaved'); + msg.textContent = 'Gespeichert ✓'; + msg.classList.add('show'); + setTimeout(() => msg.classList.remove('show'), 1800); + } +}); + +document.getElementById('logoutBtn').addEventListener('click', async () => { + await fetch('/api/logout', { method: 'POST' }); + window.location.href = '/'; +}); + +// --- Sichern & Wiederherstellen --- +function showTransferMsg(text, isError) { + const box = document.getElementById('transferMsg'); + box.innerHTML = isError ? `

${text}

` : `

${text}

`; +} + +document.getElementById('bookExportBtn').addEventListener('click', () => { + window.location.href = '/api/book/export'; +}); + +document.getElementById('bookImportBtn').addEventListener('click', async () => { + const fileInput = document.getElementById('bookImportFile'); + const file = fileInput.files[0]; + if (!file) { showTransferMsg('Bitte zuerst eine ZIP-Datei auswählen.', true); return; } + const replaceAll = document.getElementById('bookImportReplace').checked; + if (replaceAll && !confirm('Wirklich ALLE bestehenden Freunde vor dem Import löschen?')) return; + + const fd = new FormData(); + fd.append('file', file); + fd.append('replaceAll', replaceAll ? 'true' : 'false'); + showTransferMsg('Importiere...', false); + const res = await fetch('/api/book/import', { method: 'POST', body: fd }); + const data = await res.json(); + if (res.ok) { + showTransferMsg(`${data.imported.length} Freund(e) importiert: ${data.imported.join(', ') || '–'}`, false); + fileInput.value = ''; + } else { + showTransferMsg(data.error || 'Import fehlgeschlagen.', true); + } +}); + +document.getElementById('friendImportBtn').addEventListener('click', async () => { + const fileInput = document.getElementById('friendImportFile'); + const file = fileInput.files[0]; + if (!file) { showTransferMsg('Bitte zuerst eine ZIP-Datei auswählen.', true); return; } + const fd = new FormData(); + fd.append('file', file); + showTransferMsg('Importiere...', false); + const res = await fetch('/api/friends/import', { method: 'POST', body: fd }); + const data = await res.json(); + if (res.ok) { + showTransferMsg(`"${data.friend.name}" wurde importiert.`, false); + fileInput.value = ''; + } else { + showTransferMsg(data.error || 'Import fehlgeschlagen.', true); + } +}); + +checkSession(); +loadSettings(); diff --git a/backend/public/book/book.css b/backend/public/book/book.css index 2337373..ae2adcb 100644 --- a/backend/public/book/book.css +++ b/backend/public/book/book.css @@ -51,3 +51,45 @@ #pageIndicator { font-family: 'Caveat', cursive; font-size: 1.3rem; color: var(--ink-soft); } .empty-book { text-align: center; margin-top: 3em; } + +/* Deckblatt */ +.cover-page { + position: relative; + min-height: 640px; + border-radius: 18px; + background: var(--paper-2); + box-shadow: var(--shadow); + overflow: hidden; + border: 2px dashed var(--mustard); +} +.cover-stickers { position: absolute; inset: 0; } +.cover-sticker { + position: absolute; + object-fit: cover; + aspect-ratio: 1 / 1; + border: 6px solid #fff; + border-radius: 8px; + box-shadow: 0 6px 16px rgba(0,0,0,0.22); +} +.cover-title-wrap { + position: relative; + z-index: 5; + text-align: center; + top: 42%; + transform: translateY(-50%); + padding: 0.6em 1.2em; +} +.cover-title { + display: inline-block; + font-size: 3.6rem; + background: rgba(255,253,247,0.92); + padding: 0.25em 0.7em; + border-radius: 12px; + box-shadow: 0 6px 18px rgba(0,0,0,0.18); + transform: rotate(-2deg); + max-width: 90%; +} +@media (max-width: 640px) { + .cover-page { min-height: 460px; } + .cover-title { font-size: 2.3rem; } +} diff --git a/backend/public/book/book.js b/backend/public/book/book.js index 314c4ca..bc46e12 100644 --- a/backend/public/book/book.js +++ b/backend/public/book/book.js @@ -1,5 +1,6 @@ let friends = []; -let currentIndex = 0; +let bookTitle = 'Unser Freundesbuch'; +let currentIndex = -1; // -1 = Deckblatt, 0..n-1 = Freunde const STECKBRIEF_LABELS = { spitzname: 'Spitzname', @@ -17,6 +18,8 @@ const STECKBRIEF_LABELS = { sonstiges: 'Sonstiges', }; +const COVER_MAX_STICKERS = 24; + function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str || ''; @@ -28,29 +31,86 @@ function randomRotation(seed) { return angles[seed % angles.length]; } -async function load() { - const res = await fetch('/api/book'); - if (res.status === 401) { window.location.href = '/'; return; } - const data = await res.json(); - friends = data.friends || []; - if (!friends.length) { - document.getElementById('bookRoot').innerHTML = ` -
-

Noch ist dieses Buch leer 📖

-

Lege im Admin-Bereich Freunde an und teile den Link mit ihnen.

-
`; - return; - } - document.getElementById('bookNav').style.display = 'flex'; - renderPage(); +// Einfacher, deterministischer Pseudo-Zufallsgenerator (gleiche Seed -> gleiches Ergebnis), +// damit das Deckblatt bei jedem Aufruf ähnlich aussieht statt bei jedem Reload zu "springen". +function seededRandom(seed) { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; } function uploadUrl(folder, filename) { return `/uploads/${encodeURIComponent(folder)}/${encodeURIComponent(filename)}`; } -function renderPage() { - const f = friends[currentIndex]; +async function load() { + const [bookRes, settingsRes] = await Promise.all([fetch('/api/book'), fetch('/api/settings')]); + if (bookRes.status === 401 || settingsRes.status === 401) { window.location.href = '/'; return; } + const bookData = await bookRes.json(); + const settingsData = await settingsRes.json(); + friends = bookData.friends || []; + bookTitle = (settingsData.settings && settingsData.settings.bookTitle) || 'Unser Freundesbuch'; + + document.getElementById('bookNav').style.display = 'flex'; + renderPage(); +} + +// --- Deckblatt: Titel + "sticker-gebombte" Foto-Collage --- +function collectStickerImages() { + const imgs = []; + friends.forEach((f) => { + if (f.profileImage) imgs.push({ folder: f.folderName, filename: f.profileImage }); + (f.blocks || []).forEach((b) => { + if (b.type === 'image' && b.filename) imgs.push({ folder: f.folderName, filename: b.filename }); + if (b.type === 'audio' && b.coverFilename) imgs.push({ folder: f.folderName, filename: b.coverFilename }); + }); + }); + return imgs; +} + +function renderCoverPage() { + const root = document.getElementById('bookRoot'); + let imgs = collectStickerImages(); + + if (imgs.length > COVER_MAX_STICKERS) { + const step = imgs.length / COVER_MAX_STICKERS; + imgs = Array.from({ length: COVER_MAX_STICKERS }, (_, i) => imgs[Math.floor(i * step)]); + } + + let stickersHtml = ''; + if (imgs.length) { + const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(imgs.length * 1.6)))); + const rows = Math.ceil(imgs.length / cols); + stickersHtml = imgs + .map((img, i) => { + const cellX = i % cols; + const cellY = Math.floor(i / cols); + const rx = seededRandom(i * 13 + 1); + const ry = seededRandom(i * 17 + 2); + const rr = seededRandom(i * 7 + 3); + const rs = seededRandom(i * 5 + 4); + const leftPct = ((cellX + 0.1 + rx * 0.8) / cols) * 100; + const topPct = ((cellY + 0.1 + ry * 0.8) / rows) * 100; + const rot = (rr * 50 - 25).toFixed(1); + const size = Math.round(85 + rs * 65); + return ``; + }) + .join(''); + } + + root.innerHTML = ` +
+
${stickersHtml}
+
+
+

${escapeHtml(bookTitle)}

+
+
+ `; +} + +function renderFriendPage(f) { const root = document.getElementById('bookRoot'); const profileHtml = f.profileImage @@ -77,7 +137,7 @@ function renderPage() { if (b.type === 'audio') { return `
${b.coverFilename ? `` : ''} -
🎙️ ${b.caption ? escapeHtml(b.caption) : 'Sprachnachricht'}
+
🎙️${b.caption ? ' ' + escapeHtml(b.caption) : ''}
`; } @@ -104,14 +164,31 @@ function renderPage() {
`; +} - document.getElementById('pageIndicator').textContent = `Seite ${currentIndex + 1} von ${friends.length}`; - document.getElementById('prevBtn').disabled = currentIndex === 0; - document.getElementById('nextBtn').disabled = currentIndex === friends.length - 1; +function renderPage() { + const root = document.getElementById('bookRoot'); + + if (currentIndex === -1) { + renderCoverPage(); + } else if (!friends.length) { + root.innerHTML = ` +
+

Noch ist dieses Buch leer 📖

+

Lege im Admin-Bereich Freunde an und teile den Link mit ihnen.

+
`; + } else { + renderFriendPage(friends[currentIndex]); + } + + const indicator = document.getElementById('pageIndicator'); + indicator.textContent = currentIndex === -1 ? 'Deckblatt' : `Seite ${currentIndex + 1} von ${friends.length}`; + document.getElementById('prevBtn').disabled = currentIndex === -1; + document.getElementById('nextBtn').disabled = currentIndex >= friends.length - 1; } document.getElementById('prevBtn').addEventListener('click', () => { - if (currentIndex > 0) { currentIndex--; renderPage(); window.scrollTo({ top: 0, behavior: 'smooth' }); } + if (currentIndex > -1) { currentIndex--; renderPage(); window.scrollTo({ top: 0, behavior: 'smooth' }); } }); document.getElementById('nextBtn').addEventListener('click', () => { if (currentIndex < friends.length - 1) { currentIndex++; renderPage(); window.scrollTo({ top: 0, behavior: 'smooth' }); } diff --git a/backend/server.js b/backend/server.js index 6aeb667..d195308 100644 --- a/backend/server.js +++ b/backend/server.js @@ -31,6 +31,7 @@ app.use('/uploads', uploadRoutes); const PUBLIC_DIR = path.join(__dirname, 'public'); app.use('/assets', express.static(path.join(PUBLIC_DIR, 'shared'))); app.use('/admin', express.static(path.join(PUBLIC_DIR, 'admin'))); +app.get('/admin/settings', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'admin', 'settings.html'))); app.use('/friend', express.static(path.join(PUBLIC_DIR, 'friend'))); app.use('/book', express.static(path.join(PUBLIC_DIR, 'book'))); app.get('/', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'login', 'index.html'))); diff --git a/backend/src/pdf.js b/backend/src/pdf.js index 59a925d..d24ffa6 100644 --- a/backend/src/pdf.js +++ b/backend/src/pdf.js @@ -21,6 +21,66 @@ function esc(str) { } const ROTATIONS = [-4, 3, -2, 5, -3, 2, -5, 4]; +const COVER_MAX_STICKERS = 30; + +// Gleicher deterministischer Pseudo-Zufallsgenerator wie im Buch-Viewer (book.js) +function seededRandom(seed) { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +function collectStickerImages(friends) { + const imgs = []; + friends.forEach((f) => { + const uploadsDir = path.join(storage.friendFolderPath(f.folderName), 'uploads'); + if (f.profileImage) imgs.push(path.join(uploadsDir, f.profileImage)); + (f.blocks || []).forEach((b) => { + if (b.type === 'image' && b.filename) imgs.push(path.join(uploadsDir, b.filename)); + if (b.type === 'audio' && b.coverFilename) imgs.push(path.join(uploadsDir, b.coverFilename)); + }); + }); + return imgs; +} + +function buildCoverPageHtml(title, friends) { + let files = collectStickerImages(friends); + if (files.length > COVER_MAX_STICKERS) { + const step = files.length / COVER_MAX_STICKERS; + files = Array.from({ length: COVER_MAX_STICKERS }, (_, i) => files[Math.floor(i * step)]); + } + + const cols = Math.min(6, Math.max(3, Math.ceil(Math.sqrt(files.length * 1.6)) || 3)); + const rows = Math.max(1, Math.ceil(files.length / cols)); + + const stickers = files + .map((filePath, i) => { + const uri = fileToDataUri(filePath); + if (!uri) return ''; + const cellX = i % cols; + const cellY = Math.floor(i / cols); + const rx = seededRandom(i * 13 + 1); + const ry = seededRandom(i * 17 + 2); + const rr = seededRandom(i * 7 + 3); + const rs = seededRandom(i * 5 + 4); + const leftPct = ((cellX + 0.1 + rx * 0.8) / cols) * 100; + const topPct = ((cellY + 0.1 + ry * 0.8) / rows) * 100; + const rot = (rr * 50 - 25).toFixed(1); + const sizeMm = Math.round(28 + rs * 20); + return ``; + }) + .join(''); + + return ` +
+
${stickers}
+
+
+

${esc(title)}

+
+
`; +} function buildFriendPageHtml(friend, folder) { const uploadsDir = path.join(storage.friendFolderPath(folder), 'uploads'); @@ -77,7 +137,8 @@ function buildFriendPageHtml(friend, folder) { `; } -function buildFullHtml(friends) { +function buildFullHtml(friends, bookTitle) { + const coverPage = buildCoverPageHtml(bookTitle || 'Unser Freundesbuch', friends); const pages = friends .map((f) => buildFriendPageHtml(f, f.folderName)) .join('\n'); @@ -140,9 +201,24 @@ function buildFullHtml(friends) { .audio-block { width: 58mm; min-height: 30mm; background: #e8f3ec; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; font-size: 13px; padding: 3mm; } .audio-cover { width: 100%; height: 30mm; object-fit: cover; border-radius: 4px; margin-bottom: 2mm; } .cap { font-family: Georgia, serif; font-size: 11px; color: #6b4f8c; margin-top: 2mm; text-align: center; } + + /* Deckblatt */ + .cover-page { display: flex; align-items: center; justify-content: center; padding: 0; } + .cover-stickers { position: absolute; inset: 0; } + .cover-sticker { + position: absolute; object-fit: cover; border: 4px solid #fff; border-radius: 6px; + box-shadow: 0 3px 10px rgba(0,0,0,0.25); + } + .cover-title-wrap { position: relative; z-index: 5; text-align: center; padding: 0 12mm; } + .cover-title { + display: inline-block; font-size: 52px; background: rgba(255,253,247,0.94); + padding: 6mm 10mm; border-radius: 8px; box-shadow: 0 4px 14px rgba(0,0,0,0.2); + transform: rotate(-2deg); color: #6b4f8c; max-width: 160mm; + } +${coverPage} ${pages || '

Noch keine Freunde angelegt.

'} `; @@ -150,7 +226,8 @@ ${pages || '

Noch keine Freunde angelegt.

{ folderName: f.folderName, name: f.name, createdAt: f.createdAt, + order: f.order, profileImage: f.profileImage, shareEnabled: !!(f.share && f.share.enabled), blockCount: (f.blocks || []).length, @@ -89,6 +90,13 @@ router.delete('/friends/:id', requireAdmin, (req, res) => { res.json({ ok: true }); }); +// Reihenfolge der Freunde festlegen (bestimmt auch die Seitenfolge im Buch) +router.post('/friends/reorder', requireAdmin, (req, res) => { + const order = req.body.order || []; // Array von Freund-IDs in gewünschter Reihenfolge + storage.reorderFriends(order); + res.json({ friends: storage.getAllFriendsMeta().map((f) => f.id) }); +}); + // Freigabe (Share-Link) verwalten router.post('/friends/:id/share', requireAdmin, (req, res) => { const folder = storage.findFolderById(req.params.id); @@ -119,7 +127,8 @@ router.get('/settings', requireAdmin, (req, res) => { router.put('/settings', requireAdmin, (req, res) => { const current = storage.readSettings(); const publicBaseUrl = typeof req.body.publicBaseUrl === 'string' ? req.body.publicBaseUrl.trim().replace(/\/+$/, '') : current.publicBaseUrl; - const updated = storage.writeSettings({ ...current, publicBaseUrl }); + const bookTitle = typeof req.body.bookTitle === 'string' && req.body.bookTitle.trim() ? req.body.bookTitle.trim() : current.bookTitle; + const updated = storage.writeSettings({ ...current, publicBaseUrl, bookTitle }); res.json({ settings: updated }); }); diff --git a/backend/src/utils/storage.js b/backend/src/utils/storage.js index 9ea358d..2efcdab 100644 --- a/backend/src/utils/storage.js +++ b/backend/src/utils/storage.js @@ -66,6 +66,7 @@ function createFriend(name) { const id = randomId(); const folderName = `${slugify(name)}-${id}`; const dir = friendFolderPath(folderName); + const nextOrder = listFriendFolders().length; // ans Ende der Reihenfolge anhängen fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(path.join(dir, 'uploads'), { recursive: true }); const meta = { @@ -73,6 +74,7 @@ function createFriend(name) { folderName, name, createdAt: new Date().toISOString(), + order: nextOrder, profileImage: null, steckbrief: {}, blocks: [], @@ -91,10 +93,23 @@ function deleteFriend(folderName) { } function getAllFriendsMeta() { - return listFriendFolders() - .map((f) => readMeta(f)) - .filter(Boolean) - .sort((a, b) => a.name.localeCompare(b.name, 'de')); + const metas = listFriendFolders().map((f) => readMeta(f)).filter(Boolean); + // Rückwärtskompatibilität: ältere Einträge ohne "order" ans Ende sortieren, stabil nach Name + metas.forEach((m, i) => { + if (typeof m.order !== 'number') m.order = 100000 + i; + }); + return metas.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, 'de')); +} + +function reorderFriends(orderedIds) { + orderedIds.forEach((id, index) => { + const folder = findFolderById(id); + if (!folder) return; + const meta = readMeta(folder); + if (!meta) return; + meta.order = index; + writeMeta(folder, meta); + }); } // --- Einstellungen (z. B. öffentliche Basis-URL hinter einem Reverse-Proxy) --- @@ -115,6 +130,7 @@ function readSettings() { } return { publicBaseUrl: (process.env.PUBLIC_BASE_URL || '').trim(), + bookTitle: (process.env.BOOK_TITLE || 'Unser Freundesbuch').trim(), ...fromFile, }; } @@ -140,6 +156,7 @@ module.exports = { createFriend, deleteFriend, getAllFriendsMeta, + reorderFriends, readSettings, writeSettings, };