This commit is contained in:
2026-09-07 12:21:53 +02:00
commit 93051f791d
25 changed files with 4226 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
.profile-preview {
width: 140px; height: 140px; border-radius: 50%;
object-fit: cover; border: 6px solid #fff; box-shadow: var(--shadow);
margin: 0.4em auto 0; display: block;
}
.profile-preview.placeholder {
display: flex; align-items: center; justify-content: center;
font-size: 2.4rem; background: var(--paper); color: var(--rose);
}
.steckbrief-form {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1em;
}
.saved-msg { margin-left: 0.8em; color: #3a8a5a; font-weight: 700; opacity: 0; transition: opacity 0.3s; }
.saved-msg.show { opacity: 1; }
.add-block-tabs { display: flex; gap: 0.5em; margin: 0.8em 0; flex-wrap: wrap; }
.tab-btn { background: #fff; color: var(--ink-soft); border: 2px solid var(--line); box-shadow: none; padding: 0.5em 1em; }
.tab-btn.active { background: var(--ink-soft); color: #fff; border-color: var(--ink-soft); }
.blocks-list { display: flex; flex-wrap: wrap; gap: 1.2em; margin-top: 1.4em; }
.block-item {
background: var(--paper-2); border-radius: 10px; box-shadow: var(--shadow);
padding: 1em; width: 220px; position: relative;
}
.block-item img { width: 100%; height: 140px; object-fit: cover; border-radius: 6px; }
.block-item audio { width: 100%; margin-top: 0.4em; }
.block-item textarea { min-height: 4em; font-size: 0.9rem; }
.block-item .block-caption { font-size: 0.82rem; margin-top: 0.5em; }
.block-item .block-actions { display: flex; justify-content: space-between; margin-top: 0.6em; gap: 0.4em; }
.block-item .block-actions button { font-size: 0.78rem; padding: 0.35em 0.7em; }
.block-type-tag {
position: absolute; top: -8px; left: 10px; font-size: 0.7rem; font-weight: 700;
background: var(--mustard); color: #533; padding: 0.1em 0.6em; border-radius: 999px;
}

View File

@@ -0,0 +1,270 @@
const STECKBRIEF_FIELDS = [
['spitzname', 'Spitzname'],
['geburtstag', 'Geburtstag'],
['adresse', 'Adresse / Wohnort'],
['lieblingsfarbe', 'Lieblingsfarbe'],
['lieblingsessen', 'Lieblingsessen'],
['lieblingstier', 'Lieblingstier'],
['hobbys', 'Hobbys'],
['lieblingsmusik', 'Lieblingsmusik'],
['lieblingsfilm', 'Lieblingsfilm/-serie'],
['traumberuf', 'Traumberuf'],
['bestefreunde', 'Beste Freunde'],
['motto', 'Motto / Lebensweisheit'],
['sonstiges', 'Sonstiges'],
];
const pathParts = window.location.pathname.split('/').filter(Boolean); // ['friend', ':id'] or ['share', ':id']
const mode = pathParts[0]; // 'friend' | 'share'
const friendId = pathParts[1];
let currentFriend = null;
let currentFolder = null;
let selectedType = 'image';
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
async function init() {
const sessionRes = await fetch('/api/session');
const session = await sessionRes.json();
const isAdmin = session.role === 'admin';
const isMatchingFriend = session.role === 'friend' && session.friendId === friendId;
document.getElementById('adminBackWrap').style.display = isAdmin ? 'block' : 'none';
if (isAdmin || isMatchingFriend) {
loadFriend();
} else if (mode === 'share') {
showLoginGate();
} else {
// /friend/:id ohne Admin-Session -> zur Startseite
window.location.href = '/';
}
}
function showLoginGate() {
document.getElementById('loginGate').style.display = 'block';
document.getElementById('editorRoot').style.display = 'none';
}
document.getElementById('gateForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('gatePassword').value;
const errBox = document.getElementById('gateErr');
errBox.innerHTML = '';
const res = await fetch(`/api/share/${friendId}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (!res.ok) {
errBox.innerHTML = `<p class="error-msg">${data.error}</p>`;
return;
}
loadFriend();
});
async function loadFriend() {
const res = await fetch(`/api/friends/${friendId}`);
if (res.status === 401) { showLoginGate(); return; }
if (res.status === 404) { document.body.innerHTML = '<div class="container"><p class="error-msg">Diesen Freund gibt es nicht (mehr).</p></div>'; return; }
const data = await res.json();
currentFriend = data.friend;
currentFolder = currentFriend.folderName;
document.getElementById('loginGate').style.display = 'none';
document.getElementById('editorRoot').style.display = 'block';
document.getElementById('pageTitle').textContent = `📖 ${currentFriend.name}`;
renderProfileImage();
renderSteckbrief();
renderBlocks();
}
function uploadUrl(filename) {
return `/uploads/${encodeURIComponent(currentFolder)}/${encodeURIComponent(filename)}`;
}
function renderProfileImage() {
const img = document.getElementById('profileImg');
const placeholder = document.getElementById('profilePlaceholder');
if (currentFriend.profileImage) {
img.src = uploadUrl(currentFriend.profileImage) + `?t=${Date.now()}`;
img.style.display = 'block';
placeholder.style.display = 'none';
} else {
img.style.display = 'none';
placeholder.style.display = 'flex';
}
}
document.getElementById('profileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('image', file);
const res = await fetch(`/api/friends/${friendId}/profileimage`, { method: 'POST', body: fd });
const data = await res.json();
if (res.ok) {
currentFriend = data.friend;
renderProfileImage();
} else {
alert(data.error || 'Upload fehlgeschlagen.');
}
});
function renderSteckbrief() {
const form = document.getElementById('steckbriefForm');
form.innerHTML = '';
STECKBRIEF_FIELDS.forEach(([key, label]) => {
const wrap = document.createElement('div');
const val = (currentFriend.steckbrief && currentFriend.steckbrief[key]) || '';
wrap.innerHTML = `<label>${label}</label><input type="text" data-key="${key}" value="${escapeHtml(val)}" />`;
form.appendChild(wrap);
});
}
document.getElementById('steckbriefForm').addEventListener('submit', async (e) => {
e.preventDefault();
const inputs = document.querySelectorAll('#steckbriefForm input');
const payload = {};
inputs.forEach((inp) => { payload[inp.dataset.key] = inp.value; });
const res = await fetch(`/api/friends/${friendId}/steckbrief`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) {
currentFriend = data.friend;
const savedEl = document.getElementById('sbSaved');
savedEl.textContent = 'Gespeichert ✓';
savedEl.classList.add('show');
setTimeout(() => savedEl.classList.remove('show'), 1800);
} else {
alert(data.error || 'Speichern fehlgeschlagen.');
}
});
// --- Block-Typ-Auswahl ---
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
selectedType = btn.dataset.type;
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === btn));
document.getElementById('blockType').value = selectedType;
const fileWrap = document.getElementById('blockFileWrap');
const textWrap = document.getElementById('blockTextWrap');
const fileLabel = document.getElementById('blockFileLabel');
const fileInput = document.getElementById('blockFile');
if (selectedType === 'text') {
fileWrap.style.display = 'none';
textWrap.style.display = 'block';
} else {
fileWrap.style.display = 'block';
textWrap.style.display = 'none';
fileLabel.textContent = selectedType === 'image' ? 'Bild auswählen' : 'MP3 auswählen';
fileInput.accept = selectedType === 'image' ? 'image/*' : 'audio/*';
}
});
});
document.getElementById('blockForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData();
fd.append('type', selectedType);
fd.append('caption', document.getElementById('blockCaption').value);
if (selectedType === 'text') {
fd.append('content', document.getElementById('blockContent').value);
} else {
const file = document.getElementById('blockFile').files[0];
if (!file) { alert('Bitte eine Datei auswählen.'); return; }
fd.append('file', file);
}
const res = await fetch(`/api/friends/${friendId}/blocks`, { method: 'POST', body: fd });
const data = await res.json();
if (res.ok) {
currentFriend = data.friend;
document.getElementById('blockForm').reset();
renderBlocks();
} else {
alert(data.error || 'Hinzufügen fehlgeschlagen.');
}
});
function renderBlocks() {
const list = document.getElementById('blocksList');
list.innerHTML = '';
const blocks = [...(currentFriend.blocks || [])].sort((a, b) => a.order - b.order);
blocks.forEach((b, idx) => {
const el = document.createElement('div');
el.className = 'block-item';
const tag = { image: '🖼️ Bild', text: '📝 Text', audio: '🎙️ Audio' }[b.type] || b.type;
let inner = `<div class="block-type-tag">${tag}</div>`;
if (b.type === 'image') {
inner += `<img src="${uploadUrl(b.filename)}" />`;
} else if (b.type === 'audio') {
inner += `<audio controls src="${uploadUrl(b.filename)}"></audio>`;
} else {
inner += `<textarea data-content-for="${b.id}">${escapeHtml(b.content)}</textarea>`;
}
inner += `<input type="text" class="block-caption" placeholder="Bildunterschrift" data-caption-for="${b.id}" value="${escapeHtml(b.caption)}" />`;
inner += `<div class="block-actions">
<button type="button" class="secondary" data-save="${b.id}">💾</button>
<button type="button" class="secondary" data-up="${b.id}" ${idx === 0 ? 'disabled' : ''}>↑</button>
<button type="button" class="secondary" data-down="${b.id}" ${idx === blocks.length - 1 ? 'disabled' : ''}>↓</button>
<button type="button" class="danger" data-del="${b.id}">🗑️</button>
</div>`;
el.innerHTML = inner;
list.appendChild(el);
});
list.querySelectorAll('[data-save]').forEach((btn) => btn.addEventListener('click', () => saveBlock(btn.dataset.save)));
list.querySelectorAll('[data-del]').forEach((btn) => btn.addEventListener('click', () => deleteBlock(btn.dataset.del)));
list.querySelectorAll('[data-up]').forEach((btn) => btn.addEventListener('click', () => moveBlock(btn.dataset.up, -1)));
list.querySelectorAll('[data-down]').forEach((btn) => btn.addEventListener('click', () => moveBlock(btn.dataset.down, 1)));
}
async function saveBlock(id) {
const captionInput = document.querySelector(`[data-caption-for="${id}"]`);
const contentInput = document.querySelector(`[data-content-for="${id}"]`);
const payload = { caption: captionInput ? captionInput.value : '' };
if (contentInput) payload.content = contentInput.value;
const res = await fetch(`/api/friends/${friendId}/blocks/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (res.ok) { currentFriend = data.friend; renderBlocks(); }
}
async function deleteBlock(id) {
if (!confirm('Diesen Block wirklich löschen?')) return;
const res = await fetch(`/api/friends/${friendId}/blocks/${id}`, { method: 'DELETE' });
const data = await res.json();
if (res.ok) { currentFriend = data.friend; renderBlocks(); }
}
async function moveBlock(id, dir) {
const blocks = [...(currentFriend.blocks || [])].sort((a, b) => a.order - b.order);
const idx = blocks.findIndex((b) => b.id === id);
const swapIdx = idx + dir;
if (swapIdx < 0 || swapIdx >= blocks.length) return;
[blocks[idx], blocks[swapIdx]] = [blocks[swapIdx], blocks[idx]];
const order = blocks.map((b) => b.id);
const res = await fetch(`/api/friends/${friendId}/blocks/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order }),
});
const data = await res.json();
if (res.ok) { currentFriend = data.friend; renderBlocks(); }
}
init();

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Freundesbuch</title>
<link rel="stylesheet" href="/assets/style.css" />
<link rel="stylesheet" href="/friend/friend.css" />
</head>
<body>
<div id="loginGate" class="container" style="display:none; max-width:380px; text-align:center;">
<div class="card" style="position:relative;">
<div class="tape" style="top:-10px; left:50%; transform:translateX(-50%) rotate(-3deg);"></div>
<span style="font-size:2.6rem;">🔒</span>
<h1 id="gateFriendName">Seite gesperrt</h1>
<p class="muted">Gib das Freigabe-Passwort ein, um deine Seite zu bearbeiten.</p>
<form id="gateForm" style="display:flex; flex-direction:column; gap:0.8em; margin-top:1em;">
<input type="password" id="gatePassword" placeholder="Passwort" required autofocus />
<button type="submit">Öffnen</button>
</form>
<div id="gateErr"></div>
</div>
</div>
<div id="editorRoot" style="display:none;">
<div class="top-bar card" style="border-radius:0;">
<h1 style="margin:0;" id="pageTitle">📖 Freund</h1>
<div id="adminBackWrap"><a class="btn secondary" href="/admin">← Zur Übersicht</a></div>
</div>
<div class="container">
<section class="card" style="text-align:center; position:relative;">
<div class="tape" style="top:-10px; left:50%; transform:translateX(-50%) rotate(-2deg);"></div>
<h2>Profilbild</h2>
<img id="profileImg" class="profile-preview" style="display:none;" />
<div id="profilePlaceholder" class="profile-preview placeholder">📷</div>
<div style="margin-top:0.8em;">
<input type="file" id="profileInput" accept="image/*" />
</div>
</section>
<section class="card" style="margin-top:1.4em;">
<h2>Steckbrief</h2>
<form id="steckbriefForm" class="steckbrief-form"></form>
<button type="submit" form="steckbriefForm" style="margin-top:1em;">Steckbrief speichern</button>
<span id="sbSaved" class="saved-msg"></span>
</section>
<section class="card" style="margin-top:1.4em;">
<h2>Erinnerungen &amp; Blöcke</h2>
<p class="muted">Füge Bilder, Texte oder Sprachnachrichten hinzu.</p>
<div class="add-block-tabs">
<button type="button" class="tab-btn active" data-type="image">🖼️ Bild</button>
<button type="button" class="tab-btn" data-type="text">📝 Text</button>
<button type="button" class="tab-btn" data-type="audio">🎙️ Audio</button>
</div>
<form id="blockForm">
<input type="hidden" id="blockType" value="image" />
<div id="blockFileWrap">
<label id="blockFileLabel">Bild auswählen</label>
<input type="file" id="blockFile" accept="image/*" />
</div>
<div id="blockTextWrap" style="display:none;">
<label>Text</label>
<textarea id="blockContent" placeholder="Schreib etwas Schönes..."></textarea>
</div>
<label style="margin-top:0.8em;">Bildunterschrift (optional)</label>
<input type="text" id="blockCaption" placeholder="z. B. Sommer 2026" />
<button type="submit" style="margin-top:0.8em;">+ Hinzufügen</button>
</form>
<div id="blocksList" class="blocks-list"></div>
</section>
</div>
</div>
<script src="/friend/friend.js"></script>
</body>
</html>