diff --git a/app/api/pdf.py b/app/api/pdf.py
index 5193564..24fac2c 100644
--- a/app/api/pdf.py
+++ b/app/api/pdf.py
@@ -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))
diff --git a/app/api/scenario.py b/app/api/scenario.py
index 3b21ce7..915078d 100644
--- a/app/api/scenario.py
+++ b/app/api/scenario.py
@@ -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"}
)
diff --git a/app/config.py b/app/config.py
index 1651943..2966b6e 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,5 +1,5 @@
"""Application configuration."""
-VERSION = "2.0.0"
+VERSION = "3.0.0"
MAX_FILE_SIZE_MB = 10
DEFAULT_COLOR = "#ffffff"
diff --git a/app/core/__init__.py b/app/core/__init__.py
index ca49866..dd3f06e 100644
--- a/app/core/__init__.py
+++ b/app/core/__init__.py
@@ -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",
]
diff --git a/app/core/excel_reader.py b/app/core/excel_reader.py
deleted file mode 100644
index dc7ed61..0000000
--- a/app/core/excel_reader.py
+++ /dev/null
@@ -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
diff --git a/app/core/pdf_generator.py b/app/core/pdf_generator.py
index b1458f5..9991a63 100644
--- a/app/core/pdf_generator.py
+++ b/app/core/pdf_generator.py
@@ -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"
{block.responsible}"
+ 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', '
'), 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("Legenda:", 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("Legenda:", 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()
diff --git a/app/core/timetable.py b/app/core/timetable.py
deleted file mode 100644
index af97851..0000000
--- a/app/core/timetable.py
+++ /dev/null
@@ -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
diff --git a/app/core/validator.py b/app/core/validator.py
index 94b9dc5..37aba51 100644
--- a/app/core/validator.py
+++ b/app/core/validator.py
@@ -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)}"
- )
diff --git a/app/main.py b/app/main.py
index 451384d..d12185e 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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,
)
diff --git a/app/models/event.py b/app/models/event.py
index a87b72c..128a4f6 100644
--- a/app/models/event.py
+++ b/app/models/event.py
@@ -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):
diff --git a/app/models/responses.py b/app/models/responses.py
index d642e0d..5535ab0 100644
--- a/app/models/responses.py
+++ b/app/models/responses.py
@@ -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] = []
diff --git a/app/static/css/app.css b/app/static/css/app.css
index 6409944..8ebb531 100644
--- a/app/static/css/app.css
+++ b/app/static/css/app.css
@@ -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);
}
diff --git a/app/static/index.html b/app/static/index.html
index 6093bbe..58d0305 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -3,131 +3,193 @@
Tvorba časových harmonogramů
- - -