This commit is contained in:
2026-09-07 13:00:59 +02:00
parent cbe4726c07
commit e0326285d3
12 changed files with 478 additions and 143 deletions

View File

@@ -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. Buch-Import kann optional vorher alles Bestehende gelöscht werden.
- **Freundesbuch-Ansicht**: verspieltes Scrapbook-Layout mit schräg angeordneten - **Freundesbuch-Ansicht**: verspieltes Scrapbook-Layout mit schräg angeordneten
Polaroid-Fotos, Washi-Tape-Optik usw. seitenweise durchblätterbar. 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 ## 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. vergebenen Freigabe-Passwort an und kann **nur** dort etwas eintragen.
- **/book** die verspielte Freundesbuch-Ansicht mit Blätterfunktion und PDF-Export - **/book** die verspielte Freundesbuch-Ansicht mit Blätterfunktion und PDF-Export
(Button oben rechts). (Button oben rechts).
- Im Admin-Bereich (`/admin`) findest du außerdem die Bereiche **Einstellungen** - Im Admin-Bereich gibt es oben zwei Reiter: **Freunde** (`/admin`, Standardansicht) und
(öffentliche URL) und **Sichern & Wiederherstellen** (Export/Import für einzelne Freunde **Einstellungen & Sichern** (`/admin/settings`) mit dem Buch-Titel, der öffentlichen URL
und für das gesamte Buch als ZIP-Datei) sowie einen Export-Button auf jeder Freundeskarte. sowie Export/Import für einzelne Freunde und das gesamte Buch.
## Hinweise ## Hinweise

View File

