feat: v3.0 - canvas editor, JSON-only, no Excel, new UI
Some checks failed
Build & Push Docker / build (push) Has been cancelled

- Remove all Excel code (import, export, template, pandas, openpyxl)
- New canvas-based schedule editor with drag & drop (interact.js)
- Modern 3-panel UI: sidebar, canvas, documentation tab
- New data model: Block with id/date/start/end, ProgramType with id/name/color
- Clean API: GET /api/health, POST /api/validate, GET /api/sample, POST /api/generate-pdf
- Rewritten PDF generator using ScenarioDocument directly (no DataFrame)
- Professional PDF output: dark header, colored blocks, merged cells, legend, footer
- Sample JSON: "Zimní výjezd oddílu" with 11 blocks, 3 program types
- 30 tests passing (API, core models, PDF generation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 17:02:51 +01:00
parent e2bdadd0ce
commit 25fd578543
27 changed files with 2004 additions and 3016 deletions

View File

@@ -2,12 +2,11 @@
from io import BytesIO
import pandas as pd
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from app.models.event import ScenarioDocument
from app.core.validator import validate_inputs, ValidationError, ScenarsError
from app.core.validator import ScenarsError
from app.core.pdf_generator import generate_pdf
router = APIRouter()
@@ -17,35 +16,7 @@ router = APIRouter()
async def generate_pdf_endpoint(doc: ScenarioDocument):
"""Generate PDF timetable from ScenarioDocument."""
try:
validate_inputs(doc.event.title, doc.event.detail, 0)
except ValidationError as e:
raise HTTPException(status_code=422, detail=str(e))
# Convert to DataFrame
rows = []
for block in doc.blocks:
rows.append({
'Datum': block.datum,
'Zacatek': block.zacatek,
'Konec': block.konec,
'Program': block.program,
'Typ': block.typ,
'Garant': block.garant,
'Poznamka': block.poznamka,
})
df = pd.DataFrame(rows)
if df.empty:
raise HTTPException(status_code=422, detail="No blocks provided")
# Build program descriptions and colors
program_descriptions = {pt.code: pt.description for pt in doc.program_types}
program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types}
try:
pdf_bytes = generate_pdf(df, doc.event.title, doc.event.detail,
program_descriptions, program_colors)
pdf_bytes = generate_pdf(doc)
except ScenarsError as e:
raise HTTPException(status_code=422, detail=str(e))

View File

