generated from coulomb/repo-seed
Changes: - Add fileEditor.init() call in DOMContentLoaded to activate edit buttons - Remove individual file upload inputs (projectInput, svgInput, cssInput, csvInput) that had CORS issues when loading project configurations - Keep only the folder picker which works reliably - Update UI to emphasize folder picker as the primary loading method - Remove corresponding event handlers from engine.js - Remove tests for individual file upload functionality The folder picker loads all project files in one operation without CORS issues, while individual file uploads failed when trying to load referenced files (CSV, SVG, CSS) from the project.json. All 56 tests passing. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
879 lines
32 KiB
JavaScript
879 lines
32 KiB
JavaScript
window.timelineEngine = {
|
||
config: null,
|
||
template: null,
|
||
csvOverride: false,
|
||
cssOverride: false,
|
||
csvData: null, // Store current CSV text
|
||
cssData: null, // Store current CSS text
|
||
|
||
projectBasePath: '',
|
||
|
||
showTemplatePreview() {
|
||
if (!this.template) return;
|
||
|
||
console.log("Showing template preview");
|
||
|
||
// Detect template theme for appropriate colors
|
||
const isEnhanced = this.template.includes('Enhanced');
|
||
const isBlueTheme = this.template.includes('My Project');
|
||
const isGreenTheme = this.template.includes('Example');
|
||
|
||
// Color scheme selection
|
||
let primaryColor = '#0A4D8C'; // default
|
||
let secondaryColor = '#5C6B7A'; // default
|
||
|
||
if (isEnhanced) {
|
||
if (isBlueTheme) {
|
||
primaryColor = '#3b82f6';
|
||
secondaryColor = '#60a5fa';
|
||
} else if (isGreenTheme) {
|
||
primaryColor = '#2d8659';
|
||
secondaryColor = '#4a9b6b';
|
||
}
|
||
}
|
||
|
||
// Create sample month graphics matching the template style
|
||
const sampleMonths = isEnhanced ? `
|
||
<line x1="240" y1="75" x2="240" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
|
||
<rect x="210" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
|
||
<text x="244" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Jan 25</text>
|
||
|
||
<line x1="360" y1="75" x2="360" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
|
||
<rect x="330" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
|
||
<text x="364" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Feb 25</text>
|
||
|
||
<line x1="480" y1="75" x2="480" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
|
||
<rect x="450" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
|
||
<text x="484" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Mar 25</text>
|
||
|
||
<line x1="600" y1="75" x2="600" y2="300" stroke="${secondaryColor}" stroke-width="2" opacity="0.6" />
|
||
<rect x="570" y="70" width="60" height="25" fill="${primaryColor}" opacity="0.1" rx="4" />
|
||
<text x="604" y="90" fill="${primaryColor}" font-size="13" font-weight="600">Apr 25</text>
|
||
` : `
|
||
<line x1="240" y1="75" x2="240" y2="300" stroke="#E3E8EF" />
|
||
<text x="244" y="90" fill="#5C6B7A" font-size="12">Jan 25</text>
|
||
<line x1="360" y1="75" x2="360" y2="300" stroke="#E3E8EF" />
|
||
<text x="364" y="90" fill="#5C6B7A" font-size="12">Feb 25</text>
|
||
<line x1="480" y1="75" x2="480" y2="300" stroke="#E3E8EF" />
|
||
<text x="484" y="90" fill="#5C6B7A" font-size="12">Mar 25</text>
|
||
<line x1="600" y1="75" x2="600" y2="300" stroke="#E3E8EF" />
|
||
<text x="604" y="90" fill="#5C6B7A" font-size="12">Apr 25</text>
|
||
`;
|
||
|
||
// Create sample lane content matching the template style
|
||
const sampleLanes = isEnhanced ? `
|
||
<rect x="40" y="116" width="640" height="80" fill="rgba(255,255,255,0.7)" stroke="${secondaryColor}" stroke-width="1" opacity="0.5" rx="8" />
|
||
<text x="56" y="136" fill="${primaryColor}" font-size="14" font-weight="700">Example Epic</text>
|
||
<circle cx="270" cy="156" r="6" fill="${primaryColor}" stroke="${secondaryColor}" stroke-width="2" />
|
||
<text x="282" y="160" font-size="12" fill="${primaryColor}" font-weight="500">
|
||
<tspan class="item-id">EX-1: </tspan>
|
||
<tspan class="item-title" fill="${primaryColor === '#3b82f6' ? '#1e40af' : '#1e5a3d'}">Sample Task Preview</tspan>
|
||
</text>
|
||
|
||
<rect x="40" y="212" width="640" height="80" fill="rgba(255,255,255,0.7)" stroke="${secondaryColor}" stroke-width="1" opacity="0.5" rx="8" />
|
||
<text x="56" y="232" fill="${primaryColor}" font-size="14" font-weight="700">Another Epic</text>
|
||
<circle cx="390" cy="252" r="6" fill="${primaryColor}" stroke="${secondaryColor}" stroke-width="2" />
|
||
<text x="402" y="256" font-size="12" fill="${primaryColor}" font-weight="500">
|
||
<tspan class="item-id">EX-2: </tspan>
|
||
<tspan class="item-title" fill="${primaryColor === '#3b82f6' ? '#1e40af' : '#1e5a3d'}">Template Preview</tspan>
|
||
</text>
|
||
` : `
|
||
<rect x="40" y="116" width="640" height="80" fill="#FFFFFF" stroke="#E3E8EF" rx="10" />
|
||
<text x="56" y="136" fill="#0B1F3B" font-size="14" font-weight="600">Example Epic</text>
|
||
<circle cx="270" cy="156" r="5" fill="#0A4D8C" />
|
||
<text x="282" y="160" font-size="12" fill="#0B1F3B">
|
||
<tspan class="item-id">EX-1: </tspan>
|
||
<tspan class="item-title">Sample Task Preview</tspan>
|
||
</text>
|
||
|
||
<rect x="40" y="212" width="640" height="80" fill="#FFFFFF" stroke="#E3E8EF" rx="10" />
|
||
<text x="56" y="232" fill="#0B1F3B" font-size="14" font-weight="600">Another Epic</text>
|
||
<circle cx="390" cy="252" r="5" fill="#0A4D8C" />
|
||
<text x="402" y="256" font-size="12" fill="#0B1F3B">
|
||
<tspan class="item-id">EX-2: </tspan>
|
||
<tspan class="item-title">Template Preview</tspan>
|
||
</text>
|
||
`;
|
||
|
||
// Replace placeholders with sample data
|
||
let previewSvg = this.template
|
||
.replace("{{MONTHS}}", sampleMonths)
|
||
.replace("{{LANES}}", sampleLanes);
|
||
|
||
// Add dimensions for proper preview display (using reasonable default size)
|
||
const previewWidth = 720;
|
||
const previewHeight = 360;
|
||
previewSvg = previewSvg.replace(
|
||
/<svg([^>]*?)>/,
|
||
`<svg$1 width="${previewWidth}" height="${previewHeight}" viewBox="0 0 ${previewWidth} ${previewHeight}">`
|
||
);
|
||
|
||
document.getElementById("viewer").innerHTML = previewSvg;
|
||
|
||
// Initialize zoom functionality for the preview
|
||
if (window.svgViewer) {
|
||
window.svgViewer.resetZoom();
|
||
window.svgViewer.updateZoomControls();
|
||
}
|
||
|
||
console.log("Template preview displayed with theme:", { isEnhanced, isBlueTheme, isGreenTheme });
|
||
},
|
||
|
||
updateFileStatus(fileType, filename, status = 'loaded') {
|
||
const statusElement = document.getElementById(`${fileType}File`);
|
||
|
||
if (statusElement) {
|
||
if (filename && status === 'loaded') {
|
||
statusElement.textContent = filename;
|
||
statusElement.style.color = '#28a745';
|
||
statusElement.title = `Loaded: ${filename}`;
|
||
} else if (filename && status === 'error') {
|
||
statusElement.textContent = `Error: ${filename}`;
|
||
statusElement.style.color = '#dc3545';
|
||
statusElement.title = `Failed to load: ${filename}`;
|
||
} else {
|
||
statusElement.textContent = 'Not loaded';
|
||
statusElement.style.color = '#6c757d';
|
||
statusElement.title = 'No file loaded';
|
||
}
|
||
}
|
||
|
||
// Add visual feedback with brief highlight animation
|
||
if (statusElement && filename) {
|
||
statusElement.style.transform = 'scale(1.05)';
|
||
setTimeout(() => {
|
||
statusElement.style.transform = 'scale(1)';
|
||
}, 200);
|
||
}
|
||
},
|
||
|
||
resolveProjectPath(relativePath) {
|
||
if (!relativePath) return relativePath;
|
||
// If path is already absolute (starts with http/https) or has no base path, return as-is
|
||
if (relativePath.startsWith('http') || !this.projectBasePath) {
|
||
return relativePath;
|
||
}
|
||
const resolvedPath = this.projectBasePath + relativePath;
|
||
console.log("Resolved path:", relativePath, "->", resolvedPath);
|
||
return resolvedPath;
|
||
},
|
||
|
||
async autoLoadDefaultProject() {
|
||
console.log("Starting autoLoadDefaultProject");
|
||
// Try projects in order: binect, my-project, example
|
||
const candidates = [
|
||
{ path: "binect/project.json", basePath: "binect/" },
|
||
{ path: "my-project/project.json", basePath: "my-project/" },
|
||
{ path: "example/project.json", basePath: "example/" }
|
||
];
|
||
for (const candidate of candidates) {
|
||
try {
|
||
const res = await fetch(candidate.path);
|
||
if (!res.ok) continue;
|
||
const cfg = await res.json();
|
||
this.projectBasePath = candidate.basePath;
|
||
await this.loadProjectConfigObject(cfg);
|
||
console.log("Project loaded successfully from:", candidate.path);
|
||
return;
|
||
} catch (e) {
|
||
continue;
|
||
}
|
||
}
|
||
console.log("No project could be auto-loaded");
|
||
},
|
||
|
||
async loadProjectConfigObject(cfg) {
|
||
this.config = cfg;
|
||
const name = cfg.name || "Timeline";
|
||
document.getElementById("projectName").textContent = name;
|
||
document.getElementById("projectSubtitle").textContent =
|
||
cfg.description || "Projektkonfiguration geladen.";
|
||
|
||
// Update project status
|
||
this.updateFileStatus('project', name, 'loaded');
|
||
|
||
// Enable project edit button
|
||
if (window.fileEditor) {
|
||
window.fileEditor.enableEditButton('project');
|
||
}
|
||
|
||
// Show field mappings in debug panel
|
||
this.showFieldMappings();
|
||
|
||
// Track loading errors for user feedback
|
||
const loadingErrors = [];
|
||
|
||
// Stylesheet
|
||
if (cfg.stylesheet && !this.cssOverride) {
|
||
const stylesheetPath = this.resolveProjectPath(cfg.stylesheet);
|
||
|
||
// Load CSS via fetch to store content for editing
|
||
try {
|
||
const cssResponse = await fetch(stylesheetPath);
|
||
if (cssResponse.ok) {
|
||
const cssText = await cssResponse.text();
|
||
this.cssData = cssText; // Store for editing
|
||
|
||
// Apply CSS
|
||
const blob = new Blob([cssText], { type: "text/css" });
|
||
const linkElement = document.getElementById("dynamicCss");
|
||
linkElement.href = URL.createObjectURL(blob);
|
||
|
||
this.updateFileStatus('css', cfg.stylesheet + ' ✨', 'loaded');
|
||
|
||
// Enable CSS edit button
|
||
if (window.fileEditor) {
|
||
window.fileEditor.enableEditButton('css');
|
||
}
|
||
|
||
console.log("Stylesheet loaded successfully:", stylesheetPath);
|
||
} else {
|
||
throw new Error(`HTTP ${cssResponse.status}`);
|
||
}
|
||
} catch (e) {
|
||
console.error("Stylesheet could not be loaded:", e);
|
||
this.updateFileStatus('css', cfg.stylesheet, 'error');
|
||
loadingErrors.push(`Stylesheet: ${cfg.stylesheet}`);
|
||
}
|
||
}
|
||
|
||
// SVG template
|
||
if (cfg.svgTemplate) {
|
||
try {
|
||
const templatePath = this.resolveProjectPath(cfg.svgTemplate);
|
||
console.log("Attempting to fetch SVG template from:", templatePath);
|
||
const svgResponse = await fetch(templatePath);
|
||
console.log("SVG template fetch response:", svgResponse.status, svgResponse.statusText);
|
||
|
||
if (!svgResponse.ok) {
|
||
throw new Error(`HTTP ${svgResponse.status}: ${svgResponse.statusText}`);
|
||
}
|
||
|
||
this.template = await svgResponse.text();
|
||
console.log("SVG template loaded, length:", this.template.length);
|
||
this.updateFileStatus('svg', cfg.svgTemplate, 'loaded');
|
||
|
||
// Enable SVG edit button
|
||
if (window.fileEditor) {
|
||
window.fileEditor.enableEditButton('svg');
|
||
}
|
||
|
||
// Show template fields in debug panel
|
||
this.showTemplateFields();
|
||
|
||
// Show template preview if no CSV data is loaded yet
|
||
if (!this.csvOverride && document.querySelector("#viewer").innerHTML.includes("Keine Timeline verfügbar")) {
|
||
this.showTemplatePreview();
|
||
}
|
||
} catch (e) {
|
||
console.error("SVG template could not be loaded:", e);
|
||
console.error("Failed SVG template path was:", this.resolveProjectPath(cfg.svgTemplate));
|
||
this.updateFileStatus('svg', cfg.svgTemplate, 'error');
|
||
loadingErrors.push(`SVG template: ${cfg.svgTemplate}`);
|
||
}
|
||
}
|
||
|
||
// CSV data
|
||
if (cfg.dataSource && !this.csvOverride) {
|
||
try {
|
||
const csvPath = this.resolveProjectPath(cfg.dataSource);
|
||
console.log("Attempting to fetch CSV from:", csvPath);
|
||
const csvResponse = await fetch(csvPath);
|
||
console.log("CSV fetch response:", csvResponse.status, csvResponse.statusText);
|
||
|
||
if (!csvResponse.ok) {
|
||
throw new Error(`HTTP ${csvResponse.status}: ${csvResponse.statusText}`);
|
||
}
|
||
|
||
const csvText = await csvResponse.text();
|
||
console.log("CSV text loaded, length:", csvText.length);
|
||
console.log("CSV preview:", csvText.substring(0, 200));
|
||
|
||
this.updateFileStatus('csv', cfg.dataSource, 'loaded');
|
||
|
||
// Enable CSV edit button
|
||
if (window.fileEditor) {
|
||
window.fileEditor.enableEditButton('csv');
|
||
}
|
||
|
||
this.processCsv(csvText);
|
||
} catch (e) {
|
||
console.error("CSV could not be loaded:", e);
|
||
console.error("Failed CSV path was:", this.resolveProjectPath(cfg.dataSource));
|
||
this.updateFileStatus('csv', cfg.dataSource, 'error');
|
||
loadingErrors.push(`CSV data: ${cfg.dataSource}`);
|
||
}
|
||
}
|
||
|
||
// Show user feedback if there were loading errors
|
||
if (loadingErrors.length > 0) {
|
||
setTimeout(() => {
|
||
const viewer = document.getElementById("viewer");
|
||
if (viewer && !viewer.innerHTML.includes('<svg')) {
|
||
viewer.innerHTML = `
|
||
<div style="text-align:center; padding:40px 20px; color:#6c757d;">
|
||
<div style="font-size:48px; margin-bottom:16px;">⚠️</div>
|
||
<h4 style="margin:0 0 8px 0; color:#dc3545;">Projektdateien konnten nicht geladen werden</h4>
|
||
<p style="margin:0 0 12px 0; font-size:14px;">
|
||
<strong>Fehlgeschlagene Dateien:</strong><br>
|
||
${loadingErrors.map(err => `• ${err}`).join('<br>')}
|
||
</p>
|
||
<p style="margin:0; font-size:12px; color:#6c757d;">
|
||
💡 <strong>Tipp:</strong> Lade die Dateien manuell mit den Load-Buttons oben oder
|
||
verwende einen lokalen Server (<code>make serve</code>).
|
||
</p>
|
||
</div>
|
||
`;
|
||
}
|
||
}, 500); // Small delay to let other operations complete
|
||
}
|
||
},
|
||
|
||
processCsv(text) {
|
||
console.log("processCsv called with text length:", text?.length);
|
||
|
||
// Store CSV data for editing
|
||
this.csvData = text;
|
||
|
||
if (!this.config || !this.config.fieldMapping) {
|
||
console.error("No config or fieldMapping found.");
|
||
return;
|
||
}
|
||
|
||
if (typeof Papa === 'undefined') {
|
||
console.error("Papa Parse library not available");
|
||
document.getElementById("viewer").innerHTML =
|
||
"<em style='color:#dc3545;'>Fehler: CSV Parser nicht verfügbar.</em>";
|
||
return;
|
||
}
|
||
|
||
const m = this.config.fieldMapping;
|
||
|
||
const self = this; // Capture 'this' context
|
||
Papa.parse(text, {
|
||
header: true,
|
||
skipEmptyLines: true,
|
||
complete: (res) => {
|
||
console.log("Papa.parse complete, found", res.data.length, "rows");
|
||
const rows = res.data;
|
||
|
||
// Show CSV preview in debug panel
|
||
self.showCSVPreview(text, rows);
|
||
|
||
const items = rows.map((r) => {
|
||
const dueField = (m.due || []).find(f => r[f]);
|
||
const item = {
|
||
id: m.id ? r[m.id] : (r["ID"] || ""),
|
||
title: m.title ? r[m.title] : (r["Title"] || ""),
|
||
lane: m.lane ? (r[m.lane] || "Ohne Epic") : (r["Lane"] || "Default"),
|
||
due: self.parseDate(dueField ? r[dueField] : null)
|
||
};
|
||
return item;
|
||
}).filter(i => i.title && i.due);
|
||
|
||
console.log("Filtered to", items.length, "valid items");
|
||
|
||
if (!items.length) {
|
||
document.getElementById("viewer").innerHTML =
|
||
"<em style='color:#999;'>Keine gültigen Items gefunden.</em>";
|
||
return;
|
||
}
|
||
|
||
// Ensure generator is available
|
||
if (!window.timelineGenerator) {
|
||
console.error("Timeline generator not available");
|
||
document.getElementById("viewer").innerHTML =
|
||
"<em style='color:#dc3545;'>Fehler: Timeline Generator nicht verfügbar.</em>";
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log("Generating timeline with:", items, self.config, self.template ? "template loaded" : "no template");
|
||
const svg = window.timelineGenerator.generate(items, self.config, self.template);
|
||
document.getElementById("viewer").innerHTML = svg;
|
||
const dlBtn = document.getElementById("downloadSvg");
|
||
dlBtn.disabled = false;
|
||
dlBtn.style.opacity = 1;
|
||
|
||
// Initialize zoom functionality for the new SVG
|
||
if (window.svgViewer) {
|
||
window.svgViewer.resetZoom();
|
||
window.svgViewer.updateZoomControls();
|
||
}
|
||
|
||
console.log("Timeline generated successfully");
|
||
} catch (error) {
|
||
console.error("Error generating timeline:", error);
|
||
document.getElementById("viewer").innerHTML =
|
||
"<em style='color:#dc3545;'>Fehler beim Generieren der Timeline.</em>";
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
parseDate(str) {
|
||
if (!str) return null;
|
||
str = String(str).trim();
|
||
let d = null;
|
||
|
||
// YYYY/MM/DD oder YYYY-MM-DD
|
||
let m = str.match(/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})$/);
|
||
if (m) {
|
||
const [_, yy, mm, dd] = m;
|
||
d = new Date(Number(yy), Number(mm) - 1, Number(dd));
|
||
return isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
// DD.MM.YYYY
|
||
m = str.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||
if (m) {
|
||
const [_, dd, mm, yy] = m;
|
||
d = new Date(Number(yy), Number(mm) - 1, Number(dd));
|
||
return isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
// Fallback
|
||
d = new Date(str);
|
||
return isNaN(d.getTime()) ? null : d;
|
||
},
|
||
|
||
// --------- Debug Display Functions ---------
|
||
|
||
showFieldMappings() {
|
||
if (!this.config || !this.config.fieldMapping) return;
|
||
|
||
const debugInfo = document.getElementById("debugInfo");
|
||
const fieldMappingInfo = document.getElementById("fieldMappingInfo");
|
||
const fieldMappingDisplay = document.getElementById("fieldMappingDisplay");
|
||
|
||
if (debugInfo && fieldMappingInfo && fieldMappingDisplay) {
|
||
debugInfo.style.display = "block";
|
||
fieldMappingInfo.style.display = "block";
|
||
|
||
const mapping = this.config.fieldMapping;
|
||
let display = "CSV Column → Timeline Field\n";
|
||
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
|
||
display += `ID: ${JSON.stringify(mapping.id)}\n`;
|
||
display += `Title: ${JSON.stringify(mapping.title)}\n`;
|
||
display += `Lane: ${JSON.stringify(mapping.lane)}\n`;
|
||
display += `Due: ${JSON.stringify(mapping.due)}\n`;
|
||
if (mapping.epic) display += `Epic: ${JSON.stringify(mapping.epic)}\n`;
|
||
if (mapping.type) display += `Type: ${JSON.stringify(mapping.type)}\n`;
|
||
if (mapping.color) display += `Color: ${JSON.stringify(mapping.color)}\n`;
|
||
|
||
fieldMappingDisplay.textContent = display;
|
||
}
|
||
},
|
||
|
||
showTemplateFields() {
|
||
if (!this.template) return;
|
||
|
||
const debugInfo = document.getElementById("debugInfo");
|
||
const templateFieldsInfo = document.getElementById("templateFieldsInfo");
|
||
const templateFieldsDisplay = document.getElementById("templateFieldsDisplay");
|
||
|
||
if (debugInfo && templateFieldsInfo && templateFieldsDisplay) {
|
||
debugInfo.style.display = "block";
|
||
templateFieldsInfo.style.display = "block";
|
||
|
||
// Extract placeholders from template
|
||
const placeholders = new Set();
|
||
const placeholderRegex = /\{\{([A-Z_]+)\}\}/g;
|
||
let match;
|
||
while ((match = placeholderRegex.exec(this.template)) !== null) {
|
||
placeholders.add(match[1]);
|
||
}
|
||
|
||
let display = "Required Template Placeholders:\n";
|
||
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
|
||
|
||
// Group by category
|
||
const monthFields = Array.from(placeholders).filter(p => p.includes('MONTH'));
|
||
const laneFields = Array.from(placeholders).filter(p => p.includes('LANE'));
|
||
const taskFields = Array.from(placeholders).filter(p => p.includes('TASK') || p.includes('TEXT'));
|
||
const otherFields = Array.from(placeholders).filter(p =>
|
||
!monthFields.includes(p) && !laneFields.includes(p) && !taskFields.includes(p)
|
||
);
|
||
|
||
if (monthFields.length > 0) {
|
||
display += "\nMonth Fields:\n";
|
||
monthFields.forEach(f => display += ` • ${f}\n`);
|
||
}
|
||
if (laneFields.length > 0) {
|
||
display += "\nLane Fields:\n";
|
||
laneFields.forEach(f => display += ` • ${f}\n`);
|
||
}
|
||
if (taskFields.length > 0) {
|
||
display += "\nTask Fields:\n";
|
||
taskFields.forEach(f => display += ` • ${f}\n`);
|
||
}
|
||
if (otherFields.length > 0) {
|
||
display += "\nOther Fields:\n";
|
||
otherFields.forEach(f => display += ` • ${f}\n`);
|
||
}
|
||
|
||
templateFieldsDisplay.textContent = display;
|
||
}
|
||
},
|
||
|
||
showCSVPreview(csvText, parsedData) {
|
||
const debugInfo = document.getElementById("debugInfo");
|
||
const csvDataInfo = document.getElementById("csvDataInfo");
|
||
const csvDataDisplay = document.getElementById("csvDataDisplay");
|
||
|
||
if (debugInfo && csvDataInfo && csvDataDisplay) {
|
||
debugInfo.style.display = "block";
|
||
csvDataInfo.style.display = "block";
|
||
|
||
const lines = csvText.trim().split('\n');
|
||
const headers = lines[0] ? lines[0].split(',') : [];
|
||
const firstDataLine = lines[1] ? lines[1].split(',') : [];
|
||
|
||
let display = "CSV Structure Preview:\n";
|
||
display += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
|
||
display += `Headers: ${headers.join(', ')}\n\n`;
|
||
|
||
if (firstDataLine.length > 0) {
|
||
display += "First Data Row:\n";
|
||
headers.forEach((header, i) => {
|
||
display += ` ${header}: "${firstDataLine[i] || ''}"\n`;
|
||
});
|
||
}
|
||
|
||
if (parsedData) {
|
||
display += `\nParsed Rows: ${parsedData.length}\n`;
|
||
const validCount = parsedData.filter(r => {
|
||
const mapping = this.config?.fieldMapping || {};
|
||
const titleField = mapping.title;
|
||
const dueField = Array.isArray(mapping.due) ? mapping.due.find(f => r[f]) : mapping.due;
|
||
return r[titleField] && r[dueField];
|
||
}).length;
|
||
display += `Valid Items: ${validCount}\n`;
|
||
|
||
if (validCount === 0 && parsedData.length > 0) {
|
||
display += "\n⚠️ WARNING: No valid items found!\n";
|
||
display += "Check that:\n";
|
||
display += " • CSV headers match field mappings\n";
|
||
display += " • Title and Due fields have values\n";
|
||
display += " • Date format is parseable (e.g., YYYY-MM-DD)\n";
|
||
}
|
||
}
|
||
|
||
csvDataDisplay.textContent = display;
|
||
}
|
||
}
|
||
};
|
||
|
||
// --------- SVG Viewer with Zoom functionality ---------
|
||
|
||
window.svgViewer = {
|
||
currentZoom: 1,
|
||
minZoom: 0.25,
|
||
maxZoom: 3,
|
||
zoomStep: 0.25,
|
||
|
||
initializeZoom() {
|
||
const viewer = document.getElementById("viewer");
|
||
const zoomControls = document.getElementById("zoomControls");
|
||
const zoomIn = document.getElementById("zoomIn");
|
||
const zoomOut = document.getElementById("zoomOut");
|
||
const zoomReset = document.getElementById("zoomReset");
|
||
|
||
if (!viewer || !zoomControls || !zoomIn || !zoomOut || !zoomReset) {
|
||
console.warn("Zoom controls not found in DOM");
|
||
return;
|
||
}
|
||
|
||
// Show zoom controls when SVG is present
|
||
this.updateZoomControls();
|
||
|
||
// Zoom in
|
||
zoomIn.addEventListener("click", () => {
|
||
this.zoomIn();
|
||
});
|
||
|
||
// Zoom out
|
||
zoomOut.addEventListener("click", () => {
|
||
this.zoomOut();
|
||
});
|
||
|
||
// Reset zoom
|
||
zoomReset.addEventListener("click", () => {
|
||
this.resetZoom();
|
||
});
|
||
|
||
// Mouse wheel zoom (with Ctrl key)
|
||
viewer.addEventListener("wheel", (e) => {
|
||
if (e.ctrlKey || e.metaKey) {
|
||
e.preventDefault();
|
||
if (e.deltaY < 0) {
|
||
this.zoomIn();
|
||
} else {
|
||
this.zoomOut();
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log("SVG zoom functionality initialized");
|
||
},
|
||
|
||
showZoomControls() {
|
||
const zoomControls = document.getElementById("zoomControls");
|
||
if (zoomControls) {
|
||
zoomControls.style.display = "block";
|
||
}
|
||
},
|
||
|
||
hideZoomControls() {
|
||
const zoomControls = document.getElementById("zoomControls");
|
||
if (zoomControls) {
|
||
zoomControls.style.display = "none";
|
||
}
|
||
},
|
||
|
||
zoomIn() {
|
||
if (this.currentZoom < this.maxZoom) {
|
||
this.currentZoom = Math.min(this.maxZoom, this.currentZoom + this.zoomStep);
|
||
this.applyZoom();
|
||
}
|
||
},
|
||
|
||
zoomOut() {
|
||
if (this.currentZoom > this.minZoom) {
|
||
this.currentZoom = Math.max(this.minZoom, this.currentZoom - this.zoomStep);
|
||
this.applyZoom();
|
||
}
|
||
},
|
||
|
||
resetZoom() {
|
||
this.currentZoom = 1;
|
||
this.applyZoom();
|
||
},
|
||
|
||
applyZoom() {
|
||
const viewer = document.getElementById("viewer");
|
||
const container = document.getElementById("viewerContainer");
|
||
if (viewer && container) {
|
||
viewer.style.transform = `scale(${this.currentZoom})`;
|
||
|
||
// Adjust container overflow behavior based on zoom level
|
||
if (this.currentZoom !== 1) {
|
||
viewer.classList.add("zoomed");
|
||
container.style.overflow = "auto";
|
||
} else {
|
||
viewer.classList.remove("zoomed");
|
||
container.style.overflow = "hidden";
|
||
}
|
||
|
||
this.updateZoomControls();
|
||
}
|
||
},
|
||
|
||
updateZoomControls() {
|
||
const zoomIn = document.getElementById("zoomIn");
|
||
const zoomOut = document.getElementById("zoomOut");
|
||
const zoomReset = document.getElementById("zoomReset");
|
||
|
||
if (zoomIn && zoomOut && zoomReset) {
|
||
// Update button states
|
||
zoomIn.disabled = this.currentZoom >= this.maxZoom;
|
||
zoomOut.disabled = this.currentZoom <= this.minZoom;
|
||
|
||
// Update zoom percentage display
|
||
const percentage = Math.round(this.currentZoom * 100);
|
||
zoomReset.textContent = `${percentage}%`;
|
||
|
||
// Show/hide controls based on whether SVG is present
|
||
const hasSvg = document.querySelector("#viewer svg") !== null;
|
||
if (hasSvg) {
|
||
this.showZoomControls();
|
||
} else {
|
||
this.hideZoomControls();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// --------- UI event handlers ---------
|
||
|
||
window.setupEventHandlers = function() {
|
||
// Handler for loading entire project folder
|
||
const folderInput = document.getElementById("folderInput");
|
||
if (folderInput) {
|
||
folderInput.addEventListener("change", async (ev) => {
|
||
const files = Array.from(ev.target.files);
|
||
if (!files.length) return;
|
||
|
||
console.log("Folder selected with", files.length, "files");
|
||
|
||
try {
|
||
// Find project.json in the uploaded files
|
||
const projectFile = files.find(f => f.name === 'project.json');
|
||
if (!projectFile) {
|
||
alert('No project.json found in selected folder. Please select a folder containing a project.json file.');
|
||
return;
|
||
}
|
||
|
||
// Parse project.json
|
||
const projectText = await projectFile.text();
|
||
const cfg = JSON.parse(projectText);
|
||
console.log("Loaded project configuration:", cfg);
|
||
|
||
// Clear projectBasePath since we're loading from uploaded files
|
||
window.timelineEngine.projectBasePath = '';
|
||
|
||
// Update project status
|
||
window.timelineEngine.updateFileStatus('project', projectFile.name, 'loaded');
|
||
|
||
// Enable project edit button
|
||
if (window.fileEditor) {
|
||
window.fileEditor.enableEditButton('project');
|
||
}
|
||
|
||
// Load referenced files from the folder
|
||
const errors = [];
|
||
|
||
// Load stylesheet
|
||
if (cfg.stylesheet) {
|
||
const cssFile = files.find(f => f.name === cfg.stylesheet || f.webkitRelativePath.endsWith(cfg.stylesheet));
|
||
if (cssFile) {
|
||
const cssText = await cssFile.text();
|
||
window.timelineEngine.cssData = cssText; // Store for editing
|
||
window.timelineEngine.cssOverride = true;
|
||
const blob = new Blob([cssText], { type: "text/css" });
|
||
document.getElementById("dynamicCss").href = URL.createObjectURL(blob);
|
||
window.timelineEngine.updateFileStatus('css', cssFile.name, 'loaded');
|
||
|
||
// Enable CSS edit button
|
||
if (window.fileEditor) {
|
||
window.fileEditor.enableEditButton('css');
|
||
}
|
||
|
||
console.log("Loaded stylesheet:", cssFile.name);
|
||
} else {
|
||
errors.push(`Stylesheet: ${cfg.stylesheet}`);
|
||
window.timelineEngine.updateFileStatus('css', cfg.stylesheet, 'error');
|
||
}
|
||
}
|
||
|
||
// Load SVG template
|
||
if (cfg.svgTemplate) {
|
||
const svgFile = files.find(f => f.name === cfg.svgTemplate || f.webkitRelativePath.endsWith(cfg.svgTemplate));
|
||
if (svgFile) {
|
||
window.timelineEngine.template = await svgFile.text();
|
||
window.timelineEngine.updateFileStatus('svg', svgFile.name, 'loaded');
|
||
window.timelineEngine.showTemplateFields();
|
||
|
||
// Enable SVG edit button
|
||
if (window.fileEditor) {
|
||
window.fileEditor.enableEditButton('svg');
|
||
}
|
||
|
||
console.log("Loaded SVG template:", svgFile.name);
|
||
} else {
|
||
errors.push(`SVG template: ${cfg.svgTemplate}`);
|
||
window.timelineEngine.updateFileStatus('svg', cfg.svgTemplate, 'error');
|
||
}
|
||
}
|
||
|
||
// Load CSV data
|
||
if (cfg.dataSource) {
|
||
const csvFile = files.find(f => f.name === cfg.dataSource || f.webkitRelativePath.endsWith(cfg.dataSource));
|
||
if (csvFile) {
|
||
const csvText = await csvFile.text();
|
||
window.timelineEngine.csvOverride = true;
|
||
window.timelineEngine.updateFileStatus('csv', csvFile.name, 'loaded');
|
||
|
||
// Enable CSV edit button
|
||
if (window.fileEditor) {
|
||
window.fileEditor.enableEditButton('csv');
|
||
}
|
||
|
||
console.log("Loaded CSV data:", csvFile.name);
|
||
|
||
// Set config and process CSV
|
||
window.timelineEngine.config = cfg;
|
||
window.timelineEngine.processCsv(csvText);
|
||
} else {
|
||
errors.push(`CSV data: ${cfg.dataSource}`);
|
||
window.timelineEngine.updateFileStatus('csv', cfg.dataSource, 'error');
|
||
}
|
||
}
|
||
|
||
// Update project name and description
|
||
const name = cfg.name || "Timeline";
|
||
document.getElementById("projectName").textContent = name;
|
||
document.getElementById("projectSubtitle").textContent = cfg.description || "Project loaded from folder.";
|
||
|
||
// Show field mappings
|
||
window.timelineEngine.showFieldMappings();
|
||
|
||
// Show any errors
|
||
if (errors.length > 0) {
|
||
setTimeout(() => {
|
||
alert(`⚠️ Some files could not be loaded:\n\n${errors.join('\n')}\n\nMake sure all referenced files are in the selected folder.`);
|
||
}, 500);
|
||
} else {
|
||
console.log("✅ All project files loaded successfully from folder");
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error("Error loading project folder:", error);
|
||
alert(`Error loading project: ${error.message}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Individual file inputs removed - use folder picker instead
|
||
|
||
const downloadSvg = document.getElementById("downloadSvg");
|
||
if (downloadSvg) {
|
||
downloadSvg.addEventListener("click", () => {
|
||
const svg = document.querySelector("#viewer svg");
|
||
if (!svg) return;
|
||
|
||
// Always external view for export: IDs ausblenden
|
||
svg.querySelectorAll(".item-id").forEach(el => el.style.display = "none");
|
||
|
||
const now = new Date();
|
||
const ts = String(now.getFullYear()).slice(2)
|
||
+ String(now.getMonth() + 1).padStart(2, "0")
|
||
+ String(now.getDate()).padStart(2, "0")
|
||
+ "T"
|
||
+ String(now.getHours()).padStart(2, "0")
|
||
+ String(now.getMinutes()).padStart(2, "0");
|
||
|
||
const blob = new Blob([svg.outerHTML], { type: "image/svg+xml" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `${ts}-timeline.svg`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
}
|
||
|
||
const toggleView = document.getElementById("toggleView");
|
||
if (toggleView) {
|
||
toggleView.addEventListener("click", () => {
|
||
const body = document.body;
|
||
const isInternal = body.classList.contains("internal-mode");
|
||
|
||
if (isInternal) {
|
||
body.classList.remove("internal-mode");
|
||
body.classList.add("external-mode");
|
||
toggleView.textContent = "Switch to Internal View";
|
||
// Hide IDs in external mode
|
||
document.querySelectorAll(".item-id").forEach(el => el.style.display = "none");
|
||
} else {
|
||
body.classList.remove("external-mode");
|
||
body.classList.add("internal-mode");
|
||
toggleView.textContent = "Switch to External View";
|
||
// Show IDs in internal mode
|
||
document.querySelectorAll(".item-id").forEach(el => el.style.display = "inline");
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|