Files
bordervillegame/admin_clothing.html
T
2026-08-22 08:40:29 +02:00

447 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Kleidungs-Editor</title>
<style>
body {
margin: 0;
background: #111;
color: #eee;
font-family: Arial, sans-serif;
padding: 20px;
}
h1 { margin-top: 0; }
a.back { color: #6fbf73; text-decoration: none; font-size: 14px; }
a.back:hover { text-decoration: underline; }
.panel {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #333; font-size: 14px; }
th { color: #aaa; font-weight: normal; }
input, select {
background: #222; border: 1px solid #444; color: #eee;
padding: 6px 8px; border-radius: 4px; font-size: 14px;
}
button {
background: #2c7a3d; border: none; color: white;
padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 14px;
}
button.danger { background: #a83232; }
button.secondary { background: #555; }
button:hover { opacity: 0.85; }
.form-grid { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; margin-top: 10px; }
.swatch { display: inline-block; width: 20px; height: 20px; border-radius: 3px; vertical-align: middle; border: 1px solid #555; }
.msg { margin-top: 10px; font-size: 13px; min-height: 18px; }
.slot-tabs { display: flex; gap: 6px; margin-bottom: 12px; }
.slot-tab-btn {
background: #222; border: 1px solid #444; color: #ccc;
padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 13px;
}
.slot-tab-btn.active { background: #2c7a3d; border-color: #2c7a3d; color: white; }
#pixelCanvas { border: 1px solid #444; cursor: crosshair; image-rendering: pixelated; }
</style>
</head>
<body>
<a class="back" href="/admin.html">&larr; Zurück zum Admin-Menü</a>
<h1>👕 Kleidungs-Editor</h1>
<p style="color:#888;">Male Kleidungsstücke, die Spieler im Kleidungsladen kaufen können. Jeder Slot hat eine
feste Größe passend zur In-Game-Darstellung (Oberteil 20×9, Hose 20×7, Schuhe 20×4 Pixel).</p>
<div class="panel">
<h3 style="margin-top:0;">Vorhandene Kleidungsstücke</h3>
<div class="slot-tabs">
<button class="slot-tab-btn active" data-filter-slot="shirt">👕 Oberteile</button>
<button class="slot-tab-btn" data-filter-slot="pants">👖 Hosen</button>
<button class="slot-tab-btn" data-filter-slot="shoes">👟 Schuhe</button>
<button class="slot-tab-btn" data-filter-slot="helmet">⛑️ Helme</button>
</div>
<table>
<thead><tr><th>Vorschau</th><th>Name</th><th>Preis</th><th>Aktionen</th></tr></thead>
<tbody id="itemsTableBody"></tbody>
</table>
</div>
<div class="panel">
<h3 id="formTitle" style="margin-top:0;">Neues Kleidungsstück</h3>
<div class="form-grid">
<label>Slot:
<select id="itemSlot">
<option value="shirt">Oberteil</option>
<option value="pants">Hose</option>
<option value="shoes">Schuhe</option>
<option value="helmet">Helm</option>
</select>
</label>
<label>Name: <input type="text" id="itemName" placeholder="z.B. Rotes T-Shirt"></label>
<label>Preis: <input type="number" id="itemPrice" value="150" min="0" style="width:80px;"> $</label>
<label>Grundfarbe: <input type="color" id="itemColor" value="#3498db"></label>
</div>
<div style="display:flex; gap:20px; align-items:flex-start; flex-wrap:wrap; margin-top:16px;">
<div>
<canvas id="pixelCanvas" width="400" height="180"></canvas>
</div>
<div style="display:flex; flex-direction:column; gap:8px; min-width:180px;">
<label style="font-size:12px; color:#999;">Malfarbe</label>
<input id="pixelColor" type="color" value="#ffffff" style="width:60px; height:36px; padding:2px; cursor:pointer;">
<button id="eraserBtn" class="secondary" onclick="toggleEraser()" style="font-size:11px;">🧹 Radiergummi (transparent)</button>
<div style="display:flex; gap:4px; flex-wrap:wrap; margin-top:4px;">
<button class="secondary" onclick="presetStripes()" style="font-size:11px;">Streifen</button>
<button class="secondary" onclick="presetBorder()" style="font-size:11px;">Rand</button>
</div>
<button class="secondary" onclick="clearPixelCanvas()" style="margin-top:8px;">Löschen (Grundfarbe)</button>
<button onclick="saveClothingItem()" style="background:#2c7a3d;">Speichern</button>
<button class="secondary" onclick="resetForm()">Neues Kleidungsstück</button>
<div class="msg" id="formMsg"></div>
</div>
</div>
</div>
<script>
// -------------------------------------------------------------
// ZUGRIFFSSCHUTZ
// -------------------------------------------------------------
const token = localStorage.getItem("token");
// Voll-Admin ODER passende Gruppen-Berechtigung reicht jetzt aus - der
// Server prüft das bei jedem Aufruf ohnehin final ab (siehe requireAdmin);
// hier reicht ein simpler Login-Check, authFetch() fängt fehlende Rechte
// beim ersten echten API-Aufruf sauber ab (Meldung + Weiterleitung)
let isAdmin = localStorage.getItem("isAdmin") === "true";
if (!token) {
alert("Bitte zuerst einloggen.");
location.href = "/index.html";
}
async function authFetch(url, options = {}) {
options.headers = { ...(options.headers || {}), "Authorization": "Bearer " + token };
const res = await fetch(url, options);
if (res.status === 401 || res.status === 403) {
alert("Sitzung abgelaufen oder kein Admin-Zugriff. Bitte erneut einloggen.");
location.href = "/index.html";
throw new Error("Nicht autorisiert");
}
return res;
}
// -------------------------------------------------------------
// SLOT-GRÖSSEN (müssen zur Darstellung in game.js passen)
// -------------------------------------------------------------
const SLOT_SIZES = {
shirt: { w: 20, h: 9 },
pants: { w: 20, h: 7 },
shoes: { w: 20, h: 4 },
helmet: { w: 20, h: 20 }
};
let allItems = [];
let editingId = null;
let activeFilterSlot = "shirt";
async function loadClothingItems() {
const res = await authFetch("/api/admin/clothing_items");
const data = await res.json();
allItems = data.items || [];
renderItemsTable();
}
function renderItemsTable() {
const tbody = document.getElementById("itemsTableBody");
tbody.innerHTML = "";
allItems.filter(i => i.slot === activeFilterSlot).forEach(item => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><span class="swatch" style="background:${item.color};"></span>${item.image_data ? " 🎨" : ""}</td>
<td>${escapeHtml(item.name)}</td>
<td>${item.price}$</td>
<td>
<button onclick="editItem(${item.id})">Bearbeiten</button>
<button class="danger" onclick="deleteItem(${item.id})">Löschen</button>
</td>
`;
tbody.appendChild(tr);
});
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
document.querySelectorAll(".slot-tab-btn").forEach(btn => {
btn.addEventListener("click", () => {
document.querySelectorAll(".slot-tab-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
activeFilterSlot = btn.dataset.filterSlot;
renderItemsTable();
});
});
function editItem(id) {
const item = allItems.find(i => i.id === id);
if (!item) return;
editingId = id;
document.getElementById("formTitle").textContent = "Kleidungsstück bearbeiten: " + item.name;
document.getElementById("itemSlot").value = item.slot;
document.getElementById("itemName").value = item.name;
document.getElementById("itemColor").value = item.color;
document.getElementById("itemPrice").value = item.price;
rebuildPixelGrid();
if (item.image_data) loadTextureIntoGrid(item.image_data);
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
}
function resetForm() {
editingId = null;
document.getElementById("formTitle").textContent = "Neues Kleidungsstück";
document.getElementById("itemName").value = "";
document.getElementById("itemColor").value = "#3498db";
document.getElementById("itemPrice").value = "150";
rebuildPixelGrid();
}
async function deleteItem(id) {
if (!confirm("Dieses Kleidungsstück wirklich löschen?")) return;
const res = await authFetch("/api/admin/clothing_items/" + id, { method: "DELETE" });
const data = await res.json();
if (data.ok) {
showMsg("Gelöscht.");
await loadClothingItems();
if (editingId === id) resetForm();
} else {
showMsg("Löschen fehlgeschlagen.", true);
}
}
function showMsg(text, isError) {
const el = document.getElementById("formMsg");
el.style.color = isError ? "#e06c6c" : "#6fbf73";
el.textContent = text;
setTimeout(() => { el.textContent = ""; }, 4000);
}
// -------------------------------------------------------------
// PIXEL-EDITOR
// -------------------------------------------------------------
const pixelCanvas = document.getElementById("pixelCanvas");
const pctx = pixelCanvas.getContext("2d");
const DISPLAY_MAX_W = 400;
const DISPLAY_MAX_H = 220;
let gridW = 20, gridH = 9;
let pixelGrid = [];
let cellPx = 8;
let isPainting = false;
function fillGridWithColor(color) {
pixelGrid = [];
for (let y = 0; y < gridH; y++) {
const row = [];
for (let x = 0; x < gridW; x++) row.push(color);
pixelGrid.push(row);
}
}
function fillGridTransparent() {
pixelGrid = [];
for (let y = 0; y < gridH; y++) {
const row = [];
for (let x = 0; x < gridW; x++) row.push(null);
pixelGrid.push(row);
}
}
function rebuildPixelGrid() {
const slot = document.getElementById("itemSlot").value;
const size = SLOT_SIZES[slot];
gridW = size.w;
gridH = size.h;
cellPx = Math.min(DISPLAY_MAX_W / gridW, DISPLAY_MAX_H / gridH, 30);
pixelCanvas.width = gridW * cellPx;
pixelCanvas.height = gridH * cellPx;
// Helme starten komplett transparent (sitzen nur über dem Kopf, nicht der
// ganzen Figur) - alle anderen Slots wie bisher komplett mit Grundfarbe gefüllt
if (slot === "helmet") fillGridTransparent();
else fillGridWithColor(document.getElementById("itemColor").value);
redrawPixelCanvas();
}
rebuildPixelGrid();
function redrawPixelCanvas() {
pctx.clearRect(0, 0, pixelCanvas.width, pixelCanvas.height);
for (let y = 0; y < gridH; y++) {
for (let x = 0; x < gridW; x++) {
const cell = pixelGrid[y][x];
if (cell === null) {
// Transparente Zelle: Schachbrett-Muster zur Orientierung, statt einfach schwarz
pctx.fillStyle = ((x + y) % 2 === 0) ? "#3a3a3a" : "#2a2a2a";
pctx.fillRect(x * cellPx, y * cellPx, cellPx, cellPx);
} else {
pctx.fillStyle = cell;
pctx.fillRect(x * cellPx, y * cellPx, cellPx, cellPx);
}
}
}
}
function loadTextureIntoGrid(dataUrl) {
const img = new Image();
img.onload = () => {
const off = document.createElement("canvas");
off.width = gridW;
off.height = gridH;
const offCtx = off.getContext("2d");
offCtx.clearRect(0, 0, gridW, gridH);
offCtx.drawImage(img, 0, 0, gridW, gridH);
const data = offCtx.getImageData(0, 0, gridW, gridH).data;
for (let y = 0; y < gridH; y++) {
for (let x = 0; x < gridW; x++) {
const i = (y * gridW + x) * 4;
const alpha = data[i + 3];
pixelGrid[y][x] = alpha < 10 ? null : `rgb(${data[i]},${data[i + 1]},${data[i + 2]})`;
}
}
redrawPixelCanvas();
};
img.src = dataUrl;
}
let eraserActive = false;
function paintAt(clientX, clientY) {
const rect = pixelCanvas.getBoundingClientRect();
const x = Math.floor((clientX - rect.left) / (rect.width / gridW));
const y = Math.floor((clientY - rect.top) / (rect.height / gridH));
if (x < 0 || y < 0 || x >= gridW || y >= gridH) return;
pixelGrid[y][x] = eraserActive ? null : document.getElementById("pixelColor").value;
redrawPixelCanvas();
}
pixelCanvas.addEventListener("mousedown", e => { isPainting = true; paintAt(e.clientX, e.clientY); });
pixelCanvas.addEventListener("mousemove", e => { if (isPainting) paintAt(e.clientX, e.clientY); });
window.addEventListener("mouseup", () => { isPainting = false; });
function toggleEraser() {
eraserActive = !eraserActive;
document.getElementById("eraserBtn").style.background = eraserActive ? "#a83232" : "#555";
}
function clearPixelCanvas() {
const slot = document.getElementById("itemSlot").value;
if (slot === "helmet") fillGridTransparent();
else fillGridWithColor(document.getElementById("itemColor").value);
redrawPixelCanvas();
}
document.getElementById("itemSlot").addEventListener("change", () => {
if (editingId === null) rebuildPixelGrid();
else alert("Der Slot eines bestehenden Kleidungsstücks kann nicht geändert werden - bitte ein neues anlegen.");
});
document.getElementById("itemColor").addEventListener("input", () => {
// Grundfarbe ändert nur die Vorschau im Formular, nicht rückwirkend das Raster
});
function presetStripes() {
const color = document.getElementById("pixelColor").value;
for (let y = 0; y < gridH; y += 2) {
for (let x = 0; x < gridW; x++) pixelGrid[y][x] = color;
}
redrawPixelCanvas();
}
function presetBorder() {
const color = document.getElementById("pixelColor").value;
for (let x = 0; x < gridW; x++) {
pixelGrid[0][x] = color;
pixelGrid[gridH - 1][x] = color;
}
for (let y = 0; y < gridH; y++) {
pixelGrid[y][0] = color;
pixelGrid[y][gridW - 1] = color;
}
redrawPixelCanvas();
}
function exportGridAsDataUrl() {
const off = document.createElement("canvas");
off.width = gridW;
off.height = gridH;
const offCtx = off.getContext("2d");
offCtx.clearRect(0, 0, gridW, gridH);
for (let y = 0; y < gridH; y++) {
for (let x = 0; x < gridW; x++) {
const cell = pixelGrid[y][x];
if (cell === null) continue; // transparent bleibt transparent
offCtx.fillStyle = cell;
offCtx.fillRect(x, y, 1, 1);
}
}
return off.toDataURL("image/png");
}
async function saveClothingItem() {
const slot = document.getElementById("itemSlot").value;
const name = document.getElementById("itemName").value.trim();
const color = document.getElementById("itemColor").value;
const price = Number(document.getElementById("itemPrice").value) || 0;
if (!name) {
showMsg("Bitte einen Namen eingeben.", true);
return;
}
const payload = {
slot, name, color, price,
imageData: exportGridAsDataUrl()
};
if (editingId) payload.id = editingId;
const res = await authFetch("/api/admin/clothing_items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.ok) {
showMsg(editingId ? "Aktualisiert." : "Gespeichert.");
resetForm();
activeFilterSlot = slot;
document.querySelectorAll(".slot-tab-btn").forEach(b => b.classList.toggle("active", b.dataset.filterSlot === slot));
await loadClothingItems();
} else {
showMsg("Fehler: " + (data.error || "unbekannt"), true);
}
}
// -------------------------------------------------------------
// INIT
// -------------------------------------------------------------
loadClothingItems();
</script>
</body>
</html>