import json
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import solara
import solara.lab
from solara.lab import theme as theme
# ----------------------------
# Data model
# ----------------------------
@dataclass(frozen=True)
class BoxSpec:
id: str
column: int # 0,1,2
row: int # 0..4
color_key: str
tooltip: str
details: str
COL_TITLES = [
"Gevoelens\nin het lichaam",
"Gedragingen\ntijdens\ninteracties",
"Mogelijke\ninterventies\n(\"gereedschapskist\")",
]
COLORS = {
"red": "#e31a2c",
"orange": "#f49b00",
"green": "#93c83e",
"deepgreen": "#2e7d32",
"ink": "#202124",
}
ROW_COLOR_KEYS = ["red", "orange", "green", "orange", "red"]
ARROW_23_DIR = ["right", "right", "left", "right", "right"]
ARROW_23_COLOR_KEYS = ["orange", "green", "deepgreen", "green", "orange"]
RIGHT_COLOR_KEYS = ["green"] * 5
# ----------------------------
# Default content
# ----------------------------
DEFAULT_BOXES: List[BoxSpec] = []
for r in range(5):
DEFAULT_BOXES.append(
BoxSpec(
id=f"c0r{r}",
column=0,
row=r,
color_key=ROW_COLOR_KEYS[r],
tooltip=f"Les 2 – rij {r+1} (lichaam)",
details="Vul hier later de inhoud in voor deze lesbox (lichaamssignalen).",
)
)
DEFAULT_BOXES.append(
BoxSpec(
id=f"c1r{r}",
column=1,
row=r,
color_key=ROW_COLOR_KEYS[r],
tooltip=f"Les 3 – rij {r+1} (interacties)",
details="Vul hier later de inhoud in voor deze lesbox (gedragingen in interacties).",
)
)
DEFAULT_BOXES.append(
BoxSpec(
id=f"c2r{r}",
column=2,
row=r,
color_key=RIGHT_COLOR_KEYS[r],
tooltip=f"Les 5 – rij {r+1} (interventies)",
details="Vul hier later de inhoud in voor deze lesbox (interventies / gereedschapskist).",
)
)
DEFAULT_BOX_MAP: Dict[str, BoxSpec] = {b.id: b for b in DEFAULT_BOXES}
def safe_get_box(boxes: Dict[str, BoxSpec], box_id: Optional[str]) -> Optional[BoxSpec]:
if not box_id:
return None
return boxes.get(box_id)
def export_payload(boxes: Dict[str, BoxSpec]) -> bytes:
payload = {
"version": 1,
"titles": COL_TITLES,
"boxes": {
bid: {
"tooltip": b.tooltip,
"details": b.details,
"column": b.column,
"row": b.row,
"color_key": b.color_key,
}
for bid, b in boxes.items()
},
}
return json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
def import_payload(data: bytes) -> Dict[str, BoxSpec]:
obj = json.loads(data.decode("utf-8"))
if not isinstance(obj, dict) or "boxes" not in obj:
raise ValueError("JSON must contain a top-level 'boxes' object.")
boxes_obj = obj["boxes"]
if not isinstance(boxes_obj, dict):
raise ValueError("'boxes' must be a JSON object mapping id -> box content.")
new_boxes: Dict[str, BoxSpec] = dict(DEFAULT_BOX_MAP)
for bid, b in boxes_obj.items():
if bid not in new_boxes:
continue
if not isinstance(b, dict):
continue
old = new_boxes[bid]
new_boxes[bid] = BoxSpec(
id=old.id,
column=int(b.get("column", old.column)),
row=int(b.get("row", old.row)),
color_key=str(b.get("color_key", old.color_key)),
tooltip=str(b.get("tooltip", old.tooltip)),
details=str(b.get("details", old.details)),
)
return new_boxes
def _file_get(fileinfo, key: str, default=None):
if isinstance(fileinfo, dict):
return fileinfo.get(key, default)
return getattr(fileinfo, key, default)
def _set_info_color(hex_color: str):
theme.themes.light.info = hex_color
theme.themes.dark.info = hex_color
@solara.component
def LessonBox(box: BoxSpec, selected: bool, on_select):
bg = COLORS.get(box.color_key, COLORS["green"])
outline = "2px solid rgba(255,255,255,0.95)" if selected else "2px solid rgba(255,255,255,0.78)"
shadow = "0 10px 26px rgba(0,0,0,0.30)" if selected else "0 6px 16px rgba(0,0,0,0.22)"
solara.Button(
label="• • •",
on_click=on_select,
style={
"width": "100%",
"height": "var(--hl-box-h)",
"borderRadius": "18px",
"background": bg,
"border": outline,
"boxShadow": shadow,
"color": "white",
"fontSize": "clamp(18px, 2.2vw, 30px)",
"lineHeight": "1",
"letterSpacing": "0.22em",
},
)
@solara.component
def Diagram(boxes: Dict[str, BoxSpec], selected_id: Optional[str], set_selected_id):
arrows_12: List[Tuple[str, str]] = [("↔", ROW_COLOR_KEYS[r]) for r in range(5)]
arrows_23: List[Tuple[str, str]] = []
for r in range(5):
sym = "→" if ARROW_23_DIR[r] == "right" else "←"
arrows_23.append((sym, ARROW_23_COLOR_KEYS[r]))
title_htmls = ["<br/>".join(t.split("\n")) for t in COL_TITLES]
with solara.Column(classes=["hl-diagram-wrap"]):
with solara.Columns(widths=[1.0, 0.15, 1.0, 0.15, 1.0], gutters=True, classes=["hl-diagram"]):
# Column 0
with solara.Card(classes=["hl-colcard"]):
solara.HTML(tag="div", classes=["hl-coltitle"], unsafe_innerHTML=title_htmls[0])
with solara.Column(classes=["hl-colinner"]):
for r in range(5):
bid = f"c0r{r}"
b = boxes[bid]
is_sel = (bid == selected_id)
solara.Tooltip(
b.tooltip,
children=[LessonBox(b, is_sel, on_select=lambda bid_local=bid: set_selected_id(bid_local))],
)
# Arrows 1->2
with solara.Column(classes=["hl-arrows"]):
solara.HTML(tag="div", classes=["hl-title-spacer"])
for sym, ck in arrows_12:
solara.HTML(
tag="div",
classes=["hl-arrow"],
style=f"color: {COLORS.get(ck, COLORS['ink'])};",
unsafe_innerHTML=sym,
)
# Column 1
with solara.Card(classes=["hl-colcard"]):
solara.HTML(tag="div", classes=["hl-coltitle"], unsafe_innerHTML=title_htmls[1])
with solara.Column(classes=["hl-colinner"]):
for r in range(5):
bid = f"c1r{r}"
b = boxes[bid]
is_sel = (bid == selected_id)
solara.Tooltip(
b.tooltip,
children=[LessonBox(b, is_sel, on_select=lambda bid_local=bid: set_selected_id(bid_local))],
)
# Arrows 2->3
with solara.Column(classes=["hl-arrows"]):
solara.HTML(tag="div", classes=["hl-title-spacer"])
for sym, ck in arrows_23:
solara.HTML(
tag="div",
classes=["hl-arrow"],
style=f"color: {COLORS.get(ck, COLORS['ink'])};",
unsafe_innerHTML=sym,
)
# Column 2
with solara.Card(classes=["hl-colcard"]):
solara.HTML(tag="div", classes=["hl-coltitle"], unsafe_innerHTML=title_htmls[2])
with solara.Column(classes=["hl-colinner"]):
for r in range(5):
bid = f"c2r{r}"
b = boxes[bid]
is_sel = (bid == selected_id)
solara.Tooltip(
b.tooltip,
children=[LessonBox(b, is_sel, on_select=lambda bid_local=bid: set_selected_id(bid_local))],
)
@solara.component
def DetailPanel(box: Optional[BoxSpec], boxes: Dict[str, BoxSpec], set_boxes, clear_selection):
with solara.Card(classes=["hl-detailcard"]):
if box is None:
solara.Text("Select a box to view its content.")
return
with solara.Row(style={"alignItems": "center", "justifyContent": "space-between"}):
solara.Text(box.tooltip, classes=["hl-detailtitle"])
solara.Button("Deselect", on_click=clear_selection, text=True)
def update_details(v: str):
set_boxes(
dict(
boxes,
**{
box.id: BoxSpec(
id=box.id,
column=box.column,
row=box.row,
color_key=box.color_key,
tooltip=box.tooltip,
details=v,
)
},
)
)
solara.Details(
summary="Inhoud (bewerken)",
expand=True,
children=[
solara.InputText(
label="Tekst",
value=box.details,
on_value=update_details,
#auto_grow=True,
#rows=6,
)
],
)
solara.Details(
summary="Snelle prompts (placeholder)",
children=[
solara.Text("Voorbeeldvragen die je later kunt invullen:"),
solara.Text("• Wat merk ik in mijn lichaam?"),
solara.Text("• Wat ziet de ander aan mijn gedrag?"),
solara.Text("• Welke actie helpt terug naar groen?"),
],
)
@solara.component
def DataPanel(boxes: Dict[str, BoxSpec], set_boxes):
def on_file(fileinfo):
try:
data = _file_get(fileinfo, "data", None)
name = _file_get(fileinfo, "name", "uploaded.json")
if data is None:
raise ValueError("No file data received (did you set lazy=False?).")
new_boxes = import_payload(data)
except Exception as e:
solara.Error(f"Import failed: {e}")
return
set_boxes(new_boxes)
solara.Info(f"Imported: {name}")
with solara.Card(classes=["hl-datacard"]):
solara.Text("Import/export lesson content as JSON (client-side).")
solara.FileDrop(label="Drop JSON here to import", on_file=on_file, lazy=False)
solara.HTML(tag="div", style="height: 10px;")
solara.FileDownload(
label="Download current JSON",
data=lambda: export_payload(boxes),
filename="lessons_solara.json",
)
@solara.component
def Page():
boxes, set_boxes = solara.use_state(DEFAULT_BOX_MAP)
selected_id, set_selected_id = solara.use_state(None)
selected_box = safe_get_box(boxes, selected_id)
tab_index, set_tab_index = solara.use_state(0)
solara.Style(
"""
:root{
--hl-box-h: clamp(58px, 8.5vw, 94px);
--hl-gap: clamp(10px, 1.1vw, 18px);
--hl-title: clamp(15px, 1.8vw, 26px);
--hl-title-h: clamp(84px, 10vw, 140px);
--hl-detail-title: clamp(14px, 1.3vw, 20px);
}
.hl-diagram-wrap{
width: 100%;
overflow-x: auto;
padding-bottom: 6px;
}
.hl-diagram{
width: 100%;
max-width: 1500px;
margin: 0 auto;
}
.hl-colcard{
border-radius: 28px !important;
padding: clamp(10px, 1.0vw, 16px) !important;
}
.hl-colinner{
gap: var(--hl-gap);
}
.hl-coltitle{
height: var(--hl-title-h);
font-size: var(--hl-title);
font-weight: 650;
text-align: center;
line-height: 1.12;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 8px 12px 8px;
opacity: 0.95;
}
.hl-arrows{
gap: var(--hl-gap);
justify-content: flex-start;
}
.hl-title-spacer{
height: var(--hl-title-h);
}
.hl-arrow{
height: var(--hl-box-h);
display:flex;
align-items:center;
justify-content:center;
font-size: clamp(18px, 2.6vw, 34px);
font-weight: 900;
user-select: none;
}
.hl-detailcard, .hl-datacard{
border-radius: 22px !important;
}
.hl-detailtitle{
font-size: var(--hl-detail-title);
font-weight: 750;
margin-bottom: 10px;
}
"""
)
with solara.AppLayout(title="HB lessons prototype", navigation=False):
with solara.AppBar():
solara.lab.ThemeToggle(enable_auto=True)
solara.HTML(tag="div", style="flex: 1;")
with solara.Row():
solara.Button("Accent blauw", on_click=lambda: _set_info_color("#2196f3"), text=True)
solara.Button("Accent paars", on_click=lambda: _set_info_color("#8617c2"), text=True)
with solara.lab.Tabs(value=tab_index, on_value=set_tab_index):
solara.lab.Tab("Diagram")
solara.lab.Tab("Data")
if tab_index == 0:
with solara.ColumnsResponsive(default=[12, 12], medium=[4, 8], large=[3, 9]):
DetailPanel(
selected_box,
boxes,
set_boxes,
clear_selection=lambda: set_selected_id(None),
)
Diagram(
boxes=boxes,
selected_id=selected_id,
set_selected_id=set_selected_id,
)
else:
DataPanel(boxes, set_boxes)