mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-16 07:13:28 +02:00
- 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>
203 lines
7.4 KiB
JavaScript
203 lines
7.4 KiB
JavaScript
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' });
|