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

@@ -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);

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() {
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() {
: `<div class="friend-thumb placeholder">${(f.name || '?')[0].toUpperCase()}</div>`;
el.innerHTML = `
<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}
<h3>${escapeHtml(f.name)}</h3>
<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) => {
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 ? `<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();
loadSettings();
loadFriends();

View File

@@ -16,19 +16,13 @@
</div>
</div>
<div class="container">
<section class="card" id="settingsCard">
<h2>Einstellungen</h2>
<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>
<nav class="tab-nav">
<a href="/admin" class="tab-link active">👥 Freunde</a>
<a href="/admin/settings" class="tab-link">⚙️ Einstellungen &amp; Sichern</a>
</nav>
<section class="card" id="createCard" style="margin-top:1.4em;">
<div class="container">
<section class="card" id="createCard">
<h2>Neuen Freund anlegen</h2>
<form id="createForm" class="inline-form">
<input type="text" id="newName" placeholder="Name des Freundes" required />
@@ -37,35 +31,9 @@
<div id="createErr"></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>
<section style="margin-top:2em;">
<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>
<p id="emptyMsg" class="muted" style="display:none;">Noch keine Freunde angelegt leg oben den ersten an!</p>
</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();