chore(build): refactor codebase for production

This commit is contained in:
shafin-r
2026-03-12 02:39:34 +06:00
parent 121cc2bf71
commit bd35f6a852
123 changed files with 3501 additions and 3254 deletions

View File

@ -19,7 +19,6 @@ import { StudentLayout } from "./pages/student/StudentLayout";
import { TargetedPractice } from "./pages/student/targeted-practice/page"; import { TargetedPractice } from "./pages/student/targeted-practice/page";
import { Drills } from "./pages/student/drills/page"; import { Drills } from "./pages/student/drills/page";
import { HardTestModules } from "./pages/student/hard-test-modules/page"; import { HardTestModules } from "./pages/student/hard-test-modules/page";
import { Analytics } from "./pages/student/Analytics";
import { QuestMap } from "./pages/student/QuestMap"; import { QuestMap } from "./pages/student/QuestMap";
import { Register } from "./pages/auth/Register"; import { Register } from "./pages/auth/Register";
@ -61,10 +60,6 @@ function App() {
path: "profile", path: "profile",
element: <Profile />, element: <Profile />,
}, },
{
path: "analytics",
element: <Analytics />,
},
{ {
path: "quests", path: "quests",
element: <QuestMap />, element: <QuestMap />,

View File

@ -14,7 +14,6 @@ import {
ChevronDown, ChevronDown,
BookOpen, BookOpen,
Home, Home,
Video,
Target, Target,
Zap, Zap,
Trophy, Trophy,

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X, Calculator, Maximize2, Minimize2 } from "lucide-react"; import { X, Calculator, Maximize2, Minimize2 } from "lucide-react";

View File

@ -417,7 +417,7 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
export const ChestOpenModal = ({ node, claimResult, onClose }: Props) => { export const ChestOpenModal = ({ claimResult, onClose }: Props) => {
const [phase, setPhase] = useState<Phase>("idle"); const [phase, setPhase] = useState<Phase>("idle");
const [showXP, setShowXP] = useState(false); const [showXP, setShowXP] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

View File

@ -773,6 +773,7 @@ export const InfoHeader = ({ onViewAll, mode = "DEFAULT" }: Props) => {
<p className="hc-role">{roleLabel}</p> <p className="hc-role">{roleLabel}</p>
</div> </div>
</div> </div>
{/* @ts-ignore */}
<InventoryButton label="Inventory" /> <InventoryButton label="Inventory" />
<Drawer direction="top"> <Drawer direction="top">
<DrawerTrigger asChild> <DrawerTrigger asChild>

View File

@ -3,7 +3,6 @@ import {
useInventoryStore, useInventoryStore,
getLiveEffects, getLiveEffects,
formatTimeLeft, formatTimeLeft,
hasActiveEffect,
} from "../stores/useInventoryStore"; } from "../stores/useInventoryStore";
import { InventoryModal } from "./InventoryModal"; import { InventoryModal } from "./InventoryModal";

View File

@ -23,14 +23,6 @@ const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const isVideoLesson = (id: string) => UUID_REGEX.test(id); const isVideoLesson = (id: string) => UUID_REGEX.test(id);
function getLocalLessonTitle(lessonId: string): string {
const comp = LESSON_COMPONENT_MAP[lessonId as LessonId] as any;
if (comp?.displayName) return comp.displayName;
return lessonId
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
const 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'); @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
@ -207,11 +199,12 @@ export const LessonModal = ({
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) throw new Error("No auth storage"); if (!authStorage) throw new Error("No auth storage");
const { const {
// @ts-ignore
state: { token }, state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } }; } = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) throw new Error("No token"); if (!token) throw new Error("No token");
// fetchLessonById returns LessonDetails directly // @ts-ignore
const response: LessonDetails = await api.fetchLessonById( const response: LessonDetails = await api.fetchLessonById(
token, token,
lessonId, lessonId,

View File

@ -281,6 +281,7 @@ const SectionDetail = ({
<div className="psc-detail-card"> <div className="psc-detail-card">
<div className="psc-detail-top"> <div className="psc-detail-top">
<div className="psc-detail-icon-wrap" style={{ background: iconBg }}> <div className="psc-detail-icon-wrap" style={{ background: iconBg }}>
{/* @ts-ignore */}
<Icon size={15} color={barColor} /> <Icon size={15} color={barColor} />
</div> </div>
<span className="psc-detail-label">{label}</span> <span className="psc-detail-label">{label}</span>

View File

@ -846,7 +846,6 @@ export const QuestNodeModal = ({
node, node,
arc, arc,
arcAccent, arcAccent,
arcDark,
arcId = "east_blue", arcId = "east_blue",
nodeIndex = 0, nodeIndex = 0,
onClose, onClose,

View File

@ -1,507 +0,0 @@
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useNavigate } from "react-router-dom";
import type { QuestNode, QuestArc } from "../types/quest";
import { CREW_RANKS } from "../types/quest";
import {
useQuestStore,
getQuestSummary,
getCrewRank,
} from "../stores/useQuestStore";
import { ChestOpenModal } from "./ChestOpenModal";
// ─── Styles ───────────────────────────────────────────────────────────────────
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@600;700;900&family=Sorts+Mill+Goudy:ital@0;1&family=Nunito:wght@700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
/* ══ CARD SHELL ══ */
.qpc2-card {
position: relative; overflow: hidden;
border-radius: 24px;
background: linear-gradient(160deg, #0b1a35 0%, #060e1f 55%, #0d1530 100%);
border: 1.5px solid rgba(251,191,36,0.2);
box-shadow:
0 8px 32px rgba(0,0,0,0.35),
0 0 0 1px rgba(255,255,255,0.04) inset,
0 1px 0 rgba(255,255,255,0.08) inset;
}
/* Animated sea shimmer behind everything */
.qpc2-sea {
position: absolute; inset: 0; pointer-events: none; z-index: 0;
background:
repeating-linear-gradient(105deg, transparent 0%, transparent 55%,
rgba(56,189,248,0.022) 56%, transparent 57%),
repeating-linear-gradient(75deg, transparent 0%, transparent 70%,
rgba(56,189,248,0.014) 71%, transparent 72%);
background-size: 300% 300%, 250% 250%;
animation: qpc2Sea 12s ease-in-out infinite alternate;
}
@keyframes qpc2Sea {
0% { background-position: 0% 0%, 100% 0%; }
100% { background-position: 100% 100%, 0% 100%; }
}
/* Faint gold orb top-right */
.qpc2-orb {
position: absolute; top: -40px; right: -30px;
width: 160px; height: 160px; border-radius: 50%;
background: radial-gradient(circle, rgba(251,191,36,0.14) 0%, transparent 70%);
pointer-events: none; z-index: 0;
}
/* ══ RANK HERO (always visible) ══ */
.qpc2-hero {
position: relative; z-index: 2;
padding: 1rem 1.1rem 0.9rem;
cursor: pointer;
transition: background 0.18s ease;
}
.qpc2-hero:hover { background: rgba(255,255,255,0.025); }
.qpc2-hero-row {
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
}
.qpc2-hero-left { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
.qpc2-hero-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
/* Rank badge icon */
.qpc2-rank-icon {
width: 44px; height: 44px; border-radius: 14px; flex-shrink: 0;
background: linear-gradient(135deg, #1e0e4a, #3730a3);
border: 1.5px solid rgba(251,191,36,0.35);
display: flex; align-items: center; justify-content: center;
font-size: 1.35rem;
box-shadow: 0 4px 0 rgba(30,14,74,0.7), 0 0 16px rgba(251,191,36,0.1);
}
.qpc2-rank-label {
font-family: 'Cinzel', serif;
font-size: 0.78rem; font-weight: 700;
color: rgba(255,255,255,0.45); letter-spacing: 0.12em;
text-transform: uppercase; margin-bottom: 0.1rem;
}
.qpc2-rank-name {
font-family: 'Sorts Mill Goudy', serif;
font-size: 1.05rem; font-weight: 700;
color: #fbbf24;
text-shadow: 0 0 18px rgba(251,191,36,0.45);
line-height: 1.1;
}
/* Rank progress bar */
.qpc2-rank-bar-wrap {
margin-top: 0.55rem;
display: flex; align-items: center; gap: 0.6rem;
}
.qpc2-rank-bar-track {
flex: 1; height: 5px; border-radius: 100px;
background: rgba(255,255,255,0.1); overflow: hidden;
}
.qpc2-rank-bar-fill {
height: 100%; border-radius: 100px;
background: linear-gradient(90deg, #fbbf24, #f59e0b);
box-shadow: 0 0 8px rgba(251,191,36,0.5);
transition: width 0.7s cubic-bezier(0.34,1.56,0.64,1);
}
.qpc2-rank-bar-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.6rem; font-weight: 700;
color: rgba(255,255,255,0.35); white-space: nowrap;
}
/* Stats row */
.qpc2-stats {
display: flex; gap: 0.5rem; margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px solid rgba(255,255,255,0.07);
}
.qpc2-stat {
flex: 1; display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
}
.qpc2-stat-val {
font-family: 'Nunito', sans-serif;
font-size: 0.95rem; font-weight: 900; color: #fbbf24;
}
.qpc2-stat-lbl {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.56rem; font-weight: 700;
color: rgba(255,255,255,0.35); text-align: center;
letter-spacing: 0.06em; text-transform: uppercase;
}
.qpc2-stat-div {
width: 1px; background: rgba(255,255,255,0.08); margin: 0.1rem 0;
}
/* Chest badge */
.qpc2-chest-badge {
display: flex; align-items: center; gap: 0.22rem;
padding: 0.22rem 0.6rem;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
border-radius: 100px;
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900; color: #1a0800;
box-shadow: 0 2px 0 #d97706, 0 0 10px rgba(251,191,36,0.35);
animation: qpc2ChestPop 1.8s ease-in-out infinite;
}
@keyframes qpc2ChestPop {
0%,100%{ transform: scale(1); }
50% { transform: scale(1.07); }
}
/* Expand chevron */
.qpc2-chevron {
color: rgba(255,255,255,0.35);
transition: transform 0.3s cubic-bezier(0.34,1.56,0.64,1), color 0.2s;
}
.qpc2-chevron.open { transform: rotate(180deg); color: #fbbf24; }
/* ══ COLLAPSIBLE BODY ══ */
.qpc2-body {
position: relative; z-index: 2;
overflow: hidden;
max-height: 0;
transition: max-height 0.4s cubic-bezier(0.4,0,0.2,1);
}
.qpc2-body.open { max-height: 600px; }
.qpc2-divider {
height: 1px; background: rgba(255,255,255,0.07); margin: 0 1.1rem;
}
/* ══ QUEST ROWS ══ */
.qpc2-quest-list { display: flex; flex-direction: column; padding: 0.5rem 0; }
.qpc2-quest-row {
display: flex; align-items: center; gap: 0.7rem;
padding: 0.75rem 1.1rem;
cursor: pointer;
transition: background 0.15s ease;
position: relative;
}
.qpc2-quest-row:hover { background: rgba(255,255,255,0.03); }
/* Left accent line = arc colour */
.qpc2-quest-row::before {
content: ''; position: absolute; left: 0; top: 16%; bottom: 16%;
width: 3px; border-radius: 0 3px 3px 0;
background: var(--ac);
opacity: 0.7;
}
.qpc2-quest-icon {
width: 38px; height: 38px; border-radius: 12px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1.2rem;
background: rgba(255,255,255,0.05);
border: 1.5px solid rgba(255,255,255,0.08);
transition: transform 0.2s ease;
}
.qpc2-quest-row:hover .qpc2-quest-icon { transform: scale(1.1) rotate(-5deg); }
.qpc2-quest-icon.claimable {
background: rgba(251,191,36,0.12);
border-color: rgba(251,191,36,0.4);
animation: qpc2Wiggle 2s ease-in-out infinite;
}
@keyframes qpc2Wiggle {
0%,100%{ transform: rotate(0deg); }
25% { transform: rotate(-8deg) scale(1.06); }
75% { transform: rotate(8deg) scale(1.06); }
}
.qpc2-quest-body { flex: 1; min-width: 0; }
.qpc2-quest-arc {
font-size: 0.57rem; font-weight: 800; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--ac);
margin-bottom: 0.08rem;
}
.qpc2-quest-title {
font-family: 'Sorts Mill Goudy', serif;
font-size: 0.82rem; font-weight: 700; color: rgba(255,255,255,0.9);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 0.28rem;
}
.qpc2-mini-track {
height: 4px; background: rgba(255,255,255,0.08);
border-radius: 100px; overflow: hidden; margin-bottom: 0.18rem;
}
.qpc2-mini-fill {
height: 100%; border-radius: 100px;
background: var(--ac);
box-shadow: 0 0 5px color-mix(in srgb, var(--ac) 55%, transparent);
transition: width 0.5s cubic-bezier(0.34,1.56,0.64,1);
}
.qpc2-mini-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.58rem; font-weight: 700; color: rgba(255,255,255,0.3);
}
.qpc2-claimable-label {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.62rem; font-weight: 700; color: #fbbf24;
}
/* Claim button */
.qpc2-claim-btn {
padding: 0.32rem 0.7rem; border: none; border-radius: 100px; cursor: pointer;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
font-family: 'Nunito', sans-serif;
font-size: 0.65rem; font-weight: 900; color: #1a0800;
box-shadow: 0 2px 0 #d97706, 0 3px 8px rgba(251,191,36,0.25);
flex-shrink: 0; white-space: nowrap;
transition: all 0.12s ease;
}
.qpc2-claim-btn:hover { transform: translateY(-1px); box-shadow: 0 3px 0 #d97706; }
.qpc2-claim-btn:active { transform: translateY(1px); }
/* ══ FOOTER LINK ══ */
.qpc2-footer {
position: relative; z-index: 2;
display: flex; align-items: center; justify-content: center; gap: 0.3rem;
padding: 0.65rem 1.1rem;
border-top: 1px solid rgba(255,255,255,0.07);
cursor: pointer;
transition: background 0.15s ease;
}
.qpc2-footer:hover { background: rgba(255,255,255,0.03); }
.qpc2-footer-label {
font-family: 'Nunito', sans-serif;
font-size: 0.72rem; font-weight: 800;
color: rgba(251,191,36,0.7);
letter-spacing: 0.04em;
}
.qpc2-footer:hover .qpc2-footer-label { color: #fbbf24; }
/* ══ EMPTY STATE ══ */
.qpc2-empty {
padding: 1.25rem 1.1rem; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 0.35rem;
}
.qpc2-empty-title {
font-family: 'Sorts Mill Goudy', serif;
font-size: 0.88rem; font-weight: 700; color: rgba(255,255,255,0.55);
}
.qpc2-empty-sub {
font-family: 'Nunito Sans', sans-serif;
font-size: 0.68rem; font-weight: 600; color: rgba(255,255,255,0.25);
}
`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getActiveQuests(arcs: QuestArc[]) {
const results: { node: QuestNode; arc: QuestArc }[] = [];
for (const arc of arcs) {
for (const node of arc.nodes) {
if (node.status === "claimable" || node.status === "active") {
results.push({ node, arc });
}
}
}
// Claimable first, then active; max 2 shown
results.sort((a, b) => {
if (a.node.status === "claimable" && b.node.status !== "claimable")
return -1;
if (b.node.status === "claimable" && a.node.status !== "claimable")
return 1;
return 0;
});
return results.slice(0, 2);
}
// ─── Component ────────────────────────────────────────────────────────────────
interface Props {
onViewAll?: () => void;
}
export const QuestProgressCard = ({ onViewAll }: Props) => {
const navigate = useNavigate();
const arcs = useQuestStore((s) => s.arcs);
const claimNode = useQuestStore((s) => s.claimNode);
const summary = getQuestSummary(arcs);
const rank = getCrewRank(arcs);
const activeQuests = getActiveQuests(arcs);
const [open, setOpen] = useState(false);
const [claimingNode, setClaimingNode] = useState<{
node: QuestNode;
arcId: string;
} | null>(null);
const handleViewAll = () => {
if (onViewAll) onViewAll();
else navigate("/student/quests");
};
const handleClaim = (node: QuestNode, arcId: string) => {
setClaimingNode({ node, arcId });
};
const handleChestClose = () => {
if (!claimingNode) return;
claimNode(claimingNode.arcId, claimingNode.node.id);
setClaimingNode(null);
};
// Next rank label
const nextRankLabel = rank.next
? `${Math.round(rank.progressToNext * 100)}% to ${rank.next.label}`
: "Max rank reached";
return (
<>
<style>{STYLES}</style>
<div className="qpc2-card">
{/* Atmosphere layers */}
<div className="qpc2-sea" />
<div className="qpc2-orb" />
{/* ── Rank hero (always visible, tap to expand) ── */}
<div className="qpc2-hero" onClick={() => setOpen((o) => !o)}>
<div className="qpc2-hero-row">
<div className="qpc2-hero-left">
<div className="qpc2-rank-icon">{rank.emoji}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p className="qpc2-rank-label">Crew Rank</p>
<p className="qpc2-rank-name">{rank.label}</p>
</div>
</div>
<div className="qpc2-hero-right">
{summary.claimableNodes > 0 && (
<div className="qpc2-chest-badge">
📦 {summary.claimableNodes}
</div>
)}
<ChevronDown
size={18}
className={`qpc2-chevron${open ? " open" : ""}`}
/>
</div>
</div>
{/* Rank progress bar */}
<div className="qpc2-rank-bar-wrap">
<div className="qpc2-rank-bar-track">
<div
className="qpc2-rank-bar-fill"
style={{ width: `${Math.round(rank.progressToNext * 100)}%` }}
/>
</div>
<span className="qpc2-rank-bar-label">{nextRankLabel}</span>
</div>
{/* Stats strip */}
<div className="qpc2-stats">
{[
{ val: `${summary.earnedXP}`, lbl: "XP Earned" },
null,
{
val: `${summary.completedNodes}/${summary.totalNodes}`,
lbl: "Quests Done",
},
null,
{
val: `${summary.arcsCompleted}/${summary.totalArcs}`,
lbl: "Arcs",
},
].map((item, i) =>
item === null ? (
<div key={i} className="qpc2-stat-div" />
) : (
<div key={i} className="qpc2-stat">
<span className="qpc2-stat-val">{item.val}</span>
<span className="qpc2-stat-lbl">{item.lbl}</span>
</div>
),
)}
</div>
</div>
{/* ── Collapsible quest list ── */}
<div className={`qpc2-body${open ? " open" : ""}`}>
<div className="qpc2-divider" />
<div className="qpc2-quest-list">
{activeQuests.length === 0 ? (
<div className="qpc2-empty">
<span style={{ fontSize: "1.75rem" }}></span>
<p className="qpc2-empty-title">All caught up, Captain!</p>
<p className="qpc2-empty-sub">
No active quests keep sailing
</p>
</div>
) : (
activeQuests.map(({ node, arc }) => {
const pct = Math.min(
100,
Math.round((node.progress / node.requirement.target) * 100),
);
const isClaimable = node.status === "claimable";
return (
<div
key={node.id}
className="qpc2-quest-row"
style={{ "--ac": arc.accentColor } as React.CSSProperties}
onClick={() => !isClaimable && handleViewAll()}
>
<div
className={`qpc2-quest-icon${isClaimable ? " claimable" : ""}`}
>
{isClaimable ? "📦" : node.emoji}
</div>
<div className="qpc2-quest-body">
<p className="qpc2-quest-arc">
{arc.emoji} {arc.name}
</p>
<p className="qpc2-quest-title">{node.title}</p>
{isClaimable ? (
<p className="qpc2-claimable-label">
Chest ready to open!
</p>
) : (
<>
<div className="qpc2-mini-track">
<div
className="qpc2-mini-fill"
style={{ width: `${pct}%` }}
/>
</div>
<p className="qpc2-mini-label">
{node.progress} / {node.requirement.target}{" "}
{node.requirement.label}
</p>
</>
)}
</div>
{isClaimable ? (
<button
className="qpc2-claim-btn"
onClick={(e) => {
e.stopPropagation();
handleClaim(node, arc.id);
}}
>
Open 📦
</button>
) : (
<ChevronRight size={14} color="rgba(255,255,255,0.2)" />
)}
</div>
);
})
)}
</div>
{/* Footer — navigate to full map */}
<div className="qpc2-footer" onClick={handleViewAll}>
<span className="qpc2-footer-label">View full quest map</span>
<ChevronRight size={14} color="rgba(251,191,36,0.7)" />
</div>
</div>
</div>
{claimingNode && (
<ChestOpenModal node={claimingNode.node} onClose={handleChestClose} />
)}
</>
);
};

View File

@ -1,4 +1,5 @@
import { Component, type ReactNode } from "react"; import { Component, type ReactNode } from "react";
// @ts-ignore
import { BlockMath, InlineMath } from "react-katex"; import { BlockMath, InlineMath } from "react-katex";
// ─── Error boundary ─────────────────────────────────────────────────────────── // ─── Error boundary ───────────────────────────────────────────────────────────

View File

@ -28,7 +28,7 @@ interface Props {
// ─── Nav items ──────────────────────────────────────────────────────────────── // ─── Nav items ────────────────────────────────────────────────────────────────
const NAV_ITEMS: (SearchItem & { const NAV_ITEMS: (SearchItem & {
icon: React.ElementType; icon: React.ComponentType<any>;
color: string; color: string;
bg: string; bg: string;
})[] = [ })[] = [
@ -490,6 +490,7 @@ export const SearchOverlay = ({
className="so-item-icon" className="so-item-icon"
style={{ background: bg }} style={{ background: bg }}
> >
{/* @ts-ignore */}
<Icon size={16} color={color} /> <Icon size={16} color={color} />
</div> </div>
<div className="so-item-body"> <div className="so-item-body">
@ -517,6 +518,7 @@ export const SearchOverlay = ({
className="so-quick-chip" className="so-quick-chip"
onClick={() => handleSelect(item)} onClick={() => handleSelect(item)}
> >
{/* @ts-ignore */}
<item.icon size={13} color={item.color} /> <item.icon size={13} color={item.color} />
{item.title} {item.title}
</button> </button>
@ -533,6 +535,7 @@ export const SearchOverlay = ({
.filter((s) => s.user_status === "IN_PROGRESS") .filter((s) => s.user_status === "IN_PROGRESS")
.slice(0, 3) .slice(0, 3)
.map((sheet) => { .map((sheet) => {
// @ts-ignore
const item: SearchItem = { const item: SearchItem = {
type: "sheet", type: "sheet",
title: sheet.title, title: sheet.title,
@ -602,8 +605,9 @@ export const SearchOverlay = ({
const Icon = navMeta?.icon ?? BookOpen; const Icon = navMeta?.icon ?? BookOpen;
const iconColor = navMeta?.color ?? "#a855f7"; const iconColor = navMeta?.color ?? "#a855f7";
const iconBg = navMeta?.bg ?? "#fdf4ff"; const iconBg = navMeta?.bg ?? "#fdf4ff";
const statusMeta = item.status const statusMeta = item.status
? STATUS_META[item.status as keyof typeof STATUS_META] ? STATUS_META[item?.status as keyof typeof STATUS_META]
: null; : null;
return ( return (

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const BoxPlotComparisonWidget: React.FC = () => { const BoxPlotComparisonWidget: React.FC = () => {
// Box Plot A is fixed // Box Plot A is fixed
@ -9,16 +9,24 @@ const BoxPlotComparisonWidget: React.FC = () => {
const [spread, setSpread] = useState(1); // Scale spread const [spread, setSpread] = useState(1); // Scale spread
const statsB = { const statsB = {
min: 10 + shift - (5 * (spread - 1)), // Just approximating visual expansion min: 10 + shift - 5 * (spread - 1), // Just approximating visual expansion
q1: 16 + shift - (2 * (spread - 1)), q1: 16 + shift - 2 * (spread - 1),
med: 26 + shift, med: 26 + shift,
q3: 34 + shift + (2 * (spread - 1)), q3: 34 + shift + 2 * (spread - 1),
max: 38 + shift + (4 * (spread - 1)) max: 38 + shift + 4 * (spread - 1),
}; };
const scaleX = (val: number) => (val / 60) * 100; // 0 to 60 range mapping to % const scaleX = (val: number) => (val / 60) * 100; // 0 to 60 range mapping to %
const BoxPlot = ({ stats, color, label }: { stats: any, color: string, label: string }) => { const BoxPlot = ({
stats,
color,
label,
}: {
stats: any;
color: string;
label: string;
}) => {
const leftW = scaleX(stats.min); const leftW = scaleX(stats.min);
const rightW = scaleX(stats.max); const rightW = scaleX(stats.max);
const boxL = scaleX(stats.q1); const boxL = scaleX(stats.q1);
@ -27,27 +35,47 @@ const BoxPlotComparisonWidget: React.FC = () => {
return ( return (
<div className="relative h-16 w-full mb-8 group"> <div className="relative h-16 w-full mb-8 group">
<div className="absolute left-0 top-0 text-xs font-bold text-slate-400">{label}</div> <div className="absolute left-0 top-0 text-xs font-bold text-slate-400">
{label}
{/* Main Line (Whisker to Whisker) */}
<div className="absolute top-1/2 left-0 h-0.5 bg-slate-300 -translate-y-1/2"
style={{ left: `${leftW}%`, width: `${rightW - leftW}%` }} />
{/* Whiskers */}
<div className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2" style={{ left: `${leftW}%` }} />
<div className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2" style={{ left: `${rightW}%` }} />
{/* Box */}
<div className={`absolute top-1/2 -translate-y-1/2 h-8 border-2 ${color} bg-white opacity-90`}
style={{ left: `${boxL}%`, width: `${boxR - boxL}%`, borderColor: 'currentColor' }}>
</div> </div>
{/* Main Line (Whisker to Whisker) */}
<div
className="absolute top-1/2 left-0 h-0.5 bg-slate-300 -translate-y-1/2"
style={{ left: `${leftW}%`, width: `${rightW - leftW}%` }}
/>
{/* Whiskers */}
<div
className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2"
style={{ left: `${leftW}%` }}
/>
<div
className="absolute top-1/2 h-3 w-0.5 bg-slate-400 -translate-y-1/2"
style={{ left: `${rightW}%` }}
/>
{/* Box */}
<div
className={`absolute top-1/2 -translate-y-1/2 h-8 border-2 ${color} bg-white opacity-90`}
style={{
left: `${boxL}%`,
width: `${boxR - boxL}%`,
borderColor: "currentColor",
}}
></div>
{/* Median Line */} {/* Median Line */}
<div className="absolute top-1/2 h-8 w-1 bg-slate-800 -translate-y-1/2" style={{ left: `${med}%` }} /> <div
className="absolute top-1/2 h-8 w-1 bg-slate-800 -translate-y-1/2"
style={{ left: `${med}%` }}
/>
{/* Labels on Hover */} {/* Labels on Hover */}
<div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-10 left-0 w-full text-center text-xs font-mono text-slate-500 pointer-events-none"> <div className="opacity-0 group-hover:opacity-100 transition-opacity absolute top-10 left-0 w-full text-center text-xs font-mono text-slate-500 pointer-events-none">
Min:{stats.min.toFixed(0)} Q1:{stats.q1.toFixed(0)} Med:{stats.med.toFixed(0)} Q3:{stats.q3.toFixed(0)} Max:{stats.max.toFixed(0)} Min:{stats.min.toFixed(0)} Q1:{stats.q1.toFixed(0)} Med:
{stats.med.toFixed(0)} Q3:{stats.q3.toFixed(0)} Max:
{stats.max.toFixed(0)}
</div> </div>
</div> </div>
); );
@ -55,52 +83,98 @@ const BoxPlotComparisonWidget: React.FC = () => {
const iqrA = statsA.q3 - statsA.q1; const iqrA = statsA.q3 - statsA.q1;
const iqrB = statsB.q3 - statsB.q1; const iqrB = statsB.q3 - statsB.q1;
const rangeA = statsA.max - statsA.min;
const rangeB = statsB.max - statsB.min;
return ( return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="mb-6 relative h-48 border-b border-slate-200"> <div className="mb-6 relative h-48 border-b border-slate-200">
<BoxPlot stats={statsA} color="text-indigo-500" label="Dataset A (Fixed)" /> <BoxPlot
<BoxPlot stats={statsB} color="text-rose-500" label="Dataset B (Adjustable)" /> stats={statsA}
color="text-indigo-500"
label="Dataset A (Fixed)"
/>
<BoxPlot
stats={statsB}
color="text-rose-500"
label="Dataset B (Adjustable)"
/>
{/* Axis */} {/* Axis */}
<div className="absolute bottom-0 w-full flex justify-between text-xs text-slate-400 font-mono px-2"> <div className="absolute bottom-0 w-full flex justify-between text-xs text-slate-400 font-mono px-2">
<span>0</span><span>10</span><span>20</span><span>30</span><span>40</span><span>50</span><span>60</span> <span>0</span>
<span>10</span>
<span>20</span>
<span>30</span>
<span>40</span>
<span>50</span>
<span>60</span>
</div> </div>
</div> </div>
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div> <div>
<label className="text-xs font-bold text-slate-500 uppercase">Shift Center (Median B)</label> <label className="text-xs font-bold text-slate-500 uppercase">
<input type="range" min="-15" max="15" value={shift} onChange={e => setShift(parseInt(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/> Shift Center (Median B)
</label>
<input
type="range"
min="-15"
max="15"
value={shift}
onChange={(e) => setShift(parseInt(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"
/>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-slate-500 uppercase">Adjust Spread (IQR B)</label> <label className="text-xs font-bold text-slate-500 uppercase">
<input type="range" min="0.5" max="2" step="0.1" value={spread} onChange={e => setSpread(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/> Adjust Spread (IQR B)
</label>
<input
type="range"
min="0.5"
max="2"
step="0.1"
value={spread}
onChange={(e) => setSpread(parseFloat(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"
/>
</div> </div>
</div> </div>
<div className="flex-1 grid grid-cols-2 gap-4"> <div className="flex-1 grid grid-cols-2 gap-4">
<div className="bg-slate-50 p-3 rounded border border-slate-200"> <div className="bg-slate-50 p-3 rounded border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase">Median Comparison</div> <div className="text-xs font-bold text-slate-400 uppercase">
Median Comparison
</div>
<div className="flex justify-between items-center mt-1"> <div className="flex justify-between items-center mt-1">
<span className="text-indigo-600 font-bold">{statsA.med}</span> <span className="text-indigo-600 font-bold">{statsA.med}</span>
<span className="text-slate-400">{statsA.med > statsB.med ? '>' : statsA.med < statsB.med ? '<' : '='}</span> <span className="text-slate-400">
{statsA.med > statsB.med
? ">"
: statsA.med < statsB.med
? "<"
: "="}
</span>
<span className="text-rose-600 font-bold">{statsB.med}</span> <span className="text-rose-600 font-bold">{statsB.med}</span>
</div> </div>
</div> </div>
<div className="bg-slate-50 p-3 rounded border border-slate-200"> <div className="bg-slate-50 p-3 rounded border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase">IQR Comparison</div> <div className="text-xs font-bold text-slate-400 uppercase">
IQR Comparison
</div>
<div className="flex justify-between items-center mt-1"> <div className="flex justify-between items-center mt-1">
<span className="text-indigo-600 font-bold">{iqrA.toFixed(0)}</span> <span className="text-indigo-600 font-bold">
<span className="text-slate-400">{iqrA > iqrB ? '>' : iqrA < iqrB ? '<' : '='}</span> {iqrA.toFixed(0)}
</span>
<span className="text-slate-400">
{iqrA > iqrB ? ">" : iqrA < iqrB ? "<" : "="}
</span>
<span className="text-rose-600 font-bold">{iqrB.toFixed(0)}</span> <span className="text-rose-600 font-bold">{iqrB.toFixed(0)}</span>
</div> </div>
</div> </div>
<div className="col-span-2 text-xs text-slate-500 text-center"> <div className="col-span-2 text-xs text-slate-500 text-center">
The box length represents the IQR (Middle 50%). The whiskers represent the full Range. The box length represents the IQR (Middle 50%). The whiskers
represent the full Range.
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from "react";
const CircleTheoremsWidget: React.FC = () => { const CircleTheoremsWidget: React.FC = () => {
// C is the point on the major arc // C is the point on the major arc
@ -9,23 +9,9 @@ const CircleTheoremsWidget: React.FC = () => {
const R = 120; const R = 120;
const center = { x: 200, y: 180 }; const center = { x: 200, y: 180 };
// Fixed points A and B at the bottom
const angleA = 330; // 30 deg below x axis
const angleB = 210; // 150 deg below x axis? No, let's place them symmetrically
// Let's place A and B to define a nice arc
// A at -30 deg (330), B at 210 is too far.
// Let's put A at 320 (-40) and B at 220 (-140).
// Wait, standard unit circle angles.
// A at 340 (-20), B at 200. Arc is 140 deg at bottom.
// Major arc is top. C moves on top.
const posA = { x: center.x + R * Math.cos(340 * Math.PI/180), y: center.y - R * Math.sin(340 * Math.PI/180) }; // SVG y inverted logic?
// Let's just use standard math cos/sin and add to center.y
// SVG y is positive down.
const getPos = (deg: number) => ({ const getPos = (deg: number) => ({
x: center.x + R * Math.cos(deg * Math.PI / 180), x: center.x + R * Math.cos((deg * Math.PI) / 180),
y: center.y + R * Math.sin(deg * Math.PI / 180) y: center.y + R * Math.sin((deg * Math.PI) / 180),
}); });
const A = getPos(30); // Bottom Right const A = getPos(30); // Bottom Right
@ -38,7 +24,7 @@ const CircleTheoremsWidget: React.FC = () => {
const rect = svgRef.current.getBoundingClientRect(); const rect = svgRef.current.getBoundingClientRect();
const dx = e.clientX - rect.left - center.x; const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y; const dy = e.clientY - rect.top - center.y;
let deg = Math.atan2(dy, dx) * 180 / Math.PI; let deg = (Math.atan2(dy, dx) * 180) / Math.PI;
if (deg < 0) deg += 360; if (deg < 0) deg += 360;
// Constrain C to the major arc (approx 160 to 350 is the "bad" zone? No, A=30, B=150. // Constrain C to the major arc (approx 160 to 350 is the "bad" zone? No, A=30, B=150.
@ -53,9 +39,12 @@ const CircleTheoremsWidget: React.FC = () => {
return ( return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200 flex flex-col items-center">
<h3 className="font-bold text-slate-700 mb-2">Central vs. Inscribed Angle</h3> <h3 className="font-bold text-slate-700 mb-2">
Central vs. Inscribed Angle
</h3>
<div className="text-sm text-slate-500 mb-4 text-center max-w-md"> <div className="text-sm text-slate-500 mb-4 text-center max-w-md">
Drag point <strong className="text-emerald-600">C</strong> along the top arc. Notice that the inscribed angle stays constant! Drag point <strong className="text-emerald-600">C</strong> along the top
arc. Notice that the inscribed angle stays constant!
</div> </div>
<svg <svg
@ -63,52 +52,113 @@ const CircleTheoremsWidget: React.FC = () => {
width="400" width="400"
height="350" height="350"
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false} onMouseUp={() => (isDragging.current = false)}
onMouseLeave={() => isDragging.current = false} onMouseLeave={() => (isDragging.current = false)}
className="select-none" className="select-none"
> >
{/* Circle */} {/* Circle */}
<circle cx={center.x} cy={center.y} r={R} stroke="#cbd5e1" strokeWidth="2" fill="transparent" /> <circle
cx={center.x}
cy={center.y}
r={R}
stroke="#cbd5e1"
strokeWidth="2"
fill="transparent"
/>
{/* Central Angle Lines */} {/* Central Angle Lines */}
<path d={`M ${A.x} ${A.y} L ${center.x} ${center.y} L ${B.x} ${B.y}`} stroke="#e2e8f0" strokeWidth="2" fill="transparent" strokeDasharray="5,5"/> <path
d={`M ${A.x} ${A.y} L ${center.x} ${center.y} L ${B.x} ${B.y}`}
stroke="#e2e8f0"
strokeWidth="2"
fill="transparent"
strokeDasharray="5,5"
/>
{/* Central Angle Wedge */} {/* Central Angle Wedge */}
{/* 30 to 150 */} {/* 30 to 150 */}
<path d={`M ${center.x} ${center.y} L ${A.x} ${A.y} A ${R} ${R} 0 0 1 ${B.x} ${B.y} Z`} fill="rgba(99, 102, 241, 0.1)" stroke="none" /> <path
<text x={center.x} y={center.y + 40} textAnchor="middle" className="text-sm font-bold fill-indigo-600">{centralAngleValue}°</text> d={`M ${center.x} ${center.y} L ${A.x} ${A.y} A ${R} ${R} 0 0 1 ${B.x} ${B.y} Z`}
<text x={center.x} y={center.y + 60} textAnchor="middle" className="text-xs fill-indigo-400 uppercase">Central</text> fill="rgba(99, 102, 241, 0.1)"
stroke="none"
/>
<text
x={center.x}
y={center.y + 40}
textAnchor="middle"
className="text-sm font-bold fill-indigo-600"
>
{centralAngleValue}°
</text>
<text
x={center.x}
y={center.y + 60}
textAnchor="middle"
className="text-xs fill-indigo-400 uppercase"
>
Central
</text>
{/* Inscribed Angle Lines */} {/* Inscribed Angle Lines */}
<path d={`M ${A.x} ${A.y} L ${C.x} ${C.y} L ${B.x} ${B.y}`} stroke="#059669" strokeWidth="3" fill="transparent" strokeLinejoin="round" /> <path
d={`M ${A.x} ${A.y} L ${C.x} ${C.y} L ${B.x} ${B.y}`}
stroke="#059669"
strokeWidth="3"
fill="transparent"
strokeLinejoin="round"
/>
{/* Points */} {/* Points */}
<circle cx={center.x} cy={center.y} r="4" fill="#64748b" /> {/* Center */} <circle cx={center.x} cy={center.y} r="4" fill="#64748b" />{" "}
<text x={center.x + 10} y={center.y} className="text-xs fill-slate-400">O</text> {/* Center */}
<text x={center.x + 10} y={center.y} className="text-xs fill-slate-400">
O
</text>
<circle cx={A.x} cy={A.y} r="5" fill="#475569" /> <circle cx={A.x} cy={A.y} r="5" fill="#475569" />
<text x={A.x + 10} y={A.y} className="text-xs font-bold fill-slate-600">A</text> <text x={A.x + 10} y={A.y} className="text-xs font-bold fill-slate-600">
A
</text>
<circle cx={B.x} cy={B.y} r="5" fill="#475569" /> <circle cx={B.x} cy={B.y} r="5" fill="#475569" />
<text x={B.x - 20} y={B.y} className="text-xs font-bold fill-slate-600">B</text> <text x={B.x - 20} y={B.y} className="text-xs font-bold fill-slate-600">
B
</text>
{/* Draggable C */} {/* Draggable C */}
<g onMouseDown={() => isDragging.current = true} className="cursor-grab active:cursor-grabbing"> <g
<circle cx={C.x} cy={C.y} r="15" fill="transparent" /> {/* Hit area */} onMouseDown={() => (isDragging.current = true)}
<circle cx={C.x} cy={C.y} r="8" fill="#059669" stroke="white" strokeWidth="2" className="shadow-lg" /> className="cursor-grab active:cursor-grabbing"
<text x={C.x} y={C.y - 15} textAnchor="middle" className="text-sm font-bold fill-emerald-700">C</text> >
<circle cx={C.x} cy={C.y} r="15" fill="transparent" />{" "}
{/* Hit area */}
<circle
cx={C.x}
cy={C.y}
r="8"
fill="#059669"
stroke="white"
strokeWidth="2"
className="shadow-lg"
/>
<text
x={C.x}
y={C.y - 15}
textAnchor="middle"
className="text-sm font-bold fill-emerald-700"
>
C
</text>
</g> </g>
{/* Inscribed Angle Label */} {/* Inscribed Angle Label */}
{/* Simple approximation for label placement: slightly "in" from C towards center */} {/* Simple approximation for label placement: slightly "in" from C towards center */}
<text x={C.x + (center.x - C.x)*0.2} y={C.y + (center.y - C.y)*0.2 + 5} textAnchor="middle" className="text-lg font-bold fill-emerald-600"> <text
x={C.x + (center.x - C.x) * 0.2}
y={C.y + (center.y - C.y) * 0.2 + 5}
textAnchor="middle"
className="text-lg font-bold fill-emerald-600"
>
{centralAngleValue / 2}° {centralAngleValue / 2}°
</text> </text>
</svg> </svg>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 mt-4 w-full text-center"> <div className="bg-slate-50 p-4 rounded-lg border border-slate-200 mt-4 w-full text-center">
<p className="font-mono text-lg text-slate-800"> <p className="font-mono text-lg text-slate-800">
Inscribed Angle = <span className="text-emerald-600">½</span> × Central Angle Inscribed Angle = <span className="text-emerald-600">½</span> ×
Central Angle
</p> </p>
<p className="font-mono text-md text-slate-600 mt-1"> <p className="font-mono text-md text-slate-600 mt-1">
{centralAngleValue / 2}° = ½ × {centralAngleValue}° {centralAngleValue / 2}° = ½ × {centralAngleValue}°

View File

@ -1,7 +1,14 @@
import React, { useState } from 'react'; import { useState } from "react";
import { MousePointerClick } from 'lucide-react'; import { MousePointerClick } from "lucide-react";
export type SegmentType = 'ic' | 'dc' | 'modifier' | 'conjunction' | 'punct' | 'subject' | 'verb'; export type SegmentType =
| "ic"
| "dc"
| "modifier"
| "conjunction"
| "punct"
| "subject"
| "verb";
export interface Segment { export interface Segment {
text: string; text: string;
@ -19,52 +26,95 @@ interface ClauseBreakdownWidgetProps {
accentColor?: string; accentColor?: string;
} }
const TYPE_STYLES: Record<SegmentType, { bg: string; text: string; border: string; ring: string }> = { const TYPE_STYLES: Record<
ic: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', ring: '#93c5fd' }, SegmentType,
dc: { bg: 'bg-green-100', text: 'text-green-800', border: 'border-green-300', ring: '#86efac' }, { bg: string; text: string; border: string; ring: string }
modifier: { bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-300', ring: '#fdba74' }, > = {
conjunction: { bg: 'bg-purple-100', text: 'text-purple-800', border: 'border-purple-300', ring: '#c4b5fd' }, ic: {
subject: { bg: 'bg-sky-100', text: 'text-sky-800', border: 'border-sky-300', ring: '#7dd3fc' }, bg: "bg-blue-100",
verb: { bg: 'bg-rose-100', text: 'text-rose-800', border: 'border-rose-300', ring: '#fda4af' }, text: "text-blue-800",
punct: { bg: 'bg-gray-100', text: 'text-gray-600', border: 'border-gray-300', ring: '#d1d5db' }, border: "border-blue-300",
ring: "#93c5fd",
},
dc: {
bg: "bg-green-100",
text: "text-green-800",
border: "border-green-300",
ring: "#86efac",
},
modifier: {
bg: "bg-orange-100",
text: "text-orange-800",
border: "border-orange-300",
ring: "#fdba74",
},
conjunction: {
bg: "bg-purple-100",
text: "text-purple-800",
border: "border-purple-300",
ring: "#c4b5fd",
},
subject: {
bg: "bg-sky-100",
text: "text-sky-800",
border: "border-sky-300",
ring: "#7dd3fc",
},
verb: {
bg: "bg-rose-100",
text: "text-rose-800",
border: "border-rose-300",
ring: "#fda4af",
},
punct: {
bg: "bg-gray-100",
text: "text-gray-600",
border: "border-gray-300",
ring: "#d1d5db",
},
}; };
const TYPE_LABELS: Record<SegmentType, string> = { const TYPE_LABELS: Record<SegmentType, string> = {
ic: 'Independent Clause', ic: "Independent Clause",
dc: 'Dependent Clause', dc: "Dependent Clause",
modifier: 'Modifier', modifier: "Modifier",
conjunction: 'Conjunction', conjunction: "Conjunction",
subject: 'Subject', subject: "Subject",
verb: 'Verb / Predicate', verb: "Verb / Predicate",
punct: 'Punctuation', punct: "Punctuation",
}; };
// Pre-resolved tab accent classes (avoids Tailwind purge issues with dynamic strings) // Pre-resolved tab accent classes (avoids Tailwind purge issues with dynamic strings)
const TAB_ACTIVE: Record<string, string> = { const TAB_ACTIVE: Record<string, string> = {
purple: 'border-b-2 border-purple-600 text-purple-700 bg-white', purple: "border-b-2 border-purple-600 text-purple-700 bg-white",
teal: 'border-b-2 border-teal-600 text-teal-700 bg-white', teal: "border-b-2 border-teal-600 text-teal-700 bg-white",
fuchsia: 'border-b-2 border-fuchsia-600 text-fuchsia-700 bg-white', fuchsia: "border-b-2 border-fuchsia-600 text-fuchsia-700 bg-white",
amber: 'border-b-2 border-amber-600 text-amber-700 bg-white', amber: "border-b-2 border-amber-600 text-amber-700 bg-white",
}; };
export default function ClauseBreakdownWidget({ examples, accentColor = 'purple' }: ClauseBreakdownWidgetProps) { export default function ClauseBreakdownWidget({
examples,
accentColor = "purple",
}: ClauseBreakdownWidgetProps) {
const [activeTab, setActiveTab] = useState(0); const [activeTab, setActiveTab] = useState(0);
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const example = examples[activeTab]; const example = examples[activeTab];
const switchTab = (i: number) => { setActiveTab(i); setSelected(null); }; const switchTab = (i: number) => {
setActiveTab(i);
setSelected(null);
};
const selectedSeg = selected !== null ? example.segments[selected] : null; const selectedSeg = selected !== null ? example.segments[selected] : null;
const tabActive = TAB_ACTIVE[accentColor] ?? TAB_ACTIVE.purple; const tabActive = TAB_ACTIVE[accentColor] ?? TAB_ACTIVE.purple;
// Unique labeled segment types for the legend // Unique labeled segment types for the legend
const legendTypes = Array.from( const legendTypes = Array.from(
new Set(example.segments.filter(s => s.label).map(s => s.type)) new Set(example.segments.filter((s) => s.label).map((s) => s.type)),
); );
return ( return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm"> <div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
{/* Tab strip */} {/* Tab strip */}
{examples.length > 1 && ( {examples.length > 1 && (
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto"> <div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
@ -73,7 +123,9 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
key={i} key={i}
onClick={() => switchTab(i)} onClick={() => switchTab(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${ className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeTab ? tabActive : 'text-gray-500 hover:text-gray-700' i === activeTab
? tabActive
: "text-gray-500 hover:text-gray-700"
}`} }`}
> >
{ex.title} {ex.title}
@ -83,14 +135,18 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
)} )}
{examples.length === 1 && ( {examples.length === 1 && (
<div className="px-5 pt-4 pb-1"> <div className="px-5 pt-4 pb-1">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-400">{example.title}</p> <p className="text-xs font-semibold uppercase tracking-wider text-gray-400">
{example.title}
</p>
</div> </div>
)} )}
{/* Instruction */} {/* Instruction */}
<div className="px-5 pt-3 pb-1 flex items-center gap-1.5"> <div className="px-5 pt-3 pb-1 flex items-center gap-1.5">
<MousePointerClick className="w-3.5 h-3.5 text-gray-400 shrink-0" /> <MousePointerClick className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<p className="text-xs text-gray-400 italic">Click any colored part to see its grammatical role</p> <p className="text-xs text-gray-400 italic">
Click any colored part to see its grammatical role
</p>
</div> </div>
{/* Sentence display */} {/* Sentence display */}
@ -99,7 +155,11 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
{example.segments.map((seg, i) => { {example.segments.map((seg, i) => {
if (!seg.label) { if (!seg.label) {
// Punctuation / unlabeled — plain unstyled text, not clickable // Punctuation / unlabeled — plain unstyled text, not clickable
return <span key={i} className="text-gray-700">{seg.text}</span>; return (
<span key={i} className="text-gray-700">
{seg.text}
</span>
);
} }
const style = TYPE_STYLES[seg.type]; const style = TYPE_STYLES[seg.type];
const isSelected = selected === i; const isSelected = selected === i;
@ -112,7 +172,14 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
? `border-2 ${style.border} font-semibold` ? `border-2 ${style.border} font-semibold`
: `border ${style.border} hover:opacity-80` : `border ${style.border} hover:opacity-80`
}`} }`}
style={isSelected ? { outline: `2.5px solid ${style.ring}`, outlineOffset: '1px' } : {}} style={
isSelected
? {
outline: `2.5px solid ${style.ring}`,
outlineOffset: "1px",
}
: {}
}
> >
{seg.text} {seg.text}
</span> </span>
@ -130,29 +197,38 @@ export default function ClauseBreakdownWidget({ examples, accentColor = 'purple'
style={{ backgroundColor: TYPE_STYLES[selectedSeg.type].ring }} style={{ backgroundColor: TYPE_STYLES[selectedSeg.type].ring }}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-xs font-bold uppercase tracking-wider mb-0.5 ${TYPE_STYLES[selectedSeg.type].text}`}> <p
className={`text-xs font-bold uppercase tracking-wider mb-0.5 ${TYPE_STYLES[selectedSeg.type].text}`}
>
{selectedSeg.label ?? TYPE_LABELS[selectedSeg.type]} {selectedSeg.label ?? TYPE_LABELS[selectedSeg.type]}
</p> </p>
<p className={`text-sm font-semibold leading-snug ${TYPE_STYLES[selectedSeg.type].text}`}> <p
className={`text-sm font-semibold leading-snug ${TYPE_STYLES[selectedSeg.type].text}`}
>
"{selectedSeg.text.trim()}" "{selectedSeg.text.trim()}"
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<p className="mt-2 text-xs text-gray-400 italic px-1">No element selected click a colored span above.</p> <p className="mt-2 text-xs text-gray-400 italic px-1">
No element selected click a colored span above.
</p>
)} )}
</div> </div>
{/* Legend */} {/* Legend */}
<div className="px-5 py-3 border-t border-gray-100 bg-gray-50 flex flex-wrap gap-2"> <div className="px-5 py-3 border-t border-gray-100 bg-gray-50 flex flex-wrap gap-2">
{legendTypes.map(type => { {legendTypes.map((type) => {
const style = TYPE_STYLES[type]; const style = TYPE_STYLES[type];
return ( return (
<span <span
key={type} key={type}
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border ${style.bg} ${style.text} ${style.border}`} className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border ${style.bg} ${style.text} ${style.border}`}
> >
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: TYPE_STYLES[type].ring }} /> <span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: TYPE_STYLES[type].ring }}
/>
{TYPE_LABELS[type]} {TYPE_LABELS[type]}
</span> </span>
); );

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import { useState } from "react";
import { CheckCircle2, RotateCcw, ChevronRight } from 'lucide-react'; import { CheckCircle2, RotateCcw, ChevronRight } from "lucide-react";
export interface VocabOption { export interface VocabOption {
id: string; id: string;
@ -20,41 +20,58 @@ interface ContextEliminationWidgetProps {
accentColor?: string; accentColor?: string;
} }
export default function ContextEliminationWidget({ exercises, accentColor = 'rose' }: ContextEliminationWidgetProps) { export default function ContextEliminationWidget({
exercises,
accentColor = "rose",
}: ContextEliminationWidgetProps) {
const [activeEx, setActiveEx] = useState(0); const [activeEx, setActiveEx] = useState(0);
const [eliminated, setEliminated] = useState<Set<string>>(new Set()); const [eliminated, setEliminated] = useState<Set<string>>(new Set());
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
const [triedCorrect, setTriedCorrect] = useState(false); const [triedCorrect, setTriedCorrect] = useState(false);
const exercise = exercises[activeEx]; const exercise = exercises[activeEx];
const wrongIds = exercise.options.filter(o => !o.isCorrect).map(o => o.id); const wrongIds = exercise.options
const allWrongEliminated = wrongIds.every(id => eliminated.has(id)); .filter((o) => !o.isCorrect)
.map((o) => o.id);
const eliminate = (id: string) => { const eliminate = (id: string) => {
const opt = exercise.options.find(o => o.id === id)!; const opt = exercise.options.find((o) => o.id === id)!;
if (opt.isCorrect) { if (opt.isCorrect) {
setTriedCorrect(true); setTriedCorrect(true);
setTimeout(() => setTriedCorrect(false), 1500); setTimeout(() => setTriedCorrect(false), 1500);
} else { } else {
const newElim = new Set([...eliminated, id]); const newElim = new Set([...eliminated, id]);
setEliminated(newElim); setEliminated(newElim);
if (wrongIds.every(wid => newElim.has(wid))) { if (wrongIds.every((wid) => newElim.has(wid))) {
setRevealed(true); setRevealed(true);
} }
} }
}; };
const reset = () => { setEliminated(new Set()); setRevealed(false); setTriedCorrect(false); }; const reset = () => {
const switchEx = (i: number) => { setActiveEx(i); setEliminated(new Set()); setRevealed(false); setTriedCorrect(false); }; setEliminated(new Set());
setRevealed(false);
setTriedCorrect(false);
};
const switchEx = (i: number) => {
setActiveEx(i);
setEliminated(new Set());
setRevealed(false);
setTriedCorrect(false);
};
// Highlight the target word in the sentence // Highlight the target word in the sentence
const renderSentence = () => { const renderSentence = () => {
const idx = exercise.sentence.toLowerCase().indexOf(exercise.word.toLowerCase()); const idx = exercise.sentence
.toLowerCase()
.indexOf(exercise.word.toLowerCase());
if (idx === -1) return <>{exercise.sentence}</>; if (idx === -1) return <>{exercise.sentence}</>;
return ( return (
<> <>
{exercise.sentence.slice(0, idx)} {exercise.sentence.slice(0, idx)}
<mark className={`bg-${accentColor}-200 text-${accentColor}-900 font-bold px-0.5 rounded not-italic`}> <mark
className={`bg-${accentColor}-200 text-${accentColor}-900 font-bold px-0.5 rounded not-italic`}
>
{exercise.sentence.slice(idx, idx + exercise.word.length)} {exercise.sentence.slice(idx, idx + exercise.word.length)}
</mark> </mark>
{exercise.sentence.slice(idx + exercise.word.length)} {exercise.sentence.slice(idx + exercise.word.length)}
@ -74,7 +91,7 @@ export default function ContextEliminationWidget({ exercises, accentColor = 'ros
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${ className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeEx i === activeEx
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700` ? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
: 'text-gray-500 hover:text-gray-700' : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
Word {i + 1} Word {i + 1}
@ -84,17 +101,27 @@ export default function ContextEliminationWidget({ exercises, accentColor = 'ros
)} )}
{/* Sentence in context */} {/* Sentence in context */}
<div className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}> <div
<p className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-2`}>Sentence in Context</p> className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}
<p className="text-gray-700 italic leading-relaxed text-sm">{renderSentence()}</p> >
<p
className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-2`}
>
Sentence in Context
</p>
<p className="text-gray-700 italic leading-relaxed text-sm">
{renderSentence()}
</p>
</div> </div>
{/* Question + instruction */} {/* Question + instruction */}
<div className="px-5 pt-4 pb-2"> <div className="px-5 pt-4 pb-2">
<p className="font-medium text-gray-800 text-sm mb-1">{exercise.question}</p> <p className="font-medium text-gray-800 text-sm mb-1">
{exercise.question}
</p>
<p className="text-xs text-gray-400 italic"> <p className="text-xs text-gray-400 italic">
{revealed {revealed
? 'You found it! The correct definition is highlighted.' ? "You found it! The correct definition is highlighted."
: 'Click "Eliminate" on definitions that don\'t fit the context. Work by elimination.'} : 'Click "Eliminate" on definitions that don\'t fit the context. Work by elimination.'}
</p> </p>
</div> </div>
@ -108,40 +135,52 @@ export default function ContextEliminationWidget({ exercises, accentColor = 'ros
{/* Options */} {/* Options */}
<div className="px-5 py-3 space-y-2"> <div className="px-5 py-3 space-y-2">
{exercise.options.map(opt => { {exercise.options.map((opt) => {
const isElim = eliminated.has(opt.id); const isElim = eliminated.has(opt.id);
const isAnswer = opt.isCorrect && revealed; const isAnswer = opt.isCorrect && revealed;
let wrapCls = 'border-gray-200 bg-white'; let wrapCls = "border-gray-200 bg-white";
if (isAnswer) wrapCls = 'border-green-400 bg-green-50'; if (isAnswer) wrapCls = "border-green-400 bg-green-50";
else if (isElim) wrapCls = 'border-gray-100 bg-gray-50'; else if (isElim) wrapCls = "border-gray-100 bg-gray-50";
return ( return (
<div <div
key={opt.id} key={opt.id}
className={`rounded-xl border px-4 py-3 transition-all ${wrapCls} ${isElim ? 'opacity-50' : ''}`} className={`rounded-xl border px-4 py-3 transition-all ${wrapCls} ${isElim ? "opacity-50" : ""}`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className={`text-xs font-bold mt-0.5 shrink-0 ${isElim ? 'text-gray-400' : isAnswer ? 'text-green-700' : 'text-gray-500'}`}> <span
className={`text-xs font-bold mt-0.5 shrink-0 ${isElim ? "text-gray-400" : isAnswer ? "text-green-700" : "text-gray-500"}`}
>
{opt.id}. {opt.id}.
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${ <p
isElim ? 'text-gray-400 line-through' : className={`text-sm leading-snug ${
isAnswer ? 'text-green-800 font-semibold' : isElim
'text-gray-700' ? "text-gray-400 line-through"
}`}> : isAnswer
? "text-green-800 font-semibold"
: "text-gray-700"
}`}
>
{opt.definition} {opt.definition}
</p> </p>
{isElim && ( {isElim && (
<p className="text-xs text-gray-400 mt-0.5 italic">{opt.elimReason}</p> <p className="text-xs text-gray-400 mt-0.5 italic">
{opt.elimReason}
</p>
)} )}
{isAnswer && ( {isAnswer && (
<p className="text-xs text-green-700 mt-1">✓ {opt.elimReason}</p> <p className="text-xs text-green-700 mt-1">
{opt.elimReason}
</p>
)} )}
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{isAnswer && <CheckCircle2 className="w-5 h-5 text-green-500" />} {isAnswer && (
<CheckCircle2 className="w-5 h-5 text-green-500" />
)}
{!isElim && !isAnswer && !revealed && ( {!isElim && !isAnswer && !revealed && (
<button <button
onClick={() => eliminate(opt.id)} onClick={() => eliminate(opt.id)}
@ -158,7 +197,10 @@ export default function ContextEliminationWidget({ exercises, accentColor = 'ros
</div> </div>
<div className="px-5 pb-5 flex items-center gap-3"> <div className="px-5 pb-5 flex items-center gap-3">
<button onClick={reset} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"> <button
onClick={reset}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> Reset <RotateCcw className="w-3.5 h-3.5" /> Reset
</button> </button>
{revealed && activeEx < exercises.length - 1 && ( {revealed && activeEx < exercises.length - 1 && (

View File

@ -1,6 +1,11 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState } from "react";
import { scaleToSvg, scaleFromSvg, round, calculateDistanceSquared } from '../utils/math'; import {
import { CircleState, Point } from '../types'; scaleToSvg,
scaleFromSvg,
round,
calculateDistanceSquared,
} from "../../utils/math";
import { type CircleState, type Point } from "../../types/lesson";
interface CoordinatePlaneProps { interface CoordinatePlaneProps {
circle: CircleState; circle: CircleState;
@ -8,7 +13,7 @@ interface CoordinatePlaneProps {
onPointClick?: (p: Point) => void; onPointClick?: (p: Point) => void;
interactive?: boolean; interactive?: boolean;
showDistance?: boolean; showDistance?: boolean;
mode?: 'view' | 'place_point'; mode?: "view" | "place_point";
} }
const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
@ -16,7 +21,7 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
point, point,
onPointClick, onPointClick,
showDistance = false, showDistance = false,
mode = 'view' mode = "view",
}) => { }) => {
const svgRef = useRef<SVGSVGElement>(null); const svgRef = useRef<SVGSVGElement>(null);
const [hoverPoint, setHoverPoint] = useState<Point | null>(null); const [hoverPoint, setHoverPoint] = useState<Point | null>(null);
@ -39,7 +44,7 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
const rPx = toX(circle.r) - toX(0); const rPx = toX(circle.r) - toX(0);
const handleMouseMove = (e: React.MouseEvent) => { const handleMouseMove = (e: React.MouseEvent) => {
if (mode !== 'place_point' || !svgRef.current) return; if (mode !== "place_point" || !svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect(); const rect = svgRef.current.getBoundingClientRect();
const rawX = e.clientX - rect.left; const rawX = e.clientX - rect.left;
const rawY = e.clientY - rect.top; const rawY = e.clientY - rect.top;
@ -52,7 +57,7 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
}; };
const handleClick = () => { const handleClick = () => {
if (mode === 'place_point' && hoverPoint && onPointClick) { if (mode === "place_point" && hoverPoint && onPointClick) {
onPointClick(hoverPoint); onPointClick(hoverPoint);
} }
}; };
@ -64,11 +69,13 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
ticks.push(i); ticks.push(i);
} }
const dSquared = point ? calculateDistanceSquared(point.x, point.y, circle.h, circle.k) : 0; const dSquared = point
? calculateDistanceSquared(point.x, point.y, circle.h, circle.k)
: 0;
const isInside = dSquared < circle.r * circle.r; const isInside = dSquared < circle.r * circle.r;
const isOn = Math.abs(dSquared - circle.r * circle.r) < 0.01; const isOn = Math.abs(dSquared - circle.r * circle.r) < 0.01;
const pointColor = isOn ? 'text-yellow-600' : isInside ? 'text-green-600' : 'text-red-600';
const pointFill = isOn ? '#ca8a04' : isInside ? '#16a34a' : '#dc2626'; const pointFill = isOn ? "#ca8a04" : isInside ? "#16a34a" : "#dc2626";
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
@ -80,19 +87,47 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={() => setHoverPoint(null)} onMouseLeave={() => setHoverPoint(null)}
onClick={handleClick} onClick={handleClick}
className={`${mode === 'place_point' ? 'cursor-crosshair' : 'cursor-default'}`} className={`${mode === "place_point" ? "cursor-crosshair" : "cursor-default"}`}
> >
{/* Grid Background */} {/* Grid Background */}
{ticks.map(t => ( {ticks.map((t) => (
<React.Fragment key={t}> <React.Fragment key={t}>
<line x1={toX(t)} y1={0} x2={toX(t)} y2={height} stroke="#e2e8f0" strokeWidth="1" /> <line
<line x1={0} y1={toY(t)} x2={width} y2={toY(t)} stroke="#e2e8f0" strokeWidth="1" /> x1={toX(t)}
y1={0}
x2={toX(t)}
y2={height}
stroke="#e2e8f0"
strokeWidth="1"
/>
<line
x1={0}
y1={toY(t)}
x2={width}
y2={toY(t)}
stroke="#e2e8f0"
strokeWidth="1"
/>
</React.Fragment> </React.Fragment>
))} ))}
{/* Axes */} {/* Axes */}
<line x1={toX(0)} y1={0} x2={toX(0)} y2={height} stroke="#64748b" strokeWidth="2" /> <line
<line x1={0} y1={toY(0)} x2={width} y2={toY(0)} stroke="#64748b" strokeWidth="2" /> x1={toX(0)}
y1={0}
x2={toX(0)}
y2={height}
stroke="#64748b"
strokeWidth="2"
/>
<line
x1={0}
y1={toY(0)}
x2={width}
y2={toY(0)}
stroke="#64748b"
strokeWidth="2"
/>
{/* Circle */} {/* Circle */}
<circle <circle
@ -107,7 +142,15 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
{/* Center Point */} {/* Center Point */}
<circle cx={cx} cy={cy} r={4} fill="#4f46e5" /> <circle cx={cx} cy={cy} r={4} fill="#4f46e5" />
<text x={cx + 8} y={cy - 8} fontSize="12" fill="#4f46e5" fontWeight="bold">Center ({circle.h}, {circle.k})</text> <text
x={cx + 8}
y={cy - 8}
fontSize="12"
fill="#4f46e5"
fontWeight="bold"
>
Center ({circle.h}, {circle.k})
</text>
{/* Radius Line (only if distance line is not active to avoid clutter) */} {/* Radius Line (only if distance line is not active to avoid clutter) */}
{!point && ( {!point && (
@ -122,7 +165,9 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
/> />
)} )}
{!point && ( {!point && (
<text x={cx + rPx/2} y={cy - 5} fontSize="12" fill="#4f46e5">r = {circle.r}</text> <text x={cx + rPx / 2} y={cy - 5} fontSize="12" fill="#4f46e5">
r = {circle.r}
</text>
)} )}
{/* Placed Point */} {/* Placed Point */}
@ -137,16 +182,34 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
strokeWidth="2" strokeWidth="2"
strokeDasharray="4,4" strokeDasharray="4,4"
/> />
<circle cx={toX(point.x)} cy={toY(point.y)} r={6} fill={pointFill} stroke="white" strokeWidth="2" /> <circle
<text x={toX(point.x) + 8} y={toY(point.y) - 8} fontSize="12" fontWeight="bold" fill={pointFill}> cx={toX(point.x)}
cy={toY(point.y)}
r={6}
fill={pointFill}
stroke="white"
strokeWidth="2"
/>
<text
x={toX(point.x) + 8}
y={toY(point.y) - 8}
fontSize="12"
fontWeight="bold"
fill={pointFill}
>
({point.x}, {point.y}) ({point.x}, {point.y})
</text> </text>
</> </>
)} )}
{/* Hover Ghost Point */} {/* Hover Ghost Point */}
{mode === 'place_point' && hoverPoint && !point && ( {mode === "place_point" && hoverPoint && !point && (
<circle cx={toX(hoverPoint.x)} cy={toY(hoverPoint.y)} r={4} fill="rgba(0,0,0,0.3)" /> <circle
cx={toX(hoverPoint.x)}
cy={toY(hoverPoint.y)}
r={4}
fill="rgba(0,0,0,0.3)"
/>
)} )}
</svg> </svg>
@ -157,25 +220,42 @@ const CoordinatePlane: React.FC<CoordinatePlaneProps> = ({
{/* Info Panel below graph */} {/* Info Panel below graph */}
{point && showDistance && ( {point && showDistance && (
<div className={`mt-4 p-4 rounded-lg border-l-4 w-full max-w-md bg-white shadow-sm transition-colors ${ <div
isOn ? 'border-yellow-500 bg-yellow-50' : className={`mt-4 p-4 rounded-lg border-l-4 w-full max-w-md bg-white shadow-sm transition-colors ${
isInside ? 'border-green-500 bg-green-50' : isOn
'border-red-500 bg-red-50' ? "border-yellow-500 bg-yellow-50"
}`}> : isInside
? "border-green-500 bg-green-50"
: "border-red-500 bg-red-50"
}`}
>
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="font-bold text-slate-700">Distance Check:</span> <span className="font-bold text-slate-700">Distance Check:</span>
<span className={`px-2 py-0.5 rounded text-sm font-bold uppercase ${ <span
isOn ? 'bg-yellow-200 text-yellow-800' : className={`px-2 py-0.5 rounded text-sm font-bold uppercase ${
isInside ? 'bg-green-200 text-green-800' : isOn
'bg-red-200 text-red-800' ? "bg-yellow-200 text-yellow-800"
}`}> : isInside
{isOn ? 'On Circle' : isInside ? 'Inside' : 'Outside'} ? "bg-green-200 text-green-800"
: "bg-red-200 text-red-800"
}`}
>
{isOn ? "On Circle" : isInside ? "Inside" : "Outside"}
</span> </span>
</div> </div>
<div className="font-mono text-sm space-y-1"> <div className="font-mono text-sm space-y-1">
<p>d² = (x - h)² + (y - k)²</p> <p>d² = (x - h)² + (y - k)²</p>
<p>d² = ({point.x} - {circle.h})² + ({point.y} - {circle.k})²</p> <p>
<p>d² = {round(calculateDistanceSquared(point.x, point.y, circle.h, circle.k))} <span className="mx-2 text-slate-400">vs</span> r² = {circle.r * circle.r}</p> d² = ({point.x} - {circle.h})² + ({point.y} - {circle.k})²
</p>
<p>
d² ={" "}
{round(
calculateDistanceSquared(point.x, point.y, circle.h, circle.k),
)}{" "}
<span className="mx-2 text-slate-400">vs</span> r² ={" "}
{circle.r * circle.r}
</p>
</div> </div>
</div> </div>
)} )}

View File

@ -1,9 +1,9 @@
import React, { useState } from 'react'; import { useState } from "react";
import { CheckCircle2, XCircle, RotateCcw } from 'lucide-react'; import { CheckCircle2, XCircle, RotateCcw } from "lucide-react";
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
export type Verdict = 'supported' | 'contradicted' | 'neither'; export type Verdict = "supported" | "contradicted" | "neither";
export interface ChartSeries { export interface ChartSeries {
name: string; name: string;
@ -11,7 +11,7 @@ export interface ChartSeries {
} }
export interface ChartData { export interface ChartData {
type: 'bar' | 'line'; type: "bar" | "line";
title: string; title: string;
yLabel?: string; yLabel?: string;
xLabel?: string; xLabel?: string;
@ -34,15 +34,24 @@ export interface DataExercise {
// ── Chart palette ────────────────────────────────────────────────────────── // ── Chart palette ──────────────────────────────────────────────────────────
const PALETTE = ['#3b82f6', '#8b5cf6', '#f97316', '#10b981', '#ef4444', '#ec4899']; const PALETTE = [
"#3b82f6",
"#8b5cf6",
"#f97316",
"#10b981",
"#ef4444",
"#ec4899",
];
// ── BarChart ─────────────────────────────────────────────────────────────── // ── BarChart ───────────────────────────────────────────────────────────────
function BarChart({ chart }: { chart: ChartData }) { function BarChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(null); const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(
null,
);
const labels = chart.series[0].data.map(d => d.label); const labels = chart.series[0].data.map((d) => d.label);
const allValues = chart.series.flatMap(s => s.data.map(d => d.value)); const allValues = chart.series.flatMap((s) => s.data.map((d) => d.value));
const maxVal = Math.max(...allValues); const maxVal = Math.max(...allValues);
// Round up max to nearest 10 for cleaner y-axis // Round up max to nearest 10 for cleaner y-axis
const yMax = Math.ceil(maxVal / 10) * 10; const yMax = Math.ceil(maxVal / 10) * 10;
@ -52,22 +61,36 @@ function BarChart({ chart }: { chart: ChartData }) {
return ( return (
<div className="px-2"> <div className="px-2">
<p className="text-xs font-semibold text-gray-600 text-center mb-4">{chart.title}</p> <p className="text-xs font-semibold text-gray-600 text-center mb-4">
{chart.title}
</p>
<div className="flex gap-2"> <div className="flex gap-2">
{/* Y-axis */} {/* Y-axis */}
<div className="flex flex-col-reverse justify-between items-end pr-1" style={{ height: chartH, minWidth: 32 }}> <div
{yTicks.map(t => ( className="flex flex-col-reverse justify-between items-end pr-1"
<span key={t} className="text-[10px] text-gray-400 leading-none">{t}{chart.unit ?? ''}</span> style={{ height: chartH, minWidth: 32 }}
>
{yTicks.map((t) => (
<span key={t} className="text-[10px] text-gray-400 leading-none">
{t}
{chart.unit ?? ""}
</span>
))} ))}
</div> </div>
{/* Bar groups */} {/* Bar groups */}
<div className="flex-1 flex items-end gap-2 border-b border-l border-gray-300" style={{ height: chartH }}> <div
{labels.map((label, pi) => ( className="flex-1 flex items-end gap-2 border-b border-l border-gray-300"
style={{ height: chartH }}
>
{labels.map((_, pi) => (
<div key={pi} className="flex-1 flex flex-col items-center gap-0"> <div key={pi} className="flex-1 flex flex-col items-center gap-0">
{/* Bar group */} {/* Bar group */}
<div className="w-full flex items-end gap-0.5" style={{ height: chartH - 2 }}> <div
className="w-full flex items-end gap-0.5"
style={{ height: chartH - 2 }}
>
{chart.series.map((s, si) => { {chart.series.map((s, si) => {
const val = s.data[pi].value; const val = s.data[pi].value;
const heightPct = (val / yMax) * 100; const heightPct = (val / yMax) * 100;
@ -79,9 +102,11 @@ function BarChart({ chart }: { chart: ChartData }) {
style={{ style={{
height: `${heightPct}%`, height: `${heightPct}%`,
backgroundColor: isHov backgroundColor: isHov
? PALETTE[si % PALETTE.length] + 'dd' ? PALETTE[si % PALETTE.length] + "dd"
: PALETTE[si % PALETTE.length] + 'cc', : PALETTE[si % PALETTE.length] + "cc",
outline: isHov ? `2px solid ${PALETTE[si % PALETTE.length]}` : 'none', outline: isHov
? `2px solid ${PALETTE[si % PALETTE.length]}`
: "none",
}} }}
onMouseEnter={() => setHovered({ si, pi })} onMouseEnter={() => setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
@ -90,9 +115,12 @@ function BarChart({ chart }: { chart: ChartData }) {
{isHov && ( {isHov && (
<div <div
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap z-10 pointer-events-none" className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-white whitespace-nowrap z-10 pointer-events-none"
style={{ backgroundColor: PALETTE[si % PALETTE.length] }} style={{
backgroundColor: PALETTE[si % PALETTE.length],
}}
> >
{val}{chart.unit ?? ''} {val}
{chart.unit ?? ""}
</div> </div>
)} )}
</div> </div>
@ -107,17 +135,32 @@ function BarChart({ chart }: { chart: ChartData }) {
{/* X-axis labels */} {/* X-axis labels */}
<div className="flex gap-2 ml-10 mt-1"> <div className="flex gap-2 ml-10 mt-1">
{labels.map((label, i) => ( {labels.map((label, i) => (
<div key={i} className="flex-1 text-center text-[10px] text-gray-500 leading-tight">{label}</div> <div
key={i}
className="flex-1 text-center text-[10px] text-gray-500 leading-tight"
>
{label}
</div>
))} ))}
</div> </div>
{chart.xLabel && <p className="text-[10px] text-gray-400 text-center mt-1">{chart.xLabel}</p>} {chart.xLabel && (
<p className="text-[10px] text-gray-400 text-center mt-1">
{chart.xLabel}
</p>
)}
{/* Legend */} {/* Legend */}
{chart.series.length > 1 && ( {chart.series.length > 1 && (
<div className="flex flex-wrap gap-3 mt-3 justify-center"> <div className="flex flex-wrap gap-3 mt-3 justify-center">
{chart.series.map((s, si) => ( {chart.series.map((s, si) => (
<div key={si} className="flex items-center gap-1.5 text-xs text-gray-600"> <div
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: PALETTE[si % PALETTE.length] }} /> key={si}
className="flex items-center gap-1.5 text-xs text-gray-600"
>
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: PALETTE[si % PALETTE.length] }}
/>
{s.name} {s.name}
</div> </div>
))} ))}
@ -127,17 +170,26 @@ function BarChart({ chart }: { chart: ChartData }) {
{/* Hover info bar */} {/* Hover info bar */}
{hovered && ( {hovered && (
<div className="mt-3 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3"> <div className="mt-3 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3">
<span className="font-semibold" style={{ color: PALETTE[hovered.si % PALETTE.length] }}> <span
className="font-semibold"
style={{ color: PALETTE[hovered.si % PALETTE.length] }}
>
{chart.series[hovered.si].name} {chart.series[hovered.si].name}
</span> </span>
{''} {""}
{chart.series[0].data[hovered.pi].label}: <span className="font-semibold"> {chart.series[0].data[hovered.pi].label}:{" "}
{chart.series[hovered.si].data[hovered.pi].value}{chart.unit ?? ''} <span className="font-semibold">
{chart.series[hovered.si].data[hovered.pi].value}
{chart.unit ?? ""}
</span> </span>
</div> </div>
)} )}
{chart.source && <p className="text-[10px] text-gray-400 text-center mt-2">Source: {chart.source}</p>} {chart.source && (
<p className="text-[10px] text-gray-400 text-center mt-2">
Source: {chart.source}
</p>
)}
</div> </div>
); );
} }
@ -145,14 +197,17 @@ function BarChart({ chart }: { chart: ChartData }) {
// ── LineChart ────────────────────────────────────────────────────────────── // ── LineChart ──────────────────────────────────────────────────────────────
function LineChart({ chart }: { chart: ChartData }) { function LineChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(null); const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(
null,
);
const W = 480, H = 200; const W = 480,
H = 200;
const PAD = { top: 20, right: 20, bottom: 36, left: 48 }; const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
const cW = W - PAD.left - PAD.right; const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom; const cH = H - PAD.top - PAD.bottom;
const allValues = chart.series.flatMap(s => s.data.map(d => d.value)); const allValues = chart.series.flatMap((s) => s.data.map((d) => d.value));
const minVal = Math.min(...allValues); const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues); const maxVal = Math.max(...allValues);
const spread = maxVal - minVal || 1; const spread = maxVal - minVal || 1;
@ -163,28 +218,51 @@ function LineChart({ chart }: { chart: ChartData }) {
const yMax = maxVal + yPad; const yMax = maxVal + yPad;
const yRange = yMax - yMin; const yRange = yMax - yMin;
const labels = chart.series[0].data.map(d => d.label); const labels = chart.series[0].data.map((d) => d.label);
const xStep = cW / (labels.length - 1); const xStep = cW / (labels.length - 1);
const xPos = (i: number) => PAD.left + i * xStep; const xPos = (i: number) => PAD.left + i * xStep;
const yPos = (v: number) => PAD.top + cH - ((v - yMin) / yRange) * cH; const yPos = (v: number) => PAD.top + cH - ((v - yMin) / yRange) * cH;
// Y-axis ticks: 5 evenly spaced // Y-axis ticks: 5 evenly spaced
const yTicks = Array.from({ length: 5 }, (_, i) => minVal + ((maxVal - minVal) / 4) * i); const yTicks = Array.from(
{ length: 5 },
(_, i) => minVal + ((maxVal - minVal) / 4) * i,
);
return ( return (
<div> <div>
<p className="text-xs font-semibold text-gray-600 text-center mb-2">{chart.title}</p> <p className="text-xs font-semibold text-gray-600 text-center mb-2">
{chart.title}
</p>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" style={{ maxHeight: 220 }}> <svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ maxHeight: 220 }}
>
{/* Grid lines */} {/* Grid lines */}
{yTicks.map((t, i) => { {yTicks.map((t, i) => {
const y = yPos(t); const y = yPos(t);
return ( return (
<g key={i}> <g key={i}>
<line x1={PAD.left} x2={W - PAD.right} y1={y} y2={y} stroke="#e5e7eb" strokeWidth="1" /> <line
<text x={PAD.left - 4} y={y + 3.5} textAnchor="end" fontSize="9" fill="#9ca3af"> x1={PAD.left}
{t % 1 === 0 ? t : t.toFixed(2)}{chart.unit ?? ''} x2={W - PAD.right}
y1={y}
y2={y}
stroke="#e5e7eb"
strokeWidth="1"
/>
<text
x={PAD.left - 4}
y={y + 3.5}
textAnchor="end"
fontSize="9"
fill="#9ca3af"
>
{t % 1 === 0 ? t : t.toFixed(2)}
{chart.unit ?? ""}
</text> </text>
</g> </g>
); );
@ -193,10 +271,18 @@ function LineChart({ chart }: { chart: ChartData }) {
{/* Lines + dots */} {/* Lines + dots */}
{chart.series.map((s, si) => { {chart.series.map((s, si) => {
const color = PALETTE[si % PALETTE.length]; const color = PALETTE[si % PALETTE.length];
const pts = s.data.map((d, i) => `${xPos(i)},${yPos(d.value)}`).join(' '); const pts = s.data
.map((d, i) => `${xPos(i)},${yPos(d.value)}`)
.join(" ");
return ( return (
<g key={si}> <g key={si}>
<polyline points={pts} fill="none" stroke={color} strokeWidth="2.5" strokeLinejoin="round" /> <polyline
points={pts}
fill="none"
stroke={color}
strokeWidth="2.5"
strokeLinejoin="round"
/>
{s.data.map((d, pi) => { {s.data.map((d, pi) => {
const isHov = hovered?.si === si && hovered?.pi === pi; const isHov = hovered?.si === si && hovered?.pi === pi;
const cx = xPos(pi); const cx = xPos(pi);
@ -204,20 +290,36 @@ function LineChart({ chart }: { chart: ChartData }) {
return ( return (
<g key={pi}> <g key={pi}>
<circle <circle
cx={cx} cy={cy} r={isHov ? 7 : 5} cx={cx}
fill={color} stroke="white" strokeWidth="2" cy={cy}
style={{ cursor: 'pointer', transition: 'r 0.1s' }} r={isHov ? 7 : 5}
fill={color}
stroke="white"
strokeWidth="2"
style={{ cursor: "pointer", transition: "r 0.1s" }}
onMouseEnter={() => setHovered({ si, pi })} onMouseEnter={() => setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)} onMouseLeave={() => setHovered(null)}
/> />
{isHov && ( {isHov && (
<> <>
<rect <rect
x={cx - 28} y={cy - 26} width="56" height="18" x={cx - 28}
rx="4" fill="#1f2937" y={cy - 26}
width="56"
height="18"
rx="4"
fill="#1f2937"
/> />
<text x={cx} y={cy - 13} textAnchor="middle" fontSize="10" fill="white" fontWeight="bold"> <text
{d.value}{chart.unit ?? ''} x={cx}
y={cy - 13}
textAnchor="middle"
fontSize="10"
fill="white"
fontWeight="bold"
>
{d.value}
{chart.unit ?? ""}
</text> </text>
</> </>
)} )}
@ -230,21 +332,45 @@ function LineChart({ chart }: { chart: ChartData }) {
{/* X-axis labels */} {/* X-axis labels */}
{labels.map((label, i) => ( {labels.map((label, i) => (
<text key={i} x={xPos(i)} y={H - PAD.bottom + 14} textAnchor="middle" fontSize="9.5" fill="#6b7280"> <text
key={i}
x={xPos(i)}
y={H - PAD.bottom + 14}
textAnchor="middle"
fontSize="9.5"
fill="#6b7280"
>
{label} {label}
</text> </text>
))} ))}
{/* Axes */} {/* Axes */}
<line x1={PAD.left} x2={PAD.left} y1={PAD.top} y2={H - PAD.bottom} stroke="#d1d5db" strokeWidth="1.5" /> <line
<line x1={PAD.left} x2={W - PAD.right} y1={H - PAD.bottom} y2={H - PAD.bottom} stroke="#d1d5db" strokeWidth="1.5" /> x1={PAD.left}
x2={PAD.left}
y1={PAD.top}
y2={H - PAD.bottom}
stroke="#d1d5db"
strokeWidth="1.5"
/>
<line
x1={PAD.left}
x2={W - PAD.right}
y1={H - PAD.bottom}
y2={H - PAD.bottom}
stroke="#d1d5db"
strokeWidth="1.5"
/>
{/* Y-axis label */} {/* Y-axis label */}
{chart.yLabel && ( {chart.yLabel && (
<text <text
x={12} y={H / 2} x={12}
y={H / 2}
transform={`rotate(-90, 12, ${H / 2})`} transform={`rotate(-90, 12, ${H / 2})`}
textAnchor="middle" fontSize="9" fill="#9ca3af" textAnchor="middle"
fontSize="9"
fill="#9ca3af"
> >
{chart.yLabel} {chart.yLabel}
</text> </text>
@ -256,8 +382,14 @@ function LineChart({ chart }: { chart: ChartData }) {
{chart.series.length > 1 && ( {chart.series.length > 1 && (
<div className="flex flex-wrap gap-3 mt-1 justify-center"> <div className="flex flex-wrap gap-3 mt-1 justify-center">
{chart.series.map((s, si) => ( {chart.series.map((s, si) => (
<div key={si} className="flex items-center gap-1.5 text-xs text-gray-600"> <div
<div className="w-5 h-0.5" style={{ backgroundColor: PALETTE[si % PALETTE.length] }} /> key={si}
className="flex items-center gap-1.5 text-xs text-gray-600"
>
<div
className="w-5 h-0.5"
style={{ backgroundColor: PALETTE[si % PALETTE.length] }}
/>
{s.name} {s.name}
</div> </div>
))} ))}
@ -267,17 +399,26 @@ function LineChart({ chart }: { chart: ChartData }) {
{/* Hover tooltip */} {/* Hover tooltip */}
{hovered && ( {hovered && (
<div className="mt-2 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3"> <div className="mt-2 text-xs text-center text-gray-600 bg-gray-50 rounded-lg py-1.5 px-3">
<span className="font-semibold" style={{ color: PALETTE[hovered.si % PALETTE.length] }}> <span
className="font-semibold"
style={{ color: PALETTE[hovered.si % PALETTE.length] }}
>
{chart.series[hovered.si].name} {chart.series[hovered.si].name}
</span> </span>
{' · '} {" · "}
{chart.series[0].data[hovered.pi].label}: <span className="font-semibold"> {chart.series[0].data[hovered.pi].label}:{" "}
{chart.series[hovered.si].data[hovered.pi].value}{chart.unit ?? ''} <span className="font-semibold">
{chart.series[hovered.si].data[hovered.pi].value}
{chart.unit ?? ""}
</span> </span>
</div> </div>
)} )}
{chart.source && <p className="text-[10px] text-gray-400 text-center mt-2">Source: {chart.source}</p>} {chart.source && (
<p className="text-[10px] text-gray-400 text-center mt-2">
Source: {chart.source}
</p>
)}
</div> </div>
); );
} }
@ -285,9 +426,9 @@ function LineChart({ chart }: { chart: ChartData }) {
// ── Main widget ──────────────────────────────────────────────────────────── // ── Main widget ────────────────────────────────────────────────────────────
const VERDICT_LABELS: Record<Verdict, string> = { const VERDICT_LABELS: Record<Verdict, string> = {
supported: 'Supported by data', supported: "Supported by data",
contradicted: 'Contradicted by data', contradicted: "Contradicted by data",
neither: 'Neither proven nor disproven', neither: "Neither proven nor disproven",
}; };
interface DataClaimWidgetProps { interface DataClaimWidgetProps {
@ -296,25 +437,60 @@ interface DataClaimWidgetProps {
} }
// Pre-resolved accent classes to avoid Tailwind purge issues // Pre-resolved accent classes to avoid Tailwind purge issues
const ACCENT_CLASSES: Record<string, { tab: string; header: string; label: string; btn: string }> = { const ACCENT_CLASSES: Record<
amber: { tab: 'border-b-2 border-amber-600 text-amber-700', header: 'bg-amber-50', label: 'text-amber-600', btn: 'bg-amber-600 hover:bg-amber-700' }, string,
teal: { tab: 'border-b-2 border-teal-600 text-teal-700', header: 'bg-teal-50', label: 'text-teal-600', btn: 'bg-teal-600 hover:bg-teal-700' }, { tab: string; header: string; label: string; btn: string }
purple: { tab: 'border-b-2 border-purple-600 text-purple-700', header: 'bg-purple-50', label: 'text-purple-600', btn: 'bg-purple-600 hover:bg-purple-700' }, > = {
fuchsia: { tab: 'border-b-2 border-fuchsia-600 text-fuchsia-700', header: 'bg-fuchsia-50', label: 'text-fuchsia-600', btn: 'bg-fuchsia-600 hover:bg-fuchsia-700' }, amber: {
tab: "border-b-2 border-amber-600 text-amber-700",
header: "bg-amber-50",
label: "text-amber-600",
btn: "bg-amber-600 hover:bg-amber-700",
},
teal: {
tab: "border-b-2 border-teal-600 text-teal-700",
header: "bg-teal-50",
label: "text-teal-600",
btn: "bg-teal-600 hover:bg-teal-700",
},
purple: {
tab: "border-b-2 border-purple-600 text-purple-700",
header: "bg-purple-50",
label: "text-purple-600",
btn: "bg-purple-600 hover:bg-purple-700",
},
fuchsia: {
tab: "border-b-2 border-fuchsia-600 text-fuchsia-700",
header: "bg-fuchsia-50",
label: "text-fuchsia-600",
btn: "bg-fuchsia-600 hover:bg-fuchsia-700",
},
}; };
export default function DataClaimWidget({ exercises, accentColor = 'amber' }: DataClaimWidgetProps) { export default function DataClaimWidget({
exercises,
accentColor = "amber",
}: DataClaimWidgetProps) {
const [activeEx, setActiveEx] = useState(0); const [activeEx, setActiveEx] = useState(0);
const [answers, setAnswers] = useState<Record<number, Verdict>>({}); const [answers, setAnswers] = useState<Record<number, Verdict>>({});
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const exercise = exercises[activeEx]; const exercise = exercises[activeEx];
const allAnswered = exercise.claims.every((_, i) => answers[i] !== undefined); const allAnswered = exercise.claims.every((_, i) => answers[i] !== undefined);
const score = submitted ? exercise.claims.filter((c, i) => answers[i] === c.verdict).length : 0; const score = submitted
? exercise.claims.filter((c, i) => answers[i] === c.verdict).length
: 0;
const c = ACCENT_CLASSES[accentColor] ?? ACCENT_CLASSES.amber; const c = ACCENT_CLASSES[accentColor] ?? ACCENT_CLASSES.amber;
const reset = () => { setAnswers({}); setSubmitted(false); }; const reset = () => {
const switchEx = (i: number) => { setActiveEx(i); setAnswers({}); setSubmitted(false); }; setAnswers({});
setSubmitted(false);
};
const switchEx = (i: number) => {
setActiveEx(i);
setAnswers({});
setSubmitted(false);
};
return ( return (
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm"> <div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
@ -326,7 +502,7 @@ export default function DataClaimWidget({ exercises, accentColor = 'amber' }: Da
key={i} key={i}
onClick={() => switchEx(i)} onClick={() => switchEx(i)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${ className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors bg-white ${
i === activeEx ? c.tab : 'text-gray-500 hover:text-gray-700' i === activeEx ? c.tab : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
{ex.title} {ex.title}
@ -337,73 +513,100 @@ export default function DataClaimWidget({ exercises, accentColor = 'amber' }: Da
{/* Chart */} {/* Chart */}
<div className={`px-5 pt-5 pb-4 border-b border-gray-200 ${c.header}`}> <div className={`px-5 pt-5 pb-4 border-b border-gray-200 ${c.header}`}>
<p className={`text-xs font-bold uppercase tracking-wider mb-4 ${c.label}`}>Data Source</p> <p
{exercise.chart.type === 'bar' className={`text-xs font-bold uppercase tracking-wider mb-4 ${c.label}`}
? <BarChart chart={exercise.chart} /> >
: <LineChart chart={exercise.chart} /> Data Source
} </p>
{exercise.chart.type === "bar" ? (
<BarChart chart={exercise.chart} />
) : (
<LineChart chart={exercise.chart} />
)}
</div> </div>
{/* Claims */} {/* Claims */}
<div className="px-5 py-4"> <div className="px-5 py-4">
<p className="text-sm text-gray-600 mb-4"> <p className="text-sm text-gray-600 mb-4">
For each claim, decide if the data{' '} For each claim, decide if the data{" "}
<strong className="text-green-700">supports</strong>,{' '} <strong className="text-green-700">supports</strong>,{" "}
<strong className="text-red-600">contradicts</strong>, or{' '} <strong className="text-red-600">contradicts</strong>, or{" "}
<strong className="text-gray-600">neither proves nor disproves</strong> it: <strong className="text-gray-600">
neither proves nor disproves
</strong>{" "}
it:
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{exercise.claims.map((claim, i) => { {exercise.claims.map((claim, i) => {
const userAnswer = answers[i]; const userAnswer = answers[i];
const isCorrect = submitted && userAnswer === claim.verdict; const isCorrect = submitted && userAnswer === claim.verdict;
const isWrong = submitted && userAnswer !== undefined && userAnswer !== claim.verdict; const isWrong =
submitted &&
userAnswer !== undefined &&
userAnswer !== claim.verdict;
return ( return (
<div <div
key={i} key={i}
className={`rounded-xl border p-4 transition-all ${ className={`rounded-xl border p-4 transition-all ${
submitted submitted
? isCorrect ? 'border-green-300 bg-green-50' ? isCorrect
: isWrong ? 'border-red-200 bg-red-50' ? "border-green-300 bg-green-50"
: 'border-gray-200' : isWrong
: 'border-gray-200' ? "border-red-200 bg-red-50"
: "border-gray-200"
: "border-gray-200"
}`} }`}
> >
<p className="text-sm text-gray-800 mb-3"> <p className="text-sm text-gray-800 mb-3">
<span className="font-bold text-gray-400 mr-2">Claim {i + 1}:</span> <span className="font-bold text-gray-400 mr-2">
Claim {i + 1}:
</span>
{claim.text} {claim.text}
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(['supported', 'contradicted', 'neither'] as Verdict[]).map(v => { {(["supported", "contradicted", "neither"] as Verdict[]).map(
(v) => {
const isSelected = userAnswer === v; const isSelected = userAnswer === v;
const isCorrectOpt = submitted && v === claim.verdict; const isCorrectOpt = submitted && v === claim.verdict;
let cls = 'border-gray-200 text-gray-600 hover:border-gray-400 hover:bg-gray-50'; let cls =
if (isSelected && !submitted) cls = `border-amber-500 bg-amber-50 text-amber-800 font-semibold`; "border-gray-200 text-gray-600 hover:border-gray-400 hover:bg-gray-50";
if (isSelected && !submitted)
cls = `border-amber-500 bg-amber-50 text-amber-800 font-semibold`;
if (submitted) { if (submitted) {
if (isCorrectOpt) cls = 'border-green-400 bg-green-100 text-green-800 font-semibold'; if (isCorrectOpt)
else if (isSelected) cls = 'border-red-300 bg-red-100 text-red-700'; cls =
else cls = 'border-gray-100 text-gray-400'; "border-green-400 bg-green-100 text-green-800 font-semibold";
else if (isSelected)
cls = "border-red-300 bg-red-100 text-red-700";
else cls = "border-gray-100 text-gray-400";
} }
return ( return (
<button <button
key={v} key={v}
disabled={submitted} disabled={submitted}
onClick={() => setAnswers(prev => ({ ...prev, [i]: v }))} onClick={() =>
setAnswers((prev) => ({ ...prev, [i]: v }))
}
className={`px-3 py-1.5 rounded-full border text-xs transition-all ${cls}`} className={`px-3 py-1.5 rounded-full border text-xs transition-all ${cls}`}
> >
{VERDICT_LABELS[v]} {VERDICT_LABELS[v]}
</button> </button>
); );
})} },
)}
</div> </div>
{submitted && ( {submitted && (
<div className="mt-3 pt-2 border-t border-gray-100 flex gap-2"> <div className="mt-3 pt-2 border-t border-gray-100 flex gap-2">
{isCorrect {isCorrect ? (
? <CheckCircle2 className="w-4 h-4 text-green-600 shrink-0 mt-0.5" /> <CheckCircle2 className="w-4 h-4 text-green-600 shrink-0 mt-0.5" />
: <XCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" /> ) : (
} <XCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
)}
<p className="text-xs text-gray-600 leading-relaxed"> <p className="text-xs text-gray-600 leading-relaxed">
{!isCorrect && ( {!isCorrect && (
<span className="font-semibold text-red-700">Answer: {VERDICT_LABELS[claim.verdict]}. </span> <span className="font-semibold text-red-700">
Answer: {VERDICT_LABELS[claim.verdict]}.{" "}
</span>
)} )}
{claim.explanation} {claim.explanation}
</p> </p>
@ -422,7 +625,9 @@ export default function DataClaimWidget({ exercises, accentColor = 'amber' }: Da
disabled={!allAnswered} disabled={!allAnswered}
onClick={() => setSubmitted(true)} onClick={() => setSubmitted(true)}
className={`px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${ className={`px-5 py-2.5 rounded-full text-sm font-bold text-white transition-colors ${
allAnswered ? c.btn : 'bg-gray-200 text-gray-400 cursor-not-allowed' allAnswered
? c.btn
: "bg-gray-200 text-gray-400 cursor-not-allowed"
}`} }`}
> >
Check all answers Check all answers
@ -432,7 +637,10 @@ export default function DataClaimWidget({ exercises, accentColor = 'amber' }: Da
<p className="text-sm font-semibold text-gray-700"> <p className="text-sm font-semibold text-gray-700">
{score}/{exercise.claims.length} correct {score}/{exercise.claims.length} correct
</p> </p>
<button onClick={reset} className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"> <button
onClick={reset}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> Try again <RotateCcw className="w-3.5 h-3.5" /> Try again
</button> </button>
</div> </div>

View File

@ -1,16 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { ChevronRight, RotateCcw, CheckCircle2, AlertTriangle, Info } from 'lucide-react'; import {
ChevronRight,
RotateCcw,
CheckCircle2,
AlertTriangle,
Info,
} from "lucide-react";
export interface TreeNode { export interface TreeNode {
id: string; id: string;
question: string; question?: string;
hint?: string; hint?: string;
yesLabel?: string; yesLabel?: string;
noLabel?: string; noLabel?: string;
yes?: TreeNode; yes?: TreeNode;
no?: TreeNode; no?: TreeNode;
result?: string; result?: string;
resultType?: 'correct' | 'warning' | 'info'; resultType?: "correct" | "warning" | "info";
ruleRef?: string; ruleRef?: string;
} }
@ -25,59 +31,66 @@ interface DecisionTreeWidgetProps {
accentColor?: string; accentColor?: string;
} }
type Answers = Record<string, 'yes' | 'no'>; type Answers = Record<string, "yes" | "no">;
/** Walk the tree following answers, return ordered list of [node, answer|null] pairs traversed */ /** Walk the tree following answers, return ordered list of [node, answer|null] pairs traversed */
function getPath(root: TreeNode, answers: Answers): Array<{ node: TreeNode; answer: 'yes' | 'no' | null }> { function getPath(
const path: Array<{ node: TreeNode; answer: 'yes' | 'no' | null }> = []; root: TreeNode,
answers: Answers,
): Array<{ node: TreeNode; answer: "yes" | "no" | null }> {
const path: Array<{ node: TreeNode; answer: "yes" | "no" | null }> = [];
let current: TreeNode | undefined = root; let current: TreeNode | undefined = root;
while (current) { while (current) {
// @ts-ignore
const ans = answers[current.id] ?? null; const ans = answers[current.id] ?? null;
path.push({ node: current, answer: ans }); path.push({ node: current, answer: ans });
if (ans === null) break; // not answered yet — this is the active node if (ans === null) break; // not answered yet — this is the active node
if (current.result !== undefined) break; // leaf if (current.result !== undefined) break; // leaf
current = ans === 'yes' ? current.yes : current.no; current = ans === "yes" ? current.yes : current.no;
} }
return path; return path;
} }
const RESULT_STYLES = { const RESULT_STYLES = {
correct: { correct: {
bg: 'bg-green-50', bg: "bg-green-50",
border: 'border-green-300', border: "border-green-300",
text: 'text-green-800', text: "text-green-800",
icon: <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />, icon: <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />,
}, },
warning: { warning: {
bg: 'bg-amber-50', bg: "bg-amber-50",
border: 'border-amber-300', border: "border-amber-300",
text: 'text-amber-800', text: "text-amber-800",
icon: <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />, icon: <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />,
}, },
info: { info: {
bg: 'bg-blue-50', bg: "bg-blue-50",
border: 'border-blue-300', border: "border-blue-300",
text: 'text-blue-800', text: "text-blue-800",
icon: <Info className="w-5 h-5 text-blue-600 shrink-0 mt-0.5" />, icon: <Info className="w-5 h-5 text-blue-600 shrink-0 mt-0.5" />,
}, },
}; };
export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }: DecisionTreeWidgetProps) { export default function DecisionTreeWidget({
scenarios,
accentColor = "purple",
}: DecisionTreeWidgetProps) {
const [activeScenario, setActiveScenario] = useState(0); const [activeScenario, setActiveScenario] = useState(0);
const [answers, setAnswers] = useState<Answers>({}); const [answers, setAnswers] = useState<Answers>({});
const scenario = scenarios[activeScenario]; const scenario = scenarios[activeScenario];
const path = getPath(scenario.tree, answers); const path = getPath(scenario.tree, answers);
const lastStep = path[path.length - 1]; const lastStep = path[path.length - 1];
const isLeaf = lastStep.node.result !== undefined;
const isComplete = isLeaf && lastStep.answer === null; // reached leaf, no more choices needed // reached leaf, no more choices needed
// Actually leaf nodes don't have yes/no — they just show result when we arrive // Actually leaf nodes don't have yes/no — they just show result when we arrive
const atLeaf = lastStep.node.result !== undefined; const atLeaf = lastStep.node.result !== undefined;
const handleAnswer = (nodeId: string, ans: 'yes' | 'no') => { const handleAnswer = (nodeId: string, ans: "yes" | "no") => {
setAnswers(prev => { setAnswers((prev) => {
// Remove all answers for nodes that come AFTER this one in the current path // Remove all answers for nodes that come AFTER this one in the current path
const pathIds = path.map(p => p.node.id); const pathIds = path.map((p) => p.node.id);
const idx = pathIds.indexOf(nodeId); const idx = pathIds.indexOf(nodeId);
const newAnswers: Answers = {}; const newAnswers: Answers = {};
for (let i = 0; i < idx; i++) { for (let i = 0; i < idx; i++) {
@ -107,7 +120,7 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${ className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
i === activeScenario i === activeScenario
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700` ? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
: 'text-gray-500 hover:text-gray-700' : "text-gray-500 hover:text-gray-700"
}`} }`}
> >
{sc.label} {sc.label}
@ -117,9 +130,17 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
)} )}
{/* Sentence under analysis */} {/* Sentence under analysis */}
<div className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}> <div
<p className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-1`}>Analyze this sentence</p> className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}
<p className="text-gray-800 font-medium italic leading-relaxed">"{scenario.sentence}"</p> >
<p
className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-1`}
>
Analyze this sentence
</p>
<p className="text-gray-800 font-medium italic leading-relaxed">
"{scenario.sentence}"
</p>
</div> </div>
{/* Breadcrumb path */} {/* Breadcrumb path */}
@ -133,22 +154,36 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
<button <button
onClick={() => { onClick={() => {
// Reset from this node forward // Reset from this node forward
const pathIds = path.map(p => p.node.id); const pathIds = path.map((p) => p.node.id);
const idx = pathIds.indexOf(step.node.id); const idx = pathIds.indexOf(step.node.id);
setAnswers(prev => { setAnswers((prev) => {
const newAnswers: Answers = {}; const newAnswers: Answers = {};
for (let j = 0; j < idx; j++) newAnswers[pathIds[j]] = prev[pathIds[j]]!; for (let j = 0; j < idx; j++)
newAnswers[pathIds[j]] = prev[pathIds[j]]!;
return newAnswers; return newAnswers;
}); });
}} }}
className={`px-2 py-0.5 rounded transition-colors ${ className={`px-2 py-0.5 rounded transition-colors ${
isAnswered ? 'text-gray-600 hover:text-gray-900 hover:bg-gray-200' : 'text-gray-400' isAnswered
? "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
: "text-gray-400"
}`} }`}
> >
{step.node.question.length > 40 ? step.node.question.slice(0, 40) + '…' : step.node.question} {
// @ts-ignore
step.node.question.length > 40
? // @ts-ignore
step.node.question.slice(0, 40) + "…"
: step.node.question
}
{step.answer && ( {step.answer && (
<span className={`ml-1 font-semibold ${step.answer === 'yes' ? 'text-green-600' : 'text-red-500'}`}> <span
{step.answer === 'yes' ? (step.node.yesLabel ?? 'Yes') : (step.node.noLabel ?? 'No')} className={`ml-1 font-semibold ${step.answer === "yes" ? "text-green-600" : "text-red-500"}`}
>
{" "}
{step.answer === "yes"
? (step.node.yesLabel ?? "Yes")
: (step.node.noLabel ?? "No")}
</span> </span>
)} )}
</button> </button>
@ -161,20 +196,24 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
{/* Active node */} {/* Active node */}
<div className="px-5 py-5"> <div className="px-5 py-5">
{atLeaf ? ( {atLeaf
/* Leaf result */ ? /* Leaf result */
(() => { (() => {
const node = lastStep.node; const node = lastStep.node;
const rType = node.resultType ?? 'correct'; const rType = node.resultType ?? "correct";
const s = RESULT_STYLES[rType]; const s = RESULT_STYLES[rType];
return ( return (
<div className={`rounded-xl border p-4 ${s.bg} ${s.border}`}> <div className={`rounded-xl border p-4 ${s.bg} ${s.border}`}>
<div className="flex gap-3"> <div className="flex gap-3">
{s.icon} {s.icon}
<div> <div>
<p className={`font-semibold ${s.text} leading-snug`}>{node.result}</p> <p className={`font-semibold ${s.text} leading-snug`}>
{node.result}
</p>
{node.ruleRef && ( {node.ruleRef && (
<p className={`mt-2 text-sm font-mono ${s.text} opacity-80 bg-white/60 rounded px-2 py-1 inline-block`}> <p
className={`mt-2 text-sm font-mono ${s.text} opacity-80 bg-white/60 rounded px-2 py-1 inline-block`}
>
{node.ruleRef} {node.ruleRef}
</p> </p>
)} )}
@ -183,33 +222,35 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
</div> </div>
); );
})() })()
) : ( : /* Decision question */
/* Decision question */
(() => { (() => {
const node = lastStep.node; const node = lastStep.node;
return ( return (
<div> <div>
<p className="font-semibold text-gray-800 text-base leading-snug mb-1">{node.question}</p> <p className="font-semibold text-gray-800 text-base leading-snug mb-1">
{node.hint && <p className="text-sm text-gray-500 mb-4">{node.hint}</p>} {node.question}
</p>
{node.hint && (
<p className="text-sm text-gray-500 mb-4">{node.hint}</p>
)}
{!node.hint && <div className="mb-4" />} {!node.hint && <div className="mb-4" />}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<button <button
onClick={() => handleAnswer(node.id, 'yes')} onClick={() => handleAnswer(node.id, "yes")}
className="flex-1 min-w-[140px] px-4 py-3 rounded-xl border-2 border-green-300 bg-green-50 text-green-800 font-semibold text-sm hover:bg-green-100 transition-colors" className="flex-1 min-w-35 px-4 py-3 rounded-xl border-2 border-green-300 bg-green-50 text-green-800 font-semibold text-sm hover:bg-green-100 transition-colors"
> >
{node.yesLabel ?? 'Yes'} {node.yesLabel ?? "Yes"}
</button> </button>
<button <button
onClick={() => handleAnswer(node.id, 'no')} onClick={() => handleAnswer(node.id, "no")}
className="flex-1 min-w-[140px] px-4 py-3 rounded-xl border-2 border-red-200 bg-red-50 text-red-700 font-semibold text-sm hover:bg-red-100 transition-colors" className="flex-1 min-w-35 px-4 py-3 rounded-xl border-2 border-red-200 bg-red-50 text-red-700 font-semibold text-sm hover:bg-red-100 transition-colors"
> >
{node.noLabel ?? 'No'} {node.noLabel ?? "No"}
</button> </button>
</div> </div>
</div> </div>
); );
})() })()}
)}
</div> </div>
{/* Footer */} {/* Footer */}
@ -221,7 +262,9 @@ export default function DecisionTreeWidget({ scenarios, accentColor = 'purple' }
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
Try again Try again
</button> </button>
{atLeaf && scenarios.length > 1 && activeScenario < scenarios.length - 1 && ( {atLeaf &&
scenarios.length > 1 &&
activeScenario < scenarios.length - 1 && (
<button <button
onClick={() => switchScenario(activeScenario + 1)} onClick={() => switchScenario(activeScenario + 1)}
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`} className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const ExponentialExplorer: React.FC = () => { const ExponentialExplorer: React.FC = () => {
const [a, setA] = useState(2); // Initial Value const [a, setA] = useState(2); // Initial Value
@ -6,7 +6,6 @@ const ExponentialExplorer: React.FC = () => {
const [k, setK] = useState(0); // Horizontal Asymptote shift const [k, setK] = useState(0); // Horizontal Asymptote shift
const width = 300; const width = 300;
const height = 300;
const range = 5; // x range -5 to 5 const range = 5; // x range -5 to 5
// Mapping // Mapping
@ -33,9 +32,14 @@ const ExponentialExplorer: React.FC = () => {
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-violet-50 rounded-xl border border-violet-100 text-center"> <div className="p-4 bg-violet-50 rounded-xl border border-violet-100 text-center">
<div className="text-xs font-bold text-violet-400 uppercase mb-1">Standard Form</div> <div className="text-xs font-bold text-violet-400 uppercase mb-1">
Standard Form
</div>
<div className="text-xl font-mono font-bold text-violet-900"> <div className="text-xl font-mono font-bold text-violet-900">
y = <span className="text-indigo-600">{a}</span> · <span className="text-emerald-600">{b}</span><sup>x</sup> {k >= 0 ? '+' : ''} <span className="text-rose-600">{k}</span> y = <span className="text-indigo-600">{a}</span> ·{" "}
<span className="text-emerald-600">{b}</span>
<sup>x</sup> {k >= 0 ? "+" : ""}{" "}
<span className="text-rose-600">{k}</span>
</div> </div>
</div> </div>
@ -44,20 +48,46 @@ const ExponentialExplorer: React.FC = () => {
<label className="text-xs font-bold text-indigo-600 uppercase flex justify-between"> <label className="text-xs font-bold text-indigo-600 uppercase flex justify-between">
Initial Value (a) <span>{a}</span> Initial Value (a) <span>{a}</span>
</label> </label>
<input type="range" min="0.5" max="5" step="0.5" value={a} onChange={e => setA(parseFloat(e.target.value))} className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"/> <input
type="range"
min="0.5"
max="5"
step="0.5"
value={a}
onChange={(e) => setA(parseFloat(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
/>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-emerald-600 uppercase flex justify-between"> <label className="text-xs font-bold text-emerald-600 uppercase flex justify-between">
Growth Factor (b) <span>{b}</span> Growth Factor (b) <span>{b}</span>
</label> </label>
<input type="range" min="0.1" max="3" step="0.1" value={b} onChange={e => setB(parseFloat(e.target.value))} className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600"/> <input
<p className="text-xs text-slate-400 mt-1">{b > 1 ? "Growth (b > 1)" : "Decay (0 < b < 1)"}</p> type="range"
min="0.1"
max="3"
step="0.1"
value={b}
onChange={(e) => setB(parseFloat(e.target.value))}
className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600"
/>
<p className="text-xs text-slate-400 mt-1">
{b > 1 ? "Growth (b > 1)" : "Decay (0 < b < 1)"}
</p>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-rose-600 uppercase flex justify-between"> <label className="text-xs font-bold text-rose-600 uppercase flex justify-between">
Vertical Shift (k) <span>{k}</span> Vertical Shift (k) <span>{k}</span>
</label> </label>
<input type="range" min="-3" max="3" step="1" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"/> <input
type="range"
min="-3"
max="3"
step="1"
value={k}
onChange={(e) => setK(parseFloat(e.target.value))}
className="w-full h-2 bg-rose-100 rounded-lg appearance-none cursor-pointer accent-rose-600"
/>
</div> </div>
</div> </div>
</div> </div>
@ -65,18 +95,58 @@ const ExponentialExplorer: React.FC = () => {
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center">
<div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden"> <div className="relative w-[300px] h-[300px] bg-white border border-slate-200 rounded-xl overflow-hidden">
<svg width="100%" height="100%" viewBox="0 0 300 300"> <svg width="100%" height="100%" viewBox="0 0 300 300">
<line x1="0" y1="150" x2="300" y2="150" stroke="#cbd5e1" strokeWidth="2" /> <line
<line x1="150" y1="0" x2="150" y2="300" stroke="#cbd5e1" strokeWidth="2" /> x1="0"
y1="150"
x2="300"
y2="150"
stroke="#cbd5e1"
strokeWidth="2"
/>
<line
x1="150"
y1="0"
x2="150"
y2="300"
stroke="#cbd5e1"
strokeWidth="2"
/>
{/* Asymptote */} {/* Asymptote */}
<line x1="0" y1={toPx(k, true)} x2="300" y2={toPx(k, true)} stroke="#e11d48" strokeWidth="1" strokeDasharray="4,4" /> <line
<text x="10" y={toPx(k, true) - 5} className="text-xs font-bold fill-rose-500">y = {k}</text> x1="0"
y1={toPx(k, true)}
x2="300"
y2={toPx(k, true)}
stroke="#e11d48"
strokeWidth="1"
strokeDasharray="4,4"
/>
<text
x="10"
y={toPx(k, true) - 5}
className="text-xs font-bold fill-rose-500"
>
y = {k}
</text>
{/* Function */} {/* Function */}
<path d={generatePath()} fill="none" stroke="#8b5cf6" strokeWidth="3" /> <path
d={generatePath()}
fill="none"
stroke="#8b5cf6"
strokeWidth="3"
/>
{/* Intercept */} {/* Intercept */}
<circle cx={toPx(0)} cy={toPx(a+k, true)} r="4" fill="#4f46e5" stroke="white" strokeWidth="2" /> <circle
cx={toPx(0)}
cy={toPx(a + k, true)}
r="4"
fill="#4f46e5"
stroke="white"
strokeWidth="2"
/>
</svg> </svg>
</div> </div>
</div> </div>

View File

@ -1,19 +1,19 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const HistogramBuilderWidget: React.FC = () => { const HistogramBuilderWidget: React.FC = () => {
const [mode, setMode] = useState<'count' | 'percent'>('count'); const [mode, setMode] = useState<"count" | "percent">("count");
// Data: [60, 70), [70, 80), [80, 90), [90, 100) // Data: [60, 70), [70, 80), [80, 90), [90, 100)
const data = [ const data = [
{ bin: '60-70', count: 4, label: '60s' }, { bin: "60-70", count: 4, label: "60s" },
{ bin: '70-80', count: 9, label: '70s' }, { bin: "70-80", count: 9, label: "70s" },
{ bin: '80-90', count: 6, label: '80s' }, { bin: "80-90", count: 6, label: "80s" },
{ bin: '90-100', count: 1, label: '90s' }, { bin: "90-100", count: 1, label: "90s" },
]; ];
const total = data.reduce((acc, curr) => acc + curr.count, 0); // 20 const total = data.reduce((acc, curr) => acc + curr.count, 0); // 20
const maxCount = Math.max(...data.map(d => d.count)); const maxCount = Math.max(...data.map((d) => d.count));
const maxPercent = maxCount / total; // 0.45 const maxPercent = maxCount / total; // 0.45
return ( return (
@ -22,14 +22,14 @@ const HistogramBuilderWidget: React.FC = () => {
<h3 className="font-bold text-slate-700">Test Scores Distribution</h3> <h3 className="font-bold text-slate-700">Test Scores Distribution</h3>
<div className="flex bg-slate-100 p-1 rounded-lg"> <div className="flex bg-slate-100 p-1 rounded-lg">
<button <button
onClick={() => setMode('count')} onClick={() => setMode("count")}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === 'count' ? 'bg-white shadow-sm text-indigo-600' : 'text-slate-500 hover:text-slate-700'}`} className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === "count" ? "bg-white shadow-sm text-indigo-600" : "text-slate-500 hover:text-slate-700"}`}
> >
Frequency (Count) Frequency (Count)
</button> </button>
<button <button
onClick={() => setMode('percent')} onClick={() => setMode("percent")}
className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === 'percent' ? 'bg-white shadow-sm text-rose-600' : 'text-slate-500 hover:text-slate-700'}`} className={`px-4 py-1.5 text-sm font-bold rounded-md transition-all ${mode === "percent" ? "bg-white shadow-sm text-rose-600" : "text-slate-500 hover:text-slate-700"}`}
> >
Relative Freq (%) Relative Freq (%)
</button> </button>
@ -39,28 +39,42 @@ const HistogramBuilderWidget: React.FC = () => {
<div className="relative h-64 border-b-2 border-slate-200 flex items-end px-8 gap-1"> <div className="relative h-64 border-b-2 border-slate-200 flex items-end px-8 gap-1">
{/* Y Axis Labels */} {/* Y Axis Labels */}
<div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs font-mono text-slate-400 py-2"> <div className="absolute left-0 top-0 bottom-0 flex flex-col justify-between text-xs font-mono text-slate-400 py-2">
<span>{mode === 'count' ? maxCount + 1 : ((maxPercent + 0.05)*100).toFixed(0) + '%'}</span> <span>
<span>{mode === 'count' ? Math.round((maxCount+1)/2) : (((maxPercent + 0.05)/2)*100).toFixed(0) + '%'}</span> {mode === "count"
? maxCount + 1
: ((maxPercent + 0.05) * 100).toFixed(0) + "%"}
</span>
<span>
{mode === "count"
? Math.round((maxCount + 1) / 2)
: (((maxPercent + 0.05) / 2) * 100).toFixed(0) + "%"}
</span>
<span>0</span> <span>0</span>
</div> </div>
{data.map((d, i) => { {data.map((d, i) => {
const heightRatio = d.count / maxCount; // Normalize to max height of graph area roughly // Normalize to max height of graph area roughly
// Actually map 0 to maxScale // Actually map 0 to maxScale
const maxScale = mode === 'count' ? maxCount + 1 : (maxPercent + 0.05); const maxScale = mode === "count" ? maxCount + 1 : maxPercent + 0.05;
const val = mode === 'count' ? d.count : d.count / total; const val = mode === "count" ? d.count : d.count / total;
const hPercent = (val / maxScale) * 100; const hPercent = (val / maxScale) * 100;
return ( return (
<div key={i} className="flex-1 flex flex-col justify-end group relative h-full"> <div
key={i}
className="flex-1 flex flex-col justify-end group relative h-full"
>
{/* Tooltip */} {/* Tooltip */}
<div className="opacity-0 group-hover:opacity-100 absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs py-1 px-2 rounded pointer-events-none transition-opacity z-10 whitespace-nowrap"> <div className="opacity-0 group-hover:opacity-100 absolute -top-10 left-1/2 -translate-x-1/2 bg-slate-800 text-white text-xs py-1 px-2 rounded pointer-events-none transition-opacity z-10 whitespace-nowrap">
{d.bin}: {mode === 'count' ? d.count : `${(d.count/total*100).toFixed(0)}%`} {d.bin}:{" "}
{mode === "count"
? d.count
: `${((d.count / total) * 100).toFixed(0)}%`}
</div> </div>
{/* Bar */} {/* Bar */}
<div <div
className={`w-full transition-all duration-500 rounded-t ${mode === 'count' ? 'bg-indigo-500 group-hover:bg-indigo-600' : 'bg-rose-500 group-hover:bg-rose-600'}`} className={`w-full transition-all duration-500 rounded-t ${mode === "count" ? "bg-indigo-500 group-hover:bg-indigo-600" : "bg-rose-500 group-hover:bg-rose-600"}`}
style={{ height: `${hPercent}%` }} style={{ height: `${hPercent}%` }}
></div> ></div>
@ -75,8 +89,11 @@ const HistogramBuilderWidget: React.FC = () => {
<div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-200"> <div className="mt-8 p-4 bg-slate-50 rounded-xl border border-slate-200">
<p className="text-sm text-slate-600"> <p className="text-sm text-slate-600">
<strong>Key Takeaway:</strong> Notice that the <span className="font-bold text-slate-800">shape</span> of the distribution stays exactly the same. <strong>Key Takeaway:</strong> Notice that the{" "}
Only the <span className="font-bold text-slate-800">Y-axis scale</span> changes. <span className="font-bold text-slate-800">shape</span> of the
distribution stays exactly the same. Only the{" "}
<span className="font-bold text-slate-800">Y-axis scale</span>{" "}
changes.
</p> </p>
</div> </div>
</div> </div>

View File

@ -64,7 +64,6 @@ const PALETTES = {
}; };
export default function LessonShell({ export default function LessonShell({
title,
sections, sections,
color, color,
onFinish, onFinish,

View File

@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const LinearTransformationWidget: React.FC = () => { const LinearTransformationWidget: React.FC = () => {
const [h, setH] = useState(0); // Horizontal shift (x - h) const [h, setH] = useState(0); // Horizontal shift (x - h)
const [k, setK] = useState(0); // Vertical shift + k const [k, setK] = useState(0); // Vertical shift + k
const [reflectX, setReflectX] = useState(false); // -f(x) const [reflectX, setReflectX] = useState(false); // -f(x)
const [stretch, setStretch] = useState(1); // a * f(x) const stretch = 1; // a * f(x)
// Base function f(x) = 0.5x // Base function f(x) = 0.5x
// Transformed g(x) = a * f(x - h) + k // Transformed g(x) = a * f(x - h) + k
@ -21,12 +21,14 @@ const LinearTransformationWidget: React.FC = () => {
const size = 300; const size = 300;
const center = size / 2; const center = size / 2;
const toPx = (v: number, isY = false) => isY ? center - v * scale : center + v * scale; const toPx = (v: number, isY = false) =>
isY ? center - v * scale : center + v * scale;
// Base: y = 0.5x (to make it distinct from diagonals) // Base: y = 0.5x (to make it distinct from diagonals)
const getBasePath = () => { const getBasePath = () => {
const m = 0.5; const m = 0.5;
const x1 = -range, x2 = range; const x1 = -range,
x2 = range;
const y1 = m * x1; const y1 = m * x1;
const y2 = m * x2; const y2 = m * x2;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`; return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
@ -35,7 +37,8 @@ const LinearTransformationWidget: React.FC = () => {
const getTransformedPath = () => { const getTransformedPath = () => {
// f(x) = 0.5x // f(x) = 0.5x
// g(x) = effectiveStretch * (0.5 * (x - h)) + k // g(x) = effectiveStretch * (0.5 * (x - h)) + k
const x1 = -range, x2 = range; const x1 = -range,
x2 = range;
const y1 = effectiveStretch * (0.5 * (x1 - h)) + k; const y1 = effectiveStretch * (0.5 * (x1 - h)) + k;
const y2 = effectiveStretch * (0.5 * (x2 - h)) + k; const y2 = effectiveStretch * (0.5 * (x2 - h)) + k;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`; return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
@ -46,9 +49,14 @@ const LinearTransformationWidget: React.FC = () => {
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div className="p-4 bg-slate-50 border border-slate-200 rounded-xl font-mono text-sm"> <div className="p-4 bg-slate-50 border border-slate-200 rounded-xl font-mono text-sm">
<p className="text-slate-400 mb-2">Base: <span className="text-slate-600 font-bold">f(x) = 0.5x</span></p> <p className="text-slate-400 mb-2">
Base:{" "}
<span className="text-slate-600 font-bold">f(x) = 0.5x</span>
</p>
<p className="text-indigo-900 font-bold text-lg"> <p className="text-indigo-900 font-bold text-lg">
g(x) = {reflectX ? '-' : ''}{stretch !== 1 ? stretch : ''}f(x {h > 0 ? '-' : '+'} {Math.abs(h)}) {k >= 0 ? '+' : '-'} {Math.abs(k)} g(x) = {reflectX ? "-" : ""}
{stretch !== 1 ? stretch : ""}f(x {h > 0 ? "-" : "+"}{" "}
{Math.abs(h)}) {k >= 0 ? "+" : "-"} {Math.abs(k)}
</p> </p>
</div> </div>
@ -58,8 +66,12 @@ const LinearTransformationWidget: React.FC = () => {
Horizontal Shift (h) <span>{h}</span> Horizontal Shift (h) <span>{h}</span>
</label> </label>
<input <input
type="range" min="-5" max="5" step="1" type="range"
value={h} onChange={e => setH(parseInt(e.target.value))} min="-5"
max="5"
step="1"
value={h}
onChange={(e) => setH(parseInt(e.target.value))}
className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-1" className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600 mt-1"
/> />
<div className="flex justify-between text-[10px] text-slate-400"> <div className="flex justify-between text-[10px] text-slate-400">
@ -73,15 +85,24 @@ const LinearTransformationWidget: React.FC = () => {
Vertical Shift (k) <span>{k}</span> Vertical Shift (k) <span>{k}</span>
</label> </label>
<input <input
type="range" min="-5" max="5" step="1" type="range"
value={k} onChange={e => setK(parseInt(e.target.value))} min="-5"
max="5"
step="1"
value={k}
onChange={(e) => setK(parseInt(e.target.value))}
className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600 mt-1" className="w-full h-2 bg-emerald-100 rounded-lg appearance-none cursor-pointer accent-emerald-600 mt-1"
/> />
</div> </div>
<div className="flex items-center gap-4 pt-2"> <div className="flex items-center gap-4 pt-2">
<label className="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer"> <label className="flex items-center gap-2 text-sm font-bold text-slate-700 cursor-pointer">
<input type="checkbox" checked={reflectX} onChange={e => setReflectX(e.target.checked)} className="accent-rose-600 w-4 h-4"/> <input
type="checkbox"
checked={reflectX}
onChange={(e) => setReflectX(e.target.checked)}
className="accent-rose-600 w-4 h-4"
/>
Reflect (-f(x)) Reflect (-f(x))
</label> </label>
</div> </div>
@ -92,23 +113,60 @@ const LinearTransformationWidget: React.FC = () => {
<div className="relative w-[300px] h-[300px] border border-slate-200 rounded-xl overflow-hidden bg-white"> <div className="relative w-[300px] h-[300px] border border-slate-200 rounded-xl overflow-hidden bg-white">
<svg width="300" height="300" viewBox="0 0 300 300"> <svg width="300" height="300" viewBox="0 0 300 300">
<defs> <defs>
<pattern id="grid-t" width="20" height="20" patternUnits="userSpaceOnUse"> <pattern
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/> id="grid-t"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<path
d="M 20 0 L 0 0 0 20"
fill="none"
stroke="#f1f5f9"
strokeWidth="1"
/>
</pattern> </pattern>
</defs> </defs>
<rect width="100%" height="100%" fill="url(#grid-t)" /> <rect width="100%" height="100%" fill="url(#grid-t)" />
{/* Axes */} {/* Axes */}
<line x1="0" y1={center} x2={size} y2={center} stroke="#cbd5e1" strokeWidth="2" /> <line
<line x1={center} y1="0" x2={center} y2={size} stroke="#cbd5e1" strokeWidth="2" /> x1="0"
y1={center}
x2={size}
y2={center}
stroke="#cbd5e1"
strokeWidth="2"
/>
<line
x1={center}
y1="0"
x2={center}
y2={size}
stroke="#cbd5e1"
strokeWidth="2"
/>
{/* Base Function (Ghost) */} {/* Base Function (Ghost) */}
<path d={getBasePath()} stroke="#94a3b8" strokeWidth="2" strokeDasharray="4,4" /> <path
<text x="260" y={toPx(0.5*8, true) - 5} className="text-xs fill-slate-400 font-bold">f(x)</text> d={getBasePath()}
stroke="#94a3b8"
strokeWidth="2"
strokeDasharray="4,4"
/>
<text
x="260"
y={toPx(0.5 * 8, true) - 5}
className="text-xs fill-slate-400 font-bold"
>
f(x)
</text>
{/* Transformed Function */} {/* Transformed Function */}
<path d={getTransformedPath()} stroke="#4f46e5" strokeWidth="3" /> <path d={getTransformedPath()} stroke="#4f46e5" strokeWidth="3" />
<text x="20" y="20" className="text-xs fill-indigo-600 font-bold">g(x)</text> <text x="20" y="20" className="text-xs fill-indigo-600 font-bold">
g(x)
</text>
</svg> </svg>
</div> </div>
</div> </div>

View File

@ -1,8 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { ArrowRight } from 'lucide-react';
const MultiStepPercentWidget: React.FC = () => { const MultiStepPercentWidget: React.FC = () => {
const [start, setStart] = useState(100); const start = 100;
const [change1, setChange1] = useState(40); // +40% const [change1, setChange1] = useState(40); // +40%
const [change2, setChange2] = useState(-25); // -25% const [change2, setChange2] = useState(-25); // -25%
@ -21,25 +20,43 @@ const MultiStepPercentWidget: React.FC = () => {
<div className="flex flex-col md:flex-row gap-8 mb-8"> <div className="flex flex-col md:flex-row gap-8 mb-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div> <div>
<label className="text-xs font-bold text-slate-400 uppercase">Change 1 (Markup)</label> <label className="text-xs font-bold text-slate-400 uppercase">
Change 1 (Markup)
</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="range" min="-50" max="100" step="5" type="range"
value={change1} onChange={e => setChange1(parseInt(e.target.value))} min="-50"
max="100"
step="5"
value={change1}
onChange={(e) => setChange1(parseInt(e.target.value))}
className="flex-1 accent-indigo-600" className="flex-1 accent-indigo-600"
/> />
<span className="font-bold text-indigo-600 w-12 text-right">{change1 > 0 ? '+' : ''}{change1}%</span> <span className="font-bold text-indigo-600 w-12 text-right">
{change1 > 0 ? "+" : ""}
{change1}%
</span>
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-slate-400 uppercase">Change 2 (Discount)</label> <label className="text-xs font-bold text-slate-400 uppercase">
Change 2 (Discount)
</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="range" min="-50" max="50" step="5" type="range"
value={change2} onChange={e => setChange2(parseInt(e.target.value))} min="-50"
max="50"
step="5"
value={change2}
onChange={(e) => setChange2(parseInt(e.target.value))}
className="flex-1 accent-rose-600" className="flex-1 accent-rose-600"
/> />
<span className="font-bold text-rose-600 w-12 text-right">{change2 > 0 ? '+' : ''}{change2}%</span> <span className="font-bold text-rose-600 w-12 text-right">
{change2 > 0 ? "+" : ""}
{change2}%
</span>
</div> </div>
</div> </div>
</div> </div>
@ -51,28 +68,49 @@ const MultiStepPercentWidget: React.FC = () => {
<span>Start</span> <span>Start</span>
<span>${start}</span> <span>${start}</span>
</div> </div>
<div className="h-8 bg-slate-200 rounded-md" style={{ width: `${getWidth(start)}%` }}></div> <div
className="h-8 bg-slate-200 rounded-md"
style={{ width: `${getWidth(start)}%` }}
></div>
</div> </div>
{/* Step 1 */} {/* Step 1 */}
<div className="relative"> <div className="relative">
<div className="flex justify-between text-xs font-bold text-indigo-500 mb-1"> <div className="flex justify-between text-xs font-bold text-indigo-500 mb-1">
<span>After {change1 > 0 ? '+' : ''}{change1}%</span> <span>
After {change1 > 0 ? "+" : ""}
{change1}%
</span>
<span>${step1Val.toFixed(2)}</span> <span>${step1Val.toFixed(2)}</span>
</div> </div>
<div className="h-8 bg-indigo-100 rounded-md transition-all duration-500" style={{ width: `${getWidth(step1Val)}%` }}> <div
<div className="h-full bg-indigo-500 rounded-l-md" style={{ width: `${(start/step1Val)*100}%` }}></div> className="h-8 bg-indigo-100 rounded-md transition-all duration-500"
style={{ width: `${getWidth(step1Val)}%` }}
>
<div
className="h-full bg-indigo-500 rounded-l-md"
style={{ width: `${(start / step1Val) * 100}%` }}
></div>
</div> </div>
</div> </div>
{/* Step 2 */} {/* Step 2 */}
<div className="relative"> <div className="relative">
<div className="flex justify-between text-xs font-bold text-rose-500 mb-1"> <div className="flex justify-between text-xs font-bold text-rose-500 mb-1">
<span>After {change2 > 0 ? '+' : ''}{change2}%</span> <span>
After {change2 > 0 ? "+" : ""}
{change2}%
</span>
<span>${finalVal.toFixed(2)}</span> <span>${finalVal.toFixed(2)}</span>
</div> </div>
<div className="h-8 bg-rose-100 rounded-md transition-all duration-500" style={{ width: `${getWidth(finalVal)}%` }}> <div
<div className="h-full bg-rose-500 rounded-l-md" style={{ width: `${(step1Val/finalVal)*100}%` }}></div> className="h-8 bg-rose-100 rounded-md transition-all duration-500"
style={{ width: `${getWidth(finalVal)}%` }}
>
<div
className="h-full bg-rose-500 rounded-l-md"
style={{ width: `${(step1Val / finalVal) * 100}%` }}
></div>
</div> </div>
</div> </div>
</div> </div>
@ -80,19 +118,28 @@ const MultiStepPercentWidget: React.FC = () => {
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200 grid grid-cols-2 gap-4 text-center"> <div className="bg-slate-50 p-4 rounded-xl border border-slate-200 grid grid-cols-2 gap-4 text-center">
<div> <div>
<div className="text-xs font-bold text-slate-400 uppercase mb-1">The Trap (Additive)</div> <div className="text-xs font-bold text-slate-400 uppercase mb-1">
<div className="text-lg font-bold text-slate-400 line-through decoration-red-500 decoration-2"> The Trap (Additive)
{naiveChange > 0 ? '+' : ''}{naiveChange}% </div>
<div className="text-lg font-bold text-slate-400 line-through decoration-red-500 decoration-2">
{naiveChange > 0 ? "+" : ""}
{naiveChange}%
</div>
<div className="text-[10px] text-slate-400">
({change1} + {change2})
</div> </div>
<div className="text-[10px] text-slate-400">({change1} + {change2})</div>
</div> </div>
<div> <div>
<div className="text-xs font-bold text-emerald-600 uppercase mb-1">Actual Change</div> <div className="text-xs font-bold text-emerald-600 uppercase mb-1">
Actual Change
</div>
<div className="text-2xl font-bold text-emerald-600"> <div className="text-2xl font-bold text-emerald-600">
{overallChange > 0 ? '+' : ''}{overallChange.toFixed(2)}% {overallChange > 0 ? "+" : ""}
{overallChange.toFixed(2)}%
</div> </div>
<div className="text-[10px] text-emerald-600 font-mono"> <div className="text-[10px] text-emerald-600 font-mono">
1.{change1} × {1 + change2/100} = {(1 + change1/100) * (1 + change2/100)} 1.{change1} × {1 + change2 / 100} ={" "}
{(1 + change1 / 100) * (1 + change2 / 100)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from "react";
const PolygonWidget: React.FC = () => { const PolygonWidget: React.FC = () => {
const [n, setN] = useState(5); const [n, setN] = useState(5);
@ -15,21 +15,25 @@ const PolygonWidget: React.FC = () => {
const cy = height / 2; const cy = height / 2;
const r = 80; const r = 80;
// Generate points // @ts-ignore
const points = []; const points = [];
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const angle = (i * 2 * Math.PI) / n - Math.PI / 2; // Start at top const angle = (i * 2 * Math.PI) / n - Math.PI / 2; // Start at top
points.push({ points.push({
x: cx + r * Math.cos(angle), x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle) y: cy + r * Math.sin(angle),
}); });
} }
// Generate path string // Generate path string
const pathD = points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + ' Z'; const pathD =
points
.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`))
.join(" ") + " Z";
// Generate exterior lines (extensions) // Generate exterior lines (extensions)
const exteriorLines = points.map((p, i) => { const exteriorLines = points.map((p, i) => {
// @ts-ignore
const nextP = points[(i + 1) % n]; const nextP = points[(i + 1) % n];
// Vector from p to nextP // Vector from p to nextP
const dx = nextP.x - p.x; const dx = nextP.x - p.x;
@ -45,27 +49,49 @@ const PolygonWidget: React.FC = () => {
return ( return (
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 flex flex-col md:flex-row gap-8 items-center"> <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200 flex flex-col md:flex-row gap-8 items-center">
<div className="flex-1 w-full max-w-xs"> <div className="flex-1 w-full max-w-xs">
<label className="block text-sm font-bold text-slate-500 uppercase mb-2">Number of Sides (n): <span className="text-slate-900 text-lg">{n}</span></label> <label className="block text-sm font-bold text-slate-500 uppercase mb-2">
Number of Sides (n):{" "}
<span className="text-slate-900 text-lg">{n}</span>
</label>
<input <input
type="range" min="3" max="10" step="1" type="range"
value={n} onChange={(e) => setN(parseInt(e.target.value))} min="3"
max="10"
step="1"
value={n}
onChange={(e) => setN(parseInt(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-emerald-600 mb-6" className="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-emerald-600 mb-6"
/> />
<div className="space-y-3 font-mono text-sm"> <div className="space-y-3 font-mono text-sm">
<div className="p-3 bg-slate-50 rounded border border-slate-200"> <div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Interior Sum</div> <div className="text-xs text-slate-500 font-bold uppercase">
<div className="text-slate-800">(n - 2) × 180° = <strong className="text-emerald-600">{interiorSum}°</strong></div> Interior Sum
</div>
<div className="text-slate-800">
(n - 2) × 180° ={" "}
<strong className="text-emerald-600">{interiorSum}°</strong>
</div>
</div> </div>
<div className="p-3 bg-slate-50 rounded border border-slate-200"> <div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Each Interior Angle</div> <div className="text-xs text-slate-500 font-bold uppercase">
<div className="text-slate-800">{interiorSum} / {n} = <strong className="text-emerald-600">{eachInterior}°</strong></div> Each Interior Angle
</div>
<div className="text-slate-800">
{interiorSum} / {n} ={" "}
<strong className="text-emerald-600">{eachInterior}°</strong>
</div>
</div> </div>
<div className="p-3 bg-slate-50 rounded border border-slate-200"> <div className="p-3 bg-slate-50 rounded border border-slate-200">
<div className="text-xs text-slate-500 font-bold uppercase">Each Exterior Angle</div> <div className="text-xs text-slate-500 font-bold uppercase">
<div className="text-slate-800">360 / {n} = <strong className="text-rose-600">{eachExterior}°</strong></div> Each Exterior Angle
</div>
<div className="text-slate-800">
360 / {n} ={" "}
<strong className="text-rose-600">{eachExterior}°</strong>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -74,11 +100,25 @@ const PolygonWidget: React.FC = () => {
<svg width={width} height={height}> <svg width={width} height={height}>
{/* Extensions for exterior angles */} {/* Extensions for exterior angles */}
{exteriorLines.map((line, i) => ( {exteriorLines.map((line, i) => (
<line key={i} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2} stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" /> <line
key={i}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
stroke="#cbd5e1"
strokeWidth="2"
strokeDasharray="4,4"
/>
))} ))}
{/* Polygon */} {/* Polygon */}
<path d={pathD} fill="rgba(16, 185, 129, 0.1)" stroke="#059669" strokeWidth="3" /> <path
d={pathD}
fill="rgba(16, 185, 129, 0.1)"
stroke="#059669"
strokeWidth="3"
/>
{/* Vertices */} {/* Vertices */}
{points.map((p, i) => ( {points.map((p, i) => (
@ -86,7 +126,16 @@ const PolygonWidget: React.FC = () => {
))} ))}
{/* Center text */} {/* Center text */}
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle" fill="#059669" fontSize="24" fontWeight="bold" opacity="0.2"> <text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="middle"
fill="#059669"
fontSize="24"
fontWeight="bold"
opacity="0.2"
>
{n}-gon {n}-gon
</text> </text>
</svg> </svg>

View File

@ -1,12 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const PolynomialBehaviorWidget: React.FC = () => { const PolynomialBehaviorWidget: React.FC = () => {
const [degreeType, setDegreeType] = useState<'even' | 'odd'>('odd'); const [degreeType, setDegreeType] = useState<"even" | "odd">("odd");
const [lcSign, setLcSign] = useState<'pos' | 'neg'>('pos'); const [lcSign, setLcSign] = useState<"pos" | "neg">("pos");
// Visualization
const width = 300;
const height = 200;
const getPath = () => { const getPath = () => {
// Create schematic shapes // Create schematic shapes
@ -15,8 +11,12 @@ const PolynomialBehaviorWidget: React.FC = () => {
// Even +: High Left -> High Right // Even +: High Left -> High Right
// Even -: Low Left -> Low Right // Even -: Low Left -> Low Right
const startY = (degreeType === 'odd' && lcSign === 'pos') || (degreeType === 'even' && lcSign === 'neg') ? 180 : 20; const startY =
const endY = (lcSign === 'pos') ? 20 : 180; (degreeType === "odd" && lcSign === "pos") ||
(degreeType === "even" && lcSign === "neg")
? 180
: 20;
const endY = lcSign === "pos" ? 20 : 180;
// Control points for curvy polynomial look // Control points for curvy polynomial look
const cp1Y = startY === 20 ? 150 : 50; const cp1Y = startY === 20 ? 150 : 50;
@ -29,46 +29,115 @@ const PolynomialBehaviorWidget: React.FC = () => {
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="grid grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-2 gap-4 mb-6">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Degree (Highest Power)</p> <p className="text-xs font-bold text-slate-400 uppercase">
Degree (Highest Power)
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => setDegreeType('even')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === 'even' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-200'}`}>Even (x², x)</button> <button
<button onClick={() => setDegreeType('odd')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === 'odd' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-200'}`}>Odd (x, x³)</button> onClick={() => setDegreeType("even")}
className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === "even" ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-slate-600 border-slate-200"}`}
>
Even (x², x)
</button>
<button
onClick={() => setDegreeType("odd")}
className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${degreeType === "odd" ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-slate-600 border-slate-200"}`}
>
Odd (x, x³)
</button>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs font-bold text-slate-400 uppercase">Leading Coefficient</p> <p className="text-xs font-bold text-slate-400 uppercase">
Leading Coefficient
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<button onClick={() => setLcSign('pos')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === 'pos' ? 'bg-emerald-600 text-white border-emerald-600' : 'bg-white text-slate-600 border-slate-200'}`}>Positive (+)</button> <button
<button onClick={() => setLcSign('neg')} className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === 'neg' ? 'bg-rose-600 text-white border-rose-600' : 'bg-white text-slate-600 border-slate-200'}`}>Negative (-)</button> onClick={() => setLcSign("pos")}
className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === "pos" ? "bg-emerald-600 text-white border-emerald-600" : "bg-white text-slate-600 border-slate-200"}`}
>
Positive (+)
</button>
<button
onClick={() => setLcSign("neg")}
className={`px-4 py-2 rounded-lg font-bold text-sm border transition-all ${lcSign === "neg" ? "bg-rose-600 text-white border-rose-600" : "bg-white text-slate-600 border-slate-200"}`}
>
Negative (-)
</button>
</div> </div>
</div> </div>
</div> </div>
<div className="relative h-48 bg-slate-50 border border-slate-200 rounded-xl overflow-hidden flex items-center justify-center"> <div className="relative h-48 bg-slate-50 border border-slate-200 rounded-xl overflow-hidden flex items-center justify-center">
<svg width="300" height="200"> <svg width="300" height="200">
<line x1="150" y1="20" x2="150" y2="180" stroke="#cbd5e1" strokeWidth="2" /> <line
<line x1="20" y1="100" x2="280" y2="100" stroke="#cbd5e1" strokeWidth="2" /> x1="150"
y1="20"
x2="150"
y2="180"
stroke="#cbd5e1"
strokeWidth="2"
/>
<line
x1="20"
y1="100"
x2="280"
y2="100"
stroke="#cbd5e1"
strokeWidth="2"
/>
<path d={getPath()} stroke="#8b5cf6" strokeWidth="4" fill="none" markerEnd="url(#arrow)" markerStart="url(#arrow-start)" /> <path
d={getPath()}
stroke="#8b5cf6"
strokeWidth="4"
fill="none"
markerEnd="url(#arrow)"
markerStart="url(#arrow-start)"
/>
<defs> <defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto"> <marker
id="arrow"
markerWidth="10"
markerHeight="10"
refX="8"
refY="3"
orient="auto"
>
<path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" /> <path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" />
</marker> </marker>
<marker id="arrow-start" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto-start-reverse"> <marker
id="arrow-start"
markerWidth="10"
markerHeight="10"
refX="8"
refY="3"
orient="auto-start-reverse"
>
<path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" /> <path d="M0,0 L0,6 L9,3 z" fill="#8b5cf6" />
</marker> </marker>
</defs> </defs>
</svg> </svg>
<div className="absolute top-2 left-2 text-xs font-bold text-slate-400">End Behavior</div> <div className="absolute top-2 left-2 text-xs font-bold text-slate-400">
End Behavior
</div>
</div> </div>
<div className="mt-4 p-3 bg-indigo-50 border border-indigo-100 rounded-lg text-sm text-indigo-900 text-center"> <div className="mt-4 p-3 bg-indigo-50 border border-indigo-100 rounded-lg text-sm text-indigo-900 text-center">
{degreeType === 'even' && lcSign === 'pos' && "Ends go in the SAME direction (UP)."} {degreeType === "even" &&
{degreeType === 'even' && lcSign === 'neg' && "Ends go in the SAME direction (DOWN)."} lcSign === "pos" &&
{degreeType === 'odd' && lcSign === 'pos' && "Ends go in OPPOSITE directions (Down Left, Up Right)."} "Ends go in the SAME direction (UP)."}
{degreeType === 'odd' && lcSign === 'neg' && "Ends go in OPPOSITE directions (Up Left, Down Right)."} {degreeType === "even" &&
lcSign === "neg" &&
"Ends go in the SAME direction (DOWN)."}
{degreeType === "odd" &&
lcSign === "pos" &&
"Ends go in OPPOSITE directions (Down Left, Up Right)."}
{degreeType === "odd" &&
lcSign === "neg" &&
"Ends go in OPPOSITE directions (Up Left, Down Right)."}
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const ProbabilityTreeWidget: React.FC = () => { const ProbabilityTreeWidget: React.FC = () => {
const [replacement, setReplacement] = useState(false); const [replacement, setReplacement] = useState(false);
@ -39,25 +39,39 @@ const ProbabilityTreeWidget: React.FC = () => {
); );
}; };
const getPathColor = (path: string, segment: 'top' | 'bottom' | 'top-top' | 'top-bottom' | 'bottom-top' | 'bottom-bottom') => { const getPathColor = (
segment:
| "top"
| "bottom"
| "top-top"
| "top-bottom"
| "bottom-top"
| "bottom-bottom",
) => {
const defaultColor = "#cbd5e1"; // Slate 300 const defaultColor = "#cbd5e1"; // Slate 300
if (!hoverPath) { if (!hoverPath) {
// Default coloring based on branch type // Default coloring based on branch type
if (segment.includes('top')) return "#f43f5e"; // Red branches if (segment.includes("top")) return "#f43f5e"; // Red branches
if (segment.includes('bottom')) return "#3b82f6"; // Blue branches if (segment.includes("bottom")) return "#3b82f6"; // Blue branches
return defaultColor; return defaultColor;
} }
// Highlighting logic based on hoverPath // Highlighting logic based on hoverPath
if (segment === 'top') return (hoverPath === 'RR' || hoverPath === 'RB') ? "#f43f5e" : "#f1f5f9"; if (segment === "top")
if (segment === 'bottom') return (hoverPath === 'BR' || hoverPath === 'BB') ? "#3b82f6" : "#f1f5f9"; return hoverPath === "RR" || hoverPath === "RB" ? "#f43f5e" : "#f1f5f9";
if (segment === "bottom")
return hoverPath === "BR" || hoverPath === "BB" ? "#3b82f6" : "#f1f5f9";
if (segment === 'top-top') return hoverPath === 'RR' ? "#f43f5e" : "#f1f5f9"; if (segment === "top-top")
if (segment === 'top-bottom') return hoverPath === 'RB' ? "#3b82f6" : "#f1f5f9"; return hoverPath === "RR" ? "#f43f5e" : "#f1f5f9";
if (segment === "top-bottom")
return hoverPath === "RB" ? "#3b82f6" : "#f1f5f9";
if (segment === 'bottom-top') return hoverPath === 'BR' ? "#f43f5e" : "#f1f5f9"; if (segment === "bottom-top")
if (segment === 'bottom-bottom') return hoverPath === 'BB' ? "#3b82f6" : "#f1f5f9"; return hoverPath === "BR" ? "#f43f5e" : "#f1f5f9";
if (segment === "bottom-bottom")
return hoverPath === "BB" ? "#3b82f6" : "#f1f5f9";
return defaultColor; return defaultColor;
}; };
@ -65,38 +79,63 @@ const ProbabilityTreeWidget: React.FC = () => {
const getStrokeWidth = (segment: string) => { const getStrokeWidth = (segment: string) => {
if (!hoverPath) return 2; if (!hoverPath) return 2;
if (segment === 'top') return (hoverPath === 'RR' || hoverPath === 'RB') ? 4 : 1; if (segment === "top")
if (segment === 'bottom') return (hoverPath === 'BR' || hoverPath === 'BB') ? 4 : 1; return hoverPath === "RR" || hoverPath === "RB" ? 4 : 1;
if (segment === "bottom")
return hoverPath === "BR" || hoverPath === "BB" ? 4 : 1;
if (segment === 'top-top') return hoverPath === 'RR' ? 4 : 1; if (segment === "top-top") return hoverPath === "RR" ? 4 : 1;
if (segment === 'top-bottom') return hoverPath === 'RB' ? 4 : 1; if (segment === "top-bottom") return hoverPath === "RB" ? 4 : 1;
if (segment === 'bottom-top') return hoverPath === 'BR' ? 4 : 1; if (segment === "bottom-top") return hoverPath === "BR" ? 4 : 1;
if (segment === 'bottom-bottom') return hoverPath === 'BB' ? 4 : 1; if (segment === "bottom-bottom") return hoverPath === "BB" ? 4 : 1;
return 2; return 2;
} };
return ( return (
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
{/* Controls */} {/* Controls */}
<div className="flex flex-wrap justify-between items-center mb-6 gap-4"> <div className="flex flex-wrap justify-between items-center mb-6 gap-4">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-xs font-bold text-rose-600 uppercase mb-1">Red Items</label> <label className="text-xs font-bold text-rose-600 uppercase mb-1">
Red Items
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => setInitR(Math.max(1, initR-1))} className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200">-</button> <button
onClick={() => setInitR(Math.max(1, initR - 1))}
className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200"
>
-
</button>
<span className="font-bold w-4 text-center">{initR}</span> <span className="font-bold w-4 text-center">{initR}</span>
<button onClick={() => setInitR(Math.min(10, initR+1))} className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200">+</button> <button
onClick={() => setInitR(Math.min(10, initR + 1))}
className="w-6 h-6 bg-rose-100 text-rose-700 rounded hover:bg-rose-200"
>
+
</button>
</div> </div>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<label className="text-xs font-bold text-blue-600 uppercase mb-1">Blue Items</label> <label className="text-xs font-bold text-blue-600 uppercase mb-1">
Blue Items
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => setInitB(Math.max(1, initB-1))} className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">-</button> <button
onClick={() => setInitB(Math.max(1, initB - 1))}
className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
-
</button>
<span className="font-bold w-4 text-center">{initB}</span> <span className="font-bold w-4 text-center">{initB}</span>
<button onClick={() => setInitB(Math.min(10, initB+1))} className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200">+</button> <button
onClick={() => setInitB(Math.min(10, initB + 1))}
className="w-6 h-6 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
+
</button>
</div> </div>
</div> </div>
</div> </div>
@ -104,13 +143,13 @@ const ProbabilityTreeWidget: React.FC = () => {
<div className="flex bg-slate-100 p-1 rounded-lg"> <div className="flex bg-slate-100 p-1 rounded-lg">
<button <button
onClick={() => setReplacement(true)} onClick={() => setReplacement(true)}
className={`px-3 py-1 text-xs font-bold rounded transition-all ${replacement ? 'bg-white shadow text-indigo-600' : 'text-slate-500'}`} className={`px-3 py-1 text-xs font-bold rounded transition-all ${replacement ? "bg-white shadow text-indigo-600" : "text-slate-500"}`}
> >
With Replacement With Replacement
</button> </button>
<button <button
onClick={() => setReplacement(false)} onClick={() => setReplacement(false)}
className={`px-3 py-1 text-xs font-bold rounded transition-all ${!replacement ? 'bg-white shadow text-indigo-600' : 'text-slate-500'}`} className={`px-3 py-1 text-xs font-bold rounded transition-all ${!replacement ? "bg-white shadow text-indigo-600" : "text-slate-500"}`}
> >
Without Replacement Without Replacement
</button> </button>
@ -123,124 +162,264 @@ const ProbabilityTreeWidget: React.FC = () => {
<circle cx="20" cy="128" r="6" fill="#64748b" /> <circle cx="20" cy="128" r="6" fill="#64748b" />
{/* Level 1 Branches */} {/* Level 1 Branches */}
<path d="M 20 128 C 50 128, 50 64, 150 64" fill="none" stroke={getPathColor('R', 'top')} strokeWidth={getStrokeWidth('top')} className="transition-all duration-300" /> <path
<path d="M 20 128 C 50 128, 50 192, 150 192" fill="none" stroke={getPathColor('B', 'bottom')} strokeWidth={getStrokeWidth('bottom')} className="transition-all duration-300" /> d="M 20 128 C 50 128, 50 64, 150 64"
fill="none"
stroke={getPathColor("top")}
strokeWidth={getStrokeWidth("top")}
className="transition-all duration-300"
/>
<path
d="M 20 128 C 50 128, 50 192, 150 192"
fill="none"
stroke={getPathColor("bottom")}
strokeWidth={getStrokeWidth("bottom")}
className="transition-all duration-300"
/>
{/* Level 1 Labels */} {/* Level 1 Labels */}
<foreignObject x="60" y="70" width="60" height="30"> <foreignObject x="60" y="70" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath && (hoverPath[0]!=='R') ? 'text-slate-300' : 'text-rose-600'}`}>{initR}/{total}</div> <div
className={`text-center font-bold text-xs ${hoverPath && hoverPath[0] !== "R" ? "text-slate-300" : "text-rose-600"}`}
>
{initR}/{total}
</div>
</foreignObject> </foreignObject>
<foreignObject x="60" y="150" width="60" height="30"> <foreignObject x="60" y="150" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath && (hoverPath[0]!=='B') ? 'text-slate-300' : 'text-blue-600'}`}>{initB}/{total}</div> <div
className={`text-center font-bold text-xs ${hoverPath && hoverPath[0] !== "B" ? "text-slate-300" : "text-blue-600"}`}
>
{initB}/{total}
</div>
</foreignObject> </foreignObject>
{/* Level 1 Nodes */} {/* Level 1 Nodes */}
<circle cx="150" cy="64" r="18" fill="#f43f5e" className={`transition-all ${hoverPath && hoverPath[0] !== 'R' ? 'opacity-20' : 'opacity-100 shadow-md'}`} /> <circle
<text x="150" y="68" textAnchor="middle" fill="white" className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== 'R' ? 'opacity-20' : ''}`}>R</text> cx="150"
cy="64"
r="18"
fill="#f43f5e"
className={`transition-all ${hoverPath && hoverPath[0] !== "R" ? "opacity-20" : "opacity-100 shadow-md"}`}
/>
<text
x="150"
y="68"
textAnchor="middle"
fill="white"
className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== "R" ? "opacity-20" : ""}`}
>
R
</text>
<circle cx="150" cy="192" r="18" fill="#3b82f6" className={`transition-all ${hoverPath && hoverPath[0] !== 'B' ? 'opacity-20' : 'opacity-100 shadow-md'}`} /> <circle
<text x="150" y="196" textAnchor="middle" fill="white" className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== 'B' ? 'opacity-20' : ''}`}>B</text> cx="150"
cy="192"
r="18"
fill="#3b82f6"
className={`transition-all ${hoverPath && hoverPath[0] !== "B" ? "opacity-20" : "opacity-100 shadow-md"}`}
/>
<text
x="150"
y="196"
textAnchor="middle"
fill="white"
className={`text-xs font-bold pointer-events-none ${hoverPath && hoverPath[0] !== "B" ? "opacity-20" : ""}`}
>
B
</text>
{/* Level 2 Branches (Top) */} {/* Level 2 Branches (Top) */}
<path d="M 168 64 L 280 32" fill="none" stroke={getPathColor('RR', 'top-top')} strokeWidth={getStrokeWidth('top-top')} strokeDasharray="4,2" className="transition-all duration-300" /> <path
<path d="M 168 64 L 280 96" fill="none" stroke={getPathColor('RB', 'top-bottom')} strokeWidth={getStrokeWidth('top-bottom')} strokeDasharray="4,2" className="transition-all duration-300" /> d="M 168 64 L 280 32"
fill="none"
stroke={getPathColor("top-top")}
strokeWidth={getStrokeWidth("top-top")}
strokeDasharray="4,2"
className="transition-all duration-300"
/>
<path
d="M 168 64 L 280 96"
fill="none"
stroke={getPathColor("top-bottom")}
strokeWidth={getStrokeWidth("top-bottom")}
strokeDasharray="4,2"
className="transition-all duration-300"
/>
{/* Level 2 Top Labels */} {/* Level 2 Top Labels */}
<foreignObject x="190" y="25" width="60" height="30"> <foreignObject x="190" y="25" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'RR' ? 'text-rose-600 scale-110' : 'text-slate-400'}`}>{r_R}/{r_Total}</div> <div
className={`text-center font-bold text-xs ${hoverPath === "RR" ? "text-rose-600 scale-110" : "text-slate-400"}`}
>
{r_R}/{r_Total}
</div>
</foreignObject> </foreignObject>
<foreignObject x="190" y="80" width="60" height="30"> <foreignObject x="190" y="80" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'RB' ? 'text-blue-600 scale-110' : 'text-slate-400'}`}>{initB}/{r_Total}</div> <div
className={`text-center font-bold text-xs ${hoverPath === "RB" ? "text-blue-600 scale-110" : "text-slate-400"}`}
>
{initB}/{r_Total}
</div>
</foreignObject> </foreignObject>
{/* Level 2 Branches (Bottom) */} {/* Level 2 Branches (Bottom) */}
<path d="M 168 192 L 280 160" fill="none" stroke={getPathColor('BR', 'bottom-top')} strokeWidth={getStrokeWidth('bottom-top')} strokeDasharray="4,2" className="transition-all duration-300" /> <path
<path d="M 168 192 L 280 224" fill="none" stroke={getPathColor('BB', 'bottom-bottom')} strokeWidth={getStrokeWidth('bottom-bottom')} strokeDasharray="4,2" className="transition-all duration-300" /> d="M 168 192 L 280 160"
fill="none"
stroke={getPathColor("bottom-top")}
strokeWidth={getStrokeWidth("bottom-top")}
strokeDasharray="4,2"
className="transition-all duration-300"
/>
<path
d="M 168 192 L 280 224"
fill="none"
stroke={getPathColor("bottom-bottom")}
strokeWidth={getStrokeWidth("bottom-bottom")}
strokeDasharray="4,2"
className="transition-all duration-300"
/>
{/* Level 2 Bottom Labels */} {/* Level 2 Bottom Labels */}
<foreignObject x="190" y="150" width="60" height="30"> <foreignObject x="190" y="150" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'BR' ? 'text-rose-600 scale-110' : 'text-slate-400'}`}>{initR}/{b_Total}</div> <div
className={`text-center font-bold text-xs ${hoverPath === "BR" ? "text-rose-600 scale-110" : "text-slate-400"}`}
>
{initR}/{b_Total}
</div>
</foreignObject> </foreignObject>
<foreignObject x="190" y="210" width="60" height="30"> <foreignObject x="190" y="210" width="60" height="30">
<div className={`text-center font-bold text-xs ${hoverPath === 'BB' ? 'text-blue-600 scale-110' : 'text-slate-400'}`}>{b_B}/{b_Total}</div> <div
className={`text-center font-bold text-xs ${hoverPath === "BB" ? "text-blue-600 scale-110" : "text-slate-400"}`}
>
{b_B}/{b_Total}
</div>
</foreignObject> </foreignObject>
{/* Outcomes (Interactive Targets) */} {/* Outcomes (Interactive Targets) */}
<g <g
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => setHoverPath('RR')} onMouseEnter={() => setHoverPath("RR")}
onMouseLeave={() => setHoverPath(null)} onMouseLeave={() => setHoverPath(null)}
> >
<text x="300" y="36" className={`text-xs font-bold transition-all ${hoverPath === 'RR' ? 'fill-rose-600 text-base' : 'fill-slate-500'}`}>RR: {(pRR * 100).toFixed(1)}%</text> <text
x="300"
y="36"
className={`text-xs font-bold transition-all ${hoverPath === "RR" ? "fill-rose-600 text-base" : "fill-slate-500"}`}
>
RR: {(pRR * 100).toFixed(1)}%
</text>
<rect x="290" y="20" width="80" height="20" fill="transparent" /> <rect x="290" y="20" width="80" height="20" fill="transparent" />
</g> </g>
<g <g
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => setHoverPath('RB')} onMouseEnter={() => setHoverPath("RB")}
onMouseLeave={() => setHoverPath(null)} onMouseLeave={() => setHoverPath(null)}
> >
<text x="300" y="100" className={`text-xs font-bold transition-all ${hoverPath === 'RB' ? 'fill-indigo-600 text-base' : 'fill-slate-500'}`}>RB: {(pRB * 100).toFixed(1)}%</text> <text
x="300"
y="100"
className={`text-xs font-bold transition-all ${hoverPath === "RB" ? "fill-indigo-600 text-base" : "fill-slate-500"}`}
>
RB: {(pRB * 100).toFixed(1)}%
</text>
<rect x="290" y="85" width="80" height="20" fill="transparent" /> <rect x="290" y="85" width="80" height="20" fill="transparent" />
</g> </g>
<g <g
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => setHoverPath('BR')} onMouseEnter={() => setHoverPath("BR")}
onMouseLeave={() => setHoverPath(null)} onMouseLeave={() => setHoverPath(null)}
> >
<text x="300" y="164" className={`text-xs font-bold transition-all ${hoverPath === 'BR' ? 'fill-indigo-600 text-base' : 'fill-slate-500'}`}>BR: {(pBR * 100).toFixed(1)}%</text> <text
x="300"
y="164"
className={`text-xs font-bold transition-all ${hoverPath === "BR" ? "fill-indigo-600 text-base" : "fill-slate-500"}`}
>
BR: {(pBR * 100).toFixed(1)}%
</text>
<rect x="290" y="150" width="80" height="20" fill="transparent" /> <rect x="290" y="150" width="80" height="20" fill="transparent" />
</g> </g>
<g <g
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => setHoverPath('BB')} onMouseEnter={() => setHoverPath("BB")}
onMouseLeave={() => setHoverPath(null)} onMouseLeave={() => setHoverPath(null)}
> >
<text x="300" y="228" className={`text-xs font-bold transition-all ${hoverPath === 'BB' ? 'fill-blue-600 text-base' : 'fill-slate-500'}`}>BB: {(pBB * 100).toFixed(1)}%</text> <text
x="300"
y="228"
className={`text-xs font-bold transition-all ${hoverPath === "BB" ? "fill-blue-600 text-base" : "fill-slate-500"}`}
>
BB: {(pBB * 100).toFixed(1)}%
</text>
<rect x="290" y="215" width="80" height="20" fill="transparent" /> <rect x="290" y="215" width="80" height="20" fill="transparent" />
</g> </g>
</svg> </svg>
</div> </div>
{/* Calculation Panel */} {/* Calculation Panel */}
<div className={`p-4 rounded-lg border text-sm mt-4 transition-colors ${hoverPath ? 'bg-amber-50 border-amber-200 text-amber-900' : 'bg-slate-50 border-slate-100 text-slate-400'}`}> <div
className={`p-4 rounded-lg border text-sm mt-4 transition-colors ${hoverPath ? "bg-amber-50 border-amber-200 text-amber-900" : "bg-slate-50 border-slate-100 text-slate-400"}`}
>
{!hoverPath ? ( {!hoverPath ? (
<p className="text-center italic">Hover over an outcome (e.g., RR) to see the calculation.</p> <p className="text-center italic">
Hover over an outcome (e.g., RR) to see the calculation.
</p>
) : ( ) : (
<> <>
<p className="font-bold mb-1"> <p className="font-bold mb-1">
Calculation for <span className="font-mono bg-white px-1 rounded border border-amber-200">{hoverPath}</span> Calculation for{" "}
({hoverPath[0] === 'R' ? 'Red' : 'Blue'} then {hoverPath[1] === 'R' ? 'Red' : 'Blue'}): <span className="font-mono bg-white px-1 rounded border border-amber-200">
{hoverPath}
</span>
({hoverPath[0] === "R" ? "Red" : "Blue"} then{" "}
{hoverPath[1] === "R" ? "Red" : "Blue"}):
</p> </p>
<div className="flex flex-wrap items-center gap-2 font-mono text-lg mt-2 justify-center sm:justify-start"> <div className="flex flex-wrap items-center gap-2 font-mono text-lg mt-2 justify-center sm:justify-start">
{/* First Draw */} {/* First Draw */}
<span>P({hoverPath[0]})</span> <span>P({hoverPath[0]})</span>
<span>×</span> <span>×</span>
<span>P({hoverPath[1]} | {hoverPath[0]})</span> <span>
P({hoverPath[1]} | {hoverPath[0]})
</span>
<span>=</span> <span>=</span>
{/* Numbers */} {/* Numbers */}
{fraction(hoverPath[0] === 'R' ? initR : initB, total)} {fraction(hoverPath[0] === "R" ? initR : initB, total)}
<span>×</span> <span>×</span>
{fraction( {fraction(
hoverPath === 'RR' ? r_R : hoverPath === 'RB' ? initB : hoverPath === 'BR' ? initR : b_B, hoverPath === "RR"
hoverPath[0] === 'R' ? r_Total : b_Total ? r_R
: hoverPath === "RB"
? initB
: hoverPath === "BR"
? initR
: b_B,
hoverPath[0] === "R" ? r_Total : b_Total,
)} )}
<span>=</span> <span>=</span>
{/* Result */} {/* Result */}
<strong className="text-amber-700"> <strong className="text-amber-700">
{fraction( {fraction(
(hoverPath[0] === 'R' ? initR : initB) * (hoverPath === 'RR' ? r_R : hoverPath === 'RB' ? initB : hoverPath === 'BR' ? initR : b_B), (hoverPath[0] === "R" ? initR : initB) *
total * (hoverPath[0] === 'R' ? r_Total : b_Total) (hoverPath === "RR"
? r_R
: hoverPath === "RB"
? initB
: hoverPath === "BR"
? initR
: b_B),
total * (hoverPath[0] === "R" ? r_Total : b_Total),
)} )}
</strong> </strong>
</div> </div>
{!replacement && hoverPath[0] === hoverPath[1] && ( {!replacement && hoverPath[0] === hoverPath[1] && (
<p className="text-xs mt-3 text-rose-600 font-bold bg-white p-2 rounded inline-block border border-rose-100"> <p className="text-xs mt-3 text-rose-600 font-bold bg-white p-2 rounded inline-block border border-rose-100">
Notice: The numerator decreased because we kept the first {hoverPath[0] === 'R' ? 'Red' : 'Blue'} item! Notice: The numerator decreased because we kept the first{" "}
{hoverPath[0] === "R" ? "Red" : "Blue"} item!
</p> </p>
)} )}
</> </>

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
const RadicalSolutionWidget: React.FC = () => { const RadicalSolutionWidget: React.FC = () => {
// Equation: sqrt(x) = x - k // Equation: sqrt(x) = x - k
@ -16,32 +16,41 @@ const RadicalSolutionWidget: React.FC = () => {
if (disc >= 0) { if (disc >= 0) {
const x1 = (-b + Math.sqrt(disc)) / (2 * a); const x1 = (-b + Math.sqrt(disc)) / (2 * a);
const x2 = (-b - Math.sqrt(disc)) / (2 * a); const x2 = (-b - Math.sqrt(disc)) / (2 * a);
solutions = [x1, x2].filter(val => val >= 0); // Domain x>=0 solutions = [x1, x2].filter((val) => val >= 0); // Domain x>=0
} }
// Check validity against original equation sqrt(x) = x - k // Check validity against original equation sqrt(x) = x - k
const validSolutions = solutions.filter(x => Math.abs(Math.sqrt(x) - (x - k)) < 0.01); const validSolutions = solutions.filter(
const extraneousSolutions = solutions.filter(x => Math.abs(Math.sqrt(x) - (x - k)) >= 0.01); (x) => Math.abs(Math.sqrt(x) - (x - k)) < 0.01,
);
const extraneousSolutions = solutions.filter(
(x) => Math.abs(Math.sqrt(x) - (x - k)) >= 0.01,
);
// Vis // Vis
const width = 300;
const height = 300; const height = 300;
const range = 10; const range = 10;
const scale = 25; const scale = 25;
const toPx = (v: number, isY = false) => isY ? height - v * scale - 20 : v * scale + 20; const toPx = (v: number, isY = false) =>
isY ? height - v * scale - 20 : v * scale + 20;
const pathSqrt = () => { const pathSqrt = () => {
let d = ""; let d = "";
for (let x = 0; x <= range; x += 0.1) { for (let x = 0; x <= range; x += 0.1) {
d += d ? ` L ${toPx(x)} ${toPx(Math.sqrt(x), true)}` : `M ${toPx(x)} ${toPx(Math.sqrt(x), true)}`; d += d
? ` L ${toPx(x)} ${toPx(Math.sqrt(x), true)}`
: `M ${toPx(x)} ${toPx(Math.sqrt(x), true)}`;
} }
return d; return d;
}; };
const pathLine = () => { const pathLine = () => {
// y = x - k // y = x - k
const x1 = 0; const y1 = -k; const x1 = 0;
const x2 = range; const y2 = range - k; const y1 = -k;
const x2 = range;
const y2 = range - k;
return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`; return `M ${toPx(x1)} ${toPx(y1, true)} L ${toPx(x2)} ${toPx(y2, true)}`;
}; };
@ -50,7 +59,9 @@ const RadicalSolutionWidget: React.FC = () => {
const pathPhantom = () => { const pathPhantom = () => {
let d = ""; let d = "";
for (let x = 0; x <= range; x += 0.1) { for (let x = 0; x <= range; x += 0.1) {
d += d ? ` L ${toPx(x)} ${toPx(-Math.sqrt(x), true)}` : `M ${toPx(x)} ${toPx(-Math.sqrt(x), true)}`; d += d
? ` L ${toPx(x)} ${toPx(-Math.sqrt(x), true)}`
: `M ${toPx(x)} ${toPx(-Math.sqrt(x), true)}`;
} }
return d; return d;
}; };
@ -60,34 +71,58 @@ const RadicalSolutionWidget: React.FC = () => {
<div className="flex flex-col md:flex-row gap-8"> <div className="flex flex-col md:flex-row gap-8">
<div className="w-full md:w-1/3 space-y-6"> <div className="w-full md:w-1/3 space-y-6">
<div className="bg-slate-50 p-4 rounded-xl border border-slate-200"> <div className="bg-slate-50 p-4 rounded-xl border border-slate-200">
<div className="text-xs font-bold text-slate-400 uppercase mb-2">Equation</div> <div className="text-xs font-bold text-slate-400 uppercase mb-2">
Equation
</div>
<div className="font-mono text-lg font-bold text-slate-800"> <div className="font-mono text-lg font-bold text-slate-800">
x = x - {k} x = x - {k}
</div> </div>
</div> </div>
<div> <div>
<label className="text-xs font-bold text-slate-500 uppercase">Shift Line (k) = {k}</label> <label className="text-xs font-bold text-slate-500 uppercase">
<input type="range" min="0" max="6" step="0.5" value={k} onChange={e => setK(parseFloat(e.target.value))} className="w-full h-2 bg-slate-200 rounded-lg accent-indigo-600 mt-2"/> Shift Line (k) = {k}
</label>
<input
type="range"
min="0"
max="6"
step="0.5"
value={k}
onChange={(e) => setK(parseFloat(e.target.value))}
className="w-full h-2 bg-slate-200 rounded-lg accent-indigo-600 mt-2"
/>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="p-3 bg-emerald-50 rounded border border-emerald-100"> <div className="p-3 bg-emerald-50 rounded border border-emerald-100">
<div className="text-xs font-bold text-emerald-700 uppercase mb-1">Valid Solutions</div> <div className="text-xs font-bold text-emerald-700 uppercase mb-1">
Valid Solutions
</div>
<div className="font-mono text-sm font-bold text-emerald-900"> <div className="font-mono text-sm font-bold text-emerald-900">
{validSolutions.length > 0 ? validSolutions.map(n => `x = ${n.toFixed(2)}`).join(', ') : "None"} {validSolutions.length > 0
? validSolutions.map((n) => `x = ${n.toFixed(2)}`).join(", ")
: "None"}
</div> </div>
</div> </div>
<div className="p-3 bg-rose-50 rounded border border-rose-100"> <div className="p-3 bg-rose-50 rounded border border-rose-100">
<div className="text-xs font-bold text-rose-700 uppercase mb-1">Extraneous Solutions</div> <div className="text-xs font-bold text-rose-700 uppercase mb-1">
Extraneous Solutions
</div>
<div className="font-mono text-sm font-bold text-rose-900"> <div className="font-mono text-sm font-bold text-rose-900">
{extraneousSolutions.length > 0 ? extraneousSolutions.map(n => `x = ${n.toFixed(2)}`).join(', ') : "None"} {extraneousSolutions.length > 0
? extraneousSolutions
.map((n) => `x = ${n.toFixed(2)}`)
.join(", ")
: "None"}
</div> </div>
</div> </div>
</div> </div>
<p className="text-xs text-slate-400 leading-relaxed"> <p className="text-xs text-slate-400 leading-relaxed">
The <span className="text-rose-400 font-bold">extraneous</span> solution is a real intersection for the <em>squared</em> equation (the phantom curve), but not for the original radical. The <span className="text-rose-400 font-bold">extraneous</span>{" "}
solution is a real intersection for the <em>squared</em> equation
(the phantom curve), but not for the original radical.
</p> </p>
</div> </div>
@ -96,35 +131,95 @@ const RadicalSolutionWidget: React.FC = () => {
<svg width="100%" height="100%" viewBox="0 0 300 300"> <svg width="100%" height="100%" viewBox="0 0 300 300">
{/* Grid */} {/* Grid */}
<defs> <defs>
<pattern id="grid-rad" width="25" height="25" patternUnits="userSpaceOnUse"> <pattern
<path d="M 25 0 L 0 0 0 25" fill="none" stroke="#f8fafc" strokeWidth="1"/> id="grid-rad"
width="25"
height="25"
patternUnits="userSpaceOnUse"
>
<path
d="M 25 0 L 0 0 0 25"
fill="none"
stroke="#f8fafc"
strokeWidth="1"
/>
</pattern> </pattern>
</defs> </defs>
<rect width="100%" height="100%" fill="url(#grid-rad)" /> <rect width="100%" height="100%" fill="url(#grid-rad)" />
{/* Axes */} {/* Axes */}
<line x1="20" y1="0" x2="20" y2="300" stroke="#cbd5e1" strokeWidth="2" /> <line
<line x1="0" y1={toPx(0, true)} x2="300" y2={toPx(0, true)} stroke="#cbd5e1" strokeWidth="2" /> x1="20"
y1="0"
x2="20"
y2="300"
stroke="#cbd5e1"
strokeWidth="2"
/>
<line
x1="0"
y1={toPx(0, true)}
x2="300"
y2={toPx(0, true)}
stroke="#cbd5e1"
strokeWidth="2"
/>
{/* Phantom -sqrt(x) */} {/* Phantom -sqrt(x) */}
<path d={pathPhantom()} fill="none" stroke="#cbd5e1" strokeWidth="2" strokeDasharray="4,4" /> <path
d={pathPhantom()}
fill="none"
stroke="#cbd5e1"
strokeWidth="2"
strokeDasharray="4,4"
/>
{/* Real sqrt(x) */} {/* Real sqrt(x) */}
<path d={pathSqrt()} fill="none" stroke="#4f46e5" strokeWidth="3" /> <path
d={pathSqrt()}
fill="none"
stroke="#4f46e5"
strokeWidth="3"
/>
{/* Line x-k */} {/* Line x-k */}
<path d={pathLine()} fill="none" stroke="#64748b" strokeWidth="2" /> <path
d={pathLine()}
fill="none"
stroke="#64748b"
strokeWidth="2"
/>
{/* Points */} {/* Points */}
{validSolutions.map(x => ( {validSolutions.map((x) => (
<circle key={`v-${x}`} cx={toPx(x)} cy={toPx(Math.sqrt(x), true)} r="5" fill="#10b981" stroke="white" strokeWidth="2" /> <circle
key={`v-${x}`}
cx={toPx(x)}
cy={toPx(Math.sqrt(x), true)}
r="5"
fill="#10b981"
stroke="white"
strokeWidth="2"
/>
))} ))}
{extraneousSolutions.map(x => ( {extraneousSolutions.map((x) => (
<circle key={`e-${x}`} cx={toPx(x)} cy={toPx(-(Math.sqrt(x)), true)} r="5" fill="#f43f5e" stroke="white" strokeWidth="2" /> <circle
key={`e-${x}`}
cx={toPx(x)}
cy={toPx(-Math.sqrt(x), true)}
r="5"
fill="#f43f5e"
stroke="white"
strokeWidth="2"
/>
))} ))}
</svg> </svg>
<div className="absolute top-2 right-2 text-xs font-bold text-indigo-600">y = x</div> <div className="absolute top-2 right-2 text-xs font-bold text-indigo-600">
<div className="absolute bottom-10 right-2 text-xs font-bold text-slate-500">y = x - {k}</div> y = x
</div>
<div className="absolute bottom-10 right-2 text-xs font-bold text-slate-500">
y = x - {k}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,9 +1,9 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from "react";
type Mode = 'AA' | 'SAS' | 'SSS'; type Mode = "AA" | "SAS" | "SSS";
const SimilarityTestsWidget: React.FC = () => { const SimilarityTestsWidget: React.FC = () => {
const [mode, setMode] = useState<Mode>('AA'); const [mode, setMode] = useState<Mode>("AA");
const [scale, setScale] = useState(1.5); const [scale, setScale] = useState(1.5);
// Store Vertex B's position relative to A (x offset, y height) // Store Vertex B's position relative to A (x offset, y height)
// A is at (40, 220). SVG Y is down. // A is at (40, 220). SVG Y is down.
@ -20,13 +20,16 @@ const SimilarityTestsWidget: React.FC = () => {
const B = { x: A.x + vertexB.x, y: A.y - vertexB.y }; const B = { x: A.x + vertexB.x, y: A.y - vertexB.y };
// Calculate lengths and angles for T1 // Calculate lengths and angles for T1
const dist = (p1: {x:number, y:number}, p2: {x:number, y:number}) => Math.sqrt((p1.x - p2.x)**2 + (p1.y - p2.y)**2); const dist = (p1: { x: number; y: number }, p2: { x: number; y: number }) =>
Math.sqrt((p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2);
const c1 = dist(A, B); // side c (opp C) - Side AB const c1 = dist(A, B); // side c (opp C) - Side AB
const a1 = dist(B, C); // side a (opp A) - Side BC const a1 = dist(B, C); // side a (opp A) - Side BC
const b1 = dist(A, C); // side b (opp B) - Side AC (Base) const b1 = dist(A, C); // side b (opp B) - Side AC (Base)
const getAngle = (a: number, b: number, c: number) => { const getAngle = (a: number, b: number, c: number) => {
return Math.acos((b**2 + c**2 - a**2) / (2 * b * c)) * (180 / Math.PI); return (
Math.acos((b ** 2 + c ** 2 - a ** 2) / (2 * b * c)) * (180 / Math.PI)
);
}; };
const angleA = getAngle(a1, b1, c1); const angleA = getAngle(a1, b1, c1);
@ -45,7 +48,7 @@ const SimilarityTestsWidget: React.FC = () => {
const vecAB = { x: B.x - A.x, y: B.y - A.y }; const vecAB = { x: B.x - A.x, y: B.y - A.y };
const E = { const E = {
x: D.x + vecAB.x * scale, x: D.x + vecAB.x * scale,
y: D.y + vecAB.y * scale y: D.y + vecAB.y * scale,
}; };
// Interaction // Interaction
@ -72,18 +75,22 @@ const SimilarityTestsWidget: React.FC = () => {
const sideColor = "#059669"; // Emerald const sideColor = "#059669"; // Emerald
// Helper: draw filled angle wedge + labelled badge at a vertex // Helper: draw filled angle wedge + labelled badge at a vertex
const angleC = 180 - angleA - angleB;
const renderAngle = ( const renderAngle = (
vx: number, vy: number, vx: number,
p1x: number, p1y: number, vy: number,
p2x: number, p2y: number, p1x: number,
p1y: number,
p2x: number,
p2y: number,
deg: number, deg: number,
r = 28 r = 28,
) => { ) => {
const d1 = Math.atan2(p1y - vy, p1x - vx); const d1 = Math.atan2(p1y - vy, p1x - vx);
const d2 = Math.atan2(p2y - vy, p2x - vx); const d2 = Math.atan2(p2y - vy, p2x - vx);
const sx = vx + r * Math.cos(d1), sy = vy + r * Math.sin(d1); const sx = vx + r * Math.cos(d1),
const ex = vx + r * Math.cos(d2), ey = vy + r * Math.sin(d2); sy = vy + r * Math.sin(d1);
const ex = vx + r * Math.cos(d2),
ey = vy + r * Math.sin(d2);
const cross = (p1x - vx) * (p2y - vy) - (p1y - vy) * (p2x - vx); const cross = (p1x - vx) * (p2y - vy) - (p1y - vy) * (p2x - vx);
const sweep = cross > 0 ? 1 : 0; const sweep = cross > 0 ? 1 : 0;
let diff = d2 - d1; let diff = d2 - d1;
@ -91,13 +98,40 @@ const SimilarityTestsWidget: React.FC = () => {
while (diff < -Math.PI) diff += 2 * Math.PI; while (diff < -Math.PI) diff += 2 * Math.PI;
const mid = d1 + diff / 2; const mid = d1 + diff / 2;
const lr = r + 18; const lr = r + 18;
const lx = vx + lr * Math.cos(mid), ly = vy + lr * Math.sin(mid); const lx = vx + lr * Math.cos(mid),
ly = vy + lr * Math.sin(mid);
const txt = `${Math.round(deg)}°`; const txt = `${Math.round(deg)}°`;
return ( return (
<g> <g>
<path d={`M ${vx} ${vy} L ${sx} ${sy} A ${r} ${r} 0 0 ${sweep} ${ex} ${ey} Z`} fill={angleColor} fillOpacity={0.12} stroke={angleColor} strokeWidth={2} /> <path
<rect x={lx - 18} y={ly - 10} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={angleColor} strokeWidth={0.8} /> d={`M ${vx} ${vy} L ${sx} ${sy} A ${r} ${r} 0 0 ${sweep} ${ex} ${ey} Z`}
<text x={lx} y={ly + 5} textAnchor="middle" fill={angleColor} fontSize="13" fontWeight="bold" fontFamily="system-ui">{txt}</text> fill={angleColor}
fillOpacity={0.12}
stroke={angleColor}
strokeWidth={2}
/>
<rect
x={lx - 18}
y={ly - 10}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={angleColor}
strokeWidth={0.8}
/>
<text
x={lx}
y={ly + 5}
textAnchor="middle"
fill={angleColor}
fontSize="13"
fontWeight="bold"
fontFamily="system-ui"
>
{txt}
</text>
</g> </g>
); );
}; };
@ -106,14 +140,14 @@ const SimilarityTestsWidget: React.FC = () => {
<div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200"> <div className="bg-white p-6 rounded-xl shadow-lg border border-slate-200">
<div className="flex flex-col sm:flex-row gap-4 justify-between items-center mb-6"> <div className="flex flex-col sm:flex-row gap-4 justify-between items-center mb-6">
<div className="flex bg-slate-100 p-1 rounded-lg overflow-x-auto max-w-full"> <div className="flex bg-slate-100 p-1 rounded-lg overflow-x-auto max-w-full">
{(['AA', 'SAS', 'SSS'] as Mode[]).map(m => ( {(["AA", "SAS", "SSS"] as Mode[]).map((m) => (
<button <button
key={m} key={m}
onClick={() => setMode(m)} onClick={() => setMode(m)}
className={`px-4 py-2 rounded-md text-sm font-bold transition-all whitespace-nowrap ${ className={`px-4 py-2 rounded-md text-sm font-bold transition-all whitespace-nowrap ${
mode === m mode === m
? 'bg-white text-rose-600 shadow-sm' ? "bg-white text-rose-600 shadow-sm"
: 'text-slate-500 hover:text-rose-600' : "text-slate-500 hover:text-rose-600"
}`} }`}
> >
{m} {m}
@ -122,61 +156,150 @@ const SimilarityTestsWidget: React.FC = () => {
</div> </div>
<div className="flex items-center gap-3 bg-slate-50 px-4 py-2 rounded-lg border border-slate-200"> <div className="flex items-center gap-3 bg-slate-50 px-4 py-2 rounded-lg border border-slate-200">
<span className="text-xs font-bold text-slate-400 uppercase">Scale (k)</span> <span className="text-xs font-bold text-slate-400 uppercase">
Scale (k)
</span>
<input <input
type="range" min="0.5" max="2.5" step="0.1" type="range"
min="0.5"
max="2.5"
step="0.1"
value={scale} value={scale}
onChange={e => setScale(parseFloat(e.target.value))} onChange={(e) => setScale(parseFloat(e.target.value))}
className="w-24 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-rose-600" className="w-24 h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-rose-600"
/> />
<span className="font-mono font-bold text-rose-600 text-sm w-12 text-right">{scale.toFixed(1)}x</span> <span className="font-mono font-bold text-rose-600 text-sm w-12 text-right">
{scale.toFixed(1)}x
</span>
</div> </div>
</div> </div>
<div className="relative border border-slate-100 rounded-lg bg-slate-50 mb-6 overflow-hidden flex justify-center"> <div className="relative border border-slate-100 rounded-lg bg-slate-50 mb-6 overflow-hidden flex justify-center">
<svg <svg
ref={svgRef} ref={svgRef}
width="550" height="280" width="550"
height="280"
className="cursor-default select-none" className="cursor-default select-none"
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false} onMouseUp={() => (isDragging.current = false)}
onMouseLeave={() => isDragging.current = false} onMouseLeave={() => (isDragging.current = false)}
> >
<defs> <defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse"> <pattern
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e2e8f0" strokeWidth="0.5"/> id="grid"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<path
d="M 20 0 L 0 0 0 20"
fill="none"
stroke="#e2e8f0"
strokeWidth="0.5"
/>
</pattern> </pattern>
</defs> </defs>
<rect width="100%" height="100%" fill="url(#grid)" /> <rect width="100%" height="100%" fill="url(#grid)" />
{/* Triangle 1 (ABC) */} {/* Triangle 1 (ABC) */}
<path d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`} fill="rgba(255, 255, 255, 0.8)" stroke="#334155" strokeWidth="2" /> <path
d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`}
fill="rgba(255, 255, 255, 0.8)"
stroke="#334155"
strokeWidth="2"
/>
{/* Vertices T1 */} {/* Vertices T1 */}
<circle cx={A.x} cy={A.y} r="4" fill="#334155" /> <circle cx={A.x} cy={A.y} r="4" fill="#334155" />
<text x={A.x - 16} y={A.y + 14} fontWeight="bold" fill="#334155" fontSize="14">A</text> <text
x={A.x - 16}
y={A.y + 14}
fontWeight="bold"
fill="#334155"
fontSize="14"
>
A
</text>
<circle cx={C.x} cy={C.y} r="4" fill="#334155" /> <circle cx={C.x} cy={C.y} r="4" fill="#334155" />
<text x={C.x + 8} y={C.y + 14} fontWeight="bold" fill="#334155" fontSize="14">C</text> <text
x={C.x + 8}
y={C.y + 14}
fontWeight="bold"
fill="#334155"
fontSize="14"
>
C
</text>
{/* Draggable B */} {/* Draggable B */}
<g onMouseDown={() => isDragging.current = true} className="cursor-grab active:cursor-grabbing"> <g
<circle cx={B.x} cy={B.y} r="20" fill="transparent" /> {/* Hit area */} onMouseDown={() => (isDragging.current = true)}
<circle cx={B.x} cy={B.y} r="7" fill="#f43f5e" stroke="white" strokeWidth="2" /> className="cursor-grab active:cursor-grabbing"
<text x={B.x} y={B.y - 16} textAnchor="middle" fontWeight="bold" fill="#f43f5e" fontSize="14">B</text> >
<circle cx={B.x} cy={B.y} r="20" fill="transparent" />{" "}
{/* Hit area */}
<circle
cx={B.x}
cy={B.y}
r="7"
fill="#f43f5e"
stroke="white"
strokeWidth="2"
/>
<text
x={B.x}
y={B.y - 16}
textAnchor="middle"
fontWeight="bold"
fill="#f43f5e"
fontSize="14"
>
B
</text>
</g> </g>
{/* Triangle 2 (DEF) */} {/* Triangle 2 (DEF) */}
<path d={`M ${D.x} ${D.y} L ${E.x} ${E.y} L ${F.x} ${F.y} Z`} fill="rgba(255, 255, 255, 0.8)" stroke="#334155" strokeWidth="2" /> <path
d={`M ${D.x} ${D.y} L ${E.x} ${E.y} L ${F.x} ${F.y} Z`}
fill="rgba(255, 255, 255, 0.8)"
stroke="#334155"
strokeWidth="2"
/>
<circle cx={D.x} cy={D.y} r="4" fill="#334155" /> <circle cx={D.x} cy={D.y} r="4" fill="#334155" />
<text x={D.x - 16} y={D.y + 14} fontWeight="bold" fill="#334155" fontSize="14">D</text> <text
x={D.x - 16}
y={D.y + 14}
fontWeight="bold"
fill="#334155"
fontSize="14"
>
D
</text>
<circle cx={F.x} cy={F.y} r="4" fill="#334155" /> <circle cx={F.x} cy={F.y} r="4" fill="#334155" />
<text x={F.x + 8} y={F.y + 14} fontWeight="bold" fill="#334155" fontSize="14">F</text> <text
x={F.x + 8}
y={F.y + 14}
fontWeight="bold"
fill="#334155"
fontSize="14"
>
F
</text>
<circle cx={E.x} cy={E.y} r="4" fill="#334155" /> <circle cx={E.x} cy={E.y} r="4" fill="#334155" />
<text x={E.x} y={E.y - 16} textAnchor="middle" fontWeight="bold" fill="#334155" fontSize="14">E</text> <text
x={E.x}
y={E.y - 16}
textAnchor="middle"
fontWeight="bold"
fill="#334155"
fontSize="14"
>
E
</text>
{/* Visual Overlays based on Mode */} {/* Visual Overlays based on Mode */}
{mode === 'AA' && ( {mode === "AA" && (
<> <>
{/* Angle A and D (base-left) */} {/* Angle A and D (base-left) */}
{renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)} {renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)}
@ -187,7 +310,7 @@ const SimilarityTestsWidget: React.FC = () => {
</> </>
)} )}
{mode === 'SAS' && ( {mode === "SAS" && (
<> <>
{/* Included Angle A and D */} {/* Included Angle A and D */}
{renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)} {renderAngle(A.x, A.y, C.x, C.y, B.x, B.y, angleA)}
@ -195,38 +318,228 @@ const SimilarityTestsWidget: React.FC = () => {
{/* Side labels with background badges */} {/* Side labels with background badges */}
{/* Side AB / DE */} {/* Side AB / DE */}
<rect x={(A.x + B.x)/2 - 24} y={(A.y + B.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(A.x + B.x)/2 - 6} y={(A.y + B.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1)}</text> x={(A.x + B.x) / 2 - 24}
<rect x={(D.x + E.x)/2 - 24} y={(D.y + E.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={(A.y + B.y) / 2 - 12}
<text x={(D.x + E.x)/2 - 6} y={(D.y + E.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(A.x + B.x) / 2 - 6}
y={(A.y + B.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(c1)}
</text>
<rect
x={(D.x + E.x) / 2 - 24}
y={(D.y + E.y) / 2 - 12}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(D.x + E.x) / 2 - 6}
y={(D.y + E.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(c1 * scale)}
</text>
{/* Side AC / DF */} {/* Side AC / DF */}
<rect x={(A.x + C.x)/2 - 18} y={A.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(A.x + C.x)/2} y={A.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1)}</text> x={(A.x + C.x) / 2 - 18}
<rect x={(D.x + F.x)/2 - 18} y={D.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={A.y + 4}
<text x={(D.x + F.x)/2} y={D.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(A.x + C.x) / 2}
y={A.y + 18}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(b1)}
</text>
<rect
x={(D.x + F.x) / 2 - 18}
y={D.y + 4}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(D.x + F.x) / 2}
y={D.y + 18}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(b1 * scale)}
</text>
</> </>
)} )}
{mode === 'SSS' && ( {mode === "SSS" && (
<> <>
{/* Side AB / DE */} {/* Side AB / DE */}
<rect x={(A.x + B.x)/2 - 24} y={(A.y + B.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(A.x + B.x)/2 - 6} y={(A.y + B.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1)}</text> x={(A.x + B.x) / 2 - 24}
<rect x={(D.x + E.x)/2 - 24} y={(D.y + E.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={(A.y + B.y) / 2 - 12}
<text x={(D.x + E.x)/2 - 6} y={(D.y + E.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(c1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(A.x + B.x) / 2 - 6}
y={(A.y + B.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(c1)}
</text>
<rect
x={(D.x + E.x) / 2 - 24}
y={(D.y + E.y) / 2 - 12}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(D.x + E.x) / 2 - 6}
y={(D.y + E.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(c1 * scale)}
</text>
{/* Side AC / DF */} {/* Side AC / DF */}
<rect x={(A.x + C.x)/2 - 18} y={A.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(A.x + C.x)/2} y={A.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1)}</text> x={(A.x + C.x) / 2 - 18}
<rect x={(D.x + F.x)/2 - 18} y={D.y + 4} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={A.y + 4}
<text x={(D.x + F.x)/2} y={D.y + 18} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(b1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(A.x + C.x) / 2}
y={A.y + 18}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(b1)}
</text>
<rect
x={(D.x + F.x) / 2 - 18}
y={D.y + 4}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(D.x + F.x) / 2}
y={D.y + 18}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(b1 * scale)}
</text>
{/* Side BC / EF */} {/* Side BC / EF */}
<rect x={(B.x + C.x)/2 + 2} y={(B.y + C.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> <rect
<text x={(B.x + C.x)/2 + 20} y={(B.y + C.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(a1)}</text> x={(B.x + C.x) / 2 + 2}
<rect x={(E.x + F.x)/2 + 2} y={(E.y + F.y)/2 - 12} width={36} height={20} rx={5} fill="white" fillOpacity={0.92} stroke={sideColor} strokeWidth={0.8} /> y={(B.y + C.y) / 2 - 12}
<text x={(E.x + F.x)/2 + 20} y={(E.y + F.y)/2 + 3} fill={sideColor} fontSize="13" fontWeight="bold" textAnchor="middle">{Math.round(a1 * scale)}</text> width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(B.x + C.x) / 2 + 20}
y={(B.y + C.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(a1)}
</text>
<rect
x={(E.x + F.x) / 2 + 2}
y={(E.y + F.y) / 2 - 12}
width={36}
height={20}
rx={5}
fill="white"
fillOpacity={0.92}
stroke={sideColor}
strokeWidth={0.8}
/>
<text
x={(E.x + F.x) / 2 + 20}
y={(E.y + F.y) / 2 + 3}
fill={sideColor}
fontSize="13"
fontWeight="bold"
textAnchor="middle"
>
{Math.round(a1 * scale)}
</text>
</> </>
)} )}
</svg> </svg>
@ -235,46 +548,77 @@ const SimilarityTestsWidget: React.FC = () => {
<div className="bg-rose-50 border border-rose-100 rounded-lg p-4 text-rose-900"> <div className="bg-rose-50 border border-rose-100 rounded-lg p-4 text-rose-900">
<h4 className="font-bold mb-2 flex items-center gap-2 text-lg"> <h4 className="font-bold mb-2 flex items-center gap-2 text-lg">
<span className="w-3 h-3 rounded-full bg-rose-500"></span> <span className="w-3 h-3 rounded-full bg-rose-500"></span>
{mode === 'AA' && "Angle-Angle (AA) Similarity"} {mode === "AA" && "Angle-Angle (AA) Similarity"}
{mode === 'SAS' && "Side-Angle-Side (SAS) Similarity"} {mode === "SAS" && "Side-Angle-Side (SAS) Similarity"}
{mode === 'SSS' && "Side-Side-Side (SSS) Similarity"} {mode === "SSS" && "Side-Side-Side (SSS) Similarity"}
</h4> </h4>
<div className="text-sm font-mono space-y-2"> <div className="text-sm font-mono space-y-2">
{mode === 'AA' && ( {mode === "AA" && (
<> <>
<p className="leading-relaxed">If two angles of one triangle are equal to two angles of another triangle, then the triangles are similar.</p> <p className="leading-relaxed">
If two angles of one triangle are equal to two angles of another
triangle, then the triangles are similar.
</p>
<div className="flex gap-8 mt-2"> <div className="flex gap-8 mt-2">
<div> <div>
<span className="text-xs font-bold text-rose-400 uppercase">First Angle</span> <span className="text-xs font-bold text-rose-400 uppercase">
<p className="font-bold text-lg">A = D = {Math.round(angleA)}°</p> First Angle
</span>
<p className="font-bold text-lg">
A = D = {Math.round(angleA)}°
</p>
</div> </div>
<div> <div>
<span className="text-xs font-bold text-rose-400 uppercase">Second Angle</span> <span className="text-xs font-bold text-rose-400 uppercase">
<p className="font-bold text-lg">B = E = {Math.round(angleB)}°</p> Second Angle
</span>
<p className="font-bold text-lg">
B = E = {Math.round(angleB)}°
</p>
</div> </div>
</div> </div>
</> </>
)} )}
{mode === 'SAS' && ( {mode === "SAS" && (
<> <>
<p className="leading-relaxed">If two sides are proportional and the included angles are equal, the triangles are similar.</p> <p className="leading-relaxed">
If two sides are proportional and the included angles are equal,
the triangles are similar.
</p>
<div className="grid grid-cols-2 gap-4 mt-2"> <div className="grid grid-cols-2 gap-4 mt-2">
<div className="bg-white p-2 rounded border border-rose-100"> <div className="bg-white p-2 rounded border border-rose-100">
<p className="text-xs text-rose-500 font-bold uppercase">Side Ratio (c)</p> <p className="text-xs text-rose-500 font-bold uppercase">
<p>DE / AB = {(c1*scale).toFixed(0)} / {c1.toFixed(0)} = <strong>{scale.toFixed(1)}</strong></p> Side Ratio (c)
</p>
<p>
DE / AB = {(c1 * scale).toFixed(0)} / {c1.toFixed(0)} ={" "}
<strong>{scale.toFixed(1)}</strong>
</p>
</div> </div>
<div className="bg-white p-2 rounded border border-rose-100"> <div className="bg-white p-2 rounded border border-rose-100">
<p className="text-xs text-rose-500 font-bold uppercase">Side Ratio (b)</p> <p className="text-xs text-rose-500 font-bold uppercase">
<p>DF / AC = {(b1*scale).toFixed(0)} / {b1.toFixed(0)} = <strong>{scale.toFixed(1)}</strong></p> Side Ratio (b)
</p>
<p>
DF / AC = {(b1 * scale).toFixed(0)} / {b1.toFixed(0)} ={" "}
<strong>{scale.toFixed(1)}</strong>
</p>
</div> </div>
</div> </div>
<p className="mt-2 font-bold text-rose-800">Included Angle: A = D = {Math.round(angleA)}°</p> <p className="mt-2 font-bold text-rose-800">
Included Angle: A = D = {Math.round(angleA)}°
</p>
</> </>
)} )}
{mode === 'SSS' && ( {mode === "SSS" && (
<> <>
<p className="leading-relaxed">If the corresponding sides of two triangles are proportional, then the triangles are similar.</p> <p className="leading-relaxed">
<p className="bg-white inline-block px-2 py-1 rounded border border-rose-100 font-bold text-rose-600 mb-2">Scale Factor k = {scale.toFixed(1)}</p> If the corresponding sides of two triangles are proportional,
then the triangles are similar.
</p>
<p className="bg-white inline-block px-2 py-1 rounded border border-rose-100 font-bold text-rose-600 mb-2">
Scale Factor k = {scale.toFixed(1)}
</p>
<div className="grid grid-cols-3 gap-2 text-center text-xs"> <div className="grid grid-cols-3 gap-2 text-center text-xs">
<div className="bg-white p-1 rounded"> <div className="bg-white p-1 rounded">
DE/AB = {scale.toFixed(1)} DE/AB = {scale.toFixed(1)}
@ -290,7 +634,8 @@ const SimilarityTestsWidget: React.FC = () => {
)} )}
</div> </div>
<p className="text-xs text-rose-400 mt-4 border-t border-rose-100 pt-2"> <p className="text-xs text-rose-400 mt-4 border-t border-rose-100 pt-2">
Drag vertex <strong>B</strong> on the first triangle to explore different shapes! Drag vertex <strong>B</strong> on the first triangle to explore
different shapes!
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef } from "react";
const SimilarityWidget: React.FC = () => { const SimilarityWidget: React.FC = () => {
const [ratio, setRatio] = useState(0.5); // Position of D along AB (0 to 1) const [ratio, setRatio] = useState(0.5); // Position of D along AB (0 to 1)
@ -13,12 +13,12 @@ const SimilarityWidget: React.FC = () => {
// Calculate D and E based on ratio // Calculate D and E based on ratio
const D = { const D = {
x: A.x + (B.x - A.x) * ratio, x: A.x + (B.x - A.x) * ratio,
y: A.y + (B.y - A.y) * ratio y: A.y + (B.y - A.y) * ratio,
}; };
const E = { const E = {
x: A.x + (C.x - A.x) * ratio, x: A.x + (C.x - A.x) * ratio,
y: A.y + (C.y - A.y) * ratio y: A.y + (C.y - A.y) * ratio,
}; };
const handleInteraction = (clientY: number) => { const handleInteraction = (clientY: number) => {
@ -54,43 +54,123 @@ const SimilarityWidget: React.FC = () => {
className="select-none cursor-ns-resize" className="select-none cursor-ns-resize"
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={() => isDragging.current = false} onMouseUp={() => (isDragging.current = false)}
onMouseLeave={() => isDragging.current = false} onMouseLeave={() => (isDragging.current = false)}
> >
{/* Main Triangle */} {/* Main Triangle */}
<path d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`} fill="none" stroke="#e2e8f0" strokeWidth="2" /> <path
d={`M ${A.x} ${A.y} L ${B.x} ${B.y} L ${C.x} ${C.y} Z`}
fill="none"
stroke="#e2e8f0"
strokeWidth="2"
/>
{/* Filled Top Triangle (Similar) */} {/* Filled Top Triangle (Similar) */}
<path d={`M ${A.x} ${A.y} L ${D.x} ${D.y} L ${E.x} ${E.y} Z`} fill="rgba(244, 63, 94, 0.1)" stroke="none" /> <path
d={`M ${A.x} ${A.y} L ${D.x} ${D.y} L ${E.x} ${E.y} Z`}
fill="rgba(244, 63, 94, 0.1)"
stroke="none"
/>
{/* Parallel Line DE */} {/* Parallel Line DE */}
<line x1={D.x} y1={D.y} x2={E.x} y2={E.y} stroke="#e11d48" strokeWidth="3" /> <line
x1={D.x}
y1={D.y}
x2={E.x}
y2={E.y}
stroke="#e11d48"
strokeWidth="3"
/>
{/* Labels */} {/* Labels */}
<text x={A.x} y={A.y - 10} textAnchor="middle" fontWeight="bold" fill="#64748b">A</text> <text
<text x={B.x - 10} y={B.y} textAnchor="end" fontWeight="bold" fill="#64748b">B</text> x={A.x}
<text x={C.x + 10} y={C.y} textAnchor="start" fontWeight="bold" fill="#64748b">C</text> y={A.y - 10}
<text x={D.x - 10} y={D.y} textAnchor="end" fontWeight="bold" fill="#e11d48">D</text> textAnchor="middle"
<text x={E.x + 10} y={E.y} textAnchor="start" fontWeight="bold" fill="#e11d48">E</text> fontWeight="bold"
fill="#64748b"
>
A
</text>
<text
x={B.x - 10}
y={B.y}
textAnchor="end"
fontWeight="bold"
fill="#64748b"
>
B
</text>
<text
x={C.x + 10}
y={C.y}
textAnchor="start"
fontWeight="bold"
fill="#64748b"
>
C
</text>
<text
x={D.x - 10}
y={D.y}
textAnchor="end"
fontWeight="bold"
fill="#e11d48"
>
D
</text>
<text
x={E.x + 10}
y={E.y}
textAnchor="start"
fontWeight="bold"
fill="#e11d48"
>
E
</text>
{/* Drag Handle */} {/* Drag Handle */}
<circle cx={D.x} cy={D.y} r="6" fill="#e11d48" stroke="white" strokeWidth="2" /> <circle
<circle cx={E.x} cy={E.y} r="6" fill="#e11d48" stroke="white" strokeWidth="2" /> cx={D.x}
cy={D.y}
r="6"
fill="#e11d48"
stroke="white"
strokeWidth="2"
/>
<circle
cx={E.x}
cy={E.y}
r="6"
fill="#e11d48"
stroke="white"
strokeWidth="2"
/>
</svg> </svg>
<div className="flex-1 w-full"> <div className="flex-1 w-full">
<h3 className="text-lg font-bold text-slate-800 mb-4">Triangle Proportionality</h3> <h3 className="text-lg font-bold text-slate-800 mb-4">
<p className="text-sm text-slate-500 mb-6">Drag the red line. Because DE || BC, the small triangle is similar to the large triangle.</p> Triangle Proportionality
</h3>
<p className="text-sm text-slate-500 mb-6">
Drag the red line. Because DE || BC, the small triangle is similar to
the large triangle.
</p>
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border-l-4 border-rose-500"> <div className="bg-slate-50 p-4 rounded-lg border-l-4 border-rose-500">
<p className="text-xs font-bold text-slate-400 uppercase mb-1">Scale Factor</p> <p className="text-xs font-bold text-slate-400 uppercase mb-1">
<p className="font-mono text-xl text-rose-700">{ratio.toFixed(2)}</p> Scale Factor
</p>
<p className="font-mono text-xl text-rose-700">
{ratio.toFixed(2)}
</p>
</div> </div>
<div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm"> <div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm">
<p className="font-mono text-sm mb-2 text-slate-600">Corresponding Sides Ratio:</p> <p className="font-mono text-sm mb-2 text-slate-600">
Corresponding Sides Ratio:
</p>
<div className="flex items-center justify-between font-mono font-bold text-lg"> <div className="flex items-center justify-between font-mono font-bold text-lg">
<div className="text-rose-600">AD / AB</div> <div className="text-rose-600">AD / AB</div>
<div className="text-slate-400">=</div> <div className="text-slate-400">=</div>
@ -101,7 +181,9 @@ const SimilarityWidget: React.FC = () => {
</div> </div>
<div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm"> <div className="bg-white border border-slate-200 p-4 rounded-lg shadow-sm">
<p className="font-mono text-sm mb-2 text-slate-600">Area Ratio (k²):</p> <p className="font-mono text-sm mb-2 text-slate-600">
Area Ratio (k²):
</p>
<div className="flex items-center justify-between font-mono font-bold text-lg"> <div className="flex items-center justify-between font-mono font-bold text-lg">
<div className="text-rose-600">Area(ADE)</div> <div className="text-rose-600">Area(ADE)</div>
<div className="text-slate-400">/</div> <div className="text-slate-400">/</div>

View File

@ -1,378 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import {
ArrowLeft, User, Shield, Clock, BookOpen, Calculator, Award,
TrendingUp, CheckCircle2, Circle, Lock, Eye, EyeOff, AlertCircle,
Check, Sparkles,
} from 'lucide-react';
import { useAuth, UserRecord } from './auth/AuthContext';
import { useProgress } from './progress/ProgressContext';
import { useGoldCoins } from './practice/GoldCoinContext';
import { LESSONS, EBRW_LESSONS } from '../constants';
import Mascot from './Mascot';
// Animated count-up
function useCountUp(target: number, duration = 900) {
const [count, setCount] = useState(0);
const started = useRef(false);
useEffect(() => {
if (started.current) return;
started.current = true;
const startTime = performance.now();
const animate = (now: number) => {
const progress = Math.min((now - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - progress, 2.5);
setCount(Math.round(eased * target));
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [target, duration]);
return count;
}
interface UserDashboardProps {
onExit: () => void;
}
export default function UserDashboard({ onExit }: UserDashboardProps) {
const { username, role, getUserRecord, changePassword, updateDisplayName } = useAuth();
const { getSubjectStats, getLessonStatus } = useProgress();
const { totalCoins, state: coinState } = useGoldCoins();
const user = getUserRecord(username || '');
const mathStats = getSubjectStats('math');
const ebrwStats = getSubjectStats('ebrw');
// Account settings
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showCurrentPw, setShowCurrentPw] = useState(false);
const [showNewPw, setShowNewPw] = useState(false);
const [pwMsg, setPwMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [pwLoading, setPwLoading] = useState(false);
const [editName, setEditName] = useState(false);
const [nameInput, setNameInput] = useState(user?.displayName || '');
const [nameSaved, setNameSaved] = useState(false);
const animCoins = useCountUp(totalCoins, 1200);
// Count completed topics across all practice
const topicsAttempted = Object.keys(coinState.topicProgress).length;
// Calculate total accuracy
let totalAttempted = 0;
let totalCorrect = 0;
Object.values(coinState.topicProgress).forEach((tp: any) => {
(['easy', 'medium', 'hard'] as const).forEach(d => {
totalAttempted += tp[d]?.attempted || 0;
totalCorrect += tp[d]?.correct || 0;
});
});
const accuracy = totalAttempted > 0 ? Math.round((totalCorrect / totalAttempted) * 100) : 0;
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPwMsg(null);
if (newPassword !== confirmPassword) {
setPwMsg({ type: 'error', text: 'New passwords do not match.' });
return;
}
setPwLoading(true);
const result = await changePassword(username || '', currentPassword, newPassword);
setPwLoading(false);
if (result.success) {
setPwMsg({ type: 'success', text: 'Password changed successfully!' });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
} else {
setPwMsg({ type: 'error', text: result.error || 'Failed to change password.' });
}
};
const handleSaveName = () => {
if (username && nameInput.trim()) {
updateDisplayName(username, nameInput.trim());
setEditName(false);
setNameSaved(true);
setTimeout(() => setNameSaved(false), 2000);
}
};
// Progress ring
function ProgressRing({ percent, size = 72, stroke = 6, color }: { percent: number; size?: number; stroke?: number; color: string }) {
const r = (size - stroke) / 2;
const circ = 2 * Math.PI * r;
const offset = circ - (percent / 100) * circ;
return (
<svg width={size} height={size} className="transform -rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} className="text-slate-100" />
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke={color} strokeWidth={stroke}
strokeDasharray={circ} strokeDashoffset={offset} strokeLinecap="round"
className="transition-all duration-1000 ease-out" />
<text x={size / 2} y={size / 2} textAnchor="middle" dominantBaseline="central"
className="text-sm font-bold fill-slate-800 transform rotate-90" style={{ transformOrigin: 'center' }}>
{percent}%
</text>
</svg>
);
}
function StatusIcon({ status }: { status: string }) {
if (status === 'completed') return <CheckCircle2 className="w-4 h-4 text-emerald-500" />;
if (status === 'in_progress') return <Circle className="w-4 h-4 text-blue-400" />;
return <Lock className="w-3.5 h-3.5 text-slate-300" />;
}
return (
<div className="min-h-screen bg-gradient-to-b from-white via-slate-50/50 to-white">
{/* Header */}
<header className="sticky top-0 z-40 glass-nav border-b border-slate-100">
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<button onClick={onExit} className="flex items-center gap-2 text-sm font-semibold text-slate-500 hover:text-slate-900 transition-colors">
<ArrowLeft className="w-4 h-4" /> Back to Home
</button>
<h1 className="text-sm font-bold text-slate-800">My Dashboard</h1>
<div className="w-20" />
</div>
</header>
<div className="max-w-5xl mx-auto px-6 py-10 space-y-10">
{/* ── Welcome Hero ── */}
<div className="relative bg-gradient-to-br from-cyan-50 via-white to-blue-50 rounded-2xl p-8 border border-cyan-100 overflow-hidden anim-fade-in-up">
<div className="absolute -top-2 -right-2 pointer-events-none select-none opacity-80">
<Mascot pose="waving" height={120} />
</div>
<div className="relative">
<div className="flex items-center gap-3 mb-3">
<div className="w-12 h-12 rounded-xl bg-cyan-100 flex items-center justify-center">
<User className="w-6 h-6 text-cyan-600" />
</div>
<div>
<div className="flex items-center gap-2">
{editName ? (
<div className="flex items-center gap-2">
<input value={nameInput} onChange={e => setNameInput(e.target.value)}
className="text-xl font-bold text-slate-900 bg-white border border-slate-200 rounded-lg px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-cyan-400 w-48"
autoFocus onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
<button onClick={handleSaveName} className="text-xs font-bold text-cyan-600 hover:text-cyan-800">Save</button>
<button onClick={() => setEditName(false)} className="text-xs text-slate-400 hover:text-slate-600">Cancel</button>
</div>
) : (
<>
<h2 className="text-xl font-bold text-slate-900">{user?.displayName || username}</h2>
<button onClick={() => { setNameInput(user?.displayName || ''); setEditName(true); }}
className="text-xs text-cyan-500 hover:text-cyan-700 font-medium">edit</button>
{nameSaved && <span className="text-xs text-emerald-500 font-medium flex items-center gap-1"><Check className="w-3 h-3" /> Saved</span>}
</>
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-widest ${
role === 'admin' ? 'bg-amber-100 text-amber-700' : 'bg-cyan-100 text-cyan-700'
}`}>
{role === 'admin' && <Shield className="w-3 h-3" />}
{role}
</span>
<span className="text-xs text-slate-400">@{username}</span>
</div>
</div>
</div>
{user?.lastLoginAt && (
<p className="text-xs text-slate-400 mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
Last login: {new Date(user.lastLoginAt).toLocaleString()}
{user.lastLoginIp && user.lastLoginIp !== 'unknown' && <span className="ml-1">from {user.lastLoginIp}</span>}
</p>
)}
</div>
</div>
{/* ── Stats Overview ── */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 anim-fade-in-up stagger-1">
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-slate-900 tabular-nums">{mathStats.completed + ebrwStats.completed}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Lessons Done</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-amber-500 tabular-nums flex items-center justify-center gap-1">
<Award className="w-5 h-5" />{animCoins}
</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Gold Coins</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-emerald-500 tabular-nums">{accuracy}%</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Accuracy</p>
</div>
<div className="bg-white rounded-2xl p-5 border border-slate-200 card-lift text-center">
<p className="text-3xl font-bold text-blue-500 tabular-nums">{topicsAttempted}</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mt-1">Topics Practiced</p>
</div>
</div>
{/* ── Lesson Progress ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 anim-fade-in-up stagger-2">
{/* Math */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center">
<Calculator className="w-5 h-5 text-blue-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Mathematics</h3>
<p className="text-xs text-slate-400">{mathStats.completed}/{mathStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={mathStats.percentComplete} color="#3b82f6" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-blue-500 rounded-full transition-all duration-1000" style={{ width: `${mathStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'math')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
{/* EBRW */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 card-lift">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-purple-50 flex items-center justify-center">
<BookOpen className="w-5 h-5 text-purple-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Reading & Writing</h3>
<p className="text-xs text-slate-400">{ebrwStats.completed}/{ebrwStats.total} lessons completed</p>
</div>
</div>
<ProgressRing percent={ebrwStats.percentComplete} color="#a855f7" />
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden mb-4">
<div className="h-full bg-purple-500 rounded-full transition-all duration-1000" style={{ width: `${ebrwStats.percentComplete}%` }} />
</div>
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
{EBRW_LESSONS.map(l => (
<div key={l.id} className="flex items-center gap-2 py-1 text-xs">
<StatusIcon status={getLessonStatus(l.id, 'ebrw')} />
<span className="text-slate-600 truncate">{l.title}</span>
</div>
))}
</div>
</div>
</div>
{/* ── Practice Performance ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-3">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-amber-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Practice Performance</h3>
<p className="text-xs text-slate-400">{totalAttempted} questions attempted across {topicsAttempted} topics</p>
</div>
</div>
{topicsAttempted === 0 ? (
<div className="py-8 text-center text-slate-400 text-sm">
<Sparkles className="w-6 h-6 mx-auto mb-2 text-amber-300" />
No practice sessions yet. Start practicing to see your performance!
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(coinState.topicProgress).map(([topicId, tp]: [string, any]) => {
const easy = tp.easy || { attempted: 0, correct: 0 };
const medium = tp.medium || { attempted: 0, correct: 0 };
const hard = tp.hard || { attempted: 0, correct: 0 };
const total = easy.attempted + medium.attempted + hard.attempted;
const correct = easy.correct + medium.correct + hard.correct;
const acc = total > 0 ? Math.round((correct / total) * 100) : 0;
return (
<div key={topicId} className="border border-slate-100 rounded-xl p-3 hover:border-slate-200 transition-colors">
<p className="text-xs font-semibold text-slate-700 truncate mb-2">{topicId}</p>
<div className="flex items-center justify-between text-[10px] text-slate-400 mb-1">
<span>{correct}/{total} correct</span>
<span className={`font-bold ${acc >= 70 ? 'text-emerald-500' : acc >= 40 ? 'text-amber-500' : 'text-rose-500'}`}>{acc}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${acc >= 70 ? 'bg-emerald-400' : acc >= 40 ? 'bg-amber-400' : 'bg-rose-400'}`} style={{ width: `${acc}%` }} />
</div>
<div className="flex gap-3 mt-2 text-[10px] text-slate-400">
<span>E: {easy.correct}/{easy.attempted}</span>
<span>M: {medium.correct}/{medium.attempted}</span>
<span>H: {hard.correct}/{hard.attempted}</span>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Account Settings ── */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 anim-fade-in-up stagger-4">
<div className="flex items-center gap-3 mb-5">
<div className="w-10 h-10 rounded-xl bg-slate-100 flex items-center justify-center">
<Lock className="w-5 h-5 text-slate-500" />
</div>
<div>
<h3 className="font-bold text-slate-900">Change Password</h3>
<p className="text-xs text-slate-400">Update your account password</p>
</div>
</div>
<form onSubmit={handleChangePassword} className="max-w-sm space-y-3">
{pwMsg && (
<div className={`flex items-center gap-2 p-3 rounded-xl text-sm ${
pwMsg.type === 'success' ? 'bg-emerald-50 border border-emerald-200 text-emerald-700' : 'bg-rose-50 border border-rose-200 text-rose-700'
}`}>
{pwMsg.type === 'success' ? <Check className="w-4 h-4 shrink-0" /> : <AlertCircle className="w-4 h-4 shrink-0" />}
{pwMsg.text}
</div>
)}
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">Current Password</label>
<input type={showCurrentPw ? 'text' : 'password'} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required />
<button type="button" onClick={() => setShowCurrentPw(!showCurrentPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showCurrentPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div className="relative">
<label className="block text-xs font-semibold text-slate-600 mb-1">New Password</label>
<input type={showNewPw ? 'text' : 'password'} value={newPassword} onChange={e => setNewPassword(e.target.value)}
className="w-full px-3 py-2 pr-9 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
<button type="button" onClick={() => setShowNewPw(!showNewPw)} className="absolute right-3 top-7 text-slate-400 hover:text-slate-600">
{showNewPw ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">Confirm New Password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 text-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-cyan-400" required minLength={4} />
</div>
<button type="submit" disabled={pwLoading}
className="px-5 py-2 bg-slate-900 text-white text-sm font-bold rounded-xl hover:bg-slate-700 transition-all btn-primary disabled:opacity-50">
{pwLoading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@ -1,8 +1,7 @@
import * as React from "react" import * as React from "react";
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@ -22,8 +21,8 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
@ -32,7 +31,7 @@ function Badge({
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : "span";
return ( return (
<Comp <Comp
@ -40,7 +39,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)} className={cn(badgeVariants({ variant }), className)}
{...props} {...props}
/> />
) );
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@ -2,7 +2,7 @@ import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "../../lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -89,4 +89,4 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View File

@ -1,43 +1,43 @@
import * as React from "react" import * as React from "react";
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from "embla-carousel-react" } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react" import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1] type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel> type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0] type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1] type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = { type CarouselProps = {
opts?: CarouselOptions opts?: CarouselOptions;
plugins?: CarouselPlugin plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical" orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void setApi?: (api: CarouselApi) => void;
} };
type CarouselContextProps = { type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0] carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1] api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void scrollPrev: () => void;
scrollNext: () => void scrollNext: () => void;
canScrollPrev: boolean canScrollPrev: boolean;
canScrollNext: boolean canScrollNext: boolean;
} & CarouselProps } & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null) const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() { function useCarousel() {
const context = React.useContext(CarouselContext) const context = React.useContext(CarouselContext);
if (!context) { if (!context) {
throw new Error("useCarousel must be used within a <Carousel />") throw new Error("useCarousel must be used within a <Carousel />");
} }
return context return context;
} }
function Carousel({ function Carousel({
@ -54,53 +54,53 @@ function Carousel({
...opts, ...opts,
axis: orientation === "horizontal" ? "x" : "y", axis: orientation === "horizontal" ? "x" : "y",
}, },
plugins plugins,
) );
const [canScrollPrev, setCanScrollPrev] = React.useState(false) const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false) const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => { const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return if (!api) return;
setCanScrollPrev(api.canScrollPrev()) setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext()) setCanScrollNext(api.canScrollNext());
}, []) }, []);
const scrollPrev = React.useCallback(() => { const scrollPrev = React.useCallback(() => {
api?.scrollPrev() api?.scrollPrev();
}, [api]) }, [api]);
const scrollNext = React.useCallback(() => { const scrollNext = React.useCallback(() => {
api?.scrollNext() api?.scrollNext();
}, [api]) }, [api]);
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") { if (event.key === "ArrowLeft") {
event.preventDefault() event.preventDefault();
scrollPrev() scrollPrev();
} else if (event.key === "ArrowRight") { } else if (event.key === "ArrowRight") {
event.preventDefault() event.preventDefault();
scrollNext() scrollNext();
} }
}, },
[scrollPrev, scrollNext] [scrollPrev, scrollNext],
) );
React.useEffect(() => { React.useEffect(() => {
if (!api || !setApi) return if (!api || !setApi) return;
setApi(api) setApi(api);
}, [api, setApi]) }, [api, setApi]);
React.useEffect(() => { React.useEffect(() => {
if (!api) return if (!api) return;
onSelect(api) onSelect(api);
api.on("reInit", onSelect) api.on("reInit", onSelect);
api.on("select", onSelect) api.on("select", onSelect);
return () => { return () => {
api?.off("select", onSelect) api?.off("select", onSelect);
} };
}, [api, onSelect]) }, [api, onSelect]);
return ( return (
<CarouselContext.Provider <CarouselContext.Provider
@ -127,11 +127,11 @@ function Carousel({
{children} {children}
</div> </div>
</CarouselContext.Provider> </CarouselContext.Provider>
) );
} }
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel() const { carouselRef, orientation } = useCarousel();
return ( return (
<div <div
@ -143,16 +143,16 @@ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"flex", "flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className className,
)} )}
{...props} {...props}
/> />
</div> </div>
) );
} }
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel() const { orientation } = useCarousel();
return ( return (
<div <div
@ -162,11 +162,11 @@ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"min-w-0 shrink-0 grow-0 basis-full", "min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4", orientation === "horizontal" ? "pl-4" : "pt-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CarouselPrevious({ function CarouselPrevious({
@ -175,7 +175,7 @@ function CarouselPrevious({
size = "icon", size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel() const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return ( return (
<Button <Button
@ -187,7 +187,7 @@ function CarouselPrevious({
orientation === "horizontal" orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2" ? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
disabled={!canScrollPrev} disabled={!canScrollPrev}
onClick={scrollPrev} onClick={scrollPrev}
@ -196,7 +196,7 @@ function CarouselPrevious({
<ArrowLeft /> <ArrowLeft />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) );
} }
function CarouselNext({ function CarouselNext({
@ -205,7 +205,7 @@ function CarouselNext({
size = "icon", size = "icon",
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel() const { orientation, scrollNext, canScrollNext } = useCarousel();
return ( return (
<Button <Button
@ -217,7 +217,7 @@ function CarouselNext({
orientation === "horizontal" orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2" ? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className className,
)} )}
disabled={!canScrollNext} disabled={!canScrollNext}
onClick={scrollNext} onClick={scrollNext}
@ -226,7 +226,7 @@ function CarouselNext({
<ArrowRight /> <ArrowRight />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) );
} }
export { export {
@ -236,4 +236,4 @@ export {
CarouselItem, CarouselItem,
CarouselPrevious, CarouselPrevious,
CarouselNext, CarouselNext,
} };

View File

@ -1,32 +1,32 @@
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@ -38,11 +38,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@ -51,7 +51,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@ -60,7 +60,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@ -76,7 +76,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -86,7 +86,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
@ -95,14 +95,14 @@ function DialogFooter({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
> >
@ -113,7 +113,7 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ function DialogTitle({
@ -126,7 +126,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@ -139,7 +139,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -153,4 +153,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };

View File

@ -1,30 +1,30 @@
import * as React from "react" import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul" import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Drawer({ function Drawer({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) { }: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} /> return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
} }
function DrawerTrigger({ function DrawerTrigger({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} /> return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
} }
function DrawerPortal({ function DrawerPortal({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) { }: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} /> return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
} }
function DrawerClose({ function DrawerClose({
...props ...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) { }: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} /> return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
} }
function DrawerOverlay({ function DrawerOverlay({
@ -36,11 +36,11 @@ function DrawerOverlay({
data-slot="drawer-overlay" data-slot="drawer-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DrawerContent({ function DrawerContent({
@ -59,7 +59,7 @@ function DrawerContent({
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className className,
)} )}
{...props} {...props}
> >
@ -67,7 +67,7 @@ function DrawerContent({
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>
) );
} }
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -76,11 +76,11 @@ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="drawer-header" data-slot="drawer-header"
className={cn( className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left", "flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -90,7 +90,7 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function DrawerTitle({ function DrawerTitle({
@ -103,7 +103,7 @@ function DrawerTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DrawerDescription({ function DrawerDescription({
@ -116,7 +116,7 @@ function DrawerDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -130,4 +130,4 @@ export {
DrawerFooter, DrawerFooter,
DrawerTitle, DrawerTitle,
DrawerDescription, DrawerDescription,
} };

View File

@ -1,13 +1,13 @@
import * as React from "react" import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function DropdownMenu({ function DropdownMenu({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} /> return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
} }
function DropdownMenuPortal({ function DropdownMenuPortal({
@ -15,7 +15,7 @@ function DropdownMenuPortal({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return ( return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} /> <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
) );
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({
@ -26,7 +26,7 @@ function DropdownMenuTrigger({
data-slot="dropdown-menu-trigger" data-slot="dropdown-menu-trigger"
{...props} {...props}
/> />
) );
} }
function DropdownMenuContent({ function DropdownMenuContent({
@ -41,12 +41,12 @@ function DropdownMenuContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({
@ -54,7 +54,7 @@ function DropdownMenuGroup({
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return ( return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} /> <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
) );
} }
function DropdownMenuItem({ function DropdownMenuItem({
@ -63,8 +63,8 @@ function DropdownMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@ -73,11 +73,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({
@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
data-slot="dropdown-menu-radio-group" data-slot="dropdown-menu-radio-group"
{...props} {...props}
/> />
) );
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@ -146,7 +146,7 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
@ -154,11 +154,11 @@ function DropdownMenuLabel({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({
@ -171,7 +171,7 @@ function DropdownMenuSeparator({
className={cn("bg-border -mx-1 my-1 h-px", className)} className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({
@ -183,17 +183,17 @@ function DropdownMenuShortcut({
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest", "text-muted-foreground ml-auto text-xs tracking-widest",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) { }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} /> return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@ -202,7 +202,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@ -210,14 +210,14 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@ -229,11 +229,11 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -252,4 +252,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };

View File

@ -1,9 +1,9 @@
import { useMemo } from "react" import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Label } from "@/components/ui/label" import { Label } from "./label";
import { Separator } from "@/components/ui/separator" import { Separator } from "./separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
@ -12,11 +12,11 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
className={cn( className={cn(
"flex flex-col gap-6", "flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLegend({ function FieldLegend({
@ -32,11 +32,11 @@ function FieldLegend({
"mb-3 font-medium", "mb-3 font-medium",
"data-[variant=legend]:text-base", "data-[variant=legend]:text-base",
"data-[variant=label]:text-sm", "data-[variant=label]:text-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
@ -44,12 +44,12 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="field-group" data-slot="field-group"
className={cn( className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
const fieldVariants = cva( const fieldVariants = cva(
@ -73,8 +73,8 @@ const fieldVariants = cva(
defaultVariants: { defaultVariants: {
orientation: "vertical", orientation: "vertical",
}, },
} },
) );
function Field({ function Field({
className, className,
@ -89,7 +89,7 @@ function Field({
className={cn(fieldVariants({ orientation }), className)} className={cn(fieldVariants({ orientation }), className)}
{...props} {...props}
/> />
) );
} }
function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
@ -98,11 +98,11 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-content" data-slot="field-content"
className={cn( className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug", "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldLabel({ function FieldLabel({
@ -114,13 +114,13 @@ function FieldLabel({
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
@ -129,11 +129,11 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
@ -141,14 +141,14 @@ function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance", "text-muted-foreground text-sm leading-normal font-normal group-has-data-[orientation=horizontal]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5", "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function FieldSeparator({ function FieldSeparator({
@ -156,7 +156,7 @@ function FieldSeparator({
className, className,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
children?: React.ReactNode children?: React.ReactNode;
}) { }) {
return ( return (
<div <div
@ -164,7 +164,7 @@ function FieldSeparator({
data-content={!!children} data-content={!!children}
className={cn( className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className className,
)} )}
{...props} {...props}
> >
@ -178,7 +178,7 @@ function FieldSeparator({
</span> </span>
)} )}
</div> </div>
) );
} }
function FieldError({ function FieldError({
@ -187,37 +187,37 @@ function FieldError({
errors, errors,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined> errors?: Array<{ message?: string } | undefined>;
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (children) { if (children) {
return children return children;
} }
if (!errors?.length) { if (!errors?.length) {
return null return null;
} }
const uniqueErrors = [ const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(), ...new Map(errors.map((error) => [error?.message, error])).values(),
] ];
if (uniqueErrors?.length == 1) { if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message return uniqueErrors[0]?.message;
} }
return ( return (
<ul className="ml-4 flex list-disc flex-col gap-1"> <ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map( {uniqueErrors.map(
(error, index) => (error, index) =>
error?.message && <li key={index}>{error.message}</li> error?.message && <li key={index}>{error.message}</li>,
)} )}
</ul> </ul>
) );
}, [children, errors]) }, [children, errors]);
if (!content) { if (!content) {
return null return null;
} }
return ( return (
@ -229,7 +229,7 @@ function FieldError({
> >
{content} {content}
</div> </div>
) );
} }
export { export {
@ -243,4 +243,4 @@ export {
FieldSet, FieldSet,
FieldContent, FieldContent,
FieldTitle, FieldTitle,
} };

View File

@ -1,6 +1,5 @@
import * as React from "react" import * as React from "react";
import { cn } from "../../lib/utils";
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@ -11,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Label({ function Label({
className, className,
@ -12,11 +12,11 @@ function Label({
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };

View File

@ -1,9 +1,9 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Separator({ function Separator({
className, className,
@ -18,11 +18,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@ -1,31 +1,31 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui" import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) { function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} /> return <SheetPrimitive.Root data-slot="sheet" {...props} />;
} }
function SheetTrigger({ function SheetTrigger({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) { }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} /> return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
} }
function SheetClose({ function SheetClose({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) { }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} /> return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
} }
function SheetPortal({ function SheetPortal({
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) { }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} /> return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
} }
function SheetOverlay({ function SheetOverlay({
@ -37,11 +37,11 @@ function SheetOverlay({
data-slot="sheet-overlay" data-slot="sheet-overlay"
className={cn( className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SheetContent({ function SheetContent({
@ -51,8 +51,8 @@ function SheetContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & { }: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left" side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<SheetPortal> <SheetPortal>
@ -69,7 +69,7 @@ function SheetContent({
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" && side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className className,
)} )}
{...props} {...props}
> >
@ -82,7 +82,7 @@ function SheetContent({
)} )}
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>
) );
} }
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -92,7 +92,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)} className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -102,7 +102,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)} className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} {...props}
/> />
) );
} }
function SheetTitle({ function SheetTitle({
@ -115,7 +115,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)} className={cn("text-foreground font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function SheetDescription({ function SheetDescription({
@ -128,7 +128,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -140,4 +140,4 @@ export {
SheetFooter, SheetFooter,
SheetTitle, SheetTitle,
SheetDescription, SheetDescription,
} };

View File

@ -1,56 +1,56 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react" import { PanelLeftIcon } from "lucide-react";
import { Slot } from "radix-ui" import { Slot } from "radix-ui";
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "../../hooks/use-mobile";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "./button";
import { Input } from "@/components/ui/input" import { Input } from "./input";
import { Separator } from "@/components/ui/separator" import { Separator } from "./separator";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetDescription, SheetDescription,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from "@/components/ui/sheet" } from "./sheet";
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "./skeleton";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state" const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem" const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem" const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem" const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b" const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = { type SidebarContextProps = {
state: "expanded" | "collapsed" state: "expanded" | "collapsed";
open: boolean open: boolean;
setOpen: (open: boolean) => void setOpen: (open: boolean) => void;
openMobile: boolean openMobile: boolean;
setOpenMobile: (open: boolean) => void setOpenMobile: (open: boolean) => void;
isMobile: boolean isMobile: boolean;
toggleSidebar: () => void toggleSidebar: () => void;
} };
const SidebarContext = React.createContext<SidebarContextProps | null>(null) const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() { function useSidebar() {
const context = React.useContext(SidebarContext) const context = React.useContext(SidebarContext);
if (!context) { if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.") throw new Error("useSidebar must be used within a SidebarProvider.");
} }
return context return context;
} }
function SidebarProvider({ function SidebarProvider({
@ -62,36 +62,36 @@ function SidebarProvider({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
defaultOpen?: boolean defaultOpen?: boolean;
open?: boolean open?: boolean;
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void;
}) { }) {
const isMobile = useIsMobile() const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false) const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar. // This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component. // We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen) const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open const open = openProp ?? _open;
const setOpen = React.useCallback( const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => { (value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) { if (setOpenProp) {
setOpenProp(openState) setOpenProp(openState);
} else { } else {
_setOpen(openState) _setOpen(openState);
} }
// This sets the cookie to keep the sidebar state. // This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}, },
[setOpenProp, open] [setOpenProp, open],
) );
// Helper to toggle the sidebar. // Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => { const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]) }, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar. // Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => { React.useEffect(() => {
@ -100,18 +100,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT && event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey) (event.metaKey || event.ctrlKey)
) { ) {
event.preventDefault() event.preventDefault();
toggleSidebar() toggleSidebar();
}
} }
};
window.addEventListener("keydown", handleKeyDown) window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]) }, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed". // We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes. // This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed" const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>( const contextValue = React.useMemo<SidebarContextProps>(
() => ({ () => ({
@ -123,8 +123,8 @@ function SidebarProvider({
setOpenMobile, setOpenMobile,
toggleSidebar, toggleSidebar,
}), }),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
) );
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>
@ -140,7 +140,7 @@ function SidebarProvider({
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className className,
)} )}
{...props} {...props}
> >
@ -148,7 +148,7 @@ function SidebarProvider({
</div> </div>
</TooltipProvider> </TooltipProvider>
</SidebarContext.Provider> </SidebarContext.Provider>
) );
} }
function Sidebar({ function Sidebar({
@ -159,11 +159,11 @@ function Sidebar({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
side?: "left" | "right" side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset" variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none" collapsible?: "offcanvas" | "icon" | "none";
}) { }) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar() const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") { if (collapsible === "none") {
return ( return (
@ -171,13 +171,13 @@ function Sidebar({
data-slot="sidebar" data-slot="sidebar"
className={cn( className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) );
} }
if (isMobile) { if (isMobile) {
@ -202,7 +202,7 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div> <div className="flex h-full w-full flex-col">{children}</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} }
return ( return (
@ -223,7 +223,7 @@ function Sidebar({
"group-data-[side=right]:rotate-180", "group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)} )}
/> />
<div <div
@ -237,7 +237,7 @@ function Sidebar({
variant === "floating" || variant === "inset" variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className className,
)} )}
{...props} {...props}
> >
@ -250,14 +250,14 @@ function Sidebar({
// so keep this container visually transparent. // so keep this container visually transparent.
variant === "floating" variant === "floating"
? "bg-transparent" ? "bg-transparent"
: "bg-sidebar group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm" : "bg-sidebar group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm",
)} )}
> >
{children} {children}
</div> </div>
</div> </div>
</div> </div>
) );
} }
function SidebarTrigger({ function SidebarTrigger({
@ -265,7 +265,7 @@ function SidebarTrigger({
onClick, onClick,
...props ...props
}: React.ComponentProps<typeof Button>) { }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<Button <Button
@ -275,19 +275,19 @@ function SidebarTrigger({
size="icon" size="icon"
className={cn("size-7", className)} className={cn("size-7", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event) onClick?.(event);
toggleSidebar() toggleSidebar();
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) );
} }
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar() const { toggleSidebar } = useSidebar();
return ( return (
<button <button
@ -298,17 +298,17 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar} onClick={toggleSidebar}
title="Toggle Sidebar" title="Toggle Sidebar"
className={cn( className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex", "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-0.5 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full", "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@ -318,11 +318,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
className={cn( className={cn(
"bg-background relative flex w-full flex-1 flex-col", "bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarInput({ function SidebarInput({
@ -336,7 +336,7 @@ function SidebarInput({
className={cn("bg-background h-8 w-full shadow-none", className)} className={cn("bg-background h-8 w-full shadow-none", className)}
{...props} {...props}
/> />
) );
} }
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@ -347,7 +347,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) { function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@ -358,7 +358,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)} className={cn("flex flex-col gap-2 p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarSeparator({ function SidebarSeparator({
@ -372,7 +372,7 @@ function SidebarSeparator({
className={cn("bg-sidebar-border mx-2 w-auto", className)} className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props} {...props}
/> />
) );
} }
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) { function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@ -382,11 +382,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content" data-sidebar="content"
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) { function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@ -397,7 +397,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)} className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} {...props}
/> />
) );
} }
function SidebarGroupLabel({ function SidebarGroupLabel({
@ -405,7 +405,7 @@ function SidebarGroupLabel({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) { }: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div" const Comp = asChild ? Slot.Root : "div";
return ( return (
<Comp <Comp
@ -414,11 +414,11 @@ function SidebarGroupLabel({
className={cn( className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupAction({ function SidebarGroupAction({
@ -426,7 +426,7 @@ function SidebarGroupAction({
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) { }: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@ -437,11 +437,11 @@ function SidebarGroupAction({
// Increases the hit area of the button on mobile. // Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden", "after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarGroupContent({ function SidebarGroupContent({
@ -455,7 +455,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)} className={cn("w-full text-sm", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@ -466,7 +466,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-1", className)} className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@ -477,7 +477,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)} className={cn("group/menu-item relative", className)}
{...props} {...props}
/> />
) );
} }
const sidebarMenuButtonVariants = cva( const sidebarMenuButtonVariants = cva(
@ -499,8 +499,8 @@ const sidebarMenuButtonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function SidebarMenuButton({ function SidebarMenuButton({
asChild = false, asChild = false,
@ -511,12 +511,12 @@ function SidebarMenuButton({
className, className,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
isActive?: boolean isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent> tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) { } & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
const { isMobile, state } = useSidebar() const { isMobile, state } = useSidebar();
const button = ( const button = (
<Comp <Comp
@ -527,16 +527,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)} className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} {...props}
/> />
) );
if (!tooltip) { if (!tooltip) {
return button return button;
} }
if (typeof tooltip === "string") { if (typeof tooltip === "string") {
tooltip = { tooltip = {
children: tooltip, children: tooltip,
} };
} }
return ( return (
@ -549,7 +549,7 @@ function SidebarMenuButton({
{...tooltip} {...tooltip}
/> />
</Tooltip> </Tooltip>
) );
} }
function SidebarMenuAction({ function SidebarMenuAction({
@ -558,10 +558,10 @@ function SidebarMenuAction({
showOnHover = false, showOnHover = false,
...props ...props
}: React.ComponentProps<"button"> & { }: React.ComponentProps<"button"> & {
asChild?: boolean asChild?: boolean;
showOnHover?: boolean showOnHover?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@ -577,11 +577,11 @@ function SidebarMenuAction({
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
showOnHover && showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0", "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuBadge({ function SidebarMenuBadge({
@ -599,11 +599,11 @@ function SidebarMenuBadge({
"peer-data-[size=default]/menu-button:top-1.5", "peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5", "peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSkeleton({ function SidebarMenuSkeleton({
@ -611,12 +611,12 @@ function SidebarMenuSkeleton({
showIcon = false, showIcon = false,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showIcon?: boolean showIcon?: boolean;
}) { }) {
// Random width between 50 to 90%. // Random width between 50 to 90%.
const width = React.useMemo(() => { const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%` return `${Math.floor(Math.random() * 40) + 50}%`;
}, []) }, []);
return ( return (
<div <div
@ -641,7 +641,7 @@ function SidebarMenuSkeleton({
} }
/> />
</div> </div>
) );
} }
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@ -652,11 +652,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
className={cn( className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5", "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubItem({ function SidebarMenuSubItem({
@ -670,7 +670,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)} className={cn("group/menu-sub-item relative", className)}
{...props} {...props}
/> />
) );
} }
function SidebarMenuSubButton({ function SidebarMenuSubButton({
@ -680,11 +680,11 @@ function SidebarMenuSubButton({
className, className,
...props ...props
}: React.ComponentProps<"a"> & { }: React.ComponentProps<"a"> & {
asChild?: boolean asChild?: boolean;
size?: "sm" | "md" size?: "sm" | "md";
isActive?: boolean isActive?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "a" const Comp = asChild ? Slot.Root : "a";
return ( return (
<Comp <Comp
@ -698,11 +698,11 @@ function SidebarMenuSubButton({
size === "sm" && "text-xs", size === "sm" && "text-xs",
size === "md" && "text-sm", size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden", "group-data-[collapsible=icon]:hidden",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@ -730,4 +730,4 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
} };

View File

@ -1,4 +1,4 @@
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} {...props}
/> />
) );
} }
export { Skeleton } export { Skeleton };

View File

@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props} {...props}
/> />
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b", className)}
{...props} {...props}
/> />
) );
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} {...props}
/> />
) );
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", "bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({
@ -99,7 +99,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@ -111,4 +111,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View File

@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui" import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "../../lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
@ -13,19 +13,19 @@ function TooltipProvider({
delayDuration={delayDuration} delayDuration={delayDuration}
{...props} {...props}
/> />
) );
} }
function Tooltip({ function Tooltip({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) { }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} /> return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
} }
function TooltipTrigger({ function TooltipTrigger({
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) { }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} /> return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
} }
function TooltipContent({ function TooltipContent({
@ -41,7 +41,7 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", "bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className className,
)} )}
{...props} {...props}
> >
@ -49,7 +49,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@ -1,344 +0,0 @@
import type { QuestArc } from "../types/quest";
// ─── QUEST DATA ───────────────────────────────────────────────────────────────
// Replace each node's `progress` and `status` with live API values.
// Everything else (titles, flavour, rewards) is content — edit freely.
export const QUEST_ARCS: QuestArc[] = [
// ── ARC 1: The Calm Seas ──────────────────────────────────────────────────
{
id: "east_blue",
name: "The Calm Seas",
subtitle: "Every great voyage begins at shore",
emoji: "🌊",
accentColor: "#0ea5e9",
accentDark: "#0369a1",
bgFrom: "#0c4a6e",
bgTo: "#075985",
nodes: [
{
id: "eb_1",
title: "First Steps",
flavourText:
'"I\'ll become the greatest sailor who ever lived!" — Every legend begins with a single step.',
islandName: "Hawthorn Cove",
emoji: "🏝️",
requirement: {
type: "questions",
target: 10,
label: "questions answered",
},
progress: 10,
status: "completed",
reward: { xp: 50, title: "Cabin Hand" },
},
{
id: "eb_2",
title: "Cast Off",
flavourText:
'"The sea doesn\'t care who you were — only who you become." Chart your course.',
islandName: "Redmast Port",
emoji: "⚓",
requirement: {
type: "sessions",
target: 3,
label: "practice sessions",
},
progress: 3,
status: "completed",
reward: { xp: 75 },
},
{
id: "eb_3",
title: "The Tangerine Coast",
flavourText:
'"Even alone, I protect my crew." Keep your streak burning bright.',
islandName: "Citrus Bay",
emoji: "🍊",
requirement: { type: "streak", target: 3, label: "day streak" },
progress: 3,
status: "completed",
reward: {
xp: 100,
item: "streak_shield",
itemLabel: "Streak Shield ×1",
},
},
{
id: "eb_4",
title: "The Fog Village",
flavourText:
'"I\'ve fooled everyone — except myself." Prove yourself across new territory.',
islandName: "Mistholm Village",
emoji: "🌿",
requirement: { type: "topics", target: 5, label: "topics practiced" },
progress: 3,
status: "claimable",
reward: { xp: 125, title: "Deckhand" },
},
{
id: "eb_5",
title: "The Floating Galley",
flavourText:
'"Nothing happened." Cut through the noise with razor accuracy.',
islandName: "The Iron Kitchen",
emoji: "🍖",
requirement: {
type: "accuracy",
target: 75,
label: "% accuracy (any session)",
},
progress: 58,
status: "active",
reward: {
xp: 150,
item: "xp_boost",
itemLabel: "2× XP Boost (1 session)",
},
},
{
id: "eb_6",
title: "The Sharkfin Strait",
flavourText:
'"This is my dream!" Conquer the Calm Seas before the Grand Voyage beckons.',
islandName: "Sharkfin Strait",
emoji: "🦈",
requirement: {
type: "questions",
target: 100,
label: "questions answered",
},
progress: 0,
status: "locked",
reward: { xp: 300, title: "First Mate" },
},
],
},
// ── ARC 2: The Amber Wastes ───────────────────────────────────────────────
{
id: "alabasta",
name: "The Amber Wastes",
subtitle: "Through the desert sands, to glory",
emoji: "🏜️",
accentColor: "#f59e0b",
accentDark: "#b45309",
bgFrom: "#78350f",
bgTo: "#92400e",
nodes: [
{
id: "al_1",
title: "Crossing the Mirrorlake",
flavourText:
'"A true sailor never makes excuses after losing." Enter the warzone.',
islandName: "Mirrorlake Basin",
emoji: "💧",
requirement: {
type: "sessions",
target: 5,
label: "practice sessions",
},
progress: 5,
status: "completed",
reward: { xp: 150 },
},
{
id: "al_2",
title: "The Sand March",
flavourText:
'"They underestimated us." Grind through the scorching heat.',
islandName: "The Amber Dunes",
emoji: "🌵",
requirement: {
type: "questions",
target: 50,
label: "questions answered",
},
progress: 50,
status: "completed",
reward: {
xp: 175,
item: "xp_boost",
itemLabel: "1.5× XP Boost (1 session)",
},
},
{
id: "al_3",
title: "The Sunstone Palace",
flavourText: '"I refuse to let my crew fall!" Climb the leaderboard.',
islandName: "Sunstone City",
emoji: "🏰",
requirement: {
type: "leaderboard",
target: 10,
label: "leaderboard rank",
},
progress: 22,
status: "active",
reward: { xp: 250, title: "Corsair" },
},
{
id: "al_4",
title: "Blades in the Bazaar",
flavourText:
'"I\'ll cut through iron." Maintain brutal accuracy under pressure.',
islandName: "Bazaar Streets",
emoji: "⚔️",
requirement: {
type: "accuracy",
target: 85,
label: "% accuracy (any session)",
},
progress: 0,
status: "locked",
reward: {
xp: 300,
item: "streak_shield",
itemLabel: "Streak Shield ×2",
},
},
{
id: "al_5",
title: "The Warlord Falls",
flavourText:
"\"I'm not dying here, partner.\" Prove you're worthy of the Wastes.",
islandName: "The Throne Dune",
emoji: "👑",
requirement: { type: "streak", target: 7, label: "day streak" },
progress: 0,
status: "locked",
reward: { xp: 400, title: "Corsair" },
},
{
id: "al_6",
title: "The Princess's Farewell",
flavourText:
'"Even if our paths split, you\'ll always sail with my crew." The arc is complete.',
islandName: "Mirrorlake Harbour",
emoji: "🌅",
requirement: { type: "xp", target: 1000, label: "total XP earned" },
progress: 0,
status: "locked",
reward: { xp: 500, title: "Sea Emperor" },
},
],
},
// ── ARC 3: The Sky Reaches ────────────────────────────────────────────────
{
id: "skypiea",
name: "The Sky Reaches",
subtitle: "Ascend to the island above the clouds",
emoji: "☁️",
accentColor: "#a855f7",
accentDark: "#7c3aed",
bgFrom: "#3b0764",
bgTo: "#4c1d95",
nodes: [
{
id: "sk_1",
title: "The Skyward Torrent",
flavourText:
'"The sky island is real!" Believe it — launch yourself upward.',
islandName: "Upper Cloudreach",
emoji: "🌤️",
requirement: {
type: "topics",
target: 3,
label: "topics at 70%+ accuracy",
},
progress: 0,
status: "locked",
reward: { xp: 200 },
},
{
id: "sk_2",
title: "The Trial of Storms",
flavourText:
'"Follow the wind, follow the stars." Navigate every corner of the cloudscape.',
islandName: "The Tempest Ordeal",
emoji: "🎯",
requirement: {
type: "topics",
target: 8,
label: "distinct topics practiced",
},
progress: 0,
status: "locked",
reward: {
xp: 250,
item: "xp_boost",
itemLabel: "2× XP Boost (2 sessions)",
},
},
{
id: "sk_3",
title: "The Sky God's Wrath",
flavourText: '"I am the heavens." Are you good enough to defy a deity?',
islandName: "The Celestial Ark",
emoji: "⚡",
requirement: {
type: "accuracy",
target: 90,
label: "% accuracy (any session)",
},
progress: 0,
status: "locked",
reward: { xp: 400, title: "Sea Emperor" },
},
{
id: "sk_4",
title: "The Ancient Bell",
flavourText:
'"I hear the torrent calling." Ring the bell — make history echo.',
islandName: "The Cloudvine Spire",
emoji: "🔔",
requirement: {
type: "questions",
target: 250,
label: "questions answered",
},
progress: 0,
status: "locked",
reward: {
xp: 500,
item: "streak_shield",
itemLabel: "Streak Shield ×3",
},
},
{
id: "sk_5",
title: "The Gilded Ruins",
flavourText:
'"THE GREAT CAPTAIN WAS HERE." Touch the treasure that all legends sought.',
islandName: "Aureveil",
emoji: "💰",
requirement: { type: "xp", target: 3000, label: "total XP earned" },
progress: 0,
status: "locked",
reward: { xp: 750, title: "Grand Captain" },
},
{
id: "sk_6",
title: "The Grand Captain",
flavourText:
'"This is my treasure!" You\'ve reached the summit — your target score awaits.',
islandName: "The Last Isle",
emoji: "🏴‍☠️",
requirement: {
type: "sessions",
target: 30,
label: "total sessions completed",
},
progress: 0,
status: "locked",
reward: {
xp: 1000,
title: "Grand Captain",
item: "xp_boost",
itemLabel: "Permanent 1.2× XP",
},
},
],
},
];

View File

@ -1,13 +0,0 @@
import { QUEST_ARCS } from "../data/questData";
// Returns the player's current crew rank, or a default if none earned yet
export function getCrewRank(arcs = QUEST_ARCS): string {
const earned = arcs
.flatMap((a) => a.nodes)
.filter((n) => n.status === "completed" && n.reward.title)
.map((n) => n.reward.title!);
// Return the last one — questData is ordered by difficulty,
// so the last earned title is always the highest rank
return earned.at(-1) ?? "Cabin Hand";
}

View File

@ -4,7 +4,7 @@ import { useSatExam } from "../stores/useSatExam";
export const useSatTimer = () => { export const useSatTimer = () => {
const phase = useSatExam((s) => s.phase); const phase = useSatExam((s) => s.phase);
const getRemainingTime = useSatExam((s) => s.getRemainingTime); const getRemainingTime = useSatExam((s) => s.getRemainingTime);
const startBreak = useSatExam((s) => s.startBreak);
const skipBreak = useSatExam((s) => s.skipBreak); const skipBreak = useSatExam((s) => s.skipBreak);
const finishExam = useSatExam((s) => s.finishExam); const finishExam = useSatExam((s) => s.finishExam);

View File

@ -1,32 +0,0 @@
// src/pages/ErrorPage.tsx
import { useRouteError, isRouteErrorResponse } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError();
console.error(error);
let title = "Something went wrong";
let message = "An unexpected error occurred.";
if (isRouteErrorResponse(error)) {
title = `${error.status} ${error.statusText}`;
message = error.data?.message || message;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white shadow-xl rounded-2xl p-8 max-w-md text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4">{title}</h1>
<p className="text-gray-600 mb-6">{message}</p>
<button
onClick={() => (window.location.href = "/")}
className="px-4 py-2 bg-black text-white rounded-lg"
>
Go Home
</button>
</div>
</div>
);
}

View File

@ -6,7 +6,6 @@ import {
Loader2, Loader2,
Mail, Mail,
Lock, Lock,
User,
ImageIcon, ImageIcon,
BookOpen, BookOpen,
Star, Star,

View File

@ -1,71 +0,0 @@
import { List, SquarePen, DecimalsArrowRight, MapPin } from "lucide-react";
import { Progress } from "../../components/ui/progress";
import { Button } from "../../components/ui/button";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "../../components/ui/card";
import { Field, FieldLabel } from "../../components/ui/field";
import { CircularProgress } from "../../components/CircularProgress";
export const Analytics = () => {
return (
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4 lg:pl-[calc(17rem+2rem)] lg:mr-0">
<h1 className="font-satoshi-bold text-3xl text-center tracking-tight">
Analytics
</h1>
<section className="flex w-full gap-3 justify-between">
<Card className="w-1/3 relative bg-linear-to-br from-purple-600 to-purple-700 rounded-4xl">
<div className="space-y-4">
<CardContent className="md:w-full space-y-4 flex flex-col items-center justify-center h-50">
<MapPin size={60} color="white" />
<h1 className="text-4xl font-satoshi-bold text-white flex">
<span>145</span> <span className="text-xl">th</span>
</h1>
</CardContent>
</div>
<div className="overflow-hidden opacity-0 -rotate-45 absolute -top-2 -right-30 ">
<DecimalsArrowRight size={380} color="white" />
</div>
</Card>
<Card
className="w-2/3 relative bg-linear-to-br from-gray-100 to-gray-300 rounded-4xl
flex-row"
>
<div className="space-y-4">
<CardHeader className="md:w-full">
<CardTitle className="font-satoshi-bold tracking-tight text-3xl ">
Details
</CardTitle>
</CardHeader>
<CardContent className="md:w-full space-y-4"></CardContent>
<CardFooter className="flex justify-between"></CardFooter>
</div>
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-2 -right-30 ">
<DecimalsArrowRight size={380} color="white" />
</div>
</Card>
</section>
<section>
<Card>
<CardContent>
<Field className="w-full max-w-sm">
<FieldLabel htmlFor="progress-upload">
<span className="font-satoshi text-xl">Score</span>
<span className="ml-auto font-satoshi">
<span className="text-5xl">854</span>
<span className="text-lg">/1600</span>
</span>
</FieldLabel>
<Progress value={55} id="progress-upload" max={100} />
</Field>
</CardContent>
</Card>
</section>
</main>
);
};

View File

@ -7,7 +7,6 @@ import { formatStatus } from "../../lib/utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { SearchOverlay } from "../../components/SearchOverlay"; import { SearchOverlay } from "../../components/SearchOverlay";
import { InfoHeader } from "../../components/InfoHeader"; import { InfoHeader } from "../../components/InfoHeader";
import { InventoryButton } from "../../components/InventoryButton";
// ─── Shared blob/dot background (same as break/results screens) ──────────────── // ─── Shared blob/dot background (same as break/results screens) ────────────────
const DOTS = [ const DOTS = [

View File

@ -491,7 +491,9 @@ export const Lessons = () => {
setLessonLoading(true); setLessonLoading(true);
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return; if (!authStorage) return;
const { const {
// @ts-ignore
state: { token }, state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } }; } = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) return; if (!token) return;
@ -631,7 +633,7 @@ export const Lessons = () => {
lesson={lesson} lesson={lesson}
index={i} index={i}
searchQuery={searchQuery} searchQuery={searchQuery}
onClick={() => handleLessonClick(lesson.id)} onClick={() => handleLessonClick(lesson.id, lesson.title)}
/> />
))} ))}
</div> </div>

View File

@ -8,8 +8,6 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
import { LevelBar } from "../../components/LevelBar";
import { InfoHeader } from "../../components/InfoHeader"; import { InfoHeader } from "../../components/InfoHeader";
const DOTS = [ const DOTS = [

View File

@ -756,9 +756,9 @@ const RouteSegment = ({
isNext, isNext,
accent, accent,
}: RouteSegmentProps) => { }: RouteSegmentProps) => {
const lineRef = useRef<THREE.Line>(null!); const lineRef = useRef<THREE.Line | null>(null);
const glowRef = useRef<THREE.Mesh>(null!); const glowRef = useRef<THREE.Mesh | null>(null);
const shipRef = useRef<THREE.Group>(null!); const shipRef = useRef<THREE.Group | null>(null);
const shipT = useRef(0); const shipT = useRef(0);
// CatmullRom curve bowing sideways — alternate direction per segment // CatmullRom curve bowing sideways — alternate direction per segment
@ -799,8 +799,10 @@ const RouteSegment = ({
useFrame((_, dt) => { useFrame((_, dt) => {
// Scroll dashes forward along the route // Scroll dashes forward along the route
if (lineRef.current) { if (lineRef.current) {
const mat = lineRef.current.material as THREE.LineDashedMaterial; // material typings may not include dashOffset; use any and guard the value
if (dashSpeed > 0) mat.dashOffset -= dt * dashSpeed; const lineMat = lineRef.current.material as any;
if (dashSpeed > 0)
lineMat.dashOffset = (lineMat.dashOffset ?? 0) - dt * dashSpeed;
} }
// Pulse glow on active segments // Pulse glow on active segments
if (glowRef.current && (isActive || isNext)) { if (glowRef.current && (isActive || isNext)) {
@ -837,9 +839,14 @@ const RouteSegment = ({
{/* Dashed route line */} {/* Dashed route line */}
<line <line
ref={lineRef} ref={(r: any) => {
// r may be an SVGLineElement in JSX DOM typings; treat as any to satisfy TS and assign to Line ref
lineRef.current = r as THREE.Line | null;
}}
// @ts-ignore - geometry is a three.js prop, not an SVG attribute
geometry={lineGeo} geometry={lineGeo}
onUpdate={(self) => self.computeLineDistances()} // onUpdate receives a three.js Line; use any to avoid DOM typings
onUpdate={(self: any) => self.computeLineDistances()}
> >
<lineDashedMaterial <lineDashedMaterial
color={color} color={color}
@ -1275,7 +1282,7 @@ const LeftPanel = ({
arcs: QuestArc[]; arcs: QuestArc[];
activeArcId: string; activeArcId: string;
onSelectArc: (id: string) => void; onSelectArc: (id: string) => void;
scrollRef: React.RefObject<HTMLDivElement>; scrollRef: React.RefObject<HTMLDivElement | null>;
user: any; user: any;
onClaim: (n: QuestNode) => void; onClaim: (n: QuestNode) => void;
}) => { }) => {
@ -1557,7 +1564,7 @@ export const QuestMap = () => {
const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>( const [claimResult, setClaimResult] = useState<ClaimedRewardResponse | null>(
null, null,
); );
const [claimLoading, setClaimLoading] = useState(false);
const [claimError, setClaimError] = useState<string | null>(null); const [claimError, setClaimError] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null); const [selectedNode, setSelectedNode] = useState<QuestNode | null>(null);
@ -1597,14 +1604,12 @@ export const QuestMap = () => {
setClaimingNode(node); setClaimingNode(node);
setClaimResult(null); setClaimResult(null);
setClaimError(null); setClaimError(null);
setClaimLoading(true);
try { try {
const result = await api.claimReward(token, node.node_id); const result = await api.claimReward(token, node.node_id);
setClaimResult(result); setClaimResult(result);
} catch (err) { } catch (err) {
setClaimError(err instanceof Error ? err.message : "Claim failed"); setClaimError(err instanceof Error ? err.message : "Claim failed");
} finally {
setClaimLoading(false);
} }
}, },
[token], [token],

View File

@ -434,9 +434,10 @@ export const Rewards = () => {
if (!user) return; if (!user) return;
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return; if (!authStorage) return;
const { const parsed = JSON.parse(authStorage) as {
state: { token }, state?: { token?: string };
} = JSON.parse(authStorage) as { state?: { token?: string } }; } | null;
const token = parsed?.state?.token;
if (!token) return; if (!token) return;
try { try {
setLoading(true); setLoading(true);
@ -481,7 +482,7 @@ export const Rewards = () => {
// ✅ FIX 2: Safely cast user_rank — null becomes undefined so all optional chaining works // ✅ FIX 2: Safely cast user_rank — null becomes undefined so all optional chaining works
const ur = (leaderboard?.user_rank ?? undefined) as const ur = (leaderboard?.user_rank ?? undefined) as
| Record<string, unknown> | Record<string, number>
| undefined; | undefined;
const islandStats = getIslandStats(ur, activeTab); const islandStats = getIslandStats(ur, activeTab);

View File

@ -324,9 +324,10 @@ export const Drills = () => {
setLoading(true); setLoading(true);
const authStorage = localStorage.getItem("auth-storage"); const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return; if (!authStorage) return;
const { const parsed = JSON.parse(authStorage) as {
state: { token }, state?: { token?: string };
} = JSON.parse(authStorage) as { state?: { token?: string } }; } | null;
const token = parsed?.state?.token;
if (!token) return; if (!token) return;
const response = await api.fetchAllTopics(token); const response = await api.fetchAllTopics(token);
setTopics(response); setTopics(response);

View File

@ -47,9 +47,9 @@ function FormulaCard({
/> />
</div> </div>
<div <div
className={`transition-all duration-300 ease-in-out ${open ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0"}`} className={`transition-all duration-300 ease-in-out ${open ? "max-h-100 opacity-100" : "max-h-0 opacity-0"}`}
> >
<div className="border-t border-emerald-100 px-5 py-4 flex flex-col sm:flex-row items-center gap-5 bg-gradient-to-br from-emerald-50/50 to-white/80"> <div className="border-t border-emerald-100 px-5 py-4 flex flex-col sm:flex-row items-center gap-5 bg-linear-to-br from-emerald-50/50 to-white/80">
<div className="shrink-0">{diagram}</div> <div className="shrink-0">{diagram}</div>
<div className="text-sm text-slate-600 space-y-1 font-mono"> <div className="text-sm text-slate-600 space-y-1 font-mono">
{example} {example}

View File

@ -469,7 +469,7 @@ const CirclePropertiesLesson: React.FC<LessonProps> = ({ onFinish }) => {
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{CIRCLE_PROP_QUIZ_DATA.map((quiz, idx) => ( {CIRCLE_PROP_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
Circle, Circle,
Target, Target,

View File

@ -490,7 +490,7 @@ const CongruenceSimilarityLesson: React.FC<LessonProps> = ({ onFinish }) => {
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{SIMILARITY_QUIZ_DATA.map((quiz, idx) => ( {SIMILARITY_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -266,7 +266,7 @@ const DataAnalysisLesson: React.FC<LessonProps> = ({ onFinish }) => {
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{DATA_ANALYSIS_QUIZ_DATA.map((quiz, idx) => ( {DATA_ANALYSIS_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -296,7 +296,7 @@ const EBRWBoundariesLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -142,7 +142,7 @@ const EBRWCentralIdeasLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -158,7 +158,7 @@ const EBRWCommandEvidenceLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -209,7 +209,7 @@ const EBRWCommasLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -88,7 +88,7 @@ const EBRWCraftStructureLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -125,7 +125,7 @@ const EBRWCrossTextLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -195,7 +195,7 @@ const EBRWDashesApostrophesLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -105,6 +105,7 @@ const EBRWExplicitMeaningLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -105,6 +105,7 @@ const EBRWExpressionIdeasLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -220,6 +220,7 @@ const EBRWFormStructureSenseLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -273,6 +273,7 @@ const EBRWGraphicDisplaysLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -155,6 +155,7 @@ const EBRWInferencesLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -102,6 +102,7 @@ const EBRWMainIdeaLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -208,7 +208,7 @@ const EBRWPronounsLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -190,6 +190,7 @@ const EBRWRhetoricalSynthesisLesson: React.FC<LessonProps> = ({ onFinish }) => {
{isPast ? ( {isPast ? (
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
) : ( ) : (
// @ts-ignore
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
)} )}
</div> </div>

View File

@ -189,7 +189,7 @@ const EBRWSemicolonsColonsLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -214,7 +214,7 @@ const EBRWSentenceStructureLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -203,7 +203,7 @@ const EBRWSubjectVerbLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -270,7 +270,7 @@ const EBRWTextStructurePurposeLesson: React.FC<LessonProps> = ({
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -171,7 +171,7 @@ const EBRWTransitionsLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -211,7 +211,7 @@ const EBRWVerbsLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -45,7 +45,7 @@ const EBRWVocabMeaningLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -45,7 +45,7 @@ const EBRWVocabPreciseLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;

View File

@ -275,7 +275,7 @@ const EBRWWordsInContextLesson: React.FC<LessonProps> = ({ onFinish }) => {
}: { }: {
index: number; index: number;
title: string; title: string;
icon: React.ElementType; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
}) => { }) => {
const isActive = activeSection === index; const isActive = activeSection === index;
const isPast = activeSection > index; const isPast = activeSection > index;
@ -288,11 +288,7 @@ const EBRWWordsInContextLesson: React.FC<LessonProps> = ({ onFinish }) => {
className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0
${isActive ? "bg-fuchsia-600 text-white" : isPast ? "bg-fuchsia-400 text-white" : "bg-slate-200 text-slate-500"}`} ${isActive ? "bg-fuchsia-600 text-white" : isPast ? "bg-fuchsia-400 text-white" : "bg-slate-200 text-slate-500"}`}
> >
{isPast ? ( {isPast ? <Check className="w-4 h-4" /> : <Icon />}
<Check className="w-4 h-4" />
) : (
<Icon className="w-4 h-4" />
)}
</div> </div>
<p <p
className={`text-sm font-bold ${isActive ? "text-fuchsia-900" : "text-slate-600"}`} className={`text-sm font-bold ${isActive ? "text-fuchsia-900" : "text-slate-600"}`}

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
ArrowRight, ArrowRight,
Layers, Layers,

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
Search, Search,
GitBranch, GitBranch,

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import { useState } from "react";
import { import {
Scale, Scale,
ArrowRight, ArrowRight,
@ -64,9 +64,9 @@ const BalanceWidget = () => {
</button> </button>
</div> </div>
<div className="relative h-32 flex justify-center items-end mb-4"> <div className="relative h-32 flex justify-center items-end mb-4">
<div className="absolute bottom-0 w-0 h-0 border-l-[16px] border-l-transparent border-r-[16px] border-r-transparent border-b-[32px] border-b-slate-800" /> <div className="absolute bottom-0 w-0 h-0 border-l-16 border-l-transparent border-r-16 border-r-transparent border-b-32 border-b-slate-800" />
<div <div
className="w-52 h-1.5 bg-slate-600 absolute bottom-[32px] transition-transform duration-500" className="w-52 h-1.5 bg-slate-600 absolute bottom-8 transition-transform duration-500"
style={{ transform: `rotate(${tilt}deg)` }} style={{ transform: `rotate(${tilt}deg)` }}
> >
<div className="absolute left-0 -top-12 w-16 h-12 border-b-2 border-l border-r border-slate-300 rounded-b-lg bg-white/80 flex items-center justify-center"> <div className="absolute left-0 -top-12 w-16 h-12 border-b-2 border-l border-r border-slate-300 rounded-b-lg bg-white/80 flex items-center justify-center">

View File

@ -1,12 +1,4 @@
import React from "react"; import { Grid, TrendingUp, Layers, Hash, BookOpen } from "lucide-react";
import {
Grid,
TrendingUp,
Layers,
ArrowRight,
Hash,
BookOpen,
} from "lucide-react";
import LessonShell, { import LessonShell, {
ConceptCard, ConceptCard,
FormulaBox, FormulaBox,

View File

@ -57,9 +57,9 @@ const BalanceScaleWidget = () => {
</button> </button>
</div> </div>
<div className="relative h-40 w-full mb-8 flex justify-center items-end"> <div className="relative h-40 w-full mb-8 flex justify-center items-end">
<div className="absolute bottom-0 w-0 h-0 border-l-[20px] border-l-transparent border-r-[20px] border-r-transparent border-b-[40px] border-b-slate-800"></div> <div className="absolute bottom-0 w-0 h-0 border-l-20 border-l-transparent border-r-20 border-r-transparent border-b-40 border-b-slate-800"></div>
<div <div
className="w-64 h-2 bg-slate-600 absolute bottom-[40px] transition-transform duration-700" className="w-64 h-2 bg-slate-600 absolute bottom-10 transition-transform duration-700"
style={{ transform: `rotate(${tilt}deg)` }} style={{ transform: `rotate(${tilt}deg)` }}
> >
<div <div
@ -481,7 +481,7 @@ const LinearEquationsLesson: React.FC<LessonProps> = ({ onFinish }) => {
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{LINEAR_EQ_QUIZ_DATA.map((quiz, idx) => ( {LINEAR_EQ_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
TrendingUp, TrendingUp,
Hash, Hash,
@ -186,7 +185,7 @@ export default function LinearFunctionsLesson({ onFinish }: LessonProps) {
key={c} key={c}
className="flex gap-3 items-center bg-white/60 rounded-lg p-3 border border-blue-100" className="flex gap-3 items-center bg-white/60 rounded-lg p-3 border border-blue-100"
> >
<code className="font-mono text-blue-700 font-bold min-w-[110px]"> <code className="font-mono text-blue-700 font-bold min-w-27.5">
{c} {c}
</code> </code>
<span className="text-slate-600">{d}</span> <span className="text-slate-600">{d}</span>

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
Scale, Scale,
ArrowRight, ArrowRight,
@ -9,7 +8,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import LessonShell, { import LessonShell, {
ConceptCard, ConceptCard,
FormulaBox,
ExampleCard, ExampleCard,
TipCard, TipCard,
PracticeFromDataset, PracticeFromDataset,

View File

@ -327,7 +327,7 @@ const LinearParallelPerpendicularLesson: React.FC<LessonProps> = ({
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{LINEAR_PARALLEL_PERP_QUIZ_DATA.map((quiz, idx) => ( {LINEAR_PARALLEL_PERP_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -303,7 +303,7 @@ const LinearTransformationsLesson: React.FC<LessonProps> = ({ onFinish }) => {
<h2 className="text-4xl font-extrabold text-slate-900 mb-8"> <h2 className="text-4xl font-extrabold text-slate-900 mb-8">
Practice Time Practice Time
</h2> </h2>
{LINEAR_TRANSFORMATIONS_QUIZ_DATA.map((quiz, idx) => ( {LINEAR_TRANSFORMATIONS_QUIZ_DATA.map((quiz) => (
<div key={quiz.id} className="mb-12"> <div key={quiz.id} className="mb-12">
<Quiz data={quiz} /> <Quiz data={quiz} />
</div> </div>

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
ArrowRight, ArrowRight,
Triangle, Triangle,

View File

@ -1,4 +1,3 @@
import React from "react";
import { Layers, Hash, Target, Zap, RotateCcw, BookOpen } from "lucide-react"; import { Layers, Hash, Target, Zap, RotateCcw, BookOpen } from "lucide-react";
import LessonShell, { import LessonShell, {
ConceptCard, ConceptCard,

View File

@ -1,4 +1,3 @@
import React from "react";
import { import {
BarChart, BarChart,
Box, Box,

Some files were not shown because too many files have changed in this diff Show More