sync: learning operations frontend 2/2
This commit is contained in:
parent
7c7aec1984
commit
0dd318d7c9
@ -26,11 +26,33 @@ export type LearnerCommunityMembership = {
|
|||||||
source?: string
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LearnerCommunityAccess = {
|
export type LearnerCommunityAuthor = { id: string; name: string }
|
||||||
allowed: boolean
|
|
||||||
reason?: string
|
export type LearnerCommunityReply = {
|
||||||
|
id: string
|
||||||
|
postId: string
|
||||||
|
body: string
|
||||||
|
status?: string
|
||||||
|
createdAt?: number
|
||||||
|
editedAt?: number
|
||||||
|
author: LearnerCommunityAuthor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LearnerCommunityPost = {
|
||||||
|
id: string
|
||||||
|
communityId: string
|
||||||
|
channelId: string
|
||||||
|
body: string
|
||||||
|
status?: string
|
||||||
|
pinned?: boolean
|
||||||
|
createdAt?: number
|
||||||
|
editedAt?: number
|
||||||
|
author: LearnerCommunityAuthor
|
||||||
|
replies: LearnerCommunityReply[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LearnerCommunityAccess = { allowed: boolean; reason?: string }
|
||||||
|
|
||||||
export type LearnerCommunityItem = {
|
export type LearnerCommunityItem = {
|
||||||
community: LearnerCommunity
|
community: LearnerCommunity
|
||||||
channels: LearnerCommunityChannel[]
|
channels: LearnerCommunityChannel[]
|
||||||
@ -38,83 +60,76 @@ export type LearnerCommunityItem = {
|
|||||||
access: LearnerCommunityAccess
|
access: LearnerCommunityAccess
|
||||||
}
|
}
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' }
|
||||||
return typeof value === 'string' ? value.trim() : ''
|
function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined }
|
||||||
}
|
function asBool(value: unknown): boolean | undefined { return typeof value === 'boolean' ? value : undefined }
|
||||||
|
|
||||||
function asBool(value: unknown): boolean | undefined {
|
|
||||||
return typeof value === 'boolean' ? value : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCommunity(raw: unknown): LearnerCommunity {
|
function normalizeCommunity(raw: unknown): LearnerCommunity {
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
return {
|
return { id: asString(source.id), name: asString(source.name), slug: asString(source.slug) || undefined, description: asString(source.description) || undefined, visibility: asString(source.visibility) || undefined, accessPolicy: asString(source.accessPolicy) || undefined }
|
||||||
id: asString(source.id),
|
|
||||||
name: asString(source.name),
|
|
||||||
slug: asString(source.slug) || undefined,
|
|
||||||
description: asString(source.description) || undefined,
|
|
||||||
visibility: asString(source.visibility) || undefined,
|
|
||||||
accessPolicy: asString(source.accessPolicy) || undefined,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeChannel(raw: unknown): LearnerCommunityChannel {
|
function normalizeChannel(raw: unknown): LearnerCommunityChannel {
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
return {
|
return { id: asString(source.id), name: asString(source.name), slug: asString(source.slug) || undefined, topic: asString(source.topic) || undefined, isPrivate: asBool(source.isPrivate) }
|
||||||
id: asString(source.id),
|
|
||||||
name: asString(source.name),
|
|
||||||
slug: asString(source.slug) || undefined,
|
|
||||||
topic: asString(source.topic) || undefined,
|
|
||||||
isPrivate: asBool(source.isPrivate),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMembership(raw: unknown): LearnerCommunityMembership | undefined {
|
function normalizeMembership(raw: unknown): LearnerCommunityMembership | undefined {
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
const id = asString(source.id)
|
const id = asString(source.id)
|
||||||
if (!id) return undefined
|
if (!id) return undefined
|
||||||
return {
|
return { id, communityId: asString(source.communityId), userId: asString(source.userId), status: asString(source.status) || undefined, role: asString(source.role) || undefined, source: asString(source.source) || undefined }
|
||||||
id,
|
|
||||||
communityId: asString(source.communityId),
|
|
||||||
userId: asString(source.userId),
|
|
||||||
status: asString(source.status) || undefined,
|
|
||||||
role: asString(source.role) || undefined,
|
|
||||||
source: asString(source.source) || undefined,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAccess(raw: unknown): LearnerCommunityAccess {
|
function normalizeAccess(raw: unknown): LearnerCommunityAccess {
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
return {
|
return { allowed: Boolean(source.allowed), reason: asString(source.reason) || undefined }
|
||||||
allowed: Boolean(source.allowed),
|
}
|
||||||
reason: asString(source.reason) || undefined,
|
|
||||||
}
|
function normalizeAuthor(raw: unknown): LearnerCommunityAuthor {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
return { id: asString(source.id), name: asString(source.name) || 'Member' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReply(raw: unknown): LearnerCommunityReply {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
return { id: asString(source.id), postId: asString(source.postId), body: asString(source.body), status: asString(source.status) || undefined, createdAt: asNumber(source.createdAt), editedAt: asNumber(source.editedAt), author: normalizeAuthor(source.author) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePost(raw: unknown): LearnerCommunityPost {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
const replies = Array.isArray(source.replies) ? source.replies.map(normalizeReply) : []
|
||||||
|
return { id: asString(source.id), communityId: asString(source.communityId), channelId: asString(source.channelId), body: asString(source.body), status: asString(source.status) || undefined, pinned: Boolean(source.pinned), createdAt: asNumber(source.createdAt), editedAt: asNumber(source.editedAt), author: normalizeAuthor(source.author), replies }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLearnerCommunities(): Promise<LearnerCommunityItem[]> {
|
export async function fetchLearnerCommunities(): Promise<LearnerCommunityItem[]> {
|
||||||
const result = await apiRequest<unknown[]>('/api/learner/communities')
|
const result = await apiRequest<unknown[]>('/api/learner/communities')
|
||||||
if (!Array.isArray(result)) return []
|
if (!Array.isArray(result)) return []
|
||||||
|
|
||||||
return result.map(raw => {
|
return result.map(raw => {
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
const channelsRaw = Array.isArray(source.channels) ? source.channels : []
|
const channelsRaw = Array.isArray(source.channels) ? source.channels : []
|
||||||
return {
|
return { community: normalizeCommunity(source.community), channels: channelsRaw.map(normalizeChannel).filter(channel => Boolean(channel.id && channel.name)), membership: normalizeMembership(source.membership), access: normalizeAccess(source.access) }
|
||||||
community: normalizeCommunity(source.community),
|
|
||||||
channels: channelsRaw.map(normalizeChannel).filter(channel => Boolean(channel.id && channel.name)),
|
|
||||||
membership: normalizeMembership(source.membership),
|
|
||||||
access: normalizeAccess(source.access),
|
|
||||||
}
|
|
||||||
}).filter(item => Boolean(item.community.id && item.community.name))
|
}).filter(item => Boolean(item.community.id && item.community.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function joinLearnerCommunity(communityID: string): Promise<LearnerCommunityMembership> {
|
export async function joinLearnerCommunity(communityID: string): Promise<LearnerCommunityMembership> {
|
||||||
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, {
|
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, { method: 'POST', body: JSON.stringify({}) })
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
})
|
|
||||||
const membership = normalizeMembership(result)
|
const membership = normalizeMembership(result)
|
||||||
if (!membership) {
|
if (!membership) throw new Error('Join response did not include membership details')
|
||||||
throw new Error('Join response did not include membership details')
|
|
||||||
}
|
|
||||||
return membership
|
return membership
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchCommunityPosts(communityID: string, channelID: string): Promise<LearnerCommunityPost[]> {
|
||||||
|
const result = await apiRequest<unknown[]>(`/api/learner/communities/${encodeURIComponent(communityID)}/channels/${encodeURIComponent(channelID)}/posts`)
|
||||||
|
return Array.isArray(result) ? result.map(normalizePost).filter(post => Boolean(post.id)) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCommunityPost(communityID: string, channelID: string, body: string): Promise<LearnerCommunityPost> {
|
||||||
|
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/channels/${encodeURIComponent(channelID)}/posts`, { method: 'POST', body: JSON.stringify({ body }) })
|
||||||
|
return normalizePost(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCommunityReply(communityID: string, postID: string, body: string): Promise<LearnerCommunityReply> {
|
||||||
|
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/posts/${encodeURIComponent(postID)}/replies`, { method: 'POST', body: JSON.stringify({ body }) })
|
||||||
|
return normalizeReply(result)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from './learner-live-courses'
|
import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from './learner-live-courses'
|
||||||
import { fetchPublicLiveCourses, fetchPublicLiveCourseBySlug, type LiveCourse } from './live-courses'
|
import { fetchPublicLiveCourses, fetchPublicLiveCourseBySlug, type LiveCourse } from './live-courses'
|
||||||
|
import { apiRequest } from './api/client'
|
||||||
|
|
||||||
export type LearnerCourseCard = {
|
export type LearnerCourseCard = {
|
||||||
id: string
|
id: string
|
||||||
@ -15,13 +16,39 @@ export type LearnerCourseCard = {
|
|||||||
lockMessage?: string
|
lockMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LearnerCourseLesson = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
contentType: 'text' | 'video' | 'link' | 'document'
|
||||||
|
content?: string
|
||||||
|
sourceUrl?: string
|
||||||
|
durationMinutes?: number
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LearnerCourseModule = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
lessons: LearnerCourseLesson[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LearnerCourseAsset = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
fileName?: string
|
||||||
|
protectionLevel?: string
|
||||||
|
allowDownload: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type LearnerCourseDetail = {
|
export type LearnerCourseDetail = {
|
||||||
card: LearnerCourseCard
|
card: LearnerCourseCard
|
||||||
cohorts: {
|
cohorts: { id: string; title: string; status?: string }[]
|
||||||
id: string
|
modules: LearnerCourseModule[]
|
||||||
title: string
|
assets: LearnerCourseAsset[]
|
||||||
status?: string
|
progress: { total: number; completed: number; percent: number }
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCourseSlug(slug: string | undefined, fallbackID: string): string {
|
function normalizeCourseSlug(slug: string | undefined, fallbackID: string): string {
|
||||||
@ -42,7 +69,7 @@ function buildEnrolledCard(item: LearnerLiveCourseItem): LearnerCourseCard {
|
|||||||
description: item.course.description,
|
description: item.course.description,
|
||||||
source: 'enrolled',
|
source: 'enrolled',
|
||||||
statusLabel: status,
|
statusLabel: status,
|
||||||
progressLabel: 'Progress tracking is not yet available for this course.',
|
progressLabel: typeof item.enrollment.progress === 'number' ? `${Math.round(item.enrollment.progress)}% complete` : 'Ready to continue',
|
||||||
actionLabel: 'Resume',
|
actionLabel: 'Resume',
|
||||||
locked,
|
locked,
|
||||||
lockReason: reason,
|
lockReason: reason,
|
||||||
@ -131,9 +158,60 @@ export async function fetchLearnerCourseDetailBySlug(slug: string): Promise<Lear
|
|||||||
status: item.enrollment.status,
|
status: item.enrollment.status,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const [content, assets] = await Promise.all([
|
||||||
|
apiRequest<Record<string, unknown>>(`/api/learner/live-courses/${encodeURIComponent(card.id)}/content`),
|
||||||
|
apiRequest<unknown[]>(`/api/learner/live-courses/${encodeURIComponent(card.id)}/assets`),
|
||||||
|
])
|
||||||
|
const modulesRaw = Array.isArray(content.modules) ? content.modules : []
|
||||||
|
const progressRaw = (content.progress ?? {}) as Record<string, unknown>
|
||||||
|
const modules: LearnerCourseModule[] = modulesRaw.map(raw => {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
const module = (source.module ?? {}) as Record<string, unknown>
|
||||||
|
const lessonsRaw = Array.isArray(source.lessons) ? source.lessons : []
|
||||||
|
return {
|
||||||
|
id: String(module.id ?? ''),
|
||||||
|
title: String(module.title ?? ''),
|
||||||
|
description: typeof module.description === 'string' ? module.description : undefined,
|
||||||
|
lessons: lessonsRaw.map(itemRaw => {
|
||||||
|
const item = (itemRaw ?? {}) as Record<string, unknown>
|
||||||
|
const lesson = (item.lesson ?? {}) as Record<string, unknown>
|
||||||
|
const lessonProgress = (item.progress ?? {}) as Record<string, unknown>
|
||||||
|
const contentType = String(lesson.contentType ?? 'text') as LearnerCourseLesson['contentType']
|
||||||
|
return {
|
||||||
|
id: String(lesson.id ?? ''),
|
||||||
|
title: String(lesson.title ?? ''),
|
||||||
|
description: typeof lesson.description === 'string' ? lesson.description : undefined,
|
||||||
|
contentType,
|
||||||
|
content: typeof lesson.content === 'string' ? lesson.content : undefined,
|
||||||
|
sourceUrl: typeof lesson.sourceUrl === 'string' ? lesson.sourceUrl : undefined,
|
||||||
|
durationMinutes: typeof lesson.durationMinutes === 'number' ? lesson.durationMinutes : undefined,
|
||||||
|
completed: String(lessonProgress.status ?? '').toLowerCase() === 'completed',
|
||||||
|
}
|
||||||
|
}).filter(lesson => lesson.id && lesson.title),
|
||||||
|
}
|
||||||
|
}).filter(module => module.id && module.title)
|
||||||
|
const normalizedAssets: LearnerCourseAsset[] = Array.isArray(assets) ? assets.map(raw => {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
id: String(source.id ?? ''),
|
||||||
|
title: String(source.title ?? ''),
|
||||||
|
description: typeof source.description === 'string' ? source.description : undefined,
|
||||||
|
fileName: typeof source.fileName === 'string' ? source.fileName : undefined,
|
||||||
|
protectionLevel: typeof source.protectionLevel === 'string' ? source.protectionLevel : undefined,
|
||||||
|
allowDownload: Boolean(source.allowDownload),
|
||||||
|
}
|
||||||
|
}).filter(asset => asset.id && asset.title) : []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
card,
|
card,
|
||||||
cohorts: courseCohorts,
|
cohorts: courseCohorts,
|
||||||
|
modules,
|
||||||
|
assets: normalizedAssets,
|
||||||
|
progress: {
|
||||||
|
total: Number(progressRaw.total ?? 0),
|
||||||
|
completed: Number(progressRaw.completed ?? 0),
|
||||||
|
percent: Number(progressRaw.percent ?? 0),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -145,5 +223,26 @@ export async function fetchLearnerCourseDetailBySlug(slug: string): Promise<Lear
|
|||||||
description: course.description || card.description,
|
description: course.description || card.description,
|
||||||
},
|
},
|
||||||
cohorts: [],
|
cohorts: [],
|
||||||
|
modules: [],
|
||||||
|
assets: [],
|
||||||
|
progress: { total: 0, completed: 0, percent: 0 },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function completeLearnerLesson(courseID: string, lessonID: string): Promise<{ percent: number }> {
|
||||||
|
const result = await apiRequest<Record<string, unknown>>(`/api/learner/live-courses/${encodeURIComponent(courseID)}/lessons/${encodeURIComponent(lessonID)}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
const summary = (result.courseProgress ?? {}) as Record<string, unknown>
|
||||||
|
return { percent: Number(summary.percent ?? 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accessLearnerCourseAsset(courseID: string, assetID: string): Promise<string> {
|
||||||
|
const result = await apiRequest<{ url?: string }>(`/api/learner/live-courses/${encodeURIComponent(courseID)}/assets/${encodeURIComponent(assetID)}/access`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
return String(result.url ?? '')
|
||||||
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export type LearnerLiveCourseItem = {
|
|||||||
enrollment: {
|
enrollment: {
|
||||||
id: string
|
id: string
|
||||||
status?: string
|
status?: string
|
||||||
|
progress?: number
|
||||||
}
|
}
|
||||||
access: LearnerAccess
|
access: LearnerAccess
|
||||||
upcomingSessions: LearnerLiveSession[]
|
upcomingSessions: LearnerLiveSession[]
|
||||||
@ -119,6 +120,7 @@ export async function fetchLearnerLiveCourses(): Promise<LearnerLiveCourseItem[]
|
|||||||
enrollment: {
|
enrollment: {
|
||||||
id: asString(((source.enrollment ?? {}) as Record<string, unknown>).id),
|
id: asString(((source.enrollment ?? {}) as Record<string, unknown>).id),
|
||||||
status: asString(((source.enrollment ?? {}) as Record<string, unknown>).status) || undefined,
|
status: asString(((source.enrollment ?? {}) as Record<string, unknown>).status) || undefined,
|
||||||
|
progress: asNumber(((source.enrollment ?? {}) as Record<string, unknown>).progress),
|
||||||
},
|
},
|
||||||
access: normalizeAccess(source.access),
|
access: normalizeAccess(source.access),
|
||||||
upcomingSessions: sessionItems,
|
upcomingSessions: sessionItems,
|
||||||
|
|||||||
@ -1,19 +1,8 @@
|
|||||||
import { apiRequest } from './api/client'
|
import { apiRequest } from './api/client'
|
||||||
|
import { normalizeCourseForSubjects, type ScormCourse } from './pages/scorm-shared'
|
||||||
|
|
||||||
export type LearnerScormCourse = {
|
export type LearnerScormCourse = ScormCourse & { moduleTypes: string[] }
|
||||||
id: string
|
export type LearnerScormEnrollment = { id: string; status?: string; progress?: number; score?: number }
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
subject?: string
|
|
||||||
gradeLevel?: string
|
|
||||||
moduleTypes: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LearnerScormEnrollment = {
|
|
||||||
id: string
|
|
||||||
status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LearnerScormLaunchState = {
|
export type LearnerScormLaunchState = {
|
||||||
allowed: boolean
|
allowed: boolean
|
||||||
state: 'ready' | 'locked' | 'missing_package' | 'export_not_ready'
|
state: 'ready' | 'locked' | 'missing_package' | 'export_not_ready'
|
||||||
@ -23,95 +12,90 @@ export type LearnerScormLaunchState = {
|
|||||||
resumeAvailable: boolean
|
resumeAvailable: boolean
|
||||||
progressNote?: string
|
progressNote?: string
|
||||||
}
|
}
|
||||||
|
export type LearnerScormCourseItem = { course: LearnerScormCourse; enrollment: LearnerScormEnrollment; launchState: LearnerScormLaunchState }
|
||||||
|
export type LearnerScormLaunchDetail = LearnerScormCourseItem
|
||||||
|
|
||||||
export type LearnerScormCourseItem = {
|
export type LearnerScormRuntime = {
|
||||||
course: LearnerScormCourse
|
id?: string
|
||||||
enrollment: LearnerScormEnrollment
|
userId?: string
|
||||||
launchState: LearnerScormLaunchState
|
courseId: string
|
||||||
|
enrollmentId?: string
|
||||||
|
scoId: string
|
||||||
|
status?: string
|
||||||
|
location?: string
|
||||||
|
suspendData?: string
|
||||||
|
lessonStatus?: string
|
||||||
|
completionStatus?: string
|
||||||
|
successStatus?: string
|
||||||
|
score?: number
|
||||||
|
progress?: number
|
||||||
|
values: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LearnerScormLaunchDetail = {
|
function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' }
|
||||||
course: LearnerScormCourse
|
function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined }
|
||||||
enrollment: LearnerScormEnrollment
|
|
||||||
launchState: LearnerScormLaunchState
|
|
||||||
}
|
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
|
||||||
return typeof value === 'string' ? value.trim() : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCourse(raw: unknown): LearnerScormCourse {
|
function normalizeCourse(raw: unknown): LearnerScormCourse {
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
const scos = Array.isArray(source.scos) ? source.scos : []
|
const normalized = normalizeCourseForSubjects(source as unknown as ScormCourse)
|
||||||
const moduleTypes = scos
|
const moduleTypes = normalized.scos.map(sco => sco.moduleType).filter(Boolean)
|
||||||
.map(item => {
|
return { ...normalized, moduleTypes: [...new Set(moduleTypes)] }
|
||||||
const entry = (item ?? {}) as Record<string, unknown>
|
|
||||||
return asString(entry.moduleType)
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
return {
|
|
||||||
id: asString(source.id),
|
|
||||||
title: asString(source.title),
|
|
||||||
description: asString(source.description) || undefined,
|
|
||||||
subject: asString(source.subject) || undefined,
|
|
||||||
gradeLevel: asString(source.gradeLevel) || undefined,
|
|
||||||
moduleTypes: [...new Set(moduleTypes)],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEnrollment(raw: unknown): LearnerScormEnrollment {
|
function normalizeEnrollment(raw: unknown): LearnerScormEnrollment {
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
return {
|
return { id: asString(source.id), status: asString(source.status) || undefined, progress: asNumber(source.progress), score: asNumber(source.score) }
|
||||||
id: asString(source.id),
|
|
||||||
status: asString(source.status) || undefined,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeLaunchState(raw: unknown): LearnerScormLaunchState {
|
function normalizeLaunchState(raw: unknown): LearnerScormLaunchState {
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
const state = asString(source.state)
|
const state = asString(source.state)
|
||||||
const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready'
|
const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready' ? state : 'locked'
|
||||||
? state
|
|
||||||
: 'locked'
|
|
||||||
const reason = asString(source.reason) || undefined
|
const reason = asString(source.reason) || undefined
|
||||||
const message = asString(source.message) || (reason === 'missing_entitlement'
|
const message = asString(source.message) || (reason === 'missing_entitlement'
|
||||||
? 'Access is locked because your entitlement is missing or inactive.'
|
? 'Access is locked because your entitlement is missing or inactive.'
|
||||||
: undefined)
|
: undefined)
|
||||||
|
return { allowed: Boolean(source.allowed), state: normalizedState, reason, message, launchUrl: asString(source.launchUrl) || undefined, resumeAvailable: Boolean(source.resumeAvailable), progressNote: asString(source.progressNote) || undefined }
|
||||||
|
}
|
||||||
|
function normalizeRuntime(raw: unknown): LearnerScormRuntime {
|
||||||
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
return {
|
return {
|
||||||
allowed: Boolean(source.allowed),
|
id: asString(source.id) || undefined,
|
||||||
state: normalizedState,
|
userId: asString(source.userId) || undefined,
|
||||||
reason,
|
courseId: asString(source.courseId),
|
||||||
message,
|
enrollmentId: asString(source.enrollmentId) || undefined,
|
||||||
launchUrl: asString(source.launchUrl) || undefined,
|
scoId: asString(source.scoId),
|
||||||
resumeAvailable: Boolean(source.resumeAvailable),
|
status: asString(source.status) || undefined,
|
||||||
progressNote: asString(source.progressNote) || undefined,
|
location: asString(source.location) || undefined,
|
||||||
|
suspendData: asString(source.suspendData) || undefined,
|
||||||
|
lessonStatus: asString(source.lessonStatus) || undefined,
|
||||||
|
completionStatus: asString(source.completionStatus) || undefined,
|
||||||
|
successStatus: asString(source.successStatus) || undefined,
|
||||||
|
score: asNumber(source.score),
|
||||||
|
progress: asNumber(source.progress),
|
||||||
|
values: source.values && typeof source.values === 'object' ? source.values as Record<string, unknown> : {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLearnerScormCourses(): Promise<LearnerScormCourseItem[]> {
|
export async function fetchLearnerScormCourses(): Promise<LearnerScormCourseItem[]> {
|
||||||
const result = await apiRequest<unknown[]>('/api/learner/scorm', { skipAuth: false })
|
const result = await apiRequest<unknown[]>('/api/learner/scorm', { skipAuth: false })
|
||||||
if (!Array.isArray(result)) return []
|
if (!Array.isArray(result)) return []
|
||||||
|
return result.map(raw => {
|
||||||
return result
|
const source = (raw ?? {}) as Record<string, unknown>
|
||||||
.map(raw => {
|
return { course: normalizeCourse(source.course), enrollment: normalizeEnrollment(source.enrollment), launchState: normalizeLaunchState(source.launchState) }
|
||||||
const source = (raw ?? {}) as Record<string, unknown>
|
}).filter(item => Boolean(item.course.id && item.course.title))
|
||||||
return {
|
|
||||||
course: normalizeCourse(source.course),
|
|
||||||
enrollment: normalizeEnrollment(source.enrollment),
|
|
||||||
launchState: normalizeLaunchState(source.launchState),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(item => Boolean(item.course.id && item.course.title))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLearnerScormLaunch(courseID: string): Promise<LearnerScormLaunchDetail> {
|
export async function fetchLearnerScormLaunch(courseID: string): Promise<LearnerScormLaunchDetail> {
|
||||||
const result = await apiRequest<unknown>(`/api/learner/scorm/${encodeURIComponent(courseID)}/launch`, { skipAuth: false })
|
const result = await apiRequest<unknown>(`/api/learner/scorm/${encodeURIComponent(courseID)}/launch`, { skipAuth: false })
|
||||||
const source = (result ?? {}) as Record<string, unknown>
|
const source = (result ?? {}) as Record<string, unknown>
|
||||||
|
return { course: normalizeCourse(source.course), enrollment: normalizeEnrollment(source.enrollment), launchState: normalizeLaunchState(source.launchState) }
|
||||||
return {
|
}
|
||||||
course: normalizeCourse(source.course),
|
|
||||||
enrollment: normalizeEnrollment(source.enrollment),
|
export async function fetchLearnerScormRuntime(courseID: string, scoID: string): Promise<LearnerScormRuntime> {
|
||||||
launchState: normalizeLaunchState(source.launchState),
|
return normalizeRuntime(await apiRequest<unknown>(`/api/learner/scorm/${encodeURIComponent(courseID)}/runtime?scoId=${encodeURIComponent(scoID)}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function commitLearnerScormRuntime(courseID: string, payload: Partial<LearnerScormRuntime>): Promise<{ runtime: LearnerScormRuntime; courseProgress: { percent: number } }> {
|
||||||
|
const result = await apiRequest<Record<string, unknown>>(`/api/learner/scorm/${encodeURIComponent(courseID)}/runtime`, { method: 'PATCH', body: JSON.stringify(payload) })
|
||||||
|
const progress = (result.courseProgress ?? {}) as Record<string, unknown>
|
||||||
|
return { runtime: normalizeRuntime(result.runtime), courseProgress: { percent: Number(progress.percent ?? 0) } }
|
||||||
}
|
}
|
||||||
|
|||||||
68
Frontend/src/app/pages/AdminCommunitiesPage.tsx
Normal file
68
Frontend/src/app/pages/AdminCommunitiesPage.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { MessageSquare, Plus, ShieldCheck } from 'lucide-react'
|
||||||
|
import { createAdminCommunity, createAdminCommunityChannel, fetchAdminCommunities, fetchAdminCommunityChannels, fetchAdminCommunityPosts, patchAdminCommunityPost, type AdminCommunity, type AdminCommunityChannel, type AdminCommunityPost } from '../admin-communities'
|
||||||
|
|
||||||
|
export function AdminCommunitiesPage() {
|
||||||
|
const [communities, setCommunities] = useState<AdminCommunity[]>([])
|
||||||
|
const [communityID, setCommunityID] = useState('')
|
||||||
|
const [channels, setChannels] = useState<AdminCommunityChannel[]>([])
|
||||||
|
const [channelID, setChannelID] = useState('')
|
||||||
|
const [posts, setPosts] = useState<AdminCommunityPost[]>([])
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [channelName, setChannelName] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const loadCommunities = async () => {
|
||||||
|
const items = await fetchAdminCommunities()
|
||||||
|
setCommunities(items)
|
||||||
|
if (!communityID && items[0]) setCommunityID(items[0].id)
|
||||||
|
}
|
||||||
|
useEffect(() => { void loadCommunities().catch(err => setError(String(err))) }, [])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!communityID) return
|
||||||
|
void fetchAdminCommunityChannels(communityID).then(items => {
|
||||||
|
setChannels(items)
|
||||||
|
setChannelID(current => items.some(item => item.id === current) ? current : (items[0]?.id ?? ''))
|
||||||
|
}).catch(err => setError(String(err)))
|
||||||
|
}, [communityID])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!communityID || !channelID) { setPosts([]); return }
|
||||||
|
void fetchAdminCommunityPosts(communityID, channelID).then(setPosts).catch(err => setError(String(err)))
|
||||||
|
}, [communityID, channelID])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 p-5" data-testid="admin-communities-page">
|
||||||
|
<header><h1 className="text-xl font-semibold text-stone-900">Community moderation</h1><p className="mt-1 text-sm text-stone-600">Create spaces and channels, pin useful discussions, and hide or restore posts.</p></header>
|
||||||
|
{error && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||||
|
<aside className="space-y-3 rounded-2xl border border-stone-200 bg-white p-4">
|
||||||
|
<form className="flex gap-2" onSubmit={event => { event.preventDefault(); if (!name.trim()) return; void createAdminCommunity({ name: name.trim(), visibility: 'private', accessPolicy: 'membership' }).then(() => { setName(''); return loadCommunities() }) }}>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="New community" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" />
|
||||||
|
<button type="submit" className="rounded-lg bg-indigo-600 p-2 text-white"><Plus size={16} /></button>
|
||||||
|
</form>
|
||||||
|
{communities.map(item => <button key={item.id} type="button" onClick={() => setCommunityID(item.id)} className={`w-full rounded-xl px-3 py-3 text-left ${item.id === communityID ? 'bg-indigo-50' : 'hover:bg-stone-50'}`}><p className="font-semibold text-stone-900">{item.name}</p><p className="mt-1 text-xs text-stone-500">{item.visibility || 'private'} · {item.status || 'active'}</p></button>)}
|
||||||
|
</aside>
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-stone-200 bg-white p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={channelName} onChange={e => setChannelName(e.target.value)} placeholder="Add channel" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" />
|
||||||
|
<button type="button" disabled={!communityID || !channelName.trim()} onClick={() => void createAdminCommunityChannel(communityID, { name: channelName.trim(), topic: '', isPrivate: false }).then(async () => { setChannelName(''); const next = await fetchAdminCommunityChannels(communityID); setChannels(next); setChannelID(next.at(-1)?.id ?? channelID) })} className="rounded-lg border border-stone-200 px-3 py-2 text-sm font-semibold">Add channel</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2 overflow-x-auto">{channels.map(channel => <button key={channel.id} type="button" onClick={() => setChannelID(channel.id)} className={`whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-semibold ${channel.id === channelID ? 'bg-indigo-600 text-white' : 'bg-stone-100 text-stone-700'}`}># {channel.name}</button>)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{posts.map(post => <article key={post.id} className="rounded-2xl border border-stone-200 bg-white p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3"><p className="text-xs font-semibold text-stone-600">{post.author?.name || 'Member'}</p><span className="text-xs text-stone-500">{post.status || 'active'}</span></div>
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm text-stone-800">{post.body}</p>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={() => void patchAdminCommunityPost(communityID, post.id, { pinned: !post.pinned }).then(updated => setPosts(prev => prev.map(item => item.id === post.id ? updated : item)))} className="rounded-lg border border-stone-200 px-2.5 py-1.5 text-xs font-semibold">{post.pinned ? 'Unpin' : 'Pin'}</button>
|
||||||
|
<button type="button" onClick={() => void patchAdminCommunityPost(communityID, post.id, { status: post.status === 'hidden' ? 'active' : 'hidden' }).then(updated => setPosts(prev => prev.map(item => item.id === post.id ? updated : item)))} className="rounded-lg border border-stone-200 px-2.5 py-1.5 text-xs font-semibold">{post.status === 'hidden' ? 'Restore' : 'Hide'}</button>
|
||||||
|
</div>
|
||||||
|
</article>)}
|
||||||
|
{channelID && posts.length === 0 && <div className="rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500"><MessageSquare className="mx-auto mb-2" size={20} />No posts in this channel.</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
Frontend/src/app/pages/AdminNotificationsPage.tsx
Normal file
43
Frontend/src/app/pages/AdminNotificationsPage.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Bell, Mail, Plus } from 'lucide-react'
|
||||||
|
import { createNotificationDelivery, createNotificationTemplate, fetchNotificationDeliveries, fetchNotificationTemplates, patchNotificationTemplate, type NotificationDelivery, type NotificationTemplate } from '../admin-notifications'
|
||||||
|
|
||||||
|
export function AdminNotificationsPage() {
|
||||||
|
const [templates, setTemplates] = useState<NotificationTemplate[]>([])
|
||||||
|
const [deliveries, setDeliveries] = useState<NotificationDelivery[]>([])
|
||||||
|
const [tab, setTab] = useState<'templates' | 'deliveries'>('templates')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [subject, setSubject] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [testTo, setTestTo] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const [t, d] = await Promise.all([fetchNotificationTemplates(), fetchNotificationDeliveries()])
|
||||||
|
setTemplates(Array.isArray(t) ? t : [])
|
||||||
|
setDeliveries(Array.isArray(d) ? d : [])
|
||||||
|
}
|
||||||
|
useEffect(() => { void load().catch(err => setError(String(err))) }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5 p-5" data-testid="admin-notifications-page">
|
||||||
|
<header><h1 className="text-xl font-semibold text-stone-900">Notification operations</h1><p className="mt-1 text-sm text-stone-600">Manage transactional templates and inspect delivery outcomes from the existing notification queue.</p></header>
|
||||||
|
{error && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{error}</p>}
|
||||||
|
<div className="flex gap-2"><button type="button" onClick={() => setTab('templates')} className={`rounded-full px-3 py-1.5 text-sm font-semibold ${tab === 'templates' ? 'bg-indigo-600 text-white' : 'bg-stone-100 text-stone-700'}`}>Templates</button><button type="button" onClick={() => setTab('deliveries')} className={`rounded-full px-3 py-1.5 text-sm font-semibold ${tab === 'deliveries' ? 'bg-indigo-600 text-white' : 'bg-stone-100 text-stone-700'}`}>Deliveries</button></div>
|
||||||
|
{tab === 'templates' && <div className="grid gap-4 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<form className="space-y-3 rounded-2xl border border-stone-200 bg-white p-4" onSubmit={event => { event.preventDefault(); if (!name.trim()) return; void createNotificationTemplate({ name: name.trim(), channel: 'email', subject, body, enabled: true }).then(() => { setName(''); setSubject(''); setBody(''); return load() }) }}>
|
||||||
|
<h2 className="font-semibold text-stone-900">New email template</h2>
|
||||||
|
<input value={name} onChange={e => setName(e.target.value)} placeholder="Template name" className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm" />
|
||||||
|
<input value={subject} onChange={e => setSubject(e.target.value)} placeholder="Subject" className="w-full rounded-lg border border-stone-200 px-3 py-2 text-sm" />
|
||||||
|
<textarea value={body} onChange={e => setBody(e.target.value)} placeholder="Message body" className="min-h-32 w-full rounded-lg border border-stone-200 p-3 text-sm" />
|
||||||
|
<button type="submit" className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white"><Plus size={15} />Create template</button>
|
||||||
|
</form>
|
||||||
|
<div className="space-y-3">{templates.map(template => <article key={template.id} className="rounded-2xl border border-stone-200 bg-white p-4"><div className="flex items-start justify-between gap-3"><div><p className="font-semibold text-stone-900">{template.name}</p><p className="mt-1 text-sm text-stone-600">{template.subject}</p></div><button type="button" onClick={() => void patchNotificationTemplate(template.id, { enabled: !template.enabled }).then(updated => setTemplates(prev => prev.map(item => item.id === updated.id ? updated : item)))} className={`rounded-full px-2 py-1 text-xs font-semibold ${template.enabled === false ? 'bg-stone-100 text-stone-600' : 'bg-emerald-50 text-emerald-700'}`}>{template.enabled === false ? 'Disabled' : 'Enabled'}</button></div><p className="mt-3 whitespace-pre-wrap text-sm text-stone-700">{template.body}</p></article>)}</div>
|
||||||
|
</div>}
|
||||||
|
{tab === 'deliveries' && <div className="space-y-4">
|
||||||
|
<form className="flex flex-col gap-2 rounded-2xl border border-stone-200 bg-white p-4 sm:flex-row" onSubmit={event => { event.preventDefault(); if (!testTo.trim()) return; void createNotificationDelivery({ channel: 'email', to: testTo.trim(), subject: 'Learn platform test notification', body: 'Notification delivery test from the admin workspace.', sync: true }).then(() => { setTestTo(''); return load() }) }}><input value={testTo} onChange={e => setTestTo(e.target.value)} type="email" placeholder="recipient@example.com" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" /><button type="submit" className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white"><Mail size={15} />Send test</button></form>
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white"><table className="responsive-table min-w-full text-sm"><thead className="bg-stone-50 text-left text-xs text-stone-500"><tr><th className="px-3 py-2">Recipient</th><th className="px-3 py-2">Status</th><th className="px-3 py-2">Provider</th><th className="px-3 py-2">Subject</th></tr></thead><tbody>{deliveries.map(item => <tr key={item.id} className="border-t border-stone-100"><td data-primary="true" data-label="Recipient" className="px-3 py-2 font-medium text-stone-900">{item.to || '—'}</td><td data-label="Status" className="px-3 py-2">{item.status || 'unknown'}</td><td data-label="Provider" className="px-3 py-2">{item.provider || '—'}</td><td data-label="Subject" className="px-3 py-2">{item.subject || '—'}</td></tr>)}</tbody></table>{deliveries.length === 0 && <p className="p-6 text-center text-sm text-stone-500"><Bell className="mx-auto mb-2" size={18} />No deliveries recorded yet.</p>}</div>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,124 +1,188 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { MessageSquare, Users } from 'lucide-react'
|
import { MessageSquare, Pin, Send, Users } from 'lucide-react'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { fetchLearnerCommunities, joinLearnerCommunity, type LearnerCommunityItem } from '../learner-communities'
|
import {
|
||||||
|
createCommunityPost,
|
||||||
|
createCommunityReply,
|
||||||
|
fetchCommunityPosts,
|
||||||
|
fetchLearnerCommunities,
|
||||||
|
joinLearnerCommunity,
|
||||||
|
type LearnerCommunityItem,
|
||||||
|
type LearnerCommunityPost,
|
||||||
|
} from '../learner-communities'
|
||||||
|
|
||||||
type LoadState = 'loading' | 'ready' | 'error'
|
type LoadState = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
function formatDate(value?: number) {
|
||||||
|
if (!value) return ''
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
export function LearnerCommunityPage() {
|
export function LearnerCommunityPage() {
|
||||||
const [items, setItems] = useState<LearnerCommunityItem[]>([])
|
const [items, setItems] = useState<LearnerCommunityItem[]>([])
|
||||||
const [state, setState] = useState<LoadState>('loading')
|
const [state, setState] = useState<LoadState>('loading')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [joiningID, setJoiningID] = useState('')
|
const [joiningID, setJoiningID] = useState('')
|
||||||
|
const [selectedCommunityID, setSelectedCommunityID] = useState('')
|
||||||
|
const [selectedChannelID, setSelectedChannelID] = useState('')
|
||||||
|
const [posts, setPosts] = useState<LearnerCommunityPost[]>([])
|
||||||
|
const [postBody, setPostBody] = useState('')
|
||||||
|
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({})
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
void fetchLearnerCommunities().then(result => {
|
||||||
const load = async () => {
|
if (cancelled) return
|
||||||
try {
|
setItems(result)
|
||||||
setState('loading')
|
const first = result[0]
|
||||||
const result = await fetchLearnerCommunities()
|
setSelectedCommunityID(first?.community.id ?? '')
|
||||||
if (cancelled) return
|
setSelectedChannelID(first?.channels[0]?.id ?? '')
|
||||||
setItems(result)
|
setState('ready')
|
||||||
setState('ready')
|
}).catch(err => {
|
||||||
} catch (err) {
|
if (cancelled) return
|
||||||
if (cancelled) return
|
setError(err instanceof ApiError ? err.message : 'Failed to load communities')
|
||||||
const message = err instanceof ApiError ? err.message : 'Failed to load communities'
|
setState('error')
|
||||||
setError(message)
|
})
|
||||||
setState('error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void load()
|
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const selected = useMemo(() => items.find(item => item.community.id === selectedCommunityID) ?? null, [items, selectedCommunityID])
|
||||||
|
const joined = selected?.membership?.status?.toLowerCase() === 'active'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCommunityID || !selectedChannelID) {
|
||||||
|
setPosts([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void fetchCommunityPosts(selectedCommunityID, selectedChannelID).then(setPosts).catch(err => {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to load community posts')
|
||||||
|
})
|
||||||
|
}, [selectedCommunityID, selectedChannelID])
|
||||||
|
|
||||||
const handleJoin = async (communityID: string) => {
|
const handleJoin = async (communityID: string) => {
|
||||||
try {
|
try {
|
||||||
setJoiningID(communityID)
|
setJoiningID(communityID)
|
||||||
const membership = await joinLearnerCommunity(communityID)
|
const membership = await joinLearnerCommunity(communityID)
|
||||||
setItems(prev => prev.map(item => (
|
setItems(prev => prev.map(item => item.community.id === communityID ? { ...item, membership } : item))
|
||||||
item.community.id === communityID
|
|
||||||
? { ...item, membership }
|
|
||||||
: item
|
|
||||||
)))
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof ApiError ? err.message : 'Unable to join community'
|
setError(err instanceof ApiError ? err.message : 'Unable to join community')
|
||||||
setError(message)
|
|
||||||
} finally {
|
} finally {
|
||||||
setJoiningID('')
|
setJoiningID('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submitPost = async () => {
|
||||||
|
if (!selectedCommunityID || !selectedChannelID || !postBody.trim()) return
|
||||||
|
try {
|
||||||
|
setSending(true)
|
||||||
|
const post = await createCommunityPost(selectedCommunityID, selectedChannelID, postBody.trim())
|
||||||
|
setPosts(prev => [post, ...prev])
|
||||||
|
setPostBody('')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to create post')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitReply = async (postID: string) => {
|
||||||
|
const body = replyDrafts[postID]?.trim()
|
||||||
|
if (!selectedCommunityID || !body) return
|
||||||
|
try {
|
||||||
|
setSending(true)
|
||||||
|
const reply = await createCommunityReply(selectedCommunityID, postID, body)
|
||||||
|
setPosts(prev => prev.map(post => post.id === postID ? { ...post, replies: [...post.replies, reply] } : post))
|
||||||
|
setReplyDrafts(prev => ({ ...prev, [postID]: '' }))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Unable to reply')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="learner-community-page">
|
<div data-testid="learner-community-page" className="space-y-5">
|
||||||
<header className="mb-6">
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">Community</h1>
|
<h1 className="text-2xl font-semibold tracking-tight text-stone-900">Community</h1>
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
<p className="mt-2 text-sm text-stone-600">Join your school spaces, follow channels, post questions, and reply to other learners.</p>
|
||||||
Access your learning communities and join channels available to your role or entitlements.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
{state === 'loading' && <p className="text-sm text-stone-600">Loading your communities...</p>}
|
||||||
{state === 'loading' && <p className="text-sm text-slate-600">Loading your communities...</p>}
|
|
||||||
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
|
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
{state === 'ready' && items.length === 0 && <div className="rounded-2xl border border-stone-200 bg-stone-50 p-8 text-sm text-stone-600">You do not currently have access to any communities.</div>}
|
||||||
|
|
||||||
{state === 'ready' && (
|
{state === 'ready' && items.length > 0 && (
|
||||||
<section className="space-y-5">
|
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
|
||||||
{items.length === 0 && (
|
<aside className="space-y-3 rounded-2xl border border-stone-200 bg-white p-3">
|
||||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-8 text-sm text-slate-600">
|
{items.map(item => (
|
||||||
You do not currently have access to any communities.
|
<button key={item.community.id} type="button" onClick={() => {
|
||||||
</div>
|
setSelectedCommunityID(item.community.id)
|
||||||
)}
|
setSelectedChannelID(item.channels[0]?.id ?? '')
|
||||||
|
}} className={`w-full rounded-xl px-3 py-3 text-left transition ${item.community.id === selectedCommunityID ? 'bg-indigo-50 text-indigo-950' : 'hover:bg-stone-50'}`}>
|
||||||
|
<p className="font-semibold">{item.community.name}</p>
|
||||||
|
<p className="mt-1 text-xs text-stone-500">{item.channels.length} channels</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
{items.map(item => {
|
{selected && (
|
||||||
const membershipStatus = item.membership?.status?.toLowerCase() ?? ''
|
<section className="min-w-0 space-y-4">
|
||||||
const isJoined = membershipStatus === 'active'
|
<div className="rounded-2xl border border-stone-200 bg-white p-5">
|
||||||
return (
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<article key={item.community.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
<div>
|
||||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
<h2 className="text-xl font-semibold text-stone-900">{selected.community.name}</h2>
|
||||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{item.community.visibility || 'private'}</span>
|
<p className="mt-1 text-sm text-stone-600">{selected.community.description}</p>
|
||||||
<span className="rounded-full bg-sky-100 px-2.5 py-1 font-medium text-sky-800">{item.community.accessPolicy || 'membership'}</span>
|
|
||||||
{isJoined && <span className="rounded-full bg-emerald-100 px-2.5 py-1 font-medium text-emerald-800">Joined</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-2xl font-semibold tracking-tight text-slate-900">{item.community.name}</h2>
|
|
||||||
{item.community.description && <p className="mt-2 text-sm text-slate-600">{item.community.description}</p>}
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2 text-xs font-medium text-slate-600">
|
|
||||||
<Users size={14} />
|
|
||||||
Access: {item.access.reason || 'membership'}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.08em] text-slate-500">Channels</p>
|
|
||||||
<ul className="space-y-2 text-sm text-slate-700">
|
|
||||||
{item.channels.map(channel => (
|
|
||||||
<li key={channel.id} className="flex items-center gap-2">
|
|
||||||
<MessageSquare size={14} className="text-slate-500" />
|
|
||||||
<span>{channel.name}</span>
|
|
||||||
{channel.topic && <span className="text-slate-500">- {channel.topic}</span>}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{item.channels.length === 0 && <li className="text-slate-500">No channels configured yet.</li>}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isJoined && (
|
|
||||||
<div className="mt-5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={joiningID === item.community.id}
|
|
||||||
onClick={() => { void handleJoin(item.community.id) }}
|
|
||||||
className="rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 hover:border-sky-500 hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-70"
|
|
||||||
>
|
|
||||||
{joiningID === item.community.id ? 'Joining...' : 'Join Community'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{!joined && <button type="button" disabled={joiningID === selected.community.id} onClick={() => void handleJoin(selected.community.id)} className="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-60">{joiningID ? 'Joining…' : 'Join community'}</button>}
|
||||||
</article>
|
</div>
|
||||||
)
|
<div className="mt-4 flex gap-2 overflow-x-auto pb-1">
|
||||||
})}
|
{selected.channels.map(channel => (
|
||||||
</section>
|
<button key={channel.id} type="button" onClick={() => setSelectedChannelID(channel.id)} className={`whitespace-nowrap rounded-full border px-3 py-1.5 text-xs font-semibold ${channel.id === selectedChannelID ? 'border-indigo-300 bg-indigo-50 text-indigo-800' : 'border-stone-200 bg-white text-stone-600'}`}>
|
||||||
|
# {channel.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{joined && selectedChannelID && (
|
||||||
|
<div className="rounded-2xl border border-stone-200 bg-white p-4">
|
||||||
|
<textarea value={postBody} onChange={event => setPostBody(event.target.value)} placeholder="Start a discussion or ask a question…" className="min-h-24 w-full rounded-xl border border-stone-200 p-3 text-sm outline-none focus:border-indigo-400" />
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<button type="button" disabled={sending || !postBody.trim()} onClick={() => void submitPost()} className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-50"><Send size={15} /> Post</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{posts.map(post => (
|
||||||
|
<article key={post.id} className="rounded-2xl border border-stone-200 bg-white p-5" data-testid={`community-post-${post.id}`}>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-stone-500">
|
||||||
|
<span className="font-semibold text-stone-700">{post.author.name}</span>
|
||||||
|
<span>{formatDate(post.createdAt)}</span>
|
||||||
|
{post.pinned && <span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-amber-700"><Pin size={11} /> Pinned</span>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-stone-800">{post.body}</p>
|
||||||
|
<div className="mt-4 space-y-2 border-l-2 border-stone-100 pl-4">
|
||||||
|
{post.replies.map(reply => (
|
||||||
|
<div key={reply.id} className="rounded-lg bg-stone-50 p-3">
|
||||||
|
<p className="text-xs font-semibold text-stone-700">{reply.author.name}</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm text-stone-700">{reply.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{joined && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={replyDrafts[post.id] ?? ''} onChange={event => setReplyDrafts(prev => ({ ...prev, [post.id]: event.target.value }))} placeholder="Reply…" className="min-w-0 flex-1 rounded-lg border border-stone-200 px-3 py-2 text-sm" />
|
||||||
|
<button type="button" disabled={sending || !(replyDrafts[post.id] ?? '').trim()} onClick={() => void submitReply(post.id)} className="rounded-lg border border-stone-200 px-3 text-sm font-semibold text-stone-700 disabled:opacity-50">Reply</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{posts.length === 0 && selectedChannelID && <div className="rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500"><MessageSquare className="mx-auto mb-2" size={20} />No posts yet. Start the conversation.</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router'
|
import { Link, useParams } from 'react-router'
|
||||||
import { ArrowLeft, Lock, PlayCircle } from 'lucide-react'
|
import { ArrowLeft, CheckCircle2, Download, ExternalLink, Lock, PlayCircle } from 'lucide-react'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { fetchLearnerCourseDetailBySlug, type LearnerCourseDetail } from '../learner-courses'
|
import {
|
||||||
|
accessLearnerCourseAsset,
|
||||||
|
completeLearnerLesson,
|
||||||
|
fetchLearnerCourseDetailBySlug,
|
||||||
|
type LearnerCourseDetail,
|
||||||
|
} from '../learner-courses'
|
||||||
|
|
||||||
type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
|
type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
|
||||||
|
|
||||||
@ -11,113 +16,110 @@ export function LearnerCourseDetailPage() {
|
|||||||
const [detail, setDetail] = useState<LearnerCourseDetail | null>(null)
|
const [detail, setDetail] = useState<LearnerCourseDetail | null>(null)
|
||||||
const [state, setState] = useState<LoadState>('loading')
|
const [state, setState] = useState<LoadState>('loading')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [savingLesson, setSavingLesson] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
const load = async () => {
|
||||||
let cancelled = false
|
try {
|
||||||
|
setState('loading')
|
||||||
const load = async () => {
|
const result = await fetchLearnerCourseDetailBySlug(slug)
|
||||||
try {
|
if (!result) { setState('not-found'); return }
|
||||||
setState('loading')
|
setDetail(result); setState('ready')
|
||||||
const result = await fetchLearnerCourseDetailBySlug(slug)
|
} catch (err) {
|
||||||
if (cancelled) return
|
setError(err instanceof ApiError ? err.message : 'Failed to load learner course detail')
|
||||||
if (!result) {
|
setState('error')
|
||||||
setState('not-found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDetail(result)
|
|
||||||
setState('ready')
|
|
||||||
} catch (err) {
|
|
||||||
if (cancelled) return
|
|
||||||
setError(err instanceof ApiError ? err.message : 'Failed to load learner course detail')
|
|
||||||
setState('error')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void load()
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [slug])
|
|
||||||
|
|
||||||
if (state === 'loading') {
|
|
||||||
return <p className="text-sm text-slate-600">Loading course details...</p>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'not-found') {
|
useEffect(() => { void load() }, [slug])
|
||||||
return (
|
|
||||||
<div>
|
if (state === 'loading') return <p className="text-sm text-stone-600">Loading course details...</p>
|
||||||
<h1 className="text-3xl font-semibold text-slate-900">Course not found</h1>
|
if (state === 'not-found') return <div><h1 className="text-3xl font-semibold text-stone-900">Course not found</h1><p className="mt-2 text-sm text-stone-600">This course may not be available in your learner catalog.</p></div>
|
||||||
<p className="mt-2 text-sm text-slate-600">This course may not be available in your learner catalog.</p>
|
if (state === 'error') return <p className="text-sm text-red-600">{error}</p>
|
||||||
</div>
|
if (!detail) return null
|
||||||
)
|
|
||||||
|
const completeLesson = async (lessonID: string) => {
|
||||||
|
try {
|
||||||
|
setSavingLesson(lessonID)
|
||||||
|
await completeLearnerLesson(detail.card.id, lessonID)
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
setSavingLesson('')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state === 'error') {
|
const openAsset = async (assetID: string) => {
|
||||||
return <p className="text-sm text-red-600">{error}</p>
|
const url = await accessLearnerCourseAsset(detail.card.id, assetID)
|
||||||
|
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article data-testid="learner-course-detail-page">
|
<article data-testid="learner-course-detail-page" className="space-y-5">
|
||||||
<Link data-testid="learner-course-detail-back" to="/learn/courses" className="mb-6 inline-flex items-center gap-2 text-sm font-semibold text-slate-600 hover:text-slate-900">
|
<Link data-testid="learner-course-detail-back" to="/learn/courses" className="inline-flex items-center gap-2 text-sm font-semibold text-stone-600 hover:text-stone-900"><ArrowLeft size={16} />Back to my courses</Link>
|
||||||
<ArrowLeft size={16} />
|
|
||||||
Back to my courses
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<header className="rounded-3xl border border-slate-200 bg-slate-50 p-6 md:p-8">
|
<header className="rounded-3xl border border-stone-200 bg-stone-50 p-6 md:p-8">
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-500">{detail?.card.statusLabel}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-stone-500">{detail.card.statusLabel}</p>
|
||||||
<h1 className="mt-2 text-3xl font-semibold leading-tight tracking-tight text-slate-900 md:text-5xl">{detail?.card.title}</h1>
|
<h1 className="mt-2 text-3xl font-semibold leading-tight tracking-tight text-stone-900 md:text-5xl">{detail.card.title}</h1>
|
||||||
<p className="mt-3 text-sm text-slate-600">{detail?.card.description || 'Course overview details will expand as resource metadata is added.'}</p>
|
<p className="mt-3 text-sm text-stone-600">{detail.card.description}</p>
|
||||||
|
{detail.progress.total > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<div className="flex justify-between text-xs font-medium text-stone-600"><span>{detail.progress.completed} of {detail.progress.total} lessons complete</span><span>{Math.round(detail.progress.percent)}%</span></div>
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-stone-200"><div className="h-full bg-indigo-600 transition-all" style={{ width: `${Math.min(100, detail.progress.percent)}%` }} /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!detail?.card.locked && (
|
{detail.card.locked && <section className="rounded-2xl border border-amber-200 bg-amber-50 p-4"><p className="inline-flex items-center gap-2 text-sm font-semibold text-amber-900"><Lock size={16} />Course Locked</p><p className="mt-2 text-sm text-amber-800">{detail.card.lockMessage}</p></section>}
|
||||||
<div className="mt-6">
|
|
||||||
<Link
|
|
||||||
data-testid="learner-course-detail-resume"
|
|
||||||
to="/learn/live"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800 hover:border-emerald-500 hover:bg-emerald-100"
|
|
||||||
>
|
|
||||||
<PlayCircle size={16} />
|
|
||||||
Resume Course
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detail?.card.locked && (
|
{!detail.card.locked && (
|
||||||
<section className="mt-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
|
<section className="space-y-4">
|
||||||
<p className="inline-flex items-center gap-2 text-sm font-semibold text-amber-900">
|
<h2 className="text-xl font-semibold text-stone-900">Course content</h2>
|
||||||
<Lock size={16} />
|
{detail.modules.map((module, moduleIndex) => (
|
||||||
Course Locked
|
<article key={module.id} className="rounded-2xl border border-stone-200 bg-white p-5">
|
||||||
</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.1em] text-stone-500">Module {moduleIndex + 1}</p>
|
||||||
<p className="mt-2 text-sm text-amber-800">{detail.card.lockMessage || 'This course is currently locked.'}</p>
|
<h3 className="mt-1 text-lg font-semibold text-stone-900">{module.title}</h3>
|
||||||
|
{module.description && <p className="mt-1 text-sm text-stone-600">{module.description}</p>}
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{module.lessons.map((lesson, lessonIndex) => (
|
||||||
|
<div key={lesson.id} className="rounded-xl border border-stone-200 bg-stone-50 p-4" data-testid={`course-lesson-${lesson.id}`}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-stone-500">Lesson {lessonIndex + 1}{lesson.durationMinutes ? ` · ${lesson.durationMinutes} min` : ''}</p>
|
||||||
|
<h4 className="mt-1 font-semibold text-stone-900">{lesson.title}</h4>
|
||||||
|
{lesson.description && <p className="mt-1 text-sm text-stone-600">{lesson.description}</p>}
|
||||||
|
</div>
|
||||||
|
{lesson.completed && <span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-1 text-xs font-semibold text-emerald-700"><CheckCircle2 size={13} />Complete</span>}
|
||||||
|
</div>
|
||||||
|
{lesson.contentType === 'text' && lesson.content && <div className="mt-4 whitespace-pre-wrap text-sm leading-7 text-stone-700">{lesson.content}</div>}
|
||||||
|
{(lesson.contentType === 'video' || lesson.contentType === 'link' || lesson.contentType === 'document') && lesson.sourceUrl && <a href={lesson.sourceUrl} target="_blank" rel="noreferrer" className="mt-4 inline-flex items-center gap-2 text-sm font-semibold text-indigo-700"><ExternalLink size={14} />Open lesson resource</a>}
|
||||||
|
{!lesson.completed && <div className="mt-4"><button type="button" disabled={savingLesson === lesson.id} onClick={() => void completeLesson(lesson.id)} className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-60"><PlayCircle size={14} />{savingLesson === lesson.id ? 'Saving…' : 'Mark complete'}</button></div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{module.lessons.length === 0 && <p className="text-sm text-stone-500">No published lessons in this module yet.</p>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{detail.modules.length === 0 && <div className="rounded-2xl border border-dashed border-stone-300 p-8 text-center text-sm text-stone-500">Course content is being prepared.</div>}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
{detail.assets.length > 0 && (
|
||||||
<h2 className="text-lg font-semibold text-slate-900">Modules</h2>
|
<section className="rounded-2xl border border-stone-200 bg-white p-5">
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
<h2 className="text-lg font-semibold text-stone-900">Resources</h2>
|
||||||
Module-level learner content is not yet tracked here. This placeholder will be replaced once module/course APIs are integrated.
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||||
</p>
|
{detail.assets.map(asset => (
|
||||||
</section>
|
<div key={asset.id} className="rounded-xl border border-stone-200 p-4">
|
||||||
|
<p className="font-semibold text-stone-900">{asset.title}</p>
|
||||||
<section className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
{asset.description && <p className="mt-1 text-sm text-stone-600">{asset.description}</p>}
|
||||||
<h2 className="text-lg font-semibold text-slate-900">Assets and Resources</h2>
|
{asset.allowDownload && <button type="button" onClick={() => void openAsset(asset.id)} className="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-indigo-700"><Download size={14} />Open resource</button>}
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
|
||||||
Asset-level course resources are not yet available in this view. Follow-up work will connect protected assets and downloads.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
|
||||||
<h2 className="text-lg font-semibold text-slate-900">Cohorts</h2>
|
|
||||||
{detail && detail.cohorts.length > 0 ? (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{detail.cohorts.map(cohort => (
|
|
||||||
<div key={cohort.id} className="rounded-lg border border-slate-200 bg-white p-3">
|
|
||||||
<p className="text-sm font-semibold text-slate-800">{cohort.title}</p>
|
|
||||||
{cohort.status && <p className="mt-1 text-xs text-slate-600">Status: {cohort.status}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</section>
|
||||||
<p className="mt-2 text-sm text-slate-600">No learner cohort records are currently available for this course.</p>
|
)}
|
||||||
)}
|
|
||||||
|
<section className="rounded-2xl border border-stone-200 bg-stone-50 p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-stone-900">Cohorts</h2>
|
||||||
|
{detail.cohorts.length > 0 ? <div className="mt-3 space-y-2">{detail.cohorts.map(cohort => <div key={cohort.id} className="rounded-lg border border-stone-200 bg-white p-3"><p className="text-sm font-semibold text-stone-800">{cohort.title}</p>{cohort.status && <p className="mt-1 text-xs text-stone-600">Status: {cohort.status}</p>}</div>)}</div> : <p className="mt-2 text-sm text-stone-600">No cohort records are currently available.</p>}
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,160 +1,139 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Link, useParams } from 'react-router'
|
import { ChevronLeft, ChevronRight, PlayCircle, Save } from 'lucide-react'
|
||||||
import { AlertCircle, ExternalLink, PlayCircle } from 'lucide-react'
|
import { useParams } from 'react-router'
|
||||||
import { ApiError } from '../api/client'
|
import { ApiError } from '../api/client'
|
||||||
import { fetchLearnerScormLaunch, type LearnerScormLaunchDetail } from '../learner-scorm'
|
import { commitLearnerScormRuntime, fetchLearnerScormLaunch, fetchLearnerScormRuntime, type LearnerScormLaunchDetail, type LearnerScormRuntime } from '../learner-scorm'
|
||||||
|
import { generateSCOHtml } from './scorm-shared'
|
||||||
|
|
||||||
type LoadState = 'loading' | 'ready' | 'error'
|
type LoadState = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
function launchStateHeadline(state: LearnerScormLaunchDetail['launchState']['state']): string {
|
|
||||||
if (state === 'missing_package') return 'Package missing'
|
|
||||||
if (state === 'export_not_ready') return 'Export not ready'
|
|
||||||
if (state === 'locked') return 'Access restricted'
|
|
||||||
return 'Ready to launch'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LearnerScormLaunchPage() {
|
export function LearnerScormLaunchPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const { id = '' } = useParams<{ id: string }>()
|
||||||
const courseID = (params.id ?? '').trim()
|
const courseID = id.trim()
|
||||||
|
|
||||||
const [detail, setDetail] = useState<LearnerScormLaunchDetail | null>(null)
|
const [detail, setDetail] = useState<LearnerScormLaunchDetail | null>(null)
|
||||||
|
const [runtime, setRuntime] = useState<LearnerScormRuntime | null>(null)
|
||||||
|
const [scoIndex, setScoIndex] = useState(0)
|
||||||
const [state, setState] = useState<LoadState>('loading')
|
const [state, setState] = useState<LoadState>('loading')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [launchStarted, setLaunchStarted] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const valuesRef = useRef<Record<string, unknown>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!courseID) {
|
if (!courseID) { setError('A SCORM course id is required.'); setState('error'); return }
|
||||||
setState('error')
|
|
||||||
setError('A SCORM course id is required to launch this page.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
void fetchLearnerScormLaunch(courseID).then(result => {
|
||||||
const load = async () => {
|
if (cancelled) return
|
||||||
try {
|
setDetail(result); setState('ready')
|
||||||
setState('loading')
|
}).catch(err => {
|
||||||
const result = await fetchLearnerScormLaunch(courseID)
|
if (cancelled) return
|
||||||
if (cancelled) return
|
setError(err instanceof ApiError ? err.message : 'Failed to load SCORM course'); setState('error')
|
||||||
setDetail(result)
|
})
|
||||||
setState('ready')
|
|
||||||
} catch (err) {
|
|
||||||
if (cancelled) return
|
|
||||||
const message = err instanceof ApiError ? err.message : 'Failed to load learner SCORM launch details'
|
|
||||||
setError(message)
|
|
||||||
setState('error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void load()
|
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [courseID])
|
}, [courseID])
|
||||||
|
|
||||||
const launchState = detail?.launchState
|
const selectedSCO = detail?.course.scos[scoIndex] ?? null
|
||||||
const canLaunch = Boolean(launchState?.allowed && launchState.state === 'ready')
|
|
||||||
const blockedMessage = launchState?.reason === 'missing_entitlement'
|
useEffect(() => {
|
||||||
? 'Launch is blocked until your entitlement is granted or reactivated.'
|
if (!selectedSCO || !courseID) return
|
||||||
: 'Launch is blocked until this SCORM course is ready.'
|
void fetchLearnerScormRuntime(courseID, selectedSCO.id).then(next => {
|
||||||
const title = useMemo(() => {
|
setRuntime(next)
|
||||||
if (!detail?.course.title) return 'SCORM Launch'
|
valuesRef.current = { ...next.values }
|
||||||
return detail.course.title
|
}).catch(err => setError(err instanceof Error ? err.message : 'Unable to load runtime state'))
|
||||||
}, [detail?.course.title])
|
}, [courseID, selectedSCO?.id])
|
||||||
|
|
||||||
|
const commit = useCallback(async () => {
|
||||||
|
if (!selectedSCO || !courseID) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const values = { ...valuesRef.current }
|
||||||
|
const lessonStatus = String(values['cmi.core.lesson_status'] ?? runtime?.lessonStatus ?? '')
|
||||||
|
const completionStatus = String(values['cmi.completion_status'] ?? runtime?.completionStatus ?? '')
|
||||||
|
const successStatus = String(values['cmi.success_status'] ?? runtime?.successStatus ?? '')
|
||||||
|
const score = Number(values['cmi.core.score.raw'] ?? values['cmi.score.raw'] ?? runtime?.score ?? 0)
|
||||||
|
const progress = Number(values['cmi.progress_measure'] ?? runtime?.progress ?? 0)
|
||||||
|
const result = await commitLearnerScormRuntime(courseID, {
|
||||||
|
scoId: selectedSCO.id,
|
||||||
|
status: 'committed',
|
||||||
|
location: String(values['cmi.core.lesson_location'] ?? values['cmi.location'] ?? ''),
|
||||||
|
suspendData: String(values['cmi.suspend_data'] ?? ''),
|
||||||
|
lessonStatus,
|
||||||
|
completionStatus,
|
||||||
|
successStatus,
|
||||||
|
score: Number.isFinite(score) ? score : 0,
|
||||||
|
progress: Number.isFinite(progress) ? (progress <= 1 ? progress * 100 : progress) : 0,
|
||||||
|
values,
|
||||||
|
})
|
||||||
|
setRuntime(result.runtime)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [courseID, runtime, selectedSCO])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedSCO) return
|
||||||
|
const getValue = (key: string) => String(valuesRef.current[key] ?? '')
|
||||||
|
const setValue = (key: string, value: string) => { valuesRef.current[key] = value; return 'true' }
|
||||||
|
const api12 = {
|
||||||
|
LMSInitialize: () => 'true',
|
||||||
|
LMSFinish: () => { void commit(); return 'true' },
|
||||||
|
LMSGetValue: getValue,
|
||||||
|
LMSSetValue: setValue,
|
||||||
|
LMSCommit: () => { void commit(); return 'true' },
|
||||||
|
LMSGetLastError: () => '0',
|
||||||
|
LMSGetErrorString: () => 'No error',
|
||||||
|
LMSGetDiagnostic: () => '',
|
||||||
|
}
|
||||||
|
const api2004 = {
|
||||||
|
Initialize: () => 'true',
|
||||||
|
Terminate: () => { void commit(); return 'true' },
|
||||||
|
GetValue: getValue,
|
||||||
|
SetValue: setValue,
|
||||||
|
Commit: () => { void commit(); return 'true' },
|
||||||
|
GetLastError: () => '0',
|
||||||
|
GetErrorString: () => 'No error',
|
||||||
|
GetDiagnostic: () => '',
|
||||||
|
}
|
||||||
|
const globalWindow = window as unknown as Record<string, unknown>
|
||||||
|
globalWindow.API = api12
|
||||||
|
globalWindow.API_1484_11 = api2004
|
||||||
|
return () => {
|
||||||
|
delete globalWindow.API
|
||||||
|
delete globalWindow.API_1484_11
|
||||||
|
}
|
||||||
|
}, [commit, selectedSCO])
|
||||||
|
|
||||||
|
const html = useMemo(() => detail && selectedSCO ? generateSCOHtml(detail.course, selectedSCO) : '', [detail, selectedSCO])
|
||||||
|
const percent = detail?.enrollment.progress ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="learner-scorm-launch-page">
|
<div data-testid="learner-scorm-launch-page" className="space-y-4">
|
||||||
<header className="mb-6">
|
<header>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">{title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight text-stone-900">{detail?.course.title ?? 'SCORM Launch'}</h1>
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
<p className="mt-2 text-sm text-stone-600">SCORM progress, bookmark data, scores, and suspend data are persisted to your learner record.</p>
|
||||||
Launch your SCORM package and use resume placeholders until runtime progress sync is fully integrated.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
{state === 'loading' && <p className="text-sm text-stone-600">Loading launch details...</p>}
|
||||||
{state === 'loading' && <p className="text-sm text-slate-600">Loading launch details...</p>}
|
|
||||||
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
|
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
{state === 'ready' && detail && !detail.launchState.allowed && <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">{detail.launchState.message || 'This package is not available yet.'}</div>}
|
||||||
{state === 'ready' && detail && launchState && (
|
{state === 'ready' && detail && detail.launchState.allowed && selectedSCO && (
|
||||||
<section className="space-y-5">
|
<>
|
||||||
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-white p-3">
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
<div>
|
||||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{detail.enrollment.status || 'enrolled'}</span>
|
<p className="text-xs font-semibold uppercase tracking-wide text-stone-500">Lesson {scoIndex + 1} of {detail.course.scos.length}</p>
|
||||||
<span className="rounded-full bg-sky-100 px-2.5 py-1 font-medium text-sky-800">{launchState.state}</span>
|
<p className="font-semibold text-stone-900">{selectedSCO.title}</p>
|
||||||
{detail.course.subject && (
|
|
||||||
<span className="rounded-full bg-indigo-100 px-2.5 py-1 font-medium uppercase tracking-wide text-indigo-800">{detail.course.subject}</span>
|
|
||||||
)}
|
|
||||||
{detail.course.gradeLevel && (
|
|
||||||
<span className="rounded-full bg-violet-100 px-2.5 py-1 font-medium uppercase tracking-wide text-violet-800">{detail.course.gradeLevel}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-xl font-semibold text-slate-900">{launchStateHeadline(launchState.state)}</h2>
|
<button type="button" disabled={scoIndex === 0} onClick={() => setScoIndex(index => Math.max(0, index - 1))} className="rounded-lg border border-stone-200 p-2 disabled:opacity-40"><ChevronLeft size={16} /></button>
|
||||||
<p className="mt-2 text-sm text-slate-600">{launchState.message || 'Launch details are not available for this course yet.'}</p>
|
<button data-testid="learner-scorm-launch-primary" type="button" onClick={() => void commit()} disabled={saving} className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-60">{saving ? <Save size={15} /> : <PlayCircle size={15} />}{saving ? 'Saving…' : 'Save progress'}</button>
|
||||||
{detail.course.moduleTypes.length > 0 && (
|
<button type="button" disabled={scoIndex >= detail.course.scos.length - 1} onClick={() => setScoIndex(index => Math.min(detail.course.scos.length - 1, index + 1))} className="rounded-lg border border-stone-200 p-2 disabled:opacity-40"><ChevronRight size={16} /></button>
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
|
||||||
{detail.course.moduleTypes.map(module => (
|
|
||||||
<span key={module} className="rounded bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700">
|
|
||||||
{module.replaceAll('_', ' ')}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{launchState.progressNote && (
|
|
||||||
<div className="mt-4 rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-700">
|
|
||||||
{launchState.progressNote}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!canLaunch && (
|
|
||||||
<div className="mt-4 inline-flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800">
|
|
||||||
<AlertCircle size={14} />
|
|
||||||
{blockedMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
data-testid="learner-scorm-launch-primary"
|
|
||||||
type="button"
|
|
||||||
disabled={!canLaunch}
|
|
||||||
onClick={() => setLaunchStarted(true)}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-sky-300 bg-sky-50 px-3 py-1.5 text-xs font-semibold text-sky-800 enabled:hover:border-sky-500 enabled:hover:bg-sky-100 disabled:cursor-not-allowed disabled:border-slate-200 disabled:bg-slate-100 disabled:text-slate-500"
|
|
||||||
>
|
|
||||||
<PlayCircle size={14} />
|
|
||||||
{launchState.resumeAvailable ? 'Resume package' : 'Launch package'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{launchState.launchUrl && (
|
|
||||||
<a
|
|
||||||
data-testid="learner-scorm-open-export-endpoint"
|
|
||||||
href={launchState.launchUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
|
|
||||||
>
|
|
||||||
<ExternalLink size={14} />
|
|
||||||
Open export endpoint
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Link
|
|
||||||
data-testid="learner-scorm-back-to-courses"
|
|
||||||
to="/learn/scorm"
|
|
||||||
className="inline-flex items-center rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-semibold text-slate-700 hover:border-slate-400 hover:bg-slate-100"
|
|
||||||
>
|
|
||||||
Back to SCORM courses
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
<div className="rounded-2xl border border-stone-200 bg-white p-2">
|
||||||
{launchStarted && canLaunch && (
|
<iframe title={selectedSCO.title} srcDoc={html} sandbox="allow-scripts allow-same-origin" className="h-[65vh] min-h-[520px] w-full rounded-xl border-0 bg-white" />
|
||||||
<article className="rounded-2xl border border-emerald-200 bg-emerald-50 p-6">
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-emerald-900">SCORM launch started</h3>
|
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
|
||||||
<p className="mt-2 text-sm text-emerald-800">
|
Course progress: {Math.round(percent)}% · Runtime status: {runtime?.lessonStatus || runtime?.completionStatus || 'incomplete'}
|
||||||
Runtime playback is staged here. Progress and bookmark resume remain placeholder behavior until full SCORM runtime APIs are connected.
|
</div>
|
||||||
</p>
|
</>
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { KeycloakThemeEditorPage } from '../pages/KeycloakThemeEditorPage'
|
|||||||
import { FormsBuilderPage } from '../pages/FormsBuilderPage'
|
import { FormsBuilderPage } from '../pages/FormsBuilderPage'
|
||||||
import { SitemapMenuBuilderPage } from '../pages/SitemapMenuBuilderPage'
|
import { SitemapMenuBuilderPage } from '../pages/SitemapMenuBuilderPage'
|
||||||
import { FeatureGuardPage } from '../pages/FeatureGuardPage'
|
import { FeatureGuardPage } from '../pages/FeatureGuardPage'
|
||||||
|
import { AdminCommunitiesPage } from '../pages/AdminCommunitiesPage'
|
||||||
|
import { AdminNotificationsPage } from '../pages/AdminNotificationsPage'
|
||||||
import { AdminContentPage } from '../pages/AdminContentPage'
|
import { AdminContentPage } from '../pages/AdminContentPage'
|
||||||
import { AdminLearnersPage } from '../pages/AdminLearnersPage'
|
import { AdminLearnersPage } from '../pages/AdminLearnersPage'
|
||||||
import { AdminSettingsPage } from '../pages/AdminSettingsPage'
|
import { AdminSettingsPage } from '../pages/AdminSettingsPage'
|
||||||
@ -25,18 +27,10 @@ function AdminEmbeddedScormPage() {
|
|||||||
return <ScormPage embedded />
|
return <ScormPage embedded />
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminNotificationsNotEnabledPage() {
|
|
||||||
return <FeatureGuardPage title="Notification Operations" summary="Notification operations are not available in this workspace yet." />
|
|
||||||
}
|
|
||||||
|
|
||||||
function AdminBillingNotEnabledPage() {
|
function AdminBillingNotEnabledPage() {
|
||||||
return <FeatureGuardPage title="Billing Administration" summary="Billing administration is not available in this workspace yet." />
|
return <FeatureGuardPage title="Billing Administration" summary="Billing administration is not available in this workspace yet." />
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminCommunitiesNotEnabledPage() {
|
|
||||||
return <FeatureGuardPage title="Community Moderation" summary="Community moderation tools are not available in this workspace yet." />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const adminRoutes: RouteObject[] = [
|
export const adminRoutes: RouteObject[] = [
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
@ -53,10 +47,10 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
{ path: 'content', Component: AdminContentPage },
|
{ path: 'content', Component: AdminContentPage },
|
||||||
{ path: 'blog', Component: AdminBlogPage, handle: { adminSurface: 'plain' } },
|
{ path: 'blog', Component: AdminBlogPage, handle: { adminSurface: 'plain' } },
|
||||||
{ path: 'learners', Component: AdminLearnersPage },
|
{ path: 'learners', Component: AdminLearnersPage },
|
||||||
{ path: 'notifications', Component: AdminNotificationsNotEnabledPage },
|
{ path: 'notifications', Component: AdminNotificationsPage },
|
||||||
{ path: 'assets', Component: AdminAssetsPage },
|
{ path: 'assets', Component: AdminAssetsPage },
|
||||||
{ path: 'billing', Component: AdminBillingNotEnabledPage },
|
{ path: 'billing', Component: AdminBillingNotEnabledPage },
|
||||||
{ path: 'communities', Component: AdminCommunitiesNotEnabledPage },
|
{ path: 'communities', Component: AdminCommunitiesPage },
|
||||||
{ path: 'audit', Component: AdminAuditPage },
|
{ path: 'audit', Component: AdminAuditPage },
|
||||||
{ path: 'settings', Component: AdminSettingsPage },
|
{ path: 'settings', Component: AdminSettingsPage },
|
||||||
],
|
],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user