From 990a669d1690599e01fcb3a81c8b1b4bcf9e4b0f Mon Sep 17 00:00:00 2001 From: kinga Date: Sun, 17 May 2026 21:15:56 +0200 Subject: [PATCH 1/3] feat(media-management): add functionality to delete unused media and enhance media usage tracking - Introduced `MediaDeleteUnusedButton` component for bulk deletion of unused media files. - Implemented `countUnusedMedia` and `deleteAllUnusedMedia` functions to facilitate media management. - Enhanced media usage tracking by adding `flashcardsCount` to the `MediaUsage` interface and updating related logic. - Updated `AdminMediaPage` to utilize new media usage functions for improved performance. - Added scripts for wiping content in Docker and Prisma, ensuring a clean state for development. --- .../app/(admin)/admin/actions/media.ts | 66 +++++- .../flashcards/admin-flashcards-client.tsx | 10 + .../app/(admin)/admin/media/page.tsx | 218 +++--------------- .../app/api/flashcard-decks/[id]/route.ts | 7 + .../admin/media-delete-unused-button.tsx | 58 +++++ LearningPlatform/lib/media-usage.ts | 210 +++++++++++++++++ LearningPlatform/package.json | 4 +- .../migration.sql | 5 + LearningPlatform/prisma/schema.prisma | 2 +- .../scripts/wipe-content-docker.ps1 | 21 ++ LearningPlatform/scripts/wipe-content.sql | 59 +++++ LearningPlatform/scripts/wipe-content.ts | 118 ++++++++++ .../api/flashcard-decks-and-summary.test.ts | 5 + 13 files changed, 597 insertions(+), 186 deletions(-) create mode 100644 LearningPlatform/components/admin/media-delete-unused-button.tsx create mode 100644 LearningPlatform/lib/media-usage.ts create mode 100644 LearningPlatform/prisma/migrations/20260517120000_flashcard_deck_cascade_delete/migration.sql create mode 100644 LearningPlatform/scripts/wipe-content-docker.ps1 create mode 100644 LearningPlatform/scripts/wipe-content.sql create mode 100644 LearningPlatform/scripts/wipe-content.ts diff --git a/LearningPlatform/app/(admin)/admin/actions/media.ts b/LearningPlatform/app/(admin)/admin/actions/media.ts index 1c0e2fc..5be82c5 100644 --- a/LearningPlatform/app/(admin)/admin/actions/media.ts +++ b/LearningPlatform/app/(admin)/admin/actions/media.ts @@ -5,6 +5,37 @@ import config from '@payload-config' import { requireAdmin } from '@/lib/auth-helpers' import { revalidatePath } from 'next/cache' import { logActivity, ActivityAction } from '@/lib/activity-log' +import { collectUsedMediaIds } from '@/lib/media-usage' + +const MEDIA_PAGE_SIZE = 100 + +async function listAllMediaIds(): Promise { + const payload = await getPayload({ config }) + const ids: string[] = [] + let page = 1 + + while (true) { + const result = await payload.find({ + collection: 'media', + limit: MEDIA_PAGE_SIZE, + page, + depth: 0, + }) + for (const doc of result.docs) { + ids.push(String(doc.id)) + } + if (!result.hasNextPage) break + page++ + } + + return ids +} + +export async function countUnusedMedia(): Promise { + await requireAdmin() + const [allIds, usedIds] = await Promise.all([listAllMediaIds(), collectUsedMediaIds()]) + return allIds.filter((id) => !usedIds.has(id)).length +} export async function deleteMedia(id: string) { const admin = await requireAdmin() @@ -16,12 +47,39 @@ export async function deleteMedia(id: string) { }) logActivity({ - action: ActivityAction.MEDIA_DELETED, - actorUserId: admin.id, - actorEmail: admin.email, + action: ActivityAction.MEDIA_DELETED, + actorUserId: admin.id, + actorEmail: admin.email, resourceType: 'media', - resourceId: String(id), + resourceId: String(id), }) revalidatePath('/admin/media') } + +export async function deleteAllUnusedMedia(): Promise<{ deleted: number }> { + const admin = await requireAdmin() + const payload = await getPayload({ config }) + const [allIds, usedIds] = await Promise.all([listAllMediaIds(), collectUsedMediaIds()]) + const unusedIds = allIds.filter((id) => !usedIds.has(id)) + + let deleted = 0 + for (const id of unusedIds) { + await payload.delete({ collection: 'media', id }) + deleted++ + } + + if (deleted > 0) { + logActivity({ + action: ActivityAction.MEDIA_DELETED, + actorUserId: admin.id, + actorEmail: admin.email, + resourceType: 'media', + resourceId: 'bulk-unused', + metadata: { count: deleted, mediaIds: unusedIds.slice(0, 50) }, + }) + } + + revalidatePath('/admin/media') + return { deleted } +} diff --git a/LearningPlatform/app/(admin)/admin/flashcards/admin-flashcards-client.tsx b/LearningPlatform/app/(admin)/admin/flashcards/admin-flashcards-client.tsx index 8a450af..7c35e6c 100644 --- a/LearningPlatform/app/(admin)/admin/flashcards/admin-flashcards-client.tsx +++ b/LearningPlatform/app/(admin)/admin/flashcards/admin-flashcards-client.tsx @@ -832,6 +832,16 @@ export function AdminFlashcardsPage() { setError(typeof data?.error === 'string' ? data.error : 'Could not delete deck.') return } + const removedDeckIds = new Set([deck.id]) + if (opts.isMain) { + for (const row of deckRows) { + if (row.parentDeckId === deck.id) removedDeckIds.add(row.id) + } + } + setFlashcards((prev) => prev.filter((fc) => !removedDeckIds.has(fc.deck.id))) + setDeckRows((prev) => + prev.filter((row) => row.id !== deck.id && row.parentDeckId !== deck.id), + ) if (inlineSubdeckMainId === deck.id) setInlineSubdeckMainId(null) if (inlineStandaloneSubdeckMainId === deck.id) { setInlineStandaloneSubdeckMainId(null) diff --git a/LearningPlatform/app/(admin)/admin/media/page.tsx b/LearningPlatform/app/(admin)/admin/media/page.tsx index 34c913e..9231177 100644 --- a/LearningPlatform/app/(admin)/admin/media/page.tsx +++ b/LearningPlatform/app/(admin)/admin/media/page.tsx @@ -1,6 +1,5 @@ import { getPayload } from 'payload' import config from '@payload-config' -import { unstable_cache } from 'next/cache' import { Card, CardContent } from '@/components/ui/card' import { cn } from '@/lib/utils' import { adminGlassCard } from '@/lib/student-glass-styles' @@ -11,7 +10,14 @@ import { payloadTableExists } from '@/lib/payload-utils' import { ReloadButton } from '@/components/ui/reload-button' import Image from 'next/image' import { MediaDeleteButton } from '@/components/admin/media-delete-button' +import { MediaDeleteUnusedButton } from '@/components/admin/media-delete-unused-button' import { MediaUploader } from '@/components/admin/media-uploader' +import { + getMediaUsageForIds, + isMediaInUse, + mediaUsageTotal, + type MediaUsage, +} from '@/lib/media-usage' export const dynamic = 'force-dynamic' @@ -31,136 +37,6 @@ interface Media { url: string } -interface MediaUsage { - lessonsCount: number - tasksCount: number - coursesCount: number - lessonRefs: Array<{ id: number; title: string }> - taskRefs: Array<{ id: number; lessonId: number; lessonTitle: string }> - courseRefs: Array<{ id: number | string; title: string }> -} - -type LessonDoc = { - id: number - title: string - theoryBlocks?: Array> -} - -function coverMediaIdFromCourse(course: Record): string { - const ci = course.coverImage - if (ci == null || ci === '') return '' - if (typeof ci === 'number' || typeof ci === 'string') return String(ci) - if (typeof ci === 'object' && ci !== null && 'id' in ci) return String((ci as { id: unknown }).id) - return '' -} - -// PERFORMANCE: Cache heavy collection scans — revalidate every 30 s. -const getCachedLessonsTasksCourses = unstable_cache( - async () => { - const payload = await getPayload({ config }) - const [lessonsResult, tasksResult, coursesResult] = await Promise.all([ - payload.find({ collection: 'lessons', limit: 1000, depth: 0 }), - payload.find({ collection: 'tasks', limit: 5000, depth: 1 }), - payload.find({ collection: 'courses', limit: 500, depth: 0 }), - ]) - return { lessons: lessonsResult.docs, tasks: tasksResult.docs, courses: coursesResult.docs } - }, - ['admin-media-usage-data-v2'], - { revalidate: 30 }, -) - -// PERFORMANCE FIX: Build usage map for ALL media at once (not per-item) -// This prevents fetching 1000 lessons × 100 media = 100,000 fetches! -async function getAllMediaUsage(mediaIds: Array): Promise> { - const usageMap = new Map() - - // Initialize all media with zero usage - for (const id of mediaIds) { - usageMap.set(String(id), { - lessonsCount: 0, - tasksCount: 0, - coursesCount: 0, - lessonRefs: [], - taskRefs: [], - courseRefs: [], - }) - } - - const { lessons, tasks, courses } = await getCachedLessonsTasksCourses() - - // Build lesson usage map - for (const lesson of lessons as LessonDoc[]) { - if (lesson.theoryBlocks && Array.isArray(lesson.theoryBlocks)) { - for (const block of lesson.theoryBlocks) { - const typedBlock = block as { blockType?: string; image?: number | { id?: number } | string } - if (typedBlock.blockType === 'image') { - const imageId = typeof typedBlock.image === 'number' - ? String(typedBlock.image) - : typeof typedBlock.image === 'string' - ? typedBlock.image - : String(typedBlock.image?.id) - - const usage = usageMap.get(imageId) - if (usage) { - usage.lessonsCount++ - if (usage.lessonRefs.length < 3) { - usage.lessonRefs.push({ - id: lesson.id as number, - title: lesson.title, - }) - } - } - } - } - } - } - - // Build task usage map - for (const task of tasks) { - const taskItem = task as Record - const questionMediaId = String(taskItem.questionMedia ?? '') - const solutionMediaId = String(taskItem.solutionMedia ?? '') - const lessonValue = taskItem.lesson as { id?: number; title?: string } | number | undefined - - const taskRef = { - id: taskItem.id as number, - lessonId: typeof lessonValue === 'number' ? lessonValue : lessonValue?.id ?? 0, - lessonTitle: typeof lessonValue === 'object' ? lessonValue?.title ?? 'Untitled' : 'Untitled', - } - - if (questionMediaId && usageMap.has(questionMediaId)) { - const usage = usageMap.get(questionMediaId)! - usage.tasksCount++ - if (usage.taskRefs.length < 3) { - usage.taskRefs.push(taskRef) - } - } - - if (solutionMediaId && solutionMediaId !== questionMediaId && usageMap.has(solutionMediaId)) { - const usage = usageMap.get(solutionMediaId)! - usage.tasksCount++ - if (usage.taskRefs.length < 3) { - usage.taskRefs.push(taskRef) - } - } - } - - for (const course of courses as Array>) { - const coverId = coverMediaIdFromCourse(course) - if (!coverId || !usageMap.has(coverId)) continue - const usage = usageMap.get(coverId)! - usage.coursesCount++ - if (usage.courseRefs.length < 3) { - usage.courseRefs.push({ - id: course.id as number | string, - title: String(course.title ?? 'Course'), - }) - } - } - - return usageMap -} - export default async function AdminMediaPage({ searchParams }: PageProps) { const sp = await searchParams const requestedPage = Math.max(1, parseInt(sp.page || '1', 10) || 1) @@ -173,7 +49,6 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { let totalPages = 1 try { - // Check if table exists before querying const tableExists = await payloadTableExists('media') if (!tableExists) { error = '⚠️ The database is not initialized. Run migrations: npm run payload:migrate' @@ -200,10 +75,7 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { }) } - const mediaData = findResult.docs - - // Cast to proper Media type - media = mediaData.map((item) => { + media = findResult.docs.map((item) => { const mediaItem = item as Record return { id: mediaItem.id as number | string, @@ -216,15 +88,14 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { } }) - // PERFORMANCE FIX: Get usage stats for items on this page at once (not per-item) - // Scans cached lessons/tasks/courses once; only resolves usage for visible media ids. - const usageMap = await getAllMediaUsage(media.map(m => m.id)) + const usageMap = await getMediaUsageForIds(media.map((m) => m.id)) mediaWithUsage = media.map((item: Media) => ({ ...item, - usage: usageMap.get(String(item.id)) || { + usage: usageMap.get(String(item.id)) ?? { lessonsCount: 0, tasksCount: 0, coursesCount: 0, + flashcardsCount: 0, lessonRefs: [], taskRefs: [], courseRefs: [], @@ -234,7 +105,7 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { } catch (err) { console.error('Failed to fetch media:', err) const errorMessage = err instanceof Error ? err.message : String(err) - + if (errorMessage.includes('relation') && errorMessage.includes('does not exist')) { error = '⚠️ The database is not initialized. Run migrations: npm run payload:migrate' } else { @@ -254,9 +125,7 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { return 'other' } - const totalUsed = mediaWithUsage.filter( - (m) => m.usage.lessonsCount > 0 || m.usage.tasksCount > 0 || m.usage.coursesCount > 0, - ).length + const totalUsed = mediaWithUsage.filter((m) => isMediaInUse(m.usage)).length return (
@@ -270,7 +139,8 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { : 'All uploaded files')}

