Rollen (Admin/Mitglied), Seite Kooperationen, Textanpassungen

- Gemeinsame Zugänge mit Rollen: Admin darf alles, Mitglied nutzt Kalender,
  Fahrtplaner, Treffpunkte und bearbeitet Mitglieder (kein Hinzufügen,
  Löschen, Import, keine Patches, keine Statistik-Korrektur)
- Server prüft die Rechte (403), Konten ohne Rolle gelten als Admin
- npm run user: add <name> [admin|mitglied], role <name> <rolle>
- Neue öffentliche Seite Kooperationen mit Titelbild, Anführer,
  Social-Media-Links und Beschreibung, Pflege durch Admin
- Kopfzeile bleibt bei mittlerer Breite einzeilig
- Texte auf Wunsch gekürzt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-12 19:47:03 +02:00
1 parent f46016487d
commit 819219fb2d
18 files changed
+661 -106

No files matched your search

+82 -1
View File
@@ -858,6 +858,84 @@ button.small, .button.small { padding: 5px 11px; font-size: .82rem; }
}
.empty-state svg { width: 56px; height: 56px; color: var(--border); }
/* ---------- Kooperationen ---------- */
.coop-grid { display: grid; gap: 20px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); }
.coop-card {
display: flex;
flex-direction: column;
text-decoration: none;
color: var(--text);
background: var(--card);
box-shadow: inset 0 0 0 1px var(--border);
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 0 100%);
transition: background-color .2s;
}
.coop-card:hover { background: var(--card-2); }
.coop-card-img {
aspect-ratio: 16 / 9;
max-width: 100%;
overflow: hidden;
display: grid;
place-items: center;
background: repeating-linear-gradient(135deg, var(--flag) 0 18px, #303b6a 18px 36px);
}
.coop-card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform .6s var(--ease-out); }
.coop-card:hover .coop-card-img img { transform: scale(1.05); }
.coop-initials { font-family: var(--font-stencil); font-size: 3.4rem; color: var(--accent); letter-spacing: .1em; }
.coop-card-body { padding: 16px 18px 18px; display: grid; gap: 4px; }
.coop-card-body h2 { margin: 0; font-size: 1.4rem; font-weight: 700; }
.coop-card-body p { margin: 0; }
.coop-card .go { margin-top: 8px; font-family: var(--font-head); text-transform: uppercase; letter-spacing: .16em; font-size: .85rem; color: var(--accent); }
.back-link { display: inline-block; margin-bottom: 14px; font-family: var(--font-head); text-transform: uppercase; letter-spacing: .14em; text-decoration: none; }
.coop-hero {
position: relative;
aspect-ratio: 21 / 9;
max-height: 62vh;
width: 100%;
max-width: 100%;
overflow: hidden;
margin-bottom: 20px;
background: repeating-linear-gradient(135deg, var(--flag) 0 22px, #303b6a 22px 44px);
clip-path: polygon(0 0, calc(100% - 28px) 0, 100% 28px, 100% 100%, 28px 100%, 0 calc(100% - 28px));
}
.coop-hero img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* Abdunklung nur unten, damit der Name auf jedem Foto lesbar bleibt */
.coop-hero::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(13, 17, 38, .9) 0%, rgba(13, 17, 38, .35) 40%, transparent 70%);
pointer-events: none;
}
.coop-hero.no-image::before {
content: attr(data-initials);
position: absolute;
right: 6%;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-stencil);
font-size: clamp(5rem, 16vw, 12rem);
color: var(--accent);
opacity: .35;
}
.coop-hero-text { position: absolute; left: clamp(16px, 3vw, 40px); right: clamp(16px, 3vw, 40px); bottom: clamp(16px, 3vw, 36px); z-index: 1; }
.coop-hero-text h1 { margin: 0; font-size: clamp(2rem, 6vw, 4.4rem); font-weight: 700; letter-spacing: .03em; text-shadow: 0 4px 24px rgba(0, 0, 0, .5); }
@media (max-width: 600px) { .coop-hero { aspect-ratio: 4 / 3; } }
.coop-info .card { margin: 0; }
.coop-description { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 70ch; margin: 0; line-height: 1.65; }
.coop-leader { margin: 0; font-family: var(--font-head); font-size: 1.5rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
.card h2.spaced { margin-top: 24px; }
.coop-socials { display: flex; flex-wrap: wrap; gap: 8px; }
.coop-socials svg { width: 18px; height: 18px; }
.admin-actions { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--border); }
.socials-fields { border: none; padding: 0; margin: 4px 0 8px; }
.socials-fields legend { font-size: .94rem; color: var(--muted); padding: 0; margin-bottom: 6px; }
.socials-fields .grid2 { gap: 0 12px; }
.dropzone.wide { min-height: 220px; }
.dropzone.wide img { max-height: 260px; width: 100%; object-fit: cover; }
/* ---------- Fahrtplaner ---------- */
.cars { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; margin-top: 14px; }
.car-meet { margin: 10px 0 4px; padding-top: 10px; border-top: 1px solid var(--border); }
@@ -901,7 +979,10 @@ footer .f-brand img { width: 32px; height: 32px; }
/* ---------- Responsiv ---------- */
.nav-toggle { display: none; }
@media (max-width: 960px) {
/* Mittlere Breite: Zusatz neben dem Logo weg, damit alle Menüpunkte in eine Zeile passen */
@media (max-width: 1280px) { .brand small { display: none; } }
@media (max-width: 1040px) {
.site-header { padding: 8px var(--gutter); }
.site-header .inner { gap: 8px; }
.nav-toggle {
-1
View File
@@ -112,7 +112,6 @@
<h2>Highcap Rasselbande</h2>
<p>
Wir sind die <strong>Highcap Rasselbande (HCRB)</strong>, eine Airsoft-Gruppe aus Berlin.
Gespielt wird bei uns vor allem auf der <strong>Darkzone</strong>, dort sind wir regelmäßig unterwegs.
</p>
<p>
Hier planen wir unsere Spieltage und Fahrgemeinschaften. Videos und Bilder von unseren Spielen
+37 -3
View File
@@ -81,6 +81,7 @@ const NAV = [
{ href: '/gruppe', key: 'gruppe', label: 'Gruppe' },
{ href: '/statistik', key: 'statistik', label: 'Statistik' },
{ href: '/patches', key: 'patches', label: 'Patches' },
{ href: '/kooperationen', key: 'kooperationen', label: 'Kooperationen' },
];
// overlay: Kopfzeile liegt durchsichtig über dem Hero und wird erst beim Scrollen fest (Startseite)
@@ -129,13 +130,20 @@ export function renderFooter() {
document.body.append(footer);
}
// Einmal pro Seite abfragen, ob jemand angemeldet ist
// Einmal pro Seite abfragen, wer angemeldet ist: { name, role } oder null.
// role: 'admin' oder 'mitglied'. Die Rechte prüft der Server – hier geht es nur darum, was angezeigt wird.
let mePromise;
export function currentUser() {
mePromise ??= api('GET', '/me').then(({ user }) => user || null).catch(() => null);
mePromise ??= api('GET', '/me')
.then(({ user, role }) => (user ? { name: user, role } : null))
.catch(() => null);
return mePromise;
}
export async function isAdmin() {
return (await currentUser())?.role === 'admin';
}
async function renderAuthLink(nav) {
const user = await currentUser();
if (!user) {
@@ -145,7 +153,7 @@ async function renderAuthLink(nav) {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = 'Abmelden';
btn.title = `Angemeldet als ${user}`;
btn.title = `Angemeldet als ${user.name} (${user.role === 'admin' ? 'Admin' : 'Mitglied'})`;
btn.addEventListener('click', async () => {
await api('POST', '/logout').catch(() => {});
location.href = '/';
@@ -158,3 +166,29 @@ export function formatTime(ev) {
if (!ev.time) return '';
return `${ev.time}${ev.endTime ? `–${ev.endTime}` : ''} Uhr`;
}
// ---------- Bild-Upload ----------
// Bild im Browser auf höchstens maxSize px verkleinern; Transparenz bleibt (WebP bzw. PNG).
// Liefert eine data:-URL für die API.
export async function prepareImage(file, maxSize = 1600) {
// 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, maxSize / 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;
}
+207
View File
@@ -0,0 +1,207 @@
import { $, api, esc, showError, renderHeader, renderFooter, isAdmin, prepareImage } from './common.js';
renderHeader('kooperationen');
renderFooter();
let coops = [];
let admin = false; // nur der Admin-Zugang darf anlegen, ändern, löschen
let editingId = null;
let imageData = null; // data:-URL des neu gewählten Titelbildes
const imgUrl = (c) => `/koop-img/${encodeURIComponent(c.image)}`;
// ---------- Soziale Medien ----------
const SOCIALS = [
{ key: 'website', label: 'Website', icon: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/>' },
{ key: 'instagram', label: 'Instagram', icon: '<rect x="3" y="3" width="18" height="18" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.5" cy="6.5" r=".9" fill="currentColor" stroke="none"/>' },
{ key: 'youtube', label: 'YouTube', icon: '<rect x="2" y="5" width="20" height="14" rx="4"/><path d="M10 9l5 3-5 3z" fill="currentColor"/>' },
{ key: 'tiktok', label: 'TikTok', icon: '<path d="M14 3v11.5a3.5 3.5 0 1 1-3.5-3.5"/><path d="M14 3c.4 2.6 2.2 4.4 5 4.6"/>' },
{ key: 'facebook', label: 'Facebook', icon: '<path d="M14.5 21v-8h3l.5-3.5h-3.5V7.6c0-1 .4-1.7 1.8-1.7H18V2.8c-.4 0-1.6-.2-2.9-.2-2.8 0-4.6 1.7-4.6 4.8v2.1H7.5V13h3v8"/>' },
{ key: 'discord', label: 'Discord', icon: '<path d="M7 7.5c3.3-1.3 6.7-1.3 10 0 1.7 2.5 2.6 5.3 2.5 8.5-1.5 1.2-3 1.9-4.6 2.3l-1-1.8M7 7.5C5.3 10 4.4 12.8 4.5 16c1.5 1.2 3 1.9 4.6 2.3l1-1.8M8 15.5c2.7 1.2 5.3 1.2 8 0"/><circle cx="9.5" cy="12" r="1.1" fill="currentColor" stroke="none"/><circle cx="14.5" cy="12" r="1.1" fill="currentColor" stroke="none"/>' },
];
const icon = (s) => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${s.icon}</svg>`;
function socialsHtml(c) {
const links = SOCIALS.filter((s) => c.socials?.[s.key]);
return links.length
? links.map((s) => `<a class="button" href="${esc(c.socials[s.key])}" target="_blank" rel="noopener noreferrer">${icon(s)}${s.label}</a>`).join('')
: '<p class="muted">Keine Links eingetragen.</p>';
}
// Ohne Titelbild: Anfangsbuchstaben auf Flaggenblau
const initials = (name) => name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join('').toUpperCase();
// ---------- Übersicht ----------
function renderOverview() {
$('#coop-empty').hidden = coops.length > 0;
$('#coop-empty-hint').textContent = admin
? 'Trag über „Kooperation hinzufügen“ die erste Gruppe ein.'
: 'Sobald der Admin eine Kooperation einträgt, erscheint sie hier.';
$('#coop-grid').innerHTML = coops.map((c) => `
<a class="coop-card" href="#${c.id}">
<div class="coop-card-img">
${c.image ? `<img src="${imgUrl(c)}" alt="" loading="lazy">` : `<span class="coop-initials">${esc(initials(c.name))}</span>`}
</div>
<div class="coop-card-body">
<h2>${esc(c.name)}</h2>
${c.leader ? `<p class="muted">Anführer: ${esc(c.leader)}</p>` : ''}
<span class="go">Ansehen</span>
</div>
</a>`).join('');
}
// ---------- Einzelansicht (#id) ----------
function renderDetail(c) {
document.title = `${c.name} – Kooperationen – HCRB`;
$('#detail-name').textContent = c.name;
const img = $('#detail-img');
img.hidden = !c.image;
if (c.image) img.src = imgUrl(c);
$('#detail-hero').classList.toggle('no-image', !c.image);
$('#detail-hero').dataset.initials = initials(c.name);
$('#detail-description').textContent = c.description || 'Noch keine Beschreibung.';
$('#detail-leader').textContent = c.leader || 'Nicht angegeben';
$('#detail-socials').innerHTML = socialsHtml(c);
$('#detail-admin').hidden = !admin;
}
function route() {
const id = location.hash.slice(1);
const coop = coops.find((c) => c.id === id);
$('#overview').hidden = !!coop;
$('#detail').hidden = !coop;
if (coop) {
renderDetail(coop);
scrollTo(0, 0);
} else {
document.title = 'Kooperationen – HCRB';
renderOverview();
}
}
addEventListener('hashchange', () => {
closeForm();
route();
});
$('#back-link').addEventListener('click', (e) => {
e.preventDefault();
history.pushState(null, '', location.pathname);
route();
});
// ---------- Formular ----------
function showPreview(src) {
$('#coop-preview').hidden = !src;
if (src) $('#coop-preview').src = src;
$('#drop-text').innerHTML = src
? '<strong>Anderes Titelbild wählen</strong>'
: '<strong>Titelbild auswählen</strong><br>oder hierher ziehen · am besten ein breites Foto (Querformat)';
}
function openForm(coop) {
editingId = coop?.id ?? null;
imageData = null;
$('#coop-form').reset();
$('#coop-error').hidden = true;
$('#coop-form-title').textContent = coop ? 'Kooperation bearbeiten' : 'Kooperation hinzufügen';
$('#coop-name').value = coop?.name ?? '';
$('#coop-leader').value = coop?.leader ?? '';
$('#coop-description').value = coop?.description ?? '';
for (const s of SOCIALS) $(`#social-${s.key}`).value = coop?.socials?.[s.key] ?? '';
$('#remove-image-wrap').hidden = !coop?.image;
showPreview(coop?.image ? imgUrl(coop) : null);
$('#coop-form-card').hidden = false;
$('#coop-form-card').scrollIntoView({ behavior: 'smooth', block: 'start' });
$('#coop-name').focus({ preventScroll: true });
}
function closeForm() {
$('#coop-form-card').hidden = true;
editingId = null;
imageData = null;
}
async function pickFile(file) {
if (!file) return;
$('#coop-error').hidden = true;
try {
imageData = await prepareImage(file, 2000);
showPreview(imageData);
$('#coop-remove-image').checked = false;
} catch (err) {
imageData = null;
$('#coop-error').textContent = err.message || 'Das Bild konnte nicht gelesen werden.';
$('#coop-error').hidden = false;
}
}
$('#coop-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));
$('#coop-cancel').addEventListener('click', closeForm);
$('#detail-edit').addEventListener('click', () => openForm(coops.find((c) => c.id === location.hash.slice(1))));
$('#detail-delete').addEventListener('click', async () => {
const c = coops.find((x) => x.id === location.hash.slice(1));
if (!c || !confirm(`Kooperation „${c.name}“ wirklich löschen?`)) return;
try {
await api('DELETE', `/kooperationen/${c.id}`);
history.pushState(null, '', location.pathname);
await load();
} catch (err) {
showError(err);
}
});
$('#coop-form').addEventListener('submit', async (e) => {
e.preventDefault();
const error = $('#coop-error');
error.hidden = true;
const button = $('#coop-submit');
button.disabled = true;
try {
const body = {
name: $('#coop-name').value,
leader: $('#coop-leader').value,
description: $('#coop-description').value,
socials: Object.fromEntries(SOCIALS.map((s) => [s.key, $(`#social-${s.key}`).value])),
};
if (imageData) body.image = imageData;
else if ($('#coop-remove-image').checked) body.removeImage = true;
const saved = editingId
? await api('PUT', `/kooperationen/${editingId}`, body)
: await api('POST', '/kooperationen', body);
closeForm();
coops = await api('GET', '/kooperationen');
// Nach dem Speichern die Seite der Kooperation zeigen
if (location.hash.slice(1) === saved.id) route();
else location.hash = saved.id;
} catch (err) {
error.textContent = err.message;
error.hidden = false;
} finally {
button.disabled = false;
}
});
// ---------- Laden ----------
async function load() {
coops = await api('GET', '/kooperationen');
route();
}
admin = await isAdmin();
$('#add-btn').hidden = !admin;
showPreview(null);
await load().catch(showError);
+7 -30
View File
@@ -1,10 +1,10 @@
import { $, api, esc, showError, renderHeader, renderFooter, currentUser } from './common.js';
import { $, api, esc, showError, renderHeader, renderFooter, isAdmin, prepareImage } from './common.js';
renderHeader('patches');
renderFooter();
let patches = [];
let loggedIn = false;
let admin = false; // nur der Admin-Zugang darf Patches hochladen, ändern, löschen
let editingId = null;
let imageData = null; // data:-URL des gewählten (verkleinerten) Bildes
@@ -13,9 +13,9 @@ const imgUrl = (p) => `/patch-img/${encodeURIComponent(p.image)}`;
// ---------- Galerie ----------
function render() {
$('#patch-empty').hidden = patches.length > 0;
$('#patch-empty-hint').textContent = loggedIn
$('#patch-empty-hint').textContent = admin
? 'Lade über „Patch hochladen“ den ersten Patch hoch.'
: 'Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.';
: 'Sobald der Admin 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;
@@ -34,7 +34,7 @@ function patchHtml(p) {
${p.status === 'geplant' ? '<span class="badge nogame">In Planung</span>' : ''}
<h3>${esc(p.name)}</h3>
${p.description ? `<p>${esc(p.description)}</p>` : ''}
${loggedIn ? `
${admin ? `
<div class="actions">
<button class="link" data-edit="${p.id}">Bearbeiten</button>
<button class="link danger" data-delete="${p.id}">Löschen</button>
@@ -107,29 +107,6 @@ function showPreview(src) {
: '<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;
@@ -195,8 +172,8 @@ async function load() {
render();
}
loggedIn = !!(await currentUser());
$('#add-btn').hidden = !loggedIn;
admin = await isAdmin();
$('#add-btn').hidden = !admin;
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' });
+6 -6
View File
@@ -1,10 +1,10 @@
import { $, api, esc, showError, renderHeader, renderFooter } from './common.js';
import { $, api, esc, showError, renderHeader, renderFooter, isAdmin } from './common.js';
renderHeader('statistik');
renderFooter();
let stats = [];
let canEdit = false; // angemeldet und laufendes Jahr
let canEdit = false; // Admin und laufendes Jahr
function detail(s) {
if (!s.manual) return '';
@@ -50,12 +50,12 @@ $('#stats-list').addEventListener('click', async (e) => {
}
});
let loggedIn = false;
let admin = false;
async function load(year) {
const data = await api('GET', `/stats${year ? `?year=${year}` : ''}`);
stats = data.entries;
canEdit = loggedIn && data.live;
canEdit = admin && data.live;
$('#stats-year').innerHTML = data.years
.map((y, i) => `<option value="${y}" ${y === data.year ? 'selected' : ''}>${y}${i === 0 ? ' (laufend)' : ''}</option>`).join('');
$('#stats-info').textContent = data.live
@@ -66,6 +66,6 @@ async function load(year) {
$('#stats-year').addEventListener('change', (e) => load(e.target.value).catch(showError));
api('GET', '/me')
.then(({ user }) => { loggedIn = !!user; return load(); })
isAdmin()
.then((yes) => { admin = yes; return load(); })
.catch(showError);
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kooperationen – HCRB</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<main>
<!-- Übersicht aller Kooperationen -->
<section id="overview">
<header class="page-head">
<div>
<span class="eyebrow">Verbündete Teams</span>
<h1>Kooperationen</h1>
<p>Gruppen, mit denen die HCRB zusammen spielt, plant und unterwegs ist.</p>
</div>
<div class="actions">
<button type="button" id="add-btn" class="primary" hidden>Kooperation hinzufügen</button>
</div>
</header>
<div id="coop-grid" class="coop-grid"></div>
<div id="coop-empty" class="empty-state" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M7 11l3-3 3 2 3-3 4 4-6 6-2-1-2 1-6-6z"/><path d="M3 9l4 2M21 9l-4 2"/></svg>
<strong>Noch keine Kooperationen</strong>
<span id="coop-empty-hint">Sobald der Admin eine Kooperation einträgt, erscheint sie hier.</span>
</div>
</section>
<!-- Einzelne Kooperation: Titelbild, Anführer, Soziale Medien, Beschreibung -->
<article id="detail" hidden>
<a href="#" class="back-link" id="back-link">‹ Alle Kooperationen</a>
<div class="coop-hero" id="detail-hero">
<img id="detail-img" alt="" hidden>
<div class="coop-hero-text">
<span class="eyebrow">Kooperation</span>
<h1 id="detail-name"></h1>
</div>
</div>
<div class="split wide-left coop-info">
<div class="card">
<h2>Über die Gruppe</h2>
<p id="detail-description" class="coop-description"></p>
</div>
<div class="card">
<h2>Anführer</h2>
<p id="detail-leader" class="coop-leader"></p>
<h2 class="spaced">Soziale Medien</h2>
<div id="detail-socials" class="coop-socials"></div>
<div id="detail-admin" class="row wrap admin-actions" hidden>
<button type="button" id="detail-edit">Bearbeiten</button>
<button type="button" id="detail-delete" class="link danger">Löschen</button>
</div>
</div>
</div>
</article>
<!-- Formular (nur Admin) -->
<div class="card" id="coop-form-card" hidden>
<h2 id="coop-form-title">Kooperation hinzufügen</h2>
<form id="coop-form" class="split">
<div>
<label class="dropzone wide" id="dropzone" for="coop-file">
<img id="coop-preview" alt="Vorschau" hidden>
<span id="drop-text"></span>
<input type="file" id="coop-file" accept="image/png,image/jpeg,image/webp,image/svg+xml,.svg" hidden>
</label>
<label class="inline" id="remove-image-wrap" hidden>
<input type="checkbox" id="coop-remove-image"> Titelbild entfernen
</label>
</div>
<div>
<label>Name der Gruppe
<input type="text" id="coop-name" required maxlength="80" placeholder="z. B. Black Wolves Airsoft">
</label>
<label>Anführer
<input type="text" id="coop-leader" maxlength="120" placeholder="Name oder Rufname">
</label>
<label>Kurze Beschreibung
<textarea id="coop-description" maxlength="2000" placeholder="Wer sind sie, wo spielen sie, was machen wir zusammen …"></textarea>
</label>
<fieldset class="socials-fields">
<legend>Soziale Medien (optional, Link einfügen)</legend>
<div class="grid2">
<label>Website <input type="text" id="social-website" inputmode="url" placeholder="https://…"></label>
<label>Instagram <input type="text" id="social-instagram" inputmode="url" placeholder="instagram.com/…"></label>
<label>YouTube <input type="text" id="social-youtube" inputmode="url" placeholder="youtube.com/@…"></label>
<label>TikTok <input type="text" id="social-tiktok" inputmode="url" placeholder="tiktok.com/@…"></label>
<label>Facebook <input type="text" id="social-facebook" inputmode="url" placeholder="facebook.com/…"></label>
<label>Discord <input type="text" id="social-discord" inputmode="url" placeholder="discord.gg/…"></label>
</div>
</fieldset>
<p id="coop-error" class="warn" hidden></p>
<div class="row wrap">
<button type="submit" class="primary" id="coop-submit">Speichern</button>
<button type="button" id="coop-cancel">Abbrechen</button>
</div>
</div>
</form>
</div>
</main>
<script type="module" src="/js/kooperationen.js"></script>
</body>
</html>
+1 -1
View File
@@ -64,7 +64,7 @@
<div id="patch-empty" class="empty-state" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><path d="M12 2.5l8 3.5v6c0 5-3.5 8.2-8 9.5-4.5-1.3-8-4.5-8-9.5V6z"/></svg>
<strong>Noch keine Patches</strong>
<span id="patch-empty-hint">Sobald jemand aus der Gruppe einen Patch hochlädt, erscheint er hier.</span>
<span id="patch-empty-hint">Sobald der Admin einen Patch hochlädt, erscheint er hier.</span>
</div>
<dialog class="lightbox" id="lightbox">
+1 -2
View File
@@ -32,8 +32,7 @@
<h2>So wird gezählt</h2>
<ul class="facts">
<li>Jeder Termin im Kalender, der als <strong>Spiel-Event</strong> markiert ist und bei dem du zugesagt hast, zählt als ein Spiel, sobald der Tag vorbei oder heute ist.</li>
<li>Termine ohne Spiel (Treffen, Grillen, Basteltage) zählen nicht.</li>
<li>Externe werden nicht aufgeführt.</li>
<li>Termine ohne Spiel (Treffen, Grillen) zählen nicht.</li>
<li>Zu Neujahr wird der Endstand gespeichert und die Zählung beginnt bei 0.</li>
</ul>
</div>