import React, { useCallback, useEffect, useMemo, useState } from "react";
import GuidelinePage from "./pages/GuidelinePage";
import TrainingPage from "./pages/TrainingPage";
import EvaluationPage from "./pages/EvaluationPage";
import RoundCompletePage from "./pages/RoundCompletePage";
import rawPrompts from "./data/prompts.json";
import { normalizePromptList } from "./lib/promptSchema";
import "./styles/guideline.css";
const API_BASE_URL = (
import.meta.env.VITE_API_BASE_URL ||
(import.meta.env.DEV ? "http://localhost:8000" : "")
).replace(/\/$/, "");
const STORAGE_KEYS = {
annotatorName: "t2av_annotator_name",
trainingPassed: "t2av_training_passed"
};
function readSession(key, fallback) {
try {
const value = sessionStorage.getItem(key);
return value === null ? fallback : JSON.parse(value);
} catch {
return fallback;
}
}
function writeSession(key, value) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
// sessionStorage unavailable (e.g. private browsing) - state just won't survive a refresh.
}
}
const normalizedPrompts = normalizePromptList(rawPrompts);
export default function App() {
// Gating note: there is no router and no URL for 'training'/'evaluation' -
// the only way to reach them is through the state transitions below, so
// there is nothing to type into an address bar to skip ahead.
//
// Stage itself is intentionally NOT restored from sessionStorage: every
// fresh load (including a same-tab refresh) should land back on the
// guideline page rather than silently resuming mid-training or
// mid-evaluation. annotatorName and trainingPassed are still remembered,
// so a returning annotator gets recognized and can skip the worked
// example again (see the collision handling in GuidelinePage) - only the
// "which page am I on" state resets.
const [stage, setStage] = useState("guideline");
const [annotatorName, setAnnotatorName] = useState(() => readSession(STORAGE_KEYS.annotatorName, ""));
const [trainingPassed, setTrainingPassed] = useState(() =>
readSession(STORAGE_KEYS.trainingPassed, false)
);
const [videoManifest, setVideoManifest] = useState(null);
const [videoManifestError, setVideoManifestError] = useState(null);
const [assignmentData, setAssignmentData] = useState(null);
const [assignmentError, setAssignmentError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(`${API_BASE_URL}/videos`)
.then((res) => {
if (!res.ok) throw new Error(`Video catalog request failed (status ${res.status})`);
return res.json();
})
.then((data) => {
if (!cancelled) setVideoManifest(data);
})
.catch((err) => {
if (!cancelled) setVideoManifestError(err.message || "Failed to load videos");
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => writeSession(STORAGE_KEYS.annotatorName, annotatorName), [annotatorName]);
useEffect(() => writeSession(STORAGE_KEYS.trainingPassed, trainingPassed), [trainingPassed]);
// Defensive fallback: the UI never lets you reach 'evaluation' without
// trainingPassed, but if sessionStorage were hand-edited, fall back to
// training rather than trusting the stored stage.
useEffect(() => {
if (stage === "evaluation" && !trainingPassed) {
setStage("training");
}
}, [stage, trainingPassed]);
const effectiveStage = stage === "evaluation" && !trainingPassed ? "training" : stage;
// Each annotator gets a balanced 20-video round, not the full catalog.
// The backend decides everything about round state (in progress / just
// completed / time to auto-advance to the next round) - this call is
// always the same "give me my current status" request, made automatically
// on every evaluation-stage visit and again once EvaluationPage reports
// the last video was saved, so the completed/code state is picked up
// without any manual "start new round" action.
const fetchAssignment = useCallback(() => {
if (!annotatorName.trim()) return;
fetch(`${API_BASE_URL}/assignment`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ annotator: annotatorName })
})
.then((res) => {
if (!res.ok) throw new Error(`Assignment request failed (status ${res.status})`);
return res.json();
})
.then((data) => setAssignmentData(data))
.catch((err) => setAssignmentError(err.message || "Failed to load your assignment"));
}, [annotatorName]);
useEffect(() => {
if (effectiveStage !== "evaluation") return;
fetchAssignment();
}, [effectiveStage, fetchAssignment]);
// All resolved (itemId, model) videos, keyed by taskId - the full 200,
// before per-annotator filtering.
const videoEntriesByTaskId = useMemo(() => {
if (!videoManifest) return null;
return new Map(videoManifest.items.map((entry) => [`${entry.itemId}__${entry.model}`, entry]));
}, [videoManifest]);
const itemsById = useMemo(
() => new Map(normalizedPrompts.map((item) => [item.itemId, item])),
[]
);
// The annotator's actual queue: their current round's video_ids (already
// excludes anything they've completed before, in any round), resolved
// against the live video catalog. An assigned id that no longer resolves
// (e.g. a video went missing after assignment) is skipped gracefully,
// matching the existing missing-video handling elsewhere.
const tasks = useMemo(() => {
if (!videoEntriesByTaskId || !assignmentData) return [];
const built = [];
assignmentData.video_ids.forEach((taskId) => {
const entry = videoEntriesByTaskId.get(taskId);
if (!entry) return;
const item = itemsById.get(entry.itemId);
if (!item) return;
built.push({
taskId,
itemId: entry.itemId,
model: entry.model,
videoUrl: entry.videoUrl,
variant: entry.variant,
item
});
});
return built;
}, [videoEntriesByTaskId, itemsById, assignmentData]);
const handleContinueToTraining = () => {
if (!annotatorName.trim()) return;
setStage("training");
};
// Offered on GuidelinePage only once handleGuidelineLookupResult has
// already confirmed (via /assignment/lookup) that this name passed
// training before - lets a returning annotator skip straight back into
// their in-progress round instead of re-doing the worked example.
const handleSkipTraining = () => {
if (!annotatorName.trim() || !trainingPassed) return;
setStage("evaluation");
};
// A round can only ever exist for a name that has already passed training
// (POST /assignment is only ever called after trainingPassed is set, see
// the evaluation-stage effect above) - so GET /assignment/lookup finding
// an existing round is a reliable, read-only signal that this annotator
// already passed training in an earlier session. GuidelinePage already
// makes this exact call for its name-collision warning; this just listens
// in on that same result instead of adding a second request.
const handleGuidelineLookupResult = useCallback((data) => {
if (data?.exists) setTrainingPassed(true);
}, []);
const handlePassTraining = () => {
setTrainingPassed(true);
setStage("evaluation");
};
if (effectiveStage === "training") {
return
Loading videos…
Loading your assignment…