Files
tutortool/docs/design_handoff/seatmap.jsx
2026-04-29 04:38:26 +02:00

447 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// seatmap.jsx — top-down room layout with tables, chairs, and tutor notes panel
// SeatMap — pure visual; takes assignments + selectedStudent + variant + onSeatClick
function SeatMap({
assignments = SEAT_ASSIGN,
selectedStudent,
onSeatClick,
hoveredSeat,
setHoveredSeat,
variant = "tutor", // "tutor" | "student" | "student-self"
ownSeat, // for student view: which seat is "mine"
scale = 1,
}) {
const W = ROOM.width, H = ROOM.height;
const studentBy = (id) => STUDENTS.find((s) => s.id === id);
return (
<div style={{
position: "relative",
width: W * scale, height: H * scale,
background: "#f7f1e3",
border: "1px solid var(--rule)",
borderRadius: 4,
overflow: "hidden",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.02), 0 1px 0 rgba(0,0,0,0.03)",
}}>
{/* Inner ruled grid — like graph paper */}
<div style={{
position: "absolute", inset: 0,
backgroundImage:
"linear-gradient(to right, rgba(110,90,60,0.05) 1px, transparent 1px), " +
"linear-gradient(to bottom, rgba(110,90,60,0.05) 1px, transparent 1px)",
backgroundSize: `${24*scale}px ${24*scale}px`,
}} />
<div style={{
position: "absolute", inset: 0,
transform: `scale(${scale})`,
transformOrigin: "top left",
width: W, height: H,
}}>
{/* Walls */}
<div style={{
position: "absolute",
left: ROOM.walls.x, top: ROOM.walls.y,
width: ROOM.walls.w, height: ROOM.walls.h,
border: "2px solid var(--ink-2)",
borderRadius: 2,
}} />
{/* Window */}
<div style={{
position: "absolute",
left: ROOM.window.x - 2, top: ROOM.window.y,
width: ROOM.window.w + 4, height: ROOM.window.h,
background: "#dfeaf0",
borderTop: "2px solid var(--ink-2)",
borderBottom: "2px solid var(--ink-2)",
}}>
<div style={{ position: "absolute", left: 1, top: "50%", width: ROOM.window.w + 2, height: 2, background: "var(--ink-2)" }} />
</div>
<div style={{
position: "absolute", left: -8, top: ROOM.window.y + ROOM.window.h / 2 - 6,
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
transform: "rotate(-90deg)", transformOrigin: "left top",
letterSpacing: "0.1em", textTransform: "uppercase",
}}>Fenster</div>
{/* Door — gap with arc */}
<div style={{
position: "absolute",
left: ROOM.door.x, top: ROOM.door.y - 2,
width: ROOM.door.w, height: 4,
background: "#f7f1e3",
}} />
<svg style={{ position: "absolute", left: ROOM.door.x, top: ROOM.door.y - 36, pointerEvents: "none" }}
width={ROOM.door.w + 10} height="40">
<path d={`M 2 38 Q 2 2 ${ROOM.door.w} 2`} stroke="var(--ink-3)" strokeWidth="1" strokeDasharray="2 2" fill="none"/>
<line x1="2" y1="38" x2="2" y2="2" stroke="var(--ink-2)" strokeWidth="1.5"/>
</svg>
<div style={{
position: "absolute", left: ROOM.door.x + 6, top: ROOM.door.y + 6,
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
letterSpacing: "0.1em", textTransform: "uppercase",
}}>Tür</div>
{/* Beamer */}
<div style={{
position: "absolute",
left: ROOM.beamer.x, top: ROOM.beamer.y,
width: ROOM.beamer.w, height: ROOM.beamer.h,
background: "var(--ink-2)", borderRadius: 1,
}} />
<div style={{
position: "absolute", left: ROOM.beamer.x + ROOM.beamer.w + 6, top: ROOM.beamer.y,
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
letterSpacing: "0.1em", textTransform: "uppercase",
}}>Beamer</div>
{/* Podium */}
<div style={{
position: "absolute",
left: ROOM.podium.x, top: ROOM.podium.y,
width: ROOM.podium.w, height: ROOM.podium.h,
border: "1.5px solid var(--ink-2)",
background: "#efe6d2",
borderRadius: 2,
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "var(--mono)", fontSize: 10, color: "var(--ink-3)",
letterSpacing: "0.15em", textTransform: "uppercase",
}}>
Pult · Tutor:in
</div>
{/* Tables */}
{ROOM.tables.map((t) => (
<div key={t.id} style={{
position: "absolute",
left: t.x, top: t.y, width: t.w, height: t.h,
background: "#e8dec5",
border: "1.5px solid var(--ink-2)",
borderRadius: 3,
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "var(--serif)", fontSize: 22, color: "rgba(31,27,22,0.35)",
fontStyle: "italic",
}}>
{t.label}
</div>
))}
{/* Seats */}
{SEATS.map((seat) => {
const sid = assignments[seat.id];
const student = sid ? studentBy(sid) : null;
const isSelected = selectedStudent && sid === selectedStudent;
const isHover = hoveredSeat === seat.id;
const isOwn = ownSeat === seat.id;
let bg, border, label, labelColor;
if (variant === "tutor") {
if (student) {
bg = isSelected ? "var(--ink)" : (isHover ? "#e0d4b6" : "#fbf7ee");
border = isSelected ? "var(--ink)" : "var(--ink-2)";
label = student.initials;
labelColor = isSelected ? "#f7eedc" : "var(--ink)";
} else {
bg = "#f7f1e3";
border = "var(--ink-4)";
label = "";
labelColor = "var(--ink-4)";
}
} else if (variant === "student") {
// student picking a seat
if (student) {
bg = "#d6cdb5"; // grey occupied
border = "var(--ink-4)";
label = "";
} else if (isOwn) {
bg = "var(--accent)";
border = "var(--accent)";
label = "";
} else {
bg = "#fbf7ee";
border = "var(--ink-2)";
label = "";
}
} else if (variant === "student-self") {
// read-only own seat
if (isOwn) {
bg = "var(--accent)";
border = "var(--accent)";
} else if (student) {
bg = "#d6cdb5";
border = "var(--ink-4)";
} else {
bg = "#fbf7ee";
border = "var(--ink-3)";
}
label = "";
}
return (
<button key={seat.id}
onClick={() => onSeatClick && onSeatClick(seat)}
onMouseEnter={() => setHoveredSeat && setHoveredSeat(seat.id)}
onMouseLeave={() => setHoveredSeat && setHoveredSeat(null)}
style={{
position: "absolute",
left: seat.x - 18, top: seat.y - 18,
width: 36, height: 36, borderRadius: "50%",
background: bg, border: `1.5px solid ${border}`,
cursor: onSeatClick ? "pointer" : "default",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "var(--sans)", fontWeight: 600, fontSize: 11,
color: labelColor, padding: 0,
boxShadow: isSelected ? "0 0 0 3px rgba(241,211,106,0.6)" : "none",
transition: "background 120ms, border-color 120ms",
}}
title={student ? student.name : "frei"}>
{label}
{isOwn && variant !== "tutor" && (
<span style={{ color: "#f7eedc", fontSize: 14 }}></span>
)}
</button>
);
})}
{/* Compass */}
<div style={{
position: "absolute", right: 18, bottom: 14,
fontFamily: "var(--mono)", fontSize: 9, color: "var(--ink-3)",
letterSpacing: "0.15em",
}}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<span>N</span>
<svg width="14" height="22"><path d="M7 2 L7 20 M3 6 L7 2 L11 6" stroke="var(--ink-3)" strokeWidth="1" fill="none"/></svg>
</div>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Tutor live view — seat map + roster + notes panel
// ─────────────────────────────────────────────────────────────
function TutorLiveView({ variant = "split" }) {
// variant: "split" (map + side panel) | "stacked" (notes below) | "compact"
const [selected, setSelected] = React.useState(3);
const [hovered, setHovered] = React.useState(null);
const [notes, setNotes] = React.useState(NOTES);
const presentIds = Object.values(SEAT_ASSIGN);
const presentSet = new Set(presentIds);
const present = STUDENTS.filter((s) => presentSet.has(s.id));
const absent = STUDENTS.filter((s) => !presentSet.has(s.id));
const sel = STUDENTS.find((s) => s.id === selected);
const seatOf = (sid) => Object.entries(SEAT_ASSIGN).find(([, v]) => v === sid)?.[0];
return (
<div className="paper-bg" style={{ width: "100%", height: "100%", padding: 24, display: "flex", flexDirection: "column", gap: 16 }}>
{/* Header bar */}
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 16 }}>
<div>
<div className="eyebrow" style={{ marginBottom: 4 }}>Tutor:innen-Ansicht · Live</div>
<div className="serif" style={{ fontSize: 30, fontWeight: 500, letterSpacing: "-0.01em" }}>
Woche 04 <span style={{ color: "var(--ink-4)" }}>·</span> <span className="marker">Donnerstag, 30. April 2026</span>
</div>
<div className="small" style={{ marginTop: 6, display: "flex", gap: 14, alignItems: "center" }}>
<span>{COURSE.name}, {COURSE.semester}</span>
<span style={{ color: "var(--rule)" }}>·</span>
<span>{ROOM.name}</span>
<span style={{ color: "var(--rule)" }}>·</span>
<span>14:00 15:00 Uhr</span>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ textAlign: "right" }}>
<div className="eyebrow">Check-in Code</div>
<div className="mono" style={{ fontSize: 22, letterSpacing: "0.18em", fontWeight: 600 }}>K7QJ-MX2P</div>
<div className="tiny" style={{ marginTop: 2 }}>tutor.puchstein.dev/s/K7QJMX2P</div>
</div>
<StatusPill status="open" />
<button className="btn ghost sm"><Icon.copy /> Kopieren</button>
<button className="btn"><Icon.lock /> Sperren</button>
</div>
</div>
<div style={{ borderTop: "1px solid var(--rule)" }} />
{/* Body grid */}
<div style={{ flex: 1, display: "grid", gridTemplateColumns: "1fr 380px", gap: 24, minHeight: 0 }}>
{/* Seat map column */}
<div style={{ display: "flex", flexDirection: "column", gap: 12, minHeight: 0 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<div className="serif" style={{ fontSize: 18, fontWeight: 500 }}>Sitzplan</div>
<UnderlineStroke width={70} />
</div>
<div style={{ display: "flex", gap: 14, alignItems: "center" }} className="small">
<LegendDot color="var(--ink)" label="anwesend" />
<LegendDot color="#f7f1e3" outline="var(--ink-4)" label="frei" />
<LegendDot color="var(--highlight-soft)" label="ausgewählt" />
</div>
</div>
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", flex: 1 }}>
<SeatMap
assignments={SEAT_ASSIGN}
selectedStudent={selected}
hoveredSeat={hovered}
setHoveredSeat={setHovered}
onSeatClick={(seat) => {
const sid = SEAT_ASSIGN[seat.id];
if (sid) setSelected(sid);
}}
variant="tutor"
scale={0.85}
/>
</div>
{/* Tally */}
<div style={{ display: "flex", gap: 24, alignItems: "center", paddingTop: 4 }}>
<Tally label="Anwesend" value={present.length} total={STUDENTS.length} accent="var(--green)" />
<Tally label="Fehlt" value={absent.length} total={STUDENTS.length} accent="var(--accent)" />
<Tally label="Bonus heute" value={`+${present.length * 3}`} suffix="Punkte" />
<div style={{ flex: 1 }} />
<button className="btn ghost sm"><Icon.plus /> Manuell eintragen</button>
</div>
</div>
{/* Notes panel column */}
<div className="card" style={{ display: "flex", flexDirection: "column", minHeight: 0, overflow: "hidden" }}>
{/* Roster */}
<div style={{ padding: "14px 16px 10px", borderBottom: "1px solid var(--rule)" }}>
<div className="serif" style={{ fontSize: 16, fontWeight: 500 }}>Studierende</div>
<div className="tiny" style={{ marginTop: 2 }}>{present.length} anwesend · {absent.length} fehlen</div>
</div>
<div className="scroll" style={{ overflowY: "auto", maxHeight: 220, padding: "6px 0" }}>
{present.map((s) => {
const isSel = s.id === selected;
const hasNote = notes[s.id];
return (
<button key={s.id}
onClick={() => setSelected(s.id)}
className="row-hover"
style={{
width: "100%", textAlign: "left", border: "none", background: isSel ? "rgba(31,27,22,0.06)" : "transparent",
padding: "7px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
borderLeft: isSel ? "3px solid var(--ink)" : "3px solid transparent",
}}>
<span style={{
width: 22, height: 22, borderRadius: "50%",
background: "var(--ink)", color: "var(--paper)",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "var(--sans)", fontSize: 9, fontWeight: 600,
}}>{s.initials}</span>
<span style={{ flex: 1, fontSize: 13 }}>{s.name}</span>
{hasNote && <span title="hat Notiz" style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--accent)" }} />}
<span className="mono tiny" style={{ color: "var(--ink-4)" }}>{CHECKED_IN_AT[s.id]}</span>
</button>
);
})}
{absent.map((s) => (
<button key={s.id}
onClick={() => setSelected(s.id)}
className="row-hover"
style={{
width: "100%", textAlign: "left", border: "none", background: "transparent",
padding: "7px 16px", display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
opacity: 0.55,
}}>
<span style={{
width: 22, height: 22, borderRadius: "50%",
background: "transparent", color: "var(--ink-3)",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "var(--sans)", fontSize: 9, fontWeight: 600,
border: "1px dashed var(--ink-4)",
}}>{s.initials}</span>
<span style={{ flex: 1, fontSize: 13, textDecoration: "line-through", textDecorationColor: "var(--ink-4)" }}>{s.name}</span>
<span className="mono tiny" style={{ color: "var(--ink-4)" }}></span>
</button>
))}
</div>
{/* Note editor */}
<div style={{ borderTop: "1px solid var(--rule)", padding: "14px 16px", flex: 1, display: "flex", flexDirection: "column", background: "#fbf7ee" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
<span style={{
width: 32, height: 32, borderRadius: "50%",
background: "var(--ink)", color: "var(--paper)",
display: "flex", alignItems: "center", justifyContent: "center",
fontFamily: "var(--sans)", fontSize: 11, fontWeight: 600,
}}>{sel?.initials}</span>
<div style={{ flex: 1 }}>
<div className="serif" style={{ fontSize: 16, fontWeight: 500 }}>{sel?.name}</div>
<div className="tiny">Sitzplatz {seatOf(selected) || "—"} · seit {CHECKED_IN_AT[selected] || "—"}</div>
</div>
<span className="stamp">Präsent</span>
</div>
<div className="eyebrow" style={{ marginTop: 6, marginBottom: 6 }}>Notiz · Woche 04</div>
<textarea
value={notes[selected] || ""}
onChange={(e) => setNotes({ ...notes, [selected]: e.target.value })}
placeholder="Beobachtungen für diese Woche…"
className="ruled"
style={{
flex: 1, minHeight: 110, resize: "none",
fontFamily: "var(--serif)", fontSize: 15, lineHeight: "28px",
padding: "0 0 4px 0",
background: "transparent", border: "none", outline: "none",
color: "var(--ink)",
}}
/>
{/* Quick tags */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
{["aktiv beteiligt", "stille:r Kämpfer:in", "verstanden ✓", "nochmal aufgreifen", "Rückfrage offen", "elegante Lösung"].map((tag) => (
<button key={tag} className="pill closed" style={{ borderColor: "var(--rule)", cursor: "pointer", fontFamily: "var(--sans)", textTransform: "none", fontSize: 11, letterSpacing: 0 }}>
+ {tag}
</button>
))}
</div>
<div className="tiny" style={{ marginTop: 10, display: "flex", justifyContent: "space-between" }}>
<span>Auto-gespeichert · 14:23</span>
<a href="#" style={{ color: "var(--ink-3)" }}>Notizen vergangener Wochen </a>
</div>
</div>
</div>
</div>
</div>
);
}
function LegendDot({ color, outline, label }) {
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<span style={{
width: 12, height: 12, borderRadius: "50%",
background: color, border: outline ? `1px solid ${outline}` : "none",
}} />
<span>{label}</span>
</span>
);
}
function Tally({ label, value, total, suffix, accent }) {
return (
<div>
<div className="eyebrow" style={{ marginBottom: 2 }}>{label}</div>
<div style={{ display: "flex", alignItems: "baseline", gap: 5 }}>
<span className="serif" style={{ fontSize: 28, fontWeight: 500, color: accent || "var(--ink)" }}>{value}</span>
{total != null && <span className="small">/ {total}</span>}
{suffix && <span className="small" style={{ marginLeft: 2 }}>{suffix}</span>}
</div>
</div>
);
}
Object.assign(window, { SeatMap, TutorLiveView, LegendDot, Tally });