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:
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, '<');
|
||||
}
|
||||
Reference in New Issue
Block a user