Files
hcrb/public/js/patches.js
T
guyg1474andClaude Opus 5.5 486e6f6784 Spiel-Events, Kalenderdateien, Patches-Seite und neues Design
- Kalender: Termine als Spiel-Event markieren (nur diese zählen in der
  Statistik), optionale Uhrzeiten, Termine bearbeiten
- .ics-Download für einzelne und alle kommenden Termine
- Neue Seite Patches: Upload (PNG, JPEG, WebP, geprüftes SVG),
  Status "Gibt es schon" / "In Planung", Bilder in data/patches/
- Volle Seitenbreite mit Spaltenlayouts, neues Design mit selbst
  gehosteten Schriften, Startseite mit Zoom und drehendem Zahnrad
- Bestehende Daten werden beim Laden ergänzt, keine Migration nötig

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:13:52 +02:00

203 lines
7.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { $, api, esc, showError, renderHeader, renderFooter, currentUser } from './common.js';
renderHeader('patches');
renderFooter();
let patches = [];
let loggedIn = false;
let editingId = null;
let imageData = null; // data:-URL des gewählten (verkleinerten) Bildes
const imgUrl = (p) => `/patch-img/${encodeURIComponent(p.image)}`;
// ---------- Galerie ----------
function render() {
$('#patch-empty').hidden = patches.length > 0;
$('#patch-empty-hint').textContent = loggedIn
? 'Lade über „Patch hochladen“ den ersten Patch hoch.'
: 'Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.';
for (const status of ['vorhanden', 'geplant']) {
const list = patches.filter((p) => p.status === status);
$(`#group-${status}`).hidden = list.length === 0;
$(`#count-${status}`).textContent = list.length === 1 ? '1 Patch' : `${list.length} Patches`;
$(`#grid-${status}`).innerHTML = list.map(patchHtml).join('');
}
}
function patchHtml(p) {
return `
<article class="patch ${p.status === 'geplant' ? 'planned' : ''}" id="${p.id}">
<button type="button" class="patch-img" data-zoom="${p.id}" aria-label="${esc(p.name)} groß anzeigen">
<img src="${imgUrl(p)}" alt="${esc(p.name)}" loading="lazy">
</button>
<div class="patch-body">
${p.status === 'geplant' ? '<span class="badge nogame">In Planung</span>' : ''}
<h3>${esc(p.name)}</h3>
${p.description ? `<p>${esc(p.description)}</p>` : ''}
${loggedIn ? `
<div class="actions">
<button class="link" data-edit="${p.id}">Bearbeiten</button>
<button class="link danger" data-delete="${p.id}">Löschen</button>
</div>` : ''}
</div>
</article>`;
}
document.addEventListener('click', async (e) => {
if (!e.target.closest('.patch-grid')) return;
const zoom = e.target.closest('[data-zoom]');
if (zoom) return openLightbox(zoom.dataset.zoom);
const edit = e.target.closest('[data-edit]');
if (edit) return openForm(patches.find((p) => p.id === edit.dataset.edit));
const del = e.target.closest('[data-delete]');
if (del) {
const p = patches.find((x) => x.id === del.dataset.delete);
if (!confirm(`Patch „${p.name}“ wirklich löschen?`)) return;
try {
await api('DELETE', `/patches/${p.id}`);
await load();
} catch (err) {
showError(err);
}
}
});
function openLightbox(id) {
const p = patches.find((x) => x.id === id);
if (!p) return;
$('#lightbox-img').src = imgUrl(p);
$('#lightbox-img').alt = p.name;
$('#lightbox-name').textContent = p.name;
$('#lightbox-text').textContent = p.description;
$('#lightbox').showModal();
}
// Klick neben das Bild schließt
$('#lightbox').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.close();
});
// ---------- Formular ----------
function openForm(patch) {
editingId = patch?.id ?? null;
imageData = null;
$('#patch-form').reset();
$('#patch-error').hidden = true;
$('#patch-form-title').textContent = patch ? 'Patch bearbeiten' : 'Patch hochladen';
$('#patch-submit').textContent = patch ? 'Speichern' : 'Hochladen';
$('#patch-name').value = patch?.name ?? '';
$('#patch-description').value = patch?.description ?? '';
$(`#patch-status-${patch?.status ?? 'vorhanden'}`).checked = true;
showPreview(patch ? imgUrl(patch) : null);
$('#patch-form-card').hidden = false;
$('#patch-form-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
$('#patch-name').focus({ preventScroll: true });
}
function closeForm() {
$('#patch-form-card').hidden = true;
editingId = null;
imageData = null;
}
function showPreview(src) {
$('#patch-preview').hidden = !src;
if (src) $('#patch-preview').src = src;
$('#drop-text').innerHTML = src
? '<strong>Anderes Bild wählen</strong>'
: '<strong>Bild auswählen</strong><br>oder hierher ziehen · PNG, JPEG, WebP oder SVG';
}
// Bild im Browser auf höchstens 1600 px verkleinern; Transparenz bleibt (WebP bzw. PNG)
async function prepareImage(file) {
// SVG bleibt unverändert (Vektorgrafik, gestochen scharf in jeder Größe)
if (file.type === 'image/svg+xml' || /\.svg$/i.test(file.name)) {
if (file.size > 2 * 1024 * 1024) throw new Error('SVG ist zu groß (max. 2 MB).');
const bytes = new Uint8Array(await file.arrayBuffer());
let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return `data:image/svg+xml;base64,${btoa(bin)}`;
}
if (!/^image\/(png|jpeg|webp)$/.test(file.type)) throw new Error('Bitte ein PNG-, JPEG-, WebP- oder SVG-Bild wählen.');
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, 1600 / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.round(bitmap.width * scale);
canvas.height = Math.round(bitmap.height * scale);
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close?.();
let url = canvas.toDataURL('image/webp', 0.9);
// Browser ohne WebP-Export (ältere Safari) liefern PNG – für Fotos ist JPEG dann kleiner
if (!url.startsWith('data:image/webp') && file.type === 'image/jpeg') url = canvas.toDataURL('image/jpeg', 0.88);
return url;
}
async function pickFile(file) {
if (!file) return;
$('#patch-error').hidden = true;
try {
imageData = await prepareImage(file);
showPreview(imageData);
if (!$('#patch-name').value) $('#patch-name').value = file.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').slice(0, 80);
} catch (err) {
imageData = null;
$('#patch-error').textContent = err.message || 'Das Bild konnte nicht gelesen werden.';
$('#patch-error').hidden = false;
}
}
$('#patch-file').addEventListener('change', (e) => pickFile(e.target.files[0]));
const drop = $('#dropzone');
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
drop.addEventListener('drop', (e) => {
e.preventDefault();
drop.classList.remove('over');
pickFile(e.dataTransfer.files[0]);
});
$('#add-btn').addEventListener('click', () => openForm(null));
$('#patch-cancel').addEventListener('click', closeForm);
$('#patch-form').addEventListener('submit', async (e) => {
e.preventDefault();
const error = $('#patch-error');
error.hidden = true;
if (!editingId && !imageData) {
error.textContent = 'Bitte zuerst ein Bild auswählen.';
error.hidden = false;
return;
}
const button = $('#patch-submit');
button.disabled = true;
try {
const body = {
name: $('#patch-name').value,
description: $('#patch-description').value,
status: document.querySelector('input[name="patch-status"]:checked').value,
};
if (imageData) body.image = imageData;
if (editingId) await api('PUT', `/patches/${editingId}`, body);
else await api('POST', '/patches', body);
closeForm();
await load();
} catch (err) {
error.textContent = err.message;
error.hidden = false;
} finally {
button.disabled = false;
}
});
// ---------- Laden ----------
async function load() {
patches = await api('GET', '/patches');
render();
}
loggedIn = !!(await currentUser());
$('#add-btn').hidden = !loggedIn;
await load().catch(showError);
// Link von der Startseite (/patches#id) springt zum Patch
if (location.hash) document.getElementById(location.hash.slice(1))?.scrollIntoView({ block: 'center' });