Files
Hermes AI Bot 4e1691ac37 fix(cache): auto-reload bei SW-Update statt confirm-Prompt
Bei neuem Service Worker wird die Seite automatisch neu geladen.
Kein Popup mehr für die User.
2026-06-27 13:08:41 +00:00

1246 lines
39 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JGA Memory-Buch — Prag 2026</title>
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#3b82f6">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="JGA Prag">
<meta name="mobile-web-app-capable" content="yes">
<script>
(function() {
const saved = localStorage.getItem('jga-theme');
document.documentElement.setAttribute('data-theme', saved || 'dark');
})();
</script>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📖</text></svg>">
<style>
:root, [data-theme="dark"] {
--bg: #1c1c1f;
--card-bg: #26262a;
--card-bg-2: #2e2e33;
--card-border: #353539;
--text: #e8e8ea;
--text-muted: #999;
--text-strong: #fff;
--accent: #3b82f6;
--accent-text: #60a5fa;
--danger: #ef4444;
--success: #22c55e;
--shadow: 0 4px 16px rgba(0,0,0,0.35);
--shadow-strong: 0 8px 32px rgba(0,0,0,0.5);
--modal-overlay: rgba(0,0,0,0.6);
}
[data-theme="light"] {
--bg: #f5f5f7;
--card-bg: #ffffff;
--card-bg-2: #f8f8fb;
--card-border: #e0e0e3;
--text: #1a1a1a;
--text-muted: #666;
--text-strong: #000;
--accent: #2563eb;
--accent-text: #2563eb;
--danger: #dc2626;
--success: #16a34a;
--shadow: 0 4px 16px rgba(0,0,0,0.08);
--shadow-strong: 0 8px 32px rgba(0,0,0,0.15);
--modal-overlay: rgba(0,0,0,0.4);
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
transition: background 0.3s, color 0.3s;
}
.container { max-width: 800px; margin: 0 auto; padding: 24px 24px 100px; }
h1 { font-size: 36px; margin: 0 0 8px; color: var(--text-strong); font-weight: 700; }
.subtitle { color: var(--text-muted); margin-bottom: 28px; font-size: 15px; }
.back-link { display: inline-block; margin-bottom: 16px; color: var(--accent-text); text-decoration: none; font-size: 14px; }
.back-link:hover { text-decoration: underline; }
.home-btn {
position: fixed;
top: 16px; left: 16px;
z-index: 1000;
background: var(--card-bg);
border: 1px solid var(--card-border);
color: var(--text);
text-decoration: none;
padding: 10px 16px;
border-radius: 22px;
font-size: 14px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
@media (max-width: 768px) {
.home-btn { top: 10px; left: 10px; padding: 8px 12px; font-size: 13px; }
.theme-toggle { top: 10px; right: 10px; width: 38px; height: 38px; font-size: 18px; }
}
.home-btn:hover {
transform: translateX(-2px);
border-color: var(--accent);
color: var(--accent);
}
.theme-toggle {
position: fixed;
top: 16px; right: 16px;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--card-border);
color: var(--text);
font-size: 20px;
cursor: pointer;
z-index: 100;
box-shadow: var(--shadow);
transition: all 0.2s;
display: flex; align-items: center; justify-content: center;
}
.theme-toggle:hover { transform: rotate(15deg) scale(1.1); border-color: var(--accent); }
/* ===== Buttons ===== */
.btn {
background: var(--accent);
color: #fff;
border: 0;
border-radius: 10px;
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn:hover { transform: translateY(-1px); }
.btn-secondary { background: var(--card-bg-2); color: var(--text); border: 1px solid var(--card-border); }
.btn-danger { background: var(--danger); }
.btn-large { font-size: 16px; padding: 14px 28px; }
/* ===== Setup-Wizard ===== */
.wizard {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 32px 28px;
box-shadow: var(--shadow);
max-width: 600px;
margin: 40px auto;
}
.wizard h2 { margin: 0 0 8px; color: var(--text-strong); font-size: 24px; }
.wizard p { color: var(--text-muted); margin: 0 0 24px; font-size: 14px; line-height: 1.5; }
.wizard-input {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.wizard-input input {
flex: 1;
background: var(--card-bg-2);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
}
.wizard-input input:focus { outline: 2px solid var(--accent); }
.wizard-input input[type="color"] { width: 50px; padding: 4px; cursor: pointer; }
.wizard-input select {
background: var(--card-bg-2);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 8px;
color: var(--text);
font-size: 18px;
}
.player-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0 24px;
min-height: 40px;
}
.player-pill {
background: var(--card-bg-2);
border: 2px solid var(--accent);
border-radius: 999px;
padding: 6px 14px;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.player-pill button {
background: transparent;
border: 0;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
}
.player-pill button:hover { color: var(--danger); }
/* ===== Header / Stats ===== */
.top-bar {
display: flex;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}
.top-bar h1 { margin: 0; }
.top-actions { display: flex; gap: 8px; }
.stats-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
font-size: 12px;
color: var(--text-muted);
}
.stats-bar .pill {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 999px;
padding: 6px 12px;
}
.stats-bar .pill strong { color: var(--text-strong); }
.filter-bar {
display: flex;
gap: 6px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-btn {
background: var(--card-bg-2);
border: 1px solid var(--card-border);
border-radius: 999px;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
color: var(--text);
}
.filter-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
/* ===== Entry Card ===== */
.entry {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 14px;
padding: 18px;
margin-bottom: 14px;
box-shadow: var(--shadow);
}
.entry-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.author-avatar {
width: 36px; height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
.entry-author { font-weight: 600; color: var(--text-strong); font-size: 15px; }
.entry-time { font-size: 12px; color: var(--text-muted); }
.entry-mood { margin-left: auto; font-size: 22px; }
.entry-title { font-size: 17px; font-weight: 700; margin: 6px 0 4px; color: var(--text-strong); }
.entry-body { font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; }
.entry-location {
display: inline-block;
margin-top: 10px;
background: var(--card-bg-2);
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
color: var(--text-muted);
}
.entry-tags { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; }
.entry-tag {
background: var(--card-bg-2);
border-radius: 999px;
padding: 2px 8px;
font-size: 11px;
color: var(--text-muted);
}
.entry-media {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
margin-top: 10px;
}
.entry-media img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
}
.entry-footer {
display: flex;
gap: 8px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--card-border);
flex-wrap: wrap;
align-items: center;
}
.reaction-btn {
background: var(--card-bg-2);
border: 1px solid var(--card-border);
border-radius: 999px;
padding: 4px 10px;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 4px;
}
.reaction-btn:hover { transform: scale(1.1); }
.reaction-btn.mine { background: rgba(59,130,246,0.2); border-color: var(--accent); }
.reaction-btn .count { font-size: 11px; color: var(--text-muted); font-weight: 600; }
.entry-actions {
margin-left: auto;
display: flex;
gap: 6px;
}
.entry-actions button {
background: transparent;
border: 0;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 4px 8px;
}
.entry-actions button:hover { color: var(--danger); }
/* ===== FAB ===== */
.fab {
position: fixed;
bottom: 30px; right: 30px;
width: 60px; height: 60px;
border-radius: 50%;
background: var(--accent);
color: #fff;
border: 0;
font-size: 32px;
cursor: pointer;
box-shadow: var(--shadow-strong);
transition: all 0.2s;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.fab:hover { transform: scale(1.1) rotate(90deg); }
/* ===== Modal ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay);
display: none;
align-items: flex-end;
justify-content: center;
z-index: 200;
backdrop-filter: blur(4px);
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 16px 16px 0 0;
padding: 24px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-strong);
}
@media (min-width: 600px) {
.modal-overlay { align-items: center; }
.modal { border-radius: 16px; max-height: 80vh; }
}
.modal h2 { margin: 0 0 16px; color: var(--text-strong); font-size: 22px; }
.form-group { margin-bottom: 14px; }
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
background: var(--card-bg-2);
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 10px 12px;
color: var(--text);
font-size: 14px;
font-family: inherit;
}
.form-group textarea { resize: vertical; min-height: 80px; }
.mood-picker {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mood-btn {
background: var(--card-bg-2);
border: 2px solid var(--card-border);
border-radius: 8px;
padding: 6px 10px;
font-size: 22px;
cursor: pointer;
transition: all 0.15s;
}
.mood-btn.active { border-color: var(--accent); background: rgba(59,130,246,0.2); }
.mood-btn:hover { transform: scale(1.15); }
.photo-preview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 6px;
margin-top: 10px;
}
.photo-preview-item {
position: relative;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}
.photo-preview-item img { width: 100%; height: 100%; object-fit: cover; }
.photo-preview-item .remove {
position: absolute;
top: 2px; right: 2px;
background: rgba(0,0,0,0.7);
color: #fff;
border: 0;
border-radius: 50%;
width: 22px; height: 22px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.modal-actions .btn { flex: 1; }
/* ===== Image Lightbox ===== */
.lightbox {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.92);
display: none;
align-items: center;
justify-content: center;
z-index: 300;
cursor: zoom-out;
}
.lightbox.open { display: flex; }
.lightbox img { max-width: 95vw; max-height: 95vh; border-radius: 8px; }
/* ===== Empty / Footer ===== */
.empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-style: italic;
background: var(--card-bg);
border: 1px dashed var(--card-border);
border-radius: 14px;
}
.empty .big { font-size: 48px; margin-bottom: 12px; }
.footer-note {
text-align: center;
color: var(--text-muted);
font-size: 12px;
margin-top: 30px;
}
.storage-warn {
background: rgba(234,179,8,0.15);
border: 1px solid #eab308;
color: #eab308;
padding: 10px 14px;
border-radius: 8px;
margin-bottom: 14px;
font-size: 13px;
}
.comment-section {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--card-border);
}
.comment {
background: var(--card-bg-2);
border-radius: 8px;
padding: 8px 10px;
margin-top: 6px;
font-size: 13px;
}
.comment .author { font-weight: 600; color: var(--text-strong); }
.comment .text { color: var(--text); }
.comment-form {
display: flex;
gap: 6px;
margin-top: 8px;
}
.comment-form input {
flex: 1;
background: var(--card-bg-2);
border: 1px solid var(--card-border);
border-radius: 6px;
padding: 6px 10px;
color: var(--text);
font-size: 13px;
}
</style>
</head>
<body>
<a href="index.html" class="home-btn" title="Zurück zur Startseite">🏠 Startseite</a>
<button class="theme-toggle" id="themeToggle" aria-label="Theme wechseln">🌙</button>
<div class="container">
<a href="index.html" class="back-link">← Zurück zur Übersicht</a>
<!-- ===== Setup-Wizard (nur beim ersten Mal) ===== -->
<div class="wizard" id="wizard" style="display:none;">
<h2>📖 Willkommen im Memory-Buch</h2>
<p>Wer ist beim JGA dabei? Lege die Mitreisenden an — du kannst später weitere hinzufügen.</p>
<div class="form-group">
<label>Name</label>
<div class="wizard-input">
<input type="text" id="newPlayerName" placeholder="z.B. Bräutigam">
<input type="color" id="newPlayerColor" value="#e91e63">
<select id="newPlayerEmoji">
<option>🤵</option><option>🤠</option><option>🥳</option><option>😎</option>
<option>🤩</option><option>😈</option><option>🧔</option><option>👨</option>
<option>👩</option><option>🧑</option><option>🦸</option><option>🥷</option>
<option>🤡</option><option>👻</option><option>🎅</option><option>👽</option>
</select>
<button class="btn" id="addPlayerBtn">+</button>
</div>
</div>
<div class="player-pills" id="playerPills"></div>
<button class="btn btn-large" id="finishSetupBtn" style="width:100%;">Setup abschließen →</button>
</div>
<!-- ===== Hauptansicht ===== -->
<div id="mainView" style="display:none;">
<div class="top-bar">
<div>
<h1>📖 JGA Prag 2026</h1>
<p class="subtitle" id="entryCount">0 Erinnerungen</p>
</div>
<div class="top-actions">
<button class="btn btn-secondary" id="managePlayersBtn">👥 Spieler</button>
<button class="btn btn-secondary" id="exportBtn">💾 Export</button>
<button class="btn btn-secondary" id="importBtn">📂 Import</button>
<input type="file" id="importFile" accept="application/json" style="display:none;">
</div>
</div>
<div id="storageWarn" class="storage-warn" style="display:none;">
⚠️ Speicher fast voll (<span id="storageUsed">0</span> KB belegt) — JSON-Backup machen!
</div>
<div class="filter-bar" id="filterBar">
<button class="filter-btn active" data-filter="all">Alle</button>
</div>
<div id="entries"></div>
<p class="footer-note">Memory-Buch wird automatisch in deinem Browser gespeichert. Backup via 💾 Export!</p>
</div>
<!-- ===== FAB ===== -->
<button class="fab" id="fab" style="display:none;" title="Neuer Eintrag">+</button>
</div>
<!-- ===== Modal: Neuer Eintrag ===== -->
<div class="modal-overlay" id="entryModal">
<div class="modal">
<h2>Neue Erinnerung</h2>
<div class="form-group">
<label>Autor</label>
<select id="entryAuthor"></select>
</div>
<div class="form-group">
<label>Titel (optional)</label>
<input type="text" id="entryTitle" placeholder="z.B. Die legendäre Boot-Tour" maxlength="80">
</div>
<div class="form-group">
<label>Was ist passiert?</label>
<textarea id="entryBody" placeholder="Erzähl, was los war..."></textarea>
</div>
<div class="form-group">
<label>Stimmung</label>
<div class="mood-picker" id="moodPicker">
<button class="mood-btn" data-mood="😊">😊</button>
<button class="mood-btn" data-mood="😂">😂</button>
<button class="mood-btn" data-mood="🥲">🥲</button>
<button class="mood-btn" data-mood="😍">😍</button>
<button class="mood-btn" data-mood="🤩">🤩</button>
<button class="mood-btn" data-mood="😎">😎</button>
<button class="mood-btn" data-mood="🤮">🤮</button>
<button class="mood-btn" data-mood="🥴">🥴</button>
<button class="mood-btn" data-mood="😭">😭</button>
</div>
</div>
<div class="form-group">
<label>Location (optional)</label>
<select id="entryLocation">
<option value="">— keine —</option>
</select>
</div>
<div class="form-group">
<label>Tags (Komma-getrennt)</label>
<input type="text" id="entryTags" placeholder="lustig, boot, beer-boat">
</div>
<div class="form-group">
<label>Fotos (automatisch resized auf 1200px)</label>
<input type="file" id="entryPhotos" accept="image/*" multiple capture="environment">
<div class="photo-preview" id="photoPreview"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" id="cancelEntryBtn">Abbrechen</button>
<button class="btn" id="saveEntryBtn">💾 Speichern</button>
</div>
</div>
</div>
<!-- ===== Modal: Spieler verwalten ===== -->
<div class="modal-overlay" id="playersModal">
<div class="modal">
<h2>👥 Spieler verwalten</h2>
<div class="form-group">
<label>Neuer Spieler</label>
<div class="wizard-input">
<input type="text" id="newPName" placeholder="Name">
<input type="color" id="newPColor" value="#3b82f6">
<select id="newPEmoji">
<option>🤵</option><option>🤠</option><option>🥳</option><option>😎</option>
<option>🤩</option><option>😈</option><option>🧔</option><option>👨</option>
<option>👩</option><option>🧑</option><option>🦸</option><option>🥷</option>
</select>
<button class="btn" id="addPBtn">+</button>
</div>
</div>
<div class="player-pills" id="existingPlayers"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="closePlayersBtn">Schließen</button>
</div>
</div>
</div>
<!-- ===== Lightbox ===== -->
<div class="lightbox" id="lightbox">
<img id="lightboxImg" src="" alt="">
</div>
<script>
/* ===== Theme ===== */
const themeToggle = document.getElementById('themeToggle');
function setTheme(mode) {
document.documentElement.setAttribute('data-theme', mode);
localStorage.setItem('jga-theme', mode);
themeToggle.textContent = mode === 'dark' ? '🌙' : '☀️';
}
themeToggle.addEventListener('click', () => {
const cur = document.documentElement.getAttribute('data-theme');
setTheme(cur === 'dark' ? 'light' : 'dark');
});
setTheme(localStorage.getItem('jga-theme') || 'dark');
/* ===== Locations (gleiche wie im Tourenplan) ===== */
const LOCATIONS = [
{ name: 'Hotel Uno', lat: 50.0717636, lon: 14.4999461 },
{ name: 'Endorfin Escape', lat: 50.0876, lon: 14.4213 },
{ name: 'Beer Boat', lat: 50.0942, lon: 14.4270 },
{ name: 'Bowling Ovčín', lat: 50.0711, lon: 14.4050 },
{ name: 'Mysterium Tour', lat: 50.0870, lon: 14.4207 },
{ name: 'Paintball Praha', lat: 50.0500, lon: 14.3500 },
{ name: 'FunArena Axtwerfen', lat: 50.0657, lon: 14.4387 },
{ name: 'Mercuria Lasertag', lat: 50.0510, lon: 14.4900 },
{ name: 'Prague Ranger', lat: 50.0860, lon: 14.4180 },
{ name: 'Level Minigolf', lat: 50.0980, lon: 14.4250 },
{ name: 'KartPlanet', lat: 50.0730, lon: 14.4700 },
{ name: 'Enigma', lat: 50.0860, lon: 14.4200 },
{ name: 'Sonstiges (kein Tag)', lat: null, lon: null }
];
/* ===== State ===== */
const REACTIONS = ['❤️','😂','🥲','😍','🤩','😎','🔥'];
const DEFAULT_AUTHOR = { name: 'Bräutigam', color: '#e91e63', emoji: '🤵' };
const STORAGE_KEYS = {
players: 'jga-memory-players',
entries: 'jga-memory-entries',
setupDone: 'jga-memory-setup-done'
};
const state = {
players: JSON.parse(localStorage.getItem(STORAGE_KEYS.players) || '[]'),
entries: JSON.parse(localStorage.getItem(STORAGE_KEYS.entries) || '[]'),
setupDone: localStorage.getItem(STORAGE_KEYS.setupDone) === '1',
filter: 'all',
pendingPhotos: [],
pendingMood: null
};
function save() {
localStorage.setItem(STORAGE_KEYS.players, JSON.stringify(state.players));
localStorage.setItem(STORAGE_KEYS.entries, JSON.stringify(state.entries));
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function timeAgo(ts) {
const sec = (Date.now() - ts) / 1000;
if (sec < 60) return 'gerade eben';
if (sec < 3600) return `vor ${Math.floor(sec/60)} Min`;
if (sec < 86400) return `vor ${Math.floor(sec/3600)} Std`;
if (sec < 604800) return `vor ${Math.floor(sec/86400)} Tag${Math.floor(sec/86400)>1?'en':''}`;
return new Date(ts).toLocaleDateString('de-DE');
}
function getStorageUsage() {
let bytes = 0;
for (const key in localStorage) {
if (localStorage.hasOwnProperty(key) && key.startsWith('jga-memory')) {
bytes += (localStorage[key].length + key.length) * 2;
}
}
return Math.round(bytes / 1024);
}
/* ===== Setup-Wizard ===== */
function showWizard() {
document.getElementById('wizard').style.display = 'block';
document.getElementById('mainView').style.display = 'none';
document.getElementById('fab').style.display = 'none';
renderWizardPlayers();
}
function showMain() {
document.getElementById('wizard').style.display = 'none';
document.getElementById('mainView').style.display = 'block';
document.getElementById('fab').style.display = 'flex';
renderAuthors();
renderLocationOptions();
renderFilters();
renderEntries();
}
function renderWizardPlayers() {
const c = document.getElementById('playerPills');
c.innerHTML = state.players.map((p, i) => `
<div class="player-pill" style="border-color:${p.color}">
<span>${p.emoji}</span> <span>${escapeHtml(p.name)}</span>
<button data-idx="${i}" title="Entfernen">✕</button>
</div>
`).join('') || '<p style="color:var(--text-muted); font-size:13px;">Noch keine Spieler — füge welche oben hinzu</p>';
c.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
state.players.splice(parseInt(btn.dataset.idx), 1);
save();
renderWizardPlayers();
});
});
}
document.getElementById('addPlayerBtn').addEventListener('click', () => {
const name = document.getElementById('newPlayerName').value.trim();
const color = document.getElementById('newPlayerColor').value;
const emoji = document.getElementById('newPlayerEmoji').value;
if (!name) return;
state.players.push({ name, color, emoji });
document.getElementById('newPlayerName').value = '';
save();
renderWizardPlayers();
});
document.getElementById('newPlayerName').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('addPlayerBtn').click();
});
document.getElementById('finishSetupBtn').addEventListener('click', () => {
if (state.players.length === 0) {
// Mindestens Bräutigam als Default
state.players.push(DEFAULT_AUTHOR);
save();
}
state.setupDone = true;
localStorage.setItem(STORAGE_KEYS.setupDone, '1');
showMain();
});
/* ===== Spieler-Modal ===== */
document.getElementById('managePlayersBtn').addEventListener('click', () => {
document.getElementById('playersModal').classList.add('open');
renderExistingPlayers();
});
document.getElementById('closePlayersBtn').addEventListener('click', () => {
document.getElementById('playersModal').classList.remove('open');
renderAuthors();
renderEntries();
});
document.getElementById('addPBtn').addEventListener('click', () => {
const name = document.getElementById('newPName').value.trim();
const color = document.getElementById('newPColor').value;
const emoji = document.getElementById('newPEmoji').value;
if (!name) return;
state.players.push({ name, color, emoji });
document.getElementById('newPName').value = '';
save();
renderExistingPlayers();
renderAuthors();
renderEntries();
});
function renderExistingPlayers() {
const c = document.getElementById('existingPlayers');
c.innerHTML = state.players.map((p, i) => `
<div class="player-pill" style="border-color:${p.color}">
<span>${p.emoji}</span> <span>${escapeHtml(p.name)}</span>
<button data-idx="${i}">✕</button>
</div>
`).join('');
c.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
state.players.splice(parseInt(btn.dataset.idx), 1);
save();
renderExistingPlayers();
renderAuthors();
});
});
}
/* ===== Filter ===== */
function renderFilters() {
const bar = document.getElementById('filterBar');
const others = state.players.map(p => `<button class="filter-btn" data-filter="author:${escapeHtml(p.name)}">${p.emoji} ${escapeHtml(p.name)}</button>`).join('');
bar.innerHTML = `
<button class="filter-btn ${state.filter==='all'?'active':''}" data-filter="all">Alle</button>
<button class="filter-btn ${state.filter==='photo'?'active':''}" data-filter="photo">📷 Mit Fotos</button>
${others}
`;
bar.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.filter = btn.dataset.filter;
renderFilters();
renderEntries();
});
});
}
/* ===== Locations & Authors ===== */
function renderLocationOptions() {
const sel = document.getElementById('entryLocation');
sel.innerHTML = '<option value="">— keine —</option>' +
LOCATIONS.map(l => `<option value="${escapeHtml(l.name)}">${escapeHtml(l.name)}</option>`).join('');
}
function renderAuthors() {
const sel = document.getElementById('entryAuthor');
sel.innerHTML = state.players.map(p => `<option value="${escapeHtml(p.name)}">${p.emoji} ${escapeHtml(p.name)}</option>`).join('');
const last = localStorage.getItem('jga-memory-last-author');
if (last && state.players.find(p => p.name === last)) sel.value = last;
}
/* ===== Foto Resize ===== */
function resizeImage(file, maxWidth = 1200, quality = 0.85) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let w = img.width, h = img.height;
if (w > maxWidth) {
h = h * (maxWidth / w);
w = maxWidth;
}
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.onerror = reject;
img.src = e.target.result;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
document.getElementById('entryPhotos').addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
for (const f of files) {
try {
const dataUrl = await resizeImage(f);
state.pendingPhotos.push({ type: 'image', dataUrl, filename: f.name });
} catch (err) {
console.error('Bild-Fehler:', err);
}
}
e.target.value = '';
renderPhotoPreview();
});
function renderPhotoPreview() {
const c = document.getElementById('photoPreview');
c.innerHTML = state.pendingPhotos.map((p, i) => `
<div class="photo-preview-item">
<img src="${p.dataUrl}" alt="">
<button class="remove" data-idx="${i}">✕</button>
</div>
`).join('');
c.querySelectorAll('.remove').forEach(btn => {
btn.addEventListener('click', () => {
state.pendingPhotos.splice(parseInt(btn.dataset.idx), 1);
renderPhotoPreview();
});
});
}
/* ===== Mood Picker ===== */
document.querySelectorAll('.mood-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.mood-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.pendingMood = btn.dataset.mood;
});
});
/* ===== Eintrag speichern ===== */
document.getElementById('saveEntryBtn').addEventListener('click', () => {
const author = document.getElementById('entryAuthor').value;
const title = document.getElementById('entryTitle').value.trim();
const body = document.getElementById('entryBody').value.trim();
const location = document.getElementById('entryLocation').value;
const tags = document.getElementById('entryTags').value.split(',').map(s => s.trim()).filter(Boolean);
if (!body && state.pendingPhotos.length === 0) {
alert('Bitte Text oder Foto hinzufügen');
return;
}
if (!author) return;
localStorage.setItem('jga-memory-last-author', author);
state.entries.push({
id: 'entry-' + Date.now(),
type: state.pendingPhotos.length > 0 ? 'mixed' : 'text',
author,
title: title || null,
body,
location: location || null,
tags,
mood: state.pendingMood || null,
media: state.pendingPhotos,
reactions: {},
comments: [],
createdAt: Date.now()
});
save();
closeEntryModal();
renderEntries();
checkStorage();
});
/* ===== Modal ===== */
document.getElementById('fab').addEventListener('click', openEntryModal);
function openEntryModal() {
document.getElementById('entryModal').classList.add('open');
document.getElementById('entryTitle').value = '';
document.getElementById('entryBody').value = '';
document.getElementById('entryTags').value = '';
document.getElementById('entryLocation').value = '';
state.pendingPhotos = [];
state.pendingMood = null;
renderPhotoPreview();
document.querySelectorAll('.mood-btn').forEach(b => b.classList.remove('active'));
renderAuthors();
}
function closeEntryModal() {
document.getElementById('entryModal').classList.remove('open');
}
document.getElementById('cancelEntryBtn').addEventListener('click', closeEntryModal);
/* ===== Einträge rendern ===== */
function renderEntries() {
const container = document.getElementById('entries');
let filtered = state.entries.slice().reverse();
if (state.filter === 'photo') {
filtered = filtered.filter(e => e.media && e.media.length > 0);
} else if (state.filter.startsWith('author:')) {
const name = state.filter.substring(7);
filtered = filtered.filter(e => e.author === name);
}
document.getElementById('entryCount').textContent = `${state.entries.length} Erinnerung${state.entries.length !== 1 ? 'en' : ''}`;
if (filtered.length === 0) {
container.innerHTML = `
<div class="empty">
<div class="big">📖</div>
<p>Noch keine Erinnerungen${state.filter !== 'all' ? ' in diesem Filter' : ''}.</p>
<p style="font-size:13px; margin-top:8px;">Klicke unten rechts auf <strong>+</strong> um die erste zu erstellen.</p>
</div>`;
return;
}
container.innerHTML = filtered.map(e => {
const author = state.players.find(p => p.name === e.author) || { color: '#888', emoji: '👤' };
const photoHtml = (e.media && e.media.length > 0) ? `
<div class="entry-media">
${e.media.map(m => `<img src="${m.dataUrl}" data-img="${m.dataUrl}" alt="">`).join('')}
</div>` : '';
const tagsHtml = e.tags && e.tags.length > 0 ? `
<div class="entry-tags">
${e.tags.map(t => `<span class="entry-tag">#${escapeHtml(t)}</span>`).join('')}
</div>` : '';
const locHtml = e.location ? `<div class="entry-location">📍 ${escapeHtml(e.location)}</div>` : '';
const moodHtml = e.mood ? `<div class="entry-mood">${e.mood}</div>` : '';
const reactionHtml = REACTIONS.map(r => {
const users = e.reactions[r] || [];
const mine = users.includes(currentAuthor());
return `<button class="reaction-btn ${mine ? 'mine' : ''}" data-reaction="${r}" data-id="${e.id}">
${r} <span class="count">${users.length || ''}</span>
</button>`;
}).join('');
const commentsHtml = (e.comments && e.comments.length > 0) ? e.comments.map(c => `
<div class="comment">
<span class="author">${escapeHtml(c.author)}:</span>
<span class="text">${escapeHtml(c.text)}</span>
</div>
`).join('') : '';
return `
<div class="entry">
<div class="entry-header">
<div class="author-avatar" style="background:${author.color}">${author.emoji}</div>
<div>
<div class="entry-author">${escapeHtml(e.author)}</div>
<div class="entry-time">${timeAgo(e.createdAt)}</div>
</div>
${moodHtml}
</div>
${e.title ? `<div class="entry-title">${escapeHtml(e.title)}</div>` : ''}
${e.body ? `<div class="entry-body">${escapeHtml(e.body)}</div>` : ''}
${photoHtml}
${locHtml}
${tagsHtml}
<div class="entry-footer">
${reactionHtml}
<div class="entry-actions">
<button data-delete="${e.id}" title="Löschen">🗑️</button>
</div>
</div>
<div class="comment-section">
${commentsHtml}
<form class="comment-form" data-form="${e.id}">
<input type="text" placeholder="Kommentar..." maxlength="200">
<button type="submit" class="btn btn-secondary">💬</button>
</form>
</div>
</div>
`;
}).join('');
// Events binden
container.querySelectorAll('[data-reaction]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.id;
const r = btn.dataset.reaction;
const entry = state.entries.find(x => x.id === id);
if (!entry) return;
const me = currentAuthor();
if (!entry.reactions) entry.reactions = {};
if (!entry.reactions[r]) entry.reactions[r] = [];
const idx = entry.reactions[r].indexOf(me);
if (idx >= 0) entry.reactions[r].splice(idx, 1);
else entry.reactions[r].push(me);
save();
renderEntries();
});
});
container.querySelectorAll('[data-delete]').forEach(btn => {
btn.addEventListener('click', () => {
if (!confirm('Wirklich löschen?')) return;
state.entries = state.entries.filter(x => x.id !== btn.dataset.delete);
save();
renderEntries();
});
});
container.querySelectorAll('[data-img]').forEach(img => {
img.addEventListener('click', () => {
document.getElementById('lightboxImg').src = img.dataset.img;
document.getElementById('lightbox').classList.add('open');
});
});
container.querySelectorAll('[data-form]').forEach(form => {
form.addEventListener('submit', (ev) => {
ev.preventDefault();
const input = form.querySelector('input');
const text = input.value.trim();
if (!text) return;
const entry = state.entries.find(x => x.id === form.dataset.form);
if (!entry) return;
if (!entry.comments) entry.comments = [];
entry.comments.push({ author: currentAuthor(), text, createdAt: Date.now() });
save();
renderEntries();
});
});
}
function currentAuthor() {
const sel = document.getElementById('entryAuthor');
if (sel && sel.value) return sel.value;
return state.players[0]?.name || 'Anonym';
}
/* ===== Lightbox ===== */
document.getElementById('lightbox').addEventListener('click', () => {
document.getElementById('lightbox').classList.remove('open');
});
/* ===== Storage Warning ===== */
function checkStorage() {
const kb = getStorageUsage();
document.getElementById('storageUsed').textContent = kb;
if (kb > 4000) {
document.getElementById('storageWarn').style.display = 'block';
} else {
document.getElementById('storageWarn').style.display = 'none';
}
}
/* ===== Export / Import ===== */
document.getElementById('exportBtn').addEventListener('click', () => {
const data = {
version: 1,
exportedAt: new Date().toISOString(),
tripName: 'JGA Prag 2026',
players: state.players,
entries: state.entries
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `jga-prag-2026-memory-${new Date().toISOString().slice(0,10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
document.getElementById('importBtn').addEventListener('click', () => {
document.getElementById('importFile').click();
});
document.getElementById('importFile').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (!data.players || !data.entries) throw new Error('Ungültiges Format');
if (!confirm(`${data.entries.length} Einträge importieren? Vorhandene Daten werden überschrieben!`)) return;
state.players = data.players;
state.entries = data.entries;
save();
renderEntries();
renderAuthors();
renderFilters();
checkStorage();
alert(`✅ Importiert: ${data.entries.length} Einträge, ${data.players.length} Spieler`);
} catch (err) {
alert('Fehler beim Import: ' + err.message);
}
};
reader.readAsText(file);
e.target.value = '';
});
/* ===== Init ===== */
if (!state.setupDone) {
showWizard();
} else {
showMain();
}
checkStorage();
// PWA Service Worker — Network-First Strategie (Updates kommen sofort durch)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./sw.js')
.then(reg => {
console.log('[PWA] SW registered:', reg.scope);
// Update-Hook: wenn ein neuer SW installiert wurde, kurz reloaden
reg.addEventListener('updatefound', () => {
const newSw = reg.installing;
if (!newSw) return;
newSw.addEventListener('statechange', () => {
if (newSw.state === 'activated' && navigator.serviceWorker.controller) {
console.log('[PWA] Neue Version aktiviert — lade automatisch neu');
window.location.reload();
}
});
});
})
.catch(err => console.warn('[PWA] SW registration failed:', err));
});
}
</script>
</body>
</html>