Tighten typing and input validation across study pages
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m2s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m2s
This commit is contained in:
parent
6d1d918355
commit
d6f3502cb1
16 changed files with 170 additions and 56 deletions
|
|
@ -27,6 +27,7 @@ import {
|
||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { ReorderItem } from "@/types/study";
|
||||||
|
|
||||||
interface DeckItem {
|
interface DeckItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -65,7 +66,14 @@ function getProgressLabel(deck: DeckItem) {
|
||||||
return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`;
|
return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) {
|
interface SortableDeckCardProps {
|
||||||
|
deck: DeckItem;
|
||||||
|
onEdit: (deck: DeckItem) => void;
|
||||||
|
onDelete: (deck: DeckItem) => void;
|
||||||
|
classSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
|
@ -314,8 +322,6 @@ export default function FlashcardsPage() {
|
||||||
if (!activeDeck) return;
|
if (!activeDeck) return;
|
||||||
|
|
||||||
let targetGroupId: string | null = null;
|
let targetGroupId: string | null = null;
|
||||||
let targetIndex = 0;
|
|
||||||
|
|
||||||
const overContainerId = over.data.current?.sortable?.containerId;
|
const overContainerId = over.data.current?.sortable?.containerId;
|
||||||
if (overContainerId) {
|
if (overContainerId) {
|
||||||
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
|
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
|
||||||
|
|
@ -344,7 +350,7 @@ export default function FlashcardsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
||||||
const updates: any[] = [];
|
const updates: ReorderItem[] = [];
|
||||||
|
|
||||||
affectedGroups.forEach(gId => {
|
affectedGroups.forEach(gId => {
|
||||||
const gItems = newItems.filter(d => d.groupId === gId);
|
const gItems = newItems.filter(d => d.groupId === gId);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import {
|
||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { ReorderItem } from "@/types/study";
|
||||||
|
|
||||||
interface QuizItem {
|
interface QuizItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -51,7 +52,14 @@ interface MaterialGroup {
|
||||||
|
|
||||||
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
||||||
|
|
||||||
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) {
|
interface SortableQuizCardProps {
|
||||||
|
quiz: QuizItem;
|
||||||
|
onEdit: (quiz: QuizItem) => void;
|
||||||
|
onDelete: (quiz: QuizItem) => void;
|
||||||
|
classSlug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
|
|
@ -312,8 +320,6 @@ export default function QuizzesPage() {
|
||||||
if (!activeQuiz) return;
|
if (!activeQuiz) return;
|
||||||
|
|
||||||
let targetGroupId: string | null = null;
|
let targetGroupId: string | null = null;
|
||||||
let targetIndex = 0;
|
|
||||||
|
|
||||||
// Check if over a group container directly
|
// Check if over a group container directly
|
||||||
const overContainerId = over.data.current?.sortable?.containerId;
|
const overContainerId = over.data.current?.sortable?.containerId;
|
||||||
if (overContainerId) {
|
if (overContainerId) {
|
||||||
|
|
@ -345,7 +351,7 @@ export default function QuizzesPage() {
|
||||||
|
|
||||||
// Re-calculate sortOrder for the affected groups to persist
|
// Re-calculate sortOrder for the affected groups to persist
|
||||||
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
||||||
const updates: any[] = [];
|
const updates: ReorderItem[] = [];
|
||||||
|
|
||||||
affectedGroups.forEach(gId => {
|
affectedGroups.forEach(gId => {
|
||||||
const gItems = newItems.filter(q => q.groupId === gId);
|
const gItems = newItems.filter(q => q.groupId === gId);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||||
const { items } = body;
|
if (!parsed.success) {
|
||||||
|
|
||||||
if (!Array.isArray(items)) {
|
|
||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { items } = parsed.data;
|
||||||
|
|
||||||
// items should be an array of { id, sortOrder, groupId }
|
// items should be an array of { id, sortOrder, groupId }
|
||||||
// Using a transaction to perform all updates
|
// Using a transaction to perform all updates
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
items.map((item: any) =>
|
items.map((item) =>
|
||||||
prisma.deck.update({
|
prisma.deck.update({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
export async function PATCH(
|
export async function PATCH(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
|
|
@ -10,7 +11,7 @@ export async function PATCH(
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, sortOrder } = body;
|
const { name, sortOrder } = body;
|
||||||
|
|
||||||
const updateData: any = {};
|
const updateData: Prisma.MaterialGroupUpdateInput = {};
|
||||||
if (name !== undefined) updateData.name = name;
|
if (name !== undefined) updateData.name = name;
|
||||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { Prisma } from "@/generated/prisma/client";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -14,7 +15,7 @@ export async function GET(request: NextRequest) {
|
||||||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause: any = { classId };
|
const whereClause: Prisma.MaterialGroupWhereInput = { classId };
|
||||||
if (type) {
|
if (type) {
|
||||||
whereClause.type = type;
|
whereClause.type = type;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||||
const { items } = body;
|
if (!parsed.success) {
|
||||||
|
|
||||||
if (!Array.isArray(items)) {
|
|
||||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { items } = parsed.data;
|
||||||
|
|
||||||
// items should be an array of { id, sortOrder, groupId }
|
// items should be an array of { id, sortOrder, groupId }
|
||||||
// Using a transaction to perform all updates
|
// Using a transaction to perform all updates
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
items.map((item: any) =>
|
items.map((item) =>
|
||||||
prisma.quizSet.update({
|
prisma.quizSet.update({
|
||||||
where: { id: item.id },
|
where: { id: item.id },
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { useState, useEffect } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import type { SharedGroupData } from "@/types/study";
|
||||||
|
|
||||||
export function SharedGroupViewer({ data }: { data: any }) {
|
export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -18,7 +19,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
||||||
items.forEach((item: any) => {
|
items.forEach((item) => {
|
||||||
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
|
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`;
|
||||||
const saved = localStorage.getItem(key);
|
const saved = localStorage.getItem(key);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
|
@ -71,7 +72,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{items.map((item: any) => (
|
{items.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="group flex flex-col bg-bg-surface border border-border-light rounded-xl shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
|
className="group flex flex-col bg-bg-surface border border-border-light rounded-xl shadow-[var(--shadow-card)] hover:shadow-[var(--shadow-card-hover)] hover:border-primary/20 transition-all duration-300"
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import { usePathname } from "next/navigation";
|
||||||
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
|
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
|
||||||
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
||||||
import { CardList } from "@/components/flashcards/CardList";
|
import { CardList } from "@/components/flashcards/CardList";
|
||||||
|
import type { SharedStudyItem } from "@/types/study";
|
||||||
|
|
||||||
interface SharedViewerProps {
|
interface SharedViewerProps {
|
||||||
type: string;
|
type: "flashcards" | "quizzes";
|
||||||
data: any;
|
data: SharedStudyItem;
|
||||||
groupMode?: boolean;
|
groupMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
||||||
|
|
||||||
const [showTopics, setShowTopics] = useState(false);
|
const [showTopics, setShowTopics] = useState(false);
|
||||||
const categories = type === "quizzes" && data.questions
|
const categories = type === "quizzes" && data.questions
|
||||||
? Array.from(new Set(data.questions.map((q: any) => q.category.toUpperCase()))).join(" · ")
|
? Array.from(new Set(data.questions.map((q) => q.category.toUpperCase()))).join(" · ")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -137,18 +138,20 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
||||||
view === "study" ? (
|
view === "study" ? (
|
||||||
<FlashcardViewer
|
<FlashcardViewer
|
||||||
key={restartKey}
|
key={restartKey}
|
||||||
cards={data.cards}
|
cards={data.cards ?? []}
|
||||||
deckId={data.id}
|
deckId={data.id}
|
||||||
isShared={true}
|
isShared={true}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CardList cards={data.cards} />
|
<CardList cards={data.cards ?? []} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<QuizViewer
|
<QuizViewer
|
||||||
key={restartKey}
|
key={restartKey}
|
||||||
quiz={{
|
quiz={{
|
||||||
...data,
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
questions: data.questions ?? [],
|
||||||
progress: [], // Start fresh since it's shared
|
progress: [], // Start fresh since it's shared
|
||||||
}}
|
}}
|
||||||
retakeIds={null}
|
retakeIds={null}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SharedViewer } from "./SharedViewer";
|
import { SharedViewer } from "./SharedViewer";
|
||||||
import { SharedGroupViewer } from "./SharedGroupViewer";
|
import { SharedGroupViewer } from "./SharedGroupViewer";
|
||||||
|
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
|
||||||
|
|
||||||
|
type SharedContentType = "flashcards" | "quizzes";
|
||||||
|
|
||||||
interface SharedPageProps {
|
interface SharedPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|
@ -48,17 +51,17 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetData: any = null;
|
let targetData: SharedStudyItem | SharedGroupData | null = null;
|
||||||
let targetType = type;
|
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
||||||
|
|
||||||
if (type === "groups" && link.group) {
|
if (type === "groups" && link.group) {
|
||||||
if (itemId) {
|
if (itemId) {
|
||||||
// Find the specific item inside the group
|
// Find the specific item inside the group
|
||||||
if (link.group.type === "DECK") {
|
if (link.group.type === "DECK") {
|
||||||
targetData = link.group.decks.find(d => d.id === itemId);
|
targetData = link.group.decks.find(d => d.id === itemId) ?? null;
|
||||||
targetType = "flashcards";
|
targetType = "flashcards";
|
||||||
} else {
|
} else {
|
||||||
targetData = link.group.quizSets.find(q => q.id === itemId);
|
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
|
||||||
targetType = "quizzes";
|
targetType = "quizzes";
|
||||||
}
|
}
|
||||||
if (!targetData) notFound();
|
if (!targetData) notFound();
|
||||||
|
|
@ -69,6 +72,8 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!targetData) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bg-base flex flex-col">
|
<div className="min-h-screen bg-bg-base flex flex-col">
|
||||||
{/* Read-only Header */}
|
{/* Read-only Header */}
|
||||||
|
|
@ -105,11 +110,11 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||||
{type === "groups" && !itemId ? (
|
{type === "groups" && !itemId ? (
|
||||||
<SharedGroupViewer data={targetData} />
|
<SharedGroupViewer data={targetData as SharedGroupData} />
|
||||||
) : (
|
) : (
|
||||||
<SharedViewer
|
<SharedViewer
|
||||||
type={targetType}
|
type={targetType}
|
||||||
data={targetData}
|
data={targetData as SharedStudyItem}
|
||||||
groupMode={type === "groups"}
|
groupMode={type === "groups"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,8 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message);
|
setError(err instanceof Error ? err.message : "Failed to create deck");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { QuizResults } from "./QuizResults";
|
import { QuizResults } from "./QuizResults";
|
||||||
|
import type { QuizAttempt, QuizSummary } from "@/types/study";
|
||||||
|
|
||||||
interface AttemptHistoryProps {
|
interface AttemptHistoryProps {
|
||||||
quiz: any;
|
quiz: QuizSummary;
|
||||||
attempts: any[];
|
attempts: QuizAttempt[];
|
||||||
onRetake: (missedIds: string[]) => void;
|
onRetake: (missedIds: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
|
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
|
||||||
const [selectedAttempt, setSelectedAttempt] = useState<any | null>(null);
|
const [selectedAttempt, setSelectedAttempt] = useState<QuizAttempt | null>(null);
|
||||||
|
|
||||||
if (selectedAttempt) {
|
if (selectedAttempt) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,20 @@
|
||||||
|
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { scoreQuestion } from "@/lib/scoring";
|
import { scoreQuestion } from "@/lib/scoring";
|
||||||
|
import type { QuizSummary } from "@/types/study";
|
||||||
|
|
||||||
interface CategoryBreakdownProps {
|
interface CategoryBreakdownProps {
|
||||||
quiz: any;
|
quiz: QuizSummary;
|
||||||
attempt: any;
|
|
||||||
answeredIds: string[];
|
answeredIds: string[];
|
||||||
answers: Record<string, string[]>;
|
answers: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
|
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
|
||||||
// Aggregate stats per category
|
// Aggregate stats per category
|
||||||
const stats: Record<string, { earned: number; possible: number }> = {};
|
const stats: Record<string, { earned: number; possible: number }> = {};
|
||||||
|
|
||||||
answeredIds.forEach((qId) => {
|
answeredIds.forEach((qId) => {
|
||||||
const q = quiz.questions.find((x: any) => x.id === qId);
|
const q = quiz.questions.find((x) => x.id === qId);
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
||||||
const cat = q.category.trim().toLowerCase();
|
const cat = q.category.trim().toLowerCase();
|
||||||
|
|
@ -23,7 +23,11 @@ export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: Categ
|
||||||
stats[cat] = { earned: 0, possible: 0 };
|
stats[cat] = { earned: 0, possible: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedQ = { id: q.id, type: q.type, options: q.options };
|
const formattedQ = {
|
||||||
|
id: q.id,
|
||||||
|
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||||
|
options: q.options,
|
||||||
|
};
|
||||||
const points = scoreQuestion(formattedQ, answers[q.id] || []);
|
const points = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||||
|
|
||||||
stats[cat].earned += points;
|
stats[cat].earned += points;
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,21 @@ import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { CategoryBreakdown } from "./CategoryBreakdown";
|
import { CategoryBreakdown } from "./CategoryBreakdown";
|
||||||
import { scoreQuestion } from "@/lib/scoring";
|
import { scoreQuestion } from "@/lib/scoring";
|
||||||
|
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
|
||||||
|
|
||||||
interface QuizResultsProps {
|
interface QuizResultsProps {
|
||||||
quiz: any; // QuizData
|
quiz: QuizSummary;
|
||||||
attempt: any; // QuizAttempt
|
attempt: QuizAttempt;
|
||||||
onRetake: (missedIds: string[]) => void;
|
onRetake: (missedIds: string[]) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ReviewItem {
|
||||||
|
question: QuizQuestion;
|
||||||
|
score: number;
|
||||||
|
selections: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
||||||
let answers: Record<string, string[]> = {};
|
let answers: Record<string, string[]> = {};
|
||||||
try {
|
try {
|
||||||
|
|
@ -19,16 +26,16 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Determine non-full-credit questions for review list
|
// Determine non-full-credit questions for review list
|
||||||
const reviewItems: any[] = [];
|
const reviewItems: ReviewItem[] = [];
|
||||||
const missedIds: string[] = [];
|
const missedIds: string[] = [];
|
||||||
|
|
||||||
const answeredIds = Object.keys(answers);
|
const answeredIds = Object.keys(answers);
|
||||||
const questionsToReview = quiz.questions.filter((q: any) => answeredIds.includes(q.id));
|
const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id));
|
||||||
|
|
||||||
questionsToReview.forEach((q: any) => {
|
questionsToReview.forEach((q) => {
|
||||||
const formattedQ = {
|
const formattedQ = {
|
||||||
id: q.id,
|
id: q.id,
|
||||||
type: q.type,
|
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||||
options: q.options,
|
options: q.options,
|
||||||
};
|
};
|
||||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||||
|
|
@ -92,7 +99,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const allIds = quiz.questions.map((q: any) => q.id).sort(() => Math.random() - 0.5);
|
const allIds = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||||
onRetake(allIds);
|
onRetake(allIds);
|
||||||
}}
|
}}
|
||||||
className="px-6 py-2.5 rounded-lg border border-primary text-primary font-medium hover:bg-primary/5 transition-all duration-200 cursor-pointer"
|
className="px-6 py-2.5 rounded-lg border border-primary text-primary font-medium hover:bg-primary/5 transition-all duration-200 cursor-pointer"
|
||||||
|
|
@ -105,7 +112,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
{/* Category Breakdown */}
|
{/* Category Breakdown */}
|
||||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
||||||
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
|
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
|
||||||
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
|
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Review List */}
|
{/* Review List */}
|
||||||
|
|
@ -113,7 +120,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
|
<h3 className="text-xl font-bold text-text-heading px-2">Areas for Review</h3>
|
||||||
|
|
||||||
{reviewItems.map((item, idx) => {
|
{reviewItems.map((item) => {
|
||||||
const q = item.question;
|
const q = item.question;
|
||||||
const sels = item.selections;
|
const sels = item.selections;
|
||||||
return (
|
return (
|
||||||
|
|
@ -128,7 +135,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 mb-6">
|
<div className="space-y-2 mb-6">
|
||||||
{q.options.map((opt: any) => {
|
{q.options.map((opt) => {
|
||||||
const isSelected = sels.includes(opt.id);
|
const isSelected = sels.includes(opt.id);
|
||||||
let stateClass = "cursor-default opacity-80";
|
let stateClass = "cursor-default opacity-80";
|
||||||
let icon = null;
|
let icon = null;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm";
|
||||||
import { scoreQuestion } from "@/lib/scoring";
|
import { scoreQuestion } from "@/lib/scoring";
|
||||||
import { QuizResults } from "./QuizResults";
|
import { QuizResults } from "./QuizResults";
|
||||||
import { recordStudyActivity } from "@/lib/activityClient";
|
import { recordStudyActivity } from "@/lib/activityClient";
|
||||||
|
import type { QuizAttempt } from "@/types/study";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -48,7 +49,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
||||||
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
|
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [resultsData, setResultsData] = useState<any | null>(null);
|
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Auto-hide toast
|
// Auto-hide toast
|
||||||
|
|
|
||||||
11
src/lib/validation/reorderSchemas.ts
Normal file
11
src/lib/validation/reorderSchemas.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const reorderItemSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
sortOrder: z.number().int(),
|
||||||
|
groupId: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reorderRequestSchema = z.object({
|
||||||
|
items: z.array(reorderItemSchema),
|
||||||
|
});
|
||||||
65
src/types/study.ts
Normal file
65
src/types/study.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
export interface ReorderItem {
|
||||||
|
id: string;
|
||||||
|
sortOrder: number;
|
||||||
|
groupId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizOption {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizQuestion {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
prompt: string;
|
||||||
|
rationale: string;
|
||||||
|
category: string;
|
||||||
|
options: QuizOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
questions: QuizQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizAttempt {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
maxScore: number;
|
||||||
|
answersJson: string;
|
||||||
|
isPartialRetake: boolean;
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedClass {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedCard {
|
||||||
|
id: string;
|
||||||
|
front: string;
|
||||||
|
back: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedStudyItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
class: SharedClass;
|
||||||
|
cards?: SharedCard[];
|
||||||
|
questions?: QuizQuestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedGroupData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
class: SharedClass;
|
||||||
|
decks: SharedStudyItem[];
|
||||||
|
quizSets: SharedStudyItem[];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue