diff --git a/Dockerfile b/Dockerfile
index 7f87db7..50ef1bd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,13 +7,37 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
COPY . .
-# Vite bakes VITE_* values at build time, so compose must choose the env file
-# during image build, not only at container runtime.
-ARG ENV_FILE=.env
-COPY ${ENV_FILE} .env
-
-# Run build with production mode
-RUN npm run build -- --mode production
+# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
+# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
+# service-host worktrees keep .env.dev as a symlink outside the Docker context.
+ARG VITE_API_BASE
+ARG VITE_API_URL
+ARG VITE_APP_NAME
+ARG VITE_APP_HMR_URL
+ARG VITE_DEV
+ARG VITE_FRONTEND_SITE_URL
+ARG VITE_SEARCH_URL
+ARG VITE_SUPABASE_ANON_KEY
+ARG VITE_SUPABASE_URL
+ARG VITE_SUPER_ADMIN_EMAIL
+ARG VITE_TLSYNC_URL
+ARG VITE_WHISPERLIVE_URL
+# Run build with production mode. Keep these as build-step environment values
+# rather than final-image ENV entries; Vite still embeds the public client config
+# into the static bundle, but nginx image metadata does not need them.
+RUN VITE_API_BASE="${VITE_API_BASE}" \
+ VITE_API_URL="${VITE_API_URL}" \
+ VITE_APP_NAME="${VITE_APP_NAME}" \
+ VITE_APP_HMR_URL="${VITE_APP_HMR_URL}" \
+ VITE_DEV="${VITE_DEV}" \
+ VITE_FRONTEND_SITE_URL="${VITE_FRONTEND_SITE_URL}" \
+ VITE_SEARCH_URL="${VITE_SEARCH_URL}" \
+ VITE_SUPABASE_ANON_KEY="${VITE_SUPABASE_ANON_KEY}" \
+ VITE_SUPABASE_URL="${VITE_SUPABASE_URL}" \
+ VITE_SUPER_ADMIN_EMAIL="${VITE_SUPER_ADMIN_EMAIL}" \
+ VITE_TLSYNC_URL="${VITE_TLSYNC_URL}" \
+ VITE_WHISPERLIVE_URL="${VITE_WHISPERLIVE_URL}" \
+ npm run build -- --mode production
FROM nginx:alpine
# Copy built files
@@ -31,6 +55,24 @@ RUN echo 'server { \
expires -1; \
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
} \
+ location = /health { \
+ proxy_pass http://192.168.0.64:18000/health; \
+ proxy_set_header Host $host; \
+ proxy_set_header X-Real-IP $remote_addr; \
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
+ } \
+ location /__ccapi/ { \
+ proxy_pass http://192.168.0.64:18000/; \
+ proxy_set_header Host $host; \
+ proxy_set_header X-Real-IP $remote_addr; \
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
+ } \
+ location /api/ { \
+ proxy_pass http://192.168.0.64:18000/api/; \
+ proxy_set_header Host $host; \
+ proxy_set_header X-Real-IP $remote_addr; \
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; \
+ } \
location / { \
try_files $uri $uri/ /index.html; \
expires 30d; \
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 74c4361..11dee9f 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -16,7 +16,23 @@ services:
context: .
dockerfile: Dockerfile
args:
- ENV_FILE: .env.dev
+ # app-dev is served by nginx on the app host; browser API calls must stay
+ # same-origin and pass through Dockerfile's /__ccapi proxy. The proxy
+ # strips that prefix before forwarding, preserving mixed backend routes
+ # such as /api/exam, /me/bootstrap, and /database/timetable.
+ # .env.dev still points at the LAN API for local Vite/dev tooling.
+ VITE_API_BASE: /__ccapi
+ VITE_API_URL: /__ccapi
+ VITE_APP_NAME: ${VITE_APP_NAME:-Classroom Copilot}
+ VITE_APP_HMR_URL: ${VITE_APP_HMR_URL:-}
+ VITE_DEV: ${VITE_DEV:-false}
+ VITE_FRONTEND_SITE_URL: ${VITE_FRONTEND_SITE_URL:-}
+ VITE_SEARCH_URL: ${VITE_SEARCH_URL:-}
+ VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY:-}
+ VITE_SUPABASE_URL: ${VITE_SUPABASE_URL:-}
+ VITE_SUPER_ADMIN_EMAIL: ${VITE_SUPER_ADMIN_EMAIL:-}
+ VITE_TLSYNC_URL: ${VITE_TLSYNC_URL:-}
+ VITE_WHISPERLIVE_URL: ${VITE_WHISPERLIVE_URL:-}
env_file:
- .env.dev
ports:
diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx
index fabc000..f9139a2 100644
--- a/src/AppRoutes.tsx
+++ b/src/AppRoutes.tsx
@@ -169,6 +169,7 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/src/pages/timetable/ClassDetailPage.tsx b/src/pages/timetable/ClassDetailPage.tsx
index 4d763de..96157cb 100644
--- a/src/pages/timetable/ClassDetailPage.tsx
+++ b/src/pages/timetable/ClassDetailPage.tsx
@@ -132,10 +132,20 @@ const ClassDetailPage: React.FC = () => {
setLoading(true);
setError(null);
try {
- const clsRes = await fetch(`${API_BASE}/classes/${classId}`, {
+ const clsRes = await fetch(`${API_BASE}/database/timetable/classes/${classId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
}).then(r => r.json());
- if (clsRes.id) setCls(clsRes);
+ if (clsRes.id) {
+ setCls({
+ ...clsRes,
+ class_code: clsRes.class_code || clsRes.code,
+ year_group: clsRes.year_group || clsRes.school_year,
+ teachers: clsRes.teachers || [],
+ students: clsRes.students || [],
+ enrollment_requests: clsRes.enrollment_requests || [],
+ student_count: clsRes.student_count ?? clsRes.students?.length ?? 0,
+ });
+ }
else setError(clsRes.detail || 'Class not found');
const role = bootstrapData?.active_institute?.membership_role || '';
setIsAdmin(role === 'school_admin' || role === 'department_head');
@@ -175,20 +185,20 @@ const ClassDetailPage: React.FC = () => {
const handleAddStudent = async (studentId: string) => {
setActionError(null);
- const res = await apiPost(`/classes/${classId}/students`, { student_id: studentId });
+ const res = await apiPost(`/database/timetable/classes/${classId}/students`, { student_id: studentId });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Failed to add student');
};
const handleRemoveStudent = async (studentId: string) => {
setActionError(null);
- await apiDelete(`/classes/${classId}/students/${studentId}`);
+ await apiDelete(`/database/timetable/classes/${classId}/students/${studentId}`);
load();
};
const handleEnrollmentResponse = async (requestId: string, action: 'approve' | 'reject') => {
setActionError(null);
- const res = await apiPatch(`/classes/${classId}/enrollment-requests/${requestId}`, { action });
+ const res = await apiPatch(`/database/timetable/classes/${classId}/enrollment-requests/${requestId}`, { action });
if (res.status === 'ok') load();
else setActionError(res.detail || 'Action failed');
};