Verschiebung nach anderem RePo - nun pro Projekt getrennt

This commit is contained in:
2026-04-01 10:41:19 +02:00
commit 7b9eda1d62
1048 changed files with 93351 additions and 0 deletions

View File

@@ -0,0 +1,642 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/img/icon.png" type="image/png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Toys xXx Sphere</title>
<link rel="stylesheet" href="/css/variables.css">
<link rel="stylesheet" href="/css/style.css">
<style>
/* ── Section ── */
.section + .section { margin-top: 2.5rem; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-secondary);
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-primary);
margin: 0;
}
.btn-add {
display: flex;
align-items: center;
gap: 0.4rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-add:hover { background: #c73652; }
/* ── Toy grid ── */
.toy-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
gap: 0.85rem;
}
/* ── Toy card ── */
.toy-card {
display: flex;
align-items: center;
gap: 0.85rem;
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 10px;
padding: 0.8rem 0.9rem;
transition: border-color 0.15s;
position: relative;
}
.toy-card { cursor: pointer; }
.toy-card:hover { border-color: var(--color-primary); }
.toy-card.selected {
border-color: var(--color-primary);
background: rgba(233,69,96,0.06);
}
.toy-img {
width: 52px; height: 52px;
border-radius: 7px;
object-fit: cover;
flex-shrink: 0;
}
.toy-img-placeholder {
width: 52px; height: 52px;
border-radius: 7px;
background: var(--color-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
color: var(--color-muted);
}
.toy-info { flex: 1; min-width: 0; }
.toy-name {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toy-desc {
font-size: 0.78rem;
color: var(--color-muted);
margin-top: 0.2rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Section action buttons ── */
.section-actions { display: flex; align-items: center; gap: 0.5rem; }
.btn-action {
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s, opacity 0.15s;
}
.btn-action:disabled { opacity: 0.35; cursor: default; }
.btn-action:not(:disabled):hover { background: var(--color-primary); color: #fff; }
.btn-action-danger:not(:disabled):hover { background: rgba(233,69,96,0.18); color: var(--color-primary); }
.action-error {
font-size: 0.82rem;
color: var(--color-primary);
min-height: 1.1em;
margin-bottom: 0.4rem;
}
/* ── Empty / Loading ── */
.empty, .loading { color: var(--color-muted); font-size: 0.9rem; padding: 0.75rem 0; }
/* ── Inline-Fehler im Grid ── */
.grid-error {
font-size: 0.85rem;
color: var(--color-primary);
padding: 0.5rem 0;
}
/* ── Modal ── */
.modal-backdrop {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-backdrop.open { display: flex; }
.modal {
background: var(--color-card);
border: 1px solid var(--color-secondary);
border-radius: 12px;
padding: 2rem;
width: 100%;
max-width: 420px;
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.modal h2 {
color: var(--color-primary);
font-size: 1.1rem;
margin-bottom: 1.25rem;
}
.modal label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-top: 1rem;
margin-bottom: 0.3rem;
}
.modal input[type="text"],
.modal textarea {
width: 100%;
padding: 0.6rem 0.85rem;
border: 1px solid var(--color-secondary);
border-radius: 6px;
background: var(--color-secondary);
color: var(--color-text);
font-size: 0.95rem;
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.modal input[type="text"]:focus,
.modal textarea:focus { border-color: var(--color-primary); }
.modal input[type="file"] {
font-size: 0.85rem;
color: var(--color-muted);
margin-top: 0.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
.modal-actions .btn-cancel {
background: var(--color-secondary);
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.modal-actions .btn-cancel:hover { background: #1a4a8a; }
.modal-actions .btn-save {
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.modal-actions .btn-save:hover { background: #c73652; }
.modal-actions .btn-save:disabled { opacity: 0.5; cursor: default; }
.modal-error {
color: var(--color-primary);
font-size: 0.82rem;
margin-top: 0.75rem;
display: none;
}
@media (max-width: 768px) {
.toy-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body class="app">
<!-- Erstell-/Bearbeitungs-Modal -->
<div class="modal-backdrop" id="createModal">
<div class="modal">
<h2 id="modalTitle">Neues Toy</h2>
<label for="toyName">Name *</label>
<input type="text" id="toyName" placeholder="z.B. Vibrator" maxlength="100">
<label for="toyDesc">Beschreibung</label>
<textarea id="toyDesc" rows="3" placeholder="Kurze Beschreibung…" maxlength="500"></textarea>
<label>Bild (optional)</label>
<div id="currentImageWrap" style="display:none; align-items:center; gap:0.5rem; margin-bottom:0.4rem;">
<img id="currentImage" style="max-width:64px; max-height:64px; border-radius:6px;" src="" alt="">
<span style="font-size:0.78rem; color:var(--color-muted);">Aktuelles Bild neues Bild wählen zum Ersetzen</span>
</div>
<input type="file" id="toyBild" accept="image/*">
<div class="modal-error" id="modalError"></div>
<div class="modal-actions">
<button class="btn-cancel" id="cancelBtn">Abbrechen</button>
<button class="btn-save" id="saveBtn">Speichern</button>
</div>
</div>
</div>
<div class="main">
<div class="content">
<!-- Meine Toys -->
<div class="section">
<div class="section-header">
<h2 class="section-title">Meine Toys</h2>
<div class="section-actions">
<button class="btn-action" id="editBtn" disabled>✎ Bearbeiten</button>
<button class="btn-action btn-action-danger" id="deleteBtn" disabled>✕ Löschen</button>
<button class="btn-add" id="openCreateBtn">+ Neu</button>
</div>
</div>
<div class="action-error" id="actionError"></div>
<div class="toy-grid" id="userGrid"></div>
<div id="userLoading" class="loading" style="display:none;"></div>
<div id="userSentinel"></div>
</div>
<!-- System-Toys -->
<div class="section">
<div class="section-header">
<h2 class="section-title">System-Toys</h2>
<div class="section-actions">
<button class="btn-action" id="copyBtn" disabled>⊕ In meine Toys kopieren</button>
</div>
</div>
<div class="action-error" id="systemActionError"></div>
<div class="toy-grid" id="systemGrid"></div>
<div id="systemLoading" class="loading" style="display:none;"></div>
<div id="systemSentinel"></div>
</div>
</div>
</div>
<script src="/js/icons.js"></script>
<script src="/js/sidebar.js"></script>
<script>
const PAGE_SIZE = 12;
let userPage = 0, userTotalPages = 1, userLoading = false;
let systemPage = 0, systemTotalPages = 1, systemLoading = false;
// ── Infinite-scroll observers ──
const userObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadUserPage();
}, { rootMargin: '200px' });
const systemObserver = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadSystemPage();
}, { rootMargin: '200px' });
// ── Auth + initial load ──
fetch('/login/me')
.then(r => { if (r.status === 401) { window.location.href = '/login.html'; return null; } return r.ok ? r.json() : null; })
.then(user => {
if (!user) return;
userObserver.observe(document.getElementById('userSentinel'));
systemObserver.observe(document.getElementById('systemSentinel'));
})
.catch(() => { window.location.href = '/login.html'; });
// ── Load user toys (append, füllt Viewport automatisch auf) ──
async function loadUserPage() {
if (userLoading || userPage >= userTotalPages) return;
userLoading = true;
const loadEl = document.getElementById('userLoading');
try {
do {
loadEl.textContent = 'Wird geladen…';
loadEl.style.display = 'block';
const r = await fetch(`/toy/list/user?page=${userPage}&size=${PAGE_SIZE}`);
const data = await r.json();
userTotalPages = data.totalPages || 1;
appendGrid('userGrid', data.content, 'selectToy');
userPage++;
loadEl.style.display = 'none';
} while (userPage < userTotalPages && sentinelVisible('userSentinel'));
} catch (_) {
loadEl.textContent = 'Fehler beim Laden.';
} finally {
userLoading = false;
}
}
function reloadUserToys() {
userPage = 0;
userTotalPages = 1;
resetSelection();
document.getElementById('userGrid').innerHTML = '';
loadUserPage();
}
// ── Load system toys (append, füllt Viewport automatisch auf) ──
async function loadSystemPage() {
if (systemLoading || systemPage >= systemTotalPages) return;
systemLoading = true;
const loadEl = document.getElementById('systemLoading');
try {
do {
loadEl.textContent = 'Wird geladen…';
loadEl.style.display = 'block';
const r = await fetch(`/toy/list/system?page=${systemPage}&size=${PAGE_SIZE}`);
const data = await r.json();
systemTotalPages = data.totalPages || 1;
appendGrid('systemGrid', data.content, 'selectSystemToy');
systemPage++;
loadEl.style.display = 'none';
} while (systemPage < systemTotalPages && sentinelVisible('systemSentinel'));
} catch (_) {
loadEl.textContent = 'Fehler beim Laden.';
} finally {
systemLoading = false;
}
}
function reloadSystemToys() {
systemPage = 0;
systemTotalPages = 1;
resetSystemSelection();
document.getElementById('systemGrid').innerHTML = '';
loadSystemPage();
}
// ── Prüft ob ein Sentinel noch im (erweiterten) Viewport liegt ──
function sentinelVisible(id) {
const el = document.getElementById(id);
return el ? el.getBoundingClientRect().top <= window.innerHeight + 200 : false;
}
// ── Append items to a grid ──
function appendGrid(gridId, toys, selectFn) {
const grid = document.getElementById(gridId);
if (!toys || toys.length === 0) {
if (!grid.querySelector('.toy-card')) {
grid.innerHTML = '<p class="empty">Keine Einträge vorhanden.</p>';
}
return;
}
const emptyEl = grid.querySelector('.empty');
if (emptyEl) emptyEl.remove();
grid.insertAdjacentHTML('beforeend', toys.map(toy => `
<div class="toy-card" data-id="${esc(toy.toyId)}"
${selectFn ? `onclick="${selectFn}('${esc(toy.toyId)}')"` : ''}>
${toy.bild
? `<img class="toy-img" src="data:image/png;base64,${toy.bild}" alt="${esc(toy.name)}">`
: `<div class="toy-img-placeholder">◈</div>`}
<div class="toy-info">
<div class="toy-name">${esc(toy.name)}</div>
${toy.beschreibung ? `<div class="toy-desc">${esc(toy.beschreibung)}</div>` : ''}
</div>
</div>
`).join(''));
}
// ── Selection ──
let selectedUserToyId = null;
function selectToy(toyId) {
const prev = document.querySelector('#userGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
if (selectedUserToyId === toyId) {
selectedUserToyId = null;
} else {
selectedUserToyId = toyId;
document.querySelector(`#userGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
}
const has = selectedUserToyId != null;
document.getElementById('editBtn').disabled = !has;
document.getElementById('deleteBtn').disabled = !has;
document.getElementById('actionError').textContent = '';
}
function resetSelection() {
selectedUserToyId = null;
document.getElementById('editBtn').disabled = true;
document.getElementById('deleteBtn').disabled = true;
document.getElementById('actionError').textContent = '';
}
// ── System-Toy selection ──
let selectedSystemToyId = null;
function selectSystemToy(toyId) {
const prev = document.querySelector('#systemGrid .toy-card.selected');
if (prev) prev.classList.remove('selected');
if (selectedSystemToyId === toyId) {
selectedSystemToyId = null;
} else {
selectedSystemToyId = toyId;
document.querySelector(`#systemGrid .toy-card[data-id="${toyId}"]`).classList.add('selected');
}
document.getElementById('copyBtn').disabled = selectedSystemToyId == null;
document.getElementById('systemActionError').textContent = '';
}
function resetSystemSelection() {
selectedSystemToyId = null;
document.getElementById('copyBtn').disabled = true;
document.getElementById('systemActionError').textContent = '';
}
// ── Copy system toy ──
document.getElementById('copyBtn').addEventListener('click', () => {
if (!selectedSystemToyId) return;
const btn = document.getElementById('copyBtn');
btn.disabled = true;
fetch(`/toy/copy/${selectedSystemToyId}`, { method: 'POST' })
.then(r => {
if (r.ok || r.status === 201) {
reloadUserToys();
document.getElementById('systemActionError').textContent = '';
} else if (r.status === 409 && r.headers.get('X-Error') === 'duplicate-name') {
document.getElementById('systemActionError').textContent =
'Du hast bereits ein Toy mit diesem Namen.';
btn.disabled = false;
} else {
document.getElementById('systemActionError').textContent =
'Fehler beim Kopieren (HTTP ' + r.status + ').';
btn.disabled = false;
}
})
.catch(() => {
document.getElementById('systemActionError').textContent = 'Verbindungsfehler.';
btn.disabled = false;
});
});
// ── Header action buttons ──
document.getElementById('editBtn').addEventListener('click', () => {
if (selectedUserToyId) openModal(selectedUserToyId);
});
document.getElementById('deleteBtn').addEventListener('click', () => {
if (!selectedUserToyId) return;
if (!confirm('Toy wirklich löschen?')) return;
const btn = document.getElementById('deleteBtn');
btn.disabled = true;
const toyId = selectedUserToyId;
fetch(`/toy/${toyId}`, { method: 'DELETE' })
.then(r => {
if (r.status === 409) {
showActionError('Wird in Aufgaben verwendet nicht löschbar.');
btn.disabled = false;
} else if (r.status === 403) {
showActionError('Keine Berechtigung.');
btn.disabled = false;
} else if (r.ok || r.status === 202) {
reloadUserToys();
} else {
showActionError('Fehler beim Löschen.');
btn.disabled = false;
}
})
.catch(() => { showActionError('Verbindungsfehler.'); btn.disabled = false; });
});
function showActionError(msg) {
const el = document.getElementById('actionError');
el.textContent = msg;
setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 4000);
}
// ── Create / Edit modal ──
const modal = document.getElementById('createModal');
const saveBtn = document.getElementById('saveBtn');
let currentEditId = null;
function openModal(editId) {
currentEditId = editId || null;
document.getElementById('modalError').style.display = 'none';
document.getElementById('toyBild').value = '';
if (currentEditId) {
fetch(`/toy/${currentEditId}`)
.then(r => r.ok ? r.json() : null)
.then(toy => {
if (!toy) return;
document.getElementById('modalTitle').textContent = 'Toy bearbeiten';
document.getElementById('toyName').value = toy.name || '';
document.getElementById('toyDesc').value = toy.beschreibung || '';
const imgWrap = document.getElementById('currentImageWrap');
if (toy.bild) {
document.getElementById('currentImage').src = 'data:image/png;base64,' + toy.bild;
imgWrap.style.display = 'flex';
} else {
imgWrap.style.display = 'none';
}
modal.classList.add('open');
document.getElementById('toyName').focus();
})
.catch(() => alert('Fehler beim Laden des Toys.'));
} else {
document.getElementById('modalTitle').textContent = 'Neues Toy';
document.getElementById('toyName').value = '';
document.getElementById('toyDesc').value = '';
document.getElementById('currentImageWrap').style.display = 'none';
modal.classList.add('open');
document.getElementById('toyName').focus();
}
}
document.getElementById('openCreateBtn').addEventListener('click', () => openModal(null));
document.getElementById('cancelBtn').addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
function closeModal() { modal.classList.remove('open'); }
function editToy(toyId) { openModal(toyId); }
saveBtn.addEventListener('click', async () => {
const name = document.getElementById('toyName').value.trim();
if (!name) {
showModalError('Bitte einen Namen eingeben.');
return;
}
saveBtn.disabled = true;
saveBtn.textContent = 'Speichert…';
let bildBase64 = null;
const fileInput = document.getElementById('toyBild');
if (fileInput.files.length > 0) {
bildBase64 = await toBase64(fileInput.files[0]);
}
const payload = {
name,
beschreibung: document.getElementById('toyDesc').value.trim() || null,
bild: bildBase64
};
const isEdit = currentEditId != null;
fetch(isEdit ? `/toy/${currentEditId}` : '/toy', {
method: isEdit ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(r => {
if (r.ok || r.status === 201) {
closeModal();
reloadUserToys();
} else if (r.status === 409 && r.headers.get('X-Error') === 'duplicate-name') {
showModalError('Ein Toy mit diesem Namen existiert bereits.');
} else {
showModalError('Fehler beim Speichern (HTTP ' + r.status + ').');
}
})
.catch(() => showModalError('Verbindungsfehler.'))
.finally(() => { saveBtn.disabled = false; saveBtn.textContent = 'Speichern'; });
});
function showModalError(msg) {
const el = document.getElementById('modalError');
el.textContent = msg;
el.style.display = 'block';
}
function toBase64(file) {
const MAX = 128;
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.naturalWidth, h = img.naturalHeight;
if (w > MAX || h > MAX) {
if (w >= h) { h = Math.max(1, Math.round(MAX * h / w)); w = MAX; }
else { w = Math.max(1, Math.round(MAX * w / h)); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
resolve(canvas.toDataURL('image/png').split(',')[1]);
};
img.onerror = reject;
img.src = url;
});
}
// ── XSS-Schutz ──
function esc(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
</script>
</body>
</html>