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
19 changes: 17 additions & 2 deletions LearningPlatform/app/(student)/(shell)/courses/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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'
import { ArchiveCourseButton, UnarchiveCourseButton } 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,6 +147,17 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s
}

const courseId = String(course.id)
let courseArchived = false
if (session?.user?.id) {
const archivedCourse = await prisma.courseProgress.findFirst({
where: {
userId: session.user.id,
courseId,
Comment on lines +152 to +155
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CourseProgress has a @@unique([userId, courseId]), so this lookup should use findUnique({ where: { userId_courseId: ... } }) instead of findFirst. Using findUnique makes the intent clearer and lets Prisma take advantage of the unique constraint/index.

Suggested change
const archivedCourse = await prisma.courseProgress.findFirst({
where: {
userId: session.user.id,
courseId,
const archivedCourse = await prisma.courseProgress.findUnique({
where: {
userId_courseId: {
userId: session.user.id,
courseId,
},

Copilot uses AI. Check for mistakes.
},
select: { archivedAt: true },
})
courseArchived = Boolean(archivedCourse?.archivedAt)
}
const moduleIds = modulesWithLessons.map((m) => String(m.id))
let mainDeck = await prisma.flashcardDeck.findFirst({
where: { courseId, parentDeckId: null },
Expand Down Expand Up @@ -480,7 +491,11 @@ export default async function CoursePage({ params }: { params: Promise<{ slug: s
)}

<section className="flex justify-center pt-2">
<ArchiveCourseButton courseSlug={slug} />
{courseArchived ? (
<UnarchiveCourseButton courseId={courseId} />
) : (
<ArchiveCourseButton courseSlug={slug} />
)}
Comment on lines +494 to +498
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The course page swaps ↔ based on courseArchived, and the button components own the toast state. After an archive/unarchive click, router.refresh() will re-render the page and replace the button component, unmounting it and clearing the toast state—so the toast may flash briefly or not be visible at all. Consider hoisting the toast host/state to a parent that won’t unmount on refresh (e.g. a layout-level toast provider), or keep a stable client wrapper around this section and render the toast there instead of inside the button components.

Suggested change
{courseArchived ? (
<UnarchiveCourseButton courseId={courseId} />
) : (
<ArchiveCourseButton courseSlug={slug} />
)}
<div
className={courseArchived ? 'hidden' : undefined}
aria-hidden={courseArchived}
tabIndex={courseArchived ? -1 : undefined}
>
<ArchiveCourseButton courseSlug={slug} />
</div>
<div
className={courseArchived ? undefined : 'hidden'}
aria-hidden={!courseArchived}
tabIndex={!courseArchived ? -1 : undefined}
>
<UnarchiveCourseButton courseId={courseId} />
</div>

Copilot uses AI. Check for mistakes.
</section>
</div>
)
Expand Down
128 changes: 75 additions & 53 deletions LearningPlatform/components/profile/archive-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useState, useTransition } from 'react'
import { useEffect, useRef, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'

Expand All @@ -16,138 +16,160 @@ async function postJson(url: string, body: unknown) {
}
}

function ActionToast({ message }: { message: string }) {
return (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-md border border-black/10 bg-black/85 px-3 py-2 text-xs text-white shadow-lg"
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This toast is position: fixed and can intercept pointer events while visible. Adding pointer-events-none (or otherwise ensuring it doesn’t capture clicks) prevents it from blocking underlying UI controls for ~2.2s, which is especially noticeable on small screens.

Suggested change
className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-md border border-black/10 bg-black/85 px-3 py-2 text-xs text-white shadow-lg"
className="pointer-events-none fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-md border border-black/10 bg-black/85 px-3 py-2 text-xs text-white shadow-lg"

Copilot uses AI. Check for mistakes.
>
{message}
</div>
)
}

function useActionToast() {
const [toastMessage, setToastMessage] = useState<string | null>(null)
const timeoutRef = useRef<number | null>(null)

useEffect(() => {
return () => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current)
}
}
}, [])

const showToast = (message: string) => {
if (timeoutRef.current !== null) {
window.clearTimeout(timeoutRef.current)
}
setToastMessage(message)
timeoutRef.current = window.setTimeout(() => {
setToastMessage(null)
timeoutRef.current = null
}, 2200)
}

return { toastMessage, showToast }
}

export function ArchiveCourseButton({ courseSlug }: { courseSlug: string }) {
const router = useRouter()
const [pending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const { toastMessage, showToast } = useActionToast()

return (
<div className="space-y-1.5">
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={pending}
onClick={() => {
setError(null)
const archiveLinkedDeck = window.confirm(
'Do you also want to archive this course flashcard deck?',
)
startTransition(async () => {
try {
await postJson('/api/profile/archive', {
type: 'course',
courseSlug,
archiveLinkedDeck,
})
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to archive course')
}
await postJson('/api/profile/archive', {
type: 'course',
courseSlug,
archiveLinkedDeck,
})
showToast('Course archived')
router.refresh()
})
Comment on lines 73 to 81
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the toast state is local to each button component, calling router.refresh() right after showToast() can cause the component to unmount (e.g. when the parent server component re-renders and the archived item disappears / the button switches from Archive→Unarchive). In those cases the toast will be cleared immediately and users may not see any feedback. Consider moving the toast state/host to a stable parent (context/provider/portal) that survives refreshes, or delaying the refresh until after the toast duration.

Copilot uses AI. Check for mistakes.
}}
>
{pending ? 'Archiving...' : 'Archive course'}
</Button>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
{toastMessage ? <ActionToast message={toastMessage} /> : null}
</>
)
}

export function ArchiveDeckButton({ deckId }: { deckId: string }) {
const router = useRouter()
const [pending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const { toastMessage, showToast } = useActionToast()

return (
<div className="space-y-1.5">
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={pending}
onClick={() => {
setError(null)
const archiveLinkedCourse = window.confirm(
'This deck may be linked to a course. Do you also want to archive the course?',
)
startTransition(async () => {
try {
await postJson('/api/profile/archive', {
type: 'deck',
deckId,
archiveLinkedCourse,
})
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to archive deck')
}
await postJson('/api/profile/archive', {
type: 'deck',
deckId,
archiveLinkedCourse,
})
showToast('Deck archived')
router.refresh()
})
}}
>
{pending ? 'Archiving...' : 'Archive deck'}
</Button>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
{toastMessage ? <ActionToast message={toastMessage} /> : null}
</>
)
}

export function UnarchiveCourseButton({ courseId }: { courseId: string }) {
const router = useRouter()
const [pending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const { toastMessage, showToast } = useActionToast()
return (
<div className="space-y-1.5">
<>
<Button
type="button"
variant="hero"
size="sm"
disabled={pending}
onClick={() =>
startTransition(async () => {
setError(null)
try {
await postJson('/api/profile/unarchive', { type: 'course', courseId })
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to unarchive course')
}
await postJson('/api/profile/unarchive', { type: 'course', courseId })
showToast('Course unarchived')
router.refresh()
})
}
>
{pending ? 'Unarchiving...' : 'Unarchive'}
</Button>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
{toastMessage ? <ActionToast message={toastMessage} /> : null}
</>
)
}

export function UnarchiveDeckButton({ deckId }: { deckId: string }) {
const router = useRouter()
const [pending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const { toastMessage, showToast } = useActionToast()
return (
<div className="space-y-1.5">
<>
<Button
type="button"
variant="hero"
size="sm"
disabled={pending}
onClick={() =>
startTransition(async () => {
setError(null)
try {
await postJson('/api/profile/unarchive', { type: 'deck', deckId })
router.refresh()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to unarchive deck')
}
await postJson('/api/profile/unarchive', { type: 'deck', deckId })
showToast('Deck unarchived')
router.refresh()
})
}
>
{pending ? 'Unarchiving...' : 'Unarchive'}
</Button>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
{toastMessage ? <ActionToast message={toastMessage} /> : null}
</>
)
}
Loading