mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-15 23:13:28 +02:00
Erste Version: HCRB Spieltag-Kalender und Fahrtplaner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
10400eee59
30 files changed
+3771
No files matched your search
@@ -0,0 +1,440 @@
|
||||
/* Farben aus der Gruppenflagge: Marineblau #354173, Gold #e4b84a, Stahlgrau #cacaca */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--flag: #354173;
|
||||
--bg: #1b2140;
|
||||
--bg-deep: #141932;
|
||||
--card: #232a4d;
|
||||
--card-2: #2b335a;
|
||||
--text: #e9eaf0;
|
||||
--muted: #a1a7c2;
|
||||
--border: #3d4775;
|
||||
--accent: #e4b84a;
|
||||
--accent-text: #141932;
|
||||
--accent-soft: rgba(228, 184, 74, .14);
|
||||
--steel: #cacaca;
|
||||
--warn: #ec7f6a;
|
||||
--chip: #333c66;
|
||||
--radius: 2px;
|
||||
--font-head: "Bahnschrift", "DIN Alternate", "Roboto Condensed", "Arial Narrow", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
/* Fußzeile bleibt auch auf kurzen Seiten unten */
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
h1, h2, h3, .brand, .site-header nav a, button, .button, .badge, .cal-grid .dow {
|
||||
font-family: var(--font-head);
|
||||
}
|
||||
h1, h2, h3 { text-transform: uppercase; letter-spacing: .08em; }
|
||||
|
||||
/* ---------- Kopfzeile ---------- */
|
||||
.site-header {
|
||||
background: var(--bg-deep);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
padding: 10px 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.site-header .inner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 1.3rem;
|
||||
letter-spacing: .18em;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand img { width: 40px; height: 40px; margin-right: 10px; flex: none; }
|
||||
.brand small {
|
||||
font-size: .72rem;
|
||||
font-weight: 400;
|
||||
color: var(--steel);
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.site-header nav { display: flex; gap: 2px; flex-wrap: wrap; margin-left: auto; }
|
||||
.site-header nav a, .site-header nav button {
|
||||
text-decoration: none;
|
||||
color: var(--steel);
|
||||
padding: 6px 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
font-size: .85rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.site-header nav button { background: none; border-width: 0 0 2px; border-radius: 0; }
|
||||
.site-header nav a:hover, .site-header nav button:hover { color: var(--text); }
|
||||
.site-header nav a.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
main { max-width: 960px; width: 100%; margin: 0 auto; padding: 16px; flex: 1 0 auto; }
|
||||
main.narrow { max-width: 760px; }
|
||||
main.login { max-width: 420px; padding-top: 48px; }
|
||||
|
||||
/* ---------- Bausteine ---------- */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: .95rem;
|
||||
margin: 0 0 14px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.card h3 { font-size: .9rem; }
|
||||
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.card-head h2 { margin: 0; }
|
||||
.card-head select { width: auto; margin-top: 0; }
|
||||
|
||||
label { display: block; margin-bottom: 10px; font-size: .92rem; color: var(--muted); }
|
||||
label.inline { display: inline-flex; align-items: center; gap: 6px; color: var(--text); }
|
||||
input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; }
|
||||
input[type=text], input[type=password], input[type=number], input[type=date], input[type=time], select, textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
|
||||
textarea { resize: vertical; min-height: 60px; }
|
||||
.row input[type=text], .row input[type=number], .row select { width: auto; flex: 1 1 120px; margin-top: 0; }
|
||||
.row input[type=number] { flex: 0 0 80px; }
|
||||
|
||||
button, .button {
|
||||
font-size: .85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
button:hover, .button:hover { border-color: var(--steel); }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
button.primary, .button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 700; }
|
||||
button.primary:hover { filter: brightness(1.08); }
|
||||
button.danger { color: var(--warn); }
|
||||
button.link { background: none; border: none; padding: 4px 6px; color: var(--muted); }
|
||||
button.link:hover { color: var(--text); }
|
||||
button.small, .button.small { padding: 4px 10px; font-size: .78rem; }
|
||||
|
||||
.row { display: flex; gap: 8px; align-items: center; margin: 8px 0; }
|
||||
.row.wrap { flex-wrap: wrap; }
|
||||
.row.end { justify-content: flex-end; }
|
||||
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
@media (max-width: 560px) { .grid2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.muted { color: var(--muted); font-size: .9rem; }
|
||||
|
||||
.list { list-style: none; padding: 0; margin: 0; }
|
||||
.list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.list li:last-child { border-bottom: none; }
|
||||
.list .name { flex: 1; }
|
||||
/* Aktionen rücken nach rechts und brechen bei Platzmangel gemeinsam in die nächste Zeile um */
|
||||
.list .actions { margin-left: auto; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.list li > .link:last-child { margin-left: auto; }
|
||||
.list .meta { color: var(--muted); font-size: .85rem; }
|
||||
.list label { margin: 0; }
|
||||
|
||||
.badge {
|
||||
font-size: .72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--steel);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip { font-size: .85rem; padding: 3px 10px; border-radius: var(--radius); background: var(--chip); }
|
||||
.chip.me { background: var(--accent); color: var(--accent-text); }
|
||||
|
||||
.notice {
|
||||
border: 1px solid var(--accent);
|
||||
border-left-width: 4px;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
border: 1px solid var(--warn);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ---------- Startseite ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
height: max(380px, 72vh);
|
||||
background:
|
||||
linear-gradient(to bottom, transparent 55%, var(--bg) 100%),
|
||||
var(--flag) url("/img/flagge.jpg") center 35% / contain no-repeat;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 16px 24px;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: clamp(2.4rem, 7vw, 4rem);
|
||||
margin: 0;
|
||||
letter-spacing: .3em;
|
||||
margin-right: -.3em; /* gleicht letter-spacing am Ende aus */
|
||||
color: var(--accent);
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, .5);
|
||||
}
|
||||
.hero .subtitle {
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .35em;
|
||||
color: var(--steel);
|
||||
font-size: clamp(.8rem, 2.4vw, 1.05rem);
|
||||
border-top: 1px solid var(--accent);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
/* Hochformat: Flagge füllt die Fläche, damit das Zahnrad groß bleibt */
|
||||
.hero { height: min(78vh, 480px); background-size: auto, auto 70%; background-position: center, center 20%; }
|
||||
}
|
||||
|
||||
.intro { max-width: 680px; margin: 8px auto 0; text-align: center; color: var(--muted); }
|
||||
.notice.wip { max-width: 680px; margin: 16px auto 0; text-align: center; }
|
||||
|
||||
.social { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 4px; }
|
||||
.social .button { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.social svg { width: 18px; height: 18px; flex: none; }
|
||||
.footer-links { margin-top: 8px; display: flex; gap: 16px; justify-content: center; }
|
||||
.footer-links a { color: var(--muted); }
|
||||
.footer-links a:hover { color: var(--accent); }
|
||||
|
||||
.tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 24px 0; }
|
||||
@media (max-width: 560px) { .tiles { grid-template-columns: 1fr; } }
|
||||
.tile {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.tile:hover { background: var(--card-2); border-color: var(--accent); }
|
||||
.tile svg { flex: 0 0 36px; width: 36px; height: 36px; color: var(--accent); }
|
||||
.tile h2 { margin: 0 0 4px; font-size: 1.1rem; }
|
||||
.tile p { margin: 0; color: var(--muted); }
|
||||
|
||||
/* ---------- Kalender ---------- */
|
||||
.cal-nav { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
||||
.cal-nav h2 { margin: 0; font-size: 1rem; text-align: center; flex: 1; border: none; padding: 0; }
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 3px;
|
||||
}
|
||||
.cal-grid .dow {
|
||||
text-align: center;
|
||||
font-size: .75rem;
|
||||
color: var(--muted);
|
||||
padding: 4px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .1em;
|
||||
}
|
||||
.cal-day {
|
||||
min-height: 72px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 4px;
|
||||
background: var(--bg-deep);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-family: inherit;
|
||||
font-size: .85rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cal-day.other { opacity: .35; }
|
||||
.cal-day.today .num { background: var(--steel); color: var(--bg-deep); padding: 0 6px; }
|
||||
.cal-day.selected { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||
.cal-day .num { align-self: flex-start; font-weight: 600; }
|
||||
.cal-day .ev {
|
||||
font-size: .72rem;
|
||||
background: var(--accent-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 1px 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cal-day .ev.mine { background: var(--accent); color: var(--accent-text); }
|
||||
@media (max-width: 560px) {
|
||||
.cal-day { min-height: 52px; padding: 2px; }
|
||||
.cal-day .ev { font-size: 0; padding: 0; height: 6px; }
|
||||
}
|
||||
|
||||
.event {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--bg-deep);
|
||||
}
|
||||
.event h3 { margin: 0; font-size: .95rem; }
|
||||
.event .when { color: var(--muted); font-size: .85rem; margin-bottom: 6px; }
|
||||
.event .note { margin: 6px 0; white-space: pre-wrap; }
|
||||
|
||||
/* ---------- Statistik ---------- */
|
||||
.stats .rank { color: var(--muted); min-width: 3ch; font-family: var(--font-head); }
|
||||
.stat-total {
|
||||
min-width: 3ch;
|
||||
text-align: right;
|
||||
font-family: var(--font-head);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------- Fahrtplaner ---------- */
|
||||
.cars { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 12px; }
|
||||
.car-meet { margin: 10px 0 4px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
.car-meet label { font-size: .8rem; margin-bottom: 6px; }
|
||||
.car-meet .grid2 { gap: 8px; grid-template-columns: 1fr 1fr; }
|
||||
.car {
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--steel);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
background: var(--bg-deep);
|
||||
}
|
||||
.car h3 { margin: 0 0 4px; font-size: .9rem; }
|
||||
.car ol { margin: 6px 0 0; padding-left: 20px; }
|
||||
.meeting { display: grid; grid-template-columns: max-content 1fr; gap: 2px 12px; margin: 8px 0 4px; }
|
||||
.meeting dt { color: var(--muted); }
|
||||
.meeting dd { margin: 0; font-weight: 600; }
|
||||
.meeting dd small { display: block; font-weight: 400; color: var(--muted); }
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .2em;
|
||||
font-size: .75rem;
|
||||
padding: 24px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------- Responsiv ---------- */
|
||||
.nav-toggle { display: none; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-header { padding: 8px 16px; }
|
||||
.site-header .inner { gap: 8px; }
|
||||
.nav-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
color: var(--steel);
|
||||
}
|
||||
.site-header.open .nav-toggle { color: var(--accent); border-color: var(--accent); }
|
||||
.site-header nav { display: none; width: 100%; flex-direction: column; margin: 4px 0 0; gap: 0; }
|
||||
.site-header.open nav { display: flex; }
|
||||
.site-header nav a, .site-header nav button {
|
||||
padding: 12px 4px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.site-header nav a:last-child, .site-header nav button:last-child { border-bottom: none; }
|
||||
.site-header nav a.active { border-bottom-color: var(--border); border-left: 3px solid var(--accent); padding-left: 10px; }
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.brand small { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
main { padding: 12px; }
|
||||
.card { padding: 14px; }
|
||||
/* iOS zoomt bei Schrift unter 16px ins Feld */
|
||||
input[type=text], input[type=password], input[type=number], input[type=date], input[type=time], select, textarea { font-size: 16px; }
|
||||
.stats .meta { flex-basis: 100%; order: 10; padding-left: calc(3ch + 10px); margin-top: -6px; }
|
||||
}
|
||||
|
||||
/* Touch-Geräte: ausreichend große Tippflächen */
|
||||
@media (pointer: coarse) {
|
||||
button.small, .button.small { min-height: 36px; min-width: 36px; }
|
||||
button.link { padding: 8px 8px; }
|
||||
input[type=checkbox] { width: 20px; height: 20px; }
|
||||
}
|
||||
|
||||
@media print {
|
||||
.site-header, footer, .no-print { display: none !important; }
|
||||
:root { --text: #000; --muted: #444; --border: #999; --bg-deep: #fff; --card: #fff; }
|
||||
body { background: #fff; color: #000; }
|
||||
.card { border: none; }
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,121 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HCRB – Highcap Rasselbande</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<section class="hero">
|
||||
<div>
|
||||
<h1>HCRB</h1>
|
||||
<div class="subtitle">Highcap Rasselbande</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<p class="intro">
|
||||
Wir sind die Highcap Rasselbande – eine Airsoft-Gruppe aus Berlin.
|
||||
Am häufigsten findet ihr uns auf der Darkzone.
|
||||
</p>
|
||||
|
||||
<p class="notice wip">
|
||||
<strong>Diese Seite ist noch in Arbeit.</strong> Es kommen noch Änderungen und neue Funktionen dazu.
|
||||
</p>
|
||||
|
||||
<div class="tiles">
|
||||
<a class="tile" href="/kalender">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="16"/><path d="M3 10h18M8 3v4M16 3v4M7 14h3M14 14h3M7 17h3"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h2>Kalender</h2>
|
||||
<p>Spieltage eintragen und zusagen.</p>
|
||||
</div>
|
||||
</a>
|
||||
<a class="tile" href="/fahrt">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="square" aria-hidden="true">
|
||||
<path d="M3 16v-4l2-5h14l2 5v4H3z"/><path d="M3 12h18"/><circle cx="7.5" cy="17.5" r="1.8"/><circle cx="16.5" cy="17.5" r="1.8"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h2>Fahrtplaner</h2>
|
||||
<p>Fahrgemeinschaften einteilen.</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Nächste Spieltage</h2>
|
||||
<a href="/kalender" class="button small">Zum Kalender</a>
|
||||
</div>
|
||||
<div id="upcoming"><p class="muted">Lädt …</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Pile of Shame</h2>
|
||||
<a href="/statistik" class="button small">Zur Statistik</a>
|
||||
</div>
|
||||
<div id="shame"><p class="muted">Lädt …</p></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Über uns</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
|
||||
gibt es auf YouTube und Instagram:
|
||||
</p>
|
||||
<div class="social">
|
||||
<a class="button" href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M23 7.2a3 3 0 0 0-2.1-2.1C19 4.6 12 4.6 12 4.6s-7 0-8.9.5A3 3 0 0 0 1 7.2 31 31 0 0 0 .5 12a31 31 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 8.9.5 8.9.5s7 0 8.9-.5a3 3 0 0 0 2.1-2.1 31 31 0 0 0 .5-4.8 31 31 0 0 0-.5-4.8zM9.75 15.02V8.98L15.5 12l-5.75 3.02z"/></svg>YouTube
|
||||
</a>
|
||||
<a class="button" href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="2.5" y="2.5" width="19" height="19" rx="5"/><circle cx="12" cy="12" r="4.2"/><circle cx="17.6" cy="6.4" r="1" fill="currentColor" stroke="none"/></svg>Instagram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
<script type="module">
|
||||
import { renderHeader, renderFooter, api, esc, formatDate } from '/js/common.js';
|
||||
|
||||
renderHeader('home');
|
||||
renderFooter();
|
||||
|
||||
const box = document.getElementById('upcoming');
|
||||
try {
|
||||
const events = await api('GET', '/upcoming');
|
||||
box.innerHTML = events.length
|
||||
? `<ul class="list">${events.map((e) => `
|
||||
<li>
|
||||
<span class="name"><strong>${esc(e.title)}</strong></span>
|
||||
<span class="meta">${formatDate(e.date, true)}</span>
|
||||
<span class="badge">${e.attendeeCount} zugesagt</span>
|
||||
</li>`).join('')}</ul>`
|
||||
: '<p class="muted">Noch keine Spieltage eingetragen.</p>';
|
||||
} catch {
|
||||
box.innerHTML = '<p class="muted">Termine konnten nicht geladen werden.</p>';
|
||||
}
|
||||
|
||||
// Pile of Shame: wer dieses Jahr noch nicht spielen war
|
||||
const shame = document.getElementById('shame');
|
||||
try {
|
||||
const { year, entries } = await api('GET', '/stats');
|
||||
const names = entries.filter((s) => s.total === 0).map((s) => s.name).sort((a, b) => a.localeCompare(b, 'de'));
|
||||
shame.innerHTML = names.length
|
||||
? `<p class="muted">Waren ${year} noch kein einziges Mal spielen:</p>
|
||||
<div class="chips">${names.map((n) => `<span class="chip">${esc(n)}</span>`).join('')}</div>`
|
||||
: `<p class="muted">Leer – alle waren ${year} schon spielen.</p>`;
|
||||
} catch {
|
||||
shame.innerHTML = '<p class="muted">Konnte nicht geladen werden.</p>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,140 @@
|
||||
// Gemeinsame Helfer für alle Seiten
|
||||
|
||||
export const $ = (sel, root = document) => root.querySelector(sel);
|
||||
|
||||
export function esc(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
}
|
||||
|
||||
export function sortByName(list) {
|
||||
return [...list].sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
}
|
||||
|
||||
export function seatsLabel(n) {
|
||||
return `${n} ${n === 1 ? 'Platz' : 'Plätze'}`;
|
||||
}
|
||||
|
||||
// ---------- API ----------
|
||||
export async function api(method, path, body) {
|
||||
const res = await fetch('/api' + path, {
|
||||
method,
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : {},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401 && path !== '/login') {
|
||||
// Sitzung abgelaufen → zur Anmeldung und danach hierher zurück
|
||||
location.href = `/login?next=${encodeURIComponent(location.pathname + location.search)}`;
|
||||
return new Promise(() => {});
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Fehler ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function showError(err) {
|
||||
alert(err.message || 'Es ist ein Fehler aufgetreten.');
|
||||
}
|
||||
|
||||
// ---------- Datum ----------
|
||||
// Datumswerte werden als "YYYY-MM-DD" gespeichert (ohne Zeitzone)
|
||||
export function toIso(d) {
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
export function fromIso(iso) {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function formatDate(iso, withWeekday = false) {
|
||||
if (!iso) return '';
|
||||
return fromIso(iso).toLocaleDateString('de-DE', {
|
||||
weekday: withWeekday ? 'short' : undefined,
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- "Ich bin ..." (pro Browser gemerkt) ----------
|
||||
const ME_KEY = 'hcrb.me';
|
||||
|
||||
export function getMe() {
|
||||
try { return localStorage.getItem(ME_KEY); } catch { return null; }
|
||||
}
|
||||
|
||||
export function setMe(id) {
|
||||
try {
|
||||
id ? localStorage.setItem(ME_KEY, id) : localStorage.removeItem(ME_KEY);
|
||||
} catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
// ---------- Kopfzeile ----------
|
||||
const NAV = [
|
||||
{ href: '/', key: 'home', label: 'Start' },
|
||||
{ href: '/kalender', key: 'kalender', label: 'Kalender' },
|
||||
{ href: '/fahrt', key: 'fahrt', label: 'Fahrtplaner' },
|
||||
{ href: '/gruppe', key: 'gruppe', label: 'Gruppe' },
|
||||
{ href: '/statistik', key: 'statistik', label: 'Statistik' },
|
||||
];
|
||||
|
||||
export function renderHeader(active) {
|
||||
const header = document.createElement('header');
|
||||
header.className = 'site-header';
|
||||
header.innerHTML = `
|
||||
<div class="inner">
|
||||
<a class="brand" href="/"><img src="/img/logo.png" alt="" width="40" height="40">HCRB<small>Highcap Rasselbande</small></a>
|
||||
<button type="button" class="nav-toggle" aria-expanded="false" aria-controls="site-nav" aria-label="Menü">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M3 6h18M3 12h18M3 18h18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<nav id="site-nav">
|
||||
${NAV.map((n) => `<a href="${n.href}" class="${n.key === active ? 'active' : ''}">${n.label}</a>`).join('')}
|
||||
</nav>
|
||||
</div>`;
|
||||
document.body.prepend(header);
|
||||
// Auf schmalen Bildschirmen klappt das Menü über die Schaltfläche auf
|
||||
const toggle = header.querySelector('.nav-toggle');
|
||||
toggle.addEventListener('click', () => {
|
||||
const open = header.classList.toggle('open');
|
||||
toggle.setAttribute('aria-expanded', open);
|
||||
});
|
||||
if (!document.querySelector('link[rel="icon"]')) {
|
||||
document.head.insertAdjacentHTML('beforeend', '<link rel="icon" type="image/png" href="/img/favicon.png">');
|
||||
}
|
||||
if (active !== 'login') renderAuthLink(header.querySelector('nav'));
|
||||
}
|
||||
|
||||
// ---------- Fußzeile (öffentliche Seiten) ----------
|
||||
export function renderFooter() {
|
||||
const footer = document.createElement('footer');
|
||||
footer.innerHTML = `
|
||||
HCRB · Highcap Rasselbande · Berlin
|
||||
<div class="footer-links">
|
||||
<a href="https://www.youtube.com/@highcaprasselbande" target="_blank" rel="noopener">YouTube</a>
|
||||
<a href="https://www.instagram.com/hcrb.airsoft" target="_blank" rel="noopener">Instagram</a>
|
||||
</div>`;
|
||||
document.body.append(footer);
|
||||
}
|
||||
|
||||
async function renderAuthLink(nav) {
|
||||
const { user } = await api('GET', '/me').catch(() => ({}));
|
||||
if (!user) {
|
||||
nav.insertAdjacentHTML('beforeend', '<a href="/login">Anmelden</a>');
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Abmelden';
|
||||
btn.title = `Angemeldet als ${user}`;
|
||||
btn.addEventListener('click', async () => {
|
||||
await api('POST', '/logout').catch(() => {});
|
||||
location.href = '/';
|
||||
});
|
||||
nav.append(btn);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { $, api, renderHeader, renderFooter } from './common.js';
|
||||
|
||||
renderHeader('login');
|
||||
renderFooter();
|
||||
|
||||
// Nur Pfade auf dieser Seite zulassen (kein Weiterleiten auf fremde Seiten)
|
||||
function nextUrl() {
|
||||
const next = new URLSearchParams(location.search).get('next') || '';
|
||||
return next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : '/kalender';
|
||||
}
|
||||
|
||||
const { user } = await api('GET', '/me');
|
||||
if (user) location.replace(nextUrl());
|
||||
|
||||
$('#login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const error = $('#login-error');
|
||||
const button = e.target.querySelector('button');
|
||||
error.hidden = true;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api('POST', '/login', { username: $('#login-user').value, password: $('#login-password').value });
|
||||
location.replace(nextUrl());
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.hidden = false;
|
||||
$('#login-password').value = '';
|
||||
$('#login-password').focus();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#login-user').focus();
|
||||
@@ -0,0 +1,69 @@
|
||||
import { $, api, esc, showError, renderHeader, renderFooter } from './common.js';
|
||||
|
||||
renderHeader('statistik');
|
||||
renderFooter();
|
||||
|
||||
let stats = [];
|
||||
let canEdit = false; // angemeldet und laufendes Jahr
|
||||
|
||||
function detail(s) {
|
||||
if (!s.manual) return '';
|
||||
const sign = s.manual > 0 ? '+' : '−';
|
||||
return `<span class="meta">${s.calendar} aus Kalender ${sign} ${Math.abs(s.manual)} manuell</span>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
$('#stats-empty').hidden = stats.length > 0;
|
||||
$('#stats-edit-hint').hidden = !canEdit;
|
||||
// Gleiche Anzahl → gleicher Platz
|
||||
const rank = (s) => 1 + stats.filter((x) => x.total > s.total).length;
|
||||
$('#stats-list').innerHTML = stats.map((s) => `
|
||||
<li>
|
||||
<span class="rank">${rank(s)}.</span>
|
||||
<span class="name">${esc(s.name)}</span>
|
||||
${detail(s)}
|
||||
<span class="actions">
|
||||
${canEdit ? `
|
||||
<button class="small" data-step="-1" data-id="${s.id}" ${s.total ? '' : 'disabled'} aria-label="Ein Spiel weniger">−</button>` : ''}
|
||||
<span class="stat-total">${s.total}</span>
|
||||
${canEdit ? `
|
||||
<button class="small" data-step="1" data-id="${s.id}" aria-label="Ein Spiel mehr">+</button>` : ''}
|
||||
</span>
|
||||
</li>`).join('');
|
||||
}
|
||||
|
||||
$('#stats-list').addEventListener('click', async (e) => {
|
||||
const { step, id } = e.target.dataset;
|
||||
if (!step) return;
|
||||
const s = stats.find((x) => x.id === id);
|
||||
try {
|
||||
const { manual } = await api('PUT', `/members/${id}/plays-manual`, { manual: Math.max(s.manual, -s.calendar) + Number(step) });
|
||||
s.manual = manual;
|
||||
s.total = Math.max(0, s.calendar + manual);
|
||||
stats.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, 'de'));
|
||||
render();
|
||||
} catch (err) {
|
||||
showError(err);
|
||||
}
|
||||
});
|
||||
|
||||
let loggedIn = false;
|
||||
|
||||
async function load(year) {
|
||||
const data = await api('GET', `/stats${year ? `?year=${year}` : ''}`);
|
||||
stats = data.entries;
|
||||
canEdit = loggedIn && 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
|
||||
? `Gezählt werden die Spieltage seit dem 1. Januar ${data.year}, bei denen jemand zugesagt hat. `
|
||||
+ 'Zu Neujahr wird das Jahr gespeichert und die Zählung beginnt von vorn. Externe werden nicht aufgeführt.'
|
||||
: `Endstand ${data.year}, gespeichert am ${new Date(data.savedAt).toLocaleDateString('de-DE')}.`;
|
||||
render();
|
||||
}
|
||||
|
||||
$('#stats-year').addEventListener('change', (e) => load(e.target.value).catch(showError));
|
||||
|
||||
api('GET', '/me')
|
||||
.then(({ user }) => { loggedIn = !!user; return load(); })
|
||||
.catch(showError);
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Anmelden – HCRB</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="login">
|
||||
<div class="card">
|
||||
<h2>Anmelden</h2>
|
||||
<form id="login-form">
|
||||
<label>Benutzername
|
||||
<input type="text" id="login-user" required autocomplete="username" autocapitalize="none" spellcheck="false">
|
||||
</label>
|
||||
<label>Passwort
|
||||
<input type="password" id="login-password" required autocomplete="current-password">
|
||||
</label>
|
||||
<p id="login-error" class="warn" hidden></p>
|
||||
<button type="submit" class="primary">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Statistik – HCRB</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="narrow">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Gespielte Spieltage</h2>
|
||||
<select id="stats-year" aria-label="Jahr"></select>
|
||||
</div>
|
||||
<p id="stats-info" class="muted"></p>
|
||||
<p id="stats-edit-hint" class="muted" hidden>Mit − / + passt du die Zahl von Hand an, z. B. für spontane Fahrten,
|
||||
die nicht im Kalender standen.</p>
|
||||
<p id="stats-empty" class="muted" hidden>Noch keine Mitglieder eingetragen.</p>
|
||||
<ol id="stats-list" class="list stats"></ol>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/js/statistik.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user