774 lines
29 KiB
TypeScript
774 lines
29 KiB
TypeScript
import { useAuthStore } from "../../stores/authStore";
|
|
import { useEffect, useState, useMemo } from "react";
|
|
import { api } from "../../utils/api";
|
|
import { type Lesson, type LessonMetadata } from "../../types/lesson";
|
|
import { LessonModal } from "../../components/LessonModal";
|
|
import {
|
|
BookOpen,
|
|
Calculator,
|
|
Video,
|
|
ChevronRight,
|
|
Search,
|
|
X,
|
|
Play,
|
|
} from "lucide-react";
|
|
import { truncate } from "../../lib/utils";
|
|
import { EBRW_LESSONS, MATH_LESSONS } from "../../utils/constants";
|
|
import { renderLessonIcon } from "../../components/RenderLessonIcon";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
type VideoSubTab = "rw" | "math";
|
|
|
|
// ─── Decorative dots ─────────────────────────────────────────────────────────
|
|
const DOTS = [
|
|
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
|
|
{ size: 7, color: "#a855f7", top: "25%", left: "2%", delay: "1.2s" },
|
|
{ size: 9, color: "#22c55e", top: "60%", left: "3%", delay: "0.6s" },
|
|
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
|
|
{ size: 7, color: "#f43f5e", top: "45%", right: "2%", delay: "0.9s" },
|
|
{ size: 9, color: "#eab308", top: "75%", right: "5%", delay: "0.4s" },
|
|
];
|
|
|
|
// ─── Styles ───────────────────────────────────────────────────────────────────
|
|
const STYLES = `
|
|
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
|
|
|
|
:root { --content-max: 1100px; }
|
|
|
|
.ls-screen {
|
|
min-height: 100vh;
|
|
background: #fffbf4;
|
|
font-family: 'Nunito', sans-serif;
|
|
position: relative;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.ls-blob { position:fixed; pointer-events:none; z-index:0; filter:blur(48px); opacity:0.35; }
|
|
.ls-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:lsWobble1 14s ease-in-out infinite; }
|
|
.ls-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:lsWobble2 16s ease-in-out infinite; }
|
|
.ls-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:lsWobble1 18s ease-in-out infinite reverse; }
|
|
.ls-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:lsWobble2 12s ease-in-out infinite; }
|
|
|
|
@keyframes lsWobble1 {
|
|
0%,100%{ border-radius:60% 40% 70% 30%/50% 60% 40% 50%; transform:translate(0,0) rotate(0deg); }
|
|
50% { border-radius:40% 60% 30% 70%/60% 40% 60% 40%; transform:translate(12px,16px) rotate(8deg); }
|
|
}
|
|
@keyframes lsWobble2 {
|
|
0%,100%{ border-radius:40% 60% 30% 70%/60% 40% 60% 40%; transform:translate(0,0) rotate(0deg); }
|
|
50% { border-radius:60% 40% 70% 30%/40% 60% 40% 60%; transform:translate(-10px,12px) rotate(-6deg); }
|
|
}
|
|
|
|
.ls-dot { position:fixed; border-radius:50%; pointer-events:none; z-index:0; opacity:0.3; animation:lsFloat 7s ease-in-out infinite; }
|
|
@keyframes lsFloat {
|
|
0%,100%{ transform:translateY(0) rotate(0deg); }
|
|
50% { transform:translateY(-12px) rotate(180deg); }
|
|
}
|
|
|
|
.ls-inner {
|
|
position:relative; z-index:1;
|
|
max-width:680px; margin:0 auto;
|
|
padding:2rem 1.25rem 6rem;
|
|
display:flex; flex-direction:column; gap:1.5rem;
|
|
}
|
|
|
|
/* Desktop: wider centered layout matching rewards page */
|
|
@media (min-width: 900px) {
|
|
.ls-inner {
|
|
max-width: var(--content-max);
|
|
padding: 2rem 2rem 6rem;
|
|
|
|
}
|
|
}
|
|
|
|
@keyframes lsPopIn {
|
|
from { opacity:0; transform:scale(0.92) translateY(12px); }
|
|
to { opacity:1; transform:scale(1) translateY(0); }
|
|
}
|
|
.ls-anim { animation:lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
|
.ls-anim-1 { animation-delay:0.05s; }
|
|
.ls-anim-2 { animation-delay:0.1s; }
|
|
.ls-anim-3 { animation-delay:0.15s; }
|
|
|
|
.ls-header { animation:lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
|
|
.ls-title { font-size:1.8rem; font-weight:900; color:#1e1b4b; letter-spacing:-0.02em; }
|
|
.ls-sub {
|
|
font-family:'Nunito Sans',sans-serif;
|
|
font-size:0.85rem; font-weight:600; color:#9ca3af; margin-top:0.25rem;
|
|
line-height:1.5; max-width:420px;
|
|
}
|
|
|
|
/* ── Search ── */
|
|
.ls-search-wrap {
|
|
position: relative;
|
|
animation: lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) 0.08s both;
|
|
}
|
|
.ls-search-input {
|
|
width: 100%;
|
|
padding: 0.75rem 1rem 0.75rem 2.75rem;
|
|
border-radius: 16px;
|
|
border: 2.5px solid #f3f4f6;
|
|
background: white;
|
|
font-family: 'Nunito', sans-serif;
|
|
font-size: 0.875rem; font-weight: 700; color: #1e1b4b;
|
|
outline: none;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,0.04);
|
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
box-sizing: border-box;
|
|
}
|
|
.ls-search-input::placeholder { color: #c4b5fd; font-weight: 600; }
|
|
.ls-search-input:focus {
|
|
border-color: #a855f7;
|
|
box-shadow: 0 4px 20px rgba(168,85,247,0.15);
|
|
}
|
|
.ls-search-icon {
|
|
position: absolute; left: 0.875rem; top: 50%; transform: translateY(-50%);
|
|
color: #c4b5fd; pointer-events: none; transition: color 0.2s ease;
|
|
}
|
|
.ls-search-wrap:focus-within .ls-search-icon { color: #a855f7; }
|
|
.ls-search-clear {
|
|
position: absolute; right: 0.875rem; top: 50%; transform: translateY(-50%);
|
|
background: #f3f4f6; border: none; border-radius: 50%; cursor: pointer;
|
|
width: 22px; height: 22px; display: flex; align-items: center; justify-content: center;
|
|
color: #9ca3af; transition: background 0.15s ease, color 0.15s ease; padding: 0;
|
|
}
|
|
.ls-search-clear:hover { background: #f3e8ff; color: #a855f7; }
|
|
.ls-search-results-hint {
|
|
font-family: 'Nunito Sans', sans-serif;
|
|
font-size: 0.72rem; font-weight: 700; color: #9ca3af;
|
|
padding: 0 0.25rem;
|
|
display: flex; align-items: center; gap: 0.4rem;
|
|
}
|
|
.ls-search-results-hint strong { color: #a855f7; }
|
|
|
|
/* ── Tabs ── */
|
|
.ls-tabs-list { display:flex; gap:0.5rem; }
|
|
.ls-tab-btn {
|
|
display:flex; align-items:center; gap:0.5rem;
|
|
padding:0.55rem 1.1rem; border-radius:100px; cursor:pointer; border:none;
|
|
font-family:'Nunito',sans-serif; font-size:0.82rem; font-weight:800;
|
|
transition:all 0.2s ease;
|
|
background:white; border:2.5px solid #f3f4f6; color:#9ca3af;
|
|
box-shadow:0 2px 8px rgba(0,0,0,0.04);
|
|
}
|
|
.ls-tab-btn.active { background:#1e1b4b; border-color:#1e1b4b; color:white; box-shadow:0 4px 0 #1e1b4b66; }
|
|
.ls-tab-btn:not(.active):hover { border-color:#c4b5fd; color:#7c3aed; }
|
|
|
|
/* ── Video sub-tabs ── */
|
|
.ls-video-subtabs {
|
|
display: flex; gap: 0.4rem;
|
|
padding: 0.5rem;
|
|
background: white; border: 2px solid #f3f4f6; border-radius: 16px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
|
animation: lsPopIn 0.35s cubic-bezier(0.34,1.56,0.64,1) both;
|
|
}
|
|
.ls-video-subtab-btn {
|
|
flex: 1; display: flex; align-items: center; justify-content: center; gap: 0.45rem;
|
|
padding: 0.5rem 1rem; border-radius: 10px; cursor: pointer; border: none;
|
|
font-family: 'Nunito', sans-serif; font-size: 0.78rem; font-weight: 800;
|
|
transition: all 0.2s ease; color: #9ca3af; background: transparent;
|
|
}
|
|
.ls-video-subtab-btn.active { background: #fff7ed; color: #f97316; box-shadow: 0 2px 8px rgba(249,115,22,0.15); }
|
|
.ls-video-subtab-btn:not(.active):hover { background: #fafafa; color: #6b7280; }
|
|
.ls-video-subtab-dot { width: 7px; height: 7px; border-radius: 50%; }
|
|
.ls-video-subtab-btn.active .ls-video-subtab-dot { background: #f97316; }
|
|
.ls-video-subtab-btn:not(.active) .ls-video-subtab-dot { background: #d1d5db; }
|
|
|
|
/* ── Section group ── */
|
|
.ls-group { display:flex; flex-direction:column; gap:0; }
|
|
.ls-group-header {
|
|
display:flex; align-items:center; gap:0.75rem;
|
|
padding:0.6rem 1rem; margin-bottom:0;
|
|
}
|
|
.ls-group-accent { width:3px; height:1.1rem; border-radius:100px; flex-shrink:0; }
|
|
.ls-group-accent.rw { background:#a855f7; }
|
|
.ls-group-accent.math { background:#0891b2; }
|
|
.ls-group-accent.video{ background:#f97316; }
|
|
.ls-group-name {
|
|
font-family:'Nunito Sans',sans-serif;
|
|
font-size:0.68rem; font-weight:700; letter-spacing:0.14em;
|
|
text-transform:uppercase; color:#9ca3af; flex:1;
|
|
}
|
|
.ls-group-count { font-family:'Nunito',sans-serif; font-size:0.68rem; font-weight:800; color:#d1d5db; }
|
|
|
|
/* ── Lesson list card ── */
|
|
.ls-list {
|
|
background:white; border:2px solid #f3f4f6; border-radius:20px;
|
|
overflow:hidden; box-shadow:0 4px 14px rgba(0,0,0,0.04);
|
|
animation:lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
|
}
|
|
.ls-lesson-row {
|
|
display:flex; align-items:center; gap:1rem;
|
|
padding:1rem 1.1rem; cursor:pointer;
|
|
transition:background 0.15s ease; position:relative;
|
|
}
|
|
.ls-lesson-row:not(:last-child)::after {
|
|
content:''; position:absolute; bottom:0; left:1.1rem; right:1.1rem;
|
|
height:1px; background:#f3f4f6;
|
|
}
|
|
.ls-lesson-row:hover { background:#fafafa; }
|
|
.ls-lesson-row:active { background:#f5f3ff; }
|
|
.ls-row-num {
|
|
font-family:'Nunito',sans-serif; font-size:0.7rem; font-weight:900; color:#d1d5db;
|
|
width:1.4rem; text-align:center; flex-shrink:0; letter-spacing:0.04em;
|
|
}
|
|
.ls-row-icon {
|
|
width:36px; height:36px; border-radius:10px; flex-shrink:0;
|
|
display:flex; align-items:center; justify-content:center;
|
|
}
|
|
.ls-row-icon.rw { background:#f3e8ff; color:#a855f7; }
|
|
.ls-row-icon.math { background:#e0f2fe; color:#0891b2; }
|
|
.ls-row-icon.video { background:#fff7ed; color:#f97316; }
|
|
.ls-row-body { flex:1; min-width:0; }
|
|
.ls-row-title {
|
|
font-size:0.9rem; font-weight:800; color:#1e1b4b;
|
|
line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
}
|
|
.ls-row-desc {
|
|
font-family:'Nunito Sans',sans-serif;
|
|
font-size:0.72rem; font-weight:600; color:#9ca3af;
|
|
margin-top:0.15rem; line-height:1.4;
|
|
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
|
|
}
|
|
.ls-status {
|
|
display:flex; align-items:center; gap:0.3rem;
|
|
padding:0.25rem 0.6rem; border-radius:100px; flex-shrink:0;
|
|
font-family:'Nunito',sans-serif; font-size:0.65rem; font-weight:800;
|
|
letter-spacing:0.06em; text-transform:uppercase; white-space:nowrap;
|
|
}
|
|
.ls-status.started { background:#fef9c3; color:#ca8a04; border:1.5px solid #fde047; }
|
|
.ls-status.completed { background:#dcfce7; color:#16a34a; border:1.5px solid #86efac; }
|
|
.ls-chevron { color:#d1d5db; flex-shrink:0; }
|
|
.ls-lesson-row:hover .ls-chevron { color:#a855f7; }
|
|
|
|
/* ── Search highlight ── */
|
|
.ls-highlight {
|
|
background: linear-gradient(120deg, #fde68a 0%, #fbbf24 100%);
|
|
border-radius: 3px; padding: 0 2px; color: #92400e;
|
|
}
|
|
|
|
/* ── Video Cards Grid ── */
|
|
.ls-video-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.875rem; }
|
|
@media (max-width: 480px) { .ls-video-grid { grid-template-columns: 1fr; } }
|
|
|
|
.ls-video-card {
|
|
background: white; border: 2px solid #f3f4f6; border-radius: 16px;
|
|
overflow: hidden; cursor: pointer;
|
|
box-shadow: 0 4px 14px rgba(0,0,0,0.05);
|
|
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
|
animation: lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both;
|
|
}
|
|
.ls-video-card:hover { transform: translateY(-3px); box-shadow: 0 8px 24px rgba(249,115,22,0.15); border-color: #fed7aa; }
|
|
.ls-video-card:active { transform: translateY(-1px); }
|
|
|
|
.ls-video-thumb {
|
|
position: relative; width: 100%; aspect-ratio: 16 / 9;
|
|
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #4c1d95 100%);
|
|
overflow: hidden;
|
|
}
|
|
.ls-video-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.3s ease; }
|
|
.ls-video-card:hover .ls-video-thumb img { transform: scale(1.04); }
|
|
.ls-video-thumb-overlay {
|
|
position: absolute; inset: 0;
|
|
background: linear-gradient(to top, rgba(30,27,75,0.7) 0%, rgba(30,27,75,0.1) 50%, transparent 100%);
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.ls-video-card:hover .ls-video-thumb-overlay { opacity: 0.85; }
|
|
.ls-video-play-btn {
|
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
|
width: 40px; height: 40px; background: rgba(255,255,255,0.95); border-radius: 50%;
|
|
display: flex; align-items: center; justify-content: center;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
|
transition: transform 0.2s ease, background 0.2s ease; color: #f97316;
|
|
}
|
|
.ls-video-card:hover .ls-video-play-btn { transform: translate(-50%, -50%) scale(1.1); background: white; }
|
|
.ls-video-thumb-fallback {
|
|
position: absolute; inset: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 60%, #4c1d95 100%);
|
|
font-size: 1.6rem; opacity: 0.7;
|
|
}
|
|
.ls-video-card-body { padding: 0.75rem 0.875rem 0.875rem; }
|
|
.ls-video-card-title {
|
|
font-size: 0.82rem; font-weight: 800; color: #1e1b4b; line-height: 1.35;
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
}
|
|
.ls-video-card-topic {
|
|
font-family: 'Nunito Sans', sans-serif;
|
|
font-size: 0.68rem; font-weight: 600; color: #a855f7;
|
|
margin-top: 0.25rem;
|
|
}
|
|
.ls-video-card-arrow {
|
|
color: #f97316; opacity: 0.5; margin-top: 0.5rem;
|
|
display: flex; justify-content: flex-end;
|
|
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
}
|
|
.ls-video-card:hover .ls-video-card-arrow { opacity: 1; transform: translateX(2px); }
|
|
|
|
/* ── Skeleton ── */
|
|
.ls-skel-list { background:white; border:2px solid #f3f4f6; border-radius:20px; overflow:hidden; box-shadow:0 4px 14px rgba(0,0,0,0.04); }
|
|
.ls-skel-row { display:flex; align-items:center; gap:1rem; padding:1rem 1.1rem; position:relative; }
|
|
.ls-skel-row:not(:last-child)::after { content:''; position:absolute; bottom:0; left:1.1rem; right:1.1rem; height:1px; background:#f3f4f6; }
|
|
.ls-skel-circle { width:36px; height:36px; border-radius:10px; flex-shrink:0; }
|
|
.ls-skel-lines { flex:1; display:flex; flex-direction:column; gap:0.4rem; }
|
|
.ls-skel-line { height:10px; border-radius:100px; }
|
|
.ls-skel-video-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.875rem; }
|
|
.ls-skel-video-card { background:white; border:2px solid #f3f4f6; border-radius:16px; overflow:hidden; }
|
|
.ls-skel-video-thumb { aspect-ratio:16/9; }
|
|
.ls-skel-video-body { padding:0.75rem; display:flex; flex-direction:column; gap:0.4rem; }
|
|
@keyframes lsSkelShimmer { 0%{ background-position:200% 0; } 100%{ background-position:-200% 0; } }
|
|
.ls-skel-circle,.ls-skel-line,.ls-skel-video-thumb,.ls-skel-video-body .ls-skel-line {
|
|
background:linear-gradient(90deg,#f3f4f6 25%,#e9eaec 50%,#f3f4f6 75%);
|
|
background-size:200% 100%; animation:lsSkelShimmer 1.4s ease-in-out infinite;
|
|
}
|
|
|
|
/* ── Empty ── */
|
|
.ls-empty {
|
|
text-align:center; padding:3rem 1rem;
|
|
background:white; border:2.5px dashed #e5e7eb; border-radius:20px;
|
|
display:flex; flex-direction:column; align-items:center; gap:0.5rem;
|
|
}
|
|
.ls-empty-emoji { font-size:2.5rem; }
|
|
.ls-empty-text { font-size:0.9rem; font-weight:700; color:#9ca3af; }
|
|
.ls-empty-sub { font-family:'Nunito Sans',sans-serif; font-size:0.78rem; color:#c4b5fd; font-weight:600; }
|
|
`;
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function highlightText(text: string, query: string): React.ReactNode {
|
|
if (!query.trim()) return text;
|
|
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
if (idx === -1) return text;
|
|
return (
|
|
<>
|
|
{text.slice(0, idx)}
|
|
<mark className="ls-highlight">
|
|
{text.slice(idx, idx + query.length)}
|
|
</mark>
|
|
{text.slice(idx + query.length)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function filterLessonMetadata(
|
|
lessons: LessonMetadata[],
|
|
query: string,
|
|
): LessonMetadata[] {
|
|
if (!query.trim()) return lessons;
|
|
const q = query.toLowerCase();
|
|
return lessons.filter(
|
|
(l) =>
|
|
l.title.toLowerCase().includes(q) ||
|
|
l.description.toLowerCase().includes(q) ||
|
|
l.category.toLowerCase().includes(q),
|
|
);
|
|
}
|
|
|
|
function filterVideoLessons(lessons: Lesson[], query: string): Lesson[] {
|
|
if (!query.trim()) return lessons;
|
|
const q = query.toLowerCase();
|
|
return lessons.filter(
|
|
(l) =>
|
|
l.title.toLowerCase().includes(q) ||
|
|
l.topic?.name?.toLowerCase().includes(q),
|
|
);
|
|
}
|
|
|
|
// ─── Skeletons ────────────────────────────────────────────────────────────────
|
|
const SkeletonGroup = () => (
|
|
<div className="ls-group">
|
|
<div className="ls-skel-list">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="ls-skel-row">
|
|
<div className="ls-skel-circle" />
|
|
<div className="ls-skel-lines">
|
|
<div
|
|
className="ls-skel-line"
|
|
style={{ width: `${60 + ((i * 11) % 30)}%` }}
|
|
/>
|
|
<div
|
|
className="ls-skel-line"
|
|
style={{ width: `${40 + ((i * 7) % 35)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const SkeletonVideoGrid = () => (
|
|
<div className="ls-skel-video-grid">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="ls-skel-video-card">
|
|
<div className="ls-skel-video-thumb" />
|
|
<div className="ls-skel-video-body">
|
|
<div className="ls-skel-line" style={{ width: "80%" }} />
|
|
<div className="ls-skel-line" style={{ width: "55%" }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// ─── Video Card ───────────────────────────────────────────────────────────────
|
|
interface VideoCardProps {
|
|
lesson: Lesson; // uses the Lesson type from API: id, title, thumbnail_url, topic
|
|
index: number;
|
|
searchQuery: string;
|
|
onClick: () => void;
|
|
}
|
|
|
|
const VideoCard = ({ lesson, index, searchQuery, onClick }: VideoCardProps) => (
|
|
<div
|
|
className="ls-video-card"
|
|
style={{ animationDelay: `${0.05 + index * 0.05}s` }}
|
|
onClick={onClick}
|
|
>
|
|
<div className="ls-video-thumb">
|
|
{lesson.thumbnail_url ? (
|
|
<>
|
|
<img src={lesson.thumbnail_url} alt={lesson.title} loading="lazy" />
|
|
<div className="ls-video-thumb-overlay" />
|
|
</>
|
|
) : (
|
|
<div className="ls-video-thumb-fallback">🎬</div>
|
|
)}
|
|
<div className="ls-video-play-btn">
|
|
<Play size={16} fill="currentColor" strokeWidth={0} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ls-video-card-body">
|
|
<p className="ls-video-card-title">
|
|
{highlightText(lesson.title, searchQuery)}
|
|
</p>
|
|
{lesson.topic?.name && (
|
|
<p className="ls-video-card-topic">
|
|
{highlightText(lesson.topic.name, searchQuery)}
|
|
</p>
|
|
)}
|
|
<div className="ls-video-card-arrow">
|
|
<ChevronRight size={14} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
export const Lessons = () => {
|
|
const user = useAuthStore((s) => s.user);
|
|
|
|
// Video lessons from API — typed as Lesson[]
|
|
const [allVideos, setAllVideos] = useState<Lesson[]>([]);
|
|
const [lessonLoading, setLessonLoading] = useState(true);
|
|
|
|
const [activeTab, setActiveTab] = useState<"rw" | "math" | "video">("rw");
|
|
const [videoSubTab, setVideoSubTab] = useState<VideoSubTab>("rw");
|
|
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
const handleLessonClick = (id: string) => {
|
|
setSelectedLessonId(id);
|
|
setIsModalOpen(true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
const fetchVideos = async () => {
|
|
if (!user) return;
|
|
try {
|
|
setLessonLoading(true);
|
|
const authStorage = localStorage.getItem("auth-storage");
|
|
if (!authStorage) return;
|
|
const {
|
|
state: { token },
|
|
} = JSON.parse(authStorage) as { state?: { token?: string } };
|
|
if (!token) return;
|
|
const response = await api.fetchLessonVideos(token);
|
|
// response matches LessonsResponse: { data: Lesson[], pagination: ... }
|
|
setAllVideos(response.data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLessonLoading(false);
|
|
}
|
|
};
|
|
fetchVideos();
|
|
}, [user]);
|
|
|
|
// Split videos by sub-tab using topic name (the only subject signal in Lesson type)
|
|
const videos = useMemo<Lesson[]>(() => {
|
|
if (videoSubTab === "math") {
|
|
return allVideos.filter((v) =>
|
|
v.topic?.name?.toLowerCase().includes("math"),
|
|
);
|
|
}
|
|
return allVideos.filter(
|
|
(v) => !v.topic?.name?.toLowerCase().includes("math"),
|
|
);
|
|
}, [allVideos, videoSubTab]);
|
|
|
|
// Count for search hint
|
|
const totalCount = useMemo(() => {
|
|
if (activeTab === "video") return videos.length;
|
|
return activeTab === "math" ? MATH_LESSONS.length : EBRW_LESSONS.length;
|
|
}, [activeTab, videos.length]);
|
|
|
|
const filteredCount = useMemo(() => {
|
|
if (activeTab === "video")
|
|
return filterVideoLessons(videos, searchQuery).length;
|
|
const base = activeTab === "math" ? MATH_LESSONS : EBRW_LESSONS;
|
|
return filterLessonMetadata(base, searchQuery).length;
|
|
}, [activeTab, videos, searchQuery]);
|
|
|
|
// ── Local lesson group renderer (LessonMetadata[]) ──
|
|
const renderLessonGroups = (rawLessons: LessonMetadata[]) => {
|
|
const lessons = filterLessonMetadata(rawLessons, searchQuery);
|
|
|
|
if (!lessons.length) {
|
|
return (
|
|
<div className="ls-empty">
|
|
<span className="ls-empty-emoji">🔍</span>
|
|
<p className="ls-empty-text">No lessons found for "{searchQuery}"</p>
|
|
<p className="ls-empty-sub">Try a different search term</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const grouped = lessons.reduce<Record<string, LessonMetadata[]>>(
|
|
(acc, lesson) => {
|
|
if (!acc[lesson.category]) acc[lesson.category] = [];
|
|
acc[lesson.category].push(lesson);
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
|
|
return Object.entries(grouped).map(([category, categoryLessons], gi) => (
|
|
<div
|
|
key={category}
|
|
className="ls-group ls-anim"
|
|
style={{ animationDelay: `${0.05 + gi * 0.06}s` }}
|
|
>
|
|
<div className="ls-group-header">
|
|
<div className={`ls-group-accent ${categoryLessons[0].color}`} />
|
|
<span className="ls-group-name">
|
|
{highlightText(category, searchQuery)}
|
|
</span>
|
|
<span className="ls-group-count">{categoryLessons.length}</span>
|
|
</div>
|
|
|
|
<div className="ls-list">
|
|
{categoryLessons.map((lesson, li) => (
|
|
<div
|
|
key={lesson.id}
|
|
className="ls-lesson-row"
|
|
onClick={() => handleLessonClick(lesson.id)}
|
|
>
|
|
<span className="ls-row-num">
|
|
{String(li + 1).padStart(2, "0")}
|
|
</span>
|
|
|
|
<div className={`ls-row-icon ${lesson.color}`}>
|
|
{renderLessonIcon(lesson.iconName)}
|
|
</div>
|
|
|
|
<div className="ls-row-body">
|
|
<p className="ls-row-title">
|
|
{highlightText(lesson.title, searchQuery)}
|
|
</p>
|
|
{lesson.description && (
|
|
<p className="ls-row-desc">
|
|
{highlightText(lesson.description, searchQuery)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<ChevronRight size={16} className="ls-chevron" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
));
|
|
};
|
|
|
|
// ── Video grid renderer (Lesson[]) ──
|
|
const renderVideoGrid = () => {
|
|
const filtered = filterVideoLessons(videos, searchQuery);
|
|
|
|
if (!filtered.length) {
|
|
return (
|
|
<div className="ls-empty">
|
|
<span className="ls-empty-emoji">{searchQuery ? "🔍" : "📭"}</span>
|
|
<p className="ls-empty-text">
|
|
{searchQuery
|
|
? `No videos found for "${searchQuery}"`
|
|
: "No videos available yet."}
|
|
</p>
|
|
{searchQuery && (
|
|
<p className="ls-empty-sub">Try a different search term</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="ls-video-grid">
|
|
{filtered.map((lesson, i) => (
|
|
<VideoCard
|
|
key={lesson.id}
|
|
lesson={lesson}
|
|
index={i}
|
|
searchQuery={searchQuery}
|
|
onClick={() => handleLessonClick(lesson.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderContent = () => {
|
|
if (lessonLoading) {
|
|
return activeTab === "video" ? (
|
|
<SkeletonVideoGrid />
|
|
) : (
|
|
<>
|
|
<SkeletonGroup />
|
|
<SkeletonGroup />
|
|
</>
|
|
);
|
|
}
|
|
if (activeTab === "video") return renderVideoGrid();
|
|
if (activeTab === "math") return renderLessonGroups(MATH_LESSONS);
|
|
return renderLessonGroups(EBRW_LESSONS);
|
|
};
|
|
|
|
const isSearchActive = searchQuery.trim().length > 0;
|
|
|
|
return (
|
|
<div className="ls-screen pb-12">
|
|
<style>{STYLES}</style>
|
|
|
|
<div className="ls-blob ls-blob-1" />
|
|
<div className="ls-blob ls-blob-2" />
|
|
<div className="ls-blob ls-blob-3" />
|
|
<div className="ls-blob ls-blob-4" />
|
|
|
|
{DOTS.map((d, i) => (
|
|
<div
|
|
key={i}
|
|
className="ls-dot"
|
|
style={
|
|
{
|
|
width: d.size,
|
|
height: d.size,
|
|
background: d.color,
|
|
top: d.top,
|
|
left: (d as any).left,
|
|
right: (d as any).right,
|
|
animationDelay: d.delay,
|
|
animationDuration: `${5 + i * 0.5}s`,
|
|
} as React.CSSProperties
|
|
}
|
|
/>
|
|
))}
|
|
|
|
<div className="ls-inner">
|
|
<header className="ls-header">
|
|
<h1 className="ls-title">📚 Lessons</h1>
|
|
<p className="ls-sub">
|
|
Step-by-step lessons from expert Edbridge tutors — pick up tips to
|
|
tackle similar questions with confidence.
|
|
</p>
|
|
</header>
|
|
|
|
{/* Search */}
|
|
<div className="ls-search-wrap">
|
|
<Search size={16} className="ls-search-icon" />
|
|
<input
|
|
className="ls-search-input"
|
|
type="text"
|
|
placeholder="Search lessons, topics, categories…"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
{isSearchActive && (
|
|
<button
|
|
className="ls-search-clear"
|
|
onClick={() => setSearchQuery("")}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isSearchActive && !lessonLoading && (
|
|
<div className="ls-search-results-hint">
|
|
<Search size={11} />
|
|
<span>
|
|
<strong>{filteredCount}</strong> of {totalCount} lessons match "
|
|
<strong>{searchQuery}</strong>"
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<section className="ls-anim ls-anim-1">
|
|
<div className="ls-tabs-list" style={{ marginBottom: "1.25rem" }}>
|
|
<button
|
|
className={`ls-tab-btn${activeTab === "rw" ? " active" : ""}`}
|
|
onClick={() => setActiveTab("rw")}
|
|
>
|
|
<BookOpen size={15} /> {truncate("Reading & Writing")}
|
|
</button>
|
|
<button
|
|
className={`ls-tab-btn${activeTab === "math" ? " active" : ""}`}
|
|
onClick={() => setActiveTab("math")}
|
|
>
|
|
<Calculator size={15} /> Math
|
|
</button>
|
|
<button
|
|
className={`ls-tab-btn${activeTab === "video" ? " active" : ""}`}
|
|
onClick={() => setActiveTab("video")}
|
|
>
|
|
<Video size={15} /> Videos
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === "video" && (
|
|
<div
|
|
className="ls-video-subtabs"
|
|
style={{ marginBottom: "1.25rem" }}
|
|
>
|
|
<button
|
|
className={`ls-video-subtab-btn${videoSubTab === "rw" ? " active" : ""}`}
|
|
onClick={() => setVideoSubTab("rw")}
|
|
>
|
|
<span className="ls-video-subtab-dot" />
|
|
<BookOpen size={13} /> Reading & Writing
|
|
</button>
|
|
<button
|
|
className={`ls-video-subtab-btn${videoSubTab === "math" ? " active" : ""}`}
|
|
onClick={() => setVideoSubTab("math")}
|
|
>
|
|
<span className="ls-video-subtab-dot" />
|
|
<Calculator size={13} /> Math
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{renderContent()}
|
|
</section>
|
|
</div>
|
|
|
|
<LessonModal
|
|
open={isModalOpen}
|
|
lessonId={selectedLessonId}
|
|
onOpenChange={(open) => {
|
|
setIsModalOpen(open);
|
|
if (!open) setSelectedLessonId(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|