@@ -1,19 +1,14 @@
"""Scenario API endpoints: validate, import-excel, generate-excel, export-json, template."""
"""Scenario API endpoints: health, validate, sample."""
import json
import os
from io import BytesIO
from datetime import date, time as dt_time
import pandas as pd
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import StreamingResponse, FileResponse
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from app.config import VERSION, MAX_FILE_SIZE_MB
from app.models.event import ScenarioDocument, Block, ProgramType, EventInfo
from app.models.responses import HealthResponse, ValidationResponse, ImportExcelResponse
from app.core.validator import validate_inputs, ValidationError, TemplateError, ScenarsError
from app.core.excel_reader import read_excel, parse_inline_schedule, parse_inline_types
from app.core.timetable import create_timetable
from app.config import VERSION
from app.models.event import ScenarioDocument
from app.models.responses import HealthResponse, ValidationResponse
router = APIRouter()
@@ -34,135 +29,26 @@ async def validate_scenario(doc: ScenarioDocument):
if not doc.program_types:
errors.append("No program types defined")
type_codes = {pt.code for pt in doc.program_types}
type_ids = {pt.id for pt in doc.program_types}
for i, block in enumerate(doc.blocks):
if block.typ not in type_codes:
errors.append(f"Block {i+1}: unknown type '{block.typ}'")
if block.zacatek >= block.konec:
if block.type_id not in type_ids:
errors.append(f"Block {i+1}: unknown type '{block.type_id}'")
if block.start >= block.end:
errors.append(f"Block {i+1}: start time must be before end time")
return ValidationResponse(valid=len(errors) == 0, errors=errors)
@router.post("/import-excel")
async def import_excel(
file: UploadFile = File(...),
title: str = Form("Imported Event"),
detail: str = Form("Imported from Excel"),
):
"""Upload Excel file and return ScenarioDocument JSON."""
content = await file.read()
@router.get("/sample")
async def get_sample():
"""Return sample ScenarioDocument JSON."""
sample_path = os.path.join(os.path.dirname(__file__), "..", "static", "sample.json")
sample_path = os.path.abspath(sample_path)
if len(content) > MAX_FILE_SIZE_MB * 1024 * 1024:
raise HTTPException(status_code=413, detail=f"File exceeds {MAX_FILE_SIZE_MB}MB limit")
with open(sample_path, "r", encoding="utf-8") as f:
data = json.load(f)
try:
valid_data, error_rows = read_excel(content)
except TemplateError as e:
raise HTTPException(status_code=422, detail=str(e))
if valid_data.empty:
raise HTTPException(status_code=422, detail="No valid rows found in Excel file")
# Extract unique types
types_in_data = valid_data["Typ"].dropna().unique().tolist()
program_types = [
ProgramType(code=t, description=str(t), color="#0070C0")
for t in types_in_data
]
# Convert rows to blocks
blocks = []
for _, row in valid_data.iterrows():
blocks.append(Block(
datum=row["Datum"],
zacatek=row["Zacatek"],
konec=row["Konec"],
program=str(row["Program"]),
typ=str(row["Typ"]),
garant=str(row["Garant"]) if pd.notna(row.get("Garant")) else None,
poznamka=str(row["Poznamka"]) if pd.notna(row.get("Poznamka")) else None,
))
doc = ScenarioDocument(
event=EventInfo(title=title, detail=detail),
program_types=program_types,
blocks=blocks,
)
warnings = [f"Row {e['index']}: {e.get('error', e.get('Error', 'unknown'))}" for e in error_rows]
return ImportExcelResponse(
success=True,
document=doc.model_dump(mode="json"),
warnings=warnings,
)
@router.post("/generate-excel")
async def generate_excel(doc: ScenarioDocument):
"""Generate Excel timetable from ScenarioDocument."""
try:
validate_inputs(doc.event.title, doc.event.detail, 0)
except ValidationError as e:
raise HTTPException(status_code=422, detail=str(e))
# Convert to DataFrame
rows = []
for block in doc.blocks:
rows.append({
'Datum': block.datum,
'Zacatek': block.zacatek,
'Konec': block.konec,
'Program': block.program,
'Typ': block.typ,
'Garant': block.garant,
'Poznamka': block.poznamka,
})
df = pd.DataFrame(rows)
if df.empty:
raise HTTPException(status_code=422, detail="No blocks provided")
# Build program descriptions and colors
program_descriptions = {pt.code: pt.description for pt in doc.program_types}
program_colors = {pt.code: 'FF' + pt.color.lstrip('#') for pt in doc.program_types}
try:
wb = create_timetable(df, doc.event.title, doc.event.detail,
program_descriptions, program_colors)
except ScenarsError as e:
raise HTTPException(status_code=422, detail=str(e))
output = BytesIO()
wb.save(output)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=scenar_timetable.xlsx"}
)
@router.post("/export-json")
async def export_json(doc: ScenarioDocument):
"""Export ScenarioDocument as JSON."""
return doc.model_dump(mode="json")
@router.get("/template")
async def download_template():
"""Download the Excel template file."""
template_path = os.path.join(os.path.dirname(__file__), "..", "..", "templates", "scenar_template.xlsx")
template_path = os.path.abspath(template_path)
if not os.path.exists(template_path):
raise HTTPException(status_code=404, detail="Template file not found")
return FileResponse(
template_path,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename="scenar_template.xlsx"
return JSONResponse(
content=data,
headers={"Content-Disposition": "attachment; filename=sample.json"}
)

View File

@@ -1,5 +1,5 @@
"""Application configuration."""
VERSION = "2.0.0"
VERSION = "3.0.0"
MAX_FILE_SIZE_MB = 10
DEFAULT_COLOR = "#ffffff"

View File

@@ -1,28 +1,10 @@
"""Core business logic for Scenar Creator."""
"""Core business logic for Scenar Creator v3."""
from .validator import (
ScenarsError,
ValidationError,
TemplateError,
validate_inputs,
validate_excel_template,
normalize_time,
)
from .timetable import create_timetable, calculate_row_height, calculate_column_width
from .excel_reader import read_excel, get_program_types, parse_inline_schedule, parse_inline_types
from .validator import ScenarsError, ValidationError
from .pdf_generator import generate_pdf
__all__ = [
"ScenarsError",
"ValidationError",
"TemplateError",
"validate_inputs",
"validate_excel_template",
"normalize_time",
"create_timetable",
"calculate_row_height",
"calculate_column_width",
"read_excel",
"get_program_types",
"parse_inline_schedule",
"parse_inline_types",
"generate_pdf",
]

View File

@@ -1,274 +0,0 @@
"""
Excel reading and form parsing logic for Scenar Creator.
Extracted from scenar/core.py — read_excel, get_program_types, parse_inline_schedule, parse_inline_types.
"""
import pandas as pd
from io import BytesIO
import logging
from .validator import (
validate_excel_template,
normalize_time,
ValidationError,
TemplateError,
DEFAULT_COLOR,
)
logger = logging.getLogger(__name__)
def read_excel(file_content: bytes, show_debug: bool = False) -> tuple:
"""
Parse Excel file and return (valid_data, error_rows).
Handles different column naming conventions:
- Old format: Datum, Zacatek, Konec, Program, Typ, Garant, Poznamka
- New template: Datum, Zacatek bloku, Konec bloku, Nazev bloku, Typ bloku, Garant, Poznamka
Returns:
tuple: (pandas.DataFrame with valid rows, list of dicts with error details)
"""
try:
excel_data = pd.read_excel(BytesIO(file_content), skiprows=0)
except Exception as e:
raise TemplateError(f"Failed to read Excel file: {str(e)}")
# Map column names from various possible names to our standard names
column_mapping = {
'Zacatek bloku': 'Zacatek',
'Konec bloku': 'Konec',
'Nazev bloku': 'Program',
'Typ bloku': 'Typ',
}
excel_data = excel_data.rename(columns=column_mapping)
# Validate template
validate_excel_template(excel_data)
if show_debug:
logger.debug(f"Raw data:\n{excel_data.head()}")
error_rows = []
valid_data = []
for index, row in excel_data.iterrows():
try:
datum = pd.to_datetime(row["Datum"], errors='coerce').date()
zacatek = normalize_time(str(row["Zacatek"]))
konec = normalize_time(str(row["Konec"]))
if pd.isna(datum) or zacatek is None or konec is None:
raise ValueError("Invalid date or time format")
valid_data.append({
"index": index,
"Datum": datum,
"Zacatek": zacatek,
"Konec": konec,
"Program": row["Program"],
"Typ": row["Typ"],
"Garant": row["Garant"],
"Poznamka": row["Poznamka"],
"row_data": row
})
except Exception as e:
error_rows.append({"index": index, "row": row, "error": str(e)})
valid_data = pd.DataFrame(valid_data)
# Early return if no valid rows
if valid_data.empty:
logger.warning("No valid rows after parsing")
return valid_data.drop(columns='index', errors='ignore'), error_rows
if show_debug:
logger.debug(f"Cleaned data:\n{valid_data.head()}")
logger.debug(f"Error rows: {error_rows}")
# Detect overlaps
overlap_errors = []
for date, group in valid_data.groupby('Datum'):
sorted_group = group.sort_values(by='Zacatek')
previous_end_time = None
for _, r in sorted_group.iterrows():
if previous_end_time and r['Zacatek'] < previous_end_time:
overlap_errors.append({
"index": r["index"],
"Datum": r["Datum"],
"Zacatek": r["Zacatek"],
"Konec": r["Konec"],
"Program": r["Program"],
"Typ": r["Typ"],
"Garant": r["Garant"],
"Poznamka": r["Poznamka"],
"Error": f"Overlapping time block with previous block ending at {previous_end_time}",
"row_data": r["row_data"]
})
previous_end_time = r['Konec']
if overlap_errors:
if show_debug:
logger.debug(f"Overlap errors: {overlap_errors}")
valid_data = valid_data[~valid_data.index.isin([e['index'] for e in overlap_errors])]
error_rows.extend(overlap_errors)
return valid_data.drop(columns='index'), error_rows
def get_program_types(form_data: dict) -> tuple:
"""
Extract program types from form data.
Form fields: type_code_{i}, desc_{i}, color_{i}
Returns:
tuple: (program_descriptions dict, program_colors dict)
"""
program_descriptions = {}
program_colors = {}
def get_value(data, key, default=''):
# Support both dict-like and cgi.FieldStorage objects
if hasattr(data, 'getvalue'):
return data.getvalue(key, default)
return data.get(key, default)
for key in list(form_data.keys()):
if key.startswith('type_code_'):
index = key.split('_')[-1]
type_code = (get_value(form_data, f'type_code_{index}', '') or '').strip()
description = (get_value(form_data, f'desc_{index}', '') or '').strip()
raw_color = (get_value(form_data, f'color_{index}', DEFAULT_COLOR) or DEFAULT_COLOR)
if not type_code:
continue
color_hex = 'FF' + str(raw_color).lstrip('#')
program_descriptions[type_code] = description
program_colors[type_code] = color_hex
return program_descriptions, program_colors
def parse_inline_schedule(form_data) -> pd.DataFrame:
"""
Parse inline schedule form data into DataFrame.
Form fields:
datum_{i}, zacatek_{i}, konec_{i}, program_{i}, typ_{i}, garant_{i}, poznamka_{i}
Args:
form_data: dict or cgi.FieldStorage with form data
Returns:
DataFrame with parsed schedule data
Raises:
ValidationError: if required fields missing or invalid
"""
rows = []
row_indices = set()
# Helper to get value from both dict and FieldStorage
def get_value(data, key, default=''):
if hasattr(data, 'getvalue'): # cgi.FieldStorage
return data.getvalue(key, default).strip()
else: # dict
return data.get(key, default).strip()
# Find all row indices
for key in form_data.keys():
if key.startswith('datum_'):
idx = key.split('_')[-1]
row_indices.add(idx)
for idx in sorted(row_indices, key=int):
datum_str = get_value(form_data, f'datum_{idx}', '')
zacatek_str = get_value(form_data, f'zacatek_{idx}', '')
konec_str = get_value(form_data, f'konec_{idx}', '')
program = get_value(form_data, f'program_{idx}', '')
typ = get_value(form_data, f'typ_{idx}', '')
garant = get_value(form_data, f'garant_{idx}', '')
poznamka = get_value(form_data, f'poznamka_{idx}', '')
# Skip empty rows
if not any([datum_str, zacatek_str, konec_str, program, typ]):
continue
# Validate required fields
if not all([datum_str, zacatek_str, konec_str, program, typ]):
raise ValidationError(
f"Řádek {int(idx)+1}: Všechna povinná pole (Datum, Začátek, Konec, Program, Typ) musí být vyplněna"
)
try:
datum = pd.to_datetime(datum_str).date()
except Exception:
raise ValidationError(f"Řádek {int(idx)+1}: Neplatné datum")
zacatek = normalize_time(zacatek_str)
konec = normalize_time(konec_str)
if zacatek is None or konec is None:
raise ValidationError(f"Řádek {int(idx)+1}: Neplatný čas (použijte HH:MM nebo HH:MM:SS)")
rows.append({
'Datum': datum,
'Zacatek': zacatek,
'Konec': konec,
'Program': program,
'Typ': typ,
'Garant': garant if garant else None,
'Poznamka': poznamka if poznamka else None,
})
if not rows:
raise ValidationError("Žádné platné řádky ve formuláři")
return pd.DataFrame(rows)
def parse_inline_types(form_data) -> tuple:
"""
Parse inline type definitions from form data.
Form fields: type_name_{i}, type_desc_{i}, type_color_{i}
Args:
form_data: dict or cgi.FieldStorage with form data
Returns:
tuple: (program_descriptions dict, program_colors dict)
"""
descriptions = {}
colors = {}
type_indices = set()
# Helper to get value from both dict and FieldStorage
def get_value(data, key, default=''):
if hasattr(data, 'getvalue'): # cgi.FieldStorage
return data.getvalue(key, default).strip()
else: # dict
return data.get(key, default).strip()
# Find all type indices
for key in form_data.keys():
if key.startswith('type_name_'):
idx = key.split('_')[-1]
type_indices.add(idx)
for idx in sorted(type_indices, key=int):
type_name = get_value(form_data, f'type_name_{idx}', '')
type_desc = get_value(form_data, f'type_desc_{idx}', '')
type_color = get_value(form_data, f'type_color_{idx}', DEFAULT_COLOR)
# Skip empty types
if not type_name:
continue
descriptions[type_name] = type_desc
colors[type_name] = 'FF' + type_color.lstrip('#')
return descriptions, colors

View File

@@ -1,17 +1,17 @@
"""
PDF generation for Scenar Creator using ReportLab.
PDF generation for Scenar Creator v3 using ReportLab.
Generates A4 landscape timetable PDF with colored blocks and legend.
"""
import pandas as pd
from io import BytesIO
from datetime import datetime
from collections import defaultdict
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
import logging
from .validator import ScenarsError
@@ -20,175 +20,270 @@ logger = logging.getLogger(__name__)
def hex_to_reportlab_color(hex_color: str) -> colors.Color:
"""Convert hex color (AARRGGBB or #RRGGBB) to ReportLab color."""
"""Convert #RRGGBB hex color to ReportLab color."""
h = hex_color.lstrip('#')
if len(h) == 8: # AARRGGBB format
h = h[2:] # strip alpha
if len(h) == 8:
h = h[2:]
if len(h) == 6:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return colors.Color(r / 255.0, g / 255.0, b / 255.0)
return colors.white
def generate_pdf(data: pd.DataFrame, title: str, detail: str,
program_descriptions: dict, program_colors: dict) -> bytes:
def is_light_color(hex_color: str) -> bool:
"""Check if a color is light (needs dark text)."""
h = hex_color.lstrip('#')
if len(h) == 8:
h = h[2:]
if len(h) == 6:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.6
return False
def time_to_minutes(time_str: str) -> int:
"""Convert HH:MM to minutes since midnight."""
parts = time_str.split(":")
return int(parts[0]) * 60 + int(parts[1])
def generate_pdf(doc) -> bytes:
"""
Generate a PDF timetable.
Generate a PDF timetable from a ScenarioDocument.
Args:
data: DataFrame with validated schedule data
title: Event title
detail: Event detail/description
program_descriptions: {type: description}
program_colors: {type: color_hex in AARRGGBB format}
doc: ScenarioDocument instance
Returns:
bytes: PDF file content
Raises:
ScenarsError: if data is invalid
"""
if data.empty:
raise ScenarsError("Data is empty after validation")
if not doc.blocks:
raise ScenarsError("No blocks provided")
missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors]
if missing_types:
raise ScenarsError(
f"Missing type definitions: {', '.join(missing_types)}. "
"Please define all program types."
)
type_map = {pt.id: pt for pt in doc.program_types}
for block in doc.blocks:
if block.type_id not in type_map:
raise ScenarsError(
f"Missing type definition: '{block.type_id}'. "
"Please define all program types."
)
buffer = BytesIO()
doc = SimpleDocTemplate(
page_w, page_h = landscape(A4)
doc_pdf = SimpleDocTemplate(
buffer,
pagesize=landscape(A4),
leftMargin=10 * mm,
rightMargin=10 * mm,
topMargin=10 * mm,
bottomMargin=10 * mm,
leftMargin=12 * mm,
rightMargin=12 * mm,
topMargin=12 * mm,
bottomMargin=12 * mm,
)
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'TimetableTitle', parent=styles['Title'],
fontSize=18, alignment=TA_CENTER, spaceAfter=2 * mm
fontSize=20, alignment=TA_LEFT, spaceAfter=1 * mm,
textColor=colors.Color(0.118, 0.161, 0.231),
fontName='Helvetica-Bold'
)
detail_style = ParagraphStyle(
'TimetableDetail', parent=styles['Normal'],
fontSize=12, alignment=TA_CENTER, spaceAfter=4 * mm,
textColor=colors.gray
subtitle_style = ParagraphStyle(
'TimetableSubtitle', parent=styles['Normal'],
fontSize=12, alignment=TA_LEFT, spaceAfter=1 * mm,
textColor=colors.Color(0.4, 0.4, 0.4),
fontName='Helvetica'
)
cell_style = ParagraphStyle(
'CellStyle', parent=styles['Normal'],
fontSize=7, alignment=TA_CENTER, leading=9
info_style = ParagraphStyle(
'InfoStyle', parent=styles['Normal'],
fontSize=10, alignment=TA_LEFT, spaceAfter=4 * mm,
textColor=colors.Color(0.5, 0.5, 0.5),
fontName='Helvetica'
)
cell_style_white = ParagraphStyle(
'CellStyleWhite', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER, leading=10,
textColor=colors.white, fontName='Helvetica-Bold'
)
cell_style_dark = ParagraphStyle(
'CellStyleDark', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER, leading=10,
textColor=colors.Color(0.1, 0.1, 0.1), fontName='Helvetica-Bold'
)
time_style = ParagraphStyle(
'TimeStyle', parent=styles['Normal'],
fontSize=7, alignment=TA_RIGHT, leading=9,
textColor=colors.Color(0.5, 0.5, 0.5), fontName='Helvetica'
)
legend_style = ParagraphStyle(
'LegendStyle', parent=styles['Normal'],
fontSize=8, alignment=TA_LEFT
fontSize=9, alignment=TA_LEFT, fontName='Helvetica'
)
footer_style = ParagraphStyle(
'FooterStyle', parent=styles['Normal'],
fontSize=8, alignment=TA_CENTER,
textColor=colors.Color(0.6, 0.6, 0.6), fontName='Helvetica-Oblique'
)
elements = []
elements.append(Paragraph(title, title_style))
elements.append(Paragraph(detail, detail_style))
data = data.sort_values(by=["Datum", "Zacatek"])
# Header
elements.append(Paragraph(doc.event.title, title_style))
if doc.event.subtitle:
elements.append(Paragraph(doc.event.subtitle, subtitle_style))
start_times = data["Zacatek"]
end_times = data["Konec"]
info_parts = []
if doc.event.date:
info_parts.append(f"Datum: {doc.event.date}")
if doc.event.location:
info_parts.append(f"M\u00edsto: {doc.event.location}")
if info_parts:
elements.append(Paragraph(" | ".join(info_parts), info_style))
min_time = min(start_times)
max_time = max(end_times)
elements.append(Spacer(1, 3 * mm))
time_slots = pd.date_range(
datetime.combine(datetime.today(), min_time),
datetime.combine(datetime.today(), max_time),
freq='15min'
).time
# Group blocks by date
blocks_by_date = defaultdict(list)
for block in doc.blocks:
blocks_by_date[block.date].append(block)
# Build header row
header = ["Datum"] + [t.strftime("%H:%M") for t in time_slots]
sorted_dates = sorted(blocks_by_date.keys())
# Find global time range
all_starts = [time_to_minutes(b.start) for b in doc.blocks]
all_ends = [time_to_minutes(b.end) for b in doc.blocks]
global_start = (min(all_starts) // 30) * 30
global_end = ((max(all_ends) + 29) // 30) * 30
# Generate 30-min time slots
time_slots = []
t = global_start
while t <= global_end:
h, m = divmod(t, 60)
time_slots.append(f"{h:02d}:{m:02d}")
t += 30
# Build table: time column + one column per day
header = [""] + [d for d in sorted_dates]
table_data = [header]
cell_colors = [] # list of (row, col, color) for styling
slot_count = len(time_slots) - 1
grouped_data = data.groupby(data['Datum'])
row_idx = 1
# Build grid and track colored cells
cell_colors_list = []
for date_val, group in grouped_data:
day_name = date_val.strftime("%A")
date_str = date_val.strftime(f"%d.%m {day_name}")
for slot_idx in range(slot_count):
slot_start = time_slots[slot_idx]
slot_end = time_slots[slot_idx + 1]
row = [Paragraph(slot_start, time_style)]
for date_key in sorted_dates:
cell_content = ""
for block in blocks_by_date[date_key]:
block_start_min = time_to_minutes(block.start)
block_end_min = time_to_minutes(block.end)
slot_start_min = time_to_minutes(slot_start)
slot_end_min = time_to_minutes(slot_end)
if block_start_min <= slot_start_min and block_end_min >= slot_end_min:
pt = type_map[block.type_id]
light = is_light_color(pt.color)
cs = cell_style_dark if light else cell_style_white
if block_start_min == slot_start_min:
label = block.title
if block.responsible:
label += f"<br/><font size='6'>{block.responsible}</font>"
cell_content = Paragraph(label, cs)
else:
cell_content = ""
cell_colors_list.append((len(table_data), len(row), pt.color))
break
row.append(cell_content if cell_content else "")
row = [date_str] + [""] * len(time_slots)
table_data.append(row)
row_idx += 1
# Create a sub-row for blocks
block_row = [""] * (len(time_slots) + 1)
for _, blk in group.iterrows():
try:
start_idx = list(time_slots).index(blk["Zacatek"]) + 1
end_idx = list(time_slots).index(blk["Konec"]) + 1
except ValueError:
continue
# Column widths
avail_width = page_w - 24 * mm
time_col_width = 18 * mm
day_col_width = (avail_width - time_col_width) / max(len(sorted_dates), 1)
col_widths = [time_col_width] + [day_col_width] * len(sorted_dates)
label = blk['Program']
if pd.notna(blk.get('Garant')):
label += f"\n{blk['Garant']}"
block_row[start_idx] = Paragraph(label.replace('\n', '<br/>'), cell_style)
rl_color = hex_to_reportlab_color(program_colors[blk["Typ"]])
for ci in range(start_idx, end_idx):
cell_colors.append((row_idx, ci, rl_color))
table_data.append(block_row)
row_idx += 1
# Calculate column widths
avail_width = landscape(A4)[0] - 20 * mm
date_col_width = 30 * mm
slot_width = max(12 * mm, (avail_width - date_col_width) / max(len(time_slots), 1))
col_widths = [date_col_width] + [slot_width] * len(time_slots)
table = Table(table_data, colWidths=col_widths, repeatRows=1)
row_height = 20
table = Table(table_data, colWidths=col_widths, rowHeights=[24] + [row_height] * slot_count)
style_cmds = [
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.83, 0.83, 0.83)),
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('BACKGROUND', (0, 0), (-1, 0), colors.Color(0.118, 0.161, 0.231)),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('FONTSIZE', (0, 0), (-1, 0), 7),
('FONTSIZE', (0, 1), (-1, -1), 6),
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.Color(0.97, 0.97, 0.97)]),
('ALIGN', (0, 1), (0, -1), 'RIGHT'),
('FONTSIZE', (0, 1), (-1, -1), 7),
('GRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
('LINEBELOW', (0, 0), (-1, 0), 1.5, colors.Color(0.118, 0.161, 0.231)),
('ROWBACKGROUNDS', (1, 1), (-1, -1), [colors.white, colors.Color(0.98, 0.98, 0.98)]),
]
for r, c, clr in cell_colors:
style_cmds.append(('BACKGROUND', (c, r), (c, r), clr))
for r, c, hex_clr in cell_colors_list:
rl_color = hex_to_reportlab_color(hex_clr)
style_cmds.append(('BACKGROUND', (c, r), (c, r), rl_color))
# Merge cells for blocks spanning multiple time slots
for date_idx, date_key in enumerate(sorted_dates):
col = date_idx + 1
for block in blocks_by_date[date_key]:
block_start_min = time_to_minutes(block.start)
block_end_min = time_to_minutes(block.end)
start_row = None
end_row = None
for slot_idx in range(slot_count):
slot_min = time_to_minutes(time_slots[slot_idx])
if slot_min == block_start_min:
start_row = slot_idx + 1
if slot_idx + 1 < len(time_slots):
next_slot_min = time_to_minutes(time_slots[slot_idx + 1])
if next_slot_min == block_end_min:
end_row = slot_idx + 1
if start_row is not None and end_row is not None and end_row > start_row:
style_cmds.append(('SPAN', (col, start_row), (col, end_row)))
table.setStyle(TableStyle(style_cmds))
elements.append(table)
# Legend
elements.append(Spacer(1, 5 * mm))
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
legend_items = []
for pt in doc.program_types:
legend_items.append([Paragraph(f" {pt.name}", legend_style)])
legend_data = []
legend_colors_list = []
for i, (typ, desc) in enumerate(program_descriptions.items()):
legend_data.append([Paragraph(f"{desc} ({typ})", legend_style)])
legend_colors_list.append(hex_to_reportlab_color(program_colors[typ]))
if legend_data:
legend_table = Table(legend_data, colWidths=[80 * mm])
if legend_items:
elements.append(Paragraph("<b>Legenda:</b>", legend_style))
elements.append(Spacer(1, 2 * mm))
legend_table = Table(legend_items, colWidths=[60 * mm])
legend_cmds = [
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('BOX', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
('INNERGRID', (0, 0), (-1, -1), 0.5, colors.Color(0.85, 0.85, 0.85)),
]
for i, clr in enumerate(legend_colors_list):
legend_cmds.append(('BACKGROUND', (0, i), (0, i), clr))
for i, pt in enumerate(doc.program_types):
rl_color = hex_to_reportlab_color(pt.color)
legend_cmds.append(('BACKGROUND', (0, i), (0, i), rl_color))
if not is_light_color(pt.color):
legend_cmds.append(('TEXTCOLOR', (0, i), (0, i), colors.white))
legend_table.setStyle(TableStyle(legend_cmds))
elements.append(legend_table)
doc.build(elements)
# Footer
elements.append(Spacer(1, 5 * mm))
gen_date = datetime.now().strftime("%d.%m.%Y %H:%M")
elements.append(Paragraph(
f"Vygenerov\u00e1no Scen\u00e1r Creatorem | {gen_date}",
footer_style
))
doc_pdf.build(elements)
return buffer.getvalue()

View File

@@ -1,242 +0,0 @@
"""
Timetable generation logic for Scenar Creator.
Extracted from scenar/core.py — create_timetable (Excel output).
"""
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from datetime import datetime
import logging
from .validator import ScenarsError
logger = logging.getLogger(__name__)
def calculate_row_height(cell_value, column_width):
"""Calculate row height based on content."""
if not cell_value:
return 15
max_line_length = column_width * 1.2
lines = str(cell_value).split('\n')
line_count = 0
for line in lines:
line_count += len(line) // max_line_length + 1
return line_count * 15
def calculate_column_width(text):
"""Calculate column width based on text length."""
max_length = max(len(line) for line in str(text).split('\n'))
return max_length * 1.2
def create_timetable(data: pd.DataFrame, title: str, detail: str,
program_descriptions: dict, program_colors: dict) -> Workbook:
"""
Create an OpenPyXL timetable workbook.
Args:
data: DataFrame with validated schedule data
title: Event title
detail: Event detail/description
program_descriptions: {type: description}
program_colors: {type: color_hex}
Returns:
openpyxl.Workbook
Raises:
ScenarsError: if data is invalid or types are missing
"""
if data.empty:
raise ScenarsError("Data is empty after validation")
missing_types = [typ for typ in data["Typ"].unique() if typ not in program_colors]
if missing_types:
raise ScenarsError(
f"Missing type definitions: {', '.join(missing_types)}. "
"Please define all program types."
)
wb = Workbook()
ws = wb.active
thick_border = Border(left=Side(style='thick', color='000000'),
right=Side(style='thick', color='000000'),
top=Side(style='thick', color='000000'),
bottom=Side(style='thick', color='000000'))
# Title and detail
ws['A1'] = title
ws['A1'].alignment = Alignment(horizontal="center", vertical="center")
ws['A1'].font = Font(size=24, bold=True)
ws['A1'].border = thick_border
ws['A2'] = detail
ws['A2'].alignment = Alignment(horizontal="center", vertical="center")
ws['A2'].font = Font(size=16, italic=True)
ws['A2'].border = thick_border
if ws.column_dimensions[get_column_letter(1)].width is None:
ws.column_dimensions[get_column_letter(1)].width = 40
title_row_height = calculate_row_height(title, ws.column_dimensions[get_column_letter(1)].width)
detail_row_height = calculate_row_height(detail, ws.column_dimensions[get_column_letter(1)].width)
ws.row_dimensions[1].height = title_row_height
ws.row_dimensions[2].height = detail_row_height
data = data.sort_values(by=["Datum", "Zacatek"])
start_times = data["Zacatek"]
end_times = data["Konec"]
if start_times.isnull().any() or end_times.isnull().any():
raise ScenarsError("Data contains invalid time values")
try:
min_time = min(start_times)
max_time = max(end_times)
except ValueError as e:
raise ScenarsError(f"Error determining time range: {e}")
time_slots = pd.date_range(
datetime.combine(datetime.today(), min_time),
datetime.combine(datetime.today(), max_time),
freq='15min'
).time
total_columns = len(time_slots) + 1
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=total_columns)
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=total_columns)
row_offset = 3
col_offset = 1
cell = ws.cell(row=row_offset, column=col_offset, value="Datum")
cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.font = Font(bold=True)
cell.border = thick_border
for i, time_slot in enumerate(time_slots, start=col_offset + 1):
cell = ws.cell(row=row_offset, column=i, value=time_slot.strftime("%H:%M"))
cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.font = Font(bold=True)
cell.border = thick_border
current_row = row_offset + 1
grouped_data = data.groupby(data['Datum'])
for date, group in grouped_data:
day_name = date.strftime("%A")
date_str = date.strftime(f"%d.%m {day_name}")
cell = ws.cell(row=current_row, column=col_offset, value=date_str)
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.fill = PatternFill(start_color="D3D3D3", end_color="D3D3D3", fill_type="solid")
cell.font = Font(bold=True, size=14)
cell.border = thick_border
# Track which cells are already filled (for overlap detection)
date_row = current_row
occupied_cells = set() # (row, col) pairs already filled
for _, row in group.iterrows():
start_time = row["Zacatek"]
end_time = row["Konec"]
try:
start_index = list(time_slots).index(start_time) + col_offset + 1
end_index = list(time_slots).index(end_time) + col_offset + 1
except ValueError as e:
logger.error(f"Time slot not found: {start_time} to {end_time}")
continue
cell_value = f"{row['Program']}"
if pd.notna(row['Garant']):
cell_value += f"\n{row['Garant']}"
if pd.notna(row['Poznamka']):
cell_value += f"\n\n{row['Poznamka']}"
# Check for overlaps
working_row = date_row + 1
conflict = False
for col in range(start_index, end_index):
if (working_row, col) in occupied_cells:
conflict = True
break
# If conflict, find next available row
if conflict:
while any((working_row, col) in occupied_cells for col in range(start_index, end_index)):
working_row += 1
# Mark cells as occupied
for col in range(start_index, end_index):
occupied_cells.add((working_row, col))
try:
ws.merge_cells(start_row=working_row, start_column=start_index,
end_row=working_row, end_column=end_index - 1)
# Get the first cell of the merge (not the merged cell)
cell = ws.cell(row=working_row, column=start_index)
cell.value = cell_value
except Exception as e:
raise ScenarsError(f"Error creating timetable cell: {str(e)}")
cell.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center")
lines = str(cell_value).split("\n")
for idx, _ in enumerate(lines):
if idx == 0:
cell.font = Font(bold=True)
elif idx == 1:
cell.font = Font(bold=False)
elif idx > 1 and pd.notna(row['Poznamka']):
cell.font = Font(italic=True)
cell.fill = PatternFill(start_color=program_colors[row["Typ"]],
end_color=program_colors[row["Typ"]],
fill_type="solid")
cell.border = thick_border
# Update current_row to be after all rows for this date
if occupied_cells:
max_row_for_date = max(r for r, c in occupied_cells)
current_row = max_row_for_date + 1
else:
current_row += 1
# Legend
legend_row = current_row + 2
legend_max_length = 0
ws.cell(row=legend_row, column=1, value="Legenda:").font = Font(bold=True)
legend_row += 1
for typ, desc in program_descriptions.items():
legend_text = f"{desc} ({typ})"
legend_cell = ws.cell(row=legend_row, column=1, value=legend_text)
legend_cell.fill = PatternFill(start_color=program_colors[typ], fill_type="solid")
legend_max_length = max(legend_max_length, calculate_column_width(legend_text))
legend_row += 1
ws.column_dimensions[get_column_letter(1)].width = legend_max_length
for col in range(2, total_columns + 1):
ws.column_dimensions[get_column_letter(col)].width = 15
for row in ws.iter_rows(min_row=1, max_row=current_row - 1, min_col=1, max_col=total_columns):
for cell in row:
cell.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center")
cell.border = thick_border
for row in ws.iter_rows(min_row=1, max_row=current_row - 1):
max_height = 0
for cell in row:
if cell.value:
height = calculate_row_height(cell.value, ws.column_dimensions[get_column_letter(cell.column)].width)
if height > max_height:
max_height = height
ws.row_dimensions[row[0].row].height = max_height
return wb

