Includes homelab deploy tooling, image position/zoom manifest persistence, and full trading/casino stack. Co-authored-by: Cursor <cursoragent@cursor.com>
157 lines
5.4 KiB
JavaScript
157 lines
5.4 KiB
JavaScript
// 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, '<');
|
|
}
|