Files
svg-templater/frontend/app.js
T

1956 lines
77 KiB
JavaScript

/* ==========================================================================
SVG TEMPLATER JAVASCRIPT ENGINE
Statically configured API endpoints, state management, and Demo Mode simulation.
========================================================================== */
window.ENV = {
API_KEY: "[[.APIKey]]",
API_URL: "[[.APIUrl]]"
};
// 1. STATIC CONFIGURATION (User: Edit these values to configure backend communication)
const API_BASE_URL = window.ENV.API_URL;
const API_TOKEN = window.ENV.API_KEY; // Set your Bearer Token here if authentication is enabled
// 2. STATE MANAGER
const state = {
templates: [],
activeTemplate: null,
zoom: 100,
isDemoMode: false,
isConnected: false, // true only after a confirmed successful API response
format: 'svg', // 'svg' or 'png'
fonts: [],
lastRenderedUrl: null,
renderedUrls: [],
activePageIndex: 0,
suppressRenderToast: false
};
// 3. MOCK DATA FOR DEMO MODE
// 3. MOCK DATA FOR DEMO MODE
const MOCK_TEMPLATES = [
{
Id: "business_card_v1",
Name: "Visitenkarte VIP",
Pages: [
{
TemplateId: "business_card_v1",
Page: 1,
TemplateKeys: ["Name", "Title", "Company", "Email", "Phone", "Website"]
}
]
},
{
Id: "gift_certificate",
Name: "Geschenkgutschein Gold",
Pages: [
{
TemplateId: "gift_certificate",
Page: 1,
TemplateKeys: ["Recipient", "Amount", "Occasion", "Sender", "Date"]
},
{
TemplateId: "gift_certificate",
Page: 2,
TemplateKeys: ["Sender", "Date"]
}
]
},
{
Id: "event_badge",
Name: "Event Badge VIP",
Pages: [
{
TemplateId: "event_badge",
Page: 1,
TemplateKeys: ["AttendeeName", "AccessLevel", "EventName", "SeatNumber"]
}
]
}
];
const MOCK_SVG_TEMPLATES = {
"business_card_v1_1": `
<svg viewBox="0 0 600 350" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0f172a;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e1b4b;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background -->
<rect width="100%" height="100%" fill="url(#grad)" rx="15" />
<!-- Design accents -->
<circle cx="500" cy="80" r="120" fill="#6366f1" opacity="0.15" />
<circle cx="550" cy="120" r="80" fill="#a855f7" opacity="0.15" />
<path d="M 0,350 L 250,350 L 150,220 L 0,260 Z" fill="#6366f1" opacity="0.1" />
<!-- Logo area -->
<g transform="translate(45, 45)">
<polygon points="12 2 2 7 12 12 22 7 12 2" fill="none" stroke="#a855f7" stroke-width="2" stroke-linejoin="round"/>
<polyline points="2 17 12 22 22 17" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<polyline points="2 12 12 17 22 12" fill="none" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/>
<text x="32" y="16" fill="#ffffff" font-family="'Outfit', sans-serif" font-weight="800" font-size="18">{{ Company }}</text>
</g>
<!-- Name and Title -->
<text x="45" y="160" fill="#ffffff" font-family="'Outfit', sans-serif" font-weight="700" font-size="28" letter-spacing="-0.5">{{ Name }}</text>
<text x="45" y="190" fill="#a855f7" font-family="'Inter', sans-serif" font-weight="600" font-size="14" letter-spacing="1.5" text-transform="uppercase">{{ Title }}</text>
<!-- Divider line -->
<line x1="45" y1="215" x2="550" y2="215" stroke="#334155" stroke-width="1" />
<!-- Contact details -->
<g transform="translate(45, 255)" font-family="'Inter', sans-serif" font-size="12" fill="#94a3b8">
<!-- Email -->
<text x="0" y="0" font-weight="600" fill="#6366f1">E-MAIL:</text>
<text x="60" y="0" fill="#e2e8f0">{{ Email }}</text>
<!-- Phone -->
<text x="0" y="25" font-weight="600" fill="#6366f1">TELEFON:</text>
<text x="60" y="25" fill="#e2e8f0">{{ Phone }}</text>
<!-- Website -->
<text x="300" y="0" font-weight="600" fill="#6366f1">WEB:</text>
<text x="340" y="0" fill="#e2e8f0">{{ Website }}</text>
</g>
</svg>
`,
"gift_certificate_1": `
<svg viewBox="0 0 600 350" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#d97706;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="#111827" rx="15" />
<rect x="15" y="15" width="570" height="320" fill="none" stroke="url(#gold-grad)" stroke-width="2" rx="10" opacity="0.6"/>
<text x="300" y="65" text-anchor="middle" fill="url(#gold-grad)" font-family="'Outfit', sans-serif" font-weight="800" font-size="26" letter-spacing="3" text-transform="uppercase">Gutschein</text>
<text x="300" y="90" text-anchor="middle" fill="#94a3b8" font-family="'Inter', sans-serif" font-weight="500" font-size="12" text-transform="uppercase" letter-spacing="1">Für das Ereignis: {{ Occasion }}</text>
<text x="300" y="150" text-anchor="middle" fill="#ffffff" font-family="'Outfit', sans-serif" font-weight="700" font-size="36" fill="url(#gold-grad)">{{ Amount }} €</text>
<g transform="translate(100, 210)" font-family="'Inter', sans-serif" font-size="13" fill="#e2e8f0">
<text x="0" y="0" fill="#94a3b8">Empfänger:</text>
<text x="100" y="0" font-weight="600">{{ Recipient }}</text>
<text x="0" y="30" fill="#94a3b8">Schenker:</text>
<text x="100" y="30" font-weight="600">{{ Sender }}</text>
</g>
<text x="300" y="300" text-anchor="middle" fill="#6b7280" font-family="'Inter', sans-serif" font-size="11">Ausgestellt am: {{ Date }}</text>
</svg>
`,
"gift_certificate_2": `
<svg viewBox="0 0 600 350" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#111827" rx="15" />
<rect x="15" y="15" width="570" height="320" fill="none" stroke="#f59e0b" stroke-width="1" rx="10" opacity="0.3"/>
<text x="300" y="80" text-anchor="middle" fill="#f59e0b" font-family="'Outfit', sans-serif" font-weight="700" font-size="18" letter-spacing="1">GUTSCHEINBEDINGUNGEN</text>
<g transform="translate(60, 130)" font-family="'Inter', sans-serif" font-size="11" fill="#94a3b8">
<text x="0" y="0">1. Dieser Gutschein ist ab Ausstellungsdatum 3 Jahre gültig.</text>
<text x="0" y="25">2. Eine Barauszahlung des Gutscheinwerts ist ausgeschlossen.</text>
<text x="0" y="50">3. Der Gutschein kann für alle Dienstleistungen eingelöst werden.</text>
<text x="0" y="75">4. Bei Verlust oder Beschädigung erfolgt kein Ersatz.</text>
</g>
<text x="300" y="290" text-anchor="middle" fill="#6b7280" font-family="'Inter', sans-serif" font-size="10">Ausgestellt von: {{ Sender }} am {{ Date }}</text>
</svg>
`,
"event_badge_1": `
<svg viewBox="0 0 400 600" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="purple-fade" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#8b5cf6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e1b4b;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#purple-fade)" rx="20"/>
<rect x="15" y="15" width="370" height="570" fill="none" stroke="#f3f4f6" stroke-width="1" rx="15" opacity="0.1"/>
<!-- Badge Notch -->
<path d="M 150,0 L 250,0 L 230,30 L 170,30 Z" fill="#0f172a" />
<circle cx="200" cy="15" r="5" fill="#334155" />
<text x="200" y="90" text-anchor="middle" fill="#a78bfa" font-family="'Outfit', sans-serif" font-weight="800" font-size="16" letter-spacing="3" text-transform="uppercase">{{ EventName }}</text>
<!-- Attendee Name -->
<text x="200" y="240" text-anchor="middle" fill="#ffffff" font-family="'Outfit', sans-serif" font-weight="700" font-size="28" letter-spacing="-0.5">{{ AttendeeName }}</text>
<rect x="100" y="280" width="200" height="35" fill="#0f172a" rx="8" />
<text x="200" y="303" text-anchor="middle" fill="#10b981" font-family="'Inter', sans-serif" font-weight="700" font-size="13" letter-spacing="1">ACCESS: {{ AccessLevel }}</text>
<text x="200" y="380" text-anchor="middle" fill="#94a3b8" font-family="'Inter', sans-serif" font-size="13">Sitzplatz: <tspan font-weight="600" fill="#ffffff">{{ SeatNumber }}</tspan></text>
<!-- Mock Barcode -->
<g transform="translate(100, 460)">
<rect x="0" y="0" width="6" height="50" fill="#ffffff" />
<rect x="10" y="0" width="3" height="50" fill="#ffffff" />
<rect x="17" y="0" width="9" height="50" fill="#ffffff" />
<rect x="30" y="0" width="3" height="50" fill="#ffffff" />
<rect x="37" y="0" width="6" height="50" fill="#ffffff" />
<rect x="47" y="0" width="12" height="50" fill="#ffffff" />
<rect x="63" y="0" width="3" height="50" fill="#ffffff" />
<rect x="70" y="0" width="9" height="50" fill="#ffffff" />
<rect x="83" y="0" width="6" height="50" fill="#ffffff" />
<rect x="93" y="0" width="15" height="50" fill="#ffffff" />
<rect x="112" y="0" width="3" height="50" fill="#ffffff" />
<rect x="120" y="0" width="9" height="50" fill="#ffffff" />
<rect x="133" y="0" width="6" height="50" fill="#ffffff" />
<rect x="143" y="0" width="12" height="50" fill="#ffffff" />
<rect x="160" y="0" width="3" height="50" fill="#ffffff" />
<rect x="167" y="0" width="9" height="50" fill="#ffffff" />
<rect x="180" y="0" width="6" height="50" fill="#ffffff" />
<rect x="190" y="0" width="10" height="50" fill="#ffffff" />
</g>
<text x="200" y="535" text-anchor="middle" fill="#6b7280" font-family="'Inter', sans-serif" font-size="10" letter-spacing="4">#0094-1184-7492</text>
</svg>
`
};
// 4. UI ELEMENT CACHE
const elements = {
viewHome: document.getElementById('view-home'),
viewEditor: document.getElementById('view-editor'),
dropZone: document.getElementById('svg-drop-zone'),
fileInput: document.getElementById('svg-file-input'),
templatesGrid: document.getElementById('templates-grid'),
templatesEmpty: document.getElementById('templates-empty'),
templatesLoading: document.getElementById('templates-loading'),
statsTotalCount: document.getElementById('stats-total-count'),
statsApiHost: document.getElementById('stats-api-host'),
statsAppMode: document.getElementById('stats-app-mode'),
appModeBadge: document.getElementById('app-mode-badge'),
modeText: document.getElementById('mode-text'),
btnToggleDemo: document.getElementById('btn-toggle-demo'),
btnBackHome: document.getElementById('btn-back-home'),
editorTemplateName: document.getElementById('editor-template-name'),
dynamicInputsArea: document.getElementById('dynamic-inputs-area'),
formatSvg: document.getElementById('format-svg'),
formatPng: document.getElementById('format-png'),
resolutionSettings: document.getElementById('resolution-settings'),
renderWidth: document.getElementById('render-width'),
renderHeight: document.getElementById('render-height'),
btnSubmitRender: document.getElementById('btn-submit-render'),
renderSpinner: document.getElementById('render-spinner'),
renderBtnIcon: document.getElementById('render-btn-icon'),
renderBtnText: document.getElementById('render-btn-text'),
btnZoomIn: document.getElementById('btn-zoom-in'),
btnZoomOut: document.getElementById('btn-zoom-out'),
btnFullscreen: document.getElementById('btn-fullscreen'),
btnZoomReset: document.getElementById('btn-zoom-reset'),
toastContainer: document.getElementById('toast-container'),
variablesForm: document.getElementById('dynamic-variables-form'),
pulseDot: document.querySelector('#app-mode-badge .pulse-dot'),
fontsList: document.getElementById('fonts-list'),
fontsEmpty: document.getElementById('fonts-empty'),
fontsLoading: document.getElementById('fonts-loading'),
fontFileInput: document.getElementById('font-file-input'),
btnUploadFont: document.getElementById('btn-upload-font'),
btnDownloadPreview: document.getElementById('btn-download-preview'),
btnDownloadAll: document.getElementById('btn-download-all'),
previewCard: document.querySelector('.preview-card'),
previewViewport: document.getElementById('preview-viewport'),
previewCanvasContainer: document.getElementById('preview-canvas-container'),
previewLoadingOverlay: document.getElementById('preview-loading-overlay'),
previewPlaceholder: document.getElementById('preview-placeholder'),
previewImage: document.getElementById('preview-image'),
zoomBadge: document.getElementById('zoom-badge'),
btnRenameActive: document.getElementById('btn-rename-active'),
editorTemplateId: document.getElementById('editor-template-id'),
pageNavigator: document.getElementById('page-navigator'),
pageIndicator: document.getElementById('page-indicator'),
btnPagePrev: document.getElementById('btn-page-prev'),
btnPageNext: document.getElementById('btn-page-next'),
editorPagesList: document.getElementById('editor-pages-list'),
pageFileInput: document.getElementById('page-file-input'),
btnAddPage: document.getElementById('btn-add-page')
};
// 4.5 CUSTOM PROMPT WINDOW UTILITY (Hacker/Hackathon neobrutalist style)
function showCustomPrompt(title, defaultValue, placeholder = "") {
return new Promise((resolve) => {
// Create modal overlay
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100vw';
overlay.style.height = '100vh';
overlay.style.backgroundColor = 'rgba(10, 11, 16, 0.85)';
overlay.style.backdropFilter = 'blur(6px)';
overlay.style.display = 'flex';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.zIndex = '99999';
overlay.style.animation = 'fadeIn 0.2s ease-out';
// Create dialog card
const dialog = document.createElement('div');
dialog.className = 'card';
dialog.style.width = '400px';
dialog.style.border = '1px solid rgba(255, 255, 255, 0.08)';
dialog.style.boxShadow = '0 20px 40px rgba(0, 0, 0, 0.4)';
dialog.style.padding = '0';
dialog.style.overflow = 'hidden';
dialog.style.borderRadius = '8px';
// Header
const header = document.createElement('div');
header.className = 'card-header';
header.style.padding = '18px 24px';
header.style.borderBottom = '1px solid rgba(255, 255, 255, 0.06)';
header.style.background = '#141724';
const headerTitle = document.createElement('h2');
headerTitle.style.fontSize = '14px';
headerTitle.style.margin = '0';
headerTitle.style.color = '#ffffff';
headerTitle.textContent = title;
header.appendChild(headerTitle);
// Body
const body = document.createElement('div');
body.className = 'card-body';
body.style.padding = '24px';
body.style.background = '#141724';
body.style.display = 'flex';
body.style.flexDirection = 'column';
body.style.gap = '16px';
// Input
const input = document.createElement('input');
input.type = 'text';
input.className = 'custom-input';
input.value = defaultValue || '';
input.placeholder = placeholder || 'Name eingeben...';
input.style.width = '100%';
input.style.border = '1px solid rgba(255, 255, 255, 0.12)';
input.style.borderRadius = '6px';
input.style.padding = '10px 14px';
input.style.background = '#0a0b10';
input.style.color = '#f3f4f6';
input.style.fontFamily = 'inherit';
// Actions container
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.justifyContent = 'flex-end';
actions.style.gap = '12px';
actions.style.marginTop = '8px';
// Cancel button
const cancelBtn = document.createElement('button');
cancelBtn.className = 'back-btn';
cancelBtn.textContent = 'Abbrechen';
cancelBtn.style.padding = '8px 14px';
cancelBtn.style.fontSize = '12px';
// Submit button
const submitBtn = document.createElement('button');
submitBtn.className = 'submit-btn';
submitBtn.textContent = 'Bestätigen';
submitBtn.style.padding = '8px 16px';
submitBtn.style.width = 'auto';
submitBtn.style.fontSize = '12px';
submitBtn.style.background = '#6366f1';
submitBtn.style.color = '#ffffff';
submitBtn.style.border = 'none';
submitBtn.style.boxShadow = 'none';
submitBtn.style.borderRadius = '6px';
actions.appendChild(cancelBtn);
actions.appendChild(submitBtn);
body.appendChild(input);
body.appendChild(actions);
dialog.appendChild(header);
dialog.appendChild(body);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Focus input
setTimeout(() => input.focus(), 50);
// Helper to close and resolve
const closeWith = (val) => {
overlay.style.animation = 'fadeOut 0.15s ease-in';
setTimeout(() => {
overlay.remove();
resolve(val);
}, 100);
};
// Listeners
cancelBtn.addEventListener('click', () => closeWith(null));
submitBtn.addEventListener('click', () => closeWith(input.value));
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
closeWith(input.value);
} else if (e.key === 'Escape') {
e.preventDefault();
closeWith(null);
}
});
});
}
// 5. TOAST NOTIFICATION UTILITY
function showToast(message, type = 'info', duration = 4000) {
// Limit active toasts to max 3 to prevent screen flooding
const activeToasts = elements.toastContainer.querySelectorAll('.toast:not(.toast-leave)');
if (activeToasts.length >= 3) {
const oldest = activeToasts[0];
oldest.classList.add('toast-leave');
oldest.remove();
}
const toast = document.createElement('div');
toast.className = `toast ${type}`;
let iconSvg = '';
if (type === 'success') {
iconSvg = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>';
} else if (type === 'error') {
iconSvg = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>';
} else if (type === 'warning') {
iconSvg = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>';
} else {
iconSvg = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>';
}
toast.innerHTML = `
<span class="toast-icon">${iconSvg}</span>
<span class="toast-message">${message}</span>
`;
elements.toastContainer.appendChild(toast);
// Auto-remove
setTimeout(() => {
toast.classList.add('toast-leave');
toast.addEventListener('transitionend', () => {
toast.remove();
});
}, duration);
}
// 6. API CLIENT IMPLEMENTATION
const API = {
getHeaders() {
const headers = {};
if (API_TOKEN) {
headers['Authorization'] = `Bearer ${API_TOKEN}`;
}
return headers;
},
async fetchAllTemplates() {
if (state.isDemoMode) {
return MOCK_TEMPLATES;
}
const res = await fetch(`${API_BASE_URL}/svg/`, {
headers: this.getHeaders()
});
if (!res.ok) {
if (res.status === 401) {
throw new Error("Nicht autorisiert. Bitte prüfen Sie den statischen Token.");
}
throw new Error(`Server-Fehler: ${res.status} ${res.statusText}`);
}
// The backend returns a standard JSON array now
return await res.json();
},
async uploadSVG(file, filename, customName) {
if (state.isDemoMode) {
// Read file as text for mock parser
const reader = new FileReader();
const text = await new Promise((resolve) => {
reader.onload = (e) => resolve(e.target.result);
reader.readAsText(file);
});
const templateKeys = [];
const regex = /\{\{\s*(.*?)\s*\}\}/g;
let match;
while ((match = regex.exec(text)) !== null) {
const varname = match[1].trim();
if (!templateKeys.includes(varname)) {
templateKeys.push(varname);
}
}
const newId = `custom_template_${Math.random().toString(36).substring(2, 9)}`;
// Register template inside mock storage
MOCK_SVG_TEMPLATES[newId + "_1"] = text;
const newTemplate = {
Id: newId,
Name: customName || filename.replace('.svg', ''),
Pages: [
{
TemplateId: newId,
Page: 1,
TemplateKeys: templateKeys
}
]
};
MOCK_TEMPLATES.unshift(newTemplate);
return newTemplate;
}
const formData = new FormData();
formData.append('files', file, filename);
const urlParams = new URLSearchParams();
if (customName) {
urlParams.set('name', customName);
}
const res = await fetch(`${API_BASE_URL}/svg/?${urlParams.toString()}`, {
method: 'POST',
headers: this.getHeaders(),
body: formData
});
if (!res.ok) {
const errMsg = await res.text();
throw new Error(errMsg || `Upload failed: ${res.statusText}`);
}
return await res.json();
},
async deleteTemplate(id) {
if (state.isDemoMode) {
const index = MOCK_TEMPLATES.findIndex(t => t.Id === id);
if (index !== -1) {
MOCK_TEMPLATES.splice(index, 1);
delete MOCK_SVG_TEMPLATES[id];
return true;
}
return false;
}
const res = await fetch(`${API_BASE_URL}/svg/${id}`, {
method: 'DELETE',
headers: this.getHeaders()
});
if (!res.ok) {
const errMsg = await res.text();
throw new Error(errMsg || `Löschen fehlgeschlagen: ${res.statusText}`);
}
return true;
},
async renameTemplate(id, name) {
if (state.isDemoMode) {
const template = MOCK_TEMPLATES.find(t => t.Id === id);
if (template) {
template.Name = name;
return true;
}
return false;
}
const headers = this.getHeaders();
headers['Content-Type'] = 'application/json';
const res = await fetch(`${API_BASE_URL}/svg/${id}`, {
method: 'PATCH',
headers: headers,
body: JSON.stringify({
Name: name
})
});
if (!res.ok) {
const errMsg = await res.text();
throw new Error(errMsg || `Namen zuweisen fehlgeschlagen: ${res.statusText}`);
}
return true;
},
async addPage(templateId, files) {
if (state.isDemoMode) {
// Demo mode loop
for (let i = 0; i < files.length; i++) {
await this.addSinglePageDemo(templateId, files[i], files[i].name);
}
const template = MOCK_TEMPLATES.find(t => t.Id === templateId);
return template.Pages;
}
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i], files[i].name);
}
const res = await fetch(`${API_BASE_URL}/svg/${templateId}/page/`, {
method: 'POST',
headers: this.getHeaders(),
body: formData
});
if (!res.ok) {
const errMsg = await res.text();
throw new Error(errMsg || `Seiten hinzufügen fehlgeschlagen: ${res.statusText}`);
}
return await res.json();
},
async addSinglePageDemo(templateId, file, filename) {
// Read file as text for mock parser
const reader = new FileReader();
const text = await new Promise((resolve) => {
reader.onload = (e) => resolve(e.target.result);
reader.readAsText(file);
});
const template = MOCK_TEMPLATES.find(t => t.Id === templateId);
if (!template) return;
const templateKeys = [];
const regex = /\{\{\s*(.*?)\s*\}\}/g;
let match;
while ((match = regex.exec(text)) !== null) {
const varname = match[1].trim();
if (!templateKeys.includes(varname)) {
templateKeys.push(varname);
}
}
const newPageNumber = template.Pages.length + 1;
MOCK_SVG_TEMPLATES[`${templateId}_${newPageNumber}`] = text;
const newPage = {
TemplateId: templateId,
Page: newPageNumber,
TemplateKeys: templateKeys
};
template.Pages.push(newPage);
},
async deletePage(templateId, pageNumber) {
if (state.isDemoMode) {
const template = MOCK_TEMPLATES.find(t => t.Id === templateId);
if (!template) throw new Error("Template not found in mock store.");
const pageIndex = template.Pages.findIndex(p => p.Page === pageNumber);
if (pageIndex !== -1) {
template.Pages.splice(pageIndex, 1);
delete MOCK_SVG_TEMPLATES[`${templateId}_${pageNumber}`];
// Re-index remaining pages
template.Pages.forEach((p, idx) => {
const oldNum = p.Page;
const newNum = idx + 1;
p.Page = newNum;
if (MOCK_SVG_TEMPLATES[`${templateId}_${oldNum}`]) {
MOCK_SVG_TEMPLATES[`${templateId}_${newNum}`] = MOCK_SVG_TEMPLATES[`${templateId}_${oldNum}`];
if (oldNum !== newNum) {
delete MOCK_SVG_TEMPLATES[`${templateId}_${oldNum}`];
}
}
});
return true;
}
return false;
}
const res = await fetch(`${API_BASE_URL}/svg/${templateId}/page/${pageNumber}`, {
method: 'DELETE',
headers: this.getHeaders()
});
if (!res.ok) {
const errMsg = await res.text();
throw new Error(errMsg || `Seite löschen fehlgeschlagen: ${res.statusText}`);
}
return true;
},
async renderTemplate(id, templateKeys, format, w, h) {
if (state.isDemoMode) {
// Simulation logic
await new Promise(resolve => setTimeout(resolve, 800)); // Simulate networking
const template = MOCK_TEMPLATES.find(t => t.Id === id);
if (!template) {
throw new Error("Template not found in mock store.");
}
const urls = [];
// Render each page
for (let i = 0; i < template.Pages.length; i++) {
const pageNum = template.Pages[i].Page;
const rawSvg = MOCK_SVG_TEMPLATES[`${id}_${pageNum}`];
if (!rawSvg) continue;
// Replace variables with actual values
let rendered = rawSvg;
Object.keys(templateKeys).forEach(key => {
const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
rendered = rendered.replace(regex, templateKeys[key]);
});
// Fallback for missing keys to empty string
const missingRegex = /\{\{\s*(.*?)\s*\}\}/g;
rendered = rendered.replace(missingRegex, '');
// Convert to Blob URL
const blob = new Blob([rendered], { type: 'image/svg+xml' });
urls.push(URL.createObjectURL(blob));
}
return { Urls: urls };
}
// Prepare request parameters
const queryParams = new URLSearchParams();
queryParams.set('format', format);
if (w) queryParams.set('w', w);
if (h) queryParams.set('h', h);
const headers = this.getHeaders();
headers['Content-Type'] = 'application/json';
// POST request with a body
const res = await fetch(`${API_BASE_URL}/svg/${id}?${queryParams.toString()}`, {
method: 'POST',
headers: headers,
body: JSON.stringify({
TemplateKeys: templateKeys
})
});
if (!res.ok) {
const errMsg = await res.text();
throw new Error(errMsg || `Rendering fehlgeschlagen: ${res.statusText}`);
}
return await res.json();
},
async fetchAllFonts() {
if (state.isDemoMode) {
return ["Roboto-Regular.ttf", "Outfit-Bold.ttf", "Inter-Medium.ttf"];
}
const res = await fetch(`${API_BASE_URL}/font/`, {
headers: this.getHeaders()
});
if (!res.ok) {
throw new Error(`Schriften laden fehlgeschlagen: ${res.statusText}`);
}
return await res.json();
},
async uploadFont(fileBlob, filename) {
if (state.isDemoMode) {
// Mock upload
await new Promise(resolve => setTimeout(resolve, 800));
// Add custom font to list
const ext = filename.split('.').pop().toUpperCase();
if (!MOCK_FONTS.includes(filename)) {
MOCK_FONTS.unshift(filename);
}
return filename;
}
const headers = this.getHeaders();
const extension = filename.split('.').pop().toLowerCase();
headers['Content-Type'] = `font/${extension}`;
const res = await fetch(`${API_BASE_URL}/font/`, {
method: 'POST',
headers: headers,
body: fileBlob
});
if (!res.ok) {
const errMsg = await res.text();
throw new Error(errMsg || `Schrift-Upload fehlgeschlagen: ${res.statusText}`);
}
return filename;
}
};
// Global mock fonts store
const MOCK_FONTS = ["Roboto-Regular.ttf", "Outfit-Bold.ttf", "Inter-Medium.ttf"];
// 7. PRESENTATION & ROUTING ENGINE
function navigateTo(viewName) {
if (viewName === 'home') {
elements.viewEditor.classList.remove('active');
setTimeout(() => {
elements.viewEditor.classList.add('hidden');
elements.viewHome.classList.remove('hidden');
elements.viewHome.classList.add('active');
}, 150);
// Refresh list
loadTemplatesList();
} else if (viewName === 'editor') {
elements.viewHome.classList.remove('active');
setTimeout(() => {
elements.viewHome.classList.add('hidden');
elements.viewEditor.classList.remove('hidden');
elements.viewEditor.classList.add('active');
}, 150);
}
}
// Update the active UI Mode dashboard displays
function updateModeUI() {
// API Host display
try {
const parsedUrl = new URL(API_BASE_URL);
elements.statsApiHost.textContent = parsedUrl.host;
} catch {
elements.statsApiHost.textContent = API_BASE_URL || 'Unknown';
}
if (state.isDemoMode) {
elements.statsAppMode.textContent = 'Demo Modus';
elements.modeText.textContent = 'Demo Modus';
elements.appModeBadge.className = 'status-badge demo';
elements.pulseDot.className = 'pulse-dot demo';
elements.btnToggleDemo.classList.add('active');
} else if (state.isConnected) {
elements.statsAppMode.textContent = 'Live API';
elements.modeText.textContent = 'Live Verbunden';
elements.appModeBadge.className = 'status-badge live';
elements.pulseDot.className = 'pulse-dot live';
elements.btnToggleDemo.classList.remove('active');
} else {
elements.statsAppMode.textContent = 'Nicht verbunden';
elements.modeText.textContent = 'Nicht verbunden';
elements.appModeBadge.className = 'status-badge disconnected';
elements.pulseDot.className = 'pulse-dot disconnected';
elements.btnToggleDemo.classList.remove('active');
}
}
function enableDemoMode(enable) {
state.isDemoMode = enable;
updateModeUI();
loadTemplatesList();
loadFontsList();
}
// 8. TEMPLATE LIST VIEW RENDERER
async function loadTemplatesList() {
elements.templatesEmpty.classList.add('hidden');
elements.templatesGrid.classList.add('hidden');
elements.templatesLoading.classList.remove('hidden');
try {
state.templates = await API.fetchAllTemplates();
// Mark connection as confirmed (only if not in demo mode)
if (!state.isDemoMode) {
state.isConnected = true;
updateModeUI();
}
elements.templatesLoading.classList.add('hidden');
elements.statsTotalCount.textContent = state.templates.length;
if (state.templates.length === 0) {
elements.templatesEmpty.classList.remove('hidden');
return;
}
// Render grids
elements.templatesGrid.innerHTML = '';
state.templates.forEach(tpl => {
const card = document.createElement('div');
card.className = 'template-item';
card.id = `template-card-${tpl.Id}`;
// Calculate unique keys and page count across all pages
const uniqueKeys = [];
if (tpl.Pages) {
tpl.Pages.forEach(p => {
if (p.TemplateKeys) {
p.TemplateKeys.forEach(k => {
if (!uniqueKeys.includes(k)) {
uniqueKeys.push(k);
}
});
}
});
}
const varsCount = uniqueKeys.length;
const pageCount = tpl.Pages ? tpl.Pages.length : 0;
const displayName = tpl.Name || tpl.Id;
const subTitleHtml = `<span class="template-id-sub" style="font-size: 10px; color: var(--text-muted); margin-top: 2px; display: block;">ID: <code style="font-size: 9px; color: #ff79c6 !important; background: #08090d; border: 1px solid var(--border-color); padding: 1px 3px; border-radius: 2px;">${tpl.Id}</code></span>`;
card.innerHTML = `
<div class="template-meta">
<div class="template-icon-wrapper">
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none">
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline>
<polyline points="2 12 12 17 22 12"></polyline>
</svg>
</div>
<h3 class="template-name" title="${tpl.Id}">${displayName}</h3>
${subTitleHtml}
<span class="template-variable-count" style="margin-top: 6px; display: inline-flex; gap: 8px; flex-wrap: wrap;">
<span>${varsCount} ${varsCount === 1 ? 'Variable' : 'Variablen'}</span>
<span style="opacity: 0.5;">•</span>
<span>${pageCount} ${pageCount === 1 ? 'Seite' : 'Seiten'}</span>
</span>
</div>
<div class="template-actions">
<button class="template-btn-use" id="btn-use-${tpl.Id}">
<span>Auswählen</span>
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none">
<line x1="5" y1="12" x2="19" y2="12"></line>
<polyline points="12 5 19 12 12 19"></polyline>
</svg>
</button>
<button class="template-btn-rename" id="btn-rename-${tpl.Id}" title="Name zuweisen/ändern">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button class="template-btn-delete" id="btn-delete-${tpl.Id}" title="Vorlage löschen">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
`;
elements.templatesGrid.appendChild(card);
// Wire listeners
document.getElementById(`btn-use-${tpl.Id}`).addEventListener('click', () => {
openTemplateEditor(tpl);
});
document.getElementById(`btn-rename-${tpl.Id}`).addEventListener('click', async (e) => {
e.stopPropagation();
const oldName = tpl.Name || "";
const newName = await showCustomPrompt(`Name für Vorlage "${tpl.Id}"`, oldName, "Neuer Name...");
if (newName !== null) {
const trimmedName = newName.trim();
if (trimmedName === "") {
showToast("Der Name darf nicht leer sein!", "error");
return;
}
try {
await API.renameTemplate(tpl.Id, trimmedName);
showToast("Name erfolgreich zugewiesen!", "success");
loadTemplatesList();
// Update active editor name if it was the renamed one
if (state.activeTemplate && state.activeTemplate.Id === tpl.Id) {
state.activeTemplate.Name = trimmedName;
elements.editorTemplateName.textContent = trimmedName || tpl.Id;
}
} catch (err) {
showToast(`Fehler beim Zuweisen: ${err.message}`, "error");
}
}
});
document.getElementById(`btn-delete-${tpl.Id}`).addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(`Möchten Sie die Vorlage "${tpl.Id}" wirklich unwiderruflich löschen?`)) {
try {
const success = await API.deleteTemplate(tpl.Id);
if (success) {
showToast("Vorlage erfolgreich gelöscht", "success");
loadTemplatesList();
}
} catch (err) {
showToast(`Fehler beim Löschen: ${err.message}`, "error");
}
}
});
});
elements.templatesGrid.classList.remove('hidden');
} catch (err) {
// Mark as disconnected if this was a live-mode fetch
if (!state.isDemoMode) {
state.isConnected = false;
updateModeUI();
}
elements.templatesLoading.classList.add('hidden');
elements.templatesEmpty.classList.remove('hidden');
throw err; // Re-throw so init() or callers can handle it
}
}
// 9. DYNAMIC EDITOR ENGINE
function openTemplateEditor(template) {
state.activeTemplate = template;
state.zoom = 100;
// UI resets
elements.editorTemplateName.textContent = template.Name || template.Id;
if (elements.editorTemplateId) {
elements.editorTemplateId.textContent = template.Id;
}
elements.previewImage.classList.add('hidden');
elements.previewPlaceholder.classList.remove('hidden');
elements.previewLoadingOverlay.classList.add('hidden');
elements.btnDownloadPreview.classList.add('hidden');
if (elements.btnDownloadAll) elements.btnDownloadAll.classList.add('hidden');
elements.previewCard.classList.remove('fullscreen-active');
elements.btnFullscreen.classList.remove('active');
state.lastRenderedUrl = null;
state.renderedUrls = [];
state.activePageIndex = 0;
if (elements.pageNavigator) elements.pageNavigator.style.display = 'none';
applyZoom();
// Reset settings
state.format = 'svg';
elements.formatSvg.classList.add('active');
elements.formatPng.classList.remove('active');
elements.resolutionSettings.classList.add('hidden');
elements.renderWidth.value = '';
elements.renderHeight.value = '';
// Extract unique keys across all pages
const uniqueKeys = [];
if (template.Pages && template.Pages.length > 0) {
template.Pages.forEach(p => {
if (p.TemplateKeys) {
p.TemplateKeys.forEach(k => {
if (!uniqueKeys.includes(k)) {
uniqueKeys.push(k);
}
});
}
});
} else if (template.TemplateKeys) {
template.TemplateKeys.forEach(k => {
if (!uniqueKeys.includes(k)) {
uniqueKeys.push(k);
}
});
}
// Generate dynamic input elements
elements.dynamicInputsArea.innerHTML = '';
if (uniqueKeys.length === 0) {
elements.dynamicInputsArea.innerHTML = `
<div class="empty-state" style="padding: 20px;">
<p style="font-size: 13px; color: var(--text-secondary);">Keine Variablen in diesem Template gefunden.</p>
</div>
`;
} else {
uniqueKeys.forEach(key => {
const group = document.createElement('div');
group.className = 'input-group';
// Label
const label = document.createElement('label');
label.setAttribute('for', `input-var-${key}`);
label.textContent = key.replace(/_/g, ' '); // Clean look
// Input field
const input = document.createElement('input');
input.type = 'text';
input.id = `input-var-${key}`;
input.name = key;
input.className = 'custom-input';
input.placeholder = `Wert für ${key}...`;
input.required = false; // Allow empty replacement
// Setup autocomplete mocks for demo templates
if (state.isDemoMode) {
if (key === 'Company') input.value = 'TomatenTum Dev';
if (key === 'Name') input.value = 'Tim Müller';
if (key === 'Title') input.value = 'System Architect';
if (key === 'Email') input.value = 'tueem@tomatentum.net';
if (key === 'Phone') input.value = '+49 123 4567890';
if (key === 'Website') input.value = 'git.tomatentum.net';
if (key === 'Recipient') input.value = 'Joshua';
if (key === 'Amount') input.value = '50';
if (key === 'Occasion') input.value = 'Geburtstag';
if (key === 'Sender') input.value = 'Antigravity AI';
if (key === 'Date') input.value = new Date().toLocaleDateString('de-DE');
if (key === 'EventName') input.value = 'TomatenCon 2026';
if (key === 'AttendeeName') input.value = 'Tim Mueller';
if (key === 'AccessLevel') input.value = 'VIP GOLD';
if (key === 'SeatNumber') input.value = 'Reihe 4, Platz 12';
}
group.appendChild(label);
group.appendChild(input);
elements.dynamicInputsArea.appendChild(group);
});
}
// Refresh pages manager UI
renderPagesManager();
navigateTo('editor');
}
function renderPagesManager() {
if (!state.activeTemplate) return;
const pages = state.activeTemplate.Pages || [];
elements.editorPagesList.innerHTML = '';
if (pages.length === 0) {
const li = document.createElement('li');
li.className = 'font-list-empty';
li.style.padding = '12px';
li.style.fontSize = '12px';
li.style.textAlign = 'center';
li.style.color = 'var(--text-muted)';
li.textContent = 'Keine Seiten vorhanden.';
elements.editorPagesList.appendChild(li);
return;
}
pages.forEach(page => {
const li = document.createElement('li');
li.className = 'font-item';
li.style.display = 'flex';
li.style.justifyContent = 'space-between';
li.style.alignItems = 'center';
li.style.gap = '12px';
li.style.padding = '10px 14px';
// Left part: Page Indicator
const leftDiv = document.createElement('div');
leftDiv.style.display = 'flex';
leftDiv.style.alignItems = 'center';
leftDiv.style.gap = '8px';
const iconSpan = document.createElement('span');
iconSpan.className = 'font-item-icon';
iconSpan.style.color = '#bd93f9';
iconSpan.innerHTML = `
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
`;
const labelSpan = document.createElement('span');
labelSpan.style.fontWeight = '700';
labelSpan.textContent = `Seite ${page.Page}`;
leftDiv.appendChild(iconSpan);
leftDiv.appendChild(labelSpan);
// Middle part: Keys List
const middleDiv = document.createElement('div');
middleDiv.style.display = 'flex';
middleDiv.style.flexWrap = 'wrap';
middleDiv.style.gap = '4px';
middleDiv.style.maxWidth = '180px';
middleDiv.style.justifyContent = 'flex-start';
if (page.TemplateKeys && page.TemplateKeys.length > 0) {
page.TemplateKeys.forEach(k => {
const keyBadge = document.createElement('span');
keyBadge.style.fontSize = '9px';
keyBadge.style.background = 'rgba(0, 255, 204, 0.08)';
keyBadge.style.color = '#00ffcc';
keyBadge.style.border = '1px solid rgba(0, 255, 204, 0.2)';
keyBadge.style.padding = '1px 4px';
keyBadge.style.borderRadius = '2px';
keyBadge.style.fontFamily = 'monospace';
keyBadge.textContent = k;
middleDiv.appendChild(keyBadge);
});
} else {
const noKeysBadge = document.createElement('span');
noKeysBadge.style.fontSize = '9px';
noKeysBadge.style.color = 'var(--text-muted)';
noKeysBadge.textContent = 'keine Variablen';
middleDiv.appendChild(noKeysBadge);
}
// Right part: Actions (Delete Button)
const rightDiv = document.createElement('div');
// Only allow deleting if more than 1 page exists
if (pages.length > 1) {
const delBtn = document.createElement('button');
delBtn.className = 'template-btn-delete';
delBtn.title = 'Seite löschen';
delBtn.style.width = '28px';
delBtn.style.height = '28px';
delBtn.style.padding = '0';
delBtn.style.display = 'flex';
delBtn.style.alignItems = 'center';
delBtn.style.justifyContent = 'center';
delBtn.style.border = '2px solid var(--border-color) !important';
delBtn.innerHTML = `
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
`;
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(`Möchten Sie Seite ${page.Page} wirklich aus diesem Template löschen?`)) {
try {
showToast(`Lösche Seite ${page.Page}...`, "info");
const success = await API.deletePage(state.activeTemplate.Id, page.Page);
if (success) {
showToast("Seite erfolgreich gelöscht!", "success");
// Re-fetch template to get updated pages list
if (!state.isDemoMode) {
const freshTemplates = await API.fetchAllTemplates();
const freshActive = freshTemplates.find(t => t.Id === state.activeTemplate.Id);
if (freshActive) {
state.activeTemplate = freshActive;
}
}
// Reset pagination if needed
state.activePageIndex = 0;
state.renderedUrls = [];
elements.previewImage.classList.add('hidden');
elements.previewPlaceholder.classList.remove('hidden');
elements.btnDownloadPreview.classList.add('hidden');
if (elements.pageNavigator) elements.pageNavigator.style.display = 'none';
// Re-render editor pages manager
renderPagesManager();
// Refresh listing grid in background
loadTemplatesList();
}
} catch (err) {
showToast(`Fehler beim Löschen der Seite: ${err.message}`, "error");
}
}
});
rightDiv.appendChild(delBtn);
}
li.appendChild(leftDiv);
li.appendChild(middleDiv);
li.appendChild(rightDiv);
elements.editorPagesList.appendChild(li);
});
}
function updatePagePreview() {
if (!state.renderedUrls || state.renderedUrls.length === 0) {
if (elements.pageNavigator) elements.pageNavigator.style.display = 'none';
if (elements.btnDownloadAll) elements.btnDownloadAll.classList.add('hidden');
return;
}
const totalPages = state.renderedUrls.length;
if (state.activePageIndex >= totalPages) state.activePageIndex = totalPages - 1;
if (state.activePageIndex < 0) state.activePageIndex = 0;
const activeUrl = state.renderedUrls[state.activePageIndex];
// Update image src
elements.previewLoadingOverlay.classList.remove('hidden');
elements.previewImage.src = activeUrl;
// Update page navigator UI
if (totalPages > 1) {
if (elements.pageNavigator) {
elements.pageNavigator.style.display = 'flex';
elements.pageIndicator.textContent = `Seite ${state.activePageIndex + 1} / ${totalPages}`;
// Styled disabled state for tactile buttons
elements.btnPagePrev.disabled = (state.activePageIndex === 0);
elements.btnPagePrev.style.opacity = (state.activePageIndex === 0) ? '0.4' : '1';
elements.btnPagePrev.style.pointerEvents = (state.activePageIndex === 0) ? 'none' : 'auto';
elements.btnPageNext.disabled = (state.activePageIndex === totalPages - 1);
elements.btnPageNext.style.opacity = (state.activePageIndex === totalPages - 1) ? '0.4' : '1';
elements.btnPageNext.style.pointerEvents = (state.activePageIndex === totalPages - 1) ? 'none' : 'auto';
}
if (elements.btnDownloadAll) elements.btnDownloadAll.classList.remove('hidden');
} else {
if (elements.pageNavigator) elements.pageNavigator.style.display = 'none';
if (elements.btnDownloadAll) elements.btnDownloadAll.classList.add('hidden');
}
}
async function handleRenderSubmit(e) {
if (e && typeof e.preventDefault === 'function') {
e.preventDefault();
}
if (!state.activeTemplate) return;
// Toggle button and overlay loading indicators
elements.btnSubmitRender.disabled = true;
elements.renderSpinner.classList.remove('hidden');
elements.renderBtnIcon.classList.add('hidden');
elements.renderBtnText.textContent = 'Rendere...';
elements.previewLoadingOverlay.classList.remove('hidden');
try {
// Extract unique keys across all pages
const uniqueKeys = [];
if (state.activeTemplate.Pages && state.activeTemplate.Pages.length > 0) {
state.activeTemplate.Pages.forEach(p => {
if (p.TemplateKeys) {
p.TemplateKeys.forEach(k => {
if (!uniqueKeys.includes(k)) {
uniqueKeys.push(k);
}
});
}
});
} else if (state.activeTemplate.TemplateKeys) {
state.activeTemplate.TemplateKeys.forEach(k => {
if (!uniqueKeys.includes(k)) {
uniqueKeys.push(k);
}
});
}
const formValues = {};
uniqueKeys.forEach(key => {
const inputEl = document.getElementById(`input-var-${key}`);
if (inputEl) {
formValues[key] = inputEl.value;
}
});
const widthVal = elements.renderWidth.value ? parseInt(elements.renderWidth.value) : 0;
const heightVal = elements.renderHeight.value ? parseInt(elements.renderHeight.value) : 0;
const response = await API.renderTemplate(
state.activeTemplate.Id,
formValues,
state.format,
widthVal,
heightVal
);
state.renderedUrls = response.Urls || [];
state.activePageIndex = 0;
state.suppressRenderToast = false; // Enable success toast for initial submit
// Register preview display handlers
elements.previewImage.onload = () => {
elements.previewPlaceholder.classList.add('hidden');
elements.previewImage.classList.remove('hidden');
elements.previewLoadingOverlay.classList.add('hidden');
elements.btnDownloadPreview.classList.remove('hidden');
if (!state.suppressRenderToast) {
showToast("Grafik erfolgreich gerendert!", "success");
}
state.suppressRenderToast = false; // Reset
};
elements.previewImage.onerror = () => {
elements.previewPlaceholder.classList.remove('hidden');
elements.previewImage.classList.add('hidden');
elements.previewLoadingOverlay.classList.add('hidden');
elements.btnDownloadPreview.classList.add('hidden');
if (elements.btnDownloadAll) elements.btnDownloadAll.classList.add('hidden');
showToast("Fehler beim Laden des gerenderten Bildes.", "error");
};
// Trigger the image load in the UI
updatePagePreview();
} catch (err) {
console.error("Render submit failed:", err);
showToast(`Rendering-Fehler: ${err.message}`, "error");
elements.previewLoadingOverlay.classList.add('hidden');
} finally {
elements.btnSubmitRender.disabled = false;
elements.renderSpinner.classList.add('hidden');
elements.renderBtnIcon.classList.remove('hidden');
elements.renderBtnText.textContent = 'Rendering erstellen';
}
}
// Dedicated download function using Blob fetching to force download instead of opening a new tab
async function downloadActiveRender() {
const activeUrl = state.renderedUrls[state.activePageIndex];
if (!activeUrl) return;
try {
showToast("Bereite Download vor...", "info");
// Fetch as blob to force browser download dialog (CORS is resolved because /public/ is open)
const fileRes = await fetch(activeUrl);
if (!fileRes.ok) throw new Error("Grafik konnte nicht vom Server abgerufen werden.");
const blob = await fileRes.blob();
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
// Generate nice filename: templateId_page-X_timestamp.format
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const pageNum = state.activePageIndex + 1;
downloadLink.download = `${state.activeTemplate.Id}_page-${pageNum}_${timestamp}.${state.format}`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
// Clean up blob URL after a short delay
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
showToast("Grafik erfolgreich heruntergeladen!", "success");
} catch (err) {
console.error("Blob download failed, using fallback:", err);
// Fallback: direct navigation download if blob fails
const downloadLink = document.createElement('a');
downloadLink.href = activeUrl;
const pageNum = state.activePageIndex + 1;
downloadLink.download = `${state.activeTemplate.Id}_page-${pageNum}.${state.format}`;
downloadLink.target = '_blank';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
showToast("Download über Ausweichmethode gestartet.", "warning");
}
}
async function downloadAllPages() {
if (!state.renderedUrls || state.renderedUrls.length === 0) return;
if (typeof JSZip === 'undefined') {
showToast("ZIP-Bibliothek wird noch geladen. Bitte kurz warten...", "warning");
return;
}
showToast("Erstelle ZIP-Datei aller Seiten...", "info");
const zip = new JSZip();
const folder = zip.folder("seiten");
try {
for (let i = 0; i < state.renderedUrls.length; i++) {
const activeUrl = state.renderedUrls[i];
const fileRes = await fetch(activeUrl);
if (!fileRes.ok) throw new Error(`Seite ${i+1} konnte nicht geladen werden.`);
const blob = await fileRes.blob();
const pageNum = i + 1;
const filename = `${state.activeTemplate.Id}_page-${pageNum}.${state.format}`;
folder.file(filename, blob);
}
const zipBlob = await zip.generateAsync({ type: "blob" });
const blobUrl = URL.createObjectURL(zipBlob);
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
downloadLink.download = `${state.activeTemplate.Id}_all-pages_${timestamp}.zip`;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
showToast("ZIP-Datei erfolgreich heruntergeladen!", "success");
} catch (err) {
console.error("ZIP packaging failed:", err);
showToast(`Fehler beim ZIP-Erstellen: ${err.message}`, "error");
}
}
// 9.5 FONTS MANAGEMENT ENGINE
async function loadFontsList() {
elements.fontsEmpty.classList.add('hidden');
elements.fontsList.classList.add('hidden');
elements.fontsLoading.classList.remove('hidden');
try {
const fontItems = state.isDemoMode ? MOCK_FONTS : await API.fetchAllFonts();
state.fonts = fontItems || [];
elements.fontsLoading.classList.add('hidden');
if (state.fonts.length === 0) {
elements.fontsEmpty.classList.remove('hidden');
return;
}
elements.fontsList.innerHTML = '';
state.fonts.forEach(font => {
const li = document.createElement('li');
li.className = 'font-item';
li.innerHTML = `
<span class="font-item-icon">
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none">
<polyline points="4 7 4 4 20 4 20 7"></polyline>
<line x1="9" y1="20" x2="15" y2="20"></line>
<line x1="12" y1="4" x2="12" y2="20"></line>
</svg>
</span>
<span>${font}</span>
`;
elements.fontsList.appendChild(li);
});
elements.fontsList.classList.remove('hidden');
} catch (err) {
console.warn("Failed to load fonts:", err);
elements.fontsLoading.classList.add('hidden');
elements.fontsEmpty.classList.remove('hidden');
}
}
async function handleFontUpload(files) {
if (files.length === 0) return;
const file = files[0];
const extension = file.name.split('.').pop().toLowerCase();
if (extension !== 'ttf' && extension !== 'otf') {
showToast("Es werden nur .ttf und .otf Schriftdateien unterstützt.", "error");
return;
}
showToast("Lade Schriftart hoch...", "info");
try {
// Read file as ArrayBuffer/Blob
const reader = new FileReader();
reader.onload = async (e) => {
try {
const arrayBuffer = e.target.result;
const blob = new Blob([arrayBuffer], { type: `font/${extension}` });
await API.uploadFont(blob, file.name);
showToast(`Schriftart "${file.name}" erfolgreich installiert!`, "success");
loadFontsList();
} catch (err) {
showToast(`Schrift-Upload fehlgeschlagen: ${err.message}`, "error");
}
};
reader.readAsArrayBuffer(file);
} catch (err) {
showToast(`Fehler beim Lesen der Datei: ${err.message}`, "error");
}
}
// 10. PREVIEW CANVAS ZOOM LOGIC
function applyZoom() {
elements.previewCanvasContainer.style.setProperty('--zoom', state.zoom / 100);
if (elements.zoomBadge) {
elements.zoomBadge.textContent = `${state.zoom}%`;
}
}
elements.btnZoomIn.addEventListener('click', () => {
if (state.zoom < 1500) {
// Dynamic step sizes for comfortable vector scaling
if (state.zoom >= 400) {
state.zoom += 100;
} else if (state.zoom >= 150) {
state.zoom += 50;
} else {
state.zoom += 25;
}
applyZoom();
}
});
elements.btnZoomOut.addEventListener('click', () => {
if (state.zoom > 25) {
// Dynamic step sizes for comfortable vector scaling
if (state.zoom > 400) {
state.zoom -= 100;
} else if (state.zoom > 150) {
state.zoom -= 50;
} else {
state.zoom -= 25;
}
applyZoom();
}
});
// Toggle Fullscreen Mode in same tab
elements.btnFullscreen.addEventListener('click', () => {
const isFullscreen = elements.previewCard.classList.toggle('fullscreen-active');
elements.btnFullscreen.classList.toggle('active', isFullscreen);
if (isFullscreen) {
showToast("Vollbildmodus aktiviert", "info");
} else {
showToast("Vollbildmodus beendet", "info");
}
// Reset zoom when toggling fullscreen to fit content nicely
state.zoom = 100;
applyZoom();
});
// Reset Zoom function
elements.btnZoomReset.addEventListener('click', () => {
state.zoom = 100;
applyZoom();
// Center the viewport back to center
elements.previewViewport.scrollLeft = (elements.previewViewport.scrollWidth - elements.previewViewport.clientWidth) / 2;
elements.previewViewport.scrollTop = (elements.previewViewport.scrollHeight - elements.previewViewport.clientHeight) / 2;
showToast("Zoom zurückgesetzt", "info");
});
// 10.5 PREVIEW DRAG & PAN ENGINE
function setupPreviewPanning() {
const viewport = elements.previewViewport;
let isDown = false;
let startX;
let startY;
let scrollLeft;
let scrollTop;
// Apply grab cursor styling dynamically
viewport.style.cursor = 'grab';
viewport.addEventListener('mousedown', (e) => {
// Only pan on left mouse button click
if (e.button !== 0) return;
// Prevent pan if clicking interactive elements inside viewport
if (e.target.closest('button') || e.target.closest('a')) return;
isDown = true;
viewport.style.cursor = 'grabbing';
startX = e.pageX - viewport.offsetLeft;
startY = e.pageY - viewport.offsetTop;
scrollLeft = viewport.scrollLeft;
scrollTop = viewport.scrollTop;
});
viewport.addEventListener('mouseleave', () => {
isDown = false;
viewport.style.cursor = 'grab';
});
viewport.addEventListener('mouseup', () => {
isDown = false;
viewport.style.cursor = 'grab';
});
viewport.addEventListener('mousemove', (e) => {
if (!isDown) return;
e.preventDefault(); // Prevent text selection while dragging
const x = e.pageX - viewport.offsetLeft;
const y = e.pageY - viewport.offsetTop;
const walkX = (x - startX) * 1.5;
const walkY = (y - startY) * 1.5;
viewport.scrollLeft = scrollLeft - walkX;
viewport.scrollTop = scrollTop - walkY;
});
}
// 11. UPLOAD DRAG AND DROP FLOW
function setupDragAndDrop() {
const dropZone = elements.dropZone;
const fileInput = elements.fileInput;
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('dragover');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('dragover');
}, false);
});
dropZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
handleFiles(files);
});
fileInput.addEventListener('change', (e) => {
handleFiles(fileInput.files);
});
}
function handleFiles(files) {
if (files.length === 0) return;
const file = files[0];
if (file.type !== 'image/svg+xml' && !file.name.endsWith('.svg')) {
showToast("Es werden nur gültige .svg Dateien unterstützt.", "error");
return;
}
(async () => {
// Prompt for template name immediately, block nameless templates
let templateName = '';
while (true) {
const input = await showCustomPrompt(
"Name für neues Template festlegen",
file.name.replace('.svg', ''),
"z.B. Visitenkarte..."
);
if (input === null) {
showToast("Upload abgebrochen.", "warning");
return; // User cancelled
}
if (input.trim() !== '') {
templateName = input.trim();
break;
}
showToast("Der Vorlagen-Name darf nicht leer sein! Bitte geben Sie einen Namen ein.", "error");
}
showToast("Lade SVG Template hoch...", "info");
try {
// Pass custom name to uploadSVG so that it is assigned directly in Demo Mode or Live Mode via ?name= query
await API.uploadSVG(file, file.name, templateName);
showToast(`Template "${templateName}" erfolgreich registriert!`, "success");
loadTemplatesList();
} catch (err) {
showToast(`Upload fehlgeschlagen: ${err.message}`, "error");
}
})();
}
// 12. GENERAL INTERFACES WIRING
function wireControls() {
// Navigation back home button
elements.btnBackHome.addEventListener('click', () => {
navigateTo('home');
});
// Toggle demo mode statically
elements.btnToggleDemo.addEventListener('click', async () => {
if (!state.isDemoMode) {
// Currently LIVE → switch to Demo
enableDemoMode(true);
showToast("Demo-Modus aktiv!", "success");
} else {
// Currently DEMO → try to switch to Live
showToast("Verbinde mit Live-API...", "info");
try {
const res = await fetch(`${API_BASE_URL}/svg/`, {
headers: API.getHeaders()
});
if (!res.ok) {
if (res.status === 401) {
throw new Error("Nicht autorisiert. Bitte prüfen Sie den statischen Token.");
}
throw new Error(`Server-Fehler: ${res.status}`);
}
// Connection successful
state.isDemoMode = false;
state.isConnected = true;
updateModeUI();
await loadTemplatesList();
await loadFontsList();
showToast("Erfolgreich mit Live-API verbunden!", "success");
} catch (err) {
console.warn("Live API connection failed:", err);
showToast(`Backend nicht erreichbar: ${err.message}`, "error");
// Stay in Demo Mode, mark not connected
state.isConnected = false;
updateModeUI();
}
}
});
// Output Formats selection toggler
elements.formatSvg.addEventListener('click', () => {
state.format = 'svg';
elements.formatSvg.classList.add('active');
elements.formatPng.classList.remove('active');
elements.resolutionSettings.classList.add('hidden');
});
elements.formatPng.addEventListener('click', () => {
state.format = 'png';
elements.formatPng.classList.add('active');
elements.formatSvg.classList.remove('active');
elements.resolutionSettings.classList.remove('hidden');
});
// Render Click trigger
elements.btnSubmitRender.addEventListener('click', handleRenderSubmit);
// Font upload triggers
elements.btnUploadFont.addEventListener('click', () => {
elements.fontFileInput.click();
});
elements.fontFileInput.addEventListener('change', () => {
handleFontUpload(elements.fontFileInput.files);
});
// Preview download button trigger
elements.btnDownloadPreview.addEventListener('click', downloadActiveRender);
// Preview download all pages button trigger
if (elements.btnDownloadAll) {
elements.btnDownloadAll.addEventListener('click', downloadAllPages);
}
// Active template rename button trigger
elements.btnRenameActive.addEventListener('click', async () => {
if (!state.activeTemplate) return;
const tpl = state.activeTemplate;
const oldName = tpl.Name || "";
const newName = await showCustomPrompt("Name der Vorlage ändern", oldName, "Neuer Name...");
if (newName !== null) {
const trimmedName = newName.trim();
if (trimmedName === "") {
showToast("Der Name darf nicht leer sein!", "error");
return;
}
try {
await API.renameTemplate(tpl.Id, trimmedName);
showToast("Name erfolgreich zugewiesen!", "success");
// Update local state
state.activeTemplate.Name = trimmedName;
elements.editorTemplateName.textContent = trimmedName || tpl.Id;
// Refresh home list in background so it's fresh when navigating back
loadTemplatesList();
} catch (err) {
showToast(`Fehler beim Zuweisen: ${err.message}`, "error");
}
}
});
// Pagination controls
elements.btnPagePrev.addEventListener('click', () => {
if (state.activePageIndex > 0) {
state.activePageIndex--;
state.suppressRenderToast = true;
updatePagePreview();
}
});
elements.btnPageNext.addEventListener('click', () => {
if (state.activePageIndex < state.renderedUrls.length - 1) {
state.activePageIndex++;
state.suppressRenderToast = true;
updatePagePreview();
}
});
// Add page file input triggers
elements.btnAddPage.addEventListener('click', () => {
elements.pageFileInput.click();
});
elements.pageFileInput.addEventListener('change', async () => {
const files = elements.pageFileInput.files;
if (!files || files.length === 0) return;
if (!state.activeTemplate) return;
showToast("Füge Seite(n) zu Template hinzu...", "info");
try {
const pages = await API.addPage(state.activeTemplate.Id, files);
showToast("Seite(n) erfolgreich hinzugefügt!", "success");
// Re-fetch active template to get fresh data
if (!state.isDemoMode) {
const freshTemplates = await API.fetchAllTemplates();
const freshActive = freshTemplates.find(t => t.Id === state.activeTemplate.Id);
if (freshActive) {
state.activeTemplate = freshActive;
}
}
// Update pages list
renderPagesManager();
// Refresh variables form in case new pages introduced new variables
openTemplateEditor(state.activeTemplate);
// Refresh home grid listing in background
loadTemplatesList();
} catch (err) {
showToast(`Fehler beim Hinzufügen: ${err.message}`, "error");
} finally {
elements.pageFileInput.value = '';
}
});
}
// 13. INITIALIZATION ON BOOT
async function init() {
setupDragAndDrop();
setupPreviewPanning();
wireControls();
updateModeUI();
// Try to connect to live API on start; if it fails, stay in live mode
// (user can manually switch to Demo Mode via the button)
try {
await Promise.all([
loadTemplatesList(),
loadFontsList()
]);
} catch (err) {
console.warn("Initial API connection failed:", err);
elements.templatesLoading.classList.add('hidden');
elements.templatesEmpty.classList.remove('hidden');
showToast("Backend nicht erreichbar. Drücken Sie 'Demo Modus' um Beispieldaten zu laden.", "warning");
}
}
document.addEventListener('DOMContentLoaded', init);