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() { : `
${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 @@ -Leer lassen, um automatisch die aktuell aufgerufene Adresse zu verwenden.
- -Legt einen neuen Freund aus einer zuvor exportierten ZIP-Datei an.
- - -Die Reihenfolge hier bestimmt die Seitenfolge im Freundesbuch (nach dem Deckblatt). Mit den Pfeilen verschieben.
Leer lassen, um automatisch die aktuell aufgerufene Adresse zu verwenden.
+ +Legt einen neuen Freund aus einer zuvor exportierten ZIP-Datei an.
+ + +${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 = ` -Lege im Admin-Bereich Freunde an und teile den Link mit ihnen.
-Lege im Admin-Bereich Freunde an und teile den Link mit ihnen.
+