Initial PlayBull release with Trilogy Hub integration.
Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1225
public/js/app.js
Normal file
1225
public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
156
public/js/framed-image.js
Normal file
156
public/js/framed-image.js
Normal file
@@ -0,0 +1,156 @@
|
||||
// Gemeinsame Transform-Logik für Editor-Vorschau und finale Anzeige
|
||||
|
||||
export const FRAMES = {
|
||||
avatar: {
|
||||
id: 'avatar',
|
||||
aspectRatio: 1,
|
||||
label: 'Profilbild',
|
||||
cssClass: 'framed-image framed-image--avatar',
|
||||
previewClass: 'framed-image--avatar-xl',
|
||||
sizes: { sm: 'framed-image--avatar-sm', md: 'framed-image--avatar-md', lg: 'framed-image--avatar-lg' },
|
||||
},
|
||||
gallery: {
|
||||
id: 'gallery',
|
||||
aspectRatio: 1,
|
||||
label: 'Quadrat',
|
||||
cssClass: 'framed-image framed-image--gallery',
|
||||
previewClass: 'framed-image--gallery-xl',
|
||||
sizes: { sm: 'framed-image--gallery-sm', md: 'framed-image--gallery-md', lg: 'framed-image--gallery-lg' },
|
||||
},
|
||||
banner: {
|
||||
id: 'banner',
|
||||
aspectRatio: 3,
|
||||
label: 'Banner',
|
||||
cssClass: 'framed-image framed-image--banner',
|
||||
previewClass: 'framed-image--banner-xl',
|
||||
sizes: { sm: 'framed-image--banner-sm', md: 'framed-image--banner-md', lg: 'framed-image--banner-lg' },
|
||||
},
|
||||
card: {
|
||||
id: 'card',
|
||||
aspectRatio: 2 / 3,
|
||||
label: 'Karte',
|
||||
cssClass: 'framed-image framed-image--card',
|
||||
previewClass: 'framed-image--card-xl',
|
||||
sizes: { sm: 'framed-image--card-sm', md: 'framed-image--card-md', lg: 'framed-image--card-lg' },
|
||||
},
|
||||
};
|
||||
|
||||
export function computeFramedLayout(imgW, imgH, frameW, frameH, focus, zoom) {
|
||||
const fx = focus?.x ?? 0.5;
|
||||
const fy = focus?.y ?? 0.5;
|
||||
const z = Math.max(1, zoom ?? 1);
|
||||
const cover = Math.max(frameW / imgW, frameH / imgH);
|
||||
const scale = cover * z;
|
||||
const w = imgW * scale;
|
||||
const h = imgH * scale;
|
||||
let left = frameW / 2 - fx * w;
|
||||
let top = frameH / 2 - fy * h;
|
||||
left = Math.min(0, Math.max(frameW - w, left));
|
||||
top = Math.min(0, Math.max(frameH - h, top));
|
||||
return { width: w, height: h, left, top, scale };
|
||||
}
|
||||
|
||||
export function focusFromLayout(imgW, imgH, frameW, frameH, layout, zoom) {
|
||||
const z = Math.max(1, zoom ?? 1);
|
||||
const cover = Math.max(frameW / imgW, frameH / imgH);
|
||||
const scale = cover * z;
|
||||
const w = imgW * scale;
|
||||
const h = imgH * scale;
|
||||
return {
|
||||
x: Math.min(1, Math.max(0, (frameW / 2 - layout.left) / w)),
|
||||
y: Math.min(1, Math.max(0, (frameH / 2 - layout.top) / h)),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyFramedLayout(imgEl, layout) {
|
||||
imgEl.style.width = `${layout.width}px`;
|
||||
imgEl.style.height = `${layout.height}px`;
|
||||
imgEl.style.left = `${layout.left}px`;
|
||||
imgEl.style.top = `${layout.top}px`;
|
||||
}
|
||||
|
||||
export function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Bild konnte nicht geladen werden'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
function sizeClass(frame, size) {
|
||||
const preset = FRAMES[frame] || FRAMES.gallery;
|
||||
return preset.sizes[size] || preset.sizes.sm;
|
||||
}
|
||||
|
||||
export function isImageAvatar(avatar) {
|
||||
return avatar && typeof avatar === 'object' && avatar.type === 'image' && avatar.url;
|
||||
}
|
||||
|
||||
export function avatarEmoji(avatar) {
|
||||
if (!avatar) return '🐂';
|
||||
if (typeof avatar === 'string') return avatar;
|
||||
if (isImageAvatar(avatar)) return avatar.emoji || '🐂';
|
||||
return avatar.value || '🐂';
|
||||
}
|
||||
|
||||
export function renderFramedImage({ url, focus, zoom, frame = 'gallery', size = 'sm', alt = '' }) {
|
||||
const preset = FRAMES[frame] || FRAMES.gallery;
|
||||
const fx = focus?.x ?? 0.5;
|
||||
const fy = focus?.y ?? 0.5;
|
||||
const z = zoom ?? 1;
|
||||
return `<div class="${preset.cssClass} ${sizeClass(frame, size)} framed-image-host"
|
||||
data-framed="1" data-frame="${preset.id}" data-url="${escapeAttr(url)}" data-fx="${fx}" data-fy="${fy}" data-zoom="${z}">
|
||||
<img src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" draggable="false" />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderUploadItem(item) {
|
||||
return `<div class="upload-item" data-upload-id="${item.id}">
|
||||
${renderFramedImage({ url: item.url, focus: item.focus, zoom: item.zoom, frame: item.frame, size: 'md' })}
|
||||
<div class="upload-item-actions">
|
||||
<button type="button" class="chip" data-upload-repos="${item.id}">↕️ Position</button>
|
||||
<button type="button" class="chip" data-upload-del="${item.id}">🗑️</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function renderAvatar(avatar, { size = 'sm', alt = '' } = {}) {
|
||||
if (isImageAvatar(avatar)) {
|
||||
return renderFramedImage({
|
||||
url: avatar.url,
|
||||
focus: avatar.focus,
|
||||
zoom: avatar.zoom,
|
||||
frame: 'avatar',
|
||||
size,
|
||||
alt,
|
||||
});
|
||||
}
|
||||
const emoji = avatarEmoji(avatar);
|
||||
const cls = size === 'lg' ? 'avatar-emoji avatar-emoji--lg' : size === 'md' ? 'avatar-emoji avatar-emoji--md' : 'avatar-emoji avatar-emoji--sm';
|
||||
return `<span class="${cls}" aria-hidden="true">${emoji}</span>`;
|
||||
}
|
||||
|
||||
export function hydrateFramedImages(root = document) {
|
||||
root.querySelectorAll('.framed-image-host[data-framed]').forEach((host) => {
|
||||
const img = host.querySelector('img');
|
||||
if (!img) return;
|
||||
const apply = () => {
|
||||
const layout = computeFramedLayout(
|
||||
img.naturalWidth,
|
||||
img.naturalHeight,
|
||||
host.clientWidth,
|
||||
host.clientHeight,
|
||||
{ x: +host.dataset.fx, y: +host.dataset.fy },
|
||||
+host.dataset.zoom,
|
||||
);
|
||||
applyFramedLayout(img, layout);
|
||||
};
|
||||
if (img.complete && img.naturalWidth) apply();
|
||||
else img.addEventListener('load', apply, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
198
public/js/image-editor.js
Normal file
198
public/js/image-editor.js
Normal file
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
FRAMES,
|
||||
computeFramedLayout,
|
||||
focusFromLayout,
|
||||
applyFramedLayout,
|
||||
loadImage,
|
||||
} from './framed-image.js';
|
||||
|
||||
/**
|
||||
* Öffnet den Positions-Editor nach Dateiauswahl (oder für bestehendes Bild).
|
||||
* @returns {Promise<{focus:{x,y}, zoom:number}|null>}
|
||||
*/
|
||||
export async function openImagePositionEditor({ file, url, focus, zoom, frame = 'avatar' } = {}) {
|
||||
const preset = FRAMES[frame] || FRAMES.avatar;
|
||||
let src = url;
|
||||
if (file) src = URL.createObjectURL(file);
|
||||
|
||||
let img;
|
||||
try {
|
||||
img = await loadImage(src);
|
||||
} catch (e) {
|
||||
if (file) URL.revokeObjectURL(src);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const state = {
|
||||
focus: { x: focus?.x ?? 0.5, y: focus?.y ?? 0.5 },
|
||||
zoom: Math.max(1, zoom ?? 1),
|
||||
img,
|
||||
dragging: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
pinchDist: 0,
|
||||
pinchZoom: 1,
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop image-editor-backdrop';
|
||||
backdrop.innerHTML = `
|
||||
<div class="modal image-editor-modal">
|
||||
<div class="handle"></div>
|
||||
<div class="modal-head">
|
||||
<h2>${preset.label} positionieren</h2>
|
||||
<button type="button" class="modal-close" data-act="cancel">✕</button>
|
||||
</div>
|
||||
<p class="muted center image-editor-hint">So wird dein Bild überall angezeigt — ziehen & zoomen</p>
|
||||
<div class="image-editor-preview-wrap">
|
||||
<div class="image-editor-preview ${preset.cssClass} ${preset.previewClass || 'framed-image--avatar-xl'}" id="ieFrame">
|
||||
<img id="ieImg" src="${src}" alt="" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field image-editor-zoom-field">
|
||||
<label>Zoom <span id="ieZoomVal">${state.zoom.toFixed(1)}×</span></label>
|
||||
<input type="range" id="ieZoom" min="1" max="4" step="0.05" value="${state.zoom}" />
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="button" class="btn btn-ghost" data-act="reset">Zurücksetzen</button>
|
||||
<button type="button" class="btn btn-accent" data-act="save">Speichern</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const frameEl = backdrop.querySelector('#ieFrame');
|
||||
const imgEl = backdrop.querySelector('#ieImg');
|
||||
const zoomInput = backdrop.querySelector('#ieZoom');
|
||||
const zoomVal = backdrop.querySelector('#ieZoomVal');
|
||||
|
||||
let layout = { left: 0, top: 0, width: 0, height: 0, scale: 1 };
|
||||
|
||||
function syncFromFocus() {
|
||||
const fw = frameEl.clientWidth;
|
||||
const fh = frameEl.clientHeight;
|
||||
layout = computeFramedLayout(img.naturalWidth, img.naturalHeight, fw, fh, state.focus, state.zoom);
|
||||
applyFramedLayout(imgEl, layout);
|
||||
}
|
||||
|
||||
function syncFocusFromLayout() {
|
||||
const fw = frameEl.clientWidth;
|
||||
const fh = frameEl.clientHeight;
|
||||
state.focus = focusFromLayout(img.naturalWidth, img.naturalHeight, fw, fh, layout, state.zoom);
|
||||
}
|
||||
|
||||
function finish(result) {
|
||||
document.body.style.overflow = '';
|
||||
backdrop.remove();
|
||||
if (file) URL.revokeObjectURL(src);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
if (e.target.closest('button, input')) return;
|
||||
state.dragging = true;
|
||||
state.lastX = e.clientX;
|
||||
state.lastY = e.clientY;
|
||||
frameEl.setPointerCapture(e.pointerId);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!state.dragging) return;
|
||||
const dx = e.clientX - state.lastX;
|
||||
const dy = e.clientY - state.lastY;
|
||||
state.lastX = e.clientX;
|
||||
state.lastY = e.clientY;
|
||||
const fw = frameEl.clientWidth;
|
||||
const fh = frameEl.clientHeight;
|
||||
layout.left = Math.min(0, Math.max(fw - layout.width, layout.left + dx));
|
||||
layout.top = Math.min(0, Math.max(fh - layout.height, layout.top + dy));
|
||||
applyFramedLayout(imgEl, layout);
|
||||
syncFocusFromLayout();
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
state.dragging = false;
|
||||
try { frameEl.releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.08 : 0.08;
|
||||
setZoom(state.zoom + delta);
|
||||
}
|
||||
|
||||
function setZoom(z) {
|
||||
const fw = frameEl.clientWidth;
|
||||
const fh = frameEl.clientHeight;
|
||||
const oldZoom = state.zoom;
|
||||
state.zoom = Math.min(4, Math.max(1, z));
|
||||
const oldLayout = { ...layout };
|
||||
layout = computeFramedLayout(img.naturalWidth, img.naturalHeight, fw, fh, state.focus, state.zoom);
|
||||
// Fokuspunkt in der Mitte halten beim Zoomen
|
||||
const cx = fw / 2;
|
||||
const cy = fh / 2;
|
||||
const relX = (cx - oldLayout.left) / oldLayout.width;
|
||||
const relY = (cy - oldLayout.top) / oldLayout.height;
|
||||
layout.left = cx - relX * layout.width;
|
||||
layout.top = cy - relY * layout.height;
|
||||
layout.left = Math.min(0, Math.max(fw - layout.width, layout.left));
|
||||
layout.top = Math.min(0, Math.max(fh - layout.height, layout.top));
|
||||
syncFocusFromLayout();
|
||||
applyFramedLayout(imgEl, layout);
|
||||
zoomInput.value = state.zoom;
|
||||
zoomVal.textContent = `${state.zoom.toFixed(1)}×`;
|
||||
}
|
||||
|
||||
imgEl.addEventListener('load', syncFromFocus, { once: true });
|
||||
if (imgEl.complete) syncFromFocus();
|
||||
else requestAnimationFrame(syncFromFocus);
|
||||
|
||||
frameEl.addEventListener('pointerdown', onPointerDown);
|
||||
frameEl.addEventListener('pointermove', onPointerMove);
|
||||
frameEl.addEventListener('pointerup', onPointerUp);
|
||||
frameEl.addEventListener('pointercancel', onPointerUp);
|
||||
frameEl.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
// Pinch-Zoom auf Touch
|
||||
frameEl.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 2) {
|
||||
state.pinchDist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY,
|
||||
);
|
||||
state.pinchZoom = state.zoom;
|
||||
}
|
||||
}, { passive: true });
|
||||
frameEl.addEventListener('touchmove', (e) => {
|
||||
if (e.touches.length === 2 && state.pinchDist > 0) {
|
||||
e.preventDefault();
|
||||
const dist = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY,
|
||||
);
|
||||
setZoom(state.pinchZoom * (dist / state.pinchDist));
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
zoomInput.addEventListener('input', () => setZoom(+zoomInput.value));
|
||||
|
||||
backdrop.addEventListener('click', (e) => {
|
||||
if (e.target === backdrop) finish(null);
|
||||
});
|
||||
backdrop.querySelector('[data-act="cancel"]').onclick = () => finish(null);
|
||||
backdrop.querySelector('[data-act="reset"]').onclick = () => {
|
||||
state.focus = { x: 0.5, y: 0.5 };
|
||||
state.zoom = 1;
|
||||
zoomInput.value = 1;
|
||||
zoomVal.textContent = '1.0×';
|
||||
syncFromFocus();
|
||||
};
|
||||
backdrop.querySelector('[data-act="save"]').onclick = () => {
|
||||
syncFocusFromLayout();
|
||||
finish({ focus: { ...state.focus }, zoom: state.zoom });
|
||||
};
|
||||
});
|
||||
}
|
||||
72
public/js/upload-helper.js
Normal file
72
public/js/upload-helper.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { openImagePositionEditor } from './image-editor.js';
|
||||
|
||||
/**
|
||||
* Öffnet Editor und lädt Bild hoch. Für Avatar, Galerie und alle anderen Uploads.
|
||||
*/
|
||||
export async function uploadImageWithEditor(file, { frame, uploadFn }) {
|
||||
if (!file) return null;
|
||||
const result = await openImagePositionEditor({ file, frame });
|
||||
if (!result) return null;
|
||||
return uploadFn(file, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor für bestehendes Bild, dann Position speichern.
|
||||
*/
|
||||
export async function repositionImageWithEditor(item, { frame, saveFn }) {
|
||||
const result = await openImagePositionEditor({
|
||||
url: item.url,
|
||||
focus: item.focus,
|
||||
zoom: item.zoom,
|
||||
frame: frame || item.frame || 'gallery',
|
||||
});
|
||||
if (!result) return null;
|
||||
return saveFn(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindet einen File-Input mit Editor + Upload-Callback.
|
||||
*/
|
||||
export function wireImageFileInput(input, { frame, onPick }) {
|
||||
input.addEventListener('change', async () => {
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
await onPick(file, frame);
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt versteckten File-Input + Button — Editor öffnet sich immer vor Upload.
|
||||
*/
|
||||
export function createImageUploadButton({ label, frame, onPick, className = 'btn btn-accent' }) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'image-upload-wrap';
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = className;
|
||||
btn.textContent = label;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.hidden = true;
|
||||
btn.onclick = () => input.click();
|
||||
wireImageFileInput(input, {
|
||||
frame,
|
||||
onPick: async (file) => onPick(file, frame),
|
||||
});
|
||||
wrap.append(btn, input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function appendPositionFields(fd, result, frame) {
|
||||
fd.append('focusX', result.focus.x);
|
||||
fd.append('focusY', result.focus.y);
|
||||
fd.append('zoom', result.zoom);
|
||||
if (frame) fd.append('frame', frame);
|
||||
return fd;
|
||||
}
|
||||
Reference in New Issue
Block a user