@@ -1,6 +1,18 @@
.inline-form { display: flex; gap: 0.6em; flex-wrap: wrap; margin-top: 0.6em; } .inline-form { display: flex; gap: 0.6em; flex-wrap: wrap; margin-top: 0.6em; }
.inline-form input { flex: 1; min-width: 220px; } .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 { .friend-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); 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); } .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 { .friend-thumb {
width: 84px; height: 84px; border-radius: 50%; width: 84px; height: 84px; border-radius: 50%;
object-fit: cover; border: 4px solid #fff; box-shadow: var(--shadow); object-fit: cover; border: 4px solid #fff; box-shadow: var(--shadow);

View File

@@ -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() { async function loadSettings() {
const res = await fetch('/api/settings'); const res = await fetch('/api/settings');
if (!res.ok) return; if (!res.ok) return;
const data = await res.json(); const data = await res.json();
settings = data.settings || { publicBaseUrl: '' }; settings = data.settings || { publicBaseUrl: '', bookTitle: '' };
document.getElementById('publicBaseUrl').value = settings.publicBaseUrl || '';
} }
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() { function baseUrl() {
return settings.publicBaseUrl && settings.publicBaseUrl.length return settings.publicBaseUrl && settings.publicBaseUrl.length
? settings.publicBaseUrl ? settings.publicBaseUrl
@@ -55,7 +39,7 @@ function renderFriends() {
grid.innerHTML = ''; grid.innerHTML = '';
emptyMsg.style.display = friends.length ? 'none' : 'block'; emptyMsg.style.display = friends.length ? 'none' : 'block';
friends.forEach((f) => { friends.forEach((f, idx) => {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'friend-card'; el.className = 'friend-card';
const thumbHtml = f.profileImage const thumbHtml = f.profileImage
@@ -63,6 +47,11 @@ function renderFriends() {
: `<div class="friend-thumb placeholder">${(f.name || '?')[0].toUpperCase()}</div>`; : `<div class="friend-thumb placeholder">${(f.name || '?')[0].toUpperCase()}</div>`;
el.innerHTML = ` el.innerHTML = `
<div class="tape"></div> <div class="tape"></div>
<div class="order-controls">
<button type="button" class="ghost" data-moveup="${f.id}" ${idx === 0 ? 'disabled' : ''} title="Nach vorn">↑</button>
<span class="order-num">${idx + 1}</span>
<button type="button" class="ghost" data-movedown="${f.id}" ${idx === friends.length - 1 ? 'disabled' : ''} title="Nach hinten">↓</button>
</div>
${thumbHtml} ${thumbHtml}
<h3>${escapeHtml(f.name)}</h3> <h3>${escapeHtml(f.name)}</h3>
<span class="badge ${f.shareEnabled ? 'on' : 'off'}">${f.shareEnabled ? 'Freigegeben' : 'Nicht freigegeben'}</span> <span class="badge ${f.shareEnabled ? 'on' : 'off'}">${f.shareEnabled ? 'Freigegeben' : 'Nicht freigegeben'}</span>
@@ -85,6 +74,27 @@ function renderFriends() {
grid.querySelectorAll('[data-export]').forEach((btn) => { grid.querySelectorAll('[data-export]').forEach((btn) => {
btn.addEventListener('click', () => window.location.href = `/api/friends/${btn.dataset.export}/export`); 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, // 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 = '/'; window.location.href = '/';
}); });
// --- Sichern & Wiederherstellen ---
function showTransferMsg(text, isError) {
const box = document.getElementById('transferMsg');
box.innerHTML = isError ? `<p class="error-msg">${text}</p>` : `<p class="saved-msg2 show">${text}</p>`;
}
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(); checkSession();
loadSettings(); loadSettings();
loadFriends(); loadFriends();

View File

@@ -16,19 +16,13 @@
</div> </div>
</div> </div>
<div class="container"> <nav class="tab-nav">
<section class="card" id="settingsCard"> <a href="/admin" class="tab-link active">👥 Freunde</a>
<h2>Einstellungen</h2> <a href="/admin/settings" class="tab-link">⚙️ Einstellungen &amp; Sichern</a>
<label for="publicBaseUrl">Öffentliche URL (für Freigabe-Links hinter einem Reverse-Proxy)</label> </nav>
<div class="inline-form">
<input type="text" id="publicBaseUrl" placeholder="https://freundesbuch.example.com" />
<button type="button" id="saveSettingsBtn">Speichern</button>
</div>
<p class="muted" style="margin-top:0.4em;">Leer lassen, um automatisch die aktuell aufgerufene Adresse zu verwenden.</p>
<div id="settingsSaved" class="saved-msg2"></div>
</section>
<section class="card" id="createCard" style="margin-top:1.4em;"> <div class="container">
<section class="card" id="createCard">
<h2>Neuen Freund anlegen</h2> <h2>Neuen Freund anlegen</h2>
<form id="createForm" class="inline-form"> <form id="createForm" class="inline-form">
<input type="text" id="newName" placeholder="Name des Freundes" required /> <input type="text" id="newName" placeholder="Name des Freundes" required />
@@ -37,35 +31,9 @@
<div id="createErr"></div> <div id="createErr"></div>
</section> </section>
<section class="card" id="transferCard" style="margin-top:1.4em;">
<h2>Sichern &amp; Wiederherstellen</h2>
<div class="transfer-row">
<div>
<h3 style="font-size:1.2rem;">Ganzes Buch</h3>
<button type="button" id="bookExportBtn" class="secondary">⬇️ Buch exportieren (ZIP)</button>
</div>
<div>
<label for="bookImportFile">Buch importieren</label>
<input type="file" id="bookImportFile" accept=".zip" />
<label style="font-weight:normal; display:flex; align-items:center; gap:0.4em; margin-top:0.4em;">
<input type="checkbox" id="bookImportReplace" style="width:auto;" />
Bestehende Freunde vorher löschen
</label>
<button type="button" id="bookImportBtn" class="secondary" style="margin-top:0.4em;">⬆️ Importieren</button>
</div>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:1.2em 0;" />
<div>
<h3 style="font-size:1.2rem;">Einzelnen Freund importieren</h3>
<p class="muted">Legt einen neuen Freund aus einer zuvor exportierten ZIP-Datei an.</p>
<input type="file" id="friendImportFile" accept=".zip" />
<button type="button" id="friendImportBtn" class="secondary" style="margin-top:0.4em;">⬆️ Importieren</button>
</div>
<div id="transferMsg"></div>
</section>
<section style="margin-top:2em;"> <section style="margin-top:2em;">
<h2>Deine Freunde</h2> <h2>Deine Freunde</h2>
<p class="muted">Die Reihenfolge hier bestimmt die Seitenfolge im Freundesbuch (nach dem Deckblatt). Mit den Pfeilen verschieben.</p>
<div id="friendGrid" class="friend-grid"></div> <div id="friendGrid" class="friend-grid"></div>
<p id="emptyMsg" class="muted" style="display:none;">Noch keine Freunde angelegt leg oben den ersten an!</p> <p id="emptyMsg" class="muted" style="display:none;">Noch keine Freunde angelegt leg oben den ersten an!</p>
</section> </section>

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freundesbuch Einstellungen</title>
<link rel="stylesheet" href="/assets/style.css" />
<link rel="stylesheet" href="/admin/admin.css" />
</head>
<body>
<div class="top-bar card" style="border-radius:0;">
<h1 style="margin:0;">📖 Freundesbuch</h1>
<div style="display:flex; gap:0.6em; flex-wrap:wrap;">
<a class="btn" href="/book" target="_blank" rel="noopener">🎠 Freundesbuch anschauen</a>
<button class="secondary" id="logoutBtn">Abmelden</button>
</div>
</div>
<nav class="tab-nav">
<a href="/admin" class="tab-link">👥 Freunde</a>
<a href="/admin/settings" class="tab-link active">⚙️ Einstellungen &amp; Sichern</a>
</nav>
<div class="container">
<section class="card" id="settingsCard">
<h2>Einstellungen</h2>
<label for="bookTitle">Titel des Freundesbuchs (steht auf dem Deckblatt)</label>
<input type="text" id="bookTitle" placeholder="Unser Freundesbuch" style="margin-bottom:1em;" />
<label for="publicBaseUrl">Öffentliche URL (für Freigabe-Links hinter einem Reverse-Proxy)</label>
<div class="inline-form">
<input type="text" id="publicBaseUrl" placeholder="https://freundesbuch.example.com" />
<button type="button" id="saveSettingsBtn">Speichern</button>
</div>
<p class="muted" style="margin-top:0.4em;">Leer lassen, um automatisch die aktuell aufgerufene Adresse zu verwenden.</p>
<div id="settingsSaved" class="saved-msg2"></div>
</section>
<section class="card" id="transferCard" style="margin-top:1.4em;">
<h2>Sichern &amp; Wiederherstellen</h2>
<div class="transfer-row">
<div>
<h3 style="font-size:1.2rem;">Ganzes Buch</h3>
<button type="button" id="bookExportBtn" class="secondary">⬇️ Buch exportieren (ZIP)</button>
</div>
<div>
<label for="bookImportFile">Buch importieren</label>
<input type="file" id="bookImportFile" accept=".zip" />
<label style="font-weight:normal; display:flex; align-items:center; gap:0.4em; margin-top:0.4em;">
<input type="checkbox" id="bookImportReplace" style="width:auto;" />
Bestehende Freunde vorher löschen
</label>
<button type="button" id="bookImportBtn" class="secondary" style="margin-top:0.4em;">⬆️ Importieren</button>
</div>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:1.2em 0;" />
<div>
<h3 style="font-size:1.2rem;">Einzelnen Freund importieren</h3>
<p class="muted">Legt einen neuen Freund aus einer zuvor exportierten ZIP-Datei an.</p>
<input type="file" id="friendImportFile" accept=".zip" />
<button type="button" id="friendImportBtn" class="secondary" style="margin-top:0.4em;">⬆️ Importieren</button>
</div>
<div id="transferMsg"></div>
</section>
</div>
<script src="/admin/settings.js"></script>
</body>
</html>

View File

@@ -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 ? `<p class="error-msg">${text}</p>` : `<p class="saved-msg2 show">${text}</p>`;
}
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();

View File

@@ -51,3 +51,45 @@
#pageIndicator { font-family: 'Caveat', cursive; font-size: 1.3rem; color: var(--ink-soft); } #pageIndicator { font-family: 'Caveat', cursive; font-size: 1.3rem; color: var(--ink-soft); }
.empty-book { text-align: center; margin-top: 3em; } .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; }
}

View File

@@ -1,5 +1,6 @@
let friends = []; let friends = [];
let currentIndex = 0; let bookTitle = 'Unser Freundesbuch';
let currentIndex = -1; // -1 = Deckblatt, 0..n-1 = Freunde
const STECKBRIEF_LABELS = { const STECKBRIEF_LABELS = {
spitzname: 'Spitzname', spitzname: 'Spitzname',
@@ -17,6 +18,8 @@ const STECKBRIEF_LABELS = {
sonstiges: 'Sonstiges', sonstiges: 'Sonstiges',
}; };
const COVER_MAX_STICKERS = 24;
function escapeHtml(str) { function escapeHtml(str) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = str || ''; div.textContent = str || '';
@@ -28,29 +31,86 @@ function randomRotation(seed) {
return angles[seed % angles.length]; return angles[seed % angles.length];
} }
async function load() { // Einfacher, deterministischer Pseudo-Zufallsgenerator (gleiche Seed -> gleiches Ergebnis),
const res = await fetch('/api/book'); // damit das Deckblatt bei jedem Aufruf ähnlich aussieht statt bei jedem Reload zu "springen".
if (res.status === 401) { window.location.href = '/'; return; } function seededRandom(seed) {
const data = await res.json(); let t = (seed += 0x6d2b79f5);
friends = data.friends || []; t = Math.imul(t ^ (t >>> 15), t | 1);
if (!friends.length) { t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
document.getElementById('bookRoot').innerHTML = ` return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
<div class="empty-book">
<h2>Noch ist dieses Buch leer 📖</h2>
<p class="muted">Lege im Admin-Bereich Freunde an und teile den Link mit ihnen.</p>
</div>`;
return;
}
document.getElementById('bookNav').style.display = 'flex';
renderPage();
} }
function uploadUrl(folder, filename) { function uploadUrl(folder, filename) {
return `/uploads/${encodeURIComponent(folder)}/${encodeURIComponent(filename)}`; return `/uploads/${encodeURIComponent(folder)}/${encodeURIComponent(filename)}`;
} }
function renderPage() { async function load() {
const f = friends[currentIndex]; 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 `<img class="cover-sticker" style="left:${leftPct}%; top:${topPct}%; width:${size}px; transform: rotate(${rot}deg);" src="${uploadUrl(img.folder, img.filename)}" />`;
})
.join('');
}
root.innerHTML = `
<div class="cover-page">
<div class="cover-stickers">${stickersHtml}</div>
<div class="cover-title-wrap">
<div class="tape" style="position:static; margin: 0 auto 0.4em; transform: rotate(-4deg);"></div>
<h1 class="cover-title">${escapeHtml(bookTitle)}</h1>
</div>
</div>
`;
}
function renderFriendPage(f) {
const root = document.getElementById('bookRoot'); const root = document.getElementById('bookRoot');
const profileHtml = f.profileImage const profileHtml = f.profileImage
@@ -77,7 +137,7 @@ function renderPage() {
if (b.type === 'audio') { if (b.type === 'audio') {
return `<div class="scrap-item audio-note" style="transform: rotate(${rot * 0.5}deg);"> return `<div class="scrap-item audio-note" style="transform: rotate(${rot * 0.5}deg);">
${b.coverFilename ? `<img class="audio-cover" src="${uploadUrl(f.folderName, b.coverFilename)}" />` : ''} ${b.coverFilename ? `<img class="audio-cover" src="${uploadUrl(f.folderName, b.coverFilename)}" />` : ''}
<div>🎙️ ${b.caption ? escapeHtml(b.caption) : 'Sprachnachricht'}</div> <div>🎙️${b.caption ? ' ' + escapeHtml(b.caption) : ''}</div>
<audio controls src="${uploadUrl(f.folderName, b.filename)}"></audio> <audio controls src="${uploadUrl(f.folderName, b.filename)}"></audio>
</div>`; </div>`;
} }
@@ -104,14 +164,31 @@ function renderPage() {
</div> </div>
</div> </div>
`; `;
}
document.getElementById('pageIndicator').textContent = `Seite ${currentIndex + 1} von ${friends.length}`; function renderPage() {
document.getElementById('prevBtn').disabled = currentIndex === 0; const root = document.getElementById('bookRoot');
document.getElementById('nextBtn').disabled = currentIndex === friends.length - 1;
if (currentIndex === -1) {
renderCoverPage();
} else if (!friends.length) {
root.innerHTML = `
<div class="empty-book">
<h2>Noch ist dieses Buch leer 📖</h2>
<p class="muted">Lege im Admin-Bereich Freunde an und teile den Link mit ihnen.</p>
</div>`;
} 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', () => { 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', () => { document.getElementById('nextBtn').addEventListener('click', () => {
if (currentIndex < friends.length - 1) { currentIndex++; renderPage(); window.scrollTo({ top: 0, behavior: 'smooth' }); } if (currentIndex < friends.length - 1) { currentIndex++; renderPage(); window.scrollTo({ top: 0, behavior: 'smooth' }); }

View File

@@ -31,6 +31,7 @@ app.use('/uploads', uploadRoutes);
const PUBLIC_DIR = path.join(__dirname, 'public'); const PUBLIC_DIR = path.join(__dirname, 'public');
app.use('/assets', express.static(path.join(PUBLIC_DIR, 'shared'))); app.use('/assets', express.static(path.join(PUBLIC_DIR, 'shared')));
app.use('/admin', express.static(path.join(PUBLIC_DIR, 'admin'))); 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('/friend', express.static(path.join(PUBLIC_DIR, 'friend')));
app.use('/book', express.static(path.join(PUBLIC_DIR, 'book'))); app.use('/book', express.static(path.join(PUBLIC_DIR, 'book')));
app.get('/', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'login', 'index.html'))); app.get('/', (req, res) => res.sendFile(path.join(PUBLIC_DIR, 'login', 'index.html')));

View File

@@ -21,6 +21,66 @@ function esc(str) {
} }
const ROTATIONS = [-4, 3, -2, 5, -3, 2, -5, 4]; 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 `<img class="cover-sticker" style="left:${leftPct}%; top:${topPct}%; width:${sizeMm}mm; height:${sizeMm}mm; transform: rotate(${rot}deg);" src="${uri}" />`;
})
.join('');
return `
<section class="page cover-page">
<div class="cover-stickers">${stickers}</div>
<div class="cover-title-wrap">
<div class="tape" style="position:static; margin: 0 auto 6mm; transform: rotate(-4deg);"></div>
<h1 class="cover-title">${esc(title)}</h1>
</div>
</section>`;
}
function buildFriendPageHtml(friend, folder) { function buildFriendPageHtml(friend, folder) {
const uploadsDir = path.join(storage.friendFolderPath(folder), 'uploads'); const uploadsDir = path.join(storage.friendFolderPath(folder), 'uploads');
@@ -77,7 +137,8 @@ function buildFriendPageHtml(friend, folder) {
</section>`; </section>`;
} }
function buildFullHtml(friends) { function buildFullHtml(friends, bookTitle) {
const coverPage = buildCoverPageHtml(bookTitle || 'Unser Freundesbuch', friends);
const pages = friends const pages = friends
.map((f) => buildFriendPageHtml(f, f.folderName)) .map((f) => buildFriendPageHtml(f, f.folderName))
.join('\n'); .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-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; } .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; } .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;
}
</style> </style>
</head> </head>
<body> <body>
${coverPage}
${pages || '<section class="page"><h1>Noch keine Freunde angelegt.</h1></section>'} ${pages || '<section class="page"><h1>Noch keine Freunde angelegt.</h1></section>'}
</body> </body>
</html>`; </html>`;
@@ -150,7 +226,8 @@ ${pages || '<section class="page"><h1>Noch keine Freunde angelegt.</h1></section
async function generateBookPdf() { async function generateBookPdf() {
const friends = storage.getAllFriendsMeta(); const friends = storage.getAllFriendsMeta();
const html = buildFullHtml(friends); const settings = storage.readSettings();
const html = buildFullHtml(friends, settings.bookTitle);
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,

View File

@@ -54,6 +54,7 @@ router.get('/friends', requireAdmin, (req, res) => {
folderName: f.folderName, folderName: f.folderName,
name: f.name, name: f.name,
createdAt: f.createdAt, createdAt: f.createdAt,
order: f.order,
profileImage: f.profileImage, profileImage: f.profileImage,
shareEnabled: !!(f.share && f.share.enabled), shareEnabled: !!(f.share && f.share.enabled),
blockCount: (f.blocks || []).length, blockCount: (f.blocks || []).length,
@@ -89,6 +90,13 @@ router.delete('/friends/:id', requireAdmin, (req, res) => {
res.json({ ok: true }); 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 // Freigabe (Share-Link) verwalten
router.post('/friends/:id/share', requireAdmin, (req, res) => { router.post('/friends/:id/share', requireAdmin, (req, res) => {
const folder = storage.findFolderById(req.params.id); const folder = storage.findFolderById(req.params.id);
@@ -119,7 +127,8 @@ router.get('/settings', requireAdmin, (req, res) => {
router.put('/settings', requireAdmin, (req, res) => { router.put('/settings', requireAdmin, (req, res) => {
const current = storage.readSettings(); const current = storage.readSettings();
const publicBaseUrl = typeof req.body.publicBaseUrl === 'string' ? req.body.publicBaseUrl.trim().replace(/\/+$/, '') : current.publicBaseUrl; 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 }); res.json({ settings: updated });
}); });

View File

@@ -66,6 +66,7 @@ function createFriend(name) {
const id = randomId(); const id = randomId();
const folderName = `${slugify(name)}-${id}`; const folderName = `${slugify(name)}-${id}`;
const dir = friendFolderPath(folderName); const dir = friendFolderPath(folderName);
const nextOrder = listFriendFolders().length; // ans Ende der Reihenfolge anhängen
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
fs.mkdirSync(path.join(dir, 'uploads'), { recursive: true }); fs.mkdirSync(path.join(dir, 'uploads'), { recursive: true });
const meta = { const meta = {
@@ -73,6 +74,7 @@ function createFriend(name) {
folderName, folderName,
name, name,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
order: nextOrder,
profileImage: null, profileImage: null,
steckbrief: {}, steckbrief: {},
blocks: [], blocks: [],
@@ -91,10 +93,23 @@ function deleteFriend(folderName) {
} }
function getAllFriendsMeta() { function getAllFriendsMeta() {
return listFriendFolders() const metas = listFriendFolders().map((f) => readMeta(f)).filter(Boolean);
.map((f) => readMeta(f)) // Rückwärtskompatibilität: ältere Einträge ohne "order" ans Ende sortieren, stabil nach Name
.filter(Boolean) metas.forEach((m, i) => {
.sort((a, b) => a.name.localeCompare(b.name, 'de')); 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) --- // --- Einstellungen (z. B. öffentliche Basis-URL hinter einem Reverse-Proxy) ---
@@ -115,6 +130,7 @@ function readSettings() {
} }
return { return {
publicBaseUrl: (process.env.PUBLIC_BASE_URL || '').trim(), publicBaseUrl: (process.env.PUBLIC_BASE_URL || '').trim(),
bookTitle: (process.env.BOOK_TITLE || 'Unser Freundesbuch').trim(),
...fromFile, ...fromFile,
}; };
} }
@@ -140,6 +156,7 @@ module.exports = {
createFriend, createFriend,
deleteFriend, deleteFriend,
getAllFriendsMeta, getAllFriendsMeta,
reorderFriends,
readSettings, readSettings,
writeSettings, writeSettings,
}; };