Map-Bilder und Seitenwahl mit Emblemen

Map-Bilder aus ghostcap-gaming/cs2-map-images lokal als WebP gespeichert
(npm run images lädt und verkleinert sie). Bilder in Veto-Kacheln,
Pool-Vorschau, Serie und Seitenwahl; CT/T-Embleme aus client/public/sides
mit Ersatzsymbol, falls die Dateien fehlen.
This commit is contained in:
mregosar committed 2026-10-25 17:23:55 +01:00
1 parent e304556346
commit 7be0d85ab5
21 files changed
+701 -27

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
import { useState } from 'react';
/**
* Map-Bild aus der Konfiguration (map.image). Lädt das Bild nicht, bleibt das
* gezeichnete Muster in Map-Farbe darunter sichtbar.
*/
export default function MapImage({ map, className = '' }) {
const [failed, setFailed] = useState(false);
return (
<span className={`map-art ${className}`} style={{ '--map': map?.color ?? '#556' }} aria-hidden="true">
{map?.image && !failed && (
// Kein loading="lazy": in Kacheln mit clip-path zeichnet Chromium das Bild nach dem Laden sonst nicht neu.
<img src={map.image} alt="" onError={() => setFailed(true)} />
)}
</span>
);
}
+10 -1
View File
@@ -1,3 +1,6 @@
import MapImage from './MapImage.jsx';
import SideIcon from './SideIcon.jsx';
const STATUS = { pending: 'Ausstehend', next: 'Wird eingerichtet', live: 'Aktuell', done: 'Gespielt' };
export default function SeriesList({ state }) {
@@ -12,13 +15,19 @@ export default function SeriesList({ state }) {
const t = s.ctTeam ? teams[s.ctTeam === 'A' ? 'B' : 'A'].name : null;
return (
<li key={s.number} className={`series-item is-${s.status}`} style={{ '--map': map?.color ?? '#556' }}>
<MapImage map={map} className="series-art" />
<span className="series-no">Map {s.number}</span>
<strong className="series-map">{s.mapName}</strong>
<span className="series-origin">
{s.pickedBy ? `Pick von ${teams[s.pickedBy].name}` : 'Entscheidungsmap'}
</span>
<span className="series-sides">
{ct ? <><span className="side-ct">CT: {ct}</span> <span className="side-t">T: {t}</span></> : 'Seiten im Spiel festlegen'}
{ct ? (
<>
<span className="side-ct"><SideIcon side="CT" size={18} /> {ct}</span>
<span className="side-t"><SideIcon side="T" size={18} /> {t}</span>
</>
) : 'Seiten im Spiel festlegen'}
</span>
<span className="series-status">{STATUS[s.status]}</span>
</li>
+36
View File
@@ -0,0 +1,36 @@
import { useState } from 'react';
// Emblem-Dateien in client/public/sides/. Fehlen sie, wird ein schlichtes Ersatzsymbol gezeichnet.
const IMAGES = { CT: '/sides/ct.png', T: '/sides/t.png' };
export default function SideIcon({ side, size = 28 }) {
const [failed, setFailed] = useState(false);
if (!failed) {
return (
<img className={`side-icon side-icon-${side.toLowerCase()}`} src={IMAGES[side]} width={size} height={size}
alt="" aria-hidden="true" onError={() => setFailed(true)} />
);
}
return <FallbackIcon side={side} size={size} />;
}
function FallbackIcon({ side, size }) {
if (side === 'CT') {
return (
<svg className="side-icon side-icon-ct" viewBox="0 0 32 32" width={size} height={size} aria-hidden="true">
<path d="M16 2 4 6.5v8.2C4 22.3 9.1 28 16 30c6.9-2 12-7.7 12-15.3V6.5L16 2z" fill="currentColor" opacity=".22" />
<path d="M16 2 4 6.5v8.2C4 22.3 9.1 28 16 30c6.9-2 12-7.7 12-15.3V6.5L16 2z" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
<path d="m16 9 2.1 4.3 4.7.7-3.4 3.3.8 4.7-4.2-2.2-4.2 2.2.8-4.7-3.4-3.3 4.7-.7z" fill="currentColor" />
</svg>
);
}
return (
<svg className="side-icon side-icon-t" viewBox="0 0 32 32" width={size} height={size} aria-hidden="true">
<rect x="5" y="11" width="22" height="15" rx="2.5" fill="currentColor" opacity=".22" />
<rect x="5" y="11" width="22" height="15" rx="2.5" fill="none" stroke="currentColor" strokeWidth="2" />
<rect x="9" y="15" width="9" height="5" rx="1" fill="currentColor" />
<circle cx="22.5" cy="17.5" r="1.8" fill="currentColor" />
<path d="M11 11V8.5a2.5 2.5 0 0 1 2.5-2.5h2c1.4 0 2.5-1.1 2.5-2.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
+18 -5
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import { clock } from '../lib/labels.js';
import MapImage from './MapImage.jsx';
import SideIcon from './SideIcon.jsx';
const STEP_SHORT = { ban: 'Ban', pick: 'Pick', side: 'Seite', decider: 'Decider' };
@@ -57,7 +59,7 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
aria-pressed={isSel}
aria-label={`${meta.name}: ${statusText(m, teams)}`}
>
<span className="map-art" aria-hidden="true" />
<MapImage map={meta} />
<span className="map-name">{meta.name}</span>
<span className="map-status">{statusText(m, teams)}</span>
</button>
@@ -77,10 +79,21 @@ export default function VetoBoard({ state, viewer, act, busy, compact = false })
{yourTurn && current.type === 'side' && (
<div className="side-choice">
<p>Auf welcher Seite startet {teams[viewer.team].name} auf {sideMap?.name}?</p>
<div className="row">
<button className="btn btn-ct btn-lg" disabled={!!busy} onClick={() => submitSide('CT')}>Counter-Terrorists</button>
<button className="btn btn-t btn-lg" disabled={!!busy} onClick={() => submitSide('T')}>Terrorists</button>
<MapImage map={sideMap} className="side-choice-art" />
<div className="side-choice-body">
<p>Auf welcher Seite startet <strong>{teams[viewer.team].name}</strong> auf <strong>{sideMap?.name}</strong>?</p>
<div className="side-options">
<button className="side-option side-option-ct" disabled={!!busy} onClick={() => submitSide('CT')}>
<SideIcon side="CT" size={44} />
<span className="side-option-name">Counter-Terrorists</span>
<span className="side-option-sub">Start auf CT, Gegner beginnt als T</span>
</button>
<button className="side-option side-option-t" disabled={!!busy} onClick={() => submitSide('T')}>
<SideIcon side="T" size={44} />
<span className="side-option-name">Terrorists</span>
<span className="side-option-sub">Start auf T, Gegner beginnt als CT</span>
</button>
</div>
</div>
</div>
)}
+5 -1
View File
@@ -10,6 +10,7 @@ import ServerPanel from '../components/ServerPanel.jsx';
import OrganizerPanel from '../components/OrganizerPanel.jsx';
import SeriesList from '../components/SeriesList.jsx';
import EventLog from '../components/EventLog.jsx';
import MapImage from '../components/MapImage.jsx';
export default function MatchPage() {
const { id } = useParams();
@@ -179,7 +180,10 @@ function MapPoolPreview({ maps }) {
return (
<div className="pool-preview" aria-label="Map-Pool">
{maps.map((m) => (
<span key={m.id} className="pool-chip" style={{ '--map': m.color ?? '#556' }}>{m.name}</span>
<span key={m.id} className="pool-chip" style={{ '--map': m.color ?? '#556' }}>
<MapImage map={m} />
<span className="pool-chip-name">{m.name}</span>
</span>
))}
</div>
);
+46 -5
View File
@@ -177,8 +177,12 @@ input:focus, select:focus { border-color: var(--ct); outline: none; }
.stage-note .pool-preview { margin-top: 16px; }
.missing-list { margin: 0 0 12px; padding-left: 1.1em; color: var(--muted); }
.missing-list li { margin-bottom: 4px; }
.pool-preview { display: flex; flex-wrap: wrap; gap: 6px; }
.pool-chip { padding: 4px 10px; border-left: 3px solid var(--map); background: var(--surface-2); font-size: .875rem; border-radius: 3px; }
.pool-preview { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; }
.pool-chip {
position: relative; overflow: hidden; display: flex; align-items: flex-end;
aspect-ratio: 16 / 9; padding: 6px 8px; border-bottom: 3px solid var(--map); border-radius: 3px; background: var(--surface-2);
}
.pool-chip-name { position: relative; z-index: 1; font: 600 1rem var(--display); }
.team-card { --team: var(--team-a); background: var(--surface); border: 1px solid var(--line); border-top: 4px solid var(--team); border-radius: var(--radius); padding: 16px; }
.team-card.team-B { --team: var(--team-b); }
@@ -235,6 +239,12 @@ input:focus, select:focus { border-color: var(--ct); outline: none; }
radial-gradient(120% 90% at 85% 0%, color-mix(in srgb, var(--map) 70%, transparent), transparent 60%),
linear-gradient(180deg, color-mix(in srgb, var(--map) 35%, var(--surface-2)), var(--surface) 85%);
}
.map-art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* Abdunkeln nach unten, damit Name und Status auf jedem Bild lesbar bleiben */
.map-art::after {
content: ''; position: absolute; inset: 0;
background: linear-gradient(180deg, rgba(16, 21, 31, .05) 30%, rgba(16, 21, 31, .88) 100%);
}
.map-name, .map-status { position: relative; z-index: 1; }
.map-name { font: 700 1.625rem/1 var(--display); text-shadow: 0 1px 8px rgba(0, 0, 0, .5); }
.map-status { font-size: .8125rem; color: color-mix(in srgb, var(--text) 75%, transparent); margin-top: 4px; }
@@ -265,8 +275,26 @@ input:focus, select:focus { border-color: var(--ct); outline: none; }
@keyframes side-pulse { 50% { box-shadow: inset 0 0 0 3px var(--pending); } }
.veto-confirm .btn { width: 100%; }
.side-choice { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; }
.side-choice .btn { flex: 1; }
.side-choice { position: relative; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.side-choice-art { opacity: .45; }
.side-choice-art::after { background: linear-gradient(90deg, rgba(16, 21, 31, .92), rgba(16, 21, 31, .55)); }
.side-choice-body { position: relative; z-index: 1; padding: 18px; }
.side-options { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.side-option {
--side: var(--ct);
display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; column-gap: 12px; align-items: center;
text-align: left; padding: 14px 16px; cursor: pointer; font: inherit; color: var(--text);
background: color-mix(in srgb, var(--side) 16%, rgba(16, 21, 31, .75));
border: 2px solid color-mix(in srgb, var(--side) 70%, transparent); border-radius: var(--radius);
transition: background .15s, border-color .15s, transform .15s;
}
.side-option-t { --side: var(--t); }
.side-option .side-icon { grid-row: 1 / 3; color: var(--side); }
.side-option-name { font: 700 1.375rem/1.1 var(--display); color: var(--side); }
.side-option-sub { font-size: .8125rem; color: var(--muted); }
.side-option:hover:not(:disabled) { background: color-mix(in srgb, var(--side) 28%, rgba(16, 21, 31, .75)); border-color: var(--side); transform: translateY(-1px); }
.side-option:disabled { opacity: .5; cursor: not-allowed; }
.side-icon { flex: none; }
.step-track { list-style: none; display: flex; flex-wrap: wrap; gap: 4px; padding: 0; margin: 0; }
.step {
@@ -293,9 +321,20 @@ input:focus, select:focus { border-color: var(--ct); outline: none; }
.series { margin-bottom: 20px; }
.series-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
.series-item {
position: relative; overflow: hidden;
display: grid; grid-template-columns: auto 1fr auto; gap: 2px 16px; align-items: baseline;
padding: 14px 16px; background: var(--surface); border-left: 4px solid var(--map); border-radius: var(--radius);
}
.series-item > :not(.series-art) { position: relative; z-index: 1; }
/* Map-Bild rechts, nach links in die Fläche ausgeblendet */
.series-art {
left: auto !important; width: 60%;
-webkit-mask-image: linear-gradient(to left, #000 35%, transparent);
mask-image: linear-gradient(to left, #000 35%, transparent);
opacity: .7;
}
.series-art::after { display: none; }
.series-status { text-shadow: 0 1px 6px rgba(0, 0, 0, .8); }
.series-no { color: var(--muted); font-size: .875rem; }
.series-map { font: 700 1.625rem var(--display); }
.series-status { font-size: .875rem; color: var(--muted); text-align: right; grid-column: 3; grid-row: 1; }
@@ -303,8 +342,9 @@ input:focus, select:focus { border-color: var(--ct); outline: none; }
.series-item.is-live { box-shadow: inset 0 0 0 1px var(--map); }
.series-item.is-live .series-status, .series-item.is-next .series-status { color: var(--pending); font-weight: 600; }
.series-item.is-done { opacity: .6; }
.side-ct, .side-t { display: inline-flex; align-items: center; gap: 5px; vertical-align: middle; }
.side-ct { color: var(--ct); }
.side-t { color: var(--t); margin-left: 12px; }
.side-t { color: var(--t); margin-left: 14px; }
/* ---------- Panels unten ---------- */
.match-lower { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-top: 28px; align-items: start; }
@@ -359,6 +399,7 @@ input:focus, select:focus { border-color: var(--ct); outline: none; }
.create-teams { grid-template-columns: 1fr; }
.vs.big { justify-self: center; }
.map-grid { grid-template-columns: repeat(2, 1fr); }
.side-options { grid-template-columns: 1fr; }
.series-item { grid-template-columns: 1fr auto; }
.series-no { grid-column: 1 / -1; }
.series-status { grid-column: 2; grid-row: 2; }