View File

@@ -1,18 +1,9 @@
"""
Validation logic for Scenar Creator.
Extracted from scenar/core.py — validate_inputs, validate_excel_template, overlap detection.
"""
"""Validation logic for Scenar Creator v3."""
import pandas as pd
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
DEFAULT_COLOR = "#ffffff"
MAX_FILE_SIZE_MB = 10
REQUIRED_COLUMNS = ["Datum", "Zacatek", "Konec", "Program", "Typ", "Garant", "Poznamka"]
class ScenarsError(Exception):
"""Base exception for Scenar Creator."""
@@ -22,48 +13,3 @@ class ScenarsError(Exception):
class ValidationError(ScenarsError):
"""Raised when input validation fails."""
pass
class TemplateError(ScenarsError):
"""Raised when Excel template is invalid."""
pass
def validate_inputs(title: str, detail: str, file_size: int) -> None:
"""Validate user inputs for security and sanity."""
if not title or not isinstance(title, str):
raise ValidationError("Title is required and must be a string")
if len(title.strip()) == 0:
raise ValidationError("Title cannot be empty")
if len(title) > 200:
raise ValidationError("Title is too long (max 200 characters)")
if not detail or not isinstance(detail, str):
raise ValidationError("Detail is required and must be a string")
if len(detail.strip()) == 0:
raise ValidationError("Detail cannot be empty")
if len(detail) > 500:
raise ValidationError("Detail is too long (max 500 characters)")
if file_size > MAX_FILE_SIZE_MB * 1024 * 1024:
raise ValidationError(f"File size exceeds {MAX_FILE_SIZE_MB} MB limit")
def normalize_time(time_str: str):
"""Parse time string in formats %H:%M or %H:%M:%S."""
for fmt in ('%H:%M', '%H:%M:%S'):
try:
return datetime.strptime(time_str, fmt).time()
except ValueError:
continue
return None
def validate_excel_template(df: pd.DataFrame) -> None:
"""Validate that Excel has required columns."""
missing_cols = set(REQUIRED_COLUMNS) - set(df.columns)
if missing_cols:
raise TemplateError(
f"Excel template missing required columns: {', '.join(missing_cols)}. "
f"Expected: {', '.join(REQUIRED_COLUMNS)}"
)

View File

@@ -10,7 +10,7 @@ from app.config import VERSION
app = FastAPI(
title="Scenar Creator",
description="Web tool for creating timetable scenarios from Excel or inline forms",
description="Web tool for creating experience course scenarios with canvas editor",
version=VERSION,
)

View File

@@ -1,30 +1,33 @@
"""Pydantic v2 models for Scenar Creator."""
"""Pydantic v2 models for Scenar Creator v3."""
from datetime import date, time
import uuid
from typing import List, Optional
from pydantic import BaseModel, Field
class Block(BaseModel):
datum: date
zacatek: time
konec: time
program: str
typ: str
garant: Optional[str] = None
poznamka: Optional[str] = None
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
date: str # "YYYY-MM-DD"
start: str # "HH:MM"
end: str # "HH:MM"
title: str
type_id: str
responsible: Optional[str] = None
notes: Optional[str] = None
class ProgramType(BaseModel):
code: str
description: str
color: str # hex #RRGGBB
id: str
name: str
color: str # "#RRGGBB"
class EventInfo(BaseModel):
title: str = Field(..., max_length=200)
detail: str = Field(..., max_length=500)
title: str
subtitle: Optional[str] = None
date: Optional[str] = None
location: Optional[str] = None
class ScenarioDocument(BaseModel):

View File

@@ -1,6 +1,6 @@
"""API response models."""
from typing import Any, List, Optional
from typing import List
from pydantic import BaseModel
@@ -13,10 +13,3 @@ class HealthResponse(BaseModel):
class ValidationResponse(BaseModel):
valid: bool
errors: List[str] = []
class ImportExcelResponse(BaseModel):
success: bool
document: Optional[Any] = None
errors: List[str] = []
warnings: List[str] = []

View File

