Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ node_modules/
.env*.local
.DS_Store
.vercel
*.tsbuildinfo
132 changes: 132 additions & 0 deletions app/(dashboard)/essay-grader/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { createServerClient } from "@supabase/ssr"
import { cookies } from "next/headers"
import { notFound } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, XCircle, Lightbulb } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"

export default async function EssayEvaluationPage({
params
}: {
params: { id: string }
}) {
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name) { return cookieStore.get(name)?.value },
},
}
)

const { data: { user } } = await supabase.auth.getUser()

if (!user) {
return notFound()
}

const { data: evaluation, error } = await supabase
.from('essay_evaluations')
.select('*')
.eq('id', params.id)
.eq('user_id', user.id)
.single()

if (error || !evaluation) {
return notFound()
}

const feedback = evaluation.feedback

return (
<div className="container mx-auto py-8 max-w-4xl space-y-8">
<div className="flex items-center gap-4">
<Link href="/essay-grader">
<Button variant="outline" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">Evaluation Results</h1>
<p className="text-muted-foreground mt-1">
Graded on {new Date(evaluation.created_at).toLocaleDateString()}
</p>
</div>
</div>

<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="md:col-span-1 bg-primary/5 border-primary/20 flex flex-col items-center justify-center py-8">
<CardHeader className="text-center pb-2">
<CardTitle className="text-sm font-medium uppercase text-muted-foreground">Estimated Grade</CardTitle>
</CardHeader>
<CardContent>
<div className="text-6xl font-black text-primary">{feedback.grade}</div>
</CardContent>
</Card>

<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Strengths & Weaknesses</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="font-semibold flex items-center text-green-600 mb-2">
<CheckCircle2 className="mr-2 h-4 w-4" /> Pros
</h3>
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
{feedback.pros.map((pro: string, i: number) => (
<li key={i}>{pro}</li>
))}
</ul>
</div>
<div>
<h3 className="font-semibold flex items-center text-red-500 mb-2">
<XCircle className="mr-2 h-4 w-4" /> Cons
</h3>
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
{feedback.cons.map((con: string, i: number) => (
<li key={i}>{con}</li>
))}
</ul>
</div>
</CardContent>
</Card>
</div>

<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Lightbulb className="mr-2 h-5 w-5 text-amber-500" /> Actionable Suggestions
</CardTitle>
<CardDescription>Specific feedback to improve your essay.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{feedback.suggestions.map((suggestion: any, i: number) => (
<div key={i} className="bg-muted p-4 rounded-lg space-y-3 border border-border/50">
<blockquote className="border-l-4 border-primary pl-4 italic text-sm text-muted-foreground">
"{suggestion.quote}"
</blockquote>
<p className="text-sm font-medium">
{suggestion.comment}
</p>
</div>
))}
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Original Essay</CardTitle>
</CardHeader>
<CardContent>
<div className="p-4 bg-muted/50 rounded-lg whitespace-pre-wrap text-sm">
{evaluation.content}
</div>
</CardContent>
</Card>
</div>
)
}
166 changes: 166 additions & 0 deletions app/(dashboard)/essay-grader/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"use client"

import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { createBrowserClient } from "@supabase/ssr"
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Loader2, FileSignature, CheckCircle } from "lucide-react"

export default function EssayGraderPage() {
const router = useRouter()
const [content, setContent] = useState("")
const [rubric, setRubric] = useState("")
const [loading, setLoading] = useState(false)
const [evaluations, setEvaluations] = useState<any[]>([])
const [fetching, setFetching] = useState(true)

const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

useEffect(() => {
fetchEvaluations()
}, [])

async function fetchEvaluations() {
setFetching(true)
const { data: { user } } = await supabase.auth.getUser()
if (user) {
const { data, error } = await supabase
.from('essay_evaluations')
.select('id, grade, created_at, content')
.eq('user_id', user.id)
.order('created_at', { ascending: false })

if (!error && data) {
setEvaluations(data)
}
}
setFetching(false)
}

async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!content.trim() || loading) return

setLoading(true)

try {
const res = await fetch("/api/essays/grade", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, rubric }),
})

if (res.ok) {
const data = await res.json()
if (data.id) {
router.push(`/essay-grader/${data.id}`)
}
} else {
const errorData = await res.json()
alert(errorData.error || "Failed to evaluate essay.")
}
} catch (err) {
console.error(err)
alert("Connection error.")
} finally {
setLoading(false)
}
}

return (
<div className="container mx-auto py-8 max-w-5xl space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">AI Essay Grader</h1>
<p className="text-muted-foreground mt-2">
Paste your essay and an optional rubric to receive an instant grade, strengths, weaknesses, and actionable feedback.
</p>
</div>

<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="md:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>New Evaluation</CardTitle>
<CardDescription>Submit a new essay for AI grading.</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Essay Content</label>
<Textarea
placeholder="Paste your essay here..."
className="min-h-[250px]"
value={content}
onChange={(e) => setContent(e.target.value)}
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Grading Rubric / Prompt (Optional)</label>
<Textarea
placeholder="Paste the assignment instructions or grading rubric here..."
className="min-h-[100px]"
value={rubric}
onChange={(e) => setRubric(e.target.value)}
disabled={loading}
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={!content.trim() || loading} className="w-full">
{loading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Evaluating Essay...</>
) : (
<><CheckCircle className="mr-2 h-4 w-4" /> Grade My Essay</>
)}
</Button>
</CardFooter>
</form>
</Card>
</div>

<div className="space-y-4">
<h2 className="text-xl font-semibold">Past Evaluations</h2>
{fetching ? (
<div className="flex justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : evaluations.length === 0 ? (
<Card className="bg-muted/50 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<FileSignature className="h-10 w-10 text-muted-foreground mb-4 opacity-50" />
<p className="text-muted-foreground text-sm max-w-sm">
You haven't graded any essays yet.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{evaluations.map((evalData) => (
<Card key={evalData.id} className="hover:bg-muted/50 transition-colors cursor-pointer group" onClick={() => router.push(`/essay-grader/${evalData.id}`)}>
<CardHeader className="p-4">
<CardTitle className="text-sm font-medium flex items-center justify-between">
<span className="truncate max-w-[150px]">{evalData.content.substring(0, 30)}...</span>
<span className="bg-primary/10 text-primary px-2 py-1 rounded text-xs">
{evalData.grade}
</span>
</CardTitle>
<CardDescription className="text-xs">
{new Date(evalData.created_at).toLocaleDateString()}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
)}
</div>
</div>
</div>
)
}
68 changes: 68 additions & 0 deletions app/api/essays/grade/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server"
import { createServerClient } from "@supabase/ssr"
import { cookies } from "next/headers"
import { gradeEssay } from "@/lib/ai"
import { consumeCredit } from "@/lib/credits"

export const maxDuration = 60 // Extended duration for long essay evaluations

export async function POST(req: NextRequest) {
try {
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name) { return cookieStore.get(name)?.value },
},
}
)

const { data: { user } } = await supabase.auth.getUser()

if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

const creditCheck = await consumeCredit(user.id, "ai_chat")
if (!creditCheck.allowed) {
return NextResponse.json({ error: "Insufficient credits" }, { status: 403 })
}

const { content, rubric } = await req.json()

if (!content || content.trim().length < 50) {
return NextResponse.json({ error: "Essay content is too short." }, { status: 400 })
}

// Call Gemini to grade the essay
const feedback = await gradeEssay(content, rubric)

// Save to database
const { data: evaluation, error: dbError } = await supabase
.from("essay_evaluations")
.insert({
user_id: user.id,
content: content,
rubric: rubric || null,
grade: feedback.grade,
feedback: feedback
})
.select("id")
.single()

if (dbError || !evaluation) {
console.error("[Essay Grader DB Error]", dbError)
return NextResponse.json({ error: "Failed to save evaluation." }, { status: 500 })
}

return NextResponse.json({ id: evaluation.id })
} catch (error: any) {
console.error("[Essay Grader Error]", error)
return NextResponse.json(
{ error: error.message || "Failed to grade essay" },
{ status: 500 }
)
}
}
Loading
Loading