forked from Tadrisino/MathApp_frontend
play whole islands, make home the path itself, draw tutor diagrams, add tedi
- Home is now the island path of every unit with one clear Start button on the
current island; the six-card dashboard is gone.
- Start opens /nodes/{id}/quiz (4 questions) instead of one-question lessons, so
an island takes one round instead of ~11. Lessons stay one tap away in the sheet.
- Tutor answers render the backend's validated diagram (plane, number line,
fraction) as React SVG; function expressions go through a small parser, no eval.
- Tedi, the mobile-preview mascot, joins as the fourth companion with all six moods
and an onboarding slide.
- Light polish: 3D buttons, header gradients, warmer page background.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, CheckCircle2, Circle, Lock, Play, Sparkles } from 'lucide-react';
|
||||
import { api, type HomeNode } from '@/api';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import type { HomeNode } from '@/api';
|
||||
import { useAppData } from '@/state/AppData';
|
||||
import { findUnit, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { findUnit, unitColor, unitProgress } from '@/state/curriculum';
|
||||
import { useNav } from '@/nav/Navigator';
|
||||
import { useAsync } from '@/lib/useAsync';
|
||||
import { Sheet } from '@/components/Sheet';
|
||||
import { EmptyState, ErrorState, LoadingState, PrimaryButton, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { IslandPath } from '@/components/IslandPath';
|
||||
import { IslandSheet } from '@/components/IslandSheet';
|
||||
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
|
||||
import { toFa } from '@/lib/format';
|
||||
|
||||
const NODE_SIZE = 76;
|
||||
|
||||
/** نقشهی جزیرههای یک فصل، مثلِ مسیرِ دولینگو. */
|
||||
export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
const { home, homeError, refreshHome, companion } = useAppData();
|
||||
const nav = useNav();
|
||||
const [openNode, setOpenNode] = useState<HomeNode | null>(null);
|
||||
const found = findUnit(home, unitId);
|
||||
|
||||
@@ -32,11 +28,10 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
const { unit, unitIndex } = found;
|
||||
const color = unitColor(unitIndex);
|
||||
const progress = unitProgress(unit);
|
||||
const points = zigzagPositions(unit.nodes.length, 125, 90);
|
||||
const height = points.length ? points[points.length - 1].y + 150 : 400;
|
||||
const current = unit.nodes.find((n) => n.status !== 'completed' && n.status !== 'locked') ?? null;
|
||||
|
||||
return (
|
||||
<Screen className="bg-[#f7f8ff]">
|
||||
<Screen className="bg-page">
|
||||
<ScreenHeader
|
||||
title={unit.title}
|
||||
subtitle={unit.blurb || `${toFa(progress.done)} از ${toFa(progress.total)} جزیره`}
|
||||
@@ -53,160 +48,29 @@ export function UnitScreen({ unitId }: { unitId: string }) {
|
||||
</div>
|
||||
</ScreenHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto pb-6 pt-10">
|
||||
{unit.nodes.length === 0 ? (
|
||||
<EmptyState text="این فصل هنوز جزیرهای نداره" />
|
||||
) : (
|
||||
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
|
||||
<ZigzagPath points={points} height={height} colorOf={(i) => (unit.nodes[i].status === 'completed' ? color : null)} />
|
||||
{unit.nodes.map((node, i) => {
|
||||
const locked = node.status === 'locked';
|
||||
const done = node.status === 'completed';
|
||||
const current = !locked && !done;
|
||||
const Icon = iconFor(node.icon, i);
|
||||
return (
|
||||
<motion.div
|
||||
key={node.id}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: i * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ left: `${points[i].x}%`, top: points[i].y }}
|
||||
>
|
||||
{current && (
|
||||
<>
|
||||
<div
|
||||
className="absolute -top-14 left-1/2 z-20 -translate-x-1/2 whitespace-nowrap rounded-2xl border-2 bg-white px-3 py-1.5 text-[11px] font-black shadow-lg"
|
||||
style={{ color, borderColor: color }}
|
||||
>
|
||||
{node.title}
|
||||
<span className="absolute -bottom-2 left-1/2 h-3 w-3 -translate-x-1/2 rotate-45 border-b-2 border-r-2 bg-white" style={{ borderColor: color }} />
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.18, 1], opacity: [0.25, 0.05, 0.25] }}
|
||||
transition={{ duration: 1.8, repeat: Infinity }}
|
||||
className="absolute inset-[-13px] rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${node.title}${locked ? ' (قفل)' : done ? ' (تمامشده)' : ''}`}
|
||||
onClick={() => setOpenNode(node)}
|
||||
className="relative z-10 flex items-center justify-center rounded-full transition-transform active:translate-y-1"
|
||||
style={{
|
||||
width: NODE_SIZE,
|
||||
height: NODE_SIZE,
|
||||
backgroundColor: locked ? '#e5e4ec' : done ? color : '#fff',
|
||||
border: current ? `4px solid ${color}` : 'none',
|
||||
boxShadow: locked ? '0 6px 0 #cccbd6' : done ? `0 7px 0 ${color}99` : `0 7px 0 ${color}55, 0 0 0 5px white`,
|
||||
}}
|
||||
>
|
||||
{locked ? (
|
||||
<Lock size={24} strokeWidth={2.5} className="text-[#aaa9b8]" />
|
||||
) : done ? (
|
||||
<Check size={31} color="white" strokeWidth={3.5} />
|
||||
) : (
|
||||
<Icon size={28} style={{ color }} strokeWidth={2.5} />
|
||||
)}
|
||||
</button>
|
||||
{!current && (
|
||||
<div className="absolute left-1/2 top-full mt-2.5 w-28 -translate-x-1/2 truncate rounded-lg bg-[#f7f8ff]/90 text-center text-[10px] font-bold text-muted">
|
||||
{node.title}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 70 }}>
|
||||
<span className="mb-1 text-[10px] font-bold text-[#aaa9bd]">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
||||
<>
|
||||
<IslandPath
|
||||
nodes={unit.nodes}
|
||||
color={color}
|
||||
currentId={current?.id ?? null}
|
||||
onOpen={setOpenNode}
|
||||
onStart={(node) => nav.push({ name: 'quiz', nodeId: node.id })}
|
||||
/>
|
||||
<div className="mt-6 flex flex-col items-center">
|
||||
<span className="mb-1 text-[11px] font-bold text-muted">{progress.done === progress.total ? 'این فصل تموم شد!' : 'ادامه بده!'}</span>
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl" style={{ backgroundColor: `${color}18` }}>
|
||||
<Sparkles size={20} style={{ color }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NodeSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
|
||||
<IslandSheet node={openNode} color={color} onClose={() => setOpenNode(null)} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
/** برگهی جزیره: درسهایش، و دکمهی شروعِ درسِ بعدی. */
|
||||
function NodeSheet({ node, color, onClose }: { node: HomeNode | null; color: string; onClose: () => void }) {
|
||||
const nav = useNav();
|
||||
const lessons = useAsync(() => (node ? api.nodeLessons(node.id) : Promise.resolve(null)), [node?.id]);
|
||||
const next = node ? nextLessonOf(node) : null;
|
||||
const locked = node?.status === 'locked';
|
||||
|
||||
const open = (lessonId: number) => {
|
||||
if (!node) return;
|
||||
onClose();
|
||||
nav.push({ name: 'lesson', lessonId, nodeId: node.id });
|
||||
};
|
||||
|
||||
// فقط درسهایی که سوال دارند در lessonIds هستند؛ بقیه هنوز آزمون ندارند.
|
||||
const playable = new Set(node?.lessonIds ?? []);
|
||||
const list = lessons.data?.lessons.filter((l) => playable.has(Number(l.id))) ?? [];
|
||||
const doneCount = list.filter((l) => l.done).length;
|
||||
|
||||
return (
|
||||
<Sheet open={node !== null} onClose={onClose} title={node?.title}>
|
||||
{locked ? (
|
||||
<div className="flex flex-col items-center gap-3 py-4 text-center">
|
||||
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-soft">
|
||||
<Lock size={24} className="text-muted" />
|
||||
</span>
|
||||
<p className="text-sm leading-7 text-muted">این جزیره هنوز قفله. جزیرهی قبلی رو تموم کن تا باز بشه.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{lessons.loading ? (
|
||||
<div className="py-6 text-center text-sm text-muted">دارم درسها رو میارم…</div>
|
||||
) : lessons.error ? (
|
||||
<ErrorState error={lessons.error} onRetry={lessons.reload} />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
{toFa(doneCount)} از {toFa(list.length)} درس تموم شده
|
||||
</p>
|
||||
<ul className="mb-5 flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||
{list.map((lesson) => {
|
||||
const isNext = Number(lesson.id) === next;
|
||||
return (
|
||||
<li key={lesson.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open(Number(lesson.id))}
|
||||
className={`flex w-full items-center gap-3 rounded-2xl border px-3 py-2.5 text-right text-sm ${
|
||||
isNext ? 'border-2 bg-white font-bold' : 'border-line bg-white'
|
||||
}`}
|
||||
style={isNext ? { borderColor: color } : undefined}
|
||||
>
|
||||
{lesson.done ? (
|
||||
<CheckCircle2 size={18} className="flex-shrink-0 text-mint" />
|
||||
) : isNext ? (
|
||||
<Play size={16} className="flex-shrink-0" style={{ color }} fill={color} />
|
||||
) : (
|
||||
<Circle size={18} className="flex-shrink-0 text-faint" />
|
||||
)}
|
||||
<span className={`flex-1 ${lesson.done ? 'text-muted' : 'text-ink'}`}>{lesson.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{next != null && (
|
||||
<PrimaryButton color={color} onClick={() => open(next)}>
|
||||
<Play size={18} fill="white" /> {node?.status === 'completed' ? 'مرور دوباره' : 'شروعِ درسِ بعدی'}
|
||||
</PrimaryButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user