@@ -1,293 +1,711 @@
/* Scenar Creator v3 — Main Stylesheet */
:root {
--header-bg: #1e293b;
--accent: #3b82f6;
--accent-hover: #2563eb;
--danger: #ef4444;
--danger-hover: #dc2626;
--success: #22c55e;
--text: #1e293b;
--text-light: #64748b;
--border: #e2e8f0;
--bg: #f8fafc;
--white: #ffffff;
--sidebar-width: 280px;
--header-height: 56px;
--tab-height: 40px;
--grid-row-height: 30px;
--radius: 8px;
--radius-sm: 4px;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--shadow-lg: 0 4px 12px rgba(0,0,0,0.15);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f5f5;
color: #333;
line-height: 1.6;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
overflow: hidden;
height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
/* Header */
.header {
height: var(--header-height);
background: var(--header-bg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
color: white;
z-index: 100;
}
h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 5px;
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.subtitle {
text-align: center;
color: #7f8c8d;
margin-bottom: 20px;
.header-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.3px;
}
.header-version {
font-size: 11px;
background: rgba(255,255,255,0.15);
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Tabs */
.tabs {
height: var(--tab-height);
background: var(--white);
border-bottom: 1px solid var(--border);
display: flex;
padding: 0 20px;
gap: 0;
margin-bottom: 0;
border-bottom: 2px solid #3498db;
}
.tab {
padding: 10px 24px;
border: 1px solid #ddd;
border-bottom: none;
background: #ecf0f1;
padding: 0 16px;
height: 100%;
border: none;
background: none;
cursor: pointer;
font-size: 14px;
border-radius: 6px 6px 0 0;
transition: background 0.2s;
font-size: 13px;
font-weight: 500;
color: var(--text-light);
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-family: inherit;
}
.tab:hover {
color: var(--text);
}
.tab.active {
background: #fff;
border-color: #3498db;
border-bottom: 2px solid #fff;
margin-bottom: -2px;
font-weight: bold;
}
.tab:hover:not(.active) {
background: #d5dbdb;
}
.tab-content {
display: none;
background: #fff;
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 6px 6px;
}
.tab-content.active {
display: block;
}
/* Form */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 4px;
}
.form-group input[type="text"],
.form-group input[type="file"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.form-actions {
margin-top: 20px;
display: flex;
gap: 10px;
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Buttons */
.btn {
display: inline-block;
padding: 10px 20px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 4px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
font-size: 14px;
text-decoration: none;
text-align: center;
transition: background 0.2s;
transition: all 0.15s;
font-family: inherit;
white-space: nowrap;
}
.btn-primary {
background: #3498db;
color: #fff;
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: #2980b9;
background: var(--accent-hover);
}
.btn-secondary {
background: #95a5a6;
color: #fff;
background: rgba(255,255,255,0.12);
color: white;
border: 1px solid rgba(255,255,255,0.2);
}
.btn-secondary:hover {
background: #7f8c8d;
background: rgba(255,255,255,0.2);
}
.btn-danger {
background: #e74c3c;
color: #fff;
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #c0392b;
background: var(--danger-hover);
}
.btn-sm {
padding: 4px 10px;
padding: 6px 12px;
font-size: 12px;
}
/* Types */
.btn-xs {
padding: 4px 10px;
font-size: 11px;
background: var(--bg);
color: var(--text-light);
border: 1px solid var(--border);
}
.btn-xs:hover {
background: var(--border);
color: var(--text);
}
.btn-block {
width: 100%;
}
/* Layout */
.tab-content {
height: calc(100vh - var(--header-height) - var(--tab-height));
overflow: hidden;
}
.tab-content.hidden {
display: none;
}
.app-layout {
display: flex;
height: 100%;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--white);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
}
.sidebar-section {
margin-bottom: 20px;
}
.sidebar-heading {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-light);
margin-bottom: 10px;
font-weight: 600;
}
.form-group {
margin-bottom: 10px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-light);
margin-bottom: 4px;
}
.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="time"],
.form-group select,
.form-group textarea {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
font-family: inherit;
background: var(--white);
color: var(--text);
transition: border-color 0.15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.form-row {
display: flex;
gap: 10px;
}
.form-row .form-group {
flex: 1;
}
/* Program type rows */
.type-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
gap: 6px;
margin-bottom: 8px;
padding: 6px 8px;
background: var(--bg);
border-radius: var(--radius-sm);
}
.type-row input[type="color"] {
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
padding: 0;
background: none;
}
.type-row input[type="text"] {
flex: 1;
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 12px;
font-family: inherit;
}
.type-row input[type="color"] {
width: 40px;
height: 32px;
border: 1px solid #ccc;
border-radius: 4px;
.type-row input[type="text"]:focus {
outline: none;
border-color: var(--accent);
}
.type-remove {
width: 22px;
height: 22px;
border: none;
background: none;
color: var(--text-light);
cursor: pointer;
font-size: 16px;
line-height: 1;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.type-code {
max-width: 200px;
.type-remove:hover {
background: var(--danger);
color: white;
}
/* Schedule table */
#scheduleTable {
/* Canvas */
.canvas-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.canvas-header {
display: flex;
padding: 0 0 0 60px;
background: var(--white);
border-bottom: 1px solid var(--border);
min-height: 36px;
align-items: stretch;
}
.canvas-header .day-header {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--text);
border-left: 1px solid var(--border);
padding: 8px;
}
.canvas-header .day-header:first-child {
border-left: none;
}
.canvas-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
}
.canvas {
display: flex;
position: relative;
min-height: 600px;
}
.time-axis {
width: 60px;
min-width: 60px;
position: relative;
background: var(--white);
border-right: 1px solid var(--border);
}
.time-label {
position: absolute;
right: 8px;
font-size: 11px;
color: var(--text-light);
transform: translateY(-50%);
font-variant-numeric: tabular-nums;
}
.day-columns {
flex: 1;
display: flex;
position: relative;
}
.day-column {
flex: 1;
position: relative;
border-left: 1px solid var(--border);
min-width: 200px;
}
.day-column:first-child {
border-left: none;
}
/* Grid lines */
.grid-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: var(--border);
pointer-events: none;
}
.grid-line.hour {
background: var(--border);
}
.grid-line.half {
background: #f1f5f9;
}
/* Schedule blocks */
.schedule-block {
position: absolute;
left: 4px;
right: 4px;
border-radius: 6px;
padding: 6px 8px;
cursor: grab;
overflow: hidden;
transition: box-shadow 0.15s;
z-index: 10;
display: flex;
flex-direction: column;
min-height: 20px;
user-select: none;
touch-action: none;
}
.schedule-block:hover {
box-shadow: var(--shadow-lg);
z-index: 20;
}
.schedule-block:active {
cursor: grabbing;
}
.schedule-block .block-color-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 6px 0 0 6px;
}
.schedule-block .block-title {
font-size: 12px;
font-weight: 600;
color: white;
line-height: 1.2;
padding-left: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.schedule-block .block-time {
font-size: 10px;
color: rgba(255,255,255,0.8);
padding-left: 4px;
}
.schedule-block .block-responsible {
font-size: 10px;
color: rgba(255,255,255,0.7);
padding-left: 4px;
margin-top: auto;
}
.schedule-block.light-bg .block-title {
color: var(--text);
}
.schedule-block.light-bg .block-time {
color: var(--text-light);
}
.schedule-block.light-bg .block-responsible {
color: var(--text-light);
}
/* Resize handle */
.schedule-block .resize-handle {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 8px;
cursor: s-resize;
background: transparent;
}
.schedule-block:hover .resize-handle {
background: rgba(0,0,0,0.1);
border-radius: 0 0 6px 6px;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-overlay.hidden {
display: none;
}
.modal {
background: var(--white);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
width: 420px;
max-width: 90vw;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
font-size: 16px;
font-weight: 600;
}
.modal-close {
width: 30px;
height: 30px;
border: none;
background: none;
font-size: 20px;
cursor: pointer;
color: var(--text-light);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: var(--bg);
}
.modal-body {
padding: 20px;
}
.modal-footer {
display: flex;
justify-content: space-between;
padding: 16px 20px;
border-top: 1px solid var(--border);
}
/* Toast notification */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
padding: 12px 20px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 500;
z-index: 2000;
transition: opacity 0.3s, transform 0.3s;
box-shadow: var(--shadow-lg);
}
.toast.hidden {
opacity: 0;
transform: translateY(10px);
pointer-events: none;
}
.toast.success {
background: var(--success);
color: white;
}
.toast.error {
background: var(--danger);
color: white;
}
.toast.info {
background: var(--accent);
color: white;
}
/* Documentation */
.docs-container {
max-width: 800px;
margin: 0 auto;
padding: 32px 24px;
overflow-y: auto;
height: 100%;
}
.docs-container h2 {
font-size: 24px;
font-weight: 700;
margin-bottom: 24px;
color: var(--text);
}
.docs-container h3 {
font-size: 16px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 12px;
color: var(--text);
}
.docs-container p {
margin-bottom: 12px;
color: var(--text-light);
line-height: 1.7;
}
.docs-container ul,
.docs-container ol {
margin-bottom: 12px;
padding-left: 24px;
color: var(--text-light);
}
.docs-container li {
margin-bottom: 6px;
line-height: 1.6;
}
.docs-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0;
}
#scheduleTable th,
#scheduleTable td {
padding: 6px 8px;
border: 1px solid #ddd;
text-align: left;
}
#scheduleTable th {
background: #ecf0f1;
font-weight: 600;
margin: 16px 0;
font-size: 13px;
}
#scheduleTable input {
width: 100%;
padding: 4px 6px;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 13px;
}
#scheduleTable input[type="date"] {
min-width: 130px;
}
#scheduleTable input[type="time"] {
min-width: 90px;
}
/* Editor area */
.editor-area {
background: #fff;
padding: 20px;
border: 1px solid #ddd;
border-radius: 6px;
margin-top: 20px;
}
.editor-area h2 {
margin-bottom: 10px;
color: #2c3e50;
}
.editor-area h3 {
margin: 15px 0 8px;
color: #34495e;
}
/* Imported type editor */
.imported-type-row {
display: flex;
gap: 8px;
margin-bottom: 6px;
align-items: center;
}
.imported-type-row input[type="text"] {
flex: 1;
padding: 5px 8px;
border: 1px solid #ccc;
border-radius: 3px;
}
.imported-type-row input[type="color"] {
width: 36px;
height: 28px;
border: 1px solid #ccc;
border-radius: 3px;
}
/* Block list */
.block-item {
background: #f9f9f9;
.docs-table th,
.docs-table td {
padding: 8px 12px;
margin-bottom: 4px;
border-radius: 4px;
border-left: 4px solid #3498db;
font-size: 13px;
text-align: left;
border: 1px solid var(--border);
}
/* Status message */
.status-message {
margin-top: 15px;
padding: 12px;
border-radius: 4px;
font-size: 14px;
.docs-table th {
background: var(--bg);
font-weight: 600;
color: var(--text);
}
.status-message.success {
background: #d5f5e3;
color: #27ae60;
border: 1px solid #27ae60;
.docs-table td {
color: var(--text-light);
}
.status-message.error {
background: #fadbd8;
color: #e74c3c;
border: 1px solid #e74c3c;
/* Canvas click area for creating blocks */
.day-column-click-area {
position: absolute;
inset: 0;
z-index: 1;
}
/* JSON import */
.json-import {
margin-top: 20px;
padding: 10px;
background: #fafafa;
border-radius: 4px;
font-size: 13px;
/* Scrollbar */
.canvas-scroll::-webkit-scrollbar,
.sidebar::-webkit-scrollbar {
width: 6px;
}
h3 {
margin: 20px 0 10px;
color: #2c3e50;
.canvas-scroll::-webkit-scrollbar-track,
.sidebar::-webkit-scrollbar-track {
background: transparent;
}
.canvas-scroll::-webkit-scrollbar-thumb,
.sidebar::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.canvas-scroll::-webkit-scrollbar-thumb:hover,
.sidebar::-webkit-scrollbar-thumb:hover {
background: var(--text-light);
}
/* Sidebar buttons in non-header context */
.sidebar .btn-secondary {
background: var(--bg);
color: var(--text-light);
border: 1px solid var(--border);
}
.sidebar .btn-secondary:hover {
background: var(--border);
color: var(--text);
}

View File

@@ -3,131 +3,193 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scenar Creator</title>
<title>Scenár Creator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div class="container">
<h1>Scenar Creator</h1>
<p class="subtitle">Tvorba časových harmonogramů</p>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="switchTab(event, 'importTab')">Importovat Excel</button>
<button class="tab" onclick="switchTab(event, 'builderTab')">Vytvořit inline</button>
<header class="header">
<div class="header-left">
<h1 class="header-title">Scenár Creator</h1>
<span class="header-version">v3.0</span>
</div>
<!-- Import Excel Tab -->
<div id="importTab" class="tab-content active">
<form id="importForm" onsubmit="return handleImport(event)">
<div class="form-group">
<label for="importTitle">Název akce:</label>
<input type="text" id="importTitle" name="title" maxlength="200" required placeholder="Název události">
</div>
<div class="form-group">
<label for="importDetail">Detail:</label>
<input type="text" id="importDetail" name="detail" maxlength="500" required placeholder="Popis události">
</div>
<div class="form-group">
<label for="excelFile">Excel soubor:</label>
<input type="file" id="excelFile" name="file" accept=".xlsx,.xls" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Importovat</button>
<a href="/api/template" class="btn btn-secondary">Stáhnout šablonu</a>
</div>
</form>
<div class="header-actions">
<label class="btn btn-secondary btn-sm" id="importJsonBtn">
<input type="file" accept=".json" id="importJsonInput" hidden>
Import JSON
</label>
<button class="btn btn-secondary btn-sm" id="newScenarioBtn">Nový scénář</button>
<button class="btn btn-secondary btn-sm" id="exportJsonBtn">Export JSON</button>
<button class="btn btn-primary btn-sm" id="generatePdfBtn">Generovat PDF</button>
</div>
</header>
<!-- Builder Tab -->
<div id="builderTab" class="tab-content">
<form id="builderForm" onsubmit="return handleBuild(event)">
<div class="form-group">
<label for="builderTitle">Název akce:</label>
<input type="text" id="builderTitle" name="title" maxlength="200" required placeholder="Název události">
</div>
<div class="form-group">
<label for="builderDetail">Detail:</label>
<input type="text" id="builderDetail" name="detail" maxlength="500" required placeholder="Popis události">
</div>
<h3>Typy programů</h3>
<div id="typesContainer">
<div class="type-row" data-index="0">
<input type="text" name="type_name_0" placeholder="Kód typu (např. WORKSHOP)" class="type-code">
<input type="text" name="type_desc_0" placeholder="Popis">
<input type="color" name="type_color_0" value="#0070C0">
<button type="button" class="btn btn-danger btn-sm" onclick="removeTypeRow(0)">X</button>
</div>
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addTypeRow()">+ Přidat typ</button>
<h3>Časový harmonogram</h3>
<datalist id="availableTypes"></datalist>
<div id="scheduleContainer">
<table id="scheduleTable">
<thead>
<tr>
<th>Datum</th>
<th>Začátek</th>
<th>Konec</th>
<th>Program</th>
<th>Typ</th>
<th>Garant</th>
<th>Poznámka</th>
<th></th>
</tr>
</thead>
<tbody id="scheduleBody">
<tr data-index="0">
<td><input type="date" name="datum_0" required></td>
<td><input type="time" name="zacatek_0" required></td>
<td><input type="time" name="konec_0" required></td>
<td><input type="text" name="program_0" required placeholder="Název bloku"></td>
<td><input type="text" name="typ_0" list="availableTypes" required placeholder="Typ"></td>
<td><input type="text" name="garant_0" placeholder="Garant"></td>
<td><input type="text" name="poznamka_0" placeholder="Poznámka"></td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeScheduleRow(0)">X</button></td>
</tr>
</tbody>
</table>
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addScheduleRow()">+ Přidat řádek</button>
<div class="form-actions">
<button type="submit" class="btn btn-primary" name="format" value="excel">Stáhnout Excel</button>
<button type="button" class="btn btn-primary" onclick="handleBuildPdf()">Stáhnout PDF</button>
</div>
</form>
</div>
<!-- Import results / editor area -->
<div id="editorArea" class="editor-area" style="display:none;">
<h2>Importovaná data</h2>
<div id="importedInfo"></div>
<h3>Typy programů</h3>
<div id="importedTypesContainer"></div>
<h3>Bloky</h3>
<div id="importedBlocksContainer"></div>
<div class="form-actions">
<button class="btn btn-primary" onclick="generateExcelFromImport()">Stáhnout Excel</button>
<button class="btn btn-primary" onclick="generatePdfFromImport()">Stáhnout PDF</button>
<button class="btn btn-secondary" onclick="exportJson()">Exportovat JSON</button>
</div>
</div>
<!-- JSON import -->
<div class="json-import">
<label>Importovat JSON: <input type="file" id="jsonFile" accept=".json" onchange="handleJsonImport(event)"></label>
</div>
<div id="statusMessage" class="status-message" style="display:none;"></div>
<div class="tabs">
<button class="tab active" data-tab="editor">Editor</button>
<button class="tab" data-tab="docs">Dokumentace</button>
</div>
<div class="tab-content" id="tab-editor">
<div class="app-layout">
<aside class="sidebar">
<section class="sidebar-section">
<h3 class="sidebar-heading">Informace o akci</h3>
<div class="form-group">
<label>Název</label>
<input type="text" id="eventTitle" placeholder="Název akce">
</div>
<div class="form-group">
<label>Podtitul</label>
<input type="text" id="eventSubtitle" placeholder="Podtitul">
</div>
<div class="form-group">
<label>Datum</label>
<input type="date" id="eventDate">
</div>
<div class="form-group">
<label>Místo</label>
<input type="text" id="eventLocation" placeholder="Místo konání">
</div>
</section>
<section class="sidebar-section">
<h3 class="sidebar-heading">Typy programů</h3>
<div id="programTypesContainer"></div>
<button class="btn btn-secondary btn-xs" id="addTypeBtn">+ Přidat typ</button>
</section>
<section class="sidebar-section">
<button class="btn btn-primary btn-block" id="addBlockBtn">+ Přidat blok</button>
</section>
</aside>
<main class="canvas-wrapper">
<div class="canvas-header" id="canvasHeader"></div>
<div class="canvas-scroll">
<div class="canvas" id="canvas">
<div class="time-axis" id="timeAxis"></div>
<div class="day-columns" id="dayColumns"></div>
</div>
</div>
</main>
</div>
</div>
<div class="tab-content hidden" id="tab-docs">
<div class="docs-container">
<h2>Dokumentace — Scenár Creator v3</h2>
<h3>Jak začít</h3>
<p>Máte dvě možnosti:</p>
<ol>
<li><strong>Nový scénář</strong> — klikněte na tlačítko "Nový scénář" v záhlaví. Vytvoří se prázdný scénář s jedním dnem.</li>
<li><strong>Import JSON</strong> — klikněte na "Import JSON" a vyberte dříve uložený .json soubor. Můžete také přetáhnout JSON soubor přímo na plochu editoru.</li>
</ol>
<h3>Práce s bloky</h3>
<ul>
<li><strong>Přidání bloku:</strong> Klikněte na "+ Přidat blok" v postranním panelu, nebo klikněte na prázdné místo na časové ose.</li>
<li><strong>Přesun bloku:</strong> Chytněte blok myší a přetáhněte ho na jiný čas. Bloky se přichytávají na 15minutovou mřížku.</li>
<li><strong>Změna délky:</strong> Chytněte dolní okraj bloku a tažením změňte dobu trvání.</li>
<li><strong>Úprava bloku:</strong> Klikněte na blok pro otevření editačního popup okna, kde můžete upravit název, typ, garanta a poznámku.</li>
<li><strong>Smazání bloku:</strong> V editačním popup okně klikněte na tlačítko "Smazat blok".</li>
</ul>
<h3>Typy programů a barvy</h3>
<p>V postranním panelu v sekci "Typy programů" můžete:</p>
<ul>
<li>Přidat nový typ kliknutím na "+ Přidat typ"</li>
<li>Pojmenovat typ a vybrat barvu pomocí barevného výběru</li>
<li>Odebrat typ kliknutím na tlačítko ×</li>
</ul>
<h3>Export JSON</h3>
<p>Kliknutím na "Export JSON" stáhnete aktuální stav scénáře jako .json soubor. Tento soubor můžete později znovu importovat a pokračovat v úpravách.</p>
<h3>Generování PDF</h3>
<p>Kliknutím na "Generovat PDF" se scénář odešle na server a vygeneruje se přehledný PDF dokument ve formátu A4 na šířku s barevnými bloky a legendou.</p>
<h3>Formát JSON</h3>
<table class="docs-table">
<thead>
<tr><th>Pole</th><th>Typ</th><th>Popis</th></tr>
</thead>
<tbody>
<tr><td>version</td><td>string</td><td>Verze formátu (1.0)</td></tr>
<tr><td>event.title</td><td>string</td><td>Název akce</td></tr>
<tr><td>event.subtitle</td><td>string?</td><td>Podtitul</td></tr>
<tr><td>event.date</td><td>string?</td><td>Datum (YYYY-MM-DD)</td></tr>
<tr><td>event.location</td><td>string?</td><td>Místo</td></tr>
<tr><td>program_types[].id</td><td>string</td><td>Unikátní ID typu</td></tr>
<tr><td>program_types[].name</td><td>string</td><td>Název typu</td></tr>
<tr><td>program_types[].color</td><td>string</td><td>Barva (#RRGGBB)</td></tr>
<tr><td>blocks[].id</td><td>string</td><td>Unikátní ID bloku</td></tr>
<tr><td>blocks[].date</td><td>string</td><td>Datum (YYYY-MM-DD)</td></tr>
<tr><td>blocks[].start</td><td>string</td><td>Začátek (HH:MM)</td></tr>
<tr><td>blocks[].end</td><td>string</td><td>Konec (HH:MM)</td></tr>
<tr><td>blocks[].title</td><td>string</td><td>Název bloku</td></tr>
<tr><td>blocks[].type_id</td><td>string</td><td>ID typu programu</td></tr>
<tr><td>blocks[].responsible</td><td>string?</td><td>Garant</td></tr>
<tr><td>blocks[].notes</td><td>string?</td><td>Poznámka</td></tr>
</tbody>
</table>
<h3>Vzorový JSON</h3>
<p><a href="/api/sample" class="btn btn-secondary btn-sm">Stáhnout sample.json</a></p>
</div>
</div>
<!-- Block edit modal -->
<div class="modal-overlay hidden" id="blockModal">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">Upravit blok</h3>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="modalBlockId">
<div class="form-group">
<label>Název bloku</label>
<input type="text" id="modalBlockTitle" placeholder="Název">
</div>
<div class="form-group">
<label>Typ programu</label>
<select id="modalBlockType"></select>
</div>
<div class="form-row">
<div class="form-group">
<label>Začátek</label>
<input type="time" id="modalBlockStart">
</div>
<div class="form-group">
<label>Konec</label>
<input type="time" id="modalBlockEnd">
</div>
</div>
<div class="form-group">
<label>Garant</label>
<input type="text" id="modalBlockResponsible" placeholder="Garant">
</div>
<div class="form-group">
<label>Poznámka</label>
<textarea id="modalBlockNotes" rows="3" placeholder="Poznámka"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger btn-sm" id="modalDeleteBtn">Smazat blok</button>
<button class="btn btn-primary btn-sm" id="modalSaveBtn">Uložit</button>
</div>
</div>
</div>
<!-- Status toast -->
<div class="toast hidden" id="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/interactjs@1.10.27/dist/interact.min.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/canvas.js"></script>
<script src="/static/js/export.js"></script>
<script src="/static/js/app.js"></script>
</body>

View File

@@ -1,5 +1,5 @@
/**
* API fetch wrapper for Scenar Creator.
* API fetch wrapper for Scenar Creator v3.
*/
const API = {
@@ -9,10 +9,9 @@ const API = {
opts.headers = { 'Content-Type': 'application/json' };
opts.body = JSON.stringify(body);
} else {
opts.body = body; // FormData
opts.body = body;
}
const res = await fetch(url, opts);
return res;
return fetch(url, opts);
},
async postJson(url, body) {
@@ -33,21 +32,18 @@ const API = {
return res.blob();
},
async postFormData(url, formData) {
const res = await this.post(url, formData, false);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || 'API error');
}
return res.json();
},
async get(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
return res.json();
},
async getBlob(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(res.statusText);
return res.blob();
},
downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');

View File

@@ -1,258 +1,304 @@
/**
* Main application logic for Scenar Creator SPA.
* Main application logic for Scenar Creator v3.
* State management, UI wiring, modal handling.
*/
window.currentDocument = null;
let typeCounter = 1;
let scheduleCounter = 1;
const App = {
state: {
event: { title: '', subtitle: '', date: '', location: '' },
program_types: [],
blocks: []
},
/* --- Tab switching --- */
function switchTab(event, tabId) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(tabId).classList.add('active');
}
init() {
this.bindEvents();
this.newScenario();
},
/* --- Status messages --- */
function showStatus(message, type) {
const el = document.getElementById('statusMessage');
el.textContent = message;
el.className = 'status-message ' + type;
el.style.display = 'block';
setTimeout(() => { el.style.display = 'none'; }, 5000);
}
// --- State ---
/* --- Type management --- */
function addTypeRow() {
const container = document.getElementById('typesContainer');
const idx = typeCounter++;
const div = document.createElement('div');
div.className = 'type-row';
div.setAttribute('data-index', idx);
div.innerHTML = `
<input type="text" name="type_name_${idx}" placeholder="Kód typu" class="type-code">
<input type="text" name="type_desc_${idx}" placeholder="Popis">
<input type="color" name="type_color_${idx}" value="#0070C0">
<button type="button" class="btn btn-danger btn-sm" onclick="removeTypeRow(${idx})">X</button>
`;
container.appendChild(div);
updateTypeDatalist();
}
getDocument() {
this.syncEventFromUI();
return {
version: '1.0',
event: { ...this.state.event },
program_types: this.state.program_types.map(pt => ({ ...pt })),
blocks: this.state.blocks.map(b => ({ ...b }))
};
},
function removeTypeRow(idx) {
const row = document.querySelector(`.type-row[data-index="${idx}"]`);
if (row) row.remove();
updateTypeDatalist();
}
function updateTypeDatalist() {
const datalist = document.getElementById('availableTypes');
datalist.innerHTML = '';
document.querySelectorAll('#typesContainer .type-row').forEach(row => {
const nameInput = row.querySelector('input[name^="type_name_"]');
if (nameInput && nameInput.value.trim()) {
const opt = document.createElement('option');
opt.value = nameInput.value.trim();
datalist.appendChild(opt);
}
});
}
// Update datalist on type name changes
document.getElementById('typesContainer').addEventListener('input', function (e) {
if (e.target.name && e.target.name.startsWith('type_name_')) {
updateTypeDatalist();
}
});
/* --- Schedule management --- */
function addScheduleRow() {
const tbody = document.getElementById('scheduleBody');
const idx = scheduleCounter++;
const tr = document.createElement('tr');
tr.setAttribute('data-index', idx);
tr.innerHTML = `
<td><input type="date" name="datum_${idx}" required></td>
<td><input type="time" name="zacatek_${idx}" required></td>
<td><input type="time" name="konec_${idx}" required></td>
<td><input type="text" name="program_${idx}" required placeholder="Název bloku"></td>
<td><input type="text" name="typ_${idx}" list="availableTypes" required placeholder="Typ"></td>
<td><input type="text" name="garant_${idx}" placeholder="Garant"></td>
<td><input type="text" name="poznamka_${idx}" placeholder="Poznámka"></td>
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeScheduleRow(${idx})">X</button></td>
`;
tbody.appendChild(tr);
}
function removeScheduleRow(idx) {
const row = document.querySelector(`#scheduleBody tr[data-index="${idx}"]`);
if (row) row.remove();
}
/* --- Build ScenarioDocument from builder form --- */
function buildDocumentFromForm() {
const title = document.getElementById('builderTitle').value.trim();
const detail = document.getElementById('builderDetail').value.trim();
if (!title || !detail) {
throw new Error('Název akce a detail jsou povinné');
}
// Collect types
const programTypes = [];
document.querySelectorAll('#typesContainer .type-row').forEach(row => {
const code = row.querySelector('input[name^="type_name_"]').value.trim();
const desc = row.querySelector('input[name^="type_desc_"]').value.trim();
const color = row.querySelector('input[name^="type_color_"]').value;
if (code) {
programTypes.push({ code, description: desc, color });
}
});
// Collect blocks
const blocks = [];
document.querySelectorAll('#scheduleBody tr').forEach(tr => {
const inputs = tr.querySelectorAll('input');
const datum = inputs[0].value;
const zacatek = inputs[1].value;
const konec = inputs[2].value;
const program = inputs[3].value.trim();
const typ = inputs[4].value.trim();
const garant = inputs[5].value.trim() || null;
const poznamka = inputs[6].value.trim() || null;
if (datum && zacatek && konec && program && typ) {
blocks.push({ datum, zacatek, konec, program, typ, garant, poznamka });
}
});
if (blocks.length === 0) {
throw new Error('Přidejte alespoň jeden blok');
}
return {
version: "1.0",
event: { title, detail },
program_types: programTypes,
blocks
};
}
/* --- Handle builder form submit (Excel) --- */
async function handleBuild(event) {
event.preventDefault();
try {
const doc = buildDocumentFromForm();
const blob = await API.postBlob('/api/generate-excel', doc);
API.downloadBlob(blob, 'scenar_timetable.xlsx');
showStatus('Excel vygenerován', 'success');
} catch (err) {
showStatus('Chyba: ' + err.message, 'error');
}
return false;
}
/* --- Handle builder PDF --- */
async function handleBuildPdf() {
try {
const doc = buildDocumentFromForm();
const blob = await API.postBlob('/api/generate-pdf', doc);
API.downloadBlob(blob, 'scenar_timetable.pdf');
showStatus('PDF vygenerován', 'success');
} catch (err) {
showStatus('Chyba: ' + err.message, 'error');
}
}
/* --- Handle Excel import --- */
async function handleImport(event) {
event.preventDefault();
const form = document.getElementById('importForm');
const formData = new FormData(form);
try {
const result = await API.postFormData('/api/import-excel', formData);
if (result.success && result.document) {
window.currentDocument = result.document;
showImportedDocument(result.document);
if (result.warnings && result.warnings.length > 0) {
showStatus('Import OK, warnings: ' + result.warnings.join('; '), 'success');
} else {
showStatus('Excel importován', 'success');
}
} else {
showStatus('Import failed: ' + (result.errors || []).join('; '), 'error');
}
} catch (err) {
showStatus('Chyba importu: ' + err.message, 'error');
}
return false;
}
/* --- Show imported document in editor --- */
function showImportedDocument(doc) {
const area = document.getElementById('editorArea');
area.style.display = 'block';
// Info
document.getElementById('importedInfo').innerHTML =
`<strong>${doc.event.title}</strong> — ${doc.event.detail}`;
// Types
const typesHtml = doc.program_types.map((pt, i) => `
<div class="imported-type-row">
<input type="text" value="${pt.code}" data-field="code" data-idx="${i}">
<input type="text" value="${pt.description}" data-field="description" data-idx="${i}">
<input type="color" value="${pt.color}" data-field="color" data-idx="${i}">
</div>
`).join('');
document.getElementById('importedTypesContainer').innerHTML = typesHtml;
// Blocks
const blocksHtml = doc.blocks.map(b =>
`<div class="block-item">${b.datum} ${b.zacatek}${b.konec} | <strong>${b.program}</strong> [${b.typ}] ${b.garant || ''}</div>`
).join('');
document.getElementById('importedBlocksContainer').innerHTML = blocksHtml;
}
/* --- Get current document (with any edits from import editor) --- */
function getCurrentDocument() {
if (!window.currentDocument) {
throw new Error('No document loaded');
}
// Update types from editor
const typeRows = document.querySelectorAll('#importedTypesContainer .imported-type-row');
if (typeRows.length > 0) {
window.currentDocument.program_types = Array.from(typeRows).map(row => ({
code: row.querySelector('[data-field="code"]').value.trim(),
description: row.querySelector('[data-field="description"]').value.trim(),
color: row.querySelector('[data-field="color"]').value,
loadDocument(doc) {
this.state.event = { ...doc.event };
this.state.program_types = (doc.program_types || []).map(pt => ({ ...pt }));
this.state.blocks = (doc.blocks || []).map(b => ({
...b,
id: b.id || this.uid()
}));
}
return window.currentDocument;
}
this.syncEventToUI();
this.renderTypes();
this.renderCanvas();
},
/* --- Generate Excel from imported data --- */
async function generateExcelFromImport() {
try {
const doc = getCurrentDocument();
const blob = await API.postBlob('/api/generate-excel', doc);
API.downloadBlob(blob, 'scenar_timetable.xlsx');
showStatus('Excel vygenerován', 'success');
} catch (err) {
showStatus('Chyba: ' + err.message, 'error');
}
}
newScenario() {
const today = new Date().toISOString().split('T')[0];
this.state = {
event: { title: 'Nová akce', subtitle: '', date: 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();
},
/* --- Generate PDF from imported data --- */
async function generatePdfFromImport() {
try {
const doc = getCurrentDocument();
const blob = await API.postBlob('/api/generate-pdf', doc);
API.downloadBlob(blob, 'scenar_timetable.pdf');
showStatus('PDF vygenerován', 'success');
} catch (err) {
showStatus('Chyba: ' + err.message, 'error');
// --- 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 = document.getElementById('eventDate').value || null;
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('eventDate').value = this.state.event.date || '';
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>
`;
// Color change
row.querySelector('input[type="color"]').addEventListener('change', (e) => {
this.state.program_types[i].color = e.target.value;
this.renderCanvas();
});
// Name change
row.querySelector('input[type="text"]').addEventListener('change', (e) => {
this.state.program_types[i].name = e.target.value.trim();
});
// Remove
row.querySelector('.type-remove').addEventListener('click', () => {
this.state.program_types.splice(i, 1);
this.renderTypes();
this.renderCanvas();
});
container.appendChild(row);
});
},
addType() {
const id = 'type_' + this.uid().substring(0, 6);
this.state.program_types.push({
id,
name: 'Nový typ',
color: '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')
});
this.renderTypes();
},
// --- Blocks ---
createBlock(date, start, end) {
const typeId = this.state.program_types.length > 0 ? this.state.program_types[0].id : 'main';
const block = {
id: this.uid(),
date: date || this.state.event.date || new Date().toISOString().split('T')[0],
start: start || '09:00',
end: end || '10:00',
title: 'Nový blok',
type_id: typeId,
responsible: null,
notes: null
};
this.state.blocks.push(block);
this.renderCanvas();
this.editBlock(block.id);
},
updateBlockTime(blockId, start, end) {
const block = this.state.blocks.find(b => b.id === blockId);
if (block) {
block.start = start;
block.end = end;
this.renderCanvas();
}
},
deleteBlock(blockId) {
this.state.blocks = this.state.blocks.filter(b => b.id !== blockId);
this.renderCanvas();
},
// --- Canvas rendering ---
renderCanvas() {
const dates = this.getUniqueDates();
Canvas.renderTimeAxis();
Canvas.renderDayColumns(dates);
Canvas.renderBlocks(this.state.blocks, this.state.program_types);
},
getUniqueDates() {
const dateSet = new Set();
this.state.blocks.forEach(b => dateSet.add(b.date));
if (this.state.event.date) dateSet.add(this.state.event.date);
const dates = Array.from(dateSet).sort();
return dates.length > 0 ? dates : [new Date().toISOString().split('T')[0]];
},
// --- Modal ---
editBlock(blockId) {
const block = this.state.blocks.find(b => b.id === blockId);
if (!block) return;
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 || '';
// Populate type select
const select = document.getElementById('modalBlockType');
select.innerHTML = '';
this.state.program_types.forEach(pt => {
const opt = document.createElement('option');
opt.value = pt.id;
opt.textContent = pt.name;
if (pt.id === block.type_id) opt.selected = true;
select.appendChild(opt);
});
document.getElementById('blockModal').classList.remove('hidden');
},
saveBlockFromModal() {
const blockId = document.getElementById('modalBlockId').value;
const block = this.state.blocks.find(b => b.id === blockId);
if (!block) return;
block.title = document.getElementById('modalBlockTitle').value.trim() || 'Blok';
block.type_id = document.getElementById('modalBlockType').value;
block.start = document.getElementById('modalBlockStart').value;
block.end = document.getElementById('modalBlockEnd').value;
block.responsible = document.getElementById('modalBlockResponsible').value.trim() || null;
block.notes = document.getElementById('modalBlockNotes').value.trim() || null;
this.closeModal();
this.renderCanvas();
},
closeModal() {
document.getElementById('blockModal').classList.add('hidden');
},
// --- Toast ---
toast(message, type) {
const el = document.getElementById('toast');
el.textContent = message;
el.className = 'toast ' + (type || 'info');
setTimeout(() => el.classList.add('hidden'), 3000);
},
// --- PDF ---
async generatePdf() {
try {
const doc = this.getDocument();
if (doc.blocks.length === 0) {
this.toast('Přidejte alespoň jeden blok', 'error');
return;
}
const blob = await API.postBlob('/api/generate-pdf', doc);
API.downloadBlob(blob, 'scenar_timetable.pdf');
this.toast('PDF vygenerován', 'success');
} catch (err) {
this.toast('Chyba: ' + err.message, 'error');
}
},
// --- Utility ---
uid() {
return 'xxxx-xxxx-xxxx'.replace(/x/g, () =>
Math.floor(Math.random() * 16).toString(16)
);
},
// --- Event binding ---
bindEvents() {
// 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');
});
});
// Header buttons
document.getElementById('newScenarioBtn').addEventListener('click', () => this.newScenario());
document.getElementById('exportJsonBtn').addEventListener('click', () => exportJson());
document.getElementById('generatePdfBtn').addEventListener('click', () => this.generatePdf());
// Import JSON
document.getElementById('importJsonInput').addEventListener('change', (e) => {
if (e.target.files[0]) importJson(e.target.files[0]);
e.target.value = '';
});
// Sidebar
document.getElementById('addTypeBtn').addEventListener('click', () => this.addType());
document.getElementById('addBlockBtn').addEventListener('click', () => this.createBlock());
// Modal
document.getElementById('modalSaveBtn').addEventListener('click', () => this.saveBlockFromModal());
document.getElementById('modalClose').addEventListener('click', () => this.closeModal());
document.getElementById('modalDeleteBtn').addEventListener('click', () => {
const blockId = document.getElementById('modalBlockId').value;
this.closeModal();
this.deleteBlock(blockId);
});
// Close modal on overlay click
document.getElementById('blockModal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) this.closeModal();
});
// Drag & drop JSON on canvas
const canvasWrapper = document.querySelector('.canvas-wrapper');
if (canvasWrapper) {
canvasWrapper.addEventListener('dragover', (e) => e.preventDefault());
canvasWrapper.addEventListener('drop', (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.json')) {
importJson(file);
}
});
}
}
}
};
// Boot
document.addEventListener('DOMContentLoaded', () => App.init());

267
app/static/js/canvas.js Normal file
View File

@@ -0,0 +1,267 @@
/**
* Canvas editor for Scenar Creator v3.
* Renders schedule blocks on a time-grid canvas with drag & drop via interact.js.
*/
const Canvas = {
GRID_MINUTES: 15,
PX_PER_SLOT: 30, // 30px per 15 min
START_HOUR: 7,
END_HOUR: 23,
get pxPerMinute() {
return this.PX_PER_SLOT / this.GRID_MINUTES;
},
get totalSlots() {
return ((this.END_HOUR - this.START_HOUR) * 60) / this.GRID_MINUTES;
},
get totalHeight() {
return this.totalSlots * this.PX_PER_SLOT;
},
minutesToPx(minutes) {
return (minutes - this.START_HOUR * 60) * this.pxPerMinute;
},
pxToMinutes(px) {
return Math.round(px / this.pxPerMinute + this.START_HOUR * 60);
},
snapMinutes(minutes) {
return Math.round(minutes / this.GRID_MINUTES) * this.GRID_MINUTES;
},
formatTime(totalMinutes) {
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
},
parseTime(str) {
const [h, m] = str.split(':').map(Number);
return h * 60 + m;
},
isLightColor(hex) {
const h = hex.replace('#', '');
const r = parseInt(h.substring(0, 2), 16);
const g = parseInt(h.substring(2, 4), 16);
const b = parseInt(h.substring(4, 6), 16);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
},
renderTimeAxis() {
const axis = document.getElementById('timeAxis');
axis.innerHTML = '';
axis.style.height = this.totalHeight + 'px';
for (let slot = 0; slot <= this.totalSlots; slot++) {
const minutes = this.START_HOUR * 60 + slot * this.GRID_MINUTES;
const isHour = minutes % 60 === 0;
const isHalf = minutes % 30 === 0;
if (isHour || isHalf) {
const label = document.createElement('div');
label.className = 'time-label';
label.style.top = (slot * this.PX_PER_SLOT) + 'px';
label.textContent = this.formatTime(minutes);
if (!isHour) {
label.style.fontSize = '9px';
label.style.opacity = '0.6';
}
axis.appendChild(label);
}
}
},
renderDayColumns(dates) {
const header = document.getElementById('canvasHeader');
const columns = document.getElementById('dayColumns');
header.innerHTML = '';
columns.innerHTML = '';
if (dates.length === 0) dates = [new Date().toISOString().split('T')[0]];
dates.forEach(dateStr => {
// Header
const dh = document.createElement('div');
dh.className = 'day-header';
dh.textContent = dateStr;
header.appendChild(dh);
// Column
const col = document.createElement('div');
col.className = 'day-column';
col.dataset.date = dateStr;
col.style.height = this.totalHeight + 'px';
// Grid lines
for (let slot = 0; slot <= this.totalSlots; slot++) {
const minutes = this.START_HOUR * 60 + slot * this.GRID_MINUTES;
const line = document.createElement('div');
line.className = 'grid-line ' + (minutes % 60 === 0 ? 'hour' : 'half');
line.style.top = (slot * this.PX_PER_SLOT) + 'px';
col.appendChild(line);
}
// Click area for creating blocks
const clickArea = document.createElement('div');
clickArea.className = 'day-column-click-area';
clickArea.addEventListener('click', (e) => {
if (e.target !== clickArea) return;
const rect = col.getBoundingClientRect();
const y = e.clientY - rect.top;
const minutes = this.snapMinutes(this.pxToMinutes(y));
const endMinutes = Math.min(minutes + 60, this.END_HOUR * 60);
App.createBlock(dateStr, this.formatTime(minutes), this.formatTime(endMinutes));
});
col.appendChild(clickArea);
columns.appendChild(col);
});
},
renderBlocks(blocks, programTypes) {
// Remove existing blocks
document.querySelectorAll('.schedule-block').forEach(el => el.remove());
const typeMap = {};
programTypes.forEach(pt => { typeMap[pt.id] = pt; });
blocks.forEach(block => {
const col = document.querySelector(`.day-column[data-date="${block.date}"]`);
if (!col) return;
const pt = typeMap[block.type_id] || { color: '#94a3b8', name: '?' };
const startMin = this.parseTime(block.start);
const endMin = this.parseTime(block.end);
const top = this.minutesToPx(startMin);
const height = (endMin - startMin) * this.pxPerMinute;
const isLight = this.isLightColor(pt.color);
const el = document.createElement('div');
el.className = 'schedule-block' + (isLight ? ' light-bg' : '');
el.dataset.blockId = block.id;
el.style.top = top + 'px';
el.style.height = Math.max(height, 20) + 'px';
el.style.backgroundColor = pt.color;
el.innerHTML = `
<div class="block-color-bar" style="background:${this.darkenColor(pt.color)}"></div>
<div class="block-title">${this.escapeHtml(block.title)}</div>
<div class="block-time">${block.start} ${block.end}</div>
${block.responsible ? `<div class="block-responsible">${this.escapeHtml(block.responsible)}</div>` : ''}
<div class="resize-handle"></div>
`;
// Click to edit
el.addEventListener('click', (e) => {
if (e.target.classList.contains('resize-handle')) return;
App.editBlock(block.id);
});
col.appendChild(el);
});
this.initInteract();
},
initInteract() {
if (typeof interact === 'undefined') return;
interact('.schedule-block').draggable({
inertia: false,
modifiers: [
interact.modifiers.snap({
targets: [interact.snappers.grid({
x: 1, y: this.PX_PER_SLOT
})],
range: Infinity,
relativePoint: { x: 0, y: 0 }
}),
interact.modifiers.restrict({
restriction: 'parent',
elementRect: { top: 0, left: 0, bottom: 1, right: 1 }
})
],
listeners: {
start: (event) => {
event.target.style.zIndex = 50;
event.target.style.opacity = '0.9';
},
move: (event) => {
const target = event.target;
const y = (parseFloat(target.dataset.dragY) || 0) + event.dy;
target.dataset.dragY = y;
const currentTop = parseFloat(target.style.top) || 0;
const newTop = currentTop + event.dy;
target.style.top = newTop + 'px';
},
end: (event) => {
const target = event.target;
target.style.zIndex = '';
target.style.opacity = '';
target.dataset.dragY = 0;
const blockId = target.dataset.blockId;
const newTop = parseFloat(target.style.top);
const height = parseFloat(target.style.height);
const startMin = Canvas.snapMinutes(Canvas.pxToMinutes(newTop));
const endMin = Canvas.snapMinutes(Canvas.pxToMinutes(newTop + height));
App.updateBlockTime(blockId, Canvas.formatTime(startMin), Canvas.formatTime(endMin));
}
}
}).resizable({
edges: { bottom: '.resize-handle' },
modifiers: [
interact.modifiers.snap({
targets: [interact.snappers.grid({
x: 1, y: this.PX_PER_SLOT
})],
range: Infinity,
relativePoint: { x: 0, y: 0 },
offset: 'self'
}),
interact.modifiers.restrictSize({
min: { width: 0, height: this.PX_PER_SLOT }
})
],
listeners: {
move: (event) => {
const target = event.target;
target.style.height = event.rect.height + 'px';
},
end: (event) => {
const target = event.target;
const blockId = target.dataset.blockId;
const top = parseFloat(target.style.top);
const height = parseFloat(target.style.height);
const startMin = Canvas.snapMinutes(Canvas.pxToMinutes(top));
const endMin = Canvas.snapMinutes(Canvas.pxToMinutes(top + height));
App.updateBlockTime(blockId, Canvas.formatTime(startMin), Canvas.formatTime(endMin));
}
}
});
},
darkenColor(hex) {
const h = hex.replace('#', '');
const r = Math.max(0, parseInt(h.substring(0, 2), 16) - 30);
const g = Math.max(0, parseInt(h.substring(2, 4), 16) - 30);
const b = Math.max(0, parseInt(h.substring(4, 6), 16) - 30);
return `rgb(${r},${g},${b})`;
},
escapeHtml(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
};

View File

@@ -1,33 +1,31 @@
/**
* JSON import/export for Scenar Creator.
* JSON import/export for Scenar Creator v3.
*/
function exportJson() {
if (!window.currentDocument) {
showStatus('No document to export', 'error');
const doc = App.getDocument();
if (!doc) {
App.toast('Žádný scénář k exportu', 'error');
return;
}
const json = JSON.stringify(window.currentDocument, null, 2);
const json = JSON.stringify(doc, null, 2);
const blob = new Blob([json], { type: 'application/json' });
API.downloadBlob(blob, 'scenar_export.json');
App.toast('JSON exportován', 'success');
}
function handleJsonImport(event) {
const file = event.target.files[0];
if (!file) return;
function importJson(file) {
const reader = new FileReader();
reader.onload = function (e) {
try {
const doc = JSON.parse(e.target.result);
if (!doc.event || !doc.blocks || !doc.program_types) {
throw new Error('Invalid ScenarioDocument format');
throw new Error('Neplatný formát ScenarioDocument');
}
window.currentDocument = doc;
showImportedDocument(doc);
showStatus('JSON imported successfully', 'success');
App.loadDocument(doc);
App.toast('JSON importován', 'success');
} catch (err) {
showStatus('JSON import error: ' + err.message, 'error');
App.toast('Chyba importu: ' + err.message, 'error');
}
};
reader.readAsText(file);

138
app/static/sample.json Normal file
View File

@@ -0,0 +1,138 @@
{
"version": "1.0",
"event": {
"title": "Zimní výjezd oddílu",
"subtitle": "Víkendový zážitkový kurz pro mladé",
"date": "2026-03-01",
"location": "Chata Horní Lhota"
},
"program_types": [
{
"id": "morning",
"name": "Ranní program",
"color": "#F97316"
},
{
"id": "main",
"name": "Hlavní program",
"color": "#3B82F6"
},
{
"id": "rest",
"name": "Odpočinek",
"color": "#22C55E"
}
],
"blocks": [
{
"id": "b1",
"date": "2026-03-01",
"start": "08:00",
"end": "08:30",
"title": "Budíček a rozcvička",
"type_id": "morning",
"responsible": "Kuba",
"notes": "Venkovní rozcvička, pokud počasí dovolí"
},
{
"id": "b2",
"date": "2026-03-01",
"start": "08:30",
"end": "09:00",
"title": "Snídaně",
"type_id": "rest",
"responsible": "Kuchyň",
"notes": null
},
{
"id": "b3",
"date": "2026-03-01",
"start": "09:00",
"end": "10:30",
"title": "Stopovací hra v lese",
"type_id": "main",
"responsible": "Lucka",
"notes": "Rozdělení do 4 skupin, mapky připraveny"
},
{
"id": "b4",
"date": "2026-03-01",
"start": "10:30",
"end": "11:00",
"title": "Svačina a pauza",
"type_id": "rest",
"responsible": null,
"notes": null
},
{
"id": "b5",
"date": "2026-03-01",
"start": "11:00",
"end": "12:30",
"title": "Stavba iglú / přístřešků",
"type_id": "main",
"responsible": "Petr",
"notes": "Soutěž o nejlepší stavbu, potřeba lopaty a plachty"
},
{
"id": "b6",
"date": "2026-03-01",
"start": "12:30",
"end": "14:00",
"title": "Oběd a polední klid",
"type_id": "rest",
"responsible": "Kuchyň",
"notes": "Guláš + čaj"
},
{
"id": "b7",
"date": "2026-03-01",
"start": "14:00",
"end": "16:00",
"title": "Orientační běh",
"type_id": "main",
"responsible": "Honza",
"notes": "Trasa 3 km, kontroly rozmístěny od rána"
},
{
"id": "b8",
"date": "2026-03-01",
"start": "16:00",
"end": "16:30",
"title": "Svačina",
"type_id": "rest",
"responsible": null,
"notes": null
},
{
"id": "b9",
"date": "2026-03-01",
"start": "16:30",
"end": "18:00",
"title": "Workshopy dle výběru",
"type_id": "morning",
"responsible": "Lucka + Petr",
"notes": "Uzlování / Orientace s buzolou / Kresba mapy"
},
{
"id": "b10",
"date": "2026-03-01",
"start": "18:00",
"end": "19:00",
"title": "Večeře",
"type_id": "rest",
"responsible": "Kuchyň",
"notes": null
},
{
"id": "b11",
"date": "2026-03-01",
"start": "19:00",
"end": "21:00",
"title": "Noční hra Světlušky",
"type_id": "main",
"responsible": "Honza + Kuba",
"notes": "Potřeba: čelovky, reflexní pásky, vysílačky"
}
]
}