diff --git a/apps/workshopper/AUTH.md b/apps/workshopper/AUTH.md
index ae99c4d..2ec7531 100644
--- a/apps/workshopper/AUTH.md
+++ b/apps/workshopper/AUTH.md
@@ -56,7 +56,9 @@ An agent or developer must replicate the exact SAML configuration we built for W
* Copy the SAML configuration block (including `server.forward-headers-strategy: framework`) into their `application.yml`.
* Create the `SecurityConfig.java` to enable `.saml2Login()` and explicitly permit access to `/saml2/service-provider-metadata/**`.
* Modify their `application.yml` and `compose.prod.yaml` to mount the exact same central certificate folder (`/opt/hestia/secrets/saml:/app/certs:ro`) and read them via environment variables.
+* **CRITICAL:** Fix the Docker DNS collision in their frontend `nginx.conf`! Both currently proxy to `http://server`, which randomly resolves to the wrong backend on the shared `hestia-edge` network. Change this to their exact unique container names (e.g., `proxy_pass http://examlense-server:8081;` and `proxy_pass http://learninggoalhub-server:8080;`).
* **CRITICAL:** Update their frontend `nginx.conf` file to proxy `location ~ ^/(v3/api-docs|swagger-ui|saml2|login/saml2/sso)` to the backend server. Otherwise, NGINX will block the SAML traffic!
+* **NOTE:** While editing `compose.prod.yaml`, it is highly recommended to proactively change `POSTGRES_HOST: postgres` to their unique database container names (`examlense-postgres` or `learninggoalhub-postgres`) to prevent future DNS collisions if the database ever joins the edge network.
* Once deployed, verify that they each generate their own live metadata URLs (e.g., `https://hestia-test.aet.cit.tum.de/learninggoalhub/saml2/service-provider-metadata/tum`).
**2. Host the Privacy Policy:**
diff --git a/apps/workshopper/compose.prod.yaml b/apps/workshopper/compose.prod.yaml
index 2ba801f..eb1187f 100644
--- a/apps/workshopper/compose.prod.yaml
+++ b/apps/workshopper/compose.prod.yaml
@@ -21,7 +21,7 @@ services:
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_JPA_HIBERNATE_DDL_AUTO: update
- POSTGRES_HOST: postgres
+ POSTGRES_HOST: workshopper-postgres
POSTGRES_PORT: 5432
POSTGRES_DB: ${POSTGRES_DB:-workshopper}
POSTGRES_USER: ${POSTGRES_USER:-workshopper}
diff --git a/apps/workshopper/frontend/nginx.conf b/apps/workshopper/frontend/nginx.conf
index f20d649..5ea69fd 100644
--- a/apps/workshopper/frontend/nginx.conf
+++ b/apps/workshopper/frontend/nginx.conf
@@ -20,7 +20,7 @@ server {
}
location /api/ {
- proxy_pass http://server:8081;
+ proxy_pass http://workshopper-server:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -34,7 +34,7 @@ server {
}
location ~ ^/(v3/api-docs|swagger-ui|saml2|login/saml2/sso) {
- proxy_pass http://server:8081;
+ proxy_pass http://workshopper-server:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
diff --git a/apps/workshopper/frontend/src/components/SessionsDashboard.tsx b/apps/workshopper/frontend/src/components/SessionsDashboard.tsx
index 94d713c..4c03f6e 100644
--- a/apps/workshopper/frontend/src/components/SessionsDashboard.tsx
+++ b/apps/workshopper/frontend/src/components/SessionsDashboard.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { getSessionStatus } from "@/lib/utils";
import { GraduationCap, BookOpen, Clock, Loader2, Target, Plus, Trash2, Settings, ArrowRight, Play, Archive, FileText, ChevronRight, X, Edit2, CheckCircle2, FileEdit, MoreVertical, Pencil, Presentation, ChevronDown, Sparkles } from "lucide-react";
import hestiaLogoLight from "@/assets/logos/wordmark-light.svg";
import hestiaLogoDark from "@/assets/logos/wordmark-dark.svg";
@@ -297,34 +298,22 @@ export default function SessionsDashboard({ onNewSession, onNewLecture, onNewSes
{(() => {
const isFinished = session.status === "complete" && session.currentStep === "finished";
const isReadyForPrep = session.status === "complete" && session.currentStep !== "finished";
- const isDraft = session.status !== "complete";
- // G-1: status text inside icon container so state is readable in grayscale
- const statusShort = isFinished ? "Done" : isReadyForPrep ? "Ready" : "Draft";
+ const status = getSessionStatus(session);
return (
-
- {isFinished ?
: isReadyForPrep ?
:
}
-
{statusShort}
+
+ {status.isFinished ? : status.isReadyForPrep ? : }
+ {status.statusShort}
);
})()}
{(() => {
- const isFinished = session.status === "complete" && session.currentStep === "finished";
- const isReadyForPrep = session.status === "complete" && session.currentStep !== "finished";
- const statusLabel = isFinished ? "Completed" : isReadyForPrep ? "Ready for preparation" : "In progress";
- const statusClass = isFinished
- ? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
- : isReadyForPrep
- ? "bg-blue-500/15 text-blue-600 dark:text-blue-400"
- : "bg-amber-500/15 text-amber-600 dark:text-amber-400";
+ const status = getSessionStatus(session);
return (
{session.title || "Workshop Session"}
-
- {statusLabel}
+
+ {status.statusLabel}
);
diff --git a/apps/workshopper/frontend/src/components/WorkshopGeneratedTimetable.tsx b/apps/workshopper/frontend/src/components/WorkshopGeneratedTimetable.tsx
index 9f4e467..55d3a64 100644
--- a/apps/workshopper/frontend/src/components/WorkshopGeneratedTimetable.tsx
+++ b/apps/workshopper/frontend/src/components/WorkshopGeneratedTimetable.tsx
@@ -27,57 +27,14 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "@/hooks/use-toast";
+import { SortableBlockRow } from "./timetable/SortableBlockRow";
+import { InlineEditText } from "./timetable/InlineEditText";
-// ── Phase styling ──────────────────────────────────────────────────
-const phaseEmojis: Record
= {
- ARRIVE: "👋", ACTIVATE: "💡", INFORM: "🗣️", PROCESS: "🛠️",
- BREAK: "☕", EVALUATE: "✅", SUMMARY: "🏁", LEARNING_CYCLE: "🎯",
- CUSTOM: "✏️", BUFFER: "⏳",
-};
-
-const phaseRowColors: Record = {
- ARRIVE: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-primary",
- ACTIVATE: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-accent",
- INFORM: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-primary/80",
- PROCESS: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-accent/80",
- BREAK: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-muted-foreground",
- EVALUATE: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-primary/60",
- SUMMARY: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-accent/60",
- LEARNING_CYCLE: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-primary",
- CUSTOM: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-border",
- BUFFER: "bg-muted/30 relative before:absolute before:inset-y-0 before:left-0 before:w-[3px] before:bg-muted",
-};
-
-const getStepEmoji = (text: string) => {
- const t = text.toLowerCase();
- if (t.includes("lecture") || t.includes("presentation") || t.includes("explain") || t.includes("concept")) return "🧑🏫";
- if (t.includes("prompt") || t.includes("question") || t.includes("q&a") || t.includes("quiz")) return "❓";
- if (t.includes("activity") || t.includes("exercise") || t.includes("practice")) return "🛠️";
- if (t.includes("discuss") || t.includes("debate") || t.includes("share")) return "💬";
- if (t.includes("brainstorm") || t.includes("ideate")) return "💡";
- if (t.includes("review") || t.includes("feedback") || t.includes("evaluate")) return "✅";
- if (t.includes("welcome") || t.includes("intro")) return "👋";
- if (t.includes("wrap") || t.includes("summary") || t.includes("conclusion")) return "🏁";
- if (t.includes("break") || t.includes("pause")) return "☕";
- if (t.includes("read") || t.includes("case study")) return "📖";
- if (t.includes("video") || t.includes("watch")) return "🎥";
- if (t.includes("role play") || t.includes("simulate")) return "🎭";
- if (t.includes("icebreaker") || t.includes("game")) return "🎲";
- return "✨";
-};
-
-const getStepColorClass = (text: string) => {
- return "bg-card border-border/60";
-};
-
-const DEFAULT_ACTIVITIES = [
- "Group Discussion", "Case Study", "Role Play",
- "Hands-on Practice", "Quiz / Polls", "Q&A Session",
- "Peer Review", "Brainstorming", "Think-Pair-Share",
-];
+import { phaseEmojis, phaseRowColors, getStepEmoji, getStepColorClass, DEFAULT_ACTIVITIES } from "@/lib/constants";
+import { parseStepDuration } from "@/lib/utils";
// ── Editing state ──────────────────────────────────────────────────
-type EditTarget =
+export type EditTarget =
| { type: "sessionTitle" }
| { type: "title"; blockId: string }
| { type: "step"; blockId: string; sectionIdx: number; stepIdx: number }
@@ -87,7 +44,7 @@ type EditTarget =
| null;
// ── Internal block with stable dnd ID ─────────────────────────────
-interface DndActivityBlock extends ActivityBlock {
+export interface DndActivityBlock extends ActivityBlock {
dndId: string;
lgIndex?: number;
}
@@ -102,295 +59,9 @@ interface Props {
onSaveSession?: (session: WorkshopSession) => void;
}
-// ── Inline editable text ───────────────────────────────────────────
-function InlineEditText({
- value, editing, onStartEdit, onSave, multiline = false, className = "", inputClassName = "text-sm px-2 py-0.5", boxStyle = false
-}: {
- value: string;
- editing: boolean;
- onStartEdit: () => void;
- onSave: (v: string) => void;
- multiline?: boolean;
- className?: string;
- inputClassName?: string;
- boxStyle?: boolean;
-}) {
- const [draft, setDraft] = useState(value);
- const ref = useRef(null);
-
- useEffect(() => {
- if (editing) {
- setDraft(value);
- setTimeout(() => ref.current?.focus(), 30);
- }
- }, [editing, value]);
-
- const commit = () => onSave(draft);
-
- if (editing) {
- if (multiline) {
- return (
-