From 0811360178286fe72cdc7509396487da1a06a7cc Mon Sep 17 00:00:00 2001 From: yinggez Date: Thu, 16 Jul 2026 22:39:56 +0200 Subject: [PATCH] feat(workshopper): resolve generation timeouts and improve timetable editing - **Backend Concurrency:** Refactored `WorkshopService.java` to use a dedicated class-level `ExecutorService` for blocking LLM network calls, preventing `ForkJoinPool` thread starvation and deadlocks during concurrent session block generation. - **Draft Persistence:** Fixed a race condition in `App.tsx` where pending LLM block generation promises were not properly resolved when saving a draft, causing the UI to become permanently stuck in a loading state. - **LGH Integration:** Removed the strict `?status=APPROVED` filter from the Learning Goal Hub fetch to allow importing all available goals, and added graceful error boundary toasts for when the LGH service is offline. - **Timetable UI:** Enhanced the `SortableBlockRow` to allow activity text editing even when not in full edit mode. Added a helpful info tooltip to the activity prompt. --- .../controller/WorkshopController.java | 4 +- .../workshopper/service/WorkshopService.java | 180 +++++----- apps/workshopper/frontend/src/App.tsx | 26 +- .../frontend/src/components/LGHImport.tsx | 93 +++-- .../src/components/SlideWorkstation.tsx | 26 +- .../components/WorkshopGeneratedTimetable.tsx | 331 +++++++++++++----- .../components/timetable/InlineEditText.tsx | 14 +- .../components/timetable/SortableBlockRow.tsx | 97 +++-- .../frontend/src/components/ui/button.tsx | 34 +- .../src/components/ui/dropdown-menu.tsx | 53 ++- .../frontend/src/lib/learning-goal-hub.ts | 2 +- apps/workshopper/frontend/vite.config.ts | 2 + 12 files changed, 539 insertions(+), 323 deletions(-) diff --git a/apps/workshopper/backend/src/main/java/com/workshopper/controller/WorkshopController.java b/apps/workshopper/backend/src/main/java/com/workshopper/controller/WorkshopController.java index d9d2408..20c909a 100644 --- a/apps/workshopper/backend/src/main/java/com/workshopper/controller/WorkshopController.java +++ b/apps/workshopper/backend/src/main/java/com/workshopper/controller/WorkshopController.java @@ -264,8 +264,8 @@ public ResponseEntity fixGoalsGrammar(@RequestBody List goals) { var fixed = service.fixGoalsGrammar(goals); return ResponseEntity.ok(fixed); } catch (Exception e) { - log.error("Grammar fix failed", e); - return ResponseEntity.internalServerError().body(goals); // Fallback to original + log.warn("Grammar fix failed, falling back to original goals. Error: {}", e.getMessage()); + return ResponseEntity.ok(goals); // Fallback to original, return 200 to avoid scary console errors } } diff --git a/apps/workshopper/backend/src/main/java/com/workshopper/service/WorkshopService.java b/apps/workshopper/backend/src/main/java/com/workshopper/service/WorkshopService.java index 6a54a06..06492c4 100644 --- a/apps/workshopper/backend/src/main/java/com/workshopper/service/WorkshopService.java +++ b/apps/workshopper/backend/src/main/java/com/workshopper/service/WorkshopService.java @@ -22,6 +22,7 @@ public class WorkshopService { private final LlmService llm; private final WorkshopSessionRepository repo; private final ObjectMapper mapper = new ObjectMapper(); + private final java.util.concurrent.ExecutorService llmExecutor = java.util.concurrent.Executors.newFixedThreadPool(20); public WorkshopService(LlmService llm, WorkshopSessionRepository repo) { this.llm = llm; @@ -241,24 +242,47 @@ public WorkshopSessionDto generateSession(List goals, skeleton.learningGoal(), filteredBlocks, skeleton.omittedGoalIndices(), skeleton.sessionId()); - // LLM generates full activities for the blocks provided by the frontend - String systemPrompt = """ - You are an expert learning designer creating a practical instructor script for a teaching session. - Your task is to take a timed session skeleton and produce a concrete, step-by-step timeline that tells the instructor exactly what to do and when. - Return ONLY a valid JSON object containing a 'title' string and a 'blocks' array, no prose, no markdown. - """; - String sessionTypeLabel = resolveSessionType(meta.sessionType(), meta.sessionTypeOther()); - String userPrompt = buildHydrationPrompt(filteredSkeleton, goals, meta, sessionTypeLabel); - log.debug("Hydrating session skeleton for {} goals", goals.size()); - String rawSkeleton = llm.call(systemPrompt, userPrompt); - String hydratedJson = llm.extractJsonObject(rawSkeleton); - com.fasterxml.jackson.databind.JsonNode rootNode = mapper.readTree(hydratedJson); - String sessionTitle = rootNode.path("title").asText("Workshop Session Plan"); - List hydratedBlocks = mapper.convertValue( - rootNode.path("blocks"), new TypeReference>() { - }); + // 1. Generate title concurrently + java.util.concurrent.CompletableFuture titleFuture = java.util.concurrent.CompletableFuture.supplyAsync(() -> { + return generateSessionTitle(filteredSkeleton, goals, meta, sessionTypeLabel); + }, llmExecutor); + + // 2. Generate blocks concurrently with concurrency limit of 3 + java.util.concurrent.Semaphore concurrencySemaphore = new java.util.concurrent.Semaphore(3); + List> blockFutures = new ArrayList<>(); + + for (SkeletonBlockDto block : filteredBlocks) { + java.util.concurrent.CompletableFuture future = java.util.concurrent.CompletableFuture.supplyAsync(() -> { + try { + concurrencySemaphore.acquire(); + log.debug("Hydrating block phase={}, lgIndex={}", block.phase(), block.lgIndex()); + String prompt = buildHydrationPromptForBlock(block, filteredSkeleton, goals, meta, sessionTypeLabel); + String sysPrompt = "You are an expert learning designer. Return ONLY valid JSON representing the requested block."; + String rawJson = llm.call(sysPrompt, prompt); + String cleanedJson = llm.extractJsonObject(rawJson); + return mapper.readValue(cleanedJson, ActivityBlockDto.class); + } catch (Exception e) { + log.error("Failed to hydrate block", e); + throw new RuntimeException(e); + } finally { + concurrencySemaphore.release(); + } + }); + blockFutures.add(future); + } + + // Wait for all blocks to finish + java.util.concurrent.CompletableFuture allBlocksFuture = java.util.concurrent.CompletableFuture.allOf(blockFutures.toArray(new java.util.concurrent.CompletableFuture[0])); + allBlocksFuture.join(); + + // Collect results in order + List hydratedBlocks = blockFutures.stream() + .map(java.util.concurrent.CompletableFuture::join) + .collect(Collectors.toList()); + + String sessionTitle = titleFuture.join(); // Build omitted goals list from skeleton indices List omittedGoals = new ArrayList<>(); @@ -309,10 +333,29 @@ public WorkshopSessionDto generateSession(List goals, log.warn("Could not persist session: {}", e.getMessage()); } - return session; + return session; } - private String buildHydrationPrompt(SessionSkeletonDto skeleton, + private String generateSessionTitle(SessionSkeletonDto skeleton, List goals, WorkshopInputDto meta, String sessionTypeLabel) { + String systemPrompt = "You are an expert learning designer. Create a short, engaging title for this workshop session. Return ONLY the title as a plain string, no quotes, no JSON."; + + var sb = new StringBuilder(); + sb.append("SESSION CONTEXT:\n"); + sb.append("- Type: ").append(sessionTypeLabel).append("\n"); + sb.append("- Topic/Main Goal: ").append(skeleton.learningGoal() != null ? skeleton.learningGoal() : "").append("\n"); + sb.append("LEARNING GOALS:\n"); + for (int i = 0; i < goals.size(); i++) { + sb.append("- ").append(goals.get(i).goal()).append("\n"); + } + try { + return llm.call(systemPrompt, sb.toString()).trim().replaceAll("^\"|\"$", ""); + } catch (Exception e) { + log.warn("Failed to generate title, using fallback", e); + return "Workshop Session Plan"; + } + } + + private String buildHydrationPromptForBlock(SkeletonBlockDto targetBlock, SessionSkeletonDto skeleton, List goals, WorkshopInputDto meta, String sessionTypeLabel) throws Exception { @@ -339,87 +382,64 @@ private String buildHydrationPrompt(SessionSkeletonDto skeleton, sb.append("LG").append(i + 1).append(": ").append(g.goal()).append("\n"); } - sb.append("\nSKELETON TO HYDRATE:\n"); + sb.append("\nFULL SESSION OUTLINE (For context only):\n"); sb.append(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(skeleton.blocks())); + sb.append("\nTARGET BLOCK TO HYDRATE:\n"); + sb.append(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(targetBlock)); + sb.append( """ INSTRUCTIONS: - For each skeleton block, generate a concrete instructor script. The key output per block is 'sections' — each section is a phase of the block (e.g. You explain, Participants Practice, Check Understanding) with a step-by-step timed todo list telling the instructor EXACTLY what to do. + You are an expert learning designer. Your task is to generate a concrete instructor script for the TARGET BLOCK ONLY. + The key output is 'sections' — each section is a phase of the block with a step-by-step timed todo list telling the instructor EXACTLY what to do. CRITICAL RULES: 1. DO NOT invent or elaborate on the teaching content. Focus entirely on the PROCESS. - 2. Keep the step descriptions short and precise. Avoid fluff. For example, instead of "Instructor welcomes participants, introduces themselves, and creates a friendly atmosphere", just write "Greetings and introduction". Instead of "Instructor outlines the session agenda, learning goal (LG1: do a backflip), and timing", write "Agenda and LGs". - 3. Each step in the todo list should be timed and action-oriented. Do NOT use the word "instructor" in the steps, as this is implicitly understood. For example, use "Summarize: highlight correct examples" instead of "Summarize: instructor highlights correct examples". + 2. Keep the step descriptions short and precise. Avoid fluff. + 3. Each step in the todo list should be timed and action-oriented. Do NOT use the word "instructor". 4. Within a learning cycle block, the "Participants Practice" section MUST be led by the chosen activity and ideally broken down into these four distinct steps: - - Explain (e.g., "1 min - Explain: [provide highly specific, step-by-step instructions and mechanics for how the students will execute the activity]") - - Prompt (e.g., "1 min - Prompt: [insert the detailed prompt, scenario, or specific questions that students need to work on]") + - Explain (e.g., "1 min - Explain: [provide highly specific, step-by-step instructions]") + - Prompt (e.g., "1 min - Prompt: [insert the detailed prompt]") - Activity (e.g., "6 min - Activity: students do the activity in pairs") - - Summarize (e.g., "2 min - Summarize: [insert the possible answers to the question (if it has a correct answer), or the expected discussion outcomes to review]") + - Summarize (e.g., "2 min - Summarize: [insert possible answers]") 5. Match activities to the SELECTED ACTIVITIES list where appropriate. 6. Every section's steps must sum exactly to that section's duration. - 7. CRITICAL: Do NOT change the `phase`, `lgIndex`, section titles, or section durations from what is provided in the skeleton. - 8. For non-learning-cycle blocks (ARRIVE, ACTIVATE, EVALUATE, BREAK, SUMMARY, CUSTOM, BUFFER), generate steps directly under the block in a single section. Important: for BREAK blocks, make sure to give it a proper 'phaseLabel' like "Coffee Break" or "Lunch Break". - 9. The 'phaseLabel' should be a short, topic-focused title (e.g. "Intro to Regression" not the raw LG text). - 10. Do NOT list "Lecture" or "Presentation" under 'methods', as they are not considered interactive activities. - 11. CRITICAL: You might receive only a partial skeleton (e.g. a single block) if the user is regenerating a specific activity. Do NOT automatically turn it into a "Welcome" or "Arrival" block. STRICTLY respect the `phase` and `lgIndex` provided in the skeleton! + 7. CRITICAL: Do NOT change the `phase`, `lgIndex`, section titles, or section durations from what is provided in the target block. + 8. For non-learning-cycle blocks (ARRIVE, ACTIVATE, EVALUATE, BREAK, SUMMARY, CUSTOM, BUFFER), generate steps directly under the block in a single section. Important: for BREAK blocks, make sure to give it a proper 'phaseLabel' like "Coffee Break". + 9. The 'phaseLabel' should be a short, topic-focused title. + 10. Do NOT list "Lecture" or "Presentation" under 'methods'. - OUTPUT FORMAT (return only this JSON, no markdown): + OUTPUT FORMAT (return only this JSON object, no markdown): { - "title": "A meaningful, engaging session title", - "blocks": [ + "phase": "LEARNING_CYCLE", + "lgIndex": 1, + "duration": 20, + "phaseLabel": "Topic Label Here", + "methods": ["Think-Pair-Share"], + "materials": ["Worksheet"], + "sections": [ { - "phase": "ARRIVE", - "lgIndex": 0, - "duration": 5, - "phaseLabel": "Welcome & Setup", + "title": "You explain", + "duration": 10, + "steps": [ + "10 min — Lecture on [topic of LG1] using slides" + ], "methods": [], - "materials": [], - "sections": [ - { - "title": "Arrival", - "duration": 5, - "steps": [ - "2 min — Greetings and introduction", - "2 min — Agenda and LGs", - "1 min — Housekeeping (toilets, breaks, phone policy)" - ], - "methods": [], - "materials": [] - } - ] + "materials": ["Slides"] }, { - "phase": "LEARNING_CYCLE", - "lgIndex": 1, - "duration": 20, - "phaseLabel": "Topic Label Here", - "methods": [], - "materials": [], - "sections": [ - { - "title": "You explain", - "duration": 10, - "steps": [ - "10 min — Lecture on [topic of LG1] using slides" - ], - "methods": [], - "materials": ["Slides"] - }, - { - "title": "Participants Practice", - "duration": 10, - "steps": [ - "1 min — Explain: rules for Think-Pair-Share (now you will discuss in groups...)", - "1 min — Prompt: [insert the detailed prompt or question to solve]", - "6 min — Activity: students discuss in pairs", - "2 min — Summarize: cold-call pairs to share and provide feedback. Correct answers/outcomes: [insert possible answers]" - ], - "methods": ["Think-Pair-Share"], - "materials": ["Worksheet"] - } - ] + "title": "Participants Practice", + "duration": 10, + "steps": [ + "1 min — Explain: rules for Think-Pair-Share", + "1 min — Prompt: [insert prompt]", + "6 min — Activity: students discuss in pairs", + "2 min — Summarize: cold-call pairs" + ], + "methods": ["Think-Pair-Share"], + "materials": ["Worksheet"] } ] } diff --git a/apps/workshopper/frontend/src/App.tsx b/apps/workshopper/frontend/src/App.tsx index de58b02..ed9e57f 100644 --- a/apps/workshopper/frontend/src/App.tsx +++ b/apps/workshopper/frontend/src/App.tsx @@ -85,6 +85,8 @@ export default function App() { /** Persist current draft state to the backend, debounced. Returns the session id. */ + const pendingResolvers = useRef void>>([]); + const persistDraft = useCallback( async ( draft: DraftState, @@ -95,13 +97,18 @@ export default function App() { ): Promise => { if (draftSaveTimeout.current) clearTimeout(draftSaveTimeout.current); return new Promise((resolve) => { + pendingResolvers.current.push(resolve); draftSaveTimeout.current = setTimeout(async () => { try { const id = await saveDraft(draft, currentSessionId, currentStep, type, lectureId ?? undefined); - resolve(id); + const resolvers = pendingResolvers.current; + pendingResolvers.current = []; + resolvers.forEach(r => r(id)); } catch (e) { console.warn("Draft save failed", e); - resolve(currentSessionId ?? ""); + const resolvers = pendingResolvers.current; + pendingResolvers.current = []; + resolvers.forEach(r => r(currentSessionId ?? "")); } }, 300); }); @@ -347,9 +354,15 @@ export default function App() { try { const skeleton = generateDefaultSkeleton(goals, updatedInput.duration || 90); setCurrentSkeleton(skeleton); + + // We must save the draft first. If the LLM generation times out, we don't want to create an orphaned session. + const initialDraft = buildDraft(updatedInput, goals, skeleton); + const currentId = await persistDraft(initialDraft, "goals", sessionId, entityType, currentLectureId); + if (!sessionId) setSessionId(currentId); + const skeletonWithId: SessionSkeleton = { ...skeleton, - sessionId: sessionId ?? undefined, + sessionId: currentId, }; const result = await generateSession( goals, @@ -360,13 +373,9 @@ export default function App() { result.title = updatedInput.title.trim(); } setSession(result); - if (result.id && result.id !== sessionId) { - setSessionId(result.id); - } const draft = buildDraft(updatedInput, goals, skeleton, result); - const id = await persistDraft(draft, "timeline", sessionId ?? result.id ?? null, entityType, currentLectureId); - if (!sessionId) setSessionId(id); + await persistDraft(draft, "timeline", currentId, entityType, currentLectureId); setStep("timeline"); } catch (err) { toast({ @@ -392,6 +401,7 @@ export default function App() { setCurrentSkeleton(null); setCompletedTasks([]); setIsFinished(false); + setIsLoading(false); setStep("input-1"); }; diff --git a/apps/workshopper/frontend/src/components/LGHImport.tsx b/apps/workshopper/frontend/src/components/LGHImport.tsx index 3030977..c7473ae 100644 --- a/apps/workshopper/frontend/src/components/LGHImport.tsx +++ b/apps/workshopper/frontend/src/components/LGHImport.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { Loader2, Plus, Server, Check } from "lucide-react"; +import { Loader2, Plus, Server } from "lucide-react"; import { fetchCourses, fetchGoalsBySession, Course, SessionGroup } from "@/lib/learning-goal-hub"; import { toast } from "@/hooks/use-toast"; @@ -17,7 +17,7 @@ export function LGHImport({ onAddGoals, disabled }: Props) { const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); - const [selectedGoalIds, setSelectedGoalIds] = useState>(new Set()); + const [selectedGoalId, setSelectedGoalId] = useState(""); useEffect(() => { async function load() { @@ -38,7 +38,7 @@ export function LGHImport({ onAddGoals, disabled }: Props) { useEffect(() => { if (!selectedCourseId) { setSessions([]); - setSelectedGoalIds(new Set()); + setSelectedGoalId(""); return; } async function loadSessions() { @@ -46,7 +46,7 @@ export function LGHImport({ onAddGoals, disabled }: Props) { try { const data = await fetchGoalsBySession(selectedCourseId as number); setSessions(data); - setSelectedGoalIds(new Set()); + setSelectedGoalId(""); } catch (e) { console.error(e); toast({ title: "Error", description: "Failed to fetch learning goals", variant: "destructive" }); @@ -57,28 +57,23 @@ export function LGHImport({ onAddGoals, disabled }: Props) { loadSessions(); }, [selectedCourseId]); - const toggleGoal = (id: number) => { - setSelectedGoalIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - const handleAdd = () => { - const goalsToAdd: string[] = []; - sessions.forEach((s) => { - s.goals.forEach((g) => { - if (selectedGoalIds.has(g.id)) { - goalsToAdd.push(g.text); - } - }); - }); - if (goalsToAdd.length > 0) { - onAddGoals(goalsToAdd); - setSelectedGoalIds(new Set()); - toast({ title: "Goals Added", description: `Added ${goalsToAdd.length} goals.` }); + if (selectedGoalId === "") return; + + // Find the text of the selected goal + let goalText = ""; + for (const s of sessions) { + const g = s.goals.find(g => g.id === selectedGoalId); + if (g) { + goalText = g.text; + break; + } + } + + if (goalText) { + onAddGoals([goalText]); + setSelectedGoalId(""); + toast({ title: "Goal Added", description: "Successfully imported learning goal." }); } }; @@ -88,7 +83,7 @@ export function LGHImport({ onAddGoals, disabled }: Props) {

Import from LearningGoalHub

-

Select a course to view approved learning goals

+

Select a course and a learning goal

@@ -113,38 +108,28 @@ export function LGHImport({ onAddGoals, disabled }: Props) { {loadingSessions && (
- Loading sessions... + Loading learning goals...
)} {!loadingSessions && sessions.length > 0 && ( -
+ toggleGoal(goal.id)} - /> - - ))} -
- + + {group.goals.map(goal => ( + + ))} + ))} - + )} {sessions.length > 0 && !loadingSessions && ( @@ -153,10 +138,10 @@ export function LGHImport({ onAddGoals, disabled }: Props) { variant="secondary" size="sm" className="w-full gap-2" - disabled={selectedGoalIds.size === 0 || disabled} + disabled={selectedGoalId === "" || disabled} onClick={handleAdd} > - Add {selectedGoalIds.size} Selected Goals + Add Goal )} diff --git a/apps/workshopper/frontend/src/components/SlideWorkstation.tsx b/apps/workshopper/frontend/src/components/SlideWorkstation.tsx index 7ee068c..9010191 100644 --- a/apps/workshopper/frontend/src/components/SlideWorkstation.tsx +++ b/apps/workshopper/frontend/src/components/SlideWorkstation.tsx @@ -83,16 +83,24 @@ export function SlideWorkstation({ session, meta, goals, slidesCache, setSlidesC let hasError = false; try { - for (let i = 0; i < session.blocks.length; i++) { - const block = session.blocks[i]; - const res = await fetch(import.meta.env.BASE_URL + "api/workshop/export/block-slides", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ block, meta }), + const concurrencyLimit = 3; + for (let i = 0; i < session.blocks.length; i += concurrencyLimit) { + const chunk = session.blocks.slice(i, i + concurrencyLimit); + const promises = chunk.map(async (block, chunkIdx) => { + const actualIdx = i + chunkIdx; + const res = await fetch(import.meta.env.BASE_URL + "api/workshop/export/block-slides", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ block, meta }), + }); + if (!res.ok) throw new Error("Failed to generate slides for block " + block.phaseLabel); + return { idx: actualIdx, slides: await res.json() }; }); - if (!res.ok) throw new Error("Failed to generate slides for block " + block.phaseLabel); - const slides = await res.json(); - newCache[i] = slides; + + const results = await Promise.all(promises); + for (const { idx, slides } of results) { + newCache[idx] = slides; + } } setSlidesCache(newCache); diff --git a/apps/workshopper/frontend/src/components/WorkshopGeneratedTimetable.tsx b/apps/workshopper/frontend/src/components/WorkshopGeneratedTimetable.tsx index 55d3a64..1c1c2ee 100644 --- a/apps/workshopper/frontend/src/components/WorkshopGeneratedTimetable.tsx +++ b/apps/workshopper/frontend/src/components/WorkshopGeneratedTimetable.tsx @@ -11,12 +11,16 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ArrowLeft, ArrowRight, GripVertical, ChevronDown, ChevronUp, - Clock, Trash2, Plus, Loader2, + Clock, Trash2, Plus, Loader2, Pencil, Save, X } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, @@ -77,6 +81,11 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go const [editing, setEditing] = useState(null); const [regeneratingBlockId, setRegeneratingBlockId] = useState(null); + // Edit Mode State + const [isEditMode, setIsEditMode] = useState(false); + const [originalBlocks, setOriginalBlocks] = useState(null); + const [pendingNavigation, setPendingNavigation] = useState<"back" | "next" | null>(null); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) @@ -94,14 +103,6 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go blocks: blocks.map(({ dndId: _dndId, lgIndex: _lgIndex, ...rest }) => rest), }), [initialSession, sessionTitle, blocks]); - // Auto-save changes to the parent (debounced) - useEffect(() => { - const timeout = setTimeout(() => { - onSaveSession?.(buildSession()); - }, 500); - return () => clearTimeout(timeout); - }, [buildSession, onSaveSession]); - const handleSaveTitle = (dndId: string, v: string) => { updateBlock(dndId, { phaseLabel: v }); setEditing(null); @@ -128,19 +129,45 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go const handleSaveBlockDuration = (dndId: string, v: string) => { let num = parseInt(v, 10); if (isNaN(num) || num < 0) num = 0; - updateBlock(dndId, { duration: num }); + + setBlocks(prev => { + const next = prev.map(b => b.dndId === dndId ? { ...b, duration: num } : b); + if (!isEditMode && onSaveSession) { + setTimeout(() => { + onSaveSession({ + ...initialSession, + title: sessionTitle, + blocks: next.map(({ dndId: _dndId, lgIndex: _lgIndex, ...rest }) => rest), + }); + }, 0); + } + return next; + }); setEditing(null); }; const handleSaveSectionDuration = (dndId: string, sectionIdx: number, v: string) => { const num = parseInt(v, 10); if (!isNaN(num) && num >= 0) { - setBlocks(prev => prev.map(b => { - if (b.dndId !== dndId || !b.sections) return b; - const sections = b.sections.map((sec, si) => si === sectionIdx ? { ...sec, duration: num } : sec); - const newDuration = sections.reduce((acc, sec) => acc + (sec.duration || 0), 0); - return { ...b, sections, duration: newDuration }; - })); + setBlocks(prev => { + const next = prev.map(b => { + if (b.dndId !== dndId || !b.sections) return b; + const sections = b.sections.map((sec, si) => si === sectionIdx ? { ...sec, duration: num } : sec); + const newDuration = sections.reduce((acc, sec) => acc + (sec.duration || 0), 0); + return { ...b, sections, duration: newDuration }; + }); + + if (!isEditMode && onSaveSession) { + setTimeout(() => { + onSaveSession({ + ...initialSession, + title: sessionTitle, + blocks: next.map(({ dndId: _dndId, lgIndex: _lgIndex, ...rest }) => rest), + }); + }, 0); + } + return next; + }); } setEditing(null); }; @@ -149,25 +176,39 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go let num = parseInt(newTime, 10); if (isNaN(num) || num < 0) num = 0; - setBlocks(prev => prev.map(b => { - if (b.dndId !== dndId || !b.sections) return b; - const sections = b.sections.map((sec, si) => { - let steps = sec.steps || []; - if (si === sectionIdx) { - steps = steps.map((st, ti) => { - if (ti !== stepIdx) return st; - const match = st.match(/^(\d+)\s*(?:min|m)(?:utes?)?\s*(?:—|-|–|:)?\s*(.*)/i); - const contentText = match ? match[2] : st; - return `${num} min - ${contentText}`; - }); - } - const newDuration = steps.reduce((acc, st) => acc + parseStepDuration(st), 0); - const finalDuration = steps.length > 0 ? newDuration : (sec.duration || 0); - return { ...sec, steps, duration: finalDuration }; + setBlocks(prev => { + const next = prev.map(b => { + if (b.dndId !== dndId || !b.sections) return b; + const sections = b.sections.map((sec, si) => { + let steps = sec.steps || []; + if (si === sectionIdx) { + steps = steps.map((st, ti) => { + if (ti !== stepIdx) return st; + const match = st.match(/^(\d+)\s*(?:min|m)(?:utes?)?\s*(?:—|-|–|:)?\s*(.*)/i); + const contentText = match ? match[2] : st; + return `${num} min - ${contentText}`; + }); + } + const newDuration = steps.reduce((acc, st) => acc + parseStepDuration(st), 0); + const finalDuration = steps.length > 0 ? newDuration : (sec.duration || 0); + return { ...sec, steps, duration: finalDuration }; + }); + const newBlockDuration = sections.reduce((acc, sec) => acc + (sec.duration || 0), 0); + return { ...b, sections, duration: newBlockDuration }; }); - const newBlockDuration = sections.reduce((acc, sec) => acc + (sec.duration || 0), 0); - return { ...b, sections, duration: newBlockDuration }; - })); + + if (!isEditMode && onSaveSession) { + setTimeout(() => { + onSaveSession({ + ...initialSession, + title: sessionTitle, + blocks: next.map(({ dndId: _dndId, lgIndex: _lgIndex, ...rest }) => rest), + }); + }, 0); + } + return next; + }); + setEditing(null); }; const handleAddStep = (dndId: string, text: string) => { @@ -202,16 +243,69 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go }; const handleDragEnd = (event: DragEndEvent) => { + if (!isEditMode) return; const { active, over } = event; if (over && active.id !== over.id) { - setBlocks(prev => { - const oldIndex = prev.findIndex(b => b.dndId === active.id); - const newIndex = prev.findIndex(b => b.dndId === over.id); - return arrayMove(prev, oldIndex, newIndex); + setBlocks((items) => { + const oldIndex = items.findIndex((item) => item.dndId === active.id); + const newIndex = items.findIndex((item) => item.dndId === over.id); + return arrayMove(items, oldIndex, newIndex); }); } }; + const enterEditMode = () => { + setOriginalBlocks(JSON.parse(JSON.stringify(blocks))); + setIsEditMode(true); + }; + + const saveEditMode = () => { + const latest = buildSession(); + onSaveSession?.(latest); + setIsEditMode(false); + setOriginalBlocks(null); + }; + + const cancelEditMode = () => { + if (originalBlocks) setBlocks(originalBlocks); + setIsEditMode(false); + setOriginalBlocks(null); + setEditing(null); + }; + + const executePendingNavigation = () => { + if (pendingNavigation === "back") { + onBack(); + } else if (pendingNavigation === "next") { + const latest = buildSession(); + onNext(latest); + } + setPendingNavigation(null); + }; + + const confirmNavigation = () => { + cancelEditMode(); + executePendingNavigation(); + }; + + const handleBack = () => { + if (isEditMode) { + setPendingNavigation("back"); + } else { + onBack(); + } + }; + + const handleNext = () => { + if (isEditMode) { + setPendingNavigation("next"); + } else { + const latest = buildSession(); + onSaveSession?.(latest); + onNext(latest); + } + }; + const switchActivity = async (dndId: string, newActivity: string) => { setRegeneratingBlockId(dndId); try { @@ -246,11 +340,25 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go const newSession = await res.json(); if (newSession.blocks?.[0]) { const updatedBlock = newSession.blocks[0]; - updateBlock(dndId, { - ...updatedBlock, - dndId, - lgIndex, - blockId: block.blockId, + setBlocks(prev => { + const next = prev.map(b => b.dndId === dndId ? { + ...b, + ...updatedBlock, + dndId, + lgIndex, + blockId: block.blockId, + } : b); + + if (!isEditMode && onSaveSession) { + setTimeout(() => { + onSaveSession({ + ...initialSession, + title: sessionTitle, + blocks: next.map(({ dndId: _dndId, lgIndex: _lgIndex, ...rest }) => rest), + }); + }, 0); + } + return next; }); toast({ title: "Activity updated", description: `Switched to ${newActivity}` }); } @@ -261,12 +369,6 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go } }; - const handleNext = () => { - const latest = buildSession(); - onSaveSession?.(latest); - onNext(latest); - }; - const totalDuration = blocks.reduce((s, b) => s + b.duration, 0); const targetDuration = meta.duration || totalDuration; const timePercentage = Math.min(100, Math.round((totalDuration / targetDuration) * 100)); @@ -277,14 +379,18 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go
- setEditing({ type: "sessionTitle" })} - onSave={(v) => { setSessionTitle(v); setEditing(null); }} - className="font-display font-bold text-3xl block w-full" - inputClassName="py-1.5 px-3 w-full" - /> +

