Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player / smarthome / typing), replacing the old single room-toggle corner button with a vertical icon tab rail. Curriculum and progress now come from the backend (musicmouse/tippen/*) instead of a build-time YAML import and localStorage. Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question mark for locked albums/tracks with a hint on what unlocks them, and ResultSheet gets a new unlock-animation block alongside the existing lesson-unlock and aquarium-creature celebrations. CSS from the two apps is merged carefully: identical rules (bubble/card/ key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is, while rules that bake in each app's own hue are kept separate under a `tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so neither app's look bleeds into the other's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
172
web/src/components/tippen/LessonMap.tsx
Normal file
172
web/src/components/tippen/LessonMap.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/** The map: a single winding path, one world section at a time.
|
||||
*
|
||||
* Locked lessons are dimmed rather than hidden - seeing that "Große Buchstaben" is
|
||||
* waiting is half the reason to finish the world that is open. Each node carries its own
|
||||
* best animal, star count and a badge for which game it plays, so the map doubles as
|
||||
* both a path forward and a trophy cabinet. */
|
||||
|
||||
import type { Lesson, World } from "../../lib/tippen/curriculum";
|
||||
import { animalById } from "../../lib/tippen/grading";
|
||||
import { NODE_SPACING, pathD, pointFor } from "../../lib/tippen/lessonPath";
|
||||
import { MODE_INFO } from "../../lib/tippen/modeInfo";
|
||||
import type { Progress } from "../../lib/tippen/progress";
|
||||
|
||||
interface Props {
|
||||
worlds: readonly World[];
|
||||
lessons: readonly Lesson[];
|
||||
progress: Progress;
|
||||
/** Which card the keyboard selection is on. */
|
||||
selected: number;
|
||||
onPick: (lesson: Lesson) => void;
|
||||
}
|
||||
|
||||
const NODE_SIZE = 88;
|
||||
/** Half the SVG's viewBox width - wide enough for the path's full swing either side. */
|
||||
const PATH_HALF_WIDTH = 160;
|
||||
|
||||
/** What a consolidation node's key-label line says when it has no keys of its own to
|
||||
* show - a child-friendly word rather than the raw `LessonKind`. */
|
||||
const CONSOLIDATION_LABEL: Record<"fragments" | "words" | "sentences", string> = {
|
||||
fragments: "Wörter",
|
||||
words: "Wörter",
|
||||
sentences: "Sätze",
|
||||
};
|
||||
|
||||
export function LessonMap({ worlds, lessons, progress, selected, onPick }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="view-enter"
|
||||
style={{ flex: 1, overflowY: "auto", padding: "6px 32px 40px", minHeight: 0 }}
|
||||
>
|
||||
<div style={{ maxWidth: 480, margin: "0 auto", display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
{worlds.map((world) => {
|
||||
const worldLessons = lessons.filter((lesson) => lesson.world === world.number);
|
||||
const done = worldLessons.filter((l) => (progress.lessons[l.id]?.bestStars ?? 0) >= 2).length;
|
||||
const height = worldLessons.length * NODE_SPACING;
|
||||
const points = worldLessons.map((_, i) => pointFor(i));
|
||||
|
||||
return (
|
||||
<section key={world.number}>
|
||||
<div
|
||||
className="tp-glass-panel"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
padding: "10px 18px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 22 }}>{world.emoji}</span>
|
||||
<span style={{ fontSize: 17, fontWeight: 900, color: "var(--paper)" }}>
|
||||
Welt {world.number} — {world.title}
|
||||
</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 800, color: "var(--paper)", opacity: 0.6, marginLeft: "auto" }}>
|
||||
{done}/{worldLessons.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height, margin: "0 auto" }}>
|
||||
<svg
|
||||
style={{ position: "absolute", left: "50%", top: 0, transform: "translateX(-50%)", overflow: "visible" }}
|
||||
width={PATH_HALF_WIDTH * 2}
|
||||
height={height}
|
||||
viewBox={`${-PATH_HALF_WIDTH} 0 ${PATH_HALF_WIDTH * 2} ${height}`}
|
||||
>
|
||||
<path d={pathD(points)} stroke="oklch(97% 0.01 175 / 0.35)" strokeWidth={8} strokeLinecap="round" fill="none" />
|
||||
</svg>
|
||||
|
||||
{worldLessons.map((lesson, i) => {
|
||||
const entry = progress.lessons[lesson.id];
|
||||
const locked = !entry?.unlocked;
|
||||
const animal = entry?.bestAnimal ? animalById(entry.bestAnimal) : null;
|
||||
const index = lessons.indexOf(lesson);
|
||||
const point = points[i]!;
|
||||
const modeInfo = MODE_INFO[lesson.primaryMode];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={lesson.id}
|
||||
className="card tp-glass-panel"
|
||||
data-selected={index === selected}
|
||||
data-locked={locked}
|
||||
disabled={locked}
|
||||
onClick={() => onPick(lesson)}
|
||||
title={lesson.title}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `calc(50% + ${point.x}px)`,
|
||||
top: point.y,
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: NODE_SIZE,
|
||||
height: NODE_SIZE,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
padding: 0,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -4,
|
||||
right: -4,
|
||||
fontSize: 15,
|
||||
filter: locked ? "grayscale(1)" : "none",
|
||||
opacity: locked ? 0.4 : 0.9,
|
||||
}}
|
||||
title={modeInfo.name}
|
||||
>
|
||||
{modeInfo.emoji}
|
||||
</span>
|
||||
|
||||
<span style={{ fontSize: 24 }}>{locked ? "🔒" : (animal?.emoji ?? "·")}</span>
|
||||
|
||||
{/* The keys themselves: for a pre-reader this is the real label, the
|
||||
title is decoration. A drill has no new keys, so it says so with
|
||||
a symbol instead of showing an empty line. */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
color: "var(--paper)",
|
||||
opacity: 0.8,
|
||||
letterSpacing: lesson.isDrill ? "normal" : "0.08em",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{lesson.isDrill
|
||||
? "🔁"
|
||||
: lesson.newKeys.length > 0
|
||||
? lesson.newKeys.map((key) => (key === " " ? "␣" : key === "⇧" ? "⇧" : key.toUpperCase())).join(" ")
|
||||
: lesson.kind === "letters"
|
||||
? "üben"
|
||||
: CONSOLIDATION_LABEL[lesson.kind]}
|
||||
</span>
|
||||
|
||||
<span style={{ fontSize: 9, letterSpacing: "0.04em" }}>
|
||||
{[1, 2, 3].map((star) => (
|
||||
<span key={star} style={{ opacity: (entry?.bestStars ?? 0) >= star ? 1 : 0.22 }}>
|
||||
⭐
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user