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,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReorderItem } from "@/types/study";
|
||||
|
||||
interface DeckItem {
|
||||
id: string;
|
||||
|
|
@ -65,7 +66,14 @@ function getProgressLabel(deck: DeckItem) {
|
|||
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 style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -314,8 +322,6 @@ export default function FlashcardsPage() {
|
|||
if (!activeDeck) return;
|
||||
|
||||
let targetGroupId: string | null = null;
|
||||
let targetIndex = 0;
|
||||
|
||||
const overContainerId = over.data.current?.sortable?.containerId;
|
||||
if (overContainerId) {
|
||||
targetGroupId = overContainerId === "uncategorized" ? null : overContainerId;
|
||||
|
|
@ -344,7 +350,7 @@ export default function FlashcardsPage() {
|
|||
}
|
||||
|
||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
||||
const updates: any[] = [];
|
||||
const updates: ReorderItem[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(d => d.groupId === gId);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { ReorderItem } from "@/types/study";
|
||||
|
||||
interface QuizItem {
|
||||
id: string;
|
||||
|
|
@ -51,7 +52,14 @@ interface 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 style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -312,8 +320,6 @@ export default function QuizzesPage() {
|
|||
if (!activeQuiz) return;
|
||||
|
||||
let targetGroupId: string | null = null;
|
||||
let targetIndex = 0;
|
||||
|
||||
// Check if over a group container directly
|
||||
const overContainerId = over.data.current?.sortable?.containerId;
|
||||
if (overContainerId) {
|
||||
|
|
@ -345,7 +351,7 @@ export default function QuizzesPage() {
|
|||
|
||||
// Re-calculate sortOrder for the affected groups to persist
|
||||
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
||||
const updates: any[] = [];
|
||||
const updates: ReorderItem[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(q => q.groupId === gId);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { items } = body;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { items } = parsed.data;
|
||||
|
||||
// items should be an array of { id, sortOrder, groupId }
|
||||
// Using a transaction to perform all updates
|
||||
await prisma.$transaction(
|
||||
items.map((item: any) =>
|
||||
items.map((item) =>
|
||||
prisma.deck.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
|
|
@ -10,7 +11,7 @@ export async function PATCH(
|
|||
const body = await request.json();
|
||||
const { name, sortOrder } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
const updateData: Prisma.MaterialGroupUpdateInput = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
|
@ -14,7 +15,7 @@ export async function GET(request: NextRequest) {
|
|||
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
|
||||
}
|
||||
|
||||
const whereClause: any = { classId };
|
||||
const whereClause: Prisma.MaterialGroupWhereInput = { classId };
|
||||
if (type) {
|
||||
whereClause.type = type;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { items } = body;
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
const parsed = reorderRequestSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { items } = parsed.data;
|
||||
|
||||
// items should be an array of { id, sortOrder, groupId }
|
||||
// Using a transaction to perform all updates
|
||||
await prisma.$transaction(
|
||||
items.map((item: any) =>
|
||||
items.map((item) =>
|
||||
prisma.quizSet.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { useState, useEffect } from "react";
|
|||
|
||||
import Link from "next/link";
|
||||
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 router = useRouter();
|
||||
|
||||
|
|
@ -18,7 +19,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
|
|||
if (typeof window === 'undefined') return;
|
||||
|
||||
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 saved = localStorage.getItem(key);
|
||||
if (saved) {
|
||||
|
|
@ -71,7 +72,7 @@ export function SharedGroupViewer({ data }: { data: any }) {
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{items.map((item: any) => (
|
||||
{items.map((item) => (
|
||||
<div
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import { usePathname } from "next/navigation";
|
|||
import { FlashcardViewer } from "@/components/flashcards/FlashcardViewer";
|
||||
import { QuizViewer } from "@/components/quizzes/QuizViewer";
|
||||
import { CardList } from "@/components/flashcards/CardList";
|
||||
import type { SharedStudyItem } from "@/types/study";
|
||||
|
||||
interface SharedViewerProps {
|
||||
type: string;
|
||||
data: any;
|
||||
type: "flashcards" | "quizzes";
|
||||
data: SharedStudyItem;
|
||||
groupMode?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
|||
|
||||
const [showTopics, setShowTopics] = useState(false);
|
||||
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 (
|
||||
|
|
@ -137,18 +138,20 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
|||
view === "study" ? (
|
||||
<FlashcardViewer
|
||||
key={restartKey}
|
||||
cards={data.cards}
|
||||
cards={data.cards ?? []}
|
||||
deckId={data.id}
|
||||
isShared={true}
|
||||
/>
|
||||
) : (
|
||||
<CardList cards={data.cards} />
|
||||
<CardList cards={data.cards ?? []} />
|
||||
)
|
||||
) : (
|
||||
<QuizViewer
|
||||
key={restartKey}
|
||||
quiz={{
|
||||
...data,
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
questions: data.questions ?? [],
|
||||
progress: [], // Start fresh since it's shared
|
||||
}}
|
||||
retakeIds={null}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
|||
import Link from "next/link";
|
||||
import { SharedViewer } from "./SharedViewer";
|
||||
import { SharedGroupViewer } from "./SharedGroupViewer";
|
||||
import type { SharedGroupData, SharedStudyItem } from "@/types/study";
|
||||
|
||||
type SharedContentType = "flashcards" | "quizzes";
|
||||
|
||||
interface SharedPageProps {
|
||||
params: Promise<{
|
||||
|
|
@ -48,17 +51,17 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
notFound();
|
||||
}
|
||||
|
||||
let targetData: any = null;
|
||||
let targetType = type;
|
||||
let targetData: SharedStudyItem | SharedGroupData | null = null;
|
||||
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
|
||||
|
||||
if (type === "groups" && link.group) {
|
||||
if (itemId) {
|
||||
// Find the specific item inside the group
|
||||
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";
|
||||
} else {
|
||||
targetData = link.group.quizSets.find(q => q.id === itemId);
|
||||
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
|
||||
targetType = "quizzes";
|
||||
}
|
||||
if (!targetData) notFound();
|
||||
|
|
@ -69,6 +72,8 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
||||
}
|
||||
|
||||
if (!targetData) notFound();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg-base flex flex-col">
|
||||
{/* Read-only Header */}
|
||||
|
|
@ -105,11 +110,11 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
<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">
|
||||
{type === "groups" && !itemId ? (
|
||||
<SharedGroupViewer data={targetData} />
|
||||
<SharedGroupViewer data={targetData as SharedGroupData} />
|
||||
) : (
|
||||
<SharedViewer
|
||||
type={targetType}
|
||||
data={targetData}
|
||||
data={targetData as SharedStudyItem}
|
||||
groupMode={type === "groups"}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
}
|
||||
|
||||
onCreated();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create deck");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { QuizResults } from "./QuizResults";
|
||||
import type { QuizAttempt, QuizSummary } from "@/types/study";
|
||||
|
||||
interface AttemptHistoryProps {
|
||||
quiz: any;
|
||||
attempts: any[];
|
||||
quiz: QuizSummary;
|
||||
attempts: QuizAttempt[];
|
||||
onRetake: (missedIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function AttemptHistory({ quiz, attempts, onRetake }: AttemptHistoryProps) {
|
||||
const [selectedAttempt, setSelectedAttempt] = useState<any | null>(null);
|
||||
const [selectedAttempt, setSelectedAttempt] = useState<QuizAttempt | null>(null);
|
||||
|
||||
if (selectedAttempt) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,20 +2,20 @@
|
|||
|
||||
import { Fragment } from "react";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import type { QuizSummary } from "@/types/study";
|
||||
|
||||
interface CategoryBreakdownProps {
|
||||
quiz: any;
|
||||
attempt: any;
|
||||
quiz: QuizSummary;
|
||||
answeredIds: string[];
|
||||
answers: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: CategoryBreakdownProps) {
|
||||
export function CategoryBreakdown({ quiz, answeredIds, answers }: CategoryBreakdownProps) {
|
||||
// Aggregate stats per category
|
||||
const stats: Record<string, { earned: number; possible: number }> = {};
|
||||
|
||||
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;
|
||||
|
||||
const cat = q.category.trim().toLowerCase();
|
||||
|
|
@ -23,7 +23,11 @@ export function CategoryBreakdown({ quiz, attempt, answeredIds, answers }: Categ
|
|||
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] || []);
|
||||
|
||||
stats[cat].earned += points;
|
||||
|
|
|
|||
|
|
@ -4,14 +4,21 @@ import ReactMarkdown from "react-markdown";
|
|||
import remarkGfm from "remark-gfm";
|
||||
import { CategoryBreakdown } from "./CategoryBreakdown";
|
||||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
|
||||
|
||||
interface QuizResultsProps {
|
||||
quiz: any; // QuizData
|
||||
attempt: any; // QuizAttempt
|
||||
quiz: QuizSummary;
|
||||
attempt: QuizAttempt;
|
||||
onRetake: (missedIds: string[]) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
interface ReviewItem {
|
||||
question: QuizQuestion;
|
||||
score: number;
|
||||
selections: string[];
|
||||
}
|
||||
|
||||
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
||||
let answers: Record<string, string[]> = {};
|
||||
try {
|
||||
|
|
@ -19,16 +26,16 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
} catch {}
|
||||
|
||||
// Determine non-full-credit questions for review list
|
||||
const reviewItems: any[] = [];
|
||||
const reviewItems: ReviewItem[] = [];
|
||||
const missedIds: string[] = [];
|
||||
|
||||
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 = {
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options,
|
||||
};
|
||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
|
|
@ -92,7 +99,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
|
||||
<button
|
||||
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);
|
||||
}}
|
||||
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 */}
|
||||
<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>
|
||||
<CategoryBreakdown quiz={quiz} attempt={attempt} answeredIds={answeredIds} answers={answers} />
|
||||
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
|
||||
</div>
|
||||
|
||||
{/* Review List */}
|
||||
|
|
@ -113,7 +120,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
<div className="space-y-6">
|
||||
<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 sels = item.selections;
|
||||
return (
|
||||
|
|
@ -128,7 +135,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
</div>
|
||||
|
||||
<div className="space-y-2 mb-6">
|
||||
{q.options.map((opt: any) => {
|
||||
{q.options.map((opt) => {
|
||||
const isSelected = sels.includes(opt.id);
|
||||
let stateClass = "cursor-default opacity-80";
|
||||
let icon = null;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm";
|
|||
import { scoreQuestion } from "@/lib/scoring";
|
||||
import { QuizResults } from "./QuizResults";
|
||||
import { recordStudyActivity } from "@/lib/activityClient";
|
||||
import type { QuizAttempt } from "@/types/study";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
|
|
@ -48,7 +49,7 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
const [shuffledOptions, setShuffledOptions] = useState<Record<string, Option[]>>({});
|
||||
|
||||
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);
|
||||
|
||||
// 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