From 2dfc206fea93d324e260adeb570112a3cafb0314 Mon Sep 17 00:00:00 2001 From: Tueem Date: Thu, 28 May 2026 12:16:23 +0200 Subject: [PATCH 1/2] feat(frontend): add frontend files and handling --- assets.go | 6 + frontend/app.js | 1955 +++++++++++++++++++++++++++++++ frontend/index.html | 361 ++++++ frontend/style.css | 1124 ++++++++++++++++++ internal/command/commandline.go | 3 + internal/server/http.go | 45 +- 6 files changed, 3490 insertions(+), 4 deletions(-) create mode 100644 assets.go create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/style.css 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": ` + + + + + + + + + + + + + + + + + + + + {{ Company }} + + + + {{ Name }} + {{ Title }} + + + + + + + + E-MAIL: + {{ Email }} + + + TELEFON: + {{ Phone }} + + + WEB: + {{ Website }} + + + `, + "gift_certificate_1": ` + + + + + + + + + + + Gutschein + Für das Ereignis: {{ Occasion }} + + {{ Amount }} € + + + Empfänger: + {{ Recipient }} + + Schenker: + {{ Sender }} + + + Ausgestellt am: {{ Date }} + + `, + "gift_certificate_2": ` + + + + GUTSCHEINBEDINGUNGEN + + 1. Dieser Gutschein ist ab Ausstellungsdatum 3 Jahre gültig. + 2. Eine Barauszahlung des Gutscheinwerts ist ausgeschlossen. + 3. Der Gutschein kann für alle Dienstleistungen eingelöst werden. + 4. Bei Verlust oder Beschädigung erfolgt kein Ersatz. + + Ausgestellt von: {{ Sender }} am {{ Date }} + + `, + "event_badge_1": ` + + + + + + + + + + + + + + + {{ EventName }} + + + {{ AttendeeName }} + + + ACCESS: {{ AccessLevel }} + + Sitzplatz: {{ SeatNumber }} + + + + + + + + + + + + + + + + + + + + + + + #0094-1184-7492 + + ` +}; + +// 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 = ` +
+
+ + + + + +
+

${displayName}

+ ${subTitleHtml} + + ${varsCount} ${varsCount === 1 ? 'Variable' : 'Variablen'} + + ${pageCount} ${pageCount === 1 ? 'Seite' : 'Seiten'} + +
+
+ + + +
+ `; + + 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 Templater

+
+ +
+
+ + Connecting... +
+ + 0 Vorlagen + Host: Localhost + + +
+
+ + +
+ + +
+
+ +
+ +
+
+

Template hochladen

+

Laden Sie eine SVG-Datei hoch, um Platzhalter wie {{ variable }} automatisch zu extrahieren.

+
+
+
+ +
+
+ + + + + +
+

SVG Datei hierher ziehen

+

oder Computer durchsuchen

+ Ausschließlich valide .svg Dateien +
+
+
+
+ + +
+
+

Schriften verwalten

+

Laden Sie globale TTF/OTF-Schriften hoch, die für Text-Renderings verwendet werden können.

+
+
+
+

Installierte Schriften

+ +
+ Keine benutzerdefinierten Schriften geladen. +
+ +
+ +
+ +
+ + +
+
+
+
+ + +
+
+
+

Meine Vorlagen

+

Wählen Sie ein SVG-Template aus, um Variablen auszufüllen und Renderings zu erstellen.

+
+ +
+
+ + + + +
+
+ + + + + +
+

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 +
+
+ +
+
+ +
+
+

Variablen ausfüllen

+

Geben Sie die Werte für die im SVG definierten Platzhalter ein.

+
+
+
+
+ +
+ +
+ +
+

Ausgabe-Einstellungen

+ +
+ +
+ + +
+
+ + +
+ + +
+
+
+ + +
+
+

Seiten verwalten

+

Fügen Sie neue Seiten hinzu oder löschen Sie bestehende Seiten.

+
+
+
+
    + +
+
+
+
+ + +
+
+
+
+ + +
+
+

Live-Vorschau

+
+ + 100% + + + + + + +
+
+
+
+ +
+ +
+ + + + + +

Werte eintragen und Rendering starten

+
+ +
+
+
+
+
+
+ +
+ +
+

© 2026 SVG Templater Dashboard. Mit Liebe pair-programmiert von Antigravity.

+
+
+ + +
+ + + + + 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) From 293c720eebfa33a01aac922365d8c46578613eeb Mon Sep 17 00:00:00 2001 From: Tueem Date: Thu, 28 May 2026 12:17:04 +0200 Subject: [PATCH 2/2] fix(frontend): key as config flag --- internal/server/http.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/server/http.go b/internal/server/http.go index ae574b7..5028ed1 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -4,7 +4,6 @@ import ( "io/fs" "log" "net/http" - "os" "text/template" svgtemplater "tomatentum.net/svg-templater" @@ -97,7 +96,7 @@ func PrepareFrontend(key string) { // Execute with environment variables data := map[string]string{ - "APIKey": os.Getenv("API_KEY"), + "APIKey": key, "APIUrl": "", } if err := tmpl.Execute(w, data); err != nil {