mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
779 lines
42 KiB
React
779 lines
42 KiB
React
/*
|
||
* ViewRequirement.jsx
|
||
* Route: /group/:groupId/view/:taskListId/task/:taskId/requirement
|
||
*
|
||
* Sidebar: sectioned flat list — one plain heading per requirement type,
|
||
* each requirement is a clickable button. When selected, its lesson
|
||
* sub-tree renders inline below the item (no accordion, no scoping).
|
||
*
|
||
* read_course → CourseUnitLessonView (on-demand lesson fetch)
|
||
* read_unit → LessonBlock (lessons from unitLessonsMap)
|
||
* read_lesson → LessonView (standalone fetch by uuid)
|
||
*/
|
||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||
import AppBreadcrumb from '@/components/generic/Breadcrumb/AppBreadcrumb';
|
||
import {
|
||
House, TableOfContents, CheckCheck, Circle,
|
||
Layers, ArrowLeft, Lock, Zap, RefreshCw, Tag,
|
||
ClipboardList, GraduationCap, CheckCircle2,
|
||
} from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
|
||
import { useTask } from '@/contexts/ClientTaskContext';
|
||
import { PageMeta } from '@/contexts/MetadataContext';
|
||
import { useTaskProgress } from '@/contexts/ClientTaskProgressContext';
|
||
|
||
import LessonBlock from '@/modules/client/components/LessonBlock';
|
||
import api from '@/utils/api.util';
|
||
import { useClientTiers } from '@/contexts/ClientTiersProvider';
|
||
import { resolveTierBadge } from '@/utils/tierBadge.util';
|
||
|
||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||
const TYPE_LABEL = {
|
||
read_course: 'Read a Course',
|
||
read_unit: 'Read a Unit',
|
||
read_lesson: 'Read a Lesson',
|
||
};
|
||
|
||
// ─── Compact tier badge ───────────────────────────────────────────────────────
|
||
const TierBadge = ({ tier }) => {
|
||
const { tierMap } = useClientTiers();
|
||
if (!tier) return null;
|
||
const { label, cls } = resolveTierBadge(tier, tierMap);
|
||
return (
|
||
<Badge className={`${cls} text-[10px] px-1.5 py-0 h-[18px] leading-none shrink-0`}>
|
||
{label}
|
||
</Badge>
|
||
);
|
||
};
|
||
|
||
// ─── Sidebar ──────────────────────────────────────────────────────────────────
|
||
const SidebarContent = ({
|
||
requirements,
|
||
selection,
|
||
onSelectReq,
|
||
onSelectLesson,
|
||
isCompleted,
|
||
unitLessonsMap,
|
||
unitLoadingMap,
|
||
courseUnitsMap,
|
||
courseLoadingMap,
|
||
referenceMetaMap,
|
||
onNavigateToCourse,
|
||
}) => {
|
||
const groups = ['read_course', 'read_unit', 'read_lesson']
|
||
.map((type) => ({ type, items: requirements.filter((r) => r.type === type) }))
|
||
.filter((g) => g.items.length > 0);
|
||
|
||
return (
|
||
<ScrollArea className="h-full">
|
||
<div className="p-4 space-y-6">
|
||
<div className="flex items-center justify-between gap-2 pr-10 lg:pr-0">
|
||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground shrink-0">
|
||
Requirements
|
||
</p>
|
||
{groups.length === 1 && (
|
||
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60 bg-muted px-2 py-0.5 rounded-full shrink-0">
|
||
{TYPE_LABEL[groups[0].type]}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{groups.map(({ type, items }) => (
|
||
<div key={type} className="space-y-1">
|
||
{/* Section heading — hidden when only one type is visible (entry-scoped) */}
|
||
{groups.length > 1 && (
|
||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground px-1 mb-2">
|
||
{TYPE_LABEL[type]}
|
||
</p>
|
||
)}
|
||
|
||
{items.map((req) => {
|
||
const isSelected = selection?.reqId === req.requirement_id;
|
||
const done = isCompleted(req.requirement_id, req.reference_id);
|
||
const meta = referenceMetaMap[req.requirement_id];
|
||
|
||
return (
|
||
<div key={req.requirement_id}>
|
||
{/* Requirement button */}
|
||
<button
|
||
onClick={() => onSelectReq(req)}
|
||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-left ${
|
||
isSelected
|
||
? 'bg-muted text-foreground'
|
||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
||
}`}
|
||
>
|
||
{done
|
||
? <CheckCheck className="size-3.5 text-green-500 shrink-0" />
|
||
: <Circle className="size-3.5 shrink-0 opacity-40" />
|
||
}
|
||
<span className="truncate flex-1 font-medium min-w-0">
|
||
{req.reference_label ?? req.type}
|
||
</span>
|
||
<TierBadge tier={meta?.subscription} />
|
||
</button>
|
||
|
||
{/* Breadcrumb — always visible below the button */}
|
||
{type === 'read_unit' && meta?.courseTitle && (
|
||
<p className="pl-9 text-[11px] text-muted-foreground truncate -mt-0.5 mb-1">
|
||
from <span className="font-medium">{meta.courseTitle}</span>
|
||
</p>
|
||
)}
|
||
{type === 'read_lesson' && (meta?.unitTitle || meta?.courseTitle) && (
|
||
<p className="pl-9 text-[11px] text-muted-foreground truncate -mt-0.5 mb-1">
|
||
{[meta.unitTitle, meta.courseTitle].filter(Boolean).join(' › ')}
|
||
</p>
|
||
)}
|
||
|
||
{/* Sub-tree — only for the selected item */}
|
||
{isSelected && type === 'read_unit' && (() => {
|
||
const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
|
||
const loading = unitLoadingMap[req.requirement_id];
|
||
return (
|
||
<div className="pl-5 mt-1 space-y-0.5 border-l ml-4 mb-2">
|
||
{loading && (
|
||
<div className="py-1 space-y-1.5">
|
||
<Skeleton className="h-3 w-32" />
|
||
<Skeleton className="h-3 w-24" />
|
||
</div>
|
||
)}
|
||
{lessons.map((lesson, i) => {
|
||
const active = selection?.lessonUuid === lesson.uuid;
|
||
return (
|
||
<button
|
||
key={lesson.uuid}
|
||
onClick={() => onSelectLesson(req, lesson)}
|
||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
|
||
active
|
||
? 'bg-primary/10 text-primary font-medium'
|
||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||
}`}
|
||
>
|
||
<span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{i + 1}</span>
|
||
<span className="truncate">{lesson.title}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{isSelected && type === 'read_course' && (() => {
|
||
const courseData = courseUnitsMap[req.requirement_id] ?? {};
|
||
const units = courseData.units ?? [];
|
||
const assessment = courseData.assessment ?? null;
|
||
const courseId = courseData.course_id;
|
||
const loading = courseLoadingMap[req.requirement_id];
|
||
return (
|
||
<div className="pl-5 mt-1 space-y-3 border-l ml-4 mb-2">
|
||
{loading && (
|
||
<div className="py-1 space-y-1.5">
|
||
<Skeleton className="h-3 w-32" />
|
||
<Skeleton className="h-3 w-24" />
|
||
</div>
|
||
)}
|
||
{units.map((unit) => (
|
||
<div key={unit.unit_id} className="space-y-0.5">
|
||
<p className="flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-semibold text-muted-foreground uppercase tracking-wide truncate">
|
||
<Layers className="size-3 shrink-0 opacity-50" />
|
||
{unit.title}
|
||
</p>
|
||
{(unit.lessons ?? []).map((lesson, lIdx) => {
|
||
const active = selection?.lessonUuid === lesson.uuid;
|
||
return (
|
||
<button
|
||
key={lesson.uuid}
|
||
onClick={() => onSelectLesson(req, lesson)}
|
||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors ${
|
||
active
|
||
? 'bg-primary/10 text-primary font-medium'
|
||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||
}`}
|
||
>
|
||
<span className="text-xs tabular-nums w-4 shrink-0 opacity-40">{lIdx + 1}</span>
|
||
<span className="truncate">{lesson.title}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{unit.quiz && (
|
||
<button
|
||
onClick={() => onNavigateToCourse(courseId, { quizUnitId: String(unit.unit_id) })}
|
||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
|
||
>
|
||
<ClipboardList className="size-3.5 shrink-0 opacity-50" />
|
||
<span className="truncate flex-1">{unit.quiz.title || 'Quiz'}</span>
|
||
{unit.quiz.has_passed
|
||
? <CheckCircle2 className="size-3 text-green-500 shrink-0" />
|
||
: <Circle className="size-3 shrink-0 opacity-30" />
|
||
}
|
||
</button>
|
||
)}
|
||
</div>
|
||
))}
|
||
{assessment && (
|
||
<button
|
||
onClick={() => onNavigateToCourse(courseId, { seekAssessment: true })}
|
||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-left transition-colors text-muted-foreground hover:bg-muted hover:text-foreground border-t pt-2 mt-1"
|
||
>
|
||
<GraduationCap className="size-3.5 shrink-0 opacity-50" />
|
||
<span className="truncate flex-1">{assessment.title || 'Final Assessment'}</span>
|
||
{assessment.has_passed
|
||
? <CheckCircle2 className="size-3 text-green-500 shrink-0" />
|
||
: <Circle className="size-3 shrink-0 opacity-30" />
|
||
}
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
);
|
||
};
|
||
|
||
// ─── Course overview (before first lesson selected) ───────────────────────────
|
||
const CourseOverview = ({ meta }) => {
|
||
const { tierMap } = useClientTiers();
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{meta?.subscription && (() => {
|
||
const { rank, label, cls } = resolveTierBadge(meta.subscription, tierMap);
|
||
return (
|
||
<Badge className={`${cls} capitalize`}>
|
||
{rank > 0 ? <Lock className="size-3 mr-1" /> : <Tag className="size-3 mr-1" />}
|
||
{label}
|
||
</Badge>
|
||
);
|
||
})()}
|
||
{meta?.level && <Badge variant="outline" className="text-xs capitalize">{meta.level}</Badge>}
|
||
</div>
|
||
<h1 className="text-2xl font-bold">{meta?.title ?? 'Course'}</h1>
|
||
{meta?.description && <p className="text-sm text-muted-foreground leading-relaxed">{meta.description}</p>}
|
||
<p className="text-sm text-blue-500 dark:text-blue-400 mt-2">Select a lesson from the sidebar to begin reading.</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ─── On-demand lesson fetch for read_course ───────────────────────────────────
|
||
const CourseUnitLessonView = ({ lessonUuid }) => {
|
||
const [lesson, setLesson] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
if (!lessonUuid) return;
|
||
setLoading(true);
|
||
setLesson(null);
|
||
api.get(`/client/courses/lesson/uuid/${lessonUuid}`)
|
||
.then((r) => {
|
||
const d = r.data?.data;
|
||
if (d) setLesson({ ...d, blocks: d.blocks ?? [] });
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => setLoading(false));
|
||
}, [lessonUuid]);
|
||
|
||
if (loading) return <ContentSkeleton />;
|
||
return <LessonBlock lesson={lesson} loading={false} />;
|
||
};
|
||
|
||
// ─── Standalone lesson view (read_lesson) ─────────────────────────────────────
|
||
const LessonView = ({ req, onMeta }) => {
|
||
const [lesson, setLesson] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [locked, setLocked] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!req.reference_id) { setLoading(false); return; }
|
||
api.get(`/client/courses/lesson/uuid/${req.reference_id}`)
|
||
.then((r) => {
|
||
const d = r.data?.data;
|
||
if (d) {
|
||
setLesson({ ...d, blocks: d.blocks ?? [] });
|
||
onMeta?.(req.requirement_id, {
|
||
subscription: d.unit?.course?.subscription,
|
||
courseTitle: d.unit?.course?.title,
|
||
unitTitle: d.unit?.title,
|
||
});
|
||
}
|
||
})
|
||
.catch((err) => { if (err?.response?.status === 403) setLocked(true); })
|
||
.finally(() => setLoading(false));
|
||
}, [req.reference_id]);
|
||
|
||
if (loading) return <ContentSkeleton />;
|
||
if (locked) return <LockedContent />;
|
||
return <LessonBlock lesson={lesson} loading={false} />;
|
||
};
|
||
|
||
// ─── Skeletons / locked ───────────────────────────────────────────────────────
|
||
const ContentSkeleton = () => (
|
||
<div className="flex flex-col gap-3">
|
||
<Skeleton className="h-5 w-32" />
|
||
<Skeleton className="h-7 w-64" />
|
||
<Skeleton className="h-4 w-full" />
|
||
<Skeleton className="h-4 w-3/4" />
|
||
</div>
|
||
);
|
||
|
||
const LockedContent = () => {
|
||
const navigate = useNavigate();
|
||
return (
|
||
<div className="flex flex-col items-center gap-6 py-20 text-center">
|
||
<div className="h-16 w-16 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||
<Lock className="size-7 text-amber-500" />
|
||
</div>
|
||
<div className="space-y-2 max-w-sm">
|
||
<h2 className="text-lg font-semibold">Premium / Exclusive Content</h2>
|
||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||
Subscribe to one of our available tier plans to unlock access to this content.
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col items-center gap-2">
|
||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
||
<Zap className="size-4" /> View Available Plans
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||
const ViewRequirement = () => {
|
||
const { groupId, taskListId, taskId } = useParams();
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
|
||
const { task, taskList, loading, fetchTask, fetchTaskList } = useTask();
|
||
const { fetchProgress, isCompleted, updateLessonProgress, loading: progressLoading } = useTaskProgress();
|
||
|
||
const [selection, setSelection] = useState(null);
|
||
const [unitLessonsMap, setUnitLessonsMap] = useState({});
|
||
const [unitLoadingMap, setUnitLoadingMap] = useState({});
|
||
const [courseUnitsMap, setCourseUnitsMap] = useState({});
|
||
const [courseLoadingMap, setCourseLoadingMap] = useState({});
|
||
const [referenceMetaMap, setReferenceMetaMap] = useState({});
|
||
const [lockedReqs, setLockedReqs] = useState(new Set());
|
||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||
const [desktopOpen, setDesktopOpen] = useState(true);
|
||
const [scrollPct, setScrollPct] = useState(0);
|
||
const initialised = useRef(false);
|
||
const lastAutoMarkRef = useRef(null);
|
||
|
||
// ── Fetch task + progress ─────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
fetchTaskList(groupId, taskListId);
|
||
fetchTask(groupId, taskListId, taskId);
|
||
fetchProgress(groupId, taskListId, taskId);
|
||
}, [groupId, taskListId, taskId]);
|
||
|
||
// ── read_* requirements only ─────────────────────────────────────────────
|
||
const requirements = (task?.requirements ?? []).filter((r) =>
|
||
['read_course', 'read_unit', 'read_lesson'].includes(r.type)
|
||
);
|
||
|
||
// ── Scope sidebar to the type that was clicked in ViewTaskDetails ─────────
|
||
const entryType = useMemo(() => {
|
||
const s = location.state ?? {};
|
||
if (s.course) return 'read_course';
|
||
if (s.unit) return 'read_unit';
|
||
if (s.lesson) return 'read_lesson';
|
||
return null;
|
||
}, [location.state]);
|
||
|
||
const sidebarRequirements = entryType
|
||
? requirements.filter((r) => r.type === entryType)
|
||
: requirements;
|
||
|
||
// ── Fetch lessons for read_unit requirements ──────────────────────────────
|
||
useEffect(() => {
|
||
requirements.forEach((req) => {
|
||
if (req.type !== 'read_unit') return;
|
||
if (unitLessonsMap[req.requirement_id] || unitLoadingMap[req.requirement_id]) return;
|
||
setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: true }));
|
||
api.get(`/client/courses/unit/uuid/${req.reference_id}/lessons`)
|
||
.then((r) => {
|
||
const data = r.data?.data ?? null;
|
||
setUnitLessonsMap((p) => ({ ...p, [req.requirement_id]: { meta: data, lessons: data?.lessons ?? [] } }));
|
||
})
|
||
.catch((err) => {
|
||
if (err?.response?.status === 403)
|
||
setLockedReqs((p) => new Set(p).add(req.requirement_id));
|
||
})
|
||
.finally(() => setUnitLoadingMap((p) => ({ ...p, [req.requirement_id]: false })));
|
||
});
|
||
}, [requirements.length]);
|
||
|
||
// ── Fetch unit + lesson tree for read_course requirements ─────────────────
|
||
useEffect(() => {
|
||
requirements.forEach(async (req) => {
|
||
if (req.type !== 'read_course') return;
|
||
if (courseUnitsMap[req.requirement_id] || courseLoadingMap[req.requirement_id]) return;
|
||
setCourseLoadingMap((p) => ({ ...p, [req.requirement_id]: true }));
|
||
try {
|
||
const uuidRes = await api.get(`/client/courses/uuid/${req.reference_id}`);
|
||
const meta = uuidRes.data?.data;
|
||
if (!meta?.course_id) return;
|
||
const fullRes = await api.get(`/client/courses/${meta.course_id}`);
|
||
const full = fullRes.data?.data;
|
||
setCourseUnitsMap((p) => ({
|
||
...p,
|
||
[req.requirement_id]: {
|
||
meta: { ...meta, description: full?.description ?? meta.description },
|
||
units: full?.units ?? [],
|
||
assessment: full?.assessment ?? null,
|
||
course_id: meta.course_id,
|
||
},
|
||
}));
|
||
setReferenceMetaMap((p) => ({ ...p, [req.requirement_id]: { subscription: meta.subscription } }));
|
||
} catch (err) {
|
||
if (err?.response?.status === 403)
|
||
setLockedReqs((p) => new Set(p).add(req.requirement_id));
|
||
} finally {
|
||
setCourseLoadingMap((p) => ({ ...p, [req.requirement_id]: false }));
|
||
}
|
||
});
|
||
}, [requirements.length]);
|
||
|
||
// ── Tier meta for read_unit from unitLessonsMap ───────────────────────────
|
||
useEffect(() => {
|
||
requirements.forEach((req) => {
|
||
if (req.type !== 'read_unit') return;
|
||
const data = unitLessonsMap[req.requirement_id];
|
||
if (!data?.meta?.course || referenceMetaMap[req.requirement_id]) return;
|
||
setReferenceMetaMap((p) => ({
|
||
...p,
|
||
[req.requirement_id]: {
|
||
subscription: data.meta.course.subscription,
|
||
courseTitle: data.meta.course.title,
|
||
},
|
||
}));
|
||
});
|
||
}, [unitLessonsMap]);
|
||
|
||
// ── Tier meta for read_lesson (separate fetch) ────────────────────────────
|
||
useEffect(() => {
|
||
requirements.forEach(async (req) => {
|
||
if (req.type !== 'read_lesson' || referenceMetaMap[req.requirement_id]) return;
|
||
try {
|
||
const res = await api.get(`/client/courses/lesson/uuid/${req.reference_id}`);
|
||
const d = res.data?.data;
|
||
if (d) setReferenceMetaMap((p) => ({
|
||
...p,
|
||
[req.requirement_id]: {
|
||
subscription: d.unit?.course?.subscription,
|
||
courseTitle: d.unit?.course?.title,
|
||
unitTitle: d.unit?.title,
|
||
},
|
||
}));
|
||
} catch { /* silently fail */ }
|
||
});
|
||
}, [requirements.length]);
|
||
|
||
// ── Auto-select from router state or first item ───────────────────────────
|
||
useEffect(() => {
|
||
if (initialised.current || !requirements.length) return;
|
||
|
||
const state = location.state ?? {};
|
||
|
||
const trySelect = (key, type) => {
|
||
if (!state[key]?.id) return false;
|
||
const req = requirements.find((r) => r.requirement_id === state[key].id);
|
||
if (!req) return false;
|
||
const needsLesson = type === 'read_unit' || type === 'read_course';
|
||
setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
|
||
return true;
|
||
};
|
||
|
||
if (!trySelect('course', 'read_course') && !trySelect('unit', 'read_unit') && !trySelect('lesson', 'read_lesson')) {
|
||
const first = requirements[0];
|
||
const needsLesson = first.type === 'read_unit' || first.type === 'read_course';
|
||
setSelection({ reqId: first.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
|
||
}
|
||
|
||
initialised.current = true;
|
||
}, [requirements, location.state]);
|
||
|
||
// ── Auto-pick first lesson once unit/course lessons load ─────────────────
|
||
useEffect(() => {
|
||
if (!selection || selection.lessonUuid !== null) return;
|
||
const req = requirements.find((r) => r.requirement_id === selection.reqId);
|
||
if (req?.type === 'read_unit') {
|
||
const lessons = unitLessonsMap[req.requirement_id]?.lessons ?? [];
|
||
if (lessons.length) setSelection((p) => ({ ...p, lessonUuid: lessons[0].uuid }));
|
||
}
|
||
if (req?.type === 'read_course') {
|
||
const units = courseUnitsMap[req.requirement_id]?.units ?? [];
|
||
const first = units.flatMap((u) => u.lessons ?? [])[0];
|
||
if (first) setSelection((p) => ({ ...p, lessonUuid: first.uuid }));
|
||
}
|
||
}, [unitLessonsMap, courseUnitsMap, selection?.reqId]);
|
||
|
||
// ── Derived objects ───────────────────────────────────────────────────────
|
||
const selectedReq = requirements.find((r) => r.requirement_id === selection?.reqId) ?? null;
|
||
|
||
const selectedLesson = (() => {
|
||
if (!selectedReq || !selection?.lessonUuid) return null;
|
||
if (selectedReq.type === 'read_unit') {
|
||
return (unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [])
|
||
.find((l) => l.uuid === selection.lessonUuid) ?? null;
|
||
}
|
||
if (selectedReq.type === 'read_course') {
|
||
const units = courseUnitsMap[selectedReq.requirement_id]?.units ?? [];
|
||
return units.flatMap((u) => u.lessons ?? []).find((l) => l.uuid === selection.lessonUuid) ?? null;
|
||
}
|
||
return null;
|
||
})();
|
||
|
||
// ── Next lesson (cross-unit for read_course) ──────────────────────────────
|
||
const nextLesson = (() => {
|
||
if (!selectedReq) return null;
|
||
if (selectedReq.type === 'read_unit') {
|
||
const lessons = unitLessonsMap[selectedReq.requirement_id]?.lessons ?? [];
|
||
const idx = lessons.findIndex((l) => l.uuid === selection?.lessonUuid);
|
||
return idx !== -1 && idx < lessons.length - 1 ? lessons[idx + 1] : null;
|
||
}
|
||
if (selectedReq.type === 'read_course') {
|
||
const flat = (courseUnitsMap[selectedReq.requirement_id]?.units ?? []).flatMap((u) => u.lessons ?? []);
|
||
const idx = flat.findIndex((l) => l.uuid === selection?.lessonUuid);
|
||
return idx !== -1 && idx < flat.length - 1 ? flat[idx + 1] : null;
|
||
}
|
||
return null;
|
||
})();
|
||
|
||
// ── Scroll tracking ───────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
window.scrollTo(0, 0);
|
||
const h = document.documentElement.scrollHeight - window.innerHeight;
|
||
setScrollPct(h <= 40 ? 100 : 0);
|
||
}, [selection]);
|
||
|
||
useEffect(() => {
|
||
const onScroll = () => {
|
||
const h = document.documentElement.scrollHeight - window.innerHeight;
|
||
const y = window.scrollY;
|
||
if (h <= 0 || h - y <= 40) { setScrollPct(100); return; }
|
||
setScrollPct(Math.min(99, Math.round((y / h) * 100)));
|
||
};
|
||
window.addEventListener('scroll', onScroll, { passive: true });
|
||
return () => window.removeEventListener('scroll', onScroll);
|
||
}, []);
|
||
|
||
// ── Breadcrumbs ───────────────────────────────────────────────────────────
|
||
const crumbsFull = [
|
||
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
|
||
{ label: taskList?.name ?? '…', to: `/group/${groupId}/view/${taskListId}` },
|
||
{ label: task?.name ?? '…', to: `/group/${groupId}/view/${taskListId}/task/${taskId}` },
|
||
{ label: 'Requirements' },
|
||
];
|
||
const crumbsMobile = [
|
||
{ label: 'Home', icon: <House className="size-4" />, to: '/dashboard' },
|
||
{ label: 'Requirements' },
|
||
];
|
||
|
||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||
const handleSelectReq = useCallback((req) => {
|
||
const needsLesson = req.type === 'read_unit' || req.type === 'read_course';
|
||
setSelection({ reqId: req.requirement_id, ...(needsLesson ? { lessonUuid: null } : {}) });
|
||
setSidebarOpen(false);
|
||
}, []);
|
||
|
||
const handleSelectLesson = useCallback((req, lesson) => {
|
||
setSelection({ reqId: req.requirement_id, lessonUuid: lesson.uuid });
|
||
setSidebarOpen(false);
|
||
}, []);
|
||
|
||
const handleMarkDone = useCallback(async () => {
|
||
if (!selectedReq) return;
|
||
const completed = !isCompleted(selectedReq.requirement_id, selectedReq.reference_id);
|
||
await updateLessonProgress(groupId, taskListId, taskId, selectedReq.requirement_id, {
|
||
reference_id: selectedReq.reference_id,
|
||
completed,
|
||
siblingLessons: [],
|
||
});
|
||
}, [selectedReq, groupId, taskListId, taskId, isCompleted, updateLessonProgress]);
|
||
|
||
const selectedDone = selectedReq ? isCompleted(selectedReq.requirement_id, selectedReq.reference_id) : false;
|
||
const isLastContent = !nextLesson;
|
||
const canMarkDone = scrollPct >= 100 && isLastContent;
|
||
|
||
// ── Auto turn-in ──────────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
if (!canMarkDone || selectedDone || progressLoading || !selectedReq) return;
|
||
if (lockedReqs.has(selectedReq.requirement_id)) return;
|
||
const key = selectedReq.requirement_id + (selection?.lessonUuid ?? '');
|
||
if (lastAutoMarkRef.current === key) return;
|
||
lastAutoMarkRef.current = key;
|
||
handleMarkDone();
|
||
}, [canMarkDone, selectedDone]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const handleNavigateToCourse = useCallback((courseId, opts) => {
|
||
navigate(`/course/${courseId}/unit`, { state: opts });
|
||
}, [navigate]);
|
||
|
||
const sidebarProps = {
|
||
requirements: sidebarRequirements,
|
||
selection,
|
||
onSelectReq: handleSelectReq,
|
||
onSelectLesson: handleSelectLesson,
|
||
isCompleted,
|
||
unitLessonsMap,
|
||
unitLoadingMap,
|
||
courseUnitsMap,
|
||
courseLoadingMap,
|
||
referenceMetaMap,
|
||
onNavigateToCourse: handleNavigateToCourse,
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<PageMeta title={task ? `${task.name} – Requirements - STARR` : undefined} />
|
||
|
||
{/* ── Floating "up next" ────────────────────────────────────────── */}
|
||
{scrollPct >= 100 && nextLesson && (
|
||
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||
<div
|
||
onClick={() => handleSelectLesson(selectedReq, nextLesson)}
|
||
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
|
||
>
|
||
<div className="flex flex-col items-start">
|
||
<span className="text-xs text-muted-foreground font-normal">Up next</span>
|
||
{nextLesson.title}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Top sticky bar ────────────────────────────────────────────── */}
|
||
<div className="fixed top-[67px] left-0 right-0 z-40 bg-card border-b py-3 px-4 md:px-6">
|
||
<div className="w-full flex items-center justify-between gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="sm:hidden shrink-0"
|
||
onClick={() => navigate(`/group/${groupId}/view/${taskListId}/task/${taskId}`)}
|
||
>
|
||
<ArrowLeft className="size-4" />
|
||
</Button>
|
||
<div className="hidden sm:block min-w-0 flex-1">
|
||
<AppBreadcrumb items={crumbsFull} />
|
||
</div>
|
||
<div className="sm:hidden min-w-0 flex-1 truncate">
|
||
<AppBreadcrumb items={crumbsMobile} />
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{selectedDone && (
|
||
<span className="hidden sm:flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
|
||
<CheckCheck className="size-4" /> Done
|
||
</span>
|
||
)}
|
||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="outline" size="icon" className="lg:hidden flex-shrink-0">
|
||
<TableOfContents className="size-4" />
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side="left" className="p-0 w-80">
|
||
<SidebarContent {...sidebarProps} />
|
||
</SheetContent>
|
||
</Sheet>
|
||
<Button
|
||
variant="outline"
|
||
className="hidden lg:flex"
|
||
onClick={() => setDesktopOpen((p) => !p)}
|
||
>
|
||
<TableOfContents />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedReq && (
|
||
<div className="absolute bottom-0 left-0 right-0 h-1.5 bg-border">
|
||
<div
|
||
className={`h-full transition-all duration-150 ease-out ${
|
||
scrollPct >= 100 ? 'bg-green-500' : 'bg-primary'
|
||
}`}
|
||
style={{ width: `${scrollPct}%` }}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Desktop sidebar ───────────────────────────────────────────── */}
|
||
{desktopOpen && (
|
||
<div className="hidden lg:block fixed top-[124px] bottom-0 left-0 w-96 bg-muted/60 border-r">
|
||
<SidebarContent {...sidebarProps} />
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Main content ──────────────────────────────────────────────── */}
|
||
<div className={`mt-32 ${desktopOpen ? 'lg:ml-96' : 'lg:ml-0'} p-4 md:p-6 min-h-screen`}>
|
||
<div className="relative w-full h-full max-w-3xl mx-auto">
|
||
{loading ? (
|
||
<ContentSkeleton />
|
||
) : !requirements.length ? (
|
||
<p className="text-muted-foreground text-sm">This task has no read requirements.</p>
|
||
) : !selectedReq ? (
|
||
<p className="text-muted-foreground text-sm">Select a requirement to view.</p>
|
||
) : (
|
||
<div className="flex flex-col gap-6">
|
||
|
||
{selectedReq.type === 'read_course' && (
|
||
lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
|
||
selection?.lessonUuid ? <CourseUnitLessonView lessonUuid={selection.lessonUuid} /> :
|
||
courseLoadingMap[selectedReq.requirement_id] ? <ContentSkeleton /> :
|
||
<CourseOverview meta={courseUnitsMap[selectedReq.requirement_id]?.meta} />
|
||
)}
|
||
|
||
{selectedReq.type === 'read_unit' && (
|
||
lockedReqs.has(selectedReq.requirement_id) ? <LockedContent /> :
|
||
selectedLesson ? <LessonBlock lesson={selectedLesson} loading={false} /> :
|
||
<ContentSkeleton />
|
||
)}
|
||
|
||
{selectedReq.type === 'read_lesson' && (
|
||
<LessonView
|
||
req={selectedReq}
|
||
onMeta={(reqId, meta) =>
|
||
setReferenceMetaMap((p) => ({ ...p, [reqId]: meta }))
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{/* Turn-in footer — only shown while not yet completed */}
|
||
{!lockedReqs.has(selectedReq.requirement_id) && !selectedDone && (
|
||
<div className="flex items-center pt-4 border-t">
|
||
{nextLesson ? (
|
||
<p className="text-sm text-muted-foreground">Continue reading all lessons to complete this requirement.</p>
|
||
) : !canMarkDone ? (
|
||
<p className="text-sm text-muted-foreground">Scroll to the end to complete this requirement.</p>
|
||
) : (
|
||
<span className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||
<RefreshCw className="size-3.5 animate-spin" /> Turning in…
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default ViewRequirement;
|