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'); };