sync: learning operations frontend 2/2

This commit is contained in:
Admin 2026-09-27 23:14:52 +00:00
parent 7c7aec1984
commit 0dd318d7c9
10 changed files with 710 additions and 460 deletions

View File

@ -26,11 +26,33 @@ export type LearnerCommunityMembership = {
source?: string
}
export type LearnerCommunityAccess = {
allowed: boolean
reason?: string
export type LearnerCommunityAuthor = { id: string; name: 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 = {
community: LearnerCommunity
channels: LearnerCommunityChannel[]
@ -38,83 +60,76 @@ export type LearnerCommunityItem = {
access: LearnerCommunityAccess
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asBool(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined
}
function asString(value: unknown): string { 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 normalizeCommunity(raw: unknown): LearnerCommunity {
const source = (raw ?? {}) as Record<string, unknown>
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,
}
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 }
}
function normalizeChannel(raw: unknown): LearnerCommunityChannel {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
name: asString(source.name),
slug: asString(source.slug) || undefined,
topic: asString(source.topic) || undefined,
isPrivate: asBool(source.isPrivate),
}
return { 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 {
const source = (raw ?? {}) as Record<string, unknown>
const id = asString(source.id)
if (!id) return undefined
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,
}
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 }
}
function normalizeAccess(raw: unknown): LearnerCommunityAccess {
const source = (raw ?? {}) as Record<string, unknown>
return {
allowed: Boolean(source.allowed),
reason: asString(source.reason) || undefined,
}
return { 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[]> {
const result = await apiRequest<unknown[]>('/api/learner/communities')
if (!Array.isArray(result)) return []
return result.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
const channelsRaw = Array.isArray(source.channels) ? source.channels : []
return {
community: normalizeCommunity(source.community),
channels: channelsRaw.map(normalizeChannel).filter(channel => Boolean(channel.id && channel.name)),
membership: normalizeMembership(source.membership),
access: normalizeAccess(source.access),
}
return { 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))
}
export async function joinLearnerCommunity(communityID: string): Promise<LearnerCommunityMembership> {
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, {
method: 'POST',
body: JSON.stringify({}),
})
const result = await apiRequest<unknown>(`/api/learner/communities/${encodeURIComponent(communityID)}/join`, { method: 'POST', body: JSON.stringify({}) })
const membership = normalizeMembership(result)
if (!membership) {
throw new Error('Join response did not include membership details')
}
if (!membership) throw new Error('Join response did not include membership details')
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)
}

View File

@ -1,5 +1,6 @@
import { fetchLearnerLiveCourses, type LearnerLiveCourseItem } from './learner-live-courses'
import { fetchPublicLiveCourses, fetchPublicLiveCourseBySlug, type LiveCourse } from './live-courses'
import { apiRequest } from './api/client'
export type LearnerCourseCard = {
id: string
@ -15,13 +16,39 @@ export type LearnerCourseCard = {
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 = {
card: LearnerCourseCard
cohorts: {
id: string
title: string
status?: string
}[]
cohorts: { id: string; title: string; status?: string }[]
modules: LearnerCourseModule[]
assets: LearnerCourseAsset[]
progress: { total: number; completed: number; percent: number }
}
function normalizeCourseSlug(slug: string | undefined, fallbackID: string): string {
@ -42,7 +69,7 @@ function buildEnrolledCard(item: LearnerLiveCourseItem): LearnerCourseCard {
description: item.course.description,
source: 'enrolled',
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',
locked,
lockReason: reason,
@ -131,9 +158,60 @@ export async function fetchLearnerCourseDetailBySlug(slug: string): Promise<Lear
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 {
card,
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,
},
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 ?? '')
}

View File

@ -38,6 +38,7 @@ export type LearnerLiveCourseItem = {
enrollment: {
id: string
status?: string
progress?: number
}
access: LearnerAccess
upcomingSessions: LearnerLiveSession[]
@ -119,6 +120,7 @@ export async function fetchLearnerLiveCourses(): Promise<LearnerLiveCourseItem[]
enrollment: {
id: asString(((source.enrollment ?? {}) as Record<string, unknown>).id),
status: asString(((source.enrollment ?? {}) as Record<string, unknown>).status) || undefined,
progress: asNumber(((source.enrollment ?? {}) as Record<string, unknown>).progress),
},
access: normalizeAccess(source.access),
upcomingSessions: sessionItems,

View File

@ -1,19 +1,8 @@
import { apiRequest } from './api/client'
import { normalizeCourseForSubjects, type ScormCourse } from './pages/scorm-shared'
export type LearnerScormCourse = {
id: string
title: string
description?: string
subject?: string
gradeLevel?: string
moduleTypes: string[]
}
export type LearnerScormEnrollment = {
id: string
status?: string
}
export type LearnerScormCourse = ScormCourse & { moduleTypes: string[] }
export type LearnerScormEnrollment = { id: string; status?: string; progress?: number; score?: number }
export type LearnerScormLaunchState = {
allowed: boolean
state: 'ready' | 'locked' | 'missing_package' | 'export_not_ready'
@ -23,95 +12,90 @@ export type LearnerScormLaunchState = {
resumeAvailable: boolean
progressNote?: string
}
export type LearnerScormCourseItem = { course: LearnerScormCourse; enrollment: LearnerScormEnrollment; launchState: LearnerScormLaunchState }
export type LearnerScormLaunchDetail = LearnerScormCourseItem
export type LearnerScormCourseItem = {
course: LearnerScormCourse
enrollment: LearnerScormEnrollment
launchState: LearnerScormLaunchState
export type LearnerScormRuntime = {
id?: string
userId?: string
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 = {
course: LearnerScormCourse
enrollment: LearnerScormEnrollment
launchState: LearnerScormLaunchState
}
function asString(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' }
function asNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined }
function normalizeCourse(raw: unknown): LearnerScormCourse {
const source = (raw ?? {}) as Record<string, unknown>
const scos = Array.isArray(source.scos) ? source.scos : []
const moduleTypes = scos
.map(item => {
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)],
}
const normalized = normalizeCourseForSubjects(source as unknown as ScormCourse)
const moduleTypes = normalized.scos.map(sco => sco.moduleType).filter(Boolean)
return { ...normalized, moduleTypes: [...new Set(moduleTypes)] }
}
function normalizeEnrollment(raw: unknown): LearnerScormEnrollment {
const source = (raw ?? {}) as Record<string, unknown>
return {
id: asString(source.id),
status: asString(source.status) || undefined,
}
return { id: asString(source.id), status: asString(source.status) || undefined, progress: asNumber(source.progress), score: asNumber(source.score) }
}
function normalizeLaunchState(raw: unknown): LearnerScormLaunchState {
const source = (raw ?? {}) as Record<string, unknown>
const state = asString(source.state)
const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready'
? state
: 'locked'
const normalizedState = state === 'ready' || state === 'locked' || state === 'missing_package' || state === 'export_not_ready' ? state : 'locked'
const reason = asString(source.reason) || undefined
const message = asString(source.message) || (reason === 'missing_entitlement'
? 'Access is locked because your entitlement is missing or inactive.'
: 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 {
allowed: Boolean(source.allowed),
state: normalizedState,
reason,
message,
launchUrl: asString(source.launchUrl) || undefined,
resumeAvailable: Boolean(source.resumeAvailable),
progressNote: asString(source.progressNote) || undefined,
id: asString(source.id) || undefined,
userId: asString(source.userId) || undefined,
courseId: asString(source.courseId),
enrollmentId: asString(source.enrollmentId) || undefined,
scoId: asString(source.scoId),
status: asString(source.status) || 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[]> {
const result = await apiRequest<unknown[]>('/api/learner/scorm', { skipAuth: false })
if (!Array.isArray(result)) return []
return result
.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
return {
course: normalizeCourse(source.course),
enrollment: normalizeEnrollment(source.enrollment),
launchState: normalizeLaunchState(source.launchState),
}
})
.filter(item => Boolean(item.course.id && item.course.title))
return result.map(raw => {
const source = (raw ?? {}) as Record<string, unknown>
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> {
const result = await apiRequest<unknown>(`/api/learner/scorm/${encodeURIComponent(courseID)}/launch`, { skipAuth: false })
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), launchState: normalizeLaunchState(source.launchState) }
}
export async function fetchLearnerScormRuntime(courseID: string, scoID: string): Promise<LearnerScormRuntime> {
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) } }
}

View 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>
)
}

View 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>
)
}

View File

@ -1,124 +1,188 @@
import { useEffect, useState } from 'react'
import { MessageSquare, Users } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { MessageSquare, Pin, Send, Users } from 'lucide-react'
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'
function formatDate(value?: number) {
if (!value) return ''
return new Date(value).toLocaleString()
}
export function LearnerCommunityPage() {
const [items, setItems] = useState<LearnerCommunityItem[]>([])
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = 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(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerCommunities()
if (cancelled) return
setItems(result)
setState('ready')
} catch (err) {
if (cancelled) return
const message = err instanceof ApiError ? err.message : 'Failed to load communities'
setError(message)
setState('error')
}
}
void load()
void fetchLearnerCommunities().then(result => {
if (cancelled) return
setItems(result)
const first = result[0]
setSelectedCommunityID(first?.community.id ?? '')
setSelectedChannelID(first?.channels[0]?.id ?? '')
setState('ready')
}).catch(err => {
if (cancelled) return
setError(err instanceof ApiError ? err.message : 'Failed to load communities')
setState('error')
})
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) => {
try {
setJoiningID(communityID)
const membership = await joinLearnerCommunity(communityID)
setItems(prev => prev.map(item => (
item.community.id === communityID
? { ...item, membership }
: item
)))
setItems(prev => prev.map(item => item.community.id === communityID ? { ...item, membership } : item))
} catch (err) {
const message = err instanceof ApiError ? err.message : 'Unable to join community'
setError(message)
setError(err instanceof ApiError ? err.message : 'Unable to join community')
} finally {
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 (
<div data-testid="learner-community-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">Community</h1>
<p className="mt-2 text-sm text-slate-600">
Access your learning communities and join channels available to your role or entitlements.
</p>
<div data-testid="learner-community-page" className="space-y-5">
<header>
<h1 className="text-2xl font-semibold tracking-tight text-stone-900">Community</h1>
<p className="mt-2 text-sm text-stone-600">Join your school spaces, follow channels, post questions, and reply to other learners.</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading your communities...</p>}
{state === 'loading' && <p className="text-sm text-stone-600">Loading your communities...</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' && (
<section className="space-y-5">
{items.length === 0 && (
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-8 text-sm text-slate-600">
You do not currently have access to any communities.
</div>
)}
{state === 'ready' && items.length > 0 && (
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<aside className="space-y-3 rounded-2xl border border-stone-200 bg-white p-3">
{items.map(item => (
<button key={item.community.id} type="button" onClick={() => {
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 => {
const membershipStatus = item.membership?.status?.toLowerCase() ?? ''
const isJoined = membershipStatus === 'active'
return (
<article key={item.community.id} className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{item.community.visibility || 'private'}</span>
<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>
{selected && (
<section className="min-w-0 space-y-4">
<div className="rounded-2xl border border-stone-200 bg-white p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-stone-900">{selected.community.name}</h2>
<p className="mt-1 text-sm text-stone-600">{selected.community.description}</p>
</div>
)}
</article>
)
})}
</section>
{!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>}
</div>
<div className="mt-4 flex gap-2 overflow-x-auto pb-1">
{selected.channels.map(channel => (
<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>
)

View File

@ -1,8 +1,13 @@
import { useEffect, useState } from 'react'
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 { fetchLearnerCourseDetailBySlug, type LearnerCourseDetail } from '../learner-courses'
import {
accessLearnerCourseAsset,
completeLearnerLesson,
fetchLearnerCourseDetailBySlug,
type LearnerCourseDetail,
} from '../learner-courses'
type LoadState = 'loading' | 'ready' | 'error' | 'not-found'
@ -11,113 +16,110 @@ export function LearnerCourseDetailPage() {
const [detail, setDetail] = useState<LearnerCourseDetail | null>(null)
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [savingLesson, setSavingLesson] = useState('')
useEffect(() => {
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerCourseDetailBySlug(slug)
if (cancelled) return
if (!result) {
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')
}
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerCourseDetailBySlug(slug)
if (!result) { setState('not-found'); return }
setDetail(result); setState('ready')
} catch (err) {
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') {
return (
<div>
<h1 className="text-3xl font-semibold text-slate-900">Course not found</h1>
<p className="mt-2 text-sm text-slate-600">This course may not be available in your learner catalog.</p>
</div>
)
useEffect(() => { void load() }, [slug])
if (state === 'loading') return <p className="text-sm text-stone-600">Loading course details...</p>
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>
if (state === 'error') return <p className="text-sm text-red-600">{error}</p>
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') {
return <p className="text-sm text-red-600">{error}</p>
const openAsset = async (assetID: string) => {
const url = await accessLearnerCourseAsset(detail.card.id, assetID)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
return (
<article data-testid="learner-course-detail-page">
<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">
<ArrowLeft size={16} />
Back to my courses
</Link>
<article data-testid="learner-course-detail-page" className="space-y-5">
<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>
<header className="rounded-3xl border border-slate-200 bg-slate-50 p-6 md:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-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>
<p className="mt-3 text-sm text-slate-600">{detail?.card.description || 'Course overview details will expand as resource metadata is added.'}</p>
<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-stone-500">{detail.card.statusLabel}</p>
<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-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>
{!detail?.card.locked && (
<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 && <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>}
{detail?.card.locked && (
<section className="mt-6 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 || 'This course is currently locked.'}</p>
{!detail.card.locked && (
<section className="space-y-4">
<h2 className="text-xl font-semibold text-stone-900">Course content</h2>
{detail.modules.map((module, moduleIndex) => (
<article key={module.id} className="rounded-2xl border border-stone-200 bg-white p-5">
<p className="text-xs font-semibold uppercase tracking-[0.1em] text-stone-500">Module {moduleIndex + 1}</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 className="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-6">
<h2 className="text-lg font-semibold text-slate-900">Modules</h2>
<p className="mt-2 text-sm text-slate-600">
Module-level learner content is not yet tracked here. This placeholder will be replaced once module/course APIs are integrated.
</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">Assets and Resources</h2>
<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>}
{detail.assets.length > 0 && (
<section className="rounded-2xl border border-stone-200 bg-white p-5">
<h2 className="text-lg font-semibold text-stone-900">Resources</h2>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{detail.assets.map(asset => (
<div key={asset.id} className="rounded-xl border border-stone-200 p-4">
<p className="font-semibold text-stone-900">{asset.title}</p>
{asset.description && <p className="mt-1 text-sm text-stone-600">{asset.description}</p>}
{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>}
</div>
))}
</div>
) : (
<p className="mt-2 text-sm text-slate-600">No learner cohort records are currently available for this course.</p>
)}
</section>
)}
<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>
</article>
)

View File

@ -1,160 +1,139 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router'
import { AlertCircle, ExternalLink, PlayCircle } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight, PlayCircle, Save } from 'lucide-react'
import { useParams } from 'react-router'
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'
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() {
const params = useParams<{ id: string }>()
const courseID = (params.id ?? '').trim()
const { id = '' } = useParams<{ id: string }>()
const courseID = id.trim()
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 [error, setError] = useState('')
const [launchStarted, setLaunchStarted] = useState(false)
const [saving, setSaving] = useState(false)
const valuesRef = useRef<Record<string, unknown>>({})
useEffect(() => {
if (!courseID) {
setState('error')
setError('A SCORM course id is required to launch this page.')
return
}
if (!courseID) { setError('A SCORM course id is required.'); setState('error'); return }
let cancelled = false
const load = async () => {
try {
setState('loading')
const result = await fetchLearnerScormLaunch(courseID)
if (cancelled) return
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()
void fetchLearnerScormLaunch(courseID).then(result => {
if (cancelled) return
setDetail(result); setState('ready')
}).catch(err => {
if (cancelled) return
setError(err instanceof ApiError ? err.message : 'Failed to load SCORM course'); setState('error')
})
return () => { cancelled = true }
}, [courseID])
const launchState = detail?.launchState
const canLaunch = Boolean(launchState?.allowed && launchState.state === 'ready')
const blockedMessage = launchState?.reason === 'missing_entitlement'
? 'Launch is blocked until your entitlement is granted or reactivated.'
: 'Launch is blocked until this SCORM course is ready.'
const title = useMemo(() => {
if (!detail?.course.title) return 'SCORM Launch'
return detail.course.title
}, [detail?.course.title])
const selectedSCO = detail?.course.scos[scoIndex] ?? null
useEffect(() => {
if (!selectedSCO || !courseID) return
void fetchLearnerScormRuntime(courseID, selectedSCO.id).then(next => {
setRuntime(next)
valuesRef.current = { ...next.values }
}).catch(err => setError(err instanceof Error ? err.message : 'Unable to load runtime state'))
}, [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 (
<div data-testid="learner-scorm-launch-page">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">{title}</h1>
<p className="mt-2 text-sm text-slate-600">
Launch your SCORM package and use resume placeholders until runtime progress sync is fully integrated.
</p>
<div data-testid="learner-scorm-launch-page" className="space-y-4">
<header>
<h1 className="text-2xl font-semibold tracking-tight text-stone-900">{detail?.course.title ?? 'SCORM Launch'}</h1>
<p className="mt-2 text-sm text-stone-600">SCORM progress, bookmark data, scores, and suspend data are persisted to your learner record.</p>
</header>
{state === 'loading' && <p className="text-sm text-slate-600">Loading launch details...</p>}
{state === 'loading' && <p className="text-sm text-stone-600">Loading launch details...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && detail && launchState && (
<section className="space-y-5">
<article className="rounded-2xl border border-slate-200 bg-slate-50 p-6">
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span className="rounded-full bg-slate-100 px-2.5 py-1 font-medium text-slate-700">{detail.enrollment.status || 'enrolled'}</span>
<span className="rounded-full bg-sky-100 px-2.5 py-1 font-medium text-sky-800">{launchState.state}</span>
{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>
)}
{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 && detail.launchState.allowed && selectedSCO && (
<>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-white p-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-500">Lesson {scoIndex + 1} of {detail.course.scos.length}</p>
<p className="font-semibold text-stone-900">{selectedSCO.title}</p>
</div>
<h2 className="text-xl font-semibold text-slate-900">{launchStateHeadline(launchState.state)}</h2>
<p className="mt-2 text-sm text-slate-600">{launchState.message || 'Launch details are not available for this course yet.'}</p>
{detail.course.moduleTypes.length > 0 && (
<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 className="flex items-center gap-2">
<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>
<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>
<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>
</article>
{launchStarted && canLaunch && (
<article className="rounded-2xl border border-emerald-200 bg-emerald-50 p-6">
<h3 className="text-lg font-semibold text-emerald-900">SCORM launch started</h3>
<p className="mt-2 text-sm text-emerald-800">
Runtime playback is staged here. Progress and bookmark resume remain placeholder behavior until full SCORM runtime APIs are connected.
</p>
</article>
)}
</section>
</div>
<div className="rounded-2xl border border-stone-200 bg-white p-2">
<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" />
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
Course progress: {Math.round(percent)}% · Runtime status: {runtime?.lessonStatus || runtime?.completionStatus || 'incomplete'}
</div>
</>
)}
</div>
)

View File

@ -9,6 +9,8 @@ import { KeycloakThemeEditorPage } from '../pages/KeycloakThemeEditorPage'
import { FormsBuilderPage } from '../pages/FormsBuilderPage'
import { SitemapMenuBuilderPage } from '../pages/SitemapMenuBuilderPage'
import { FeatureGuardPage } from '../pages/FeatureGuardPage'
import { AdminCommunitiesPage } from '../pages/AdminCommunitiesPage'
import { AdminNotificationsPage } from '../pages/AdminNotificationsPage'
import { AdminContentPage } from '../pages/AdminContentPage'
import { AdminLearnersPage } from '../pages/AdminLearnersPage'
import { AdminSettingsPage } from '../pages/AdminSettingsPage'
@ -25,18 +27,10 @@ function AdminEmbeddedScormPage() {
return <ScormPage embedded />
}
function AdminNotificationsNotEnabledPage() {
return <FeatureGuardPage title="Notification Operations" summary="Notification operations are not available in this workspace yet." />
}
function AdminBillingNotEnabledPage() {
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[] = [
{
path: '/admin',
@ -53,10 +47,10 @@ export const adminRoutes: RouteObject[] = [
{ path: 'content', Component: AdminContentPage },
{ path: 'blog', Component: AdminBlogPage, handle: { adminSurface: 'plain' } },
{ path: 'learners', Component: AdminLearnersPage },
{ path: 'notifications', Component: AdminNotificationsNotEnabledPage },
{ path: 'notifications', Component: AdminNotificationsPage },
{ path: 'assets', Component: AdminAssetsPage },
{ path: 'billing', Component: AdminBillingNotEnabledPage },
{ path: 'communities', Component: AdminCommunitiesNotEnabledPage },
{ path: 'communities', Component: AdminCommunitiesPage },
{ path: 'audit', Component: AdminAuditPage },
{ path: 'settings', Component: AdminSettingsPage },
],