Erster Commit
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Skin-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;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
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; }
|
||||
.swatch { display: inline-block; width: 32px; height: 32px; border-radius: 4px; vertical-align: middle; margin-right: 6px; border: 1px solid #555; background-size: cover; image-rendering: pixelated; }
|
||||
|
||||
input { background: #222; border: 1px solid #444; color: #eee; padding: 6px 8px; border-radius: 4px; font-size: 14px; box-sizing: border-box; }
|
||||
button { background: #2c7a3d; border: none; color: white; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
button.danger { background: #a83232; }
|
||||
button.secondary { background: #555; }
|
||||
button:hover { opacity: 0.85; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-top: 12px; }
|
||||
.form-grid label { display: block; font-size: 12px; color: #999; margin-bottom: 4px; }
|
||||
|
||||
#editorArea { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-top: 16px; }
|
||||
#pixelCanvas { border: 1px solid #444; cursor: crosshair; image-rendering: pixelated; background: #444; }
|
||||
.msg { font-size: 13px; color: #6fbf73; min-height: 18px; margin-top: 8px; }
|
||||
.hint { color: #888; font-size: 12px; margin-top: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a class="back" href="/index.html">← zurück zur Startseite</a>
|
||||
<h1>🎨 Skin-Editor</h1>
|
||||
<p class="hint">Skins werden mit exakt 20×20 Pixeln gemalt (dieselbe Größe wie der Körper-Kasten im Spiel) und darüber gezeichnet, statt nur ein Emoji zu sein.</p>
|
||||
|
||||
<div class="panel">
|
||||
<table>
|
||||
<thead><tr><th></th><th>Name</th><th></th></tr></thead>
|
||||
<tbody id="itemsTableBody"></tbody>
|
||||
</table>
|
||||
<div class="hint" id="itemsEmpty" style="display:none;">Noch keine Skins angelegt.</div>
|
||||
|
||||
<h3 style="margin-top:24px;">Skin anlegen / bearbeiten</h3>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label>Name</label>
|
||||
<input id="itemName" placeholder="z.B. Punk-Frisur">
|
||||
</div>
|
||||
<div>
|
||||
<button onclick="newItemForm()" class="secondary">Neu (Formular leeren)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editorArea">
|
||||
<div>
|
||||
<canvas id="pixelCanvas" width="320" height="320"></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="#f1c27d" style="width:60px; height:36px; padding:2px; cursor:pointer;">
|
||||
|
||||
<div style="display:flex; gap:4px; flex-wrap:wrap; margin-top:4px;">
|
||||
<button onclick="presetFace()" style="font-size:11px;">Gesicht-Grundform</button>
|
||||
</div>
|
||||
|
||||
<button onclick="clearPixelCanvas()" class="danger" style="margin-top:8px;">Löschen (transparent)</button>
|
||||
<button onclick="saveItem()">Speichern</button>
|
||||
<button onclick="deleteCurrentItem()" class="danger" id="deleteBtn" style="display:none;">Löschen</button>
|
||||
<div class="msg" id="itemMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const GRID = 20; // exakt Körper-Kasten-Größe im Spiel
|
||||
const SCALE = 16;
|
||||
|
||||
let allItems = [];
|
||||
let currentEditId = null;
|
||||
let pixelGrid = [];
|
||||
|
||||
const canvas = document.getElementById("pixelCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
canvas.width = GRID * SCALE;
|
||||
canvas.height = GRID * SCALE;
|
||||
|
||||
function fillGridTransparent() {
|
||||
pixelGrid = [];
|
||||
for (let y = 0; y < GRID; y++) {
|
||||
const row = [];
|
||||
for (let x = 0; x < GRID; x++) row.push(null); // null = transparent
|
||||
pixelGrid.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
function redrawCanvas() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// Schachbrett-Muster als Transparenz-Hintergrund
|
||||
for (let y = 0; y < GRID; y++) {
|
||||
for (let x = 0; x < GRID; x++) {
|
||||
if (pixelGrid[y][x]) {
|
||||
ctx.fillStyle = pixelGrid[y][x];
|
||||
ctx.fillRect(x * SCALE, y * SCALE, SCALE, SCALE);
|
||||
} else {
|
||||
ctx.fillStyle = (x + y) % 2 === 0 ? "#3a3a3a" : "#2a2a2a";
|
||||
ctx.fillRect(x * SCALE, y * SCALE, SCALE, SCALE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadTextureIntoGrid(dataUrl) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const off = document.createElement("canvas");
|
||||
off.width = GRID;
|
||||
off.height = GRID;
|
||||
const offCtx = off.getContext("2d");
|
||||
offCtx.drawImage(img, 0, 0, GRID, GRID);
|
||||
const data = offCtx.getImageData(0, 0, GRID, GRID).data;
|
||||
|
||||
for (let y = 0; y < GRID; y++) {
|
||||
for (let x = 0; x < GRID; x++) {
|
||||
const i = (y * GRID + x) * 4;
|
||||
const alpha = data[i + 3];
|
||||
pixelGrid[y][x] = alpha > 10 ? `rgb(${data[i]},${data[i + 1]},${data[i + 2]})` : null;
|
||||
}
|
||||
}
|
||||
redrawCanvas();
|
||||
};
|
||||
img.src = dataUrl;
|
||||
}
|
||||
|
||||
function exportGridAsDataUrl() {
|
||||
const off = document.createElement("canvas");
|
||||
off.width = GRID;
|
||||
off.height = GRID;
|
||||
const offCtx = off.getContext("2d");
|
||||
for (let y = 0; y < GRID; y++) {
|
||||
for (let x = 0; x < GRID; x++) {
|
||||
if (pixelGrid[y][x]) {
|
||||
offCtx.fillStyle = pixelGrid[y][x];
|
||||
offCtx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return off.toDataURL("image/png");
|
||||
}
|
||||
|
||||
let isPainting = false;
|
||||
let isErasing = false;
|
||||
function paintAt(clientX, clientY, erase) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor((clientX - rect.left) / (rect.width / GRID));
|
||||
const y = Math.floor((clientY - rect.top) / (rect.height / GRID));
|
||||
if (x < 0 || y < 0 || x >= GRID || y >= GRID) return;
|
||||
|
||||
pixelGrid[y][x] = erase ? null : document.getElementById("pixelColor").value;
|
||||
redrawCanvas();
|
||||
}
|
||||
canvas.addEventListener("mousedown", e => {
|
||||
isPainting = true;
|
||||
isErasing = e.button === 2;
|
||||
paintAt(e.clientX, e.clientY, isErasing);
|
||||
});
|
||||
canvas.addEventListener("mousemove", e => { if (isPainting) paintAt(e.clientX, e.clientY, isErasing); });
|
||||
canvas.addEventListener("contextmenu", e => e.preventDefault());
|
||||
window.addEventListener("mouseup", () => { isPainting = false; });
|
||||
|
||||
function clearPixelCanvas() {
|
||||
fillGridTransparent();
|
||||
redrawCanvas();
|
||||
}
|
||||
|
||||
function presetFace() {
|
||||
// Einfache Kopf-Grundform als Startpunkt (Kreis-artig, Rest bleibt transparent)
|
||||
const skin = document.getElementById("pixelColor").value;
|
||||
for (let y = 3; y < 15; y++) {
|
||||
for (let x = 4; x < 16; x++) {
|
||||
pixelGrid[y][x] = skin;
|
||||
}
|
||||
}
|
||||
redrawCanvas();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// LADEN / ANZEIGEN
|
||||
// -------------------------------------------------------------
|
||||
async function loadItems() {
|
||||
const res = await authFetch("/api/admin/skin_items");
|
||||
const data = await res.json();
|
||||
allItems = data.items || [];
|
||||
renderItemsTable();
|
||||
}
|
||||
|
||||
function renderItemsTable() {
|
||||
const body = document.getElementById("itemsTableBody");
|
||||
const empty = document.getElementById("itemsEmpty");
|
||||
body.innerHTML = "";
|
||||
empty.style.display = allItems.length === 0 ? "block" : "none";
|
||||
|
||||
allItems.forEach(item => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><span class="swatch"></span></td>
|
||||
<td>${escapeHtml(item.name)}</td>
|
||||
<td>
|
||||
<button onclick="editItem(${item.id})">Bearbeiten</button>
|
||||
<button class="danger" onclick="quickDelete(${item.id})">Löschen</button>
|
||||
</td>
|
||||
`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function newItemForm() {
|
||||
currentEditId = null;
|
||||
document.getElementById("itemName").value = "";
|
||||
document.getElementById("deleteBtn").style.display = "none";
|
||||
fillGridTransparent();
|
||||
redrawCanvas();
|
||||
}
|
||||
|
||||
async function editItem(id) {
|
||||
const item = allItems.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
const res = await authFetch("/api/admin/skin_items/" + id);
|
||||
const data = await res.json();
|
||||
if (!data.ok) return;
|
||||
|
||||
currentEditId = id;
|
||||
document.getElementById("itemName").value = data.item.name;
|
||||
document.getElementById("deleteBtn").style.display = "inline-block";
|
||||
|
||||
fillGridTransparent();
|
||||
redrawCanvas();
|
||||
if (data.item.image_data) loadTextureIntoGrid(data.item.image_data);
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
const name = document.getElementById("itemName").value.trim();
|
||||
if (!name) {
|
||||
showMsg("Name ist Pflicht.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { id: currentEditId, name, imageData: exportGridAsDataUrl() };
|
||||
|
||||
const res = await authFetch("/api/admin/skin_items", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok) {
|
||||
showMsg("Gespeichert.");
|
||||
await loadItems();
|
||||
newItemForm();
|
||||
} else {
|
||||
showMsg("Fehler: " + (data.error || "unbekannt"), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentItem() {
|
||||
if (currentEditId === null) return;
|
||||
await quickDelete(currentEditId);
|
||||
newItemForm();
|
||||
}
|
||||
|
||||
async function quickDelete(id) {
|
||||
if (!confirm("Diesen Skin wirklich löschen?")) return;
|
||||
const res = await authFetch("/api/admin/skin_items/" + id, { method: "DELETE" });
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
showMsg("Gelöscht.");
|
||||
await loadItems();
|
||||
} else {
|
||||
showMsg("Löschen fehlgeschlagen.", true);
|
||||
}
|
||||
}
|
||||
|
||||
function showMsg(text, isError) {
|
||||
const el = document.getElementById("itemMsg");
|
||||
el.style.color = isError ? "#e06c6c" : "#6fbf73";
|
||||
el.textContent = text;
|
||||
setTimeout(() => { el.textContent = ""; }, 4000);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// INIT
|
||||
// -------------------------------------------------------------
|
||||
fillGridTransparent();
|
||||
redrawCanvas();
|
||||
loadItems();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user