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
44 changes: 43 additions & 1 deletion LearningPlatform/app/(public)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,33 @@ import { Home } from 'lucide-react';
import { cn } from '@/lib/utils';
import { heroMarketingAuthInputClass, heroMarketingGlassText } from '@/lib/hero-marketing-classes';

const INFRA_STATUS_CACHE_TTL_MS = 30_000;
let lastInfraStatusCheckAt = 0;
let lastInfraStatusMessage: string | null = null;

async function getInfraStatusMessage(): Promise<string | null> {
const now = Date.now();
if (now - lastInfraStatusCheckAt < INFRA_STATUS_CACHE_TTL_MS) {
return lastInfraStatusMessage;
}

try {
const res = await fetch('/api/healthz', { cache: 'no-store' });
const message =
res.ok || res.status < 500
? null
: 'Cannot connect to the server/database right now. Please try again in a moment.';
lastInfraStatusCheckAt = now;
lastInfraStatusMessage = message;
return message;
} catch {
const message = 'Cannot reach the backend right now. Check your connection and try again.';
lastInfraStatusCheckAt = now;
lastInfraStatusMessage = message;
return message;
}
}

function LoginForm() {
const isDark = useIsDark();
const router = useRouter();
Expand All @@ -40,14 +67,29 @@ function LoginForm() {
setIsLoading(true);

try {
// Fast-fail before credential check so infra outages are not mislabeled
// as "invalid email/password".
const precheckInfraMessage = await getInfraStatusMessage();
if (precheckInfraMessage) {
setError(precheckInfraMessage);
return;
}

const result = await signIn('credentials', {
email,
password,
redirect: false,
});

if (result?.error) {
setError('Invalid email or password');
const infraMessage = await getInfraStatusMessage();
if (infraMessage) {
setError(infraMessage);
} else if (result.error.toLowerCase().includes('too many')) {
setError('Too many login attempts. Please try again later.');
} else {
setError('Invalid email or password');
}
} else {
router.push(safeCallbackUrl);
router.refresh();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { FlashcardRichText } from '@/components/student/flashcard-markdown'
import { FlashcardAssistantFab } from '@/components/student/flashcard-assistant-fab'

// --- Types ---

Expand Down Expand Up @@ -79,6 +80,14 @@ function humanizeSlug(slug: string): string {
.join(' ')
}

function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (target.isContentEditable) return true
return Boolean(target.closest('[contenteditable="true"], [role="textbox"]'))
}

function studyQuery(
mode: string,
tagSlug: string,
Expand Down Expand Up @@ -280,6 +289,8 @@ function StudyPage() {

useEffect(() => {
function onKey(e: KeyboardEvent) {
if (isTypingTarget(e.target)) return

if (e.key === ' ' || e.key === 'Enter') {
if (phase === 'question') {
e.preventDefault()
Expand Down Expand Up @@ -747,6 +758,13 @@ function StudyPage() {
)}
</main>

<FlashcardAssistantFab
key={card?.id ?? 'no-card'}
enabled={Boolean(card)}
cardFront={card?.question ?? ''}
cardBack={card?.answer ?? ''}
/>

<style>{`
.katex { font-size: 1.1em; }
.katex-display { max-width: 100%; overflow-x: auto; overflow-y: hidden; padding: 0.25rem 0; }
Expand Down
18 changes: 14 additions & 4 deletions LearningPlatform/app/actions/course-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,20 @@ export async function getAllCourseProgress() {
throw new Error('Unauthorized')
}

return prisma.courseProgress.findMany({
where: { userId: session.user.id, archivedAt: null },
orderBy: { lastActivityAt: 'desc' },
})
// Backward-compatible: some local DB/schema snapshots do not include archivedAt yet.
const whereWithArchiveFilter: Record<string, unknown> = { userId: session.user.id }
whereWithArchiveFilter.archivedAt = null
try {
return await prisma.courseProgress.findMany({
where: whereWithArchiveFilter as never,
orderBy: { lastActivityAt: 'desc' },
})
} catch {
return prisma.courseProgress.findMany({
where: { userId: session.user.id },
orderBy: { lastActivityAt: 'desc' },
})
}
}

/**
Expand Down
84 changes: 84 additions & 0 deletions LearningPlatform/app/api/flashcard-assistant/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import Anthropic from '@anthropic-ai/sdk'
import type { TextBlock } from '@anthropic-ai/sdk/resources/messages'
import { requireProUser } from '@/lib/auth-helpers'
import { checkRateLimit } from '@/lib/rate-limit'
import { FLASHCARD_ASSISTANT_SYSTEM_PROMPT } from '@/lib/flashcard-assistant-prompt'
import {
type LessonAssistantModelPreset,
resolveLessonAssistantModelId,
} from '@/lib/lesson-assistant-models'

const bodySchema = z.object({
question: z.string().min(1).max(4000),
cardFront: z.string().min(1).max(12000),
cardBack: z.string().min(1).max(12000),
modelPreset: z.enum(['haiku', 'sonnet']).optional(),
})

export async function POST(req: Request) {
try {
const user = await requireProUser()
const rate = await checkRateLimit({
request: req,
key: 'flashcard-assistant',
limit: 20,
windowMs: 60_000,
identityFallback: user.id,
})
if (!rate.allowed) {
const sec = Math.max(1, Math.ceil(rate.retryAfter / 1000))
return NextResponse.json(
{ error: 'Too many requests', retryAfterMs: rate.retryAfter },
{ status: 429, headers: { 'Retry-After': String(sec) } },
)
}

const payload = await req.json().catch(() => null)
const parsed = bodySchema.safeParse(payload)
if (!parsed.success) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}

const { question, cardFront, cardBack, modelPreset: rawPreset } = parsed.data
const modelPreset: LessonAssistantModelPreset = rawPreset ?? 'haiku'
const model = resolveLessonAssistantModelId(modelPreset)

const apiKey = process.env.ANTHROPIC_API_KEY?.trim()
if (!apiKey) {
return NextResponse.json({ error: 'Flashcard assistant is not configured' }, { status: 503 })
}

let userMsg = `--- Flashcard front ---\n${cardFront.trim()}\n\n--- Flashcard back ---\n${cardBack.trim()}\n\n`
userMsg += `--- Question ---\n${question.trim()}`

const client = new Anthropic({ apiKey })
const resp = await client.messages.create({
model,
max_tokens: 4096,
system: FLASHCARD_ASSISTANT_SYSTEM_PROMPT,
messages: [{ role: 'user', content: userMsg }],
})

const answer = resp.content
.filter((b): b is TextBlock => b.type === 'text')
.map((b) => b.text)
.join('\n')
.trim()

return NextResponse.json({ answer }, { headers: { 'Cache-Control': 'private, no-store' } })
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Unauthorized') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (error.message === 'Forbidden') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
console.error('[POST /api/flashcard-assistant]', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

15 changes: 15 additions & 0 deletions LearningPlatform/app/api/healthz/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

/**
* Lightweight public readiness probe for login precheck.
* Returns only ok/error without exposing internal diagnostics.
*/
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`
return NextResponse.json({ ok: true }, { status: 200 })
} catch {
return NextResponse.json({ ok: false }, { status: 503 })
}
}
Loading
Loading