Weiter gebaut
Some checks failed
Host-Based Deploy (Java 21 Fix) / build-and-run (push) Has been cancelled

This commit is contained in:
2026-04-25 16:56:35 +02:00
parent e4b762f905
commit 4f2048bdc8
242 changed files with 14108 additions and 1770 deletions

View File

@@ -57,27 +57,90 @@
}
.nextcard-cards {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
gap: 0.5rem;
position: relative;
border-radius: 6px;
padding: 0.75rem 0.5rem 0.5rem;
overflow: visible;
padding: 0.75rem 0 1rem;
}
.nc-window {
flex: 1;
overflow-x: hidden;
overflow-y: visible;
min-width: 0;
padding-top: 14px;
position: relative;
}
.nc-slide-wrapper {
display: flex;
will-change: transform;
}
.nextcard-card-img {
width: calc((100% - 5 * 0.6rem) / 6);
flex-shrink: 0;
width: 130px;
height: auto;
border-radius: 6px;
position: relative;
z-index: 1;
margin-right: var(--nc-gap, 6px);
transition: transform 0.15s, box-shadow 0.15s;
}
.nextcard-card-img:last-child { margin-right: 0; }
.nextcard-panel.drawable .nextcard-card-img:hover {
transform: translateY(-8px) scale(1.06);
transform: translateY(-10px) scale(1.08);
box-shadow: 0 8px 20px rgba(0,0,0,0.4);
z-index: 10;
cursor: pointer;
}
.nc-nav-btn {
flex-shrink: 0;
width: 48px;
background: var(--color-secondary);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
color: var(--color-text);
font-size: 0.72rem;
font-weight: 600;
padding: 0.5rem 0.2rem;
cursor: pointer;
text-align: center;
line-height: 1.4;
align-self: stretch;
display: none;
transition: background 0.15s;
}
.nc-nav-btn:hover { background: var(--color-primary); }
/* ── Touch-Karussell ── */
.nextcard-cards.carousel-mode {
overflow: hidden;
height: 210px;
padding: 1rem 0 1rem;
justify-content: center;
}
.carousel-card {
position: absolute;
width: 100px;
border-radius: 8px;
box-shadow: 2px 4px 14px rgba(0,0,0,0.55);
transition: transform 0.18s cubic-bezier(.4,0,.2,1), opacity 0.18s;
user-select: none;
-webkit-user-drag: none;
}
.carousel-card.pos-center { transform: translateX(0) scale(1.18); opacity: 1; z-index: 5; }
.carousel-card.pos-left { transform: translateX(-120px) scale(0.84); opacity: 0.72; z-index: 3; }
.carousel-card.pos-right { transform: translateX(120px) scale(0.84); opacity: 0.72; z-index: 3; }
.carousel-card.pos-far-left { transform: translateX(-210px) scale(0.68); opacity: 0.4; z-index: 1; }
.carousel-card.pos-far-right { transform: translateX(210px) scale(0.68); opacity: 0.4; z-index: 1; }
.carousel-card.pos-exit-left {
transform: translateX(-400px) scale(0.4) rotate(-15deg);
opacity: 0; z-index: 0;
transition: transform 0.32s ease-in, opacity 0.32s ease-in;
}
.carousel-card.pos-exit-right {
transform: translateX(400px) scale(0.4) rotate(15deg);
opacity: 0; z-index: 0;
transition: transform 0.32s ease-in, opacity 0.32s ease-in;
}
.nextcard-panel.drawable .carousel-card.pos-center { cursor: pointer; }
.nextcard-overlay {
position: absolute;
inset: 0;
@@ -595,6 +658,12 @@
<button class="btn-hygiene" id="hygieneBtn" style="display:none;" onclick="openHygieneModal()">🚿 Hygiene-Öffnung</button>
</div>
<!-- Speed-Effekt-Panel -->
<div id="speedPanel" style="display:none;background:var(--color-card);border:1px solid var(--color-secondary);border-radius:12px;padding:0.85rem 1.1rem;gap:0.35rem;">
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.06em;color:var(--color-muted);" id="speedPanelTitle">Slow Motion aktiv</div>
<div style="font-size:0.9rem;font-weight:600;" id="speedPanelInfo"></div>
</div>
<!-- Verifikations-Panel -->
<div class="verification-panel" id="verificationPanel" style="display:none;">
<div class="verification-panel-title">Tägliche Verifikation</div>
@@ -724,6 +793,12 @@
<span id="drawTaskPendingText"></span>
</div>
<!-- Speed-Karte: Zeitpunkt wählen -->
<div id="drawSpeedPicker" style="display:none;margin-top:0.75rem;padding:0.75rem 1rem;border-radius:8px;background:rgba(100,149,237,0.10);border:1px solid rgba(100,149,237,0.3);gap:0.6rem;text-align:center;">
<div style="font-size:0.88rem;color:var(--color-text);">Wähle den Zeitpunkt, bis zu dem der Effekt aktiv sein soll:</div>
<input type="datetime-local" id="drawSpeedUntilInput" style="background:var(--color-secondary);border:1px solid var(--color-secondary);border-radius:7px;padding:0.45rem 0.75rem;color:var(--color-text);font-size:0.9rem;width:100%;box-sizing:border-box;">
</div>
<!-- Grüne Karte: Entscheidung -->
<div class="draw-green-choice" id="drawGreenChoice">
<p id="drawGreenText" style="text-align:center;font-size:0.88rem;color:var(--color-muted);margin:0;">
@@ -735,6 +810,8 @@
<div class="draw-modal-actions" id="drawModalActions" style="display:none;">
<!-- Non-green: OK -->
<button class="btn-draw-ok" id="btnDrawOk" onclick="closeDrawModal()">OK</button>
<!-- Speed-Karte: Bestätigen -->
<button class="btn-draw-ok" id="btnSpeedConfirm" style="display:none;" onclick="confirmSpeedCard()">✓ Bestätigen</button>
<!-- Green: zwei Optionen -->
<button class="btn-draw-unlock" id="btnDrawUnlock" style="display:none;" onclick="confirmUnlock()">🔓 Entsperren</button>
<button class="btn-draw-keep" id="btnDrawKeep" style="display:none;" onclick="keepGreenCard()">Zurücklegen</button>
@@ -833,6 +910,7 @@
renderAssignedTasks(lock);
renderNextCardPanel(lock);
renderHygienePanel(lock);
renderSpeedPanel(lock);
renderVerificationPanel(lock);
renderTempOpeningPanel(lock);
renderCardsPanel(lock);
@@ -1095,17 +1173,14 @@
panel.style.display = '';
panel.classList.remove('drawable');
// Karten-Bilder rendern (Overlay bleibt erhalten)
cardsDiv.querySelectorAll('.nextcard-card-img').forEach(el => el.remove());
const total = lock.totalCards || 0;
const show = Math.min(total, 36);
for (let i = 0; i < show; i++) {
const img = document.createElement('img');
img.className = 'nextcard-card-img';
img.src = '/img/card.png';
img.alt = 'Karte';
cardsDiv.insertBefore(img, overlay);
}
// Karten-Darstellung aufbauen
if (ncResizeObs) { ncResizeObs.disconnect(); ncResizeObs = null; }
cardsDiv.querySelectorAll('.nc-nav-btn, .nc-window, .carousel-card').forEach(el => el.remove());
cardsDiv.classList.remove('carousel-mode');
const total = lock.totalCards || 0;
const isTouch = window.matchMedia('(hover: none) and (pointer: coarse)').matches;
if (isTouch) initCarousel(cardsDiv, overlay, total);
else initCardWindow(cardsDiv, overlay, total);
// Overlay-Elemente
const timerBox = document.getElementById('nextcardTimerBox');
@@ -1314,21 +1389,254 @@
// ── Karte ziehen ──
let drawnUnlockCode = null;
function enableCardClick() {
document.querySelectorAll('.nextcard-card-img').forEach(img => {
img.addEventListener('click', onCardClick, { once: true });
// ── Touch-Karussell ──
const CAROUSEL_POS = ['pos-far-left', 'pos-left', 'pos-center', 'pos-right', 'pos-far-right'];
let carouselCards = [];
let carouselShifting = false;
let carouselTouchX = 0;
let carouselTouchT = 0;
let carouselAbort = null;
let carouselDrawable = false;
function initCarousel(cardsDiv, overlay, total) {
if (carouselAbort) carouselAbort.abort();
carouselAbort = new AbortController();
const signal = carouselAbort.signal;
carouselCards = [];
carouselShifting = false;
carouselDrawable = false;
cardsDiv.classList.add('carousel-mode');
for (let i = 0; i < 5; i++) {
const img = document.createElement('img');
img.className = 'carousel-card ' + CAROUSEL_POS[i];
img.src = '/img/card.png';
img.alt = 'Karte';
cardsDiv.insertBefore(img, overlay);
carouselCards.push(img);
}
cardsDiv.addEventListener('touchstart', e => {
carouselTouchX = e.touches[0].clientX;
carouselTouchT = Date.now();
}, { passive: true, signal });
cardsDiv.addEventListener('touchend', e => {
if (carouselShifting) return;
const touch = e.changedTouches[0];
const dx = touch.clientX - carouselTouchX;
const dt = Date.now() - carouselTouchT;
const absDx = Math.abs(dx);
if (absDx < 5) {
// Tap: Element unter dem Finger bestimmen
const el = document.elementFromPoint(touch.clientX, touch.clientY);
const idx = el ? carouselCards.indexOf(el) : -1;
if (idx < 0) return;
if (idx === 2 && carouselDrawable) {
// Mittlere Karte → Karte ziehen
e.preventDefault();
carouselDrawable = false;
carouselCards.forEach(c => c.style.pointerEvents = 'none');
openDrawModal();
} else if (idx < 2) {
_doCarouselShift('right', 1);
} else {
_doCarouselShift('left', 1);
}
return;
}
if (!carouselDrawable) return;
// Swipe erkannt: Geschwindigkeit bestimmt wie viele Karten wechseln
const velocity = absDx / dt;
const steps = velocity > 1.2 ? 3 : velocity > 0.6 ? 2 : 1;
dx < 0 ? _doCarouselShift('left', steps) : _doCarouselShift('right', steps);
}, { passive: false, signal });
}
function shiftCarouselLeft(steps = 1) { _doCarouselShift('left', steps); }
function shiftCarouselRight(steps = 1) { _doCarouselShift('right', steps); }
function _doCarouselShift(dir, remaining) {
if (carouselShifting && remaining === /* first call */ remaining) {/* skip guard for chained */}
carouselShifting = true;
if (dir === 'left') {
carouselCards[0].className = 'carousel-card pos-exit-left';
for (let i = 1; i < 5; i++) carouselCards[i].className = 'carousel-card ' + CAROUSEL_POS[i - 1];
} else {
carouselCards[4].className = 'carousel-card pos-exit-right';
for (let i = 3; i >= 0; i--) carouselCards[i].className = 'carousel-card ' + CAROUSEL_POS[i + 1];
}
setTimeout(() => {
if (dir === 'left') {
const ex = carouselCards.shift();
ex.style.transition = 'none';
ex.className = 'carousel-card pos-exit-right';
ex.getBoundingClientRect();
ex.style.transition = '';
ex.className = 'carousel-card pos-far-right';
carouselCards.push(ex);
} else {
const ex = carouselCards.pop();
ex.style.transition = 'none';
ex.className = 'carousel-card pos-exit-left';
ex.getBoundingClientRect();
ex.style.transition = '';
ex.className = 'carousel-card pos-far-left';
carouselCards.unshift(ex);
}
if (remaining > 1) _doCarouselShift(dir, remaining - 1);
else carouselShifting = false;
}, 180);
}
// ── Karten-Fenster ──
let ncTotal = 0;
let ncWindowStart = 0;
let ncVisibleCount = 0;
let ncDrawable = false;
let ncWindowEl = null;
let ncBtnLeft = null;
let ncBtnRight = null;
let ncResizeObs = null;
function initCardWindow(cardsDiv, overlay, total) {
ncTotal = total;
ncDrawable = false;
ncWindowStart = 0;
if (total === 0) return;
ncBtnLeft = document.createElement('button');
ncBtnRight = document.createElement('button');
ncWindowEl = document.createElement('div');
const wrapper = document.createElement('div');
ncBtnLeft.className = 'nc-nav-btn';
ncBtnRight.className = 'nc-nav-btn';
ncWindowEl.className = 'nc-window';
wrapper.className = 'nc-slide-wrapper';
ncBtnLeft.addEventListener('click', () => scrollCardWindow(-1));
ncBtnRight.addEventListener('click', () => scrollCardWindow(1));
ncWindowEl.appendChild(wrapper);
cardsDiv.insertBefore(ncBtnLeft, overlay);
cardsDiv.insertBefore(ncWindowEl, overlay);
cardsDiv.insertBefore(ncBtnRight, overlay);
// Klick auf beliebige Karte → Karte ziehen (per Delegation)
ncWindowEl.addEventListener('click', e => {
if (!ncDrawable) return;
if (e.target.classList.contains('nextcard-card-img')) {
ncDrawable = false;
ncWindowEl.style.pointerEvents = 'none';
openDrawModal();
}
});
if (ncResizeObs) ncResizeObs.disconnect();
ncResizeObs = new ResizeObserver(() => recalcCardWindow());
ncResizeObs.observe(ncWindowEl);
requestAnimationFrame(() => recalcCardWindow(true));
}
function recalcCardWindow(initialCenter = false) {
const slots = Math.round(ncWindowEl.offsetWidth / 50);
const newCount = Math.min(ncTotal, Math.min(20, Math.max(3, slots)));
if (newCount === ncVisibleCount && !initialCenter) {
// Breite hat sich nicht verändert genug → nur Gap neu berechnen
renderCardWindow(null);
return;
}
ncVisibleCount = newCount;
if (initialCenter) {
ncWindowStart = Math.max(0, Math.floor(ncTotal / 2) - Math.floor(ncVisibleCount / 2));
} else {
// Fensterstart so anpassen, dass die Mitte des sichtbaren Bereichs gleich bleibt
const mid = ncWindowStart + Math.floor(ncVisibleCount / 2);
ncWindowStart = Math.max(0, Math.min(ncTotal - ncVisibleCount, mid - Math.floor(ncVisibleCount / 2)));
}
renderCardWindow(null);
}
function renderCardWindow(slideDir) {
const wrapper = ncWindowEl.querySelector('.nc-slide-wrapper');
const cardW = 130;
// Karten neu befüllen
wrapper.innerHTML = '';
for (let i = 0; i < ncVisibleCount; i++) {
const img = document.createElement('img');
img.className = 'nextcard-card-img';
img.src = '/img/card.png';
img.alt = 'Karte';
wrapper.appendChild(img);
}
// Dynamischer Overlap: alle sichtbaren Karten füllen die Fensterbreite
const windowW = ncWindowEl.offsetWidth;
let gap = ncVisibleCount <= 1 ? 6 : (windowW - ncVisibleCount * cardW) / (ncVisibleCount - 1);
gap = Math.max(-105, Math.min(12, gap));
ncWindowEl.style.setProperty('--nc-gap', gap + 'px');
// Animation: alte Karten raus, neue rein
if (slideDir) {
const oldWrapper = wrapper.cloneNode(true);
oldWrapper.style.cssText = 'position:absolute;top:0;left:0;width:100%;pointer-events:none;';
ncWindowEl.appendChild(oldWrapper);
// Neue Karten von der Seite einblenden
wrapper.style.transition = 'none';
wrapper.style.transform = `translateX(${slideDir > 0 ? '100%' : '-100%'})`;
wrapper.getBoundingClientRect();
wrapper.style.transition = 'transform 0.28s ease';
wrapper.style.transform = 'translateX(0)';
// Alte Karten zur anderen Seite ausblenden
oldWrapper.style.transition = 'transform 0.28s ease';
oldWrapper.style.transform = `translateX(${slideDir > 0 ? '-100%' : '100%'})`;
setTimeout(() => oldWrapper.remove(), 300);
}
// Nav-Buttons aktualisieren
const leftCount = ncWindowStart;
const rightCount = ncTotal - ncWindowStart - ncVisibleCount;
ncBtnLeft.style.display = leftCount > 0 ? 'block' : 'none';
ncBtnRight.style.display = rightCount > 0 ? 'block' : 'none';
ncBtnLeft.textContent = `\n${leftCount}`;
ncBtnRight.textContent = `${rightCount}\n`;
ncWindowEl.style.pointerEvents = '';
}
function scrollCardWindow(dir) {
const step = Math.max(1, Math.floor(ncVisibleCount / 2));
if (dir < 0) ncWindowStart = Math.max(0, ncWindowStart - step);
else ncWindowStart = Math.min(ncTotal - ncVisibleCount, ncWindowStart + step);
renderCardWindow(dir);
}
function enableCardClick() {
if (carouselCards.length > 0) {
carouselDrawable = true;
} else if (ncWindowEl) {
ncDrawable = true;
}
}
async function onCardClick() {
// Alle weiteren Klicks blockieren
document.querySelectorAll('.nextcard-card-img').forEach(img => {
img.removeEventListener('click', onCardClick);
img.style.pointerEvents = 'none';
});
carouselDrawable = false;
carouselCards.forEach(c => c.style.pointerEvents = 'none');
if (ncWindowEl) ncWindowEl.style.pointerEvents = 'none';
document.querySelectorAll('.nextcard-card-img').forEach(img => img.style.pointerEvents = 'none');
openDrawModal();
}
let _pendingSpeedMode = null;
function openDrawModal() {
const modal = document.getElementById('drawModal');
const inner = document.getElementById('flipCardInner');
@@ -1343,11 +1651,14 @@
document.getElementById('drawGreenText').style.display = '';
document.getElementById('drawUnlockCode').style.display = 'none';
document.getElementById('drawTaskPendingHint').style.display = 'none';
document.getElementById('drawSpeedPicker').style.display = 'none';
document.getElementById('btnDrawOk').style.display = '';
document.getElementById('btnSpeedConfirm').style.display = 'none';
document.getElementById('btnDrawUnlock').style.display = 'none';
document.getElementById('btnDrawKeep').style.display = 'none';
actions.style.display = 'none';
drawnUnlockCode = null;
_pendingSpeedMode = null;
modal.classList.add('open');
// Karte serverseitig ziehen
@@ -1386,6 +1697,20 @@
document.getElementById('btnDrawUnlock').style.display = '';
document.getElementById('btnDrawKeep').style.display = '';
}
if (dto.card === 'SLOWMO_CARD' || dto.card === 'SPEEDUP_CARD') {
_pendingSpeedMode = dto.card === 'SLOWMO_CARD' ? 'SLOWMO' : 'SPEEDUP';
const picker = document.getElementById('drawSpeedPicker');
const input = document.getElementById('drawSpeedUntilInput');
// Minimum: jetzt + 1 Stunde, Standardwert: jetzt + 24 Stunden
const minDate = new Date(Date.now() + 60 * 60 * 1000);
const defDate = new Date(Date.now() + 24 * 60 * 60 * 1000);
input.min = toLocalDatetimeInputValue(minDate);
input.value = toLocalDatetimeInputValue(defDate);
picker.style.display = 'flex';
document.getElementById('btnDrawOk').style.display = 'none';
document.getElementById('btnSpeedConfirm').style.display = '';
}
}, 700);
}, 1000);
})
@@ -1418,6 +1743,56 @@
closeDrawModal();
}
function toLocalDatetimeInputValue(date) {
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
async function confirmSpeedCard() {
if (!_pendingSpeedMode) return;
const input = document.getElementById('drawSpeedUntilInput');
if (!input.value) { alert('Bitte wähle einen Zeitpunkt.'); return; }
const until = new Date(input.value);
if (until <= new Date()) { alert('Der Zeitpunkt muss in der Zukunft liegen.'); return; }
const isoUntil = `${input.value}:00`; // datetime-local hat kein Sekunden-Teil
const res = await fetch('/keyholder/cardlock/' + lockId + '/speed/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: _pendingSpeedMode, until: isoUntil })
});
if (!res.ok) { alert('Fehler beim Aktivieren des Speed-Effekts.'); return; }
closeDrawModal();
}
let speedPanelTick = null;
function renderSpeedPanel(lock) {
if (speedPanelTick) { clearInterval(speedPanelTick); speedPanelTick = null; }
const panel = document.getElementById('speedPanel');
const now = new Date();
const slowmoUntil = lock.slowmoUntil ? new Date(lock.slowmoUntil) : null;
const speedupUntil = lock.speedupUntil ? new Date(lock.speedupUntil) : null;
const active = (slowmoUntil && slowmoUntil > now) ? { mode: 'slowmo', until: slowmoUntil }
: (speedupUntil && speedupUntil > now) ? { mode: 'speedup', until: speedupUntil }
: null;
if (!active) { panel.style.display = 'none'; return; }
panel.style.display = 'flex';
document.getElementById('speedPanelTitle').textContent =
active.mode === 'slowmo' ? '🐢 Slow Motion aktiv' : '⚡ Speed Up aktiv';
function tickSpeed() {
const diff = active.until - Date.now();
if (diff <= 0) {
panel.style.display = 'none';
clearInterval(speedPanelTick); speedPanelTick = null;
return;
}
document.getElementById('speedPanelInfo').textContent =
(active.mode === 'slowmo' ? 'Aktionen dauern 4× so lange noch ' : 'Aktionen dauern 4× so kurz noch ') + fmtCountdown(diff);
}
tickSpeed();
speedPanelTick = setInterval(tickSpeed, 1000);
}
// ── Hygiene-Öffnung ──
let hygieneTickInterval = null;

View File

@@ -76,7 +76,7 @@
/* ── Detail-Modal ── */
.detail-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.65); z-index: 400;
background: rgba(0,0,0,0.65); z-index: 500;
align-items: flex-start; justify-content: center;
padding: 2rem 1rem; overflow-y: auto;
}

File diff suppressed because it is too large Load Diff