diff --git a/assets.go b/assets.go
new file mode 100644
index 0000000..44904d1
--- /dev/null
+++ b/assets.go
@@ -0,0 +1,6 @@
+package svgtemplater
+
+import "embed"
+
+//go:embed frontend/*
+var Frontend embed.FS
diff --git a/frontend/app.js b/frontend/app.js
new file mode 100644
index 0000000..2152a6e
--- /dev/null
+++ b/frontend/app.js
@@ -0,0 +1,1955 @@
+/* ==========================================================================
+ 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": `
+
+ `,
+ "gift_certificate_1": `
+
+ `,
+ "gift_certificate_2": `
+
+ `,
+ "event_badge_1": `
+
+ `
+};
+
+// 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 = '';
+ } else if (type === 'error') {
+ iconSvg = '';
+ } else if (type === 'warning') {
+ iconSvg = '';
+ } else {
+ iconSvg = '';
+ }
+
+ toast.innerHTML = `
+ ${iconSvg}
+ ${message}
+ `;
+
+ 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 = `ID: ${tpl.Id}`;
+
+ card.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ 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 = `
+
+
Keine Variablen in diesem Template gefunden.
+
+ `;
+ } 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 = `
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `
+
+
+
+ ${font}
+ `;
+ 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);
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..6371cf5
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,361 @@
+
+
+
+
+
+ SVG Templater - Premium Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SVG Datei hierher ziehen
+
oder Computer durchsuchen
+
Ausschließlich valide .svg Dateien
+
+
+
+
+
+
+
+
+
+
+
Installierte Schriften
+
+
+ Keine benutzerdefinierten Schriften geladen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lade SVG Templates...
+
+
+
+
+
+
+
+
Keine Templates vorhanden
+
Laden Sie Ihre erste SVG-Vorlage hoch oder aktivieren Sie den Demo-Modus, um fortzufahren.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Vorlage
+
template_name.svg
+
+
+
ID: template_id
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Werte eintragen und Rendering starten
+
+
![Rendered Preview]()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/style.css b/frontend/style.css
new file mode 100644
index 0000000..330ead7
--- /dev/null
+++ b/frontend/style.css
@@ -0,0 +1,1124 @@
+/* ==========================================================================
+ SVG TEMPLATER DESIGN SYSTEM
+ Premium minimalist dark theme - clean, structured, and focused.
+ ========================================================================== */
+
+:root {
+ /* Modern Slate Palette */
+ --bg-main: #0c0e15;
+ --bg-card: #141724;
+ --bg-input: #0a0b10;
+
+ --border-color: rgba(255, 255, 255, 0.08);
+ --border-hover: rgba(99, 102, 241, 0.4);
+
+ --text-primary: #f3f4f6;
+ --text-secondary: #9ca3af;
+ --text-muted: #4b5563;
+
+ /* Vibrant Accent Colors */
+ --accent: #6366f1;
+ --accent-hover: #4f46e5;
+ --success: #10b981;
+ --danger: #ef4444;
+ --warning: #f59e0b;
+ --info: #3b82f6;
+ --pink: #ff79c6;
+
+ /* Sizing & Transitions */
+ --border-radius: 6px;
+ --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+
+ /* Fonts */
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+}
+
+/* Base Reset */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ font-family: var(--font-sans);
+}
+
+body {
+ background-color: var(--bg-main);
+ color: var(--text-primary);
+ min-height: 100vh;
+ overflow-x: hidden;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* Scrollbars */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: var(--bg-main);
+}
+::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: var(--border-hover);
+}
+
+/* App Container Layout */
+.app-container {
+ max-width: 1300px;
+ margin: 0 auto;
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ min-height: 100vh;
+}
+
+/* Header Navbar */
+.app-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: 16px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.logo-area {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.logo-icon {
+ background: rgba(99, 102, 241, 0.1);
+ color: var(--accent);
+ width: 36px;
+ height: 36px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--border-radius);
+ border: 1px solid rgba(99, 102, 241, 0.2);
+}
+
+.logo-area h1 {
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -0.3px;
+}
+
+.logo-area h1 span {
+ color: var(--accent);
+}
+
+/* Inline Status Display */
+.header-status {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.status-badge {
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--border-color);
+ padding: 6px 12px;
+ border-radius: 50px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.pulse-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background-color: var(--text-muted);
+}
+
+.pulse-dot.live {
+ background-color: var(--success);
+ box-shadow: 0 0 8px var(--success);
+}
+
+.pulse-dot.demo {
+ background-color: var(--warning);
+ box-shadow: 0 0 8px var(--warning);
+}
+
+.status-meta {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.status-meta-item {
+ border-left: 1px solid var(--border-color);
+ padding-left: 12px;
+}
+
+.status-meta-item code, .status-meta-item span {
+ font-family: var(--font-mono);
+ color: var(--text-primary);
+}
+
+/* Workspace Layouts */
+.workspace-area {
+ flex-grow: 1;
+}
+
+.grid-layout {
+ display: grid;
+ grid-template-columns: 360px 1fr;
+ gap: 24px;
+ align-items: start;
+}
+
+.grid-layout-editor {
+ display: grid;
+ grid-template-columns: 380px 1fr;
+ gap: 24px;
+ align-items: start;
+}
+
+.left-column {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
+/* Cards System */
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+}
+
+.card-header {
+ padding: 16px 20px;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.card-header h2 {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.card-header p {
+ font-size: 12px;
+ color: var(--text-secondary);
+ margin-top: 2px;
+}
+
+.card-body {
+ padding: 20px;
+ flex-grow: 1;
+}
+
+/* Drag-and-Drop Zone */
+.drop-zone {
+ border: 1px dashed var(--border-color);
+ border-radius: var(--border-radius);
+ padding: 36px 16px;
+ text-align: center;
+ cursor: pointer;
+ background: rgba(255, 255, 255, 0.01);
+ position: relative;
+ transition: var(--transition);
+ overflow: hidden;
+}
+
+.drop-zone:hover, .drop-zone.dragover {
+ border-color: var(--border-hover);
+ background: rgba(99, 102, 241, 0.02);
+}
+
+.file-input-hidden {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ opacity: 0;
+ cursor: pointer;
+ z-index: 10;
+}
+
+.drop-zone-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ pointer-events: none; /* Let clicks pass to the hidden absolute input */
+}
+
+.drop-icon {
+ color: var(--text-secondary);
+ background: rgba(255, 255, 255, 0.02);
+ width: 52px;
+ height: 52px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--border-color);
+ transition: var(--transition);
+}
+
+.drop-zone:hover .drop-icon {
+ color: var(--accent);
+ background: rgba(99, 102, 241, 0.05);
+ border-color: rgba(99, 102, 241, 0.25);
+ transform: scale(1.05);
+}
+
+.drop-zone-content h3 {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.drop-zone-content p {
+ font-size: 12px;
+ color: var(--text-secondary);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+
+/* Beautiful virtual button inside drop zone */
+.drop-zone-content p span {
+ background: rgba(99, 102, 241, 0.08);
+ color: var(--accent);
+ border: 1px solid rgba(99, 102, 241, 0.15);
+ padding: 6px 14px;
+ border-radius: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ transition: var(--transition);
+ margin-top: 4px;
+}
+
+.drop-zone:hover .drop-zone-content p span {
+ background: var(--accent);
+ color: #ffffff;
+ border-color: transparent;
+ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.25);
+}
+
+.file-limits {
+ font-size: 10px;
+ color: var(--text-muted);
+}
+
+/* Template Cards Grid */
+.templates-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 16px;
+}
+
+.template-item {
+ background: rgba(255, 255, 255, 0.01);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 12px;
+ transition: var(--transition);
+}
+
+.template-item:hover {
+ border-color: var(--border-hover);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.template-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.template-icon-wrapper {
+ color: var(--accent);
+ background: rgba(99, 102, 241, 0.05);
+ width: 32px;
+ height: 32px;
+ border-radius: var(--border-radius);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 4px;
+ border: 1px solid rgba(99, 102, 241, 0.1);
+}
+
+.template-name {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.template-id-sub {
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.template-id-sub code {
+ font-family: var(--font-mono);
+ color: var(--pink);
+ background: var(--bg-input);
+ border: 1px solid var(--border-color);
+ padding: 1px 4px;
+ border-radius: 2px;
+}
+
+.template-variable-count {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+ padding: 2px 8px;
+ border-radius: 50px;
+ font-size: 10px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.template-actions {
+ display: flex;
+ gap: 8px;
+ margin-top: 4px;
+}
+
+/* Universal Button Design */
+.submit-btn, .template-btn-use, .back-btn, .preview-action-btn, .download-action-btn, .icon-btn-toggle, .template-btn-rename, .template-btn-delete {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+ font-weight: 500;
+ border-radius: var(--border-radius);
+ cursor: pointer;
+ transition: var(--transition);
+ border: 1px solid var(--border-color);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text-primary);
+ padding: 8px 12px;
+}
+
+.submit-btn:hover, .template-btn-use:hover, .back-btn:hover, .preview-action-btn:hover, .icon-btn-toggle:hover, .template-btn-rename:hover, .template-btn-delete:hover {
+ border-color: rgba(255, 255, 255, 0.2);
+ background: rgba(255, 255, 255, 0.07);
+}
+
+/* Specialized Buttons */
+.submit-btn {
+ width: 100%;
+ background: var(--accent);
+ color: #ffffff;
+ border: none;
+ padding: 10px 16px;
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.submit-btn:hover {
+ background: var(--accent-hover);
+}
+
+.template-btn-use {
+ flex-grow: 1;
+ background: rgba(99, 102, 241, 0.08);
+ color: var(--text-primary);
+ border-color: rgba(99, 102, 241, 0.2);
+ gap: 6px;
+}
+
+.template-btn-use:hover {
+ background: var(--accent);
+ color: #ffffff;
+ border-color: transparent;
+}
+
+.template-btn-delete {
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ color: var(--danger);
+ background: rgba(239, 68, 68, 0.05);
+ border-color: rgba(239, 68, 68, 0.15);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.template-btn-delete:hover {
+ background: var(--danger);
+ color: #ffffff;
+ border-color: transparent;
+}
+
+.template-btn-rename {
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ color: var(--pink);
+ background: rgba(255, 121, 198, 0.05);
+ border-color: rgba(255, 121, 198, 0.15);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.template-btn-rename:hover {
+ background: var(--pink);
+ color: #ffffff;
+ border-color: transparent;
+}
+
+.icon-btn-toggle {
+ background: rgba(245, 158, 11, 0.05);
+ color: var(--warning);
+ border-color: rgba(245, 158, 11, 0.15);
+ padding: 6px 12px;
+ gap: 6px;
+}
+
+.icon-btn-toggle.active {
+ background: var(--warning);
+ color: #000000;
+ border-color: transparent;
+}
+
+.icon-btn-toggle.active:hover {
+ background: #d97706;
+}
+
+/* Custom Input Field */
+.custom-input {
+ background: var(--bg-input);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ padding: 10px 14px;
+ border-radius: var(--border-radius);
+ font-size: 13px;
+ width: 100%;
+ outline: none;
+ transition: var(--transition);
+}
+
+.custom-input:focus {
+ border-color: var(--accent);
+}
+
+.custom-input::placeholder {
+ color: var(--text-muted);
+}
+
+/* Fonts Management */
+.font-list-container {
+ margin-top: 6px;
+}
+
+.font-list-container h3 {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+}
+
+.font-list {
+ list-style: none;
+ max-height: 120px;
+ overflow-y: auto;
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ background: rgba(0, 0, 0, 0.1);
+}
+
+.font-item {
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--text-primary);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.font-item:last-child {
+ border-bottom: none;
+}
+
+.font-item-icon {
+ color: var(--warning);
+ display: flex;
+ align-items: center;
+}
+
+.font-list-empty {
+ padding: 12px;
+ text-align: center;
+ font-size: 11px;
+ color: var(--text-muted);
+ border: 1px dashed var(--border-color);
+ border-radius: var(--border-radius);
+}
+
+/* Editor Section */
+.editor-navigation {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 16px;
+ border-bottom: 1px solid var(--border-color);
+ padding-bottom: 16px;
+}
+
+.active-template-title {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.active-template-title h2 {
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.tag {
+ background: rgba(168, 85, 247, 0.08);
+ color: #c084fc;
+ border: 1px solid rgba(168, 85, 247, 0.15);
+ font-size: 9px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 2px 6px;
+ border-radius: 50px;
+}
+
+.form-inputs-container {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ max-height: 280px;
+ overflow-y: auto;
+ padding-right: 4px;
+ margin-bottom: 16px;
+}
+
+.input-group {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.input-group label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+}
+
+.form-divider {
+ height: 1px;
+ background: var(--border-color);
+ margin: 16px 0;
+}
+
+/* Output Options */
+.rendering-settings {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.rendering-settings h3 {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+}
+
+.setting-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+}
+
+.setting-row label {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.format-selector {
+ display: flex;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+ padding: 2px;
+ border-radius: var(--border-radius);
+}
+
+.format-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ padding: 4px 12px;
+ font-size: 11px;
+ font-weight: 500;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: var(--transition);
+}
+
+.format-btn:hover {
+ color: var(--text-primary);
+}
+
+.format-btn.active {
+ background: var(--accent);
+ color: #ffffff;
+}
+
+.resolution-inputs {
+ display: flex;
+ gap: 8px;
+ width: 160px;
+}
+
+.input-with-label {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex-grow: 1;
+}
+
+.input-with-label input {
+ background: var(--bg-input);
+ border: 1px solid var(--border-color);
+ color: var(--text-primary);
+ padding: 6px 8px;
+ border-radius: 4px;
+ font-size: 11px;
+ width: 100%;
+ outline: none;
+ text-align: center;
+}
+
+.input-with-label input:focus {
+ border-color: var(--accent);
+}
+
+.input-with-label span {
+ font-size: 9px;
+ color: var(--text-muted);
+ text-align: center;
+}
+
+/* Page Manager */
+.pages-manager-card {
+ margin-top: 0;
+}
+
+/* Live Viewport */
+.preview-card {
+ height: 100%;
+}
+
+.preview-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.preview-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.preview-action-btn {
+ padding: 6px;
+ width: 28px;
+ height: 28px;
+}
+
+.zoom-badge {
+ font-size: 10px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border-color);
+ padding: 4px 8px;
+ border-radius: var(--border-radius);
+ height: 28px;
+ display: inline-flex;
+ align-items: center;
+}
+
+.download-action-btn {
+ background: var(--accent);
+ border: none;
+ color: #ffffff;
+ padding: 0 12px;
+ height: 28px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.download-action-btn:hover {
+ background: var(--accent-hover);
+}
+
+.download-all-btn {
+ background: var(--pink);
+}
+
+.download-all-btn:hover {
+ background: #e062b0;
+}
+
+/* Preview Canvas Figma Grid Background */
+.canvas-body {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #090a0f;
+ padding: 0;
+ min-height: 480px;
+ max-height: 560px;
+ position: relative;
+ overflow: hidden;
+}
+
+.canvas-viewport {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ overflow: auto;
+ padding: 24px;
+}
+
+.canvas-container {
+ position: relative;
+ background-color: #0e111a;
+ /* Soft premium low-contrast checkerboard */
+ background-image:
+ linear-gradient(45deg, #141722 25%, transparent 25%),
+ linear-gradient(-45deg, #141722 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, #141722 75%),
+ linear-gradient(-45deg, transparent 75%, #141722 75%);
+ background-size: 16px 16px;
+ background-position: 0 0, 0 8px, 8px -8px, -8px 0px;
+ border-radius: 4px;
+ border: 1px solid var(--border-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: calc(100% * var(--zoom, 1));
+ max-width: calc(760px * var(--zoom, 1));
+ flex-shrink: 0;
+ margin: auto;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+}
+
+.canvas-placeholder {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ color: var(--text-secondary);
+ text-align: center;
+ padding: 48px;
+}
+
+.canvas-placeholder svg {
+ color: var(--text-muted);
+}
+
+.canvas-placeholder p {
+ font-size: 12px;
+}
+
+.pulse-opacity {
+ animation: pulsing 2s infinite ease-in-out;
+}
+
+@keyframes pulsing {
+ 0% { opacity: 0.4; }
+ 50% { opacity: 0.8; }
+ 100% { opacity: 0.4; }
+}
+
+.canvas-container img {
+ width: 100%;
+ height: auto;
+ display: block;
+ border-radius: 4px;
+}
+
+.canvas-loading {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(12, 14, 21, 0.8);
+ backdrop-filter: blur(2px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ z-index: 100;
+}
+
+.canvas-loading p {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* Spinner */
+.spinner {
+ width: 32px;
+ height: 32px;
+ border: 2px solid rgba(255, 255, 255, 0.05);
+ border-top: 2px solid var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+.spinner-small {
+ width: 14px;
+ height: 14px;
+ border: 2px solid rgba(255, 255, 255, 0.05);
+ border-top: 2px solid var(--warning);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ margin: 8px auto;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+.loading-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ padding: 48px 0;
+}
+
+.loading-container p {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.empty-state {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ text-align: center;
+ padding: 48px 16px;
+ border: 1px dashed var(--border-color);
+ border-radius: var(--border-radius);
+}
+
+.empty-state h3 {
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.empty-state p {
+ font-size: 11px;
+ color: var(--text-secondary);
+ max-width: 280px;
+}
+
+/* Fullscreen Mode Overlay */
+.preview-card.fullscreen-active {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ z-index: 99999;
+ border-radius: 0;
+ border: none;
+ background: var(--bg-main);
+}
+
+.preview-card.fullscreen-active .preview-header {
+ background: var(--bg-card);
+ border-bottom: 1px solid var(--border-color);
+ padding: 12px 24px;
+}
+
+.preview-card.fullscreen-active .canvas-body {
+ height: calc(100vh - 53px);
+ max-height: none;
+ background: #08090d;
+}
+
+/* Toast Notifications */
+.toast-container {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ z-index: 9999;
+}
+
+.toast {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ border-left: 4px solid var(--accent);
+ color: var(--text-primary);
+ padding: 10px 16px;
+ border-radius: var(--border-radius);
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 12px;
+ font-weight: 500;
+ min-width: 260px;
+ max-width: 380px;
+ animation: toastIn 0.2s ease-out;
+ transition: var(--transition);
+}
+
+.toast.success { border-left-color: var(--success); }
+.toast.error { border-left-color: var(--danger); }
+.toast.info { border-left-color: var(--info); }
+.toast.warning { border-left-color: var(--warning); }
+
+.toast-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.toast.success .toast-icon { color: var(--success); }
+.toast.error .toast-icon { color: var(--danger); }
+.toast.info .toast-icon { color: var(--info); }
+.toast.warning .toast-icon { color: var(--warning); }
+
+@keyframes toastIn {
+ from { opacity: 0; transform: translateY(10px) scale(0.95); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.toast-leave {
+ opacity: 0 !important;
+ transform: translateY(10px) scale(0.95) !important;
+}
+
+/* Footer Styling */
+.app-footer {
+ text-align: center;
+ padding-top: 16px;
+ border-top: 1px solid var(--border-color);
+ color: var(--text-muted);
+ font-size: 11px;
+}
+
+/* View Toggling & Page Navigation */
+.view-content {
+ display: none;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+
+.view-content.active {
+ display: block;
+ opacity: 1;
+ animation: viewFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@keyframes viewFadeIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Utility Hidden */
+.hidden {
+ display: none !important;
+}
+
+/* Responsive Styles */
+@media (max-width: 1024px) {
+ .grid-layout {
+ grid-template-columns: 1fr;
+ }
+ .grid-layout-editor {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 640px) {
+ .app-container {
+ padding: 16px 12px;
+ gap: 16px;
+ }
+
+ .app-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .header-status {
+ width: 100%;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ }
+
+ .status-meta {
+ padding-left: 0;
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .status-meta-item:first-child {
+ border-left: none;
+ padding-left: 0;
+ }
+}
diff --git a/internal/command/commandline.go b/internal/command/commandline.go
index ffd60b0..e7cac62 100644
--- a/internal/command/commandline.go
+++ b/internal/command/commandline.go
@@ -14,6 +14,7 @@ var (
generateTokenFlag bool
deleteTokenFlag bool
datapath string
+ frontendkey string
)
func PrepareCommandLine() {
@@ -21,6 +22,7 @@ func PrepareCommandLine() {
flag.BoolVar(&generateTokenFlag, "tokengen", false, "Generate token with name")
flag.BoolVar(&deleteTokenFlag, "tokendel", false, "Delete token with name")
flag.StringVar(&datapath, "data", "/var/lib/svg-templater", "Override data directory")
+ flag.StringVar(&frontendkey, "frontendkey", "", "Specify the api key the frontend should use")
}
func HandleCommandline() {
@@ -45,6 +47,7 @@ func HandleCommandline() {
} else {
svg.Storage = svg.NewFileStorage(datapath, "public", "fonts")
server.PrepareHTTP()
+ server.PrepareFrontend(frontendkey)
server.Start()
}
}
diff --git a/internal/server/http.go b/internal/server/http.go
index a5baaef..ae574b7 100644
--- a/internal/server/http.go
+++ b/internal/server/http.go
@@ -1,10 +1,13 @@
package server
import (
- "fmt"
+ "io/fs"
"log"
"net/http"
+ "os"
+ "text/template"
+ svgtemplater "tomatentum.net/svg-templater"
"tomatentum.net/svg-templater/internal/routes"
"tomatentum.net/svg-templater/pkg/auth"
"tomatentum.net/svg-templater/pkg/svg"
@@ -14,9 +17,6 @@ var mux http.ServeMux
func PrepareHTTP() {
mux = *http.NewServeMux()
- registerAuthorizedFunc("/", func(w http.ResponseWriter, r *http.Request) {
- fmt.Fprintln(w, "You are authorized!")
- })
registerAuthorizedFunc("/svg/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "POST":
@@ -71,6 +71,43 @@ func PrepareHTTP() {
mux.Handle("/public/", http.StripPrefix("/public/", http.FileServer(svg.Storage.GetPublicDir())))
}
+func PrepareFrontend(key string) {
+
+ webFS, _ := fs.Sub(svgtemplater.Frontend, "frontend")
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/app.js" {
+ http.FileServer(http.FS(webFS)).ServeHTTP(w, r)
+ return
+ }
+
+ tmplData, err := fs.ReadFile(webFS, "app.js")
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ tmpl, err := template.New("index").Delims("[[", "]]").Parse(string(tmplData))
+
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Execute with environment variables
+ data := map[string]string{
+ "APIKey": os.Getenv("API_KEY"),
+ "APIUrl": "",
+ }
+ if err := tmpl.Execute(w, data); err != nil {
+ http.Error(w, "Coudln't render template", http.StatusInternalServerError)
+ return
+ }
+
+ })
+}
+
func Start() {
log.Println("Starting http server on :3000")
handler := corsMiddleware(&mux)