+ {isEditMode ? ( + setSessionTitle(e.target.value)} + className="font-display text-2xl font-semibold h-auto py-1 px-2 -ml-2 border-primary/50 focus-visible:ring-1 focus-visible:ring-primary w-[300px]" + placeholder="Workshop Title" + /> + ) : ( + {sessionTitle || "Workshop Title"} + )} +

Double-click any title, activity, or step to edit it inline. Click an activity badge to switch it. @@ -303,6 +409,7 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go meta={meta} isRegenerating={regeneratingBlockId === block.dndId} selectedActivities={meta.selectedActivities || []} + isEditMode={isEditMode} onToggleExpand={() => toggleExpand(block.dndId)} onEditTitle={() => setEditing({ type: "title", blockId: block.dndId })} onSaveTitle={v => handleSaveTitle(block.dndId, v)} @@ -322,35 +429,37 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go ))} -
- -
+ {isEditMode && ( +
+ +
+ )} {/* Sticky footer */}
- @@ -385,6 +494,66 @@ export default function WorkshopGeneratedTimetable({ session: initialSession, go
+ + {/* Floating Action Buttons for Edit Mode */} +
+
+ {isEditMode ? ( + <> + + + + ) : ( + + )} +
+
+ + {/* Unsaved Changes Dialog */} + { + if (!open) setPendingNavigation(null); + }}> + + + You have unsaved work + + Are you sure you want to proceed? +

+ If you click Yes, your recent edits will be undone. +
+ If you click No, you will remain on this page with your edits intact. +
+
+ + setPendingNavigation(null)}>No, stay here + + Yes, undo changes & proceed + + +
+
); } diff --git a/apps/workshopper/frontend/src/components/timetable/InlineEditText.tsx b/apps/workshopper/frontend/src/components/timetable/InlineEditText.tsx index 5443b50..bd53cfe 100644 --- a/apps/workshopper/frontend/src/components/timetable/InlineEditText.tsx +++ b/apps/workshopper/frontend/src/components/timetable/InlineEditText.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect } from "react"; export function InlineEditText({ - value, editing, onStartEdit, onSave, multiline = false, className = "", inputClassName = "text-sm px-2 py-0.5", boxStyle = false + value, editing, onStartEdit, onSave, multiline = false, className = "", inputClassName = "text-sm px-2 py-0.5", boxStyle = false, disabled = false }: { value: string; editing: boolean; @@ -11,6 +11,7 @@ export function InlineEditText({ className?: string; inputClassName?: string; boxStyle?: boolean; + disabled?: boolean; }) { const [draft, setDraft] = useState(value); const ref = useRef(null); @@ -52,15 +53,20 @@ export function InlineEditText({ return ( { e.stopPropagation(); onStartEdit(); }} + className={`${disabled ? "" : "cursor-text"} select-text ${boxStyle ? "inline-block border border-border/60 rounded px-1 py-0.5 bg-background/50 hover:bg-background transition-colors" : ""} ${className}`} + onDoubleClick={e => { + if (disabled) return; + e.stopPropagation(); + onStartEdit(); + }} onClick={e => { + if (disabled) return; if (boxStyle) { e.stopPropagation(); onStartEdit(); } }} - title={boxStyle ? "Click to edit" : "Double-click to edit"} + title={disabled ? "" : (boxStyle ? "Click to edit" : "Double-click to edit")} > {value || (boxStyle ? "0" : "Click to edit...")} diff --git a/apps/workshopper/frontend/src/components/timetable/SortableBlockRow.tsx b/apps/workshopper/frontend/src/components/timetable/SortableBlockRow.tsx index 2e1d338..5159a7c 100644 --- a/apps/workshopper/frontend/src/components/timetable/SortableBlockRow.tsx +++ b/apps/workshopper/frontend/src/components/timetable/SortableBlockRow.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { GripVertical, ChevronDown, ChevronUp, - Trash2, Plus, Loader2, + Trash2, Plus, Loader2, Info, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, @@ -20,7 +20,7 @@ export function SortableBlockRow({ onToggleExpand, onEditTitle, onSaveTitle, onEditStep, onSaveStep, onEditStepTime, onSaveStepTime, onEditBlockDuration, onSaveBlockDuration, onEditSectionDuration, onSaveSectionDuration, - onDeleteBlock, onSwitchActivity, onAddStep, onDeleteStep, + onDeleteBlock, onSwitchActivity, onAddStep, onDeleteStep, isEditMode = false, }: { block: DndActivityBlock; isExpanded: boolean; @@ -43,8 +43,12 @@ export function SortableBlockRow({ onSwitchActivity: (method: string) => void; onAddStep: (text: string) => void; onDeleteStep: (sectionIdx: number, stepIdx: number) => void; + isEditMode?: boolean; }) { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: block.dndId }); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: block.dndId, + disabled: !isEditMode // Disable dragging when not in edit mode + }); const style = { transform: CSS.Transform.toString(transform), transition, @@ -81,6 +85,7 @@ export function SortableBlockRow({ onStartEdit={onEditTitle} onSave={onSaveTitle} className="font-body font-semibold text-sm" + disabled={!isEditMode} /> {allMethods.length > 0 && (
@@ -134,6 +139,7 @@ export function SortableBlockRow({ onSave={onSaveBlockDuration} className="w-8 text-right text-xs font-mono" boxStyle + disabled={false} /> )} m @@ -146,14 +152,16 @@ export function SortableBlockRow({ ) : (
)} - + {isEditMode && ( + + )}
@@ -185,13 +193,21 @@ export function SortableBlockRow({ return (
{subEmoji} - onEditStep(sIdx, stIdx)} - onSave={v => onSaveStep(sIdx, stIdx, v)} - className="flex-1 text-sm text-foreground/85 leading-snug py-0.5" - /> +
+ onEditStep(sIdx, stIdx)} + onSave={v => onSaveStep(sIdx, stIdx, v)} + className="flex-1 text-sm text-foreground/85 leading-snug py-0.5 truncate" + disabled={!isEditMode} + /> + {!isEditMode && ( +
+ +
+ )} +
onSaveStepTime(sIdx, stIdx, v)} className="w-8 text-right text-xs font-mono" boxStyle + disabled={false} /> m - + {isEditMode && ( + + )}
); @@ -216,19 +235,21 @@ export function SortableBlockRow({
))} -
- - { - if (e.key === "Enter" && e.currentTarget.value.trim()) { - onAddStep(e.currentTarget.value.trim()); - e.currentTarget.value = ""; - } - }} - /> -
+ {isEditMode && ( +
+ + { + if (e.key === "Enter" && e.currentTarget.value.trim()) { + onAddStep(e.currentTarget.value.trim()); + e.currentTarget.value = ""; + } + }} + /> +
+ )} diff --git a/apps/workshopper/frontend/src/components/ui/button.tsx b/apps/workshopper/frontend/src/components/ui/button.tsx index 6b4bb7f..00a16a7 100644 --- a/apps/workshopper/frontend/src/components/ui/button.tsx +++ b/apps/workshopper/frontend/src/components/ui/button.tsx @@ -41,26 +41,22 @@ const buttonVariants = cva( } ) -const Button = React.forwardRef & VariantProps & { asChild?: boolean }>(({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}, ref) => { - const Comp = asChild ? Slot.Root : "button" +const Button = React.forwardRef & VariantProps & { asChild?: boolean }>( + ({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot.Root : "button" - return ( - - ) -}) + return ( + + ) + } +) Button.displayName = "Button" export { Button, buttonVariants } diff --git a/apps/workshopper/frontend/src/components/ui/dropdown-menu.tsx b/apps/workshopper/frontend/src/components/ui/dropdown-menu.tsx index a4695ec..2b7bf6c 100644 --- a/apps/workshopper/frontend/src/components/ui/dropdown-menu.tsx +++ b/apps/workshopper/frontend/src/components/ui/dropdown-menu.tsx @@ -18,35 +18,34 @@ function DropdownMenuPortal({ ) } -function DropdownMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - , + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => ( + +)) +DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "start", sideOffset = 4, ...props }, ref) => ( + + - ) -} - -function DropdownMenuContent({ - className, - align = "start", - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName function DropdownMenuGroup({ ...props diff --git a/apps/workshopper/frontend/src/lib/learning-goal-hub.ts b/apps/workshopper/frontend/src/lib/learning-goal-hub.ts index 387141f..0c06c63 100644 --- a/apps/workshopper/frontend/src/lib/learning-goal-hub.ts +++ b/apps/workshopper/frontend/src/lib/learning-goal-hub.ts @@ -34,7 +34,7 @@ export async function fetchCourses(): Promise { } export async function fetchGoalsBySession(courseId: number): Promise { - const res = await fetch(`${BASE_URL}/courses/${courseId}/learning-goals/by-session?status=APPROVED`); + const res = await fetch(`${BASE_URL}/courses/${courseId}/learning-goals/by-session`); if (!res.ok) throw new Error("Failed to fetch learning goals"); return await res.json(); } diff --git a/apps/workshopper/frontend/vite.config.ts b/apps/workshopper/frontend/vite.config.ts index fd6223b..25ed3d4 100644 --- a/apps/workshopper/frontend/vite.config.ts +++ b/apps/workshopper/frontend/vite.config.ts @@ -19,6 +19,8 @@ export default defineConfig({ [`${BASE_PATH}/api`]: { target: API_TARGET, changeOrigin: true, + timeout: 300000, + proxyTimeout: 300000, rewrite: (path) => path.replace(new RegExp(`^${BASE_PATH}`), ""), }, "/learninggoalhub/api": {