chore(build): refactor codebase for production
This commit is contained in:
@ -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 />,
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Home,
|
Home,
|
||||||
Video,
|
|
||||||
Target,
|
Target,
|
||||||
Zap,
|
Zap,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}°
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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`}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -64,7 +64,6 @@ const PALETTES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function LessonShell({
|
export default function LessonShell({
|
||||||
title,
|
|
||||||
sections,
|
sections,
|
||||||
color,
|
color,
|
||||||
onFinish,
|
onFinish,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
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%
|
||||||
|
|
||||||
const step1Val = start * (1 + change1/100);
|
const step1Val = start * (1 + change1 / 100);
|
||||||
const finalVal = step1Val * (1 + change2/100);
|
const finalVal = step1Val * (1 + change2 / 100);
|
||||||
|
|
||||||
const overallChange = ((finalVal - start) / start) * 100;
|
const overallChange = ((finalVal - start) / start) * 100;
|
||||||
const naiveChange = change1 + change2;
|
const naiveChange = change1 + change2;
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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,57 +15,83 @@ 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;
|
||||||
const dy = nextP.y - p.y;
|
const dy = nextP.y - p.y;
|
||||||
// Normalize and extend
|
// Normalize and extend
|
||||||
const len = Math.sqrt(dx*dx + dy*dy);
|
const len = Math.sqrt(dx * dx + dy * dy);
|
||||||
const exLen = 40;
|
const exLen = 40;
|
||||||
const exX = nextP.x + (dx/len) * exLen;
|
const exX = nextP.x + (dx / len) * exLen;
|
||||||
const exY = nextP.y + (dy/len) * exLen;
|
const exY = nextP.y + (dy / len) * exLen;
|
||||||
return { x1: nextP.x, y1: nextP.y, x2: exX, y2: exY };
|
return { x1: nextP.x, y1: nextP.y, x2: exX, y2: exY };
|
||||||
});
|
});
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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
|
||||||
@ -8,40 +8,49 @@ const RadicalSolutionWidget: React.FC = () => {
|
|||||||
// x = (x-k)^2 => x = x^2 - 2kx + k^2 => x^2 - (2k+1)x + k^2 = 0
|
// x = (x-k)^2 => x = x^2 - 2kx + k^2 => x^2 - (2k+1)x + k^2 = 0
|
||||||
// Roots via quadratic formula
|
// Roots via quadratic formula
|
||||||
const a = 1;
|
const a = 1;
|
||||||
const b = -(2*k + 1);
|
const b = -(2 * k + 1);
|
||||||
const c = k*k;
|
const c = k * k;
|
||||||
const disc = b*b - 4*a*c;
|
const disc = b * b - 4 * a * c;
|
||||||
|
|
||||||
let solutions: number[] = [];
|
let solutions: number[] = [];
|
||||||
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)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,8 +58,10 @@ const RadicalSolutionWidget: React.FC = () => {
|
|||||||
// This includes y = -sqrt(x)
|
// This includes y = -sqrt(x)
|
||||||
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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 };
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -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";
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -6,7 +6,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Mail,
|
Mail,
|
||||||
Lock,
|
Lock,
|
||||||
User,
|
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Star,
|
Star,
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -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 = [
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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],
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
Circle,
|
Circle,
|
||||||
Target,
|
Target,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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"}`}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Layers,
|
Layers,
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Triangle,
|
Triangle,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
Reference in New Issue
Block a user