-
+
+
@@ -289,12 +159,8 @@ export default async function AdminMediaPage({ searchParams }: PageProps) {
-

- No media yet -

-

- Upload a file from a lesson or task editor. -

+

No media yet

+

Upload a file from a lesson or task editor.

@@ -303,17 +169,14 @@ export default async function AdminMediaPage({ searchParams }: PageProps) {
{mediaWithUsage.map((item) => { const mediaType = getMediaType(item.mimeType) - const isUsed = - item.usage.lessonsCount > 0 || item.usage.tasksCount > 0 || item.usage.coursesCount > 0 - const usageTotal = - item.usage.lessonsCount + item.usage.tasksCount + item.usage.coursesCount + const isUsed = isMediaInUse(item.usage) + const usageTotal = mediaUsageTotal(item.usage) return ( - {/* Preview */} {item.mimeType.split('/')[1]?.toUpperCase()}
)} - - {/* Type badge */} - + + {mediaType === 'image' ? 'Image' : mediaType === 'video' ? 'Video' : 'Other'} - {/* Usage badge */} {isUsed && ( - - In use - + + In use + )} - {/* Filename */}

{item.filename} @@ -379,15 +233,13 @@ export default async function AdminMediaPage({ searchParams }: PageProps) {

- {/* File info */}
{item.filesize ? formatFileSize(item.filesize) : 'N/A'} {new Date(item.createdAt).toLocaleDateString('en-US')}
- {/* Usage info */}
-
+
{item.usage.lessonsCount > 0 && ( 📚 {item.usage.lessonsCount} {item.usage.lessonsCount === 1 ? 'lesson' : 'lessons'} @@ -404,6 +256,12 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { {item.usage.coursesCount === 1 ? 'course cover' : 'course covers'} )} + {item.usage.flashcardsCount > 0 && ( + + 🃏 {item.usage.flashcardsCount}{' '} + {item.usage.flashcardsCount === 1 ? 'flashcard' : 'flashcards'} + + )} {!isUsed && ( Unused @@ -411,7 +269,6 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { )}
- {/* References */} {(item.usage.lessonRefs.length > 0 || item.usage.taskRefs.length > 0 || item.usage.courseRefs.length > 0) && ( @@ -439,7 +296,7 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { {ref.title} ))} - + {item.usage.taskRefs.map((ref) => ( )}
- - {/* click the preview to open the media in a new tab */} ) @@ -506,7 +361,6 @@ export default async function AdminMediaPage({ searchParams }: PageProps) { )} - {/* Summary (current page only when paginated) */}

{totalDocs > MEDIA_PER_PAGE ? 'This page' : 'Library'} @@ -518,12 +372,16 @@ export default async function AdminMediaPage({ searchParams }: PageProps) {

- {media.filter(m => m.mimeType.startsWith('image/')).length} + + {media.filter((m) => m.mimeType.startsWith('image/')).length} + images
- {media.filter(m => m.mimeType.startsWith('video/')).length} + + {media.filter((m) => m.mimeType.startsWith('video/')).length} + videos
diff --git a/LearningPlatform/app/api/flashcard-decks/[id]/route.ts b/LearningPlatform/app/api/flashcard-decks/[id]/route.ts index 7922356..4d54b11 100644 --- a/LearningPlatform/app/api/flashcard-decks/[id]/route.ts +++ b/LearningPlatform/app/api/flashcard-decks/[id]/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { revalidateTag } from 'next/cache' import { prisma } from '@/lib/prisma' import { requireAdmin } from '@/lib/auth-helpers' @@ -43,6 +44,12 @@ export async function DELETE(_req: Request, { params }: RouteContext) { }) } + try { + revalidateTag('api-flashcards', 'max') + } catch { + /* best-effort */ + } + return NextResponse.json({ ok: true }) } catch (error) { if (error instanceof Error && (error.message === 'Unauthorized' || error.message === 'Forbidden')) { diff --git a/LearningPlatform/components/admin/media-delete-unused-button.tsx b/LearningPlatform/components/admin/media-delete-unused-button.tsx new file mode 100644 index 0000000..1d141ac --- /dev/null +++ b/LearningPlatform/components/admin/media-delete-unused-button.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Loader2, Trash2 } from 'lucide-react' +import { countUnusedMedia, deleteAllUnusedMedia } from '@/app/(admin)/admin/actions' +import { cn } from '@/lib/utils' +import { adminGlassOutlineButton } from '@/lib/student-glass-styles' + +export function MediaDeleteUnusedButton() { + const router = useRouter() + const [loading, setLoading] = useState(false) + + const handleDeleteAllUnused = async () => { + setLoading(true) + try { + const count = await countUnusedMedia() + if (count === 0) { + alert('No unused files to delete.') + return + } + + const confirmed = confirm( + `Delete ${count} unused file${count === 1 ? '' : 's'} from the media library?\n\nThis only removes files not linked to lessons, tasks, course covers, or flashcards. This cannot be undone.`, + ) + if (!confirmed) return + + const { deleted } = await deleteAllUnusedMedia() + router.refresh() + alert(deleted === 0 ? 'No unused files were deleted.' : `Deleted ${deleted} unused file${deleted === 1 ? '' : 's'}.`) + } catch (error) { + alert(`Failed to delete unused media: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setLoading(false) + } + } + + return ( + + ) +} diff --git a/LearningPlatform/lib/media-usage.ts b/LearningPlatform/lib/media-usage.ts new file mode 100644 index 0000000..7025c02 --- /dev/null +++ b/LearningPlatform/lib/media-usage.ts @@ -0,0 +1,210 @@ +import { getPayload } from 'payload' +import config from '@payload-config' +import { unstable_cache } from 'next/cache' +import { prisma } from '@/lib/prisma' + +export interface MediaUsage { + lessonsCount: number + tasksCount: number + coursesCount: number + flashcardsCount: number + lessonRefs: Array<{ id: number; title: string }> + taskRefs: Array<{ id: number; lessonId: number; lessonTitle: string }> + courseRefs: Array<{ id: number | string; title: string }> +} + +type LessonDoc = { + id: number + title: string + theoryBlocks?: Array> +} + +function emptyUsage(): MediaUsage { + return { + lessonsCount: 0, + tasksCount: 0, + coursesCount: 0, + flashcardsCount: 0, + lessonRefs: [], + taskRefs: [], + courseRefs: [], + } +} + +export function isMediaInUse(usage: MediaUsage): boolean { + return ( + usage.lessonsCount > 0 || + usage.tasksCount > 0 || + usage.coursesCount > 0 || + usage.flashcardsCount > 0 + ) +} + +export function mediaUsageTotal(usage: MediaUsage): number { + return usage.lessonsCount + usage.tasksCount + usage.coursesCount + usage.flashcardsCount +} + +function coverMediaIdFromCourse(course: Record): string { + const ci = course.coverImage + if (ci == null || ci === '') return '' + if (typeof ci === 'number' || typeof ci === 'string') return String(ci) + if (typeof ci === 'object' && ci !== null && 'id' in ci) return String((ci as { id: unknown }).id) + return '' +} + +const getCachedLessonsTasksCourses = unstable_cache( + async () => { + const payload = await getPayload({ config }) + const [lessonsResult, tasksResult, coursesResult] = await Promise.all([ + payload.find({ collection: 'lessons', limit: 1000, depth: 0 }), + payload.find({ collection: 'tasks', limit: 5000, depth: 1 }), + payload.find({ collection: 'courses', limit: 500, depth: 0 }), + ]) + return { lessons: lessonsResult.docs, tasks: tasksResult.docs, courses: coursesResult.docs } + }, + ['admin-media-usage-data-v2'], + { revalidate: 30 }, +) + +async function getFlashcardMediaRefCounts(): Promise> { + const cards = await prisma.flashcard.findMany({ + where: { + OR: [{ questionImageId: { not: null } }, { answerImageId: { not: null } }], + }, + select: { questionImageId: true, answerImageId: true }, + }) + const counts = new Map() + for (const card of cards) { + if (card.questionImageId) { + counts.set(card.questionImageId, (counts.get(card.questionImageId) ?? 0) + 1) + } + if (card.answerImageId) { + counts.set(card.answerImageId, (counts.get(card.answerImageId) ?? 0) + 1) + } + } + return counts +} + +/** All media ids referenced by lessons, tasks, course covers, or flashcards. */ +export async function collectUsedMediaIds(): Promise> { + const used = new Set() + const [{ lessons, tasks, courses }, flashcardCounts] = await Promise.all([ + getCachedLessonsTasksCourses(), + getFlashcardMediaRefCounts(), + ]) + + for (const id of flashcardCounts.keys()) used.add(id) + + for (const lesson of lessons as LessonDoc[]) { + if (!lesson.theoryBlocks || !Array.isArray(lesson.theoryBlocks)) continue + for (const block of lesson.theoryBlocks) { + const typedBlock = block as { blockType?: string; image?: number | { id?: number } | string } + if (typedBlock.blockType !== 'image') continue + const imageId = + typeof typedBlock.image === 'number' + ? String(typedBlock.image) + : typeof typedBlock.image === 'string' + ? typedBlock.image + : String(typedBlock.image?.id ?? '') + if (imageId) used.add(imageId) + } + } + + for (const task of tasks) { + const taskItem = task as Record + const questionMediaId = String(taskItem.questionMedia ?? '') + const solutionMediaId = String(taskItem.solutionMedia ?? '') + if (questionMediaId) used.add(questionMediaId) + if (solutionMediaId) used.add(solutionMediaId) + } + + for (const course of courses as Array>) { + const coverId = coverMediaIdFromCourse(course) + if (coverId) used.add(coverId) + } + + return used +} + +/** Per-media usage details for the given ids (admin media library cards). */ +export async function getMediaUsageForIds( + mediaIds: Array, +): Promise> { + const usageMap = new Map() + for (const id of mediaIds) { + usageMap.set(String(id), emptyUsage()) + } + + const [{ lessons, tasks, courses }, flashcardCounts] = await Promise.all([ + getCachedLessonsTasksCourses(), + getFlashcardMediaRefCounts(), + ]) + + for (const lesson of lessons as LessonDoc[]) { + if (!lesson.theoryBlocks || !Array.isArray(lesson.theoryBlocks)) continue + for (const block of lesson.theoryBlocks) { + const typedBlock = block as { blockType?: string; image?: number | { id?: number } | string } + if (typedBlock.blockType !== 'image') continue + const imageId = + typeof typedBlock.image === 'number' + ? String(typedBlock.image) + : typeof typedBlock.image === 'string' + ? typedBlock.image + : String(typedBlock.image?.id ?? '') + const usage = usageMap.get(imageId) + if (!usage) continue + usage.lessonsCount++ + if (usage.lessonRefs.length < 3) { + usage.lessonRefs.push({ id: lesson.id as number, title: lesson.title }) + } + } + } + + for (const task of tasks) { + const taskItem = task as Record + const questionMediaId = String(taskItem.questionMedia ?? '') + const solutionMediaId = String(taskItem.solutionMedia ?? '') + const lessonValue = taskItem.lesson as { id?: number; title?: string } | number | undefined + + const taskRef = { + id: taskItem.id as number, + lessonId: typeof lessonValue === 'number' ? lessonValue : (lessonValue?.id ?? 0), + lessonTitle: typeof lessonValue === 'object' ? (lessonValue?.title ?? 'Untitled') : 'Untitled', + } + + if (questionMediaId && usageMap.has(questionMediaId)) { + const usage = usageMap.get(questionMediaId)! + usage.tasksCount++ + if (usage.taskRefs.length < 3) usage.taskRefs.push(taskRef) + } + + if (solutionMediaId && solutionMediaId !== questionMediaId && usageMap.has(solutionMediaId)) { + const usage = usageMap.get(solutionMediaId)! + usage.tasksCount++ + if (usage.taskRefs.length < 3) usage.taskRefs.push(taskRef) + } + } + + for (const course of courses as Array>) { + const coverId = coverMediaIdFromCourse(course) + if (!coverId || !usageMap.has(coverId)) continue + const usage = usageMap.get(coverId)! + usage.coursesCount++ + if (usage.courseRefs.length < 3) { + usage.courseRefs.push({ + id: course.id as number | string, + title: String(course.title ?? 'Course'), + }) + } + } + + for (const mediaId of mediaIds) { + const key = String(mediaId) + const count = flashcardCounts.get(key) + if (!count) continue + const usage = usageMap.get(key) + if (usage) usage.flashcardsCount = count + } + + return usageMap +} diff --git a/LearningPlatform/package.json b/LearningPlatform/package.json index daa26ba..bd81bcc 100644 --- a/LearningPlatform/package.json +++ b/LearningPlatform/package.json @@ -42,7 +42,9 @@ "content:import:modules": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/import-modules.js", "content:import:flashcards": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/import-flashcards.js", "content:import:all": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/import-all.js", - "payload:cleanup-orphans": "tsx --tsconfig tsconfig.scripts.json ./scripts/cleanup-payload-orphans.ts" + "payload:cleanup-orphans": "tsx --tsconfig tsconfig.scripts.json ./scripts/cleanup-payload-orphans.ts", + "content:wipe": "tsx --tsconfig tsconfig.scripts.json ./scripts/wipe-content.ts", + "content:wipe:docker": "powershell -ExecutionPolicy Bypass -File ./scripts/wipe-content-docker.ps1" }, "prisma": { "seed": "tsx --tsconfig tsconfig.scripts.json ./prisma/seed.ts" diff --git a/LearningPlatform/prisma/migrations/20260517120000_flashcard_deck_cascade_delete/migration.sql b/LearningPlatform/prisma/migrations/20260517120000_flashcard_deck_cascade_delete/migration.sql new file mode 100644 index 0000000..3aaa70e --- /dev/null +++ b/LearningPlatform/prisma/migrations/20260517120000_flashcard_deck_cascade_delete/migration.sql @@ -0,0 +1,5 @@ +-- Delete flashcards when their deck is removed (matches Prisma onDelete: Cascade). +ALTER TABLE "flashcards" DROP CONSTRAINT "flashcards_deckId_fkey"; + +ALTER TABLE "flashcards" ADD CONSTRAINT "flashcards_deckId_fkey" + FOREIGN KEY ("deckId") REFERENCES "flashcard_decks"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/LearningPlatform/prisma/schema.prisma b/LearningPlatform/prisma/schema.prisma index ee126dd..d09fc94 100644 --- a/LearningPlatform/prisma/schema.prisma +++ b/LearningPlatform/prisma/schema.prisma @@ -287,7 +287,7 @@ model Flashcard { /// ID of a Payload CMS media record (image). Null means no image attached. answerImageId String? deckId String - deck FlashcardDeck @relation(fields: [deckId], references: [id], onDelete: Restrict) + deck FlashcardDeck @relation(fields: [deckId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt userProgress UserFlashcardProgress[] diff --git a/LearningPlatform/scripts/wipe-content-docker.ps1 b/LearningPlatform/scripts/wipe-content-docker.ps1 new file mode 100644 index 0000000..e317883 --- /dev/null +++ b/LearningPlatform/scripts/wipe-content-docker.ps1 @@ -0,0 +1,21 @@ +# Wipe courses, modules, lessons, tasks, and flashcards in the running Docker Postgres. +# Usage (from LearningPlatform/): +# .\scripts\wipe-content-docker.ps1 + +$ErrorActionPreference = 'Stop' +$container = 'learningplatform-postgres-1' +$sqlFile = Join-Path $PSScriptRoot 'wipe-content.sql' + +if (-not (docker inspect $container 2>$null)) { + Write-Error "Container '$container' is not running. Start with: docker compose up -d" +} + +Write-Host '[INFO] This will DELETE all courses, modules, lessons, tasks, and flashcards.' +$confirm = Read-Host 'Type YES to continue' +if ($confirm -ne 'YES') { + Write-Host '[ABORTED]' + exit 1 +} + +Get-Content $sqlFile -Raw | docker exec -i $container psql -U postgres -d exam_prep_db -v ON_ERROR_STOP=1 +Write-Host '[DONE] Content wiped. Refresh http://localhost:3000/admin' diff --git a/LearningPlatform/scripts/wipe-content.sql b/LearningPlatform/scripts/wipe-content.sql new file mode 100644 index 0000000..ee04d31 --- /dev/null +++ b/LearningPlatform/scripts/wipe-content.sql @@ -0,0 +1,59 @@ +-- Wipe all courses, modules, lessons, tasks (Payload CMS) and flashcards (Prisma). +-- Keeps: users, media, subjects, tags, admin accounts. +-- +-- Docker: +-- docker exec -i learningplatform-postgres-1 psql -U postgres -d exam_prep_db -v ON_ERROR_STOP=1 < scripts/wipe-content.sql +-- Or from LearningPlatform/: +-- Get-Content scripts/wipe-content.sql | docker exec -i learningplatform-postgres-1 psql -U postgres -d exam_prep_db -v ON_ERROR_STOP=1 + +BEGIN; + +-- Prisma: student progress + flashcards +TRUNCATE TABLE + public.task_attempt_tags, + public.task_attempts, + public.task_progress_tags, + public.task_progress, + public.lesson_progress, + public.course_progress, + public.user_flashcard_progress, + public.flashcards, + public.user_standalone_flashcard_decks, + public.flashcard_decks +RESTART IDENTITY CASCADE; + +-- Payload CMS: all tables except auth, media, subjects, migrations, preferences +DO $$ +DECLARE + r RECORD; + keep_tables text[] := ARRAY[ + 'payload_migrations', + 'payload_users', + 'media', + 'subjects', + 'payload_locked_documents', + 'payload_locked_documents_rels', + 'payload_preferences', + 'payload_preferences_rels' + ]; +BEGIN + FOR r IN + SELECT tablename + FROM pg_tables + WHERE schemaname = 'payload' + AND tablename <> ALL (keep_tables) + AND tablename NOT LIKE 'subjects%' + LOOP + EXECUTE format('TRUNCATE TABLE payload.%I RESTART IDENTITY CASCADE', r.tablename); + RAISE NOTICE 'Truncated payload.%', r.tablename; + END LOOP; +END $$; + +COMMIT; + +SELECT 'courses' AS item, COUNT(*)::text AS remaining FROM payload.courses +UNION ALL SELECT 'modules', COUNT(*)::text FROM payload.modules +UNION ALL SELECT 'lessons', COUNT(*)::text FROM payload.lessons +UNION ALL SELECT 'tasks', COUNT(*)::text FROM payload.tasks +UNION ALL SELECT 'flashcards', COUNT(*)::text FROM public.flashcards +UNION ALL SELECT 'flashcard_decks', COUNT(*)::text FROM public.flashcard_decks; diff --git a/LearningPlatform/scripts/wipe-content.ts b/LearningPlatform/scripts/wipe-content.ts new file mode 100644 index 0000000..527a7ed --- /dev/null +++ b/LearningPlatform/scripts/wipe-content.ts @@ -0,0 +1,118 @@ +/** + * Remove all CMS courses (modules, lessons, tasks cascade via Payload hooks) + * and all Prisma flashcard decks/cards/progress. + * + * Does NOT delete: users, media, subjects, tags, creative spaces. + * + * Usage (app root): + * npx tsx --tsconfig tsconfig.scripts.json ./scripts/wipe-content.ts --confirm + * + * Docker (recommended — Payload scripts often fail in the production image): + * Get-Content scripts/wipe-content.sql | docker exec -i learningplatform-postgres-1 psql -U postgres -d exam_prep_db -v ON_ERROR_STOP=1 + * Or: npm run content:wipe:docker + */ +import 'dotenv/config' +import { getPayload } from 'payload' +import config from '../src/payload/payload.config.js' +import { prisma } from '../lib/prisma.js' + +const PAGE_SIZE = 100 + +type CollectionSlug = 'courses' | 'modules' | 'lessons' | 'tasks' + +async function deleteAllInCollection( + payload: Awaited>, + collection: CollectionSlug, +): Promise { + let deleted = 0 + for (;;) { + const res = await payload.find({ + collection, + limit: PAGE_SIZE, + page: 1, + depth: 0, + overrideAccess: true, + }) + if (res.docs.length === 0) break + for (const doc of res.docs) { + await payload.delete({ + collection, + id: doc.id, + overrideAccess: true, + }) + deleted += 1 + console.log(`[DELETE] ${collection} ${doc.id}`) + } + } + return deleted +} + +async function wipeFlashcards(): Promise<{ + progress: number + flashcards: number + enrollments: number + decks: number +}> { + const progress = ( + await prisma.userFlashcardProgress.deleteMany() + ).count + const flashcards = (await prisma.flashcard.deleteMany()).count + const enrollments = ( + await prisma.userStandaloneFlashcardDeck.deleteMany() + ).count + const decks = (await prisma.flashcardDeck.deleteMany()).count + return { progress, flashcards, enrollments, decks } +} + +async function wipeCourseProgress(): Promise { + return (await prisma.courseProgress.deleteMany()).count +} + +async function main() { + const confirmed = + process.argv.includes('--confirm') || + process.env.WIPE_CONTENT_CONFIRM === '1' + + if (!confirmed) { + console.error( + '[ERROR] Destructive wipe aborted. Re-run with --confirm or WIPE_CONTENT_CONFIRM=1', + ) + process.exit(1) + } + + if (!process.env.DATABASE_URL && !process.env.PAYLOAD_DATABASE_URL) { + console.error('[ERROR] Set DATABASE_URL or PAYLOAD_DATABASE_URL') + process.exit(1) + } + + console.log('[INFO] Wiping flashcards (Prisma)...') + const flashSummary = await wipeFlashcards() + console.log('[INFO] Flashcards removed:', flashSummary) + + console.log('[INFO] Wiping Payload CMS content (courses → modules → lessons → tasks)...') + const payload = await getPayload({ config }) + + const deletedCourses = await deleteAllInCollection(payload, 'courses') + const deletedModules = await deleteAllInCollection(payload, 'modules') + const deletedLessons = await deleteAllInCollection(payload, 'lessons') + const deletedTasks = await deleteAllInCollection(payload, 'tasks') + + console.log('[INFO] Wiping orphaned course progress (Prisma)...') + const deletedProgress = await wipeCourseProgress() + + console.log('\n[INFO] Wipe complete:', { + flashcards: flashSummary, + payload: { + courses: deletedCourses, + modules: deletedModules, + lessons: deletedLessons, + tasks: deletedTasks, + }, + courseProgress: deletedProgress, + }) +} + +main().catch((err) => { + console.error('[ERROR]', err) + process.exit(1) +}) diff --git a/LearningPlatform/test/api/flashcard-decks-and-summary.test.ts b/LearningPlatform/test/api/flashcard-decks-and-summary.test.ts index 45d8b07..bb08239 100644 --- a/LearningPlatform/test/api/flashcard-decks-and-summary.test.ts +++ b/LearningPlatform/test/api/flashcard-decks-and-summary.test.ts @@ -5,11 +5,14 @@ const mockPrisma = createMockPrisma() vi.mock('@/lib/prisma', () => ({ prisma: mockPrisma })) vi.mock('@/auth', () => ({ auth: vi.fn() })) +vi.mock('next/cache', () => ({ revalidateTag: vi.fn() })) const { GET: decksGet, POST: decksPost } = await import('@/app/api/flashcard-decks/route') const { getFlashcardDashboardSummary } = await import('@/lib/flashcards-dashboard-summary') const { auth } = await import('@/auth') +const { revalidateTag } = await import('next/cache') const mockedAuth = vi.mocked(auth) +const mockedRevalidateTag = vi.mocked(revalidateTag) function adminSession() { mockedAuth.mockResolvedValue({ @@ -272,6 +275,7 @@ describe('flashcard-decks route', () => { expect(mockPrisma.flashcardDeck.delete).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'sub-1' } }), ) + expect(mockedRevalidateTag).toHaveBeenCalledWith('api-flashcards', 'max') }) it('DELETE /api/flashcard-decks/[id] cascades main deck, subdecks, and flashcards', async () => { @@ -296,6 +300,7 @@ describe('flashcard-decks route', () => { expect(mockPrisma.flashcardDeck.delete).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'main-1' } }), ) + expect(mockedRevalidateTag).toHaveBeenCalledWith('api-flashcards', 'max') }) }) From b9a3d1d252f7d76e0878ba35201056af4fe4ce61 Mon Sep 17 00:00:00 2001 From: kinga Date: Sun, 17 May 2026 21:21:08 +0200 Subject: [PATCH 2/3] feat(export): implement comprehensive export functionality for courses, tags, and flashcards - Added new export scripts for courses, tags, and flashcards, enabling users to back up and migrate content. - Introduced helper functions for exporting course structures and flashcard decks, ensuring compatibility with import processes. - Updated documentation to include new export commands and usage instructions. - Enhanced existing scripts to support dry-run and output directory options for better user control during exports. --- .../documentation/CONTENT_IMPORTS.md | 32 +++ LearningPlatform/package.json | 6 +- .../scripts/imports/helpers/course-export.js | 232 ++++++++++++++++++ .../scripts/imports/helpers/export-utils.js | 157 ++++++++++++ .../imports/helpers/flashcard-export.js | 128 ++++++++++ .../scripts/imports/runners/export-all.js | 60 +++++ .../scripts/imports/runners/export-courses.js | 56 +++++ .../imports/runners/export-flashcards.js | 54 ++++ .../scripts/imports/runners/export-tags.js | 99 ++++++++ 9 files changed, 823 insertions(+), 1 deletion(-) create mode 100644 LearningPlatform/scripts/imports/helpers/course-export.js create mode 100644 LearningPlatform/scripts/imports/helpers/export-utils.js create mode 100644 LearningPlatform/scripts/imports/helpers/flashcard-export.js create mode 100644 LearningPlatform/scripts/imports/runners/export-all.js create mode 100644 LearningPlatform/scripts/imports/runners/export-courses.js create mode 100644 LearningPlatform/scripts/imports/runners/export-flashcards.js create mode 100644 LearningPlatform/scripts/imports/runners/export-tags.js diff --git a/LearningPlatform/documentation/CONTENT_IMPORTS.md b/LearningPlatform/documentation/CONTENT_IMPORTS.md index 92cc86b..2f877fe 100644 --- a/LearningPlatform/documentation/CONTENT_IMPORTS.md +++ b/LearningPlatform/documentation/CONTENT_IMPORTS.md @@ -220,6 +220,38 @@ module.exports = [ Run order remains: **tags → courses → modules → flashcards** so `courseId` / `moduleId` / `tagSlugs` resolve correctly. +## Export (backup / migrate) + +Export writes **the same shapes** as import (`.js` files with `module.exports = …`) under `scripts/imports/data-private/export-/` by default (gitignored). + +| Script | Scope | +|--------|--------| +| `npm run content:export:all` | **tags → courses → flashcards** (recommended) | +| `npm run content:export:tags` | All Prisma tags (`{ name, slug, main? }`) | +| `npm run content:export:course` | One `.js` per Payload course (`{ subject, course, modules }`) | +| `npm run content:export:flashcards` | Deck files (`{ deck, cards }` or chunked subdecks) | + +Flags: `--out `, `--dry-run`, `--used-tags-only` (tags runner / export-all only). + +**Re-import after export** + +1. Copy `export-…/tags/*.js` → `scripts/imports/data-private/tags/` (or `data/tags/`) +2. Copy `export-…/courses/*.js` → `data-private/courses/` +3. Copy `export-…/flashcards/*.js` → `data-private/flashcards/` +4. Run `npm run content:import:all` (same order as export: **tags first**, then courses, then flashcards) + +**Tags:** Yes — tasks and flashcards reference tags by **slug**. Export includes all tags by default; use `--used-tags-only` for a smaller tag file. + +**Media:** Theory images and course covers export as `__IMPORT_PLACEHOLDER_IMAGE__` or a basename under `scripts/imports/assets/` when possible. Put custom images in `assets/` before re-import, or replace in Admin → Media after import. + +**Docker** (from `LearningPlatform/`, stack running): + +```bash +docker compose exec app npm run content:export:all +``` + +Copy files out of the container if needed (`docker cp …`). + ## Commands | Script | Scope | diff --git a/LearningPlatform/package.json b/LearningPlatform/package.json index bd81bcc..633a3a6 100644 --- a/LearningPlatform/package.json +++ b/LearningPlatform/package.json @@ -44,7 +44,11 @@ "content:import:all": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/import-all.js", "payload:cleanup-orphans": "tsx --tsconfig tsconfig.scripts.json ./scripts/cleanup-payload-orphans.ts", "content:wipe": "tsx --tsconfig tsconfig.scripts.json ./scripts/wipe-content.ts", - "content:wipe:docker": "powershell -ExecutionPolicy Bypass -File ./scripts/wipe-content-docker.ps1" + "content:wipe:docker": "powershell -ExecutionPolicy Bypass -File ./scripts/wipe-content-docker.ps1", + "content:export:all": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-all.js", + "content:export:tags": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-tags.js", + "content:export:course": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-courses.js", + "content:export:flashcards": "tsx --tsconfig tsconfig.scripts.json ./scripts/imports/runners/export-flashcards.js" }, "prisma": { "seed": "tsx --tsconfig tsconfig.scripts.json ./prisma/seed.ts" diff --git a/LearningPlatform/scripts/imports/helpers/course-export.js b/LearningPlatform/scripts/imports/helpers/course-export.js new file mode 100644 index 0000000..be36f2f --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/course-export.js @@ -0,0 +1,232 @@ +const { + lexicalToPlainText, + omitUndefined, + mediaRefForImport, +} = require('./export-utils') + +const OA = { overrideAccess: true } + +async function findAll(payload, collection, where, sort) { + const docs = [] + let page = 1 + for (;;) { + const res = await payload.find({ + collection, + where, + sort, + limit: 100, + page, + depth: 2, + ...OA, + }) + docs.push(...res.docs) + if (!res.hasNextPage) break + page += 1 + } + return docs +} + +function exportTheoryBlock(block, mediaById) { + if (!block || typeof block !== 'object') return null + const type = block.blockType + if (!type) return null + + if (type === 'text' || type === 'callout') { + return omitUndefined({ + blockType: type, + ...(type === 'callout' && block.variant != null ? { variant: block.variant } : {}), + ...(type === 'callout' && block.title != null ? { title: block.title } : {}), + content: lexicalToPlainText(block.content), + }) + } + + if (type === 'image') { + const imageId = + typeof block.image === 'object' && block.image != null + ? block.image.id + : block.image + const image = mediaRefForImport(imageId, mediaById) + return omitUndefined({ + blockType: 'image', + image, + caption: block.caption ?? undefined, + align: block.align ?? undefined, + width: block.width ?? undefined, + }) + } + + if (type === 'video') { + return omitUndefined({ + blockType: 'video', + videoUrl: block.videoUrl ?? '', + title: block.title ?? undefined, + caption: block.caption ?? undefined, + aspectRatio: block.aspectRatio === '4:3' ? '4:3' : '16:9', + }) + } + + if (type === 'math') { + return omitUndefined({ + blockType: 'math', + latex: block.latex ?? '', + displayMode: block.displayMode ?? undefined, + note: block.note ?? undefined, + }) + } + + if (type === 'table') { + return omitUndefined({ + blockType: 'table', + caption: block.caption ?? undefined, + hasHeaders: block.hasHeaders !== false, + headers: block.headers ?? undefined, + rows: block.rows ?? undefined, + }) + } + + return omitUndefined({ ...block, blockType: type }) +} + +function exportTask(task) { + const tagSlugs = Array.isArray(task.tags) + ? task.tags.map((t) => t?.slug).filter(Boolean) + : [] + + const out = omitUndefined({ + type: task.type, + order: task.order, + prompt: lexicalToPlainText(task.prompt), + tagSlugs, + points: typeof task.points === 'number' ? task.points : 1, + correctAnswer: + typeof task.correctAnswer === 'string' ? task.correctAnswer : undefined, + autoGrade: typeof task.autoGrade === 'boolean' ? task.autoGrade : undefined, + solution: task.solution ? lexicalToPlainText(task.solution) : undefined, + solutionVideoUrl: + typeof task.solutionVideoUrl === 'string' && task.solutionVideoUrl.trim() + ? task.solutionVideoUrl.trim() + : undefined, + isPublished: task.isPublished === false ? false : undefined, + }) + + if (Array.isArray(task.choices) && task.choices.length > 0) { + out.choices = task.choices.map((c) => { + if (c && typeof c === 'object' && typeof c.text === 'string') { + return { text: c.text } + } + return { text: String(c) } + }) + } + + return out +} + +function exportLesson(lesson, mediaById) { + const blocks = Array.isArray(lesson.theoryBlocks) + ? lesson.theoryBlocks.map((b) => exportTheoryBlock(b, mediaById)).filter(Boolean) + : [] + + return omitUndefined({ + title: lesson.title, + order: lesson.order, + theoryBlocks: blocks, + tasks: [], + isPublished: lesson.isPublished === false ? false : undefined, + }) +} + +async function exportCourseStructure(payload, courseDoc, mediaById) { + const courseId = String(courseDoc.id) + + let subject = { name: 'General', slug: 'general' } + if (courseDoc.subject) { + if (typeof courseDoc.subject === 'object') { + subject = { + name: courseDoc.subject.name || subject.name, + slug: courseDoc.subject.slug || subject.slug, + } + } else { + const sub = await payload.findByID({ + collection: 'subjects', + id: courseDoc.subject, + ...OA, + }) + if (sub) { + subject = { name: sub.name, slug: sub.slug } + } + } + } + + const course = omitUndefined({ + title: courseDoc.title, + slug: courseDoc.slug, + description: lexicalToPlainText(courseDoc.description), + level: courseDoc.level || 'BEGINNER', + isPublished: courseDoc.isPublished === false ? false : undefined, + coverImage: (() => { + const cover = courseDoc.coverImage + const coverId = + typeof cover === 'object' && cover != null ? cover.id : cover + if (coverId == null) return undefined + return mediaRefForImport(coverId, mediaById) + })(), + }) + + const modules = await findAll( + payload, + 'modules', + { course: { equals: courseId } }, + 'order', + ) + + const exportedModules = [] + + for (const mod of modules) { + const moduleId = String(mod.id) + const lessons = await findAll( + payload, + 'lessons', + { + and: [{ course: { equals: courseId } }, { module: { equals: moduleId } }], + }, + 'order', + ) + + const exportedLessons = [] + for (const lesson of lessons) { + const lessonExport = exportLesson(lesson, mediaById) + const tasks = await findAll( + payload, + 'tasks', + { lesson: { contains: String(lesson.id) } }, + 'order', + ) + lessonExport.tasks = tasks.map(exportTask) + exportedLessons.push(lessonExport) + } + + exportedModules.push( + omitUndefined({ + title: mod.title, + order: mod.order, + isPublished: mod.isPublished === false ? false : undefined, + lessons: exportedLessons, + }), + ) + } + + return { + subject, + course, + modules: exportedModules, + } +} + +async function exportAllCourses(payload) { + return findAll(payload, 'courses', {}, '-createdAt') +} + +module.exports = { + exportCourseStructure, + exportAllCourses, +} diff --git a/LearningPlatform/scripts/imports/helpers/export-utils.js b/LearningPlatform/scripts/imports/helpers/export-utils.js new file mode 100644 index 0000000..da3cd3c --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/export-utils.js @@ -0,0 +1,157 @@ +const fs = require('fs') +const path = require('path') + +const PLACEHOLDER_MEDIA_FILENAME = 'lesson-theory-placeholder.svg' +const IMPORT_PLACEHOLDER_IMAGE_TOKEN = '__IMPORT_PLACEHOLDER_IMAGE__' + +function lexicalToPlainText(content) { + if (!content) return '' + if (typeof content === 'string') return content + + const texts = [] + + function walk(node) { + if (!node) return + if (node?.text) texts.push(String(node.text)) + if (Array.isArray(node?.children)) node.children.forEach(walk) + if (node?.root) walk(node.root) + } + + walk(content) + return texts.join(' ').trim() +} + +function parseExportArgs(argv) { + const args = argv || process.argv.slice(2) + const dryRun = args.includes('--dry-run') || process.env.IMPORT_DRY_RUN === '1' + const help = args.includes('--help') || args.includes('-h') + const usedTagsOnly = args.includes('--used-tags-only') + + let outDir = null + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--out' && args[i + 1]) { + outDir = args[i + 1] + i += 1 + } + } + + return { dryRun, help, usedTagsOnly, outDir } +} + +function printExportHelp(title) { + console.log(`${title} +Options: + --out Output directory (default: scripts/imports/data-private/export-) + --used-tags-only Export only tags referenced by tasks/flashcards (default: all tags) + --dry-run Log only, do not write files + --help Show this message + +Re-import order (same as import): + npm run content:import:tags + npm run content:import:course (copy exported courses/*.js into data/courses/ or data-private/courses/) + npm run content:import:flashcards + Or: npm run content:import:all +`) +} + +function defaultExportDir(appRoot) { + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + return path.join(appRoot, 'scripts/imports/data-private', `export-${stamp}`) +} + +function resolveExportDir(appRoot, outDir) { + const dir = outDir ? path.resolve(appRoot, outDir) : defaultExportDir(appRoot) + return dir +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }) +} + +function safeBasename(slug) { + return String(slug) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + || 'export' +} + +/** Write `module.exports = …` file (import-compatible). */ +function writeJsExport(filePath, data, { dryRun }) { + const body = `/**\n * Auto-exported — safe to re-import with content:import:* runners.\n */\nmodule.exports = ${JSON.stringify(data, null, 2)}\n` + if (dryRun) { + console.log(`[DRY-RUN] Would write ${filePath}`) + return + } + ensureDir(path.dirname(filePath)) + fs.writeFileSync(filePath, body, 'utf8') + console.log(`[WRITE] ${filePath}`) +} + +function omitUndefined(obj) { + if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) { + return obj + } + const out = {} + for (const [k, v] of Object.entries(obj)) { + if (v === undefined) continue + out[k] = omitUndefined(v) + } + return out +} + +/** + * Map Payload media id → import token or assets basename. + * @param {Map} mediaById + */ +function mediaRefForImport(mediaId, mediaById) { + if (mediaId == null || mediaId === '') return undefined + const key = String(mediaId) + const row = mediaById.get(key) + const filename = row?.filename ? String(row.filename) : '' + if (!filename) return IMPORT_PLACEHOLDER_IMAGE_TOKEN + if (filename === PLACEHOLDER_MEDIA_FILENAME) { + return IMPORT_PLACEHOLDER_IMAGE_TOKEN + } + const assetPath = path.join(__dirname, '../assets', path.basename(filename)) + if (fs.existsSync(assetPath)) { + return path.basename(filename) + } + return IMPORT_PLACEHOLDER_IMAGE_TOKEN +} + +async function loadMediaById(payload) { + const map = new Map() + let page = 1 + for (;;) { + const res = await payload.find({ + collection: 'media', + limit: 200, + page, + depth: 0, + overrideAccess: true, + }) + for (const doc of res.docs) { + map.set(String(doc.id), { filename: doc.filename }) + } + if (!res.hasNextPage) break + page += 1 + } + return map +} + +module.exports = { + PLACEHOLDER_MEDIA_FILENAME, + IMPORT_PLACEHOLDER_IMAGE_TOKEN, + lexicalToPlainText, + parseExportArgs, + printExportHelp, + defaultExportDir, + resolveExportDir, + ensureDir, + safeBasename, + writeJsExport, + omitUndefined, + mediaRefForImport, + loadMediaById, +} diff --git a/LearningPlatform/scripts/imports/helpers/flashcard-export.js b/LearningPlatform/scripts/imports/helpers/flashcard-export.js new file mode 100644 index 0000000..cb8905b --- /dev/null +++ b/LearningPlatform/scripts/imports/helpers/flashcard-export.js @@ -0,0 +1,128 @@ +const { omitUndefined } = require('./export-utils') + +function deckSpecFromRow(deck, parentSlugById) { + return omitUndefined({ + slug: deck.slug, + name: deck.name, + description: deck.description ?? undefined, + tagSlugs: deck.tags.map((t) => t.slug), + courseId: deck.courseId ?? undefined, + moduleId: deck.moduleId ?? undefined, + parentDeckSlug: deck.parentDeckId ? parentSlugById.get(deck.parentDeckId) : undefined, + subjectId: deck.subjectId ?? undefined, + }) +} + +function cardFromRow(card) { + return omitUndefined({ + question: card.question, + answer: card.answer, + tagSlugs: card.tags.map((t) => t.slug), + }) +} + +/** + * Build import-shaped flashcard file payloads grouped by main deck. + * @returns {Array<{ basename: string, data: unknown }>} + */ +async function buildFlashcardExportFiles(prisma) { + const decks = await prisma.flashcardDeck.findMany({ + include: { tags: true, parentDeck: { select: { slug: true } } }, + orderBy: [{ parentDeckId: 'asc' }, { slug: 'asc' }], + }) + + const cards = await prisma.flashcard.findMany({ + include: { tags: true }, + orderBy: { createdAt: 'asc' }, + }) + + const cardsByDeck = new Map() + for (const card of cards) { + if (!cardsByDeck.has(card.deckId)) cardsByDeck.set(card.deckId, []) + cardsByDeck.get(card.deckId).push(card) + } + + const parentSlugById = new Map(decks.map((d) => [d.id, d.slug])) + const mains = decks.filter((d) => !d.parentDeckId) + const childrenByParent = new Map() + for (const d of decks) { + if (!d.parentDeckId) continue + if (!childrenByParent.has(d.parentDeckId)) childrenByParent.set(d.parentDeckId, []) + childrenByParent.get(d.parentDeckId).push(d) + } + + const files = [] + + for (const main of mains) { + const children = childrenByParent.get(main.id) || [] + const mainCards = (cardsByDeck.get(main.id) || []).map(cardFromRow) + const base = main.slug + + if (main.courseId && children.length > 0) { + files.push({ + basename: `${base}-main-deck`, + data: { + deck: deckSpecFromRow(main, parentSlugById), + cards: [], + }, + }) + const chunk = children.map((sub) => ({ + deck: deckSpecFromRow(sub, parentSlugById), + cards: (cardsByDeck.get(sub.id) || []).map(cardFromRow), + })) + files.push({ + basename: `${base}-subdecks`, + data: chunk, + }) + } else if (children.length > 0) { + files.push({ + basename: `${base}-main-deck`, + data: { + deck: deckSpecFromRow(main, parentSlugById), + cards: mainCards, + }, + }) + const chunk = children.map((sub) => ({ + deck: deckSpecFromRow(sub, parentSlugById), + cards: (cardsByDeck.get(sub.id) || []).map(cardFromRow), + })) + files.push({ + basename: `${base}-subdecks`, + data: chunk, + }) + } else { + files.push({ + basename: base, + data: { + deck: deckSpecFromRow(main, parentSlugById), + cards: mainCards, + }, + }) + } + } + + return files +} + +/** Collect tag slugs used on decks/cards (for --used-tags-only). */ +async function collectUsedTagSlugs(prisma) { + const slugs = new Set() + const deckTags = await prisma.flashcardDeck.findMany({ + include: { tags: { select: { slug: true } } }, + }) + const cardTags = await prisma.flashcard.findMany({ + include: { tags: { select: { slug: true } } }, + }) + for (const d of deckTags) { + for (const t of d.tags) slugs.add(t.slug) + } + for (const c of cardTags) { + for (const t of c.tags) slugs.add(t.slug) + } + return slugs +} + +module.exports = { + buildFlashcardExportFiles, + collectUsedTagSlugs, +} diff --git a/LearningPlatform/scripts/imports/runners/export-all.js b/LearningPlatform/scripts/imports/runners/export-all.js new file mode 100644 index 0000000..fab3d2b --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-all.js @@ -0,0 +1,60 @@ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { parseExportArgs, printExportHelp, resolveExportDir, ensureDir } = require('../helpers/export-utils') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseExportArgs(argv) + if (opts.help) { + printExportHelp('export-all.js — tags → courses → flashcards (import-compatible)') + return 0 + } + + const outRoot = resolveExportDir(APP_ROOT, opts.outDir) + if (!opts.dryRun) { + ensureDir(outRoot) + } + console.log(`[INFO] export-all: ${outRoot}`) + + const sharedOut = ['--out', outRoot] + if (opts.dryRun) sharedOut.push('--dry-run') + if (opts.usedTagsOnly) sharedOut.push('--used-tags-only') + + const tags = require('./export-tags') + const courses = require('./export-courses') + const flashcards = require('./export-flashcards') + + const steps = [ + { name: 'tags', run: () => tags.run(sharedOut) }, + { name: 'courses', run: () => courses.run(sharedOut) }, + { name: 'flashcards', run: () => flashcards.run(sharedOut) }, + ] + + for (const step of steps) { + console.log(`\n========== [INFO] Export step: ${step.name} ==========`) + // eslint-disable-next-line no-await-in-loop + const code = await step.run() + if (code !== 0) { + console.error(`[ERROR] export-all: failed at "${step.name}"`) + return code + } + } + + console.log('\n[INFO] export-all complete') + console.log('[HINT] Re-import: copy files into scripts/imports/data-private/{tags,courses,flashcards}/ then:') + console.log(' npm run content:import:all') + return 0 +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR] export-all:', err.message || err) + process.exit(1) + }) +} + +module.exports = { run } diff --git a/LearningPlatform/scripts/imports/runners/export-courses.js b/LearningPlatform/scripts/imports/runners/export-courses.js new file mode 100644 index 0000000..d86ffc9 --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-courses.js @@ -0,0 +1,56 @@ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { parseExportArgs, printExportHelp, resolveExportDir, writeJsExport, safeBasename, loadMediaById } = require('../helpers/export-utils') +const { initPayloadClient } = require('../helpers/payload-client') +const { exportAllCourses, exportCourseStructure } = require('../helpers/course-export') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseExportArgs(argv) + if (opts.help) { + printExportHelp('export-courses.js — Payload courses → import-compatible .js files') + return 0 + } + + const outRoot = resolveExportDir(APP_ROOT, opts.outDir) + const coursesDir = path.join(outRoot, 'courses') + console.log(`[INFO] Export directory: ${outRoot}`) + + let payload + try { + payload = await initPayloadClient(process.env.PAYLOAD_SECRET) + } catch (err) { + console.error('[ERROR] Payload init failed:', err.message || err) + return 1 + } + + const mediaById = await loadMediaById(payload) + const courses = await exportAllCourses(payload) + + if (courses.length === 0) { + console.log('[INFO] No courses to export') + return 0 + } + + for (const courseDoc of courses) { + const structure = await exportCourseStructure(payload, courseDoc, mediaById) + const file = path.join(coursesDir, `${safeBasename(structure.course.slug)}.js`) + writeJsExport(file, structure, { dryRun: opts.dryRun }) + } + + console.log(`[INFO] Exported ${courses.length} course(s)`) + return 0 +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR]', err.message || err) + process.exit(1) + }) +} + +module.exports = { run } diff --git a/LearningPlatform/scripts/imports/runners/export-flashcards.js b/LearningPlatform/scripts/imports/runners/export-flashcards.js new file mode 100644 index 0000000..d27188e --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-flashcards.js @@ -0,0 +1,54 @@ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { parseExportArgs, printExportHelp, resolveExportDir, writeJsExport, safeBasename } = require('../helpers/export-utils') +const { createPrismaClient } = require('../helpers/prisma-client') +const { buildFlashcardExportFiles } = require('../helpers/flashcard-export') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseExportArgs(argv) + if (opts.help) { + printExportHelp('export-flashcards.js — Prisma flashcard decks → import-compatible .js files') + return 0 + } + + const outRoot = resolveExportDir(APP_ROOT, opts.outDir) + const flashcardsDir = path.join(outRoot, 'flashcards') + console.log(`[INFO] Export directory: ${outRoot}`) + + const { prisma, disconnect } = createPrismaClient() + + try { + const files = await buildFlashcardExportFiles(prisma) + if (files.length === 0) { + console.log('[INFO] No flashcard decks to export') + await disconnect() + return 0 + } + + for (const { basename, data } of files) { + const file = path.join(flashcardsDir, `${safeBasename(basename)}.js`) + writeJsExport(file, data, { dryRun: opts.dryRun }) + } + + console.log(`[INFO] Exported ${files.length} flashcard file(s)`) + await disconnect() + return 0 + } catch (err) { + await disconnect() + throw err + } +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR]', err.message || err) + process.exit(1) + }) +} + +module.exports = { run } diff --git a/LearningPlatform/scripts/imports/runners/export-tags.js b/LearningPlatform/scripts/imports/runners/export-tags.js new file mode 100644 index 0000000..5416275 --- /dev/null +++ b/LearningPlatform/scripts/imports/runners/export-tags.js @@ -0,0 +1,99 @@ +const path = require('path') +const { loadEnv } = require('../helpers/utils') +const { parseExportArgs, printExportHelp, resolveExportDir, writeJsExport } = require('../helpers/export-utils') +const { createPrismaClient } = require('../helpers/prisma-client') +const { initPayloadClient } = require('../helpers/payload-client') +const { collectUsedTagSlugs } = require('../helpers/flashcard-export') + +const APP_ROOT = path.join(__dirname, '../../..') + +async function collectPayloadTaskTagSlugs(payload) { + const slugs = new Set() + let page = 1 + for (;;) { + const res = await payload.find({ + collection: 'tasks', + limit: 200, + page, + depth: 0, + overrideAccess: true, + }) + for (const task of res.docs) { + for (const t of task.tags || []) { + if (t?.slug) slugs.add(String(t.slug)) + } + } + if (!res.hasNextPage) break + page += 1 + } + return slugs +} + +async function runExportTags({ outRoot, opts, payload }) { + const { prisma, disconnect } = createPrismaClient() + try { + const tagsDir = path.join(outRoot, 'tags') + let tags + + if (opts.usedTagsOnly) { + const used = new Set([...(await collectUsedTagSlugs(prisma))]) + if (payload) { + for (const s of await collectPayloadTaskTagSlugs(payload)) used.add(s) + } + tags = await prisma.tag.findMany({ + where: { slug: { in: [...used] } }, + orderBy: { slug: 'asc' }, + }) + } else { + tags = await prisma.tag.findMany({ orderBy: { slug: 'asc' } }) + } + + const payload = tags.map((t) => ({ + name: t.name, + slug: t.slug, + ...(t.main ? { main: true } : {}), + })) + + writeJsExport(path.join(tagsDir, 'exported-tags.js'), payload, { dryRun: opts.dryRun }) + console.log(`[INFO] Exported ${payload.length} tag(s)`) + await disconnect() + return 0 + } catch (err) { + await disconnect() + throw err + } +} + +async function run(argv) { + loadEnv(APP_ROOT) + const opts = parseExportArgs(argv) + if (opts.help) { + printExportHelp('export-tags.js — Prisma tags → import-compatible .js') + return 0 + } + const outRoot = resolveExportDir(APP_ROOT, opts.outDir) + console.log(`[INFO] Export directory: ${outRoot}`) + + let payload = null + if (opts.usedTagsOnly) { + try { + payload = await initPayloadClient(process.env.PAYLOAD_SECRET) + } catch (err) { + console.error('[ERROR] Payload init failed (needed for --used-tags-only):', err.message || err) + return 1 + } + } + + return runExportTags({ outRoot, opts, payload }) +} + +if (require.main === module) { + run() + .then((code) => process.exit(code)) + .catch((err) => { + console.error('[ERROR]', err.message || err) + process.exit(1) + }) +} + +module.exports = { run, runExportTags } From cd177a0c31e7da56f0cab842083f2fb091816c42 Mon Sep 17 00:00:00 2001 From: kinga Date: Tue, 16 Jun 2026 12:02:26 +0200 Subject: [PATCH 3/3] feat(content-bundles): add import/export functionality for course bundles - Introduced `ContentBundleImportPanel` component for importing course bundles, allowing users to manage course content more efficiently. - Added `CourseExportButton` component for exporting courses as bundles, including associated media and metadata. - Implemented backend logic for zipping and importing course bundles, enhancing content sharing capabilities. - Updated documentation to include instructions for using the new import/export features and bundle structure. --- LearningPlatform/.gitignore | 3 + .../app/(admin)/admin/dashboard/page.tsx | 3 + .../admin/content-bundle-import-panel.tsx | 174 ++++++ .../components/admin/course-export-button.tsx | 59 ++ .../components/admin/courses-list.tsx | 7 + LearningPlatform/docker-compose.yml | 2 + .../documentation/CONTENT_IMPORTS.md | 39 ++ .../documentation/DOCKER-HUB-RUN.md | 0 LearningPlatform/lib/content-bundle/server.ts | 131 +++++ LearningPlatform/package-lock.json | 532 +++++++++++++++++- LearningPlatform/package.json | 4 + .../imports/helpers/bundle-export-course.js | 96 ++++ .../scripts/imports/helpers/bundle-media.js | 143 +++++ .../imports/helpers/collect-tag-slugs.js | 37 ++ .../scripts/imports/helpers/course-export.js | 53 +- .../scripts/imports/helpers/course-import.js | 6 + .../imports/helpers/export-tags-helper.js | 28 + .../scripts/imports/helpers/export-utils.js | 25 +- .../imports/helpers/flashcard-export.js | 65 ++- .../imports/helpers/flashcard-import.js | 28 +- .../scripts/imports/helpers/media-import.js | 35 ++ .../scripts/imports/helpers/utils.js | 7 + .../scripts/imports/runners/export-bundle.js | 140 +++++ .../scripts/imports/runners/export-courses.js | 40 +- .../imports/runners/export-flashcards.js | 30 +- .../scripts/imports/runners/import-bundle.js | 108 ++++ .../imports/runners/import-flashcards.js | 14 +- 27 files changed, 1753 insertions(+), 56 deletions(-) create mode 100644 LearningPlatform/components/admin/content-bundle-import-panel.tsx create mode 100644 LearningPlatform/components/admin/course-export-button.tsx rename DOCKER-HUB-RUN.md => LearningPlatform/documentation/DOCKER-HUB-RUN.md (100%) create mode 100644 LearningPlatform/lib/content-bundle/server.ts create mode 100644 LearningPlatform/scripts/imports/helpers/bundle-export-course.js create mode 100644 LearningPlatform/scripts/imports/helpers/bundle-media.js create mode 100644 LearningPlatform/scripts/imports/helpers/collect-tag-slugs.js create mode 100644 LearningPlatform/scripts/imports/helpers/export-tags-helper.js create mode 100644 LearningPlatform/scripts/imports/helpers/media-import.js create mode 100644 LearningPlatform/scripts/imports/runners/export-bundle.js create mode 100644 LearningPlatform/scripts/imports/runners/import-bundle.js diff --git a/LearningPlatform/.gitignore b/LearningPlatform/.gitignore index 7547adf..a09be91 100644 --- a/LearningPlatform/.gitignore +++ b/LearningPlatform/.gitignore @@ -52,3 +52,6 @@ public/media # private local-only import data scripts/imports/data-private/**/*.js + +# shareable course bundles (export from your machine) +content-bundles/ diff --git a/LearningPlatform/app/(admin)/admin/dashboard/page.tsx b/LearningPlatform/app/(admin)/admin/dashboard/page.tsx index 0db2a79..d2be94e 100644 --- a/LearningPlatform/app/(admin)/admin/dashboard/page.tsx +++ b/LearningPlatform/app/(admin)/admin/dashboard/page.tsx @@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { ReloadButton } from '@/components/ui/reload-button' import { CoursesList } from '@/components/admin/courses-list' import { AddCourseButton } from '@/components/admin/add-course-button' +import { ContentBundleImportPanel } from '@/components/admin/content-bundle-import-panel' import { BookOpen, Users, GraduationCap } from 'lucide-react' import { adminGlassCard } from '@/lib/student-glass-styles' import { cn } from '@/lib/utils' @@ -105,6 +106,8 @@ export default async function AdminDashboardPage() {
+ + Courses diff --git a/LearningPlatform/components/admin/content-bundle-import-panel.tsx b/LearningPlatform/components/admin/content-bundle-import-panel.tsx new file mode 100644 index 0000000..78f8f2d --- /dev/null +++ b/LearningPlatform/components/admin/content-bundle-import-panel.tsx @@ -0,0 +1,174 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { FolderInput, Loader2, RefreshCw } from 'lucide-react' +import { adminGlassCard } from '@/lib/student-glass-styles' +import { cn } from '@/lib/utils' + +type IncomingBundle = { + name: string + courseTitle?: string + courseSlug?: string + exportedAt?: string +} + +export function ContentBundleImportPanel() { + const router = useRouter() + const [bundles, setBundles] = useState([]) + const [incomingDir, setIncomingDir] = useState('') + const [loadingList, setLoadingList] = useState(true) + const [importing, setImporting] = useState(null) + const [error, setError] = useState(null) + + const loadBundles = useCallback(async () => { + setLoadingList(true) + setError(null) + try { + const res = await fetch('/api/admin/content-bundles/incoming') + const data = (await res.json()) as { + error?: string + incomingDir?: string + bundles?: IncomingBundle[] + } + if (!res.ok) throw new Error(data.error || 'Failed to load bundles') + setIncomingDir(data.incomingDir || '') + setBundles(data.bundles || []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load bundles') + setBundles([]) + } finally { + setLoadingList(false) + } + }, []) + + useEffect(() => { + void loadBundles() + }, [loadBundles]) + + const handleImport = async (folderName: string, label: string) => { + const confirmed = confirm( + `Import course bundle "${label}"?\n\nExisting content with the same slugs may be updated.`, + ) + if (!confirmed) return + + setImporting(folderName) + try { + const res = await fetch('/api/admin/content-bundles/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folderName }), + }) + const data = (await res.json()) as { error?: string; assetsCopied?: number } + if (!res.ok) throw new Error(data.error || 'Import failed') + + const mediaNote = + typeof data.assetsCopied === 'number' && data.assetsCopied > 0 + ? ` (${data.assetsCopied} image${data.assetsCopied === 1 ? '' : 's'} copied)` + : '' + + alert(`Course bundle imported successfully${mediaNote}.`) + router.refresh() + await loadBundles() + } catch (err) { + alert(`Import failed: ${err instanceof Error ? err.message : 'Unknown error'}`) + } finally { + setImporting(null) + } + } + + return ( + + +
+ + Import course bundle + + + Unzip an exported course into{' '} + content-bundles/incoming/{' '} + (folder name becomes the bundle id), then import it here. + +
+ +
+ + {incomingDir ? ( +

+ Watching: {incomingDir} +

+ ) : null} + + {error ?

{error}

: null} + + {loadingList ? ( +
+ + Loading bundles… +
+ ) : bundles.length === 0 ? ( +
+ +

No bundles in the incoming folder yet.

+

+ Example: content-bundles/incoming/my-math-course/ +

+
+ ) : ( +
    + {bundles.map((bundle) => { + const label = bundle.courseTitle || bundle.name + return ( +
  • +
    +

    {label}

    +

    {bundle.name}

    + {bundle.courseSlug || bundle.exportedAt ? ( +

    + {bundle.courseSlug ? `Slug: ${bundle.courseSlug}` : null} + {bundle.courseSlug && bundle.exportedAt ? ' · ' : null} + {bundle.exportedAt + ? `Exported: ${new Date(bundle.exportedAt).toLocaleString('en-US')}` + : null} +

    + ) : null} +
    + +
  • + ) + })} +
+ )} +
+
+ ) +} diff --git a/LearningPlatform/components/admin/course-export-button.tsx b/LearningPlatform/components/admin/course-export-button.tsx new file mode 100644 index 0000000..f31d3e0 --- /dev/null +++ b/LearningPlatform/components/admin/course-export-button.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Download, Loader2 } from 'lucide-react' +type CourseExportButtonProps = { + courseId: string + courseSlug: string + disabled?: boolean +} + +export function CourseExportButton({ courseId, courseSlug, disabled }: CourseExportButtonProps) { + const [loading, setLoading] = useState(false) + + const handleExport = async () => { + setLoading(true) + try { + const res = await fetch(`/api/admin/content-bundles/export/${encodeURIComponent(courseId)}`) + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(data?.error || 'Export failed') + } + + const blob = await res.blob() + const disposition = res.headers.get('Content-Disposition') + let filename = `${courseSlug}-bundle.zip` + const match = disposition?.match(/filename="([^"]+)"/) + if (match?.[1]) filename = match[1] + + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + URL.revokeObjectURL(url) + } catch (error) { + alert(`Failed to export course: ${error instanceof Error ? error.message : 'Unknown error'}`) + } finally { + setLoading(false) + } + } + + return ( + + ) +} diff --git a/LearningPlatform/components/admin/courses-list.tsx b/LearningPlatform/components/admin/courses-list.tsx index 24ef0c3..ad7fa9a 100644 --- a/LearningPlatform/components/admin/courses-list.tsx +++ b/LearningPlatform/components/admin/courses-list.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { studentGlassPill } from '@/lib/student-glass-styles' import { cn } from '@/lib/utils' import { Edit, Eye, Trash2, CheckCircle, Circle } from 'lucide-react' +import { CourseExportButton } from '@/components/admin/course-export-button' import { toggleCoursePublish, deleteCourse } from '@/app/(admin)/admin/actions' import { useRouter } from 'next/navigation' import { useState } from 'react' @@ -141,6 +142,12 @@ export function CoursesList({ courses }: { courses: Course[] }) { + +