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:
2026-07-02 08:31:36 +02:00
commit 94b6cc3be9
60 changed files with 9206 additions and 0 deletions

156
public/js/framed-image.js Normal file
View 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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}