Initial working commit
Some checks failed
Automated Container Build / build-and-push (push) Failing after 7s

This commit is contained in:
Elijah 2026-06-27 19:19:38 -07:00
parent 666ceb7325
commit b7ce314f01
105 changed files with 35510 additions and 11 deletions

View file

@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
interface CreateTabProps {
classId: string;
onCreated: () => void;
}
export function CreateTab({ classId, onCreated }: CreateTabProps) {
const [deckName, setDeckName] = useState("");
const [description, setDescription] = useState("");
const [cards, setCards] = useState([
{ id: "1", front: "", back: "" },
{ id: "2", front: "", back: "" },
{ id: "3", front: "", back: "" },
]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function addCard() {
setCards([
...cards,
{ id: Date.now().toString(), front: "", back: "" },
]);
}
function removeCard(id: string) {
if (cards.length <= 1) return;
setCards(cards.filter((c) => c.id !== id));
}
function updateCard(id: string, field: "front" | "back", value: string) {
setCards(
cards.map((c) => (c.id === id ? { ...c, [field]: value } : c))
);
}
async function handleCreate() {
setError(null);
if (!deckName.trim()) {
setError("Deck title is required.");
return;
}
const validCards = cards.filter((c) => c.front.trim() && c.back.trim());
if (validCards.length === 0) {
setError("Please add at least one complete card.");
return;
}
setSaving(true);
try {
const res = await fetch("/api/decks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
name: deckName,
data: {
type: "flashcards",
deckName: deckName,
description: description || undefined,
cards: validCards.map((c) => ({
front: c.front,
back: c.back,
})),
},
}),
});
if (!res.ok) {
throw new Error("Failed to create deck");
}
onCreated();
} catch (err: any) {
setError(err.message);
} finally {
setSaving(false);
}
}
return (
<div className="space-y-6">
{error && (
<div className="p-3 bg-error-bg text-error rounded-lg text-sm">
{error}
</div>
)}
{/* Deck Metadata */}
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-heading mb-1">
Title
</label>
<input
type="text"
value={deckName}
onChange={(e) => setDeckName(e.target.value)}
placeholder="Enter a title, e.g. Biology Ch. 1"
className="w-full px-4 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-heading mb-1">
Description (optional)
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full px-4 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
</div>
</div>
{/* Cards */}
<div className="space-y-4">
{cards.map((card, index) => (
<div
key={card.id}
className="bg-bg-surface-alt rounded-xl border border-border-light overflow-hidden"
>
<div className="flex items-center justify-between px-4 py-2 border-b border-border-light bg-bg-base/50">
<span className="font-semibold text-text-muted">{index + 1}</span>
<button
onClick={() => removeCard(card.id)}
disabled={cards.length <= 1}
className="p-1.5 text-text-muted hover:text-error transition-colors disabled:opacity-30 cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<input
type="text"
value={card.front}
onChange={(e) => updateCard(card.id, "front", e.target.value)}
placeholder="Enter term"
className="w-full px-0 py-2 bg-transparent border-0 border-b-2 border-border-light focus:border-primary focus:ring-0 text-sm transition-colors outline-none"
/>
<span className="block mt-1 text-[10px] font-semibold tracking-wider text-text-muted uppercase">
Term
</span>
</div>
<div>
<input
type="text"
value={card.back}
onChange={(e) => updateCard(card.id, "back", e.target.value)}
placeholder="Enter definition"
className="w-full px-0 py-2 bg-transparent border-0 border-b-2 border-border-light focus:border-primary focus:ring-0 text-sm transition-colors outline-none"
/>
<span className="block mt-1 text-[10px] font-semibold tracking-wider text-text-muted uppercase">
Definition
</span>
</div>
</div>
</div>
))}
</div>
<div className="flex flex-col items-center gap-4 pt-4">
<button
onClick={addCard}
className="px-6 py-2.5 rounded-full bg-bg-surface-alt border border-border-light text-sm font-medium text-text-heading hover:bg-bg-callout transition-colors cursor-pointer"
>
+ Add a card
</button>
<button
onClick={handleCreate}
disabled={saving}
className="w-full md:w-auto px-8 py-3 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-colors cursor-pointer disabled:opacity-50"
>
{saving ? "Creating..." : "Create"}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
"use client";
import { useState, useEffect } from "react";
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" }) {
const [instructions, setInstructions] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
fetch(`/api/settings/llm-instructions?type=${importType}`)
.then((res) => res.json())
.then((data) => setInstructions(data.value))
.finally(() => setLoading(false));
}, [importType]);
async function handleSave() {
setSaving(true);
try {
await fetch(`/api/settings/llm-instructions?type=${importType}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: instructions }),
});
} finally {
setSaving(false);
}
}
async function handleReset() {
if (!confirm("Reset to default instructions?")) return;
setSaving(true);
try {
const res = await fetch(`/api/settings/llm-instructions?type=${importType}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: "__RESET__" }),
});
const data = await res.json();
setInstructions(data.value);
} finally {
setSaving(false);
}
}
async function handleCopy() {
await navigator.clipboard.writeText(instructions);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (loading) {
return <div className="animate-subtle-pulse text-text-muted">Loading instructions...</div>;
}
return (
<div className="space-y-4">
<p className="text-sm text-text-secondary">
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
</p>
<textarea
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
rows={14}
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
/>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
>
{copied ? (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copied!
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Copy to Clipboard
</>
)}
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 rounded-lg border border-border text-sm text-text-secondary hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer disabled:opacity-50"
>
{saving ? "Saving..." : "Save Changes"}
</button>
<button
onClick={handleReset}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm text-text-muted hover:text-error hover:bg-error-bg transition-all duration-200 cursor-pointer disabled:opacity-50"
>
Reset to Default
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
"use client";
import { useState } from "react";
import { GenerateTab } from "./GenerateTab";
import { ImportTab } from "./ImportTab";
import { CreateTab } from "./CreateTab";
interface ImportModalProps {
classId: string;
importType: "flashcards" | "quizzes";
onClose: () => void;
onImported: () => void;
}
export function ImportModal({
classId,
importType,
onClose,
onImported,
}: ImportModalProps) {
const [activeTab, setActiveTab] = useState<"generate" | "import" | "create">("import");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-fade-in"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-2xl max-h-[90vh] bg-bg-surface rounded-2xl shadow-[var(--shadow-modal)] animate-slide-up flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-0">
<h2 className="text-lg font-semibold text-text-heading">
Import {importType === "flashcards" ? "Flashcard Deck" : "Quiz"}
</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Tabs */}
<div className="px-6 pt-4">
<div className="flex gap-0 border-b border-border-light">
<button
onClick={() => setActiveTab("generate")}
className={`relative px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
activeTab === "generate"
? "text-primary"
: "text-text-muted hover:text-text-heading"
}`}
>
Generate
{activeTab === "generate" && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
)}
</button>
<button
onClick={() => setActiveTab("import")}
className={`relative px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
activeTab === "import"
? "text-primary"
: "text-text-muted hover:text-text-heading"
}`}
>
Import
{activeTab === "import" && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
)}
</button>
{importType === "flashcards" && (
<button
onClick={() => setActiveTab("create")}
className={`relative px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
activeTab === "create"
? "text-primary"
: "text-text-muted hover:text-text-heading"
}`}
>
Create
{activeTab === "create" && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full" />
)}
</button>
)}
</div>
</div>
{/* Tab content */}
<div className="flex-1 overflow-y-auto p-6 md:p-8">
{activeTab === "generate" && <GenerateTab importType={importType} />}
{activeTab === "import" && (
<ImportTab
classId={classId}
importType={importType}
onImported={onImported}
/>
)}
{activeTab === "create" && importType === "flashcards" && (
<CreateTab classId={classId} onCreated={onImported} />
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,223 @@
"use client";
import { useState } from "react";
import { parseAndRepairJson } from "@/lib/jsonRepair";
import {
flashcardImportSchema,
quizImportSchema,
} from "@/lib/validation/importSchemas";
interface ImportTabProps {
classId: string;
importType: "flashcards" | "quizzes";
onImported: () => void;
}
interface PreviewData {
type: string;
name: string;
description?: string;
itemCount: number;
categories?: string[];
wasRepaired: boolean;
data: unknown;
}
export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
const [jsonText, setJsonText] = useState("");
const [name, setName] = useState("");
const [preview, setPreview] = useState<PreviewData | null>(null);
const [error, setError] = useState("");
const [importing, setImporting] = useState(false);
function handleJsonChange(text: string) {
setJsonText(text);
setError("");
setPreview(null);
if (!text.trim()) return;
// Run the parse → repair → validate pipeline
const result = parseAndRepairJson(text);
if (!result.success) {
setError(result.error || "Failed to parse JSON");
return;
}
// Validate with Zod
const schema =
importType === "flashcards" ? flashcardImportSchema : quizImportSchema;
const validated = schema.safeParse(result.data);
if (!validated.success) {
const issues = validated.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("\n");
setError(`Validation errors:\n${issues}`);
return;
}
// Build preview
const data = validated.data;
if (data.type === "flashcards") {
setPreview({
type: "flashcards",
name: data.deckName,
description: data.description,
itemCount: data.cards.length,
wasRepaired: result.wasRepaired,
data: data,
});
if (!name) setName(data.deckName);
} else {
const categories = [
...new Set(data.questions.map((q) => q.category.trim().toLowerCase())),
];
setPreview({
type: "quiz",
name: data.quizName,
description: data.description,
itemCount: data.questions.length,
categories,
wasRepaired: result.wasRepaired,
data: data,
});
if (!name) setName(data.quizName);
}
}
async function handleImport() {
if (!preview || !name.trim()) return;
setImporting(true);
try {
const endpoint =
importType === "flashcards" ? "/api/decks" : "/api/quizzes";
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
classId,
data: preview.data,
name: name.trim(),
}),
});
if (!res.ok) {
const err = await res.json();
setError(err.error || "Import failed");
return;
}
onImported();
} catch {
setError("Network error during import");
} finally {
setImporting(false);
}
}
return (
<div className="space-y-4">
{/* Name field */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={
importType === "flashcards" ? "Deck name..." : "Quiz name..."
}
className="w-full px-3.5 py-2.5 rounded-lg border border-border bg-bg-surface-alt/50 text-text-heading placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200"
/>
</div>
{/* JSON paste area */}
<div>
<label className="block text-sm font-medium text-text-secondary mb-1.5">
Paste JSON
</label>
<textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
rows={10}
placeholder="Paste the JSON output from your LLM here..."
className="w-full px-4 py-3 rounded-lg border border-border bg-bg-surface-alt/50 text-sm text-text-body font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-all duration-200 resize-y"
/>
</div>
{/* Error display */}
{error && (
<div className="bg-error-bg border border-error/20 rounded-lg px-4 py-3">
<div className="flex items-start gap-2">
<svg
className="w-4 h-4 text-error flex-shrink-0 mt-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<pre className="text-sm text-error whitespace-pre-wrap font-mono">
{error}
</pre>
</div>
</div>
)}
{/* Preview */}
{preview && (
<div className="bg-bg-callout border border-primary/10 rounded-lg px-4 py-4 space-y-3">
{preview.wasRepaired && (
<div className="flex items-center gap-2 text-xs text-primary bg-primary/5 rounded px-2.5 py-1.5">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
JSON was auto-repaired (minor syntax fixes)
</div>
)}
<div className="text-sm text-text-heading font-medium">
Preview: {preview.itemCount}{" "}
{preview.type === "flashcards" ? "cards" : "questions"}
</div>
{preview.description && (
<p className="text-sm text-text-secondary">{preview.description}</p>
)}
{preview.categories && preview.categories.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{preview.categories.map((cat) => (
<span
key={cat}
className="inline-flex px-2.5 py-1 rounded-full bg-badge-bg text-badge-text text-xs font-medium"
>
{cat}
</span>
))}
</div>
)}
</div>
)}
{/* Import button */}
<button
onClick={handleImport}
disabled={!preview || !name.trim() || importing}
className="w-full py-2.5 px-4 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 cursor-pointer"
>
{importing ? "Importing..." : "Confirm Import"}
</button>
</div>
);
}