Files
Daneel f7f2987f86
Some checks failed
Build & Push Docker / build (push) Has been cancelled
feat: v4.4.0 - export filename: <slug_nazvu>-<YYYYMMDD-HHMM>.{json,pdf}
2026-02-20 18:38:34 +01:00

550 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Main application logic for Scenar Creator v4.
* Multi-day state, duration input, horizontal canvas.
*/
const App = {
state: {
event: { title: '', subtitle: '', date_from: '', date_to: '', location: '' },
program_types: [],
blocks: []
},
init() {
this.bindEvents();
this.newScenario();
},
// ─── Helpers ───────────────────────────────────────────────────────
uid() {
return 'b_' + Math.random().toString(36).slice(2, 10);
},
// Build download filename: "<slug_nazvu>-<YYYYMMDD-HHMM>.<ext>"
buildFilename(ext) {
const title = (this.state.event.title || 'scenar').trim();
// Remove diacritics, lowercase, replace non-alphanum with dash, collapse dashes
const slug = title
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'scenar';
const now = new Date();
const ts = now.getFullYear().toString()
+ String(now.getMonth() + 1).padStart(2, '0')
+ String(now.getDate()).padStart(2, '0')
+ '-'
+ String(now.getHours()).padStart(2, '0')
+ String(now.getMinutes()).padStart(2, '0');
return `${slug}-${ts}.${ext}`;
},
parseTimeToMin(str) {
if (!str) return 0;
const [h, m] = str.split(':').map(Number);
return (h || 0) * 60 + (m || 0);
},
minutesToTime(totalMin) {
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
},
// Return sorted list of all dates from date_from to date_to (inclusive)
getDates() {
const from = this.state.event.date_from || this.state.event.date;
const to = this.state.event.date_to || from;
if (!from) return [];
const dates = [];
const cur = new Date(from + 'T12:00:00');
const end = new Date((to || from) + 'T12:00:00');
// Safety: max 31 days
let safety = 0;
while (cur <= end && safety < 31) {
dates.push(cur.toISOString().slice(0, 10));
cur.setDate(cur.getDate() + 1);
safety++;
}
return dates;
},
// ─── State ────────────────────────────────────────────────────────
getDocument() {
this.syncEventFromUI();
return {
version: '1.0',
event: {
...this.state.event,
date: this.state.event.date_from, // backward compat
},
program_types: this.state.program_types.map(pt => ({ ...pt })),
blocks: this.state.blocks.map(b => ({ ...b }))
};
},
loadDocument(doc) {
const ev = doc.event || {};
// Backward compat: if only date exists, use it as date_from = date_to
const date_from = ev.date_from || ev.date || '';
const date_to = ev.date_to || date_from;
this.state.event = {
title: ev.title || '',
subtitle: ev.subtitle || '',
date_from,
date_to,
location: ev.location || '',
};
this.state.program_types = (doc.program_types || []).map(pt => ({ ...pt }));
this.state.blocks = (doc.blocks || []).map(b => ({
...b,
id: b.id || this.uid()
}));
this.syncEventToUI();
this.renderTypes();
this.renderCanvas();
},
newScenario() {
const today = new Date().toISOString().slice(0, 10);
this.state = {
event: { title: 'Nová akce', subtitle: '', date_from: today, date_to: today, location: '' },
program_types: [
{ id: 'main', name: 'Hlavní program', color: '#3B82F6' },
{ id: 'rest', name: 'Odpočinek', color: '#22C55E' }
],
blocks: []
};
this.syncEventToUI();
this.renderTypes();
this.renderCanvas();
},
// ─── Sync sidebar <-> state ───────────────────────────────────────
syncEventFromUI() {
this.state.event.title = document.getElementById('eventTitle').value.trim() || 'Nová akce';
this.state.event.subtitle = document.getElementById('eventSubtitle').value.trim() || null;
this.state.event.date_from = document.getElementById('eventDateFrom').value || null;
this.state.event.date_to = document.getElementById('eventDateTo').value || this.state.event.date_from;
this.state.event.location = document.getElementById('eventLocation').value.trim() || null;
},
syncEventToUI() {
document.getElementById('eventTitle').value = this.state.event.title || '';
document.getElementById('eventSubtitle').value = this.state.event.subtitle || '';
document.getElementById('eventDateFrom').value = this.state.event.date_from || '';
document.getElementById('eventDateTo').value = this.state.event.date_to || this.state.event.date_from || '';
document.getElementById('eventLocation').value = this.state.event.location || '';
},
// ─── Program types ────────────────────────────────────────────────
renderTypes() {
const container = document.getElementById('programTypesContainer');
container.innerHTML = '';
this.state.program_types.forEach((pt, i) => {
const row = document.createElement('div');
row.className = 'type-row';
row.innerHTML = `
<input type="color" value="${pt.color}" data-idx="${i}">
<input type="text" value="${pt.name}" placeholder="Název typu" data-idx="${i}">
<button class="type-remove" data-idx="${i}">&times;</button>
`;
row.querySelector('input[type="color"]').addEventListener('change', (e) => {
this.state.program_types[i].color = e.target.value;
this.renderCanvas();
});
row.querySelector('input[type="text"]').addEventListener('change', (e) => {
this.state.program_types[i].name = e.target.value.trim();
});
row.querySelector('.type-remove').addEventListener('click', () => {
this.state.program_types.splice(i, 1);
this.renderTypes();
this.renderCanvas();
});
container.appendChild(row);
});
},
// ─── Canvas ───────────────────────────────────────────────────────
renderCanvas() {
Canvas.render(this.state);
},
// ─── Block modal ──────────────────────────────────────────────────
// Open modal to edit existing block
openBlockModal(blockId) {
const block = this.state.blocks.find(b => b.id === blockId);
if (!block) return;
document.getElementById('modalTitle').textContent = 'Upravit blok';
document.getElementById('modalBlockId').value = block.id;
document.getElementById('modalBlockTitle').value = block.title || '';
document.getElementById('modalBlockStart').value = block.start || '';
document.getElementById('modalBlockEnd').value = block.end || '';
document.getElementById('modalBlockResponsible').value = block.responsible || '';
document.getElementById('modalBlockNotes').value = block.notes || '';
this._populateTypeSelect(block.type_id);
this._populateDaySelect(block.date);
this._updateDuration();
// Show delete buttons; series delete only if block belongs to a series
document.getElementById('modalDeleteBtn').classList.remove('hidden');
document.getElementById('seriesRow').classList.add('hidden');
const seriesBtn = document.getElementById('modalDeleteSeriesBtn');
if (block.series_id) {
const seriesCount = this.state.blocks.filter(b => b.series_id === block.series_id).length;
seriesBtn.textContent = `Smazat sérii (${seriesCount} bloků)`;
seriesBtn.classList.remove('hidden');
} else {
seriesBtn.classList.add('hidden');
}
document.getElementById('blockModal').classList.remove('hidden');
},
// Open modal to create new block
openNewBlockModal(date, start, end) {
document.getElementById('modalTitle').textContent = 'Nový blok';
document.getElementById('modalBlockId').value = '';
document.getElementById('modalBlockTitle').value = '';
document.getElementById('modalBlockStart').value = start || '09:00';
document.getElementById('modalBlockEnd').value = end || '10:00';
document.getElementById('modalBlockResponsible').value = '';
document.getElementById('modalBlockNotes').value = '';
this._populateTypeSelect(null);
this._populateDaySelect(date);
this._updateDuration();
// Hide delete buttons, show series row
document.getElementById('modalDeleteBtn').classList.add('hidden');
document.getElementById('modalDeleteSeriesBtn').classList.add('hidden');
document.getElementById('seriesRow').classList.remove('hidden');
document.getElementById('modalAddToAllDays').checked = false;
document.getElementById('blockModal').classList.remove('hidden');
},
_populateTypeSelect(selectedId) {
const sel = document.getElementById('modalBlockType');
sel.innerHTML = '';
this.state.program_types.forEach(pt => {
const opt = document.createElement('option');
opt.value = pt.id;
opt.textContent = pt.name;
if (pt.id === selectedId) opt.selected = true;
sel.appendChild(opt);
});
},
_populateDaySelect(selectedDate) {
const sel = document.getElementById('modalBlockDate');
sel.innerHTML = '';
const dates = this.getDates();
if (dates.length === 0) {
// Fallback: show today
const today = new Date().toISOString().slice(0, 10);
const opt = document.createElement('option');
opt.value = today;
opt.textContent = this._formatDateLabel(today);
sel.appendChild(opt);
return;
}
dates.forEach(date => {
const opt = document.createElement('option');
opt.value = date;
opt.textContent = this._formatDateLabel(date);
if (date === selectedDate) opt.selected = true;
sel.appendChild(opt);
});
// If none selected, default to first
if (!selectedDate || !dates.includes(selectedDate)) {
sel.value = dates[0];
}
},
_formatDateLabel(dateStr) {
const d = new Date(dateStr + 'T12:00:00');
const weekday = d.toLocaleDateString('cs-CZ', { weekday: 'long' });
const weekdayCap = weekday.charAt(0).toUpperCase() + weekday.slice(1);
return `${weekdayCap} (${d.getDate()}.${d.getMonth() + 1})`;
},
_updateDuration() {
const startVal = document.getElementById('modalBlockStart').value;
const endVal = document.getElementById('modalBlockEnd').value;
if (!startVal || !endVal) {
document.getElementById('modalDurHours').value = '';
document.getElementById('modalDurMinutes').value = '';
return;
}
const s = this.parseTimeToMin(startVal);
let e = this.parseTimeToMin(endVal);
if (e <= s) e += 24 * 60; // overnight
const dur = e - s;
document.getElementById('modalDurHours').value = Math.floor(dur / 60);
document.getElementById('modalDurMinutes').value = dur % 60;
},
_getDurationMinutes() {
const h = parseInt(document.getElementById('modalDurHours').value) || 0;
const m = parseInt(document.getElementById('modalDurMinutes').value) || 0;
return h * 60 + m;
},
_saveModal() {
const blockId = document.getElementById('modalBlockId').value;
const date = document.getElementById('modalBlockDate').value;
const title = document.getElementById('modalBlockTitle').value.trim();
const type_id = document.getElementById('modalBlockType').value;
const start = document.getElementById('modalBlockStart').value;
const end = document.getElementById('modalBlockEnd').value;
const responsible = document.getElementById('modalBlockResponsible').value.trim() || null;
const notes = document.getElementById('modalBlockNotes').value.trim() || null;
const timeRe = /^\d{2}:\d{2}$/;
if (!title) { this.toast('Zadejte název bloku', 'error'); return; }
if (!start || !end) { this.toast('Zadejte čas začátku a konce', 'error'); return; }
if (!timeRe.test(start) || !timeRe.test(end)) { this.toast('Neplatný formát času (HH:MM)', 'error'); return; }
if (blockId) {
// Edit existing block (no series expansion on edit — user edits only this one)
const idx = this.state.blocks.findIndex(b => b.id === blockId);
if (idx !== -1) {
const existing = this.state.blocks[idx];
Object.assign(this.state.blocks[idx], {
date, title, type_id, start, end, responsible, notes,
series_id: existing.series_id || null
});
}
} else {
// New block
const addToAll = document.getElementById('modalAddToAllDays').checked;
if (addToAll) {
// Add a copy to every day in the event range, all sharing a series_id
const series_id = this.uid();
const dates = this.getDates();
for (const d of dates) {
this.state.blocks.push({ id: this.uid(), date: d, title, type_id, start, end, responsible, notes, series_id });
}
document.getElementById('blockModal').classList.add('hidden');
this.renderCanvas();
this.toast(`Blok přidán do ${dates.length} dnů`, 'success');
return;
} else {
this.state.blocks.push({ id: this.uid(), date, title, type_id, start, end, responsible, notes, series_id: null });
}
}
document.getElementById('blockModal').classList.add('hidden');
this.renderCanvas();
this.toast('Blok uložen', 'success');
},
_deleteBlock() {
const blockId = document.getElementById('modalBlockId').value;
if (!blockId) return;
this.state.blocks = this.state.blocks.filter(b => b.id !== blockId);
document.getElementById('blockModal').classList.add('hidden');
this.renderCanvas();
this.toast('Blok smazán', 'success');
},
_deleteBlockSeries() {
const blockId = document.getElementById('modalBlockId').value;
if (!blockId) return;
const block = this.state.blocks.find(b => b.id === blockId);
if (!block || !block.series_id) {
// Fallback: delete just this one
this._deleteBlock();
return;
}
const seriesId = block.series_id;
const count = this.state.blocks.filter(b => b.series_id === seriesId).length;
this.state.blocks = this.state.blocks.filter(b => b.series_id !== seriesId);
document.getElementById('blockModal').classList.add('hidden');
this.renderCanvas();
this.toast(`Série smazána (${count} bloků)`, 'success');
},
// ─── Toast ────────────────────────────────────────────────────────
toast(message, type = 'success') {
const el = document.getElementById('toast');
if (!el) return;
el.textContent = message;
el.className = `toast ${type}`;
el.classList.remove('hidden');
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => el.classList.add('hidden'), 3000);
},
// ─── Events ───────────────────────────────────────────────────────
// ─── Time input helpers ───────────────────────────────────────────
_initTimeInput(el) {
// Auto-format: allow only digits + colon, insert ':' after 2 digits
el.addEventListener('input', (e) => {
let v = e.target.value.replace(/[^0-9:]/g, '');
// Strip colons and rebuild
const digits = v.replace(/:/g, '');
if (digits.length >= 3) {
v = digits.slice(0, 2) + ':' + digits.slice(2, 4);
} else {
v = digits;
}
e.target.value = v;
});
el.addEventListener('blur', (e) => {
const v = e.target.value;
if (!v) return;
// Validate HH:MM format
if (!/^\d{2}:\d{2}$/.test(v)) {
e.target.classList.add('input-error');
this.toast('Neplatný čas (formát HH:MM)', 'error');
} else {
const [h, m] = v.split(':').map(Number);
if (h > 23 || m > 59) {
e.target.classList.add('input-error');
this.toast('Neplatný čas (00:0023:59)', 'error');
} else {
e.target.classList.remove('input-error');
}
}
});
el.addEventListener('focus', (e) => {
e.target.classList.remove('input-error');
});
},
bindEvents() {
// Init time inputs
this._initTimeInput(document.getElementById('modalBlockStart'));
this._initTimeInput(document.getElementById('modalBlockEnd'));
// Import JSON
document.getElementById('importJsonInput').addEventListener('change', (e) => {
if (e.target.files[0]) importJson(e.target.files[0]);
e.target.value = '';
});
// Export JSON
document.getElementById('exportJsonBtn').addEventListener('click', () => exportJson());
// New scenario
document.getElementById('newScenarioBtn').addEventListener('click', () => {
if (!confirm('Vytvořit nový scénář? Neuložené změny budou ztraceny.')) return;
this.newScenario();
});
// Generate PDF
document.getElementById('generatePdfBtn').addEventListener('click', async () => {
this.syncEventFromUI();
const doc = this.getDocument();
if (!doc.blocks.length) {
this.toast('Žádné bloky k exportu', 'error');
return;
}
try {
this.toast('Generuji PDF…', 'success');
const blob = await API.postBlob('/api/generate-pdf', doc);
API.downloadBlob(blob, App.buildFilename('pdf'));
this.toast('PDF staženo', 'success');
} catch (err) {
this.toast('Chyba PDF: ' + err.message, 'error');
}
});
// Add block button
document.getElementById('addBlockBtn').addEventListener('click', () => {
const dates = this.getDates();
const date = dates[0] || new Date().toISOString().slice(0, 10);
this.openNewBlockModal(date, '09:00', '10:00');
});
// Add type
document.getElementById('addTypeBtn').addEventListener('click', () => {
this.state.program_types.push({
id: 'type_' + Math.random().toString(36).slice(2, 6),
name: 'Nový typ',
color: '#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')
});
this.renderTypes();
});
// Modal close
document.getElementById('modalClose').addEventListener('click', () => {
document.getElementById('blockModal').classList.add('hidden');
});
document.getElementById('modalSaveBtn').addEventListener('click', () => this._saveModal());
document.getElementById('modalDeleteBtn').addEventListener('click', () => this._deleteBlock());
document.getElementById('modalDeleteSeriesBtn').addEventListener('click', () => this._deleteBlockSeries());
// Duration ↔ end sync (hours + minutes fields)
const durUpdater = () => {
const startVal = document.getElementById('modalBlockStart').value;
const durMin = this._getDurationMinutes();
if (!startVal || durMin <= 0) return;
const startMin = this.parseTimeToMin(startVal);
const endMin = startMin + durMin;
// Allow overnight (> 24h notation handled as raw minutes)
const h = Math.floor(endMin / 60) % 24;
const m = endMin % 60;
document.getElementById('modalBlockEnd').value =
`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
document.getElementById('modalDurHours').addEventListener('input', durUpdater);
document.getElementById('modalDurMinutes').addEventListener('input', durUpdater);
document.getElementById('modalBlockEnd').addEventListener('input', () => {
this._updateDuration();
});
document.getElementById('modalBlockStart').addEventListener('input', () => {
this._updateDuration();
});
// Date range sync: if dateFrom changes and dateTo < dateFrom, set dateTo = dateFrom
document.getElementById('eventDateFrom').addEventListener('change', (e) => {
const toEl = document.getElementById('eventDateTo');
if (!toEl.value || toEl.value < e.target.value) {
toEl.value = e.target.value;
}
this.syncEventFromUI();
this.renderCanvas();
});
document.getElementById('eventDateTo').addEventListener('change', () => {
this.syncEventFromUI();
this.renderCanvas();
});
// Tabs
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.add('hidden'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden');
});
});
// Close modal on overlay click
document.getElementById('blockModal').addEventListener('click', (e) => {
if (e.target === document.getElementById('blockModal')) {
document.getElementById('blockModal').classList.add('hidden');
}
});
},
};