Compare commits
No commits in common. "7326b9f3be51ccf14baa5c1485375ec9798529a9" and "7a01b3e8f67fbad4018f4047522fc4765648218a" have entirely different histories.
7326b9f3be
...
7a01b3e8f6
50
Dockerfile
50
Dockerfile
@ -7,37 +7,13 @@ RUN if [ ! -f package-lock.json ]; then npm install --package-lock-only; fi && n
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Vite bakes VITE_* values at build time. Pass the public VITE_* values as
|
# Vite bakes VITE_* values at build time, so compose must choose the env file
|
||||||
# build args (docker compose --env-file .env.dev) instead of COPYing an env file;
|
# during image build, not only at container runtime.
|
||||||
# service-host worktrees keep .env.dev as a symlink outside the Docker context.
|
ARG ENV_FILE=.env
|
||||||
ARG VITE_API_BASE
|
COPY ${ENV_FILE} .env
|
||||||
ARG VITE_API_URL
|
|
||||||
ARG VITE_APP_NAME
|
# Run build with production mode
|
||||||
ARG VITE_APP_HMR_URL
|
RUN npm run build -- --mode production
|
||||||
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
|
FROM nginx:alpine
|
||||||
# Copy built files
|
# Copy built files
|
||||||
@ -55,18 +31,6 @@ RUN echo 'server { \
|
|||||||
expires -1; \
|
expires -1; \
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate"; \
|
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 /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 / { \
|
location / { \
|
||||||
try_files $uri $uri/ /index.html; \
|
try_files $uri $uri/ /index.html; \
|
||||||
expires 30d; \
|
expires 30d; \
|
||||||
|
|||||||
@ -16,18 +16,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_BASE: ${VITE_API_BASE:-}
|
ENV_FILE: .env.dev
|
||||||
VITE_API_URL: ${VITE_API_URL:-}
|
|
||||||
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_file:
|
||||||
- .env.dev
|
- .env.dev
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
|
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
||||||
import type { TLHandle } from '@tldraw/tldraw'
|
|
||||||
import { PAGE_WIDTH } from '../../../utils/exam-canvas/model'
|
|
||||||
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||||
|
|
||||||
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
@ -67,28 +65,10 @@ const shapeCss = `
|
|||||||
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||||
`
|
`
|
||||||
|
|
||||||
function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
|
||||||
const p = canvasShapePalette.boundary
|
|
||||||
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
|
||||||
return (
|
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
|
||||||
<style>{shapeCss}</style>
|
|
||||||
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
|
||||||
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={p.dash} strokeLinecap="round" style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
|
||||||
</svg>
|
|
||||||
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
|
||||||
<span aria-hidden="true">{p.icon}</span>
|
|
||||||
{shape.props.label || p.label}
|
|
||||||
</span>
|
|
||||||
</HTMLContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderShape(shape: ExamCanvasTLShape) {
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
const kind = shape.props.kind
|
const kind = shape.props.kind
|
||||||
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||||
const isBoundary = kind === 'boundary'
|
const isBoundary = kind === 'boundary'
|
||||||
if (isBoundary) return renderBoundaryLine(shape)
|
|
||||||
return (
|
return (
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||||
<style>{shapeCss}</style>
|
<style>{shapeCss}</style>
|
||||||
@ -141,55 +121,7 @@ class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
|||||||
}
|
}
|
||||||
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||||
}
|
}
|
||||||
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
|
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||||
static override type = SHAPE_TYPES.boundary
|
|
||||||
static override props = sharedProps
|
|
||||||
|
|
||||||
override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) }
|
|
||||||
override canEdit() { return false }
|
|
||||||
override canResize() { return false }
|
|
||||||
override canBind() { return false }
|
|
||||||
override hideResizeHandles() { return true }
|
|
||||||
override hideRotateHandle() { return true }
|
|
||||||
override hideSelectionBoundsBg() { return true }
|
|
||||||
|
|
||||||
private pageSpanForY(y: number) {
|
|
||||||
const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE)
|
|
||||||
const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h)
|
|
||||||
const nearest = hit ?? pages.reduce<ExamPdfPageTLShape | null>((best, page) => {
|
|
||||||
if (!best) return page
|
|
||||||
const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h)))
|
|
||||||
const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h)))
|
|
||||||
return pageDy < bestDy ? page : best
|
|
||||||
}, null)
|
|
||||||
return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH }
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape {
|
|
||||||
const span = this.pageSpanForY(shape.y + shape.props.h / 2)
|
|
||||||
return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } }
|
|
||||||
}
|
|
||||||
|
|
||||||
override getGeometry(shape: ExamCanvasTLShape) {
|
|
||||||
const y = shape.props.h / 2
|
|
||||||
return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) })
|
|
||||||
}
|
|
||||||
|
|
||||||
override getHandles(shape: ExamCanvasTLShape): TLHandle[] {
|
|
||||||
return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }]
|
|
||||||
}
|
|
||||||
|
|
||||||
override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) }
|
|
||||||
override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) }
|
|
||||||
override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any {
|
|
||||||
return this.normalize({ ...current, x: initial.x })
|
|
||||||
}
|
|
||||||
override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any {
|
|
||||||
return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 })
|
|
||||||
}
|
|
||||||
override component(shape: ExamCanvasTLShape) { return renderShape(shape) }
|
|
||||||
override indicator(shape: ExamCanvasTLShape) { return <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
|
|
||||||
}
|
|
||||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||||
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
|
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,6 @@ describe('exam setup canvas serialization', () => {
|
|||||||
], pages)
|
], pages)
|
||||||
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
||||||
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
||||||
expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
|
|
||||||
expect(payload.response_areas[0].page).toBe(2)
|
expect(payload.response_areas[0].page).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -61,7 +60,6 @@ describe('exam setup canvas serialization', () => {
|
|||||||
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
||||||
})
|
})
|
||||||
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||||
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
|
|
||||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -65,19 +65,10 @@ export function newDomainId(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds & Record<string, number> {
|
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
|
||||||
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageGeometry(pageNumber: number, pages?: CanvasPageGeometry[]): CanvasPageGeometry {
|
|
||||||
return pages?.find((page) => page.pageNumber === pageNumber) ?? { pageNumber, x: 0, y: pageTop(pageNumber, pages), w: PAGE_WIDTH, h: PAGE_HEIGHT }
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): CanvasBounds & Record<string, number> {
|
|
||||||
const page = pageGeometry(pageForShape(shape, pages), pages)
|
|
||||||
return { x: page.x, y: shape.y, w: page.w, h: 8 }
|
|
||||||
}
|
|
||||||
|
|
||||||
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
||||||
const ox2 = outer.x + outer.w
|
const ox2 = outer.x + outer.w
|
||||||
const oy2 = outer.y + outer.h
|
const oy2 = outer.y + outer.h
|
||||||
@ -114,7 +105,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
||||||
bands.push({ questionId, top, bottom })
|
bands.push({ questionId, top, bottom })
|
||||||
for (const b of [top, bottom]) {
|
for (const b of [top, bottom]) {
|
||||||
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: 'manual', confirmed: true })
|
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,10 +134,8 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag
|
|||||||
const shapes: ExamCanvasShapeModel[] = []
|
const shapes: ExamCanvasShapeModel[] = []
|
||||||
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||||
for (const b of detail.boundaries ?? []) {
|
for (const b of detail.boundaries ?? []) {
|
||||||
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
|
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
|
||||||
// Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids,
|
shapes.push({ id: b.id, kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
|
||||||
// but render and save a full rendered-page-width horizontal rule.
|
|
||||||
shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id })
|
|
||||||
}
|
}
|
||||||
for (const q of detail.questions ?? []) {
|
for (const q of detail.questions ?? []) {
|
||||||
if (q.is_container || !q.bounds) continue
|
if (q.is_container || !q.bounds) continue
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user