Render Early Beginner as themed group sections on Learn page

This commit is contained in:
Dan committed 2026-06-26 21:58:25 +00:00
1 parent b69408aad9
commit 52363fc107
1 file changed
+186 -167
+186 -167
View File
@@ -1,167 +1,186 @@
import Link from "next/link";
import SiteNav from "@/components/SiteNav";
import { LEVELS, getSubjects, BEGINNER_PACKS, lessonStepCount, type Lesson } from "@/lib/curriculum";
import { getCurrentUser } from "@/lib/session";
import { getCompletedSteps, hasBadge } from "@/lib/progress";
import { isLevelUnlocked, countCompletedLessons, isPackComplete, BEGINNER_UNLOCK_THRESHOLD } from "@/lib/gating";
export const metadata = { title: "Learn · DrawIt" };
export const dynamic = "force-dynamic";
const PHASE_LABEL: Record<string, string> = {
construct: "Shapes", outline: "Outline", detail: "Details", color: "Color", light: "Light & shadow", extra: "Color it!",
};
const OPTIONAL_PHASES = new Set(["extra"]);
type Subject = ReturnType<typeof getSubjects>[number];
export default async function LearnPage() {
const user = await getCurrentUser();
const doneCount = (l: Lesson) => (user ? getCompletedSteps(user.id, l.level, l.sublevel).length : 0);
const isDone = (l: Lesson) => {
if (!user) return false;
if (l.badgeKey && hasBadge(user.id, l.badgeKey)) return true;
return doneCount(l) >= lessonStepCount(l);
};
const subjectComplete = (s: Subject) => s.lessons.every((l) => isDone(l));
// One card per phase, each gated behind the previous phase in the subject.
const phaseCard = (l: Lesson, locked: boolean) => {
const optional = OPTIONAL_PHASES.has(l.phase ?? "");
const done = isDone(l);
const inProg = !done && !locked && doneCount(l) > 0;
const inner = (
<div>
<strong style={{ display: "block" }}>{PHASE_LABEL[l.phase ?? ""] ?? l.title}{optional ? " (optional)" : ""}</strong>
<span className="muted" style={{ fontSize: "0.82rem" }}>
{locked ? "🔒 Locked" : done ? "✓ Done" : inProg ? `Step ${Math.min(doneCount(l) + 1, lessonStepCount(l))}/${lessonStepCount(l)}` : l.steps.length === 0 ? "Tap to start" : `${lessonStepCount(l)} steps`}
</span>
</div>
);
return locked ? (
<div key={l.slug} style={{ ...phaseBox, opacity: 0.55 }}>{inner}</div>
) : (
<Link key={l.slug} href={`/learn/${l.level}/${l.slug}`} style={{ ...phaseBox, textDecoration: "none", color: "inherit", borderColor: done ? "var(--leaf)" : "var(--line)" }}>{inner}</Link>
);
};
const subjectAccordion = (subj: Subject, open: boolean) => {
const complete = subjectComplete(subj);
const anyStarted = subj.lessons.some((l) => doneCount(l) > 0);
// gate each phase behind the previous one being done
let prevDone = true;
const cards = subj.lessons.map((l) => {
const locked = !prevDone;
const card = phaseCard(l, locked);
prevDone = isDone(l);
return card;
});
return (
<details key={subj.key} open={open} style={{ border: complete ? "2px solid var(--leaf)" : "2px solid var(--line)", borderRadius: 14, padding: "10px 14px", background: "var(--surface)" }}>
<summary style={{ cursor: "pointer", fontWeight: 800, fontSize: "1.05rem" }}>
{subj.emoji} {subj.name}
{complete ? <span className="pill active" style={{ marginLeft: 8 }}>✓ Done</span> : anyStarted ? <span className="pill pending" style={{ marginLeft: 8 }}>In progress</span> : null}
</summary>
<div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", marginTop: 12 }}>
{cards}
</div>
</details>
);
};
return (
<>
<SiteNav />
<main className="container page">
<h1>Learn to draw 🎨</h1>
<p className="muted">
Each picture is built up in steps. Finish the lessons to earn badges and fill your gallery.
{!user && " Log in to track your progress and earn badges."}
</p>
<div className="stack" style={{ marginTop: 18 }}>
{LEVELS.map((lvl, idx) => {
const subjects = getSubjects(lvl.key);
const unlocked = isLevelUnlocked(user?.id ?? null, lvl.key);
const firstIncomplete = subjects.find((s) => !subjectComplete(s))?.key;
return (
<div className="card" key={lvl.key}>
<div className="row" style={{ justifyContent: "space-between" }}>
<h2 style={{ margin: 0 }}>{lvl.emoji} {idx + 1}. {lvl.name}</h2>
{subjects.length === 0 && <span className="pill pending">Coming soon</span>}
{subjects.length > 0 && !unlocked && <span className="pill pending">🔒 Locked</span>}
</div>
<p className="muted">{lvl.blurb}</p>
{/* Locked level: show how to unlock */}
{subjects.length > 0 && !unlocked ? (
<div className="card" style={{ background: "var(--surface)", textAlign: "center", marginTop: 6 }}>
{!user ? (
<p className="muted" style={{ margin: 0 }}>
<Link href="/signup">Sign up</Link> and finish {BEGINNER_UNLOCK_THRESHOLD} Early Beginner drawings to unlock this level.
</p>
) : (
<>
<p style={{ margin: "0 0 6px" }}>🔒 Finish <strong>{BEGINNER_UNLOCK_THRESHOLD}</strong> Early Beginner lessons to unlock the {lvl.name} level.</p>
<p className="muted" style={{ margin: 0 }}>
You&apos;ve done {Math.min(countCompletedLessons(user.id, "early-beginner"), BEGINNER_UNLOCK_THRESHOLD)} of {BEGINNER_UNLOCK_THRESHOLD}. Keep going!
</p>
<Link className="btn" href="/learn/early-beginner/fish-outline" style={{ marginTop: 10 }}>Practice Early Beginner</Link>
</>
)}
</div>
) : lvl.key === "beginner" ? (
// Beginner: group subjects into packs, each with a per-pack badge.
<div className="stack" style={{ marginTop: 6 }}>
{BEGINNER_PACKS.map((pack) => {
const packSubjects = subjects.filter((s) => pack.subjectKeys.includes(s.key));
const packDone = !!user && isPackComplete(user.id, pack);
return (
<div key={pack.key} style={{ border: packDone ? "2px solid var(--leaf)" : "2px dashed var(--line)", borderRadius: 16, padding: "12px 14px" }}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<strong style={{ fontSize: "1.05rem" }}>{pack.emoji} {pack.name}</strong>
{packDone ? <span className="pill active">🏅 {pack.badgeName}</span> : <span className="muted" style={{ fontSize: "0.82rem" }}>Finish all {pack.subjectKeys.length} to earn {pack.badgeName}</span>}
</div>
<p className="muted" style={{ margin: "4px 0 10px", fontSize: "0.9rem" }}>{pack.blurb}</p>
<div className="stack">
{packSubjects.map((subj) => subjectAccordion(subj, subj.key === firstIncomplete))}
</div>
</div>
);
})}
</div>
) : (
// Early Beginner (and any future flat levels)
<div className="stack" style={{ marginTop: 6 }}>
{subjects.map((subj) => {
const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase;
if (!phased) {
const l = subj.lessons[0];
const done = isDone(l);
const inProg = !done && doneCount(l) > 0;
return (
<Link key={subj.key} href={`/learn/${l.level}/${l.slug}`} className="card" style={subCard(done)}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<strong>{subj.emoji} {l.title}</strong>
{done ? <span className="pill active">✓ Done</span> : inProg ? <span className="pill pending">Step {Math.min(doneCount(l) + 1, lessonStepCount(l))}/{lessonStepCount(l)}</span> : <span className="muted" style={{ fontSize: "0.85rem" }}>{lessonStepCount(l)} steps</span>}
</div>
</Link>
);
}
return subjectAccordion(subj, subj.key === firstIncomplete);
})}
</div>
)}
</div>
);
})}
</div>
</main>
</>
);
}
const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 });
const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" };
import Link from "next/link";
import SiteNav from "@/components/SiteNav";
import { LEVELS, getSubjects, getGroupedSubjects, BEGINNER_PACKS, lessonStepCount, type Lesson } from "@/lib/curriculum";
import { getCurrentUser } from "@/lib/session";
import { getCompletedSteps, hasBadge } from "@/lib/progress";
import { isLevelUnlocked, countCompletedLessons, isPackComplete, BEGINNER_UNLOCK_THRESHOLD } from "@/lib/gating";
export const metadata = { title: "Learn · DrawIt" };
export const dynamic = "force-dynamic";
const PHASE_LABEL: Record<string, string> = {
construct: "Shapes", outline: "Outline", detail: "Details", color: "Color", light: "Light & shadow", extra: "Color it!",
};
const OPTIONAL_PHASES = new Set(["extra"]);
type Subject = ReturnType<typeof getSubjects>[number];
export default async function LearnPage() {
const user = await getCurrentUser();
const doneCount = (l: Lesson) => (user ? getCompletedSteps(user.id, l.level, l.sublevel).length : 0);
const isDone = (l: Lesson) => {
if (!user) return false;
if (l.badgeKey && hasBadge(user.id, l.badgeKey)) return true;
return doneCount(l) >= lessonStepCount(l);
};
const subjectComplete = (s: Subject) => s.lessons.every((l) => isDone(l));
// One card per phase, each gated behind the previous phase in the subject.
const phaseCard = (l: Lesson, locked: boolean) => {
const optional = OPTIONAL_PHASES.has(l.phase ?? "");
const done = isDone(l);
const inProg = !done && !locked && doneCount(l) > 0;
const inner = (
<div>
<strong style={{ display: "block" }}>{PHASE_LABEL[l.phase ?? ""] ?? l.title}{optional ? " (optional)" : ""}</strong>
<span className="muted" style={{ fontSize: "0.82rem" }}>
{locked ? "🔒 Locked" : done ? "✓ Done" : inProg ? `Step ${Math.min(doneCount(l) + 1, lessonStepCount(l))}/${lessonStepCount(l)}` : l.steps.length === 0 ? "Tap to start" : `${lessonStepCount(l)} steps`}
</span>
</div>
);
return locked ? (
<div key={l.slug} style={{ ...phaseBox, opacity: 0.55 }}>{inner}</div>
) : (
<Link key={l.slug} href={`/learn/${l.level}/${l.slug}`} style={{ ...phaseBox, textDecoration: "none", color: "inherit", borderColor: done ? "var(--leaf)" : "var(--line)" }}>{inner}</Link>
);
};
const subjectAccordion = (subj: Subject, open: boolean) => {
const complete = subjectComplete(subj);
const anyStarted = subj.lessons.some((l) => doneCount(l) > 0);
// gate each phase behind the previous one being done
let prevDone = true;
const cards = subj.lessons.map((l) => {
const locked = !prevDone;
const card = phaseCard(l, locked);
prevDone = isDone(l);
return card;
});
return (
<details key={subj.key} open={open} style={{ border: complete ? "2px solid var(--leaf)" : "2px solid var(--line)", borderRadius: 14, padding: "10px 14px", background: "var(--surface)" }}>
<summary style={{ cursor: "pointer", fontWeight: 800, fontSize: "1.05rem" }}>
{subj.emoji} {subj.name}
{complete ? <span className="pill active" style={{ marginLeft: 8 }}>✓ Done</span> : anyStarted ? <span className="pill pending" style={{ marginLeft: 8 }}>In progress</span> : null}
</summary>
<div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", marginTop: 12 }}>
{cards}
</div>
</details>
);
};
return (
<>
<SiteNav />
<main className="container page">
<h1>Learn to draw 🎨</h1>
<p className="muted">
Each picture is built up in steps. Finish the lessons to earn badges and fill your gallery.
{!user && " Log in to track your progress and earn badges."}
</p>
<div className="stack" style={{ marginTop: 18 }}>
{LEVELS.map((lvl, idx) => {
const subjects = getSubjects(lvl.key);
const unlocked = isLevelUnlocked(user?.id ?? null, lvl.key);
const orderedSubjects = lvl.key === "early-beginner" ? getGroupedSubjects(lvl.key).flatMap((g) => g.subjects) : subjects;
const firstIncomplete = orderedSubjects.find((s) => !subjectComplete(s))?.key;
return (
<div className="card" key={lvl.key}>
<div className="row" style={{ justifyContent: "space-between" }}>
<h2 style={{ margin: 0 }}>{lvl.emoji} {idx + 1}. {lvl.name}</h2>
{subjects.length === 0 && <span className="pill pending">Coming soon</span>}
{subjects.length > 0 && !unlocked && <span className="pill pending">🔒 Locked</span>}
</div>
<p className="muted">{lvl.blurb}</p>
{/* Locked level: show how to unlock */}
{subjects.length > 0 && !unlocked ? (
<div className="card" style={{ background: "var(--surface)", textAlign: "center", marginTop: 6 }}>
{!user ? (
<p className="muted" style={{ margin: 0 }}>
<Link href="/signup">Sign up</Link> and finish {BEGINNER_UNLOCK_THRESHOLD} Early Beginner drawings to unlock this level.
</p>
) : (
<>
<p style={{ margin: "0 0 6px" }}>🔒 Finish <strong>{BEGINNER_UNLOCK_THRESHOLD}</strong> Early Beginner lessons to unlock the {lvl.name} level.</p>
<p className="muted" style={{ margin: 0 }}>
You&apos;ve done {Math.min(countCompletedLessons(user.id, "early-beginner"), BEGINNER_UNLOCK_THRESHOLD)} of {BEGINNER_UNLOCK_THRESHOLD}. Keep going!
</p>
<Link className="btn" href="/learn/early-beginner/fish-outline" style={{ marginTop: 10 }}>Practice Early Beginner</Link>
</>
)}
</div>
) : lvl.key === "beginner" ? (
// Beginner: group subjects into packs, each with a per-pack badge.
<div className="stack" style={{ marginTop: 6 }}>
{BEGINNER_PACKS.map((pack) => {
const packSubjects = subjects.filter((s) => pack.subjectKeys.includes(s.key));
const packDone = !!user && isPackComplete(user.id, pack);
return (
<div key={pack.key} style={{ border: packDone ? "2px solid var(--leaf)" : "2px dashed var(--line)", borderRadius: 16, padding: "12px 14px" }}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<strong style={{ fontSize: "1.05rem" }}>{pack.emoji} {pack.name}</strong>
{packDone ? <span className="pill active">🏅 {pack.badgeName}</span> : <span className="muted" style={{ fontSize: "0.82rem" }}>Finish all {pack.subjectKeys.length} to earn {pack.badgeName}</span>}
</div>
<p className="muted" style={{ margin: "4px 0 10px", fontSize: "0.9rem" }}>{pack.blurb}</p>
<div className="stack">
{packSubjects.map((subj) => subjectAccordion(subj, subj.key === firstIncomplete))}
</div>
</div>
);
})}
</div>
) : lvl.key === "early-beginner" ? (
// Early Beginner: themed groups, each holding its subjects.
<div className="stack" style={{ marginTop: 6 }}>
{getGroupedSubjects(lvl.key).map((group) => {
const groupDone = group.subjects.every((s) => subjectComplete(s));
return (
<div key={group.key} style={{ border: groupDone ? "2px solid var(--leaf)" : "2px dashed var(--line)", borderRadius: 16, padding: "12px 14px" }}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<strong style={{ fontSize: "1.05rem" }}>{group.emoji} {group.name}</strong>
<span className="muted" style={{ fontSize: "0.82rem" }}>{group.subjects.filter((s) => subjectComplete(s)).length}/{group.subjects.length} done</span>
</div>
<div className="stack" style={{ marginTop: 10 }}>
{group.subjects.map((subj) => subjectAccordion(subj, subj.key === firstIncomplete))}
</div>
</div>
);
})}
</div>
) : (
// Any future flat level
<div className="stack" style={{ marginTop: 6 }}>
{subjects.map((subj) => {
const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase;
if (!phased) {
const l = subj.lessons[0];
const done = isDone(l);
const inProg = !done && doneCount(l) > 0;
return (
<Link key={subj.key} href={`/learn/${l.level}/${l.slug}`} className="card" style={subCard(done)}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<strong>{subj.emoji} {l.title}</strong>
{done ? <span className="pill active">✓ Done</span> : inProg ? <span className="pill pending">Step {Math.min(doneCount(l) + 1, lessonStepCount(l))}/{lessonStepCount(l)}</span> : <span className="muted" style={{ fontSize: "0.85rem" }}>{lessonStepCount(l)} steps</span>}
</div>
</Link>
);
}
return subjectAccordion(subj, subj.key === firstIncomplete);
})}
</div>
)}
</div>
);
})}
</div>
</main>
</>
);
}
const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 });
const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" };