Render Early Beginner as themed group sections on Learn page
This commit is contained in:
1 parent
b69408aad9
commit
52363fc107
1 file changed
+186
-167
+186
-167
@@ -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'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'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)" };
|
||||
Reference in new issue
Block a user