modify publick curriculum

This commit is contained in:
2026-07-07 16:12:30 +09:00
parent 9a1eec48e9
commit f0df24ecaa
8 changed files with 134 additions and 38 deletions
+51 -21
View File
@@ -78,6 +78,8 @@ import { GitFileContextMenu, PowerActionModal, SourceSettingsSaveConfirmModal, S
type IdeActivity = WorkspaceActivity
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
function makeStudentSlug(email?: string | null): string {
return ((email ?? '').split('@')[0] || 'student').replace(/[^a-zA-Z0-9_-]+/g, '-').toLowerCase()
}
@@ -107,6 +109,7 @@ const EMPTY_WEEK: CurriculumWeek = {
github: '-',
detail: {
overview: '',
learningSteps: [],
topicDetails: [],
topicVideoUrls: [],
references: [],
@@ -376,6 +379,8 @@ function LocalPcWorkspaceMockup({
courseKey,
curriculumWeek,
activeLearningStep,
allowFutureLearningSteps,
onLearningStepSelect,
onSubmissionRecorded
}: {
onClose: () => void
@@ -400,6 +405,8 @@ function LocalPcWorkspaceMockup({
courseKey: string
curriculumWeek: CurriculumWeek
activeLearningStep: string | null
allowFutureLearningSteps: boolean
onLearningStepSelect?: (step: string) => void
onSubmissionRecorded: () => void
}): React.JSX.Element {
const [powerActionModalOpen, setPowerActionModalOpen] = useState(false)
@@ -571,10 +578,19 @@ function LocalPcWorkspaceMockup({
const timer = window.setInterval(() => setControlCountdownNow(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [controlEndsAt])
const materialItems = curriculumWeek.topics.map((topic, index) => ({
title: topic,
description: curriculumWeek.detail.topicDetails[index] ?? '',
videoUrl: curriculumWeek.detail.topicVideoUrls?.[index] ?? ''
const materialItems = (curriculumWeek.detail.learningSteps?.length
? curriculumWeek.detail.learningSteps
: curriculumWeek.topics.map((topic, index) => ({
title: topic,
description: curriculumWeek.detail.topicDetails[index] ?? '',
videoUrl: curriculumWeek.detail.topicVideoUrls?.[index] ?? '',
concepts: [] as string[],
}))
).map((step, index) => ({
title: step.title,
description: step.description,
videoUrl: step.videoEnabled === false ? '' : (step.videoUrl ?? ''),
concepts: step.concepts?.length ? step.concepts : [curriculumWeek.topics[index]].filter(Boolean),
}))
const activeTopicIndex = curriculumWeek.topics.findIndex((topic) => topic === activeLearningStep)
const activeMaterialIndex = Math.min(
@@ -585,7 +601,11 @@ function LocalPcWorkspaceMockup({
setExpandedMaterialIndexes([activeMaterialIndex])
}, [activeLearningStep, activeMaterialIndex, curriculumWeek.week])
const toggleMaterialItem = (index: number): void => {
if (index > activeMaterialIndex) return
if (!allowFutureLearningSteps && index > activeMaterialIndex) return
if (allowFutureLearningSteps) {
const nextStep = materialItems[index]?.title
if (nextStep) onLearningStepSelect?.(nextStep)
}
setExpandedMaterialIndexes((indexes) => (
indexes.includes(index)
? indexes.filter((item) => item !== index)
@@ -849,6 +869,7 @@ function LocalPcWorkspaceMockup({
}
}
const recordLearningCompletion = async (commitCount: number, completedWeeks: number): Promise<void> => {
if (allowFutureLearningSteps) return
if (!accessToken) return
const requestAccessToken = await getFreshAccessToken()
const recordResponse = await fetch(`${LMS_API_BASE_URL}/studio/submissions/record`, {
@@ -869,6 +890,7 @@ function LocalPcWorkspaceMockup({
}
useEffect(() => {
if (allowFutureLearningSteps) return
if (!accessToken || !courseKey || weekNumber < 1) return
const sessionKey = `autonomy-studio.lms.learning-session:${courseKey}:week-${weekNumber}:${platformId}`
@@ -920,7 +942,7 @@ function LocalPcWorkspaceMockup({
cancelled = true
window.clearInterval(intervalId)
}
}, [accessToken, courseKey, getFreshAccessToken, platformId, weekNumber])
}, [accessToken, allowFutureLearningSteps, courseKey, getFreshAccessToken, platformId, weekNumber])
const submitAllModuleChanges = async (): Promise<void> => {
if (gitSubmitting) return
@@ -2219,6 +2241,7 @@ function LocalPcWorkspaceMockup({
materialItems={materialItems}
activeMaterialIndex={activeMaterialIndex}
expandedMaterialIndexes={expandedMaterialIndexes}
allowFutureMaterialSteps={allowFutureLearningSteps}
topics={curriculumWeek.topics}
overview={curriculumWeek.detail.overview}
topicDetails={curriculumWeek.detail.topicDetails}
@@ -2662,18 +2685,6 @@ function LocalPcWorkspaceMockup({
)
}
function BootLoadingOverlay({ message }: { message: string }): React.JSX.Element {
return (
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/24 backdrop-blur-[3px]">
<section className="w-[324px] rounded-xl border border-white/14 bg-[rgba(18,24,36,0.78)] px-6 py-5 text-center shadow-[0_24px_80px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.10)] backdrop-blur-2xl">
<div className="mx-auto mb-4 h-9 w-9 animate-spin rounded-full border-2 border-white/12 border-t-[#4f8dff]" />
<p className="text-[14px] font-bold text-white">Autonomy Studio LMS</p>
<p className="mt-2 text-[12px] font-semibold text-white/58">{message}</p>
</section>
</div>
)
}
export default function App(): React.JSX.Element {
const restoredStudioSessionRef = useRef<StudioRuntimeSessionState | null>(readStudioRuntimeSessionState())
const previousWeekStateRef = useRef<Record<number, CurriculumWeek['state']>>({})
@@ -2762,6 +2773,7 @@ export default function App(): React.JSX.Element {
const selectedWeek = selectedWeekState ?? EMPTY_WEEK
const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK
const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId) ?? null
const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG
const studioDisabled = !selectedWeekState
const connectedEnvironmentProfile = connectedPlatform
? (
@@ -2777,6 +2789,13 @@ export default function App(): React.JSX.Element {
&& Boolean(authSession?.user.id)
&& session?.holderUserId === authSession?.user.id
}
useEffect(() => {
if (!publicGuideCourseActive) return
const firstStep = selectedWeekState?.topics[0]
if (!firstStep) return
if (activeLearningStep && selectedWeekState.topics.includes(activeLearningStep)) return
setActiveLearningStep(firstStep)
}, [activeLearningStep, publicGuideCourseActive, selectedWeekState])
const curriculumSummary = curriculumWeeks.length ? `${curriculumWeeks.length}개 섹션` : '등록된 섹션 없음'
const showTitlebarNotice = (title: string, tone: 'info' | 'warning' | 'error' = 'info', addToList = false): void => {
setTitlebarNotices((notices) => [
@@ -3326,6 +3345,7 @@ export default function App(): React.JSX.Element {
? 'absolute inset-0'
: ['fixed bottom-0 right-0 top-[38px]', sidebarCollapsed ? 'left-[68px]' : 'left-[240px]'].join(' ')
].join(' ')
const fullAppModalOverlayClass = 'fixed bottom-0 left-0 right-0 top-[38px] z-[80] grid place-items-center bg-black/36'
const canReleaseConnectedPlatform = connectedPlatform?.control?.status === 'ACTIVE'
const connectedPlatformControlEndsAt = canReleaseConnectedPlatform
? connectedPlatform?.control?.session?.endsAt ?? null
@@ -4042,8 +4062,10 @@ export default function App(): React.JSX.Element {
studentEmail={authSession?.user.email ?? 'student'}
weekNumber={workspaceWeek.week}
courseKey={courseKey}
curriculumWeek={workspaceWeek}
curriculumWeek={workspaceWeek}
activeLearningStep={activeLearningStep}
allowFutureLearningSteps={publicGuideCourseActive}
onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined}
onSubmissionRecorded={() => {
setCurriculumRefreshSignal((current) => current + 1)
void loadStudentCourses({ autoSelectSingle: false })
@@ -4069,8 +4091,10 @@ export default function App(): React.JSX.Element {
activeLearningStep={activeLearningStep}
studioDisabled={studioDisabled}
hasMaterials={hasSelectedWeekMaterials}
publicGuideCourse={publicGuideCourseActive}
learningStats={learningStats}
onSelectWeek={setSelectedWeekState}
onSelectLearningStep={publicGuideCourseActive ? setActiveLearningStep : undefined}
onStartStudio={requestStudioStart}
onOpenMaterials={() => {
if (hasSelectedWeekMaterials) setMaterialModalOpen(true)
@@ -4228,7 +4252,7 @@ export default function App(): React.JSX.Element {
)}
{sessionConfirmAction && (
<SessionConfirmModal
overlayClassName={workspaceOnlyModalOverlayClass}
overlayClassName={fullAppModalOverlayClass}
dark={appModalDark}
action={sessionConfirmAction}
platformName={activePlatformName}
@@ -4255,7 +4279,13 @@ export default function App(): React.JSX.Element {
</main>
</div>
</div>
{bootLoadingVisible && <BootLoadingOverlay message={bootLoadingMessage} />}
{bootLoadingVisible && (
<PlatformLoadingOverlay
title="Autonomy Studio LMS"
description={bootLoadingMessage}
className="fixed inset-0 z-[200]"
/>
)}
</div>
)
}
+6 -2
View File
@@ -144,7 +144,9 @@ export function ExpandableMarkdownMessage({
}, [content, defaultCollapsed])
useEffect(() => {
if (collapseOnSignal) setExpanded(false)
if (collapseOnSignal) {
setExpanded(false)
}
}, [collapseOnSignal, collapseSignal])
return (
@@ -195,7 +197,9 @@ export function ExpandablePlainMessage({
}, [content])
useEffect(() => {
if (collapseOnSignal) setExpanded(false)
if (collapseOnSignal) {
setExpanded(false)
}
}, [collapseOnSignal, collapseSignal])
return (
@@ -20,12 +20,16 @@ type CurriculumDetailPanelProps = {
week: CurriculumDetailWeek
selected: boolean
activeLearningStep: string | null
allowLearningStepSelection?: boolean
onSelectLearningStep?: (step: string) => void
}
export default function CurriculumDetailPanel({
week,
selected,
activeLearningStep
activeLearningStep,
allowLearningStepSelection = false,
onSelectLearningStep
}: CurriculumDetailPanelProps): React.JSX.Element {
return (
<div data-help="curriculum-detail" className="min-w-0 px-7 pb-7 pt-5">
@@ -42,18 +46,28 @@ export default function CurriculumDetailPanel({
<div className="mt-4 grid gap-3">
{week.topics.map((topic, index) => {
const active = topic === activeLearningStep
const selectable = allowLearningStepSelection && Boolean(onSelectLearningStep)
return (
<section
key={topic}
role={selectable ? 'button' : undefined}
tabIndex={selectable ? 0 : undefined}
onClick={selectable ? () => onSelectLearningStep?.(topic) : undefined}
onKeyDown={selectable ? (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onSelectLearningStep?.(topic)
} : undefined}
className={[
'rounded-lg border p-4 shadow-[0_10px_22px_rgba(20,27,44,0.04)]',
'rounded-lg border p-4 shadow-[0_10px_22px_rgba(20,27,44,0.04)] transition',
selectable ? 'cursor-pointer hover:border-[#b7a8ff] hover:bg-[#fbf9ff]' : '',
active ? 'border-[#714ef6] bg-[#f7f3ff]' : 'border-[#dde2ec] bg-white'
].join(' ')}
>
<div className="flex items-center gap-2.5">
<span className={`grid h-7 w-7 shrink-0 place-items-center rounded-md text-[12px] font-bold ${active ? 'bg-[#714ef6] text-white' : 'bg-[#ede9ff] text-[#714ef6]'}`}>{index + 1}</span>
<h3 className="min-w-0 text-[15px] font-bold text-[#111520]">{topic}</h3>
{active ? <span className="rounded bg-[#ede9ff] px-2 py-1 text-[11px] font-bold text-[#714ef6]">현재</span> : null}
{active ? <span className="rounded bg-[#ede9ff] px-2 py-1 text-[11px] font-bold text-[#714ef6]">진행중</span> : null}
</div>
<p className="mt-1 pl-[38px] text-[14px] font-medium leading-[1.5] text-[#4f596b]">
{week.detail.topicDetails[index]}
@@ -33,8 +33,10 @@ type CurriculumHomeScreenProps<Week extends CurriculumHomeWeek> = {
activeLearningStep: string | null
studioDisabled: boolean
hasMaterials: boolean
publicGuideCourse: boolean
learningStats: LearningStatItem[]
onSelectWeek: (week: Week) => void
onSelectLearningStep?: (step: string) => void
onStartStudio: () => void
onOpenMaterials: () => void
}
@@ -47,8 +49,10 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
activeLearningStep,
studioDisabled,
hasMaterials,
publicGuideCourse,
learningStats,
onSelectWeek,
onSelectLearningStep,
onStartStudio,
onOpenMaterials
}: CurriculumHomeScreenProps<Week>): React.JSX.Element {
@@ -91,6 +95,8 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
week={selectedWeek}
selected={selected}
activeLearningStep={activeLearningStep}
allowLearningStepSelection={publicGuideCourse}
onSelectLearningStep={onSelectLearningStep}
/>
</section>
</div>
@@ -103,7 +109,7 @@ export default function CurriculumHomeScreen<Week extends CurriculumHomeWeek>({
onStartStudio={onStartStudio}
onOpenMaterials={onOpenMaterials}
/>
<LearningStatsCard stats={learningStats} />
<LearningStatsCard guideMode={publicGuideCourse} stats={learningStats} />
</aside>
</div>
)
@@ -17,21 +17,28 @@ const STAT_ICONS: Record<string, LucideIcon> = {
}
type LearningStatsCardProps = {
guideMode?: boolean
stats: LearningStatItem[]
}
export default function LearningStatsCard({ stats }: LearningStatsCardProps): React.JSX.Element {
export default function LearningStatsCard({ guideMode = false, stats }: LearningStatsCardProps): React.JSX.Element {
return (
<section className="rounded-lg border border-[#d6dce8] bg-white/95 p-4 shadow-[0_16px_36px_rgba(20,27,44,0.10)]">
<div>
<div>
<strong className="block font-bold">내 학습 현황</strong>
<span className="mt-1 block text-[12px] font-medium text-[#5f6878]">전체 진행 상황 및 통계</span>
<strong className="block font-bold">{guideMode ? '체험 강좌' : '내 학습 현황'}</strong>
<span className="mt-1 block text-[12px] font-medium text-[#5f6878]">
{guideMode ? '배정 전 사용법 안내 과정' : '전체 진행 상황 및 통계'}
</span>
</div>
</div>
<div className="mt-4 overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff] shadow-[0_10px_24px_rgba(20,27,44,0.06)]">
{stats.length ? (
{guideMode ? (
<div className="px-4 py-5 text-[12px] font-medium leading-relaxed text-[#5f6878]">
이 강좌는 Autonomy Studio LMS 사용법을 확인하기 위한 공통 안내입니다. 실제 학습 시간, 커밋 수, 제출 현황은 강사가 배정한 강좌에서부터 집계됩니다.
</div>
) : stats.length ? (
stats.map(({ value, label, iconKey }, index) => {
const Icon = STAT_ICONS[iconKey] ?? Gauge
return (
@@ -23,6 +23,14 @@ export type DownloadResource = {
export type LearningStat = LearningStatItem
export type LearningStep = {
title: string
description: string
videoEnabled?: boolean
videoUrl?: string
concepts?: string[]
}
export type CurriculumWeek = {
week: number
title: string
@@ -34,6 +42,7 @@ export type CurriculumWeek = {
github: string
detail: {
overview: string
learningSteps?: LearningStep[]
topicDetails: string[]
topicVideoUrls?: string[]
practiceContents?: Array<{
@@ -7,6 +7,7 @@ export type WorkspaceGuideMaterialItem = {
title: string
description: string
videoUrl: string
concepts?: string[]
}
type WorkspaceGuideResource = {
@@ -29,6 +30,7 @@ type WorkspaceGuidePanelProps<Resource extends WorkspaceGuideResource> = {
materialItems: WorkspaceGuideMaterialItem[]
activeMaterialIndex: number
expandedMaterialIndexes: number[]
allowFutureMaterialSteps?: boolean
topics: string[]
overview: string
topicDetails: string[]
@@ -81,6 +83,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
materialItems,
activeMaterialIndex,
expandedMaterialIndexes,
allowFutureMaterialSteps = false,
topics,
overview,
topicDetails,
@@ -142,7 +145,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
const active = index === activeMaterialIndex
const previous = index < activeMaterialIndex
const expanded = expandedMaterialIndexes.includes(index)
const canToggle = previous || active
const canToggle = allowFutureMaterialSteps || previous || active
const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl)
const youtubeVideoId = getYoutubeVideoId(item.videoUrl)
@@ -171,11 +174,11 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
<span className={[
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
active ? 'bg-[#2563eb]/18 text-[#9dbdff]' : previous ? dark ? 'bg-white/12 text-white/70' : 'bg-white text-[#4f596b]' : dark ? 'bg-white/8 text-white/38' : 'bg-white text-[#9aa3b2]'
active ? 'bg-[#2563eb]/18 text-[#9dbdff]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-white text-[#4f596b]' : dark ? 'bg-white/8 text-white/38' : 'bg-white text-[#9aa3b2]'
].join(' ')}>{index + 1}</span>
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
<div className="flex min-w-0 items-center justify-between gap-2">
<strong className={['block min-w-0 truncate text-[13px] font-bold', active || previous ? textStrong : textDim].join(' ')}>{item.title}</strong>
<strong className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
<span className="flex shrink-0 items-center gap-1.5">
{active && (
<span className="rounded border border-[#60a5fa]/24 bg-[#2563eb]/16 px-2 py-0.5 text-[10px] font-bold text-[#bfdbfe]">
@@ -232,10 +235,10 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>핵심 개념</span>
<div className="mt-2 grid gap-1.5">
{topics.map((topic) => (
<div key={topic} className="flex items-center gap-2">
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
<div key={concept} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#2563eb]" />
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{topic}</span>
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{concept}</span>
</div>
))}
</div>