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
22 changes: 21 additions & 1 deletion LearningPlatform/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,27 @@ COPY prisma.config.ts ./
# This avoids running `prisma generate` before the full source (including schema)
# is copied into the image. We'll run `prisma generate` explicitly later when
# the schema file is present.
RUN npm ci --no-audit --no-fund --ignore-scripts
RUN set -eux; \
RETRIES=5; \
COUNT=0; \
until [ "$COUNT" -ge "$RETRIES" ]; do \
npm ci \
--no-audit \
--no-fund \
--ignore-scripts \
--fetch-retries=5 \
--fetch-retry-factor=2 \
--fetch-retry-mintimeout=10000 \
--fetch-retry-maxtimeout=120000 && break || true; \
COUNT=$((COUNT+1)); \
echo "npm ci failed, retrying ($COUNT/$RETRIES)"; \
npm cache verify || true; \
sleep 5; \
done; \
if [ "$COUNT" -ge "$RETRIES" ]; then \
echo "npm ci failed after $RETRIES attempts"; \
exit 1; \
fi

# Copy rest of source and run build
COPY . .
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,15 @@ export default async function LessonPage({
prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
}),
// If the user starts learning this course again, restore it to active.
prisma.courseProgress.updateMany({
where: {
userId: session.user.id,
courseId: String(courseId),
archivedAt: { not: null },
},
data: { archivedAt: null },
}),
])
taskProgressRecords.forEach((tp) => taskProgressMap.set(tp.taskId, tp))
lessonProgress = lessonProgressRecord
Expand Down
16 changes: 15 additions & 1 deletion LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { prisma } from '@/lib/prisma'
import { CourseHeroTitle } from '@/components/courses/course-hero-title'
import { studentGlassCard, studentGlassPill } from '@/lib/student-glass-styles'
import { cn } from '@/lib/utils'
import { ArchiveCourseButton } from '@/components/profile/archive-actions'

/** Same visual as dashboard `StatPill` in `flashcard-section.tsx` (server-safe duplicate). */
function CourseFlashcardStatPill({ label, count }: { label: string; count: number }) {
Expand Down Expand Up @@ -147,10 +148,19 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s

const courseId = String(course.id)
const moduleIds = modulesWithLessons.map((m) => String(m.id))
const mainDeck = await prisma.flashcardDeck.findFirst({
let mainDeck = await prisma.flashcardDeck.findFirst({
where: { courseId, parentDeckId: null },
select: { id: true, name: true, slug: true },
})
if (mainDeck && session?.user?.id) {
const archivedDeck = await prisma.userStandaloneFlashcardDeck.findUnique({
where: { userId_deckId: { userId: session.user.id, deckId: mainDeck.id } },
select: { archivedAt: true },
})
if (archivedDeck?.archivedAt) {
mainDeck = null
}
}

const subdecks = mainDeck
? await prisma.flashcardDeck.findMany({
Expand Down Expand Up @@ -468,6 +478,10 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s
</div>
</section>
)}

<section className="flex justify-center pt-2">
<ArchiveCourseButton courseSlug={slug} />
</section>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { studentGlassCard, studentGlassFooterNavButton, studentGlassPill } from '@/lib/student-glass-styles'
import type { FlashcardDashboardSummary } from '@/lib/flashcards-dashboard-summary'
import { ArchiveDeckButton } from '@/components/profile/archive-actions'

type Stats = { total: number; newCards: number; due: number }

Expand Down Expand Up @@ -419,6 +420,9 @@ export default function StudentFlashcardDeckTreePage() {
)}
</>
)}
<div className="flex justify-center pt-1">
<ArchiveDeckButton deckId={row.deck.id} />
</div>
</CardContent>
</Card>
)
Expand Down
213 changes: 213 additions & 0 deletions LearningPlatform/app/(student)/(shell)/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { auth } from '@/auth'
import { getPayload } from 'payload'
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { Card, CardContent } from '@/components/ui/card'
import { studentGlassCard } from '@/lib/student-glass-styles'
import { cn } from '@/lib/utils'
import { CourseCarousel, type CourseProgressSnapshot } from '@/components/dashboard/course-carousel'
import { FlashcardDeckCarousel } from '@/components/dashboard/flashcard-deck-carousel'
import { UnarchiveCourseButton, UnarchiveDeckButton } from '@/components/profile/archive-actions'

export const dynamic = 'force-dynamic'

type DeckStats = { total: number; newCards: number; due: number }

export default async function ProfilePage() {
const session = await auth()
const userId = session?.user?.id
if (!userId) redirect('/login')

const payload = await getPayload({ config })
const [archivedCourses, archivedDeckEnrollments] = await Promise.all([
prisma.courseProgress.findMany({
where: { userId, archivedAt: { not: null } },
orderBy: { archivedAt: 'desc' },
select: { courseId: true, completedLessons: true, totalLessons: true, progressPercentage: true },
}),
prisma.userStandaloneFlashcardDeck.findMany({
where: { userId, archivedAt: { not: null } },
orderBy: { archivedAt: 'desc' },
select: {
deck: {
select: {
id: true,
name: true,
slug: true,
description: true,
subjectId: true,
courseId: true,
parentDeckId: true,
tags: { select: { name: true } },
},
},
},
}),
])

const archivedCourseIds = archivedCourses.map((c) => c.courseId)
const courseDocs = archivedCourseIds.length
? (await payload.find({
collection: 'courses',
where: { id: { in: archivedCourseIds }, isPublished: { equals: true } },
limit: archivedCourseIds.length,
depth: 1,
})).docs
: []
const courseById = new Map(courseDocs.map((c) => [String(c.id), c]))

const archivedCourseItems = archivedCourseIds
.map((id) => {
const doc = courseById.get(id)
if (!doc) return null
return {
id,
title: doc.title,
slug: doc.slug,
description: doc.description,
coverImage: typeof doc.coverImage === 'object' ? (doc.coverImage as { filename: string; alt?: string }) : null,
level: doc.level,
subject: doc.subject as string | { name?: string } | null,
}
})
.filter((v): v is NonNullable<typeof v> => Boolean(v))

const archivedProgressByCourseId: Record<string, CourseProgressSnapshot> = {}
for (const row of archivedCourses) {
archivedProgressByCourseId[row.courseId] = {
progressPercentage: row.progressPercentage,
completedLessons: row.completedLessons,
totalLessons: row.totalLessons,
hasStarted: row.totalLessons > 0 || row.completedLessons > 0,
}
}

const rootDecks = archivedDeckEnrollments
.map((row) => row.deck)
.filter((d) => d && !d.parentDeckId)
const rootDeckIds = rootDecks.map((d) => d.id)
const allDeckIds = new Set(rootDeckIds)
let layer = [...rootDeckIds]
for (let depth = 0; depth < 16 && layer.length > 0; depth++) {
const kids = await prisma.flashcardDeck.findMany({
where: { parentDeckId: { in: layer } },
select: { id: true },
})
layer = []
for (const k of kids) {
if (!allDeckIds.has(k.id)) {
allDeckIds.add(k.id)
layer.push(k.id)
}
}
}
const statsByRootId = new Map<string, DeckStats>()
for (const id of rootDeckIds) statsByRootId.set(id, { total: 0, newCards: 0, due: 0 })
if (allDeckIds.size > 0) {
const deckRows = await prisma.flashcardDeck.findMany({
where: { id: { in: [...allDeckIds] } },
select: { id: true, parentDeckId: true },
})
const parentById = new Map(deckRows.map((d) => [d.id, d.parentDeckId]))
const rootByDeckId = new Map<string, string>()
for (const d of deckRows) {
let cursor: string | null = d.id
while (cursor) {
const parent: string | null = parentById.get(cursor) ?? null
if (!parent) {
rootByDeckId.set(d.id, cursor)
break
}
cursor = parent
}
}
const flashcards = await prisma.flashcard.findMany({
where: { deckId: { in: [...allDeckIds] } },
select: {
deckId: true,
userProgress: { where: { userId }, select: { state: true, nextReviewAt: true } },
},
})
const now = new Date()
for (const row of flashcards) {
const rootId = rootByDeckId.get(row.deckId)
if (!rootId) continue
const stats = statsByRootId.get(rootId)
if (!stats) continue
stats.total += 1
const progress = row.userProgress[0]
const state = progress?.state ?? 'NEW'
if (state === 'NEW') stats.newCards += 1
else if (progress?.nextReviewAt && progress.nextReviewAt <= now) stats.due += 1
}
}

const archivedDeckRows = rootDecks.map((deck) => ({
id: deck.id,
name: deck.name,
subtitle:
deck.courseId != null
? 'Course-linked deck'
: deck.tags?.length
? deck.tags.map((t) => t.name).join(' · ')
: deck.description ?? '',
openHref:
deck.courseId != null
? '/dashboard/flashcards'
: `/dashboard/flashcards?standaloneDeckSlug=${encodeURIComponent(deck.slug)}`,
stats: statsByRootId.get(deck.id) ?? { total: 0, newCards: 0, due: 0 },
}))

return (
<div className="container mx-auto px-5 py-7 md:px-6 md:py-8">
<div className="mx-auto w-full max-w-5xl space-y-8">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100 md:text-4xl">Your profile</h1>
<p className="mt-2 text-base leading-relaxed text-gray-600 dark:text-gray-400 md:text-lg">
Manage archived courses and flashcard decks.
</p>
</div>

<section className="space-y-4">
<h2 className="text-center text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100 md:text-4xl">
Archived courses
</h2>
{archivedCourseItems.length > 0 ? (
<CourseCarousel
courses={archivedCourseItems}
progressByCourseId={archivedProgressByCourseId}
compact
footerActionByCourseId={Object.fromEntries(
archivedCourseItems.map((course) => [course.id, <UnarchiveCourseButton key={course.id} courseId={String(course.id)} />]),
)}
/>
) : (
<Card className={cn('border-0 shadow-none', studentGlassCard)}>
<CardContent className="py-8 text-center text-gray-600 dark:text-gray-400">No archived courses yet.</CardContent>
</Card>
)}
</section>

<section className="space-y-4">
<h2 className="text-center text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100 md:text-4xl">
Archived flashcards
</h2>
{archivedDeckRows.length > 0 ? (
<FlashcardDeckCarousel
rows={archivedDeckRows}
compact
actionByDeckId={Object.fromEntries(
archivedDeckRows.map((row) => [row.id, <UnarchiveDeckButton key={row.id} deckId={row.id} />]),
)}
/>
) : (
<Card className={cn('border-0 shadow-none', studentGlassCard)}>
<CardContent className="py-8 text-center text-gray-600 dark:text-gray-400">No archived flashcard decks yet.</CardContent>
</Card>
)}
</section>
</div>
</div>
)
}
2 changes: 1 addition & 1 deletion LearningPlatform/app/actions/course-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function getAllCourseProgress() {
}

return prisma.courseProgress.findMany({
where: { userId: session.user.id },
where: { userId: session.user.id, archivedAt: null },
orderBy: { lastActivityAt: 'desc' },
})
}
Expand Down
51 changes: 51 additions & 0 deletions LearningPlatform/app/api/flashcards/study/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export async function GET(req: Request) {
whereFilter = whereFilter ? { AND: [whereFilter, mainDeckPart] } : mainDeckPart
}

let validatedMainDeckId: string | null = null
if (mainDeckSlugQ && !deckFilterSlug) {
// Ensure main deck exists and is not itself a subdeck, otherwise we might silently return unrelated data.
const mainDeck = await prisma.flashcardDeck.findUnique({
Expand All @@ -141,6 +142,7 @@ export async function GET(req: Request) {
{ status: 400 },
)
}
validatedMainDeckId = mainDeck.id
}

if (deckFilterSlug || mainDeckSlugQ) {
Expand All @@ -154,6 +156,55 @@ export async function GET(req: Request) {
{ status: 403 },
)
}

// Auto-unarchive when the user starts studying this deck scope again.
let rootDeck: { id: string; courseId: string | null } | null = null
if (validatedMainDeckId) {
rootDeck = await prisma.flashcardDeck.findUnique({
where: { id: validatedMainDeckId },
select: { id: true, courseId: true },
})
} else if (deckFilterSlug) {
const deck = await prisma.flashcardDeck.findUnique({
where: { slug: deckFilterSlug },
select: { id: true, parentDeckId: true, courseId: true },
})
if (deck) {
if (deck.parentDeckId) {
rootDeck = await prisma.flashcardDeck.findUnique({
where: { id: deck.parentDeckId },
select: { id: true, courseId: true },
})
} else {
rootDeck = { id: deck.id, courseId: deck.courseId }
}
}
}

if (rootDeck) {
if (typeof prisma.userStandaloneFlashcardDeck?.updateMany === 'function') {
await prisma.userStandaloneFlashcardDeck.updateMany({
where: {
userId: user.id,
deckId: rootDeck.id,
archivedAt: { not: null },
},
data: { archivedAt: null },
})
}
if (rootDeck.courseId) {
if (typeof prisma.courseProgress?.updateMany === 'function') {
await prisma.courseProgress.updateMany({
where: {
userId: user.id,
courseId: rootDeck.courseId,
archivedAt: { not: null },
},
data: { archivedAt: null },
})
}
}
}
}

const flashcards = await prisma.flashcard.findMany({
Expand Down
Loading
Loading