diff --git a/frontend/README.md b/frontend/README.md
index 531e0d4..b43985e 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -16,6 +16,8 @@ This workspace contains the Bun frontend monorepo for Optimark.
- `bun run check`
- `bun run preview`
+The Apollo Vite dev server proxies `/api` requests to `http://127.0.0.1:8000` so the cookie-backed auth flow works locally without adding broad frontend CORS logic.
+
## Workspace Layout
- `apps/apollo`: main React SPA
- `packages/calliope`: shared frontend design-system package for tokens, shell primitives, and reusable surface patterns
@@ -32,4 +34,4 @@ This workspace contains the Bun frontend monorepo for Optimark.
- `apps/museion`: planned
Internal pattern library or Storybook-style workspace for documenting components, flows, and visual decisions.
-The current implementation provides a mockup-aligned design system in `calliope` plus a routed Apollo showcase that exercises dashboard, editor, gradebook, and empty-state patterns for future instructor and student flows.
+The current implementation provides a mockup-aligned design system in `calliope` plus a routed Apollo app that now includes login, signup, session restoration, protected route gating, logout behavior, and showcase surfaces for future instructor and student flows.
diff --git a/frontend/apps/apollo/src/features/assignments/AssignmentBuilderPage.tsx b/frontend/apps/apollo/src/features/assignments/AssignmentBuilderPage.tsx
new file mode 100644
index 0000000..27ef6d7
--- /dev/null
+++ b/frontend/apps/apollo/src/features/assignments/AssignmentBuilderPage.tsx
@@ -0,0 +1,122 @@
+import { FileCode2, FolderOpen, Sparkles, Upload } from "lucide-react";
+import {
+ BottomActionBar,
+ FormFieldScaffold,
+ PageHeader,
+ PageShell,
+ SectionHeading,
+ StatusPill,
+ SurfacePanel,
+} from "@optimark/calliope";
+
+import { starterFiles } from "./mock-data";
+
+export function AssignmentBuilderPage() {
+ return (
+
+
+
+
+
+
}
+ title="Description"
+ actions={
+
+
+ Write
+
+
+ Preview
+
+
+ }
+ />
+
+
+ {`## Instructions
+Implement a singly linked list with the following methods:
+- append(value)
+- prepend(value)
+- delete(value)
+- find(value)
+
+### Constraints
+- Time Complexity: O(n) for searching
+- Space Complexity: O(1) for deletions
+
+Ensure all edge cases are handled (empty list, single node list).`}
+
+
+
} title="Starter Files" />
+
+ {starterFiles.map((file) => (
+
+
+
{file.icon}
+
+
{file.name}
+
{file.meta}
+
+
+
+ ))}
+
+
+ Upload Additional Files
+
+
+
+
+
+
+
+
+ Oct 15, 2026
+
+
+ 100
+
+
+ Python 3.10
+
+
+
+
+
+
+
+
+
+ Visibility
+ Hidden
+
+
+ Category
+ Coding
+
+
+
+
+ Verify Environment
+
+
+
+
+
+ Current Status
+ Draft Saving...
+
+ }
+ actions={
+ <>
+ Save Draft
+ Publish
+ >
+ }
+ />
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/assignments/AssignmentsPage.tsx b/frontend/apps/apollo/src/features/assignments/AssignmentsPage.tsx
new file mode 100644
index 0000000..b2c0595
--- /dev/null
+++ b/frontend/apps/apollo/src/features/assignments/AssignmentsPage.tsx
@@ -0,0 +1,25 @@
+import { Link } from "@tanstack/react-router";
+import { FolderOpen } from "lucide-react";
+import { EmptyState, PageHeader, PageShell } from "@optimark/calliope";
+
+export function AssignmentsPage() {
+ return (
+
+
+ Open Editor
+
+ }
+ />
+ }
+ title="Reusable assignment patterns are ready"
+ description="Issue #8 can plug actual assignment data and actions into this shared shell without restyling the workspace."
+ />
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/assignments/mock-data.tsx b/frontend/apps/apollo/src/features/assignments/mock-data.tsx
new file mode 100644
index 0000000..539e41b
--- /dev/null
+++ b/frontend/apps/apollo/src/features/assignments/mock-data.tsx
@@ -0,0 +1,14 @@
+import { FileCode2, SquareTerminal } from "lucide-react";
+
+export const starterFiles = [
+ {
+ name: "linked_list.py",
+ meta: "Python Source • 2.4 KB",
+ icon: ,
+ },
+ {
+ name: "test_suite.py",
+ meta: "Python Test • 5.1 KB",
+ icon: ,
+ },
+] as const;
diff --git a/frontend/apps/apollo/src/features/auth/api.ts b/frontend/apps/apollo/src/features/auth/api.ts
new file mode 100644
index 0000000..fc225b6
--- /dev/null
+++ b/frontend/apps/apollo/src/features/auth/api.ts
@@ -0,0 +1,36 @@
+import { ApiError, requestJson } from "../../lib/api/client";
+import type { SessionResponse } from "./session";
+
+export type AuthPayload = {
+ email: string;
+ password: string;
+};
+
+export type SignupPayload = AuthPayload & {
+ display_name: string;
+};
+
+export async function loginRequest(payload: AuthPayload): Promise {
+ return requestJson("/api/v1/auth/login", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function signupRequest(payload: SignupPayload): Promise {
+ return requestJson("/api/v1/auth/signup", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function logoutRequest(): Promise {
+ const response = await fetch("/api/v1/auth/logout", {
+ method: "POST",
+ credentials: "include",
+ });
+
+ if (!response.ok) {
+ throw new ApiError(response.status, "Unable to end the current session.");
+ }
+}
diff --git a/frontend/apps/apollo/src/features/auth/components/AuthCard.tsx b/frontend/apps/apollo/src/features/auth/components/AuthCard.tsx
new file mode 100644
index 0000000..44ee6e1
--- /dev/null
+++ b/frontend/apps/apollo/src/features/auth/components/AuthCard.tsx
@@ -0,0 +1,29 @@
+import type { ReactNode } from "react";
+
+import { SurfacePanel } from "@optimark/calliope";
+
+export function AuthCard({
+ eyebrow,
+ title,
+ subtitle,
+ children,
+ footer,
+}: {
+ eyebrow: string;
+ title: string;
+ subtitle: string;
+ children: ReactNode;
+ footer?: ReactNode;
+}) {
+ return (
+
+
+
{eyebrow}
+
{title}
+
{subtitle}
+
+ {children}
+ {footer}
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/auth/components/AuthErrorBanner.tsx b/frontend/apps/apollo/src/features/auth/components/AuthErrorBanner.tsx
new file mode 100644
index 0000000..2bb6695
--- /dev/null
+++ b/frontend/apps/apollo/src/features/auth/components/AuthErrorBanner.tsx
@@ -0,0 +1,17 @@
+import { CircleAlert } from "lucide-react";
+
+import { ApiError } from "../../../lib/api/client";
+
+export function AuthErrorBanner({ error }: { error: unknown }) {
+ const detail =
+ error instanceof ApiError
+ ? error.message
+ : "Something went wrong. Please try again.";
+
+ return (
+
+
+ {detail}
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/auth/routes/LoginPage.tsx b/frontend/apps/apollo/src/features/auth/routes/LoginPage.tsx
new file mode 100644
index 0000000..db43c38
--- /dev/null
+++ b/frontend/apps/apollo/src/features/auth/routes/LoginPage.tsx
@@ -0,0 +1,86 @@
+import { useState } from "react";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
+import { LoaderCircle } from "lucide-react";
+import { FormFieldScaffold } from "@optimark/calliope";
+
+import { loginRequest } from "../api";
+import {
+ sessionQueryKey,
+ type AuthSearch,
+} from "../session";
+import { AuthCard } from "../components/AuthCard";
+import { AuthErrorBanner } from "../components/AuthErrorBanner";
+
+export function LoginPage() {
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const search = useRouterState({
+ select: (state) => (state.location.search as AuthSearch) ?? {},
+ });
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const login = useMutation({
+ mutationFn: loginRequest,
+ onSuccess: async (session) => {
+ queryClient.setQueryData(sessionQueryKey, session);
+ await navigate({ to: search.redirect || "/dashboard" });
+ },
+ });
+
+ async function onSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ login.mutate({ email, password });
+ }
+
+ return (
+
+ Need an account?{" "}
+
+ Create one
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/auth/routes/SignupPage.tsx b/frontend/apps/apollo/src/features/auth/routes/SignupPage.tsx
new file mode 100644
index 0000000..6c5282a
--- /dev/null
+++ b/frontend/apps/apollo/src/features/auth/routes/SignupPage.tsx
@@ -0,0 +1,98 @@
+import { useState } from "react";
+
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
+import { LoaderCircle } from "lucide-react";
+import { FormFieldScaffold } from "@optimark/calliope";
+
+import { signupRequest } from "../api";
+import {
+ sessionQueryKey,
+ type AuthSearch,
+} from "../session";
+import { AuthCard } from "../components/AuthCard";
+import { AuthErrorBanner } from "../components/AuthErrorBanner";
+
+export function SignupPage() {
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const search = useRouterState({
+ select: (state) => (state.location.search as AuthSearch) ?? {},
+ });
+ const [displayName, setDisplayName] = useState("");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const signup = useMutation({
+ mutationFn: signupRequest,
+ onSuccess: async (session) => {
+ queryClient.setQueryData(sessionQueryKey, session);
+ await navigate({ to: search.redirect || "/dashboard" });
+ },
+ });
+
+ async function onSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ signup.mutate({ display_name: displayName, email, password });
+ }
+
+ return (
+
+ Already have access?{" "}
+
+ Sign in
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/auth/session.ts b/frontend/apps/apollo/src/features/auth/session.ts
new file mode 100644
index 0000000..b8045b7
--- /dev/null
+++ b/frontend/apps/apollo/src/features/auth/session.ts
@@ -0,0 +1,122 @@
+import type { QueryClient } from "@tanstack/react-query";
+import { redirect } from "@tanstack/react-router";
+
+import { ApiError, requestJson } from "../../lib/api/client";
+
+export type AuthSearch = {
+ redirect?: string;
+};
+
+export type SessionUser = {
+ id: string;
+ email: string;
+ display_name: string;
+};
+
+export type SessionResponse = {
+ user: SessionUser;
+ provider: string;
+ expires_at: string;
+};
+
+export const sessionQueryKey = ["auth", "session"] as const;
+
+export async function fetchSession(): Promise {
+ try {
+ return await requestJson("/api/v1/auth/session", {
+ method: "GET",
+ });
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 401) {
+ return null;
+ }
+
+ throw error;
+ }
+}
+
+export function sessionQueryOptions() {
+ return {
+ queryKey: sessionQueryKey,
+ queryFn: fetchSession,
+ staleTime: 60_000,
+ retry: false,
+ };
+}
+
+export function sanitizeRedirectPath(value: unknown): string | undefined {
+ if (typeof value !== "string") {
+ return undefined;
+ }
+
+ const trimmed = value.trim();
+
+ if (!trimmed) {
+ return undefined;
+ }
+
+ let decodedValue = trimmed;
+
+ try {
+ decodedValue = decodeURIComponent(trimmed);
+ } catch {
+ return undefined;
+ }
+
+ if (/[\u0000-\u001F\u007F]/.test(decodedValue)) {
+ return undefined;
+ }
+
+ if (!decodedValue.startsWith("/") || decodedValue.startsWith("//")) {
+ return undefined;
+ }
+
+ const firstNestedSlash = decodedValue.indexOf("/", 1);
+ const leadingSegment =
+ firstNestedSlash === -1 ? decodedValue.slice(1) : decodedValue.slice(1, firstNestedSlash);
+
+ if (leadingSegment.includes(":") || decodedValue.includes("://")) {
+ return undefined;
+ }
+
+ return decodedValue;
+}
+
+export function deriveInitials(displayName: string): string {
+ return displayName
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ .map((part) => part[0] ?? "")
+ .join("")
+ .slice(0, 2)
+ .toUpperCase();
+}
+
+export async function ensureSession(queryClient: QueryClient): Promise {
+ return queryClient.ensureQueryData(sessionQueryOptions());
+}
+
+export async function requireAuthenticated(queryClient: QueryClient, redirectPath: string) {
+ const session = await ensureSession(queryClient);
+
+ if (!session) {
+ throw redirect({
+ to: "/login",
+ search: { redirect: redirectPath },
+ });
+ }
+
+ return session;
+}
+
+export async function redirectAuthenticated(
+ queryClient: QueryClient,
+ redirectPath?: string,
+) {
+ const session = await ensureSession(queryClient);
+
+ if (session) {
+ throw redirect({ to: redirectPath || "/dashboard" });
+ }
+}
diff --git a/frontend/apps/apollo/src/features/dashboard/DashboardPage.tsx b/frontend/apps/apollo/src/features/dashboard/DashboardPage.tsx
new file mode 100644
index 0000000..561f7c9
--- /dev/null
+++ b/frontend/apps/apollo/src/features/dashboard/DashboardPage.tsx
@@ -0,0 +1,116 @@
+import { BookOpen, ChevronDown, LayoutDashboard, Search, Sparkles, Upload } from "lucide-react";
+import {
+ BottomActionBar,
+ MetricCard,
+ PageHeader,
+ PageShell,
+ SectionHeading,
+ StatusPill,
+ SurfacePanel,
+ brand,
+} from "@optimark/calliope";
+
+import { activityFeed, assignmentRows } from "./mock-data";
+
+export function DashboardPage() {
+ return (
+
+
+
+
+ Open Gradebook
+
+
+
+ Publish All
+
+ >
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ }
+ title="Active Coursework"
+ actions={
+
+ Filter
+
+
+ }
+ />
+
+
+
+
+ Name
+ Type
+ Status
+ Due Date
+ Submissions
+
+
+
+ {assignmentRows.map((row) => (
+
+ {row.name}
+ {row.type}
+
+ {row.status.label}
+
+ {row.due}
+ {row.submissions}
+
+ ))}
+
+
+
+
+
+ } title="Operational Feed" />
+
+ {activityFeed.map((item) => (
+
+ {item.icon}
+
+
{item.title}
+
{item.when}
+
{item.detail}
+
+
+ ))}
+
+
+
+
+
+ Quick Actions
+
+
+
+ >
+ }
+ actions={
+ <>
+ Regrade
+ Download CSV
+ >
+ }
+ />
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/dashboard/mock-data.tsx b/frontend/apps/apollo/src/features/dashboard/mock-data.tsx
new file mode 100644
index 0000000..f1ff942
--- /dev/null
+++ b/frontend/apps/apollo/src/features/dashboard/mock-data.tsx
@@ -0,0 +1,63 @@
+import { CircleAlert, FileCode2, SquareTerminal } from "lucide-react";
+
+export const assignmentRows = [
+ {
+ name: "Homework 4: Linked Lists",
+ type: "Practical",
+ status: { label: "Published", tone: "primary" as const },
+ due: "Oct 12, 23:59",
+ submissions: "42 / 45",
+ },
+ {
+ name: "Assignment 3: Binary Trees",
+ type: "Programming",
+ status: { label: "Reviewing", tone: "secondary" as const },
+ due: "Oct 05, 12:00",
+ submissions: "45 / 45",
+ },
+ {
+ name: "Midterm Quiz: Core Concepts",
+ type: "Exam",
+ status: { label: "Draft", tone: "default" as const },
+ due: "Oct 24, 09:00",
+ submissions: "0 / 45",
+ },
+ {
+ name: "Homework 5: Graph Theory",
+ type: "Practical",
+ status: { label: "Draft", tone: "default" as const },
+ due: "Nov 02, 23:59",
+ submissions: "0 / 45",
+ },
+] as const;
+
+export const activityFeed = [
+ {
+ title: "Student A submitted Homework 4",
+ when: "12 minutes ago",
+ detail: "New programming artifact is ready for queueing.",
+ tone: "primary" as const,
+ icon: ,
+ },
+ {
+ title: "Autograde completed for Assignment 3",
+ when: "45 minutes ago",
+ detail: "82% average • 4 error flags surfaced for review.",
+ tone: "secondary" as const,
+ icon: ,
+ },
+ {
+ title: "Manual review started on Midterm Essays",
+ when: "2 hours ago",
+ detail: "TA review batch is currently in progress.",
+ tone: "default" as const,
+ icon: ,
+ },
+ {
+ title: "System: Plagiarism detected in Homework 4",
+ when: "3 hours ago",
+ detail: "One critical alert needs instructor verification.",
+ tone: "danger" as const,
+ icon: ,
+ },
+] as const;
diff --git a/frontend/apps/apollo/src/features/gradebook/GradebookPage.tsx b/frontend/apps/apollo/src/features/gradebook/GradebookPage.tsx
new file mode 100644
index 0000000..912765d
--- /dev/null
+++ b/frontend/apps/apollo/src/features/gradebook/GradebookPage.tsx
@@ -0,0 +1,139 @@
+import { ChevronDown, Download, Filter, Settings } from "lucide-react";
+import {
+ BottomActionBar,
+ FormFieldScaffold,
+ PageShell,
+ SectionHeading,
+ StatusPill,
+ SurfacePanel,
+ brand,
+} from "@optimark/calliope";
+
+import { gradebookRows, gradeDistribution } from "./mock-data";
+
+export function GradebookPage() {
+ return (
+
+
+
+
+ Academic Overview
+ {brand.courseLabel} • {brand.courseTerm}
+
+
+ Average Grade
+ 84.2
+
+
+ Completion Rate
+ 92%
+
+
+ Next Deadline
+ Oct 12
+
+
+
+
+
+
+
+ All Students
+
+
+
+
+
+ All Statuses
+
+
+
+
+
+
+ Export CSV
+
+
+
+ View Settings
+
+
+
+
+
+
+
+
+ Student Name
+ HW 1
+ HW 2
+ HW 3
+ HW 4
+ Midterm
+ Overall
+
+
+
+ {gradebookRows.map((row) => (
+
+
+
+
+ {row.initials}
+
+
+
{row.student}
+
{row.email}
+
+
+
+ {row.scores.map((score, index) => (
+
+ {score.value}
+ {score.status}
+
+ ))}
+
+ {row.overall}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ {gradeDistribution.map((bar) => (
+
+ ))}
+
+
+
+
+
+ 3 students selected
+ Batch actions ready
+
+ }
+ actions={
+ <>
+ Message
+ Batch Edit
+ Release Selected
+ >
+ }
+ />
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/gradebook/mock-data.ts b/frontend/apps/apollo/src/features/gradebook/mock-data.ts
new file mode 100644
index 0000000..f19cb7a
--- /dev/null
+++ b/frontend/apps/apollo/src/features/gradebook/mock-data.ts
@@ -0,0 +1,70 @@
+export const gradebookRows = [
+ {
+ initials: "AA",
+ student: "Alex Abramov",
+ email: "a.abramov@university.edu",
+ accent: "secondary" as const,
+ scores: [
+ { value: "95/100", status: "Released", tone: "primary" as const },
+ { value: "88/100", status: "Released", tone: "primary" as const },
+ { value: "--/100", status: "Pending", tone: "secondary" as const },
+ { value: "--/100", status: "Missing", tone: "default" as const },
+ { value: "142/150", status: "Released", tone: "primary" as const },
+ ],
+ overall: "91.4%",
+ overallTone: "primary" as const,
+ },
+ {
+ initials: "EL",
+ student: "Elena Laveau",
+ email: "e.laveau@university.edu",
+ accent: "secondary" as const,
+ scores: [
+ { value: "100/100", status: "Released", tone: "primary" as const },
+ { value: "94/100", status: "Released", tone: "primary" as const },
+ { value: "98/100", status: "Released", tone: "primary" as const },
+ { value: "92/100", status: "Released", tone: "primary" as const },
+ { value: "148/150", status: "Released", tone: "primary" as const },
+ ],
+ overall: "97.8%",
+ overallTone: "primary" as const,
+ },
+ {
+ initials: "JM",
+ student: "Jordan Miller",
+ email: "j.miller@university.edu",
+ accent: "danger" as const,
+ scores: [
+ { value: "64/100", status: "Released", tone: "primary" as const },
+ { value: "72/100", status: "Released", tone: "primary" as const },
+ { value: "--/100", status: "Pending", tone: "secondary" as const },
+ { value: "--/100", status: "Pending", tone: "secondary" as const },
+ { value: "110/150", status: "Released", tone: "primary" as const },
+ ],
+ overall: "68.2%",
+ overallTone: "danger" as const,
+ },
+ {
+ initials: "SW",
+ student: "Sarah Wu",
+ email: "s.wu@university.edu",
+ accent: "secondary" as const,
+ scores: [
+ { value: "92/100", status: "Released", tone: "primary" as const },
+ { value: "89/100", status: "Released", tone: "primary" as const },
+ { value: "91/100", status: "Released", tone: "primary" as const },
+ { value: "88/100", status: "Released", tone: "primary" as const },
+ { value: "138/150", status: "Released", tone: "primary" as const },
+ ],
+ overall: "90.5%",
+ overallTone: "primary" as const,
+ },
+] as const;
+
+export const gradeDistribution = [
+ { label: "F", height: "18%", accent: false },
+ { label: "D", height: "42%", accent: false },
+ { label: "C", height: "78%", accent: true },
+ { label: "B", height: "60%", accent: false },
+ { label: "A", height: "14%", accent: false },
+] as const;
diff --git a/frontend/apps/apollo/src/features/settings/SettingsPage.tsx b/frontend/apps/apollo/src/features/settings/SettingsPage.tsx
new file mode 100644
index 0000000..284b275
--- /dev/null
+++ b/frontend/apps/apollo/src/features/settings/SettingsPage.tsx
@@ -0,0 +1,39 @@
+import { Gauge, Sparkles } from "lucide-react";
+import {
+ FormFieldScaffold,
+ PageHeader,
+ PageShell,
+ SectionHeading,
+ SurfacePanel,
+} from "@optimark/calliope";
+
+export function SettingsPage() {
+ return (
+
+
+
+
+ } />
+
+ Manual review gate
+
+
+ Hidden until publish
+
+
+
+ } />
+
+ 2 retries
+
+
+ Full term archive
+
+
+
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/shell/AppSidebar.tsx b/frontend/apps/apollo/src/features/shell/AppSidebar.tsx
new file mode 100644
index 0000000..9b3111b
--- /dev/null
+++ b/frontend/apps/apollo/src/features/shell/AppSidebar.tsx
@@ -0,0 +1,106 @@
+import { useMemo } from "react";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Link, useNavigate } from "@tanstack/react-router";
+import { Code2, LayoutDashboard, BookOpen, ListChecks, ChartColumn, Users, Settings, LoaderCircle, LogOut } from "lucide-react";
+import {
+ BrandLockup,
+ SidebarNavItem,
+ SidebarShell,
+ brand,
+ sidebarUtilityLinks,
+} from "@optimark/calliope";
+
+import { logoutRequest } from "../auth/api";
+import { deriveInitials, sessionQueryKey, sessionQueryOptions } from "../auth/session";
+
+const navItems = [
+ { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
+ { to: "/assignments", label: "Assignments", icon: BookOpen },
+ { to: "/submissions", label: "Submissions", icon: ListChecks },
+ { to: "/gradebook", label: "Gradebook", icon: ChartColumn },
+ { to: "/students", label: "Students", icon: Users },
+ { to: "/settings", label: "Settings", icon: Settings },
+] as const;
+
+function SidebarProfile({
+ displayName,
+ email,
+}: {
+ displayName: string;
+ email: string;
+}) {
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const logout = useMutation({
+ mutationFn: logoutRequest,
+ onSuccess: async () => {
+ queryClient.setQueryData(sessionQueryKey, null);
+ await navigate({ to: "/login" });
+ },
+ });
+ const initials = useMemo(() => deriveInitials(displayName), [displayName]);
+
+ return (
+
+
+
{initials}
+
+
{displayName}
+
{email}
+
+
+
logout.mutate()}
+ disabled={logout.isPending}
+ >
+ {logout.isPending ? : }
+ Log Out
+
+ {logout.isError ? (
+
We could not end the current session. Try again.
+ ) : null}
+
+ );
+}
+
+export function AppSidebar() {
+ const { data: session } = useQuery(sessionQueryOptions());
+
+ return (
+ }
+ />
+ }
+ navigation={navItems.map(({ to, label, icon: Icon }) => (
+
+ {({ isActive }) => (
+ } label={label} />
+ )}
+
+ ))}
+ primaryAction={
+
+ New Assessment
+
+ }
+ utilityLinks={sidebarUtilityLinks.map((item) => (
+
+ {item.label}
+
+ ))}
+ profile={
+
+ }
+ />
+ );
+}
diff --git a/frontend/apps/apollo/src/features/shell/AppTopbar.tsx b/frontend/apps/apollo/src/features/shell/AppTopbar.tsx
new file mode 100644
index 0000000..1102436
--- /dev/null
+++ b/frontend/apps/apollo/src/features/shell/AppTopbar.tsx
@@ -0,0 +1,50 @@
+import { useMemo } from "react";
+
+import { useQuery } from "@tanstack/react-query";
+import { useRouterState } from "@tanstack/react-router";
+import { Bell, History, Search } from "lucide-react";
+import { Topbar, TopbarTab, brand, topTabs } from "@optimark/calliope";
+
+import { deriveInitials, sessionQueryOptions } from "../auth/session";
+
+export function AppTopbar() {
+ const pathname = useRouterState({
+ select: (state) => state.location.pathname,
+ });
+ const { data: session } = useQuery(sessionQueryOptions());
+ const activeTopTab = pathname === "/gradebook" ? "analytics" : "course-settings";
+ const initials = useMemo(
+ () => deriveInitials(session?.user.display_name ?? brand.instructorName),
+ [session?.user.display_name],
+ );
+
+ return (
+ (
+
+ {tab.label}
+
+ ))}
+ search={
+
+
+
+
+ }
+ tools={
+
+
+
+
+
+
+
+
+
{session?.user.display_name ?? brand.instructorName}
+
{initials}
+
+
+ }
+ />
+ );
+}
diff --git a/frontend/apps/apollo/src/features/shell/AuthLayout.tsx b/frontend/apps/apollo/src/features/shell/AuthLayout.tsx
new file mode 100644
index 0000000..84a411f
--- /dev/null
+++ b/frontend/apps/apollo/src/features/shell/AuthLayout.tsx
@@ -0,0 +1,41 @@
+import { Outlet } from "@tanstack/react-router";
+import { Code2 } from "lucide-react";
+import { BrandLockup, SurfacePanel, brand } from "@optimark/calliope";
+
+export function AuthLayout() {
+ return (
+
+
+
+
+ }
+ />
+
+
Hosted Access
+
The academic workspace with operational calm.
+
+ Sign in to restore your course session, review assessment activity,
+ and continue in the same curated shell used across the app.
+
+
+
+
+ Session restoration
+ Cookie-backed app access restores your workspace on reload.
+
+
+ Protected surfaces
+ Instructor and student routes stay gated until a valid session exists.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/shell/ProtectedAppLayout.tsx b/frontend/apps/apollo/src/features/shell/ProtectedAppLayout.tsx
new file mode 100644
index 0000000..3360e3b
--- /dev/null
+++ b/frontend/apps/apollo/src/features/shell/ProtectedAppLayout.tsx
@@ -0,0 +1,13 @@
+import { Outlet } from "@tanstack/react-router";
+import { AppFrame } from "@optimark/calliope";
+
+import { AppSidebar } from "./AppSidebar";
+import { AppTopbar } from "./AppTopbar";
+
+export function ProtectedAppLayout() {
+ return (
+ } topbar={ }>
+
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/students/StudentsPage.tsx b/frontend/apps/apollo/src/features/students/StudentsPage.tsx
new file mode 100644
index 0000000..196a080
--- /dev/null
+++ b/frontend/apps/apollo/src/features/students/StudentsPage.tsx
@@ -0,0 +1,18 @@
+import { GraduationCap } from "lucide-react";
+import { EmptyState, PageHeader, PageShell } from "@optimark/calliope";
+
+export function StudentsPage() {
+ return (
+
+
+ }
+ title="Roster primitives are now part of the system"
+ description="Issue-specific student views can reuse the gradebook table rhythm, metadata labels, and sidebar shell without forking styles."
+ />
+
+ );
+}
diff --git a/frontend/apps/apollo/src/features/submissions/SubmissionsPage.tsx b/frontend/apps/apollo/src/features/submissions/SubmissionsPage.tsx
new file mode 100644
index 0000000..307bb53
--- /dev/null
+++ b/frontend/apps/apollo/src/features/submissions/SubmissionsPage.tsx
@@ -0,0 +1,18 @@
+import { ListChecks } from "lucide-react";
+import { EmptyState, PageHeader, PageShell } from "@optimark/calliope";
+
+export function SubmissionsPage() {
+ return (
+
+
+ }
+ title="Submission workflows will plug in here"
+ description="The design system now supports high-density queue views and calm operational empty states."
+ />
+
+ );
+}
diff --git a/frontend/apps/apollo/src/lib/api/client.ts b/frontend/apps/apollo/src/lib/api/client.ts
new file mode 100644
index 0000000..b92d942
--- /dev/null
+++ b/frontend/apps/apollo/src/lib/api/client.ts
@@ -0,0 +1,35 @@
+export class ApiError extends Error {
+ status: number;
+
+ constructor(status: number, message: string) {
+ super(message);
+ this.name = "ApiError";
+ this.status = status;
+ }
+}
+
+export async function requestJson(path: string, init?: RequestInit): Promise {
+ const response = await fetch(path, {
+ ...init,
+ credentials: "include",
+ headers: {
+ "Content-Type": "application/json",
+ ...(init?.headers ?? {}),
+ },
+ });
+
+ if (!response.ok) {
+ let detail = "Request failed.";
+
+ try {
+ const body = (await response.json()) as { detail?: string };
+ detail = body.detail ?? detail;
+ } catch {
+ detail = response.statusText || detail;
+ }
+
+ throw new ApiError(response.status, detail);
+ }
+
+ return (await response.json()) as T;
+}
diff --git a/frontend/apps/apollo/src/main.tsx b/frontend/apps/apollo/src/main.tsx
index cb7c61c..2f8fa87 100644
--- a/frontend/apps/apollo/src/main.tsx
+++ b/frontend/apps/apollo/src/main.tsx
@@ -1,824 +1,11 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import {
- QueryClient,
- QueryClientProvider,
-} from "@tanstack/react-query";
-import {
- RouterProvider,
- createRootRouteWithContext,
- createRoute,
- createRouter,
- redirect,
- Outlet,
- Link,
- useRouterState,
-} from "@tanstack/react-router";
-import {
- Bell,
- BookOpen,
- ChartColumn,
- ChevronDown,
- CircleAlert,
- Code2,
- Download,
- FileCode2,
- Filter,
- FolderOpen,
- Gauge,
- GraduationCap,
- History,
- LayoutDashboard,
- ListChecks,
- Search,
- Settings,
- Sparkles,
- SquareTerminal,
- Upload,
- Users,
-} from "lucide-react";
-import {
- AppFrame,
- BottomActionBar,
- BrandLockup,
- EmptyState,
- FormFieldScaffold,
- MetricCard,
- PageHeader,
- PageShell,
- SectionHeading,
- SidebarNavItem,
- SidebarShell,
- StatusPill,
- SurfacePanel,
- Topbar,
- TopbarTab,
- brand,
- sidebarUtilityLinks,
- topTabs,
-} from "@optimark/calliope";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { RouterProvider } from "@tanstack/react-router";
+
import "@optimark/calliope/system.css";
import "./styles.css";
-
-type AppContext = {
- queryClient: QueryClient;
-};
-
-const queryClient = new QueryClient();
-
-const navItems = [
- { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
- { to: "/assignments", label: "Assignments", icon: BookOpen },
- { to: "/submissions", label: "Submissions", icon: ListChecks },
- { to: "/gradebook", label: "Gradebook", icon: ChartColumn },
- { to: "/students", label: "Students", icon: Users },
- { to: "/settings", label: "Settings", icon: Settings },
-] as const;
-
-const assignmentRows = [
- {
- name: "Homework 4: Linked Lists",
- type: "Practical",
- status: { label: "Published", tone: "primary" as const },
- due: "Oct 12, 23:59",
- submissions: "42 / 45",
- },
- {
- name: "Assignment 3: Binary Trees",
- type: "Programming",
- status: { label: "Reviewing", tone: "secondary" as const },
- due: "Oct 05, 12:00",
- submissions: "45 / 45",
- },
- {
- name: "Midterm Quiz: Core Concepts",
- type: "Exam",
- status: { label: "Draft", tone: "default" as const },
- due: "Oct 24, 09:00",
- submissions: "0 / 45",
- },
- {
- name: "Homework 5: Graph Theory",
- type: "Practical",
- status: { label: "Draft", tone: "default" as const },
- due: "Nov 02, 23:59",
- submissions: "0 / 45",
- },
-] as const;
-
-const activityFeed = [
- {
- title: "Student A submitted Homework 4",
- when: "12 minutes ago",
- detail: "New programming artifact is ready for queueing.",
- tone: "primary" as const,
- },
- {
- title: "Autograde completed for Assignment 3",
- when: "45 minutes ago",
- detail: "82% average • 4 error flags surfaced for review.",
- tone: "secondary" as const,
- },
- {
- title: "Manual review started on Midterm Essays",
- when: "2 hours ago",
- detail: "TA review batch is currently in progress.",
- tone: "default" as const,
- },
- {
- title: "System: Plagiarism detected in Homework 4",
- when: "3 hours ago",
- detail: "One critical alert needs instructor verification.",
- tone: "danger" as const,
- },
-] as const;
-
-const starterFiles = [
- {
- name: "linked_list.py",
- meta: "Python Source • 2.4 KB",
- icon: ,
- },
- {
- name: "test_suite.py",
- meta: "Python Test • 5.1 KB",
- icon: ,
- },
-] as const;
-
-const gradebookRows = [
- {
- initials: "AA",
- student: "Alex Abramov",
- email: "a.abramov@university.edu",
- accent: "secondary" as const,
- scores: [
- { value: "95/100", status: "Released", tone: "primary" as const },
- { value: "88/100", status: "Released", tone: "primary" as const },
- { value: "--/100", status: "Pending", tone: "secondary" as const },
- { value: "--/100", status: "Missing", tone: "default" as const },
- { value: "142/150", status: "Released", tone: "primary" as const },
- ],
- overall: "91.4%",
- overallTone: "primary" as const,
- },
- {
- initials: "EL",
- student: "Elena Laveau",
- email: "e.laveau@university.edu",
- accent: "secondary" as const,
- scores: [
- { value: "100/100", status: "Released", tone: "primary" as const },
- { value: "94/100", status: "Released", tone: "primary" as const },
- { value: "98/100", status: "Released", tone: "primary" as const },
- { value: "92/100", status: "Released", tone: "primary" as const },
- { value: "148/150", status: "Released", tone: "primary" as const },
- ],
- overall: "97.8%",
- overallTone: "primary" as const,
- },
- {
- initials: "JM",
- student: "Jordan Miller",
- email: "j.miller@university.edu",
- accent: "danger" as const,
- scores: [
- { value: "64/100", status: "Released", tone: "primary" as const },
- { value: "72/100", status: "Released", tone: "primary" as const },
- { value: "--/100", status: "Pending", tone: "secondary" as const },
- { value: "--/100", status: "Pending", tone: "secondary" as const },
- { value: "110/150", status: "Released", tone: "primary" as const },
- ],
- overall: "68.2%",
- overallTone: "danger" as const,
- },
- {
- initials: "SW",
- student: "Sarah Wu",
- email: "s.wu@university.edu",
- accent: "secondary" as const,
- scores: [
- { value: "92/100", status: "Released", tone: "primary" as const },
- { value: "89/100", status: "Released", tone: "primary" as const },
- { value: "91/100", status: "Released", tone: "primary" as const },
- { value: "88/100", status: "Released", tone: "primary" as const },
- { value: "138/150", status: "Released", tone: "primary" as const },
- ],
- overall: "90.5%",
- overallTone: "primary" as const,
- },
-] as const;
-
-const gradeDistribution = [
- { label: "F", height: "18%", accent: false },
- { label: "D", height: "42%", accent: false },
- { label: "C", height: "78%", accent: true },
- { label: "B", height: "60%", accent: false },
- { label: "A", height: "14%", accent: false },
-] as const;
-
-const rootRoute = createRootRouteWithContext()({
- component: RootLayout,
-});
-
-const indexRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: "/",
- beforeLoad: () => {
- throw redirect({ to: "/dashboard" });
- },
-});
-
-const dashboardRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: "/dashboard",
- component: DashboardPage,
-});
-
-const assignmentsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: "/assignments",
- component: AssignmentsPage,
-});
-
-const assignmentEditorRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: "/assignments/new",
- component: AssignmentBuilderPage,
-});
-
-const submissionsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: "/submissions",
- component: SubmissionsPage,
-});
-
-const gradebookRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: "/gradebook",
- component: GradebookPage,
-});
-
-const studentsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: "/students",
- component: StudentsPage,
-});
-
-const settingsRoute = createRoute({
- getParentRoute: () => rootRoute,
- path: "/settings",
- component: SettingsPage,
-});
-
-const routeTree = rootRoute.addChildren([
- indexRoute,
- dashboardRoute,
- assignmentsRoute,
- assignmentEditorRoute,
- submissionsRoute,
- gradebookRoute,
- studentsRoute,
- settingsRoute,
-]);
-
-const router = createRouter({
- routeTree,
- context: {
- queryClient,
- },
-});
-
-declare module "@tanstack/react-router" {
- interface Register {
- router: typeof router;
- }
-}
-
-function RootLayout() {
- return (
- }
- topbar={ }
- >
-
-
- );
-}
-
-function AppSidebar() {
- return (
- }
- />
- }
- navigation={navItems.map(({ to, label, icon: Icon }) => (
-
- {({ isActive }) => (
- }
- label={label}
- />
- )}
-
- ))}
- primaryAction={
-
- New Assessment
-
- }
- utilityLinks={sidebarUtilityLinks.map((item) => (
-
- {item.label}
-
- ))}
- profile={
-
-
AT
-
-
{brand.instructorName}
-
{brand.instructorRole}
-
-
- }
- />
- );
-}
-
-function AppTopbar() {
- const pathname = useRouterState({
- select: (state) => state.location.pathname,
- });
- const activeTopTab = pathname === "/gradebook" ? "analytics" : "course-settings";
-
- return (
- (
-
- {tab.label}
-
- ))}
- search={
-
-
-
-
- }
- tools={
-
- }
- />
- );
-}
-
-function DashboardPage() {
- return (
-
-
-
-
- Open Gradebook
-
-
-
- Publish All
-
- >
- }
- />
-
-
-
-
-
-
-
-
-
-
- }
- title="Active Coursework"
- actions={
-
- Filter
-
-
- }
- />
-
-
-
-
- Name
- Type
- Status
- Due Date
- Submissions
-
-
-
- {assignmentRows.map((row) => (
-
- {row.name}
- {row.type}
-
- {row.status.label}
-
- {row.due}
- {row.submissions}
-
- ))}
-
-
-
-
-
- }
- title="Operational Feed"
- />
-
- {activityFeed.map((item) => (
-
-
- {item.tone === "danger" ? : }
-
-
-
{item.title}
-
{item.when}
-
{item.detail}
-
-
- ))}
-
-
-
-
-
- Quick Actions
-
-
-
- >
- }
- actions={
- <>
- Regrade
- Download CSV
- >
- }
- />
-
- );
-}
-
-function AssignmentsPage() {
- return (
-
-
- Open Editor
-
- }
- />
- }
- title="Reusable assignment patterns are ready"
- description="Issue #8 can plug actual assignment data and actions into this shared shell without restyling the workspace."
- />
-
- );
-}
-
-function AssignmentBuilderPage() {
- return (
-
-
-
-
-
-
}
- title="Description"
- actions={
-
-
- Write
-
-
- Preview
-
-
- }
- />
-
-
- {`## Instructions
-Implement a singly linked list with the following methods:
-- append(value)
-- prepend(value)
-- delete(value)
-- find(value)
-
-### Constraints
-- Time Complexity: O(n) for searching
-- Space Complexity: O(1) for deletions
-
-Ensure all edge cases are handled (empty list, single node list).`}
-
-
-
}
- title="Starter Files"
- />
-
- {starterFiles.map((file) => (
-
-
-
{file.icon}
-
-
{file.name}
-
{file.meta}
-
-
-
- ))}
-
-
- Upload Additional Files
-
-
-
-
-
-
-
-
- Oct 15, 2026
-
-
- 100
-
-
- Python 3.10
-
-
-
-
-
-
-
-
-
- Visibility
- Hidden
-
-
- Category
- Coding
-
-
-
-
- Verify Environment
-
-
-
-
-
- Current Status
- Draft Saving...
-
- }
- actions={
- <>
- Save Draft
- Publish
- >
- }
- />
-
- );
-}
-
-function SubmissionsPage() {
- return (
-
-
- }
- title="Submission workflows will plug in here"
- description="The design system now supports high-density queue views and calm operational empty states."
- />
-
- );
-}
-
-function GradebookPage() {
- return (
-
-
-
-
- Academic Overview
- {brand.courseLabel} • {brand.courseTerm}
-
-
- Average Grade
- 84.2
-
-
- Completion Rate
- 92%
-
-
- Next Deadline
- Oct 12
-
-
-
-
-
-
-
- All Students
-
-
-
-
-
- All Statuses
-
-
-
-
-
-
- Export CSV
-
-
-
- View Settings
-
-
-
-
-
-
-
-
- Student Name
- HW 1
- HW 2
- HW 3
- HW 4
- Midterm
- Overall
-
-
-
- {gradebookRows.map((row) => (
-
-
-
-
- {row.initials}
-
-
-
{row.student}
-
{row.email}
-
-
-
- {row.scores.map((score, index) => (
-
- {score.value}
- {score.status}
-
- ))}
-
- {row.overall}
-
-
- ))}
-
-
-
-
-
-
-
-
- {gradeDistribution.map((bar) => (
-
- ))}
-
-
-
-
-
- 3 students selected
- Batch actions ready
-
- }
- actions={
- <>
- Message
- Batch Edit
- Release Selected
- >
- }
- />
-
- );
-}
-
-function StudentsPage() {
- return (
-
-
- }
- title="Roster primitives are now part of the system"
- description="Issue-specific student views can reuse the gradebook table rhythm, metadata labels, and sidebar shell without forking styles."
- />
-
- );
-}
-
-function SettingsPage() {
- return (
-
-
-
-
- } />
-
- Manual review gate
-
-
- Hidden until publish
-
-
-
- } />
-
- 2 retries
-
-
- Full term archive
-
-
-
-
- );
-}
+import { queryClient, router } from "./routes/router";
const root = ReactDOM.createRoot(document.getElementById("root")!);
diff --git a/frontend/apps/apollo/src/routes/assignment-editor.tsx b/frontend/apps/apollo/src/routes/assignment-editor.tsx
new file mode 100644
index 0000000..30aca4a
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/assignment-editor.tsx
@@ -0,0 +1,10 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { AssignmentBuilderPage } from "../features/assignments/AssignmentBuilderPage";
+import { protectedLayoutRoute } from "./protected";
+
+export const assignmentEditorRoute = createRoute({
+ getParentRoute: () => protectedLayoutRoute,
+ path: "/assignments/new",
+ component: AssignmentBuilderPage,
+});
diff --git a/frontend/apps/apollo/src/routes/assignments.tsx b/frontend/apps/apollo/src/routes/assignments.tsx
new file mode 100644
index 0000000..9c1014f
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/assignments.tsx
@@ -0,0 +1,10 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { AssignmentsPage } from "../features/assignments/AssignmentsPage";
+import { protectedLayoutRoute } from "./protected";
+
+export const assignmentsRoute = createRoute({
+ getParentRoute: () => protectedLayoutRoute,
+ path: "/assignments",
+ component: AssignmentsPage,
+});
diff --git a/frontend/apps/apollo/src/routes/auth.tsx b/frontend/apps/apollo/src/routes/auth.tsx
new file mode 100644
index 0000000..31d68d0
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/auth.tsx
@@ -0,0 +1,41 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { LoginPage } from "../features/auth/routes/LoginPage";
+import { SignupPage } from "../features/auth/routes/SignupPage";
+import {
+ redirectAuthenticated,
+ sanitizeRedirectPath,
+ type AuthSearch,
+} from "../features/auth/session";
+import { AuthLayout } from "../features/shell/AuthLayout";
+import { rootRoute } from "./root";
+
+export const authLayoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: "auth-layout",
+ component: AuthLayout,
+});
+
+export const loginRoute = createRoute({
+ getParentRoute: () => authLayoutRoute,
+ path: "/login",
+ validateSearch: (search: Record): AuthSearch => ({
+ redirect: sanitizeRedirectPath(search.redirect),
+ }),
+ beforeLoad: async ({ context, search }) => {
+ await redirectAuthenticated(context.queryClient, sanitizeRedirectPath(search.redirect));
+ },
+ component: LoginPage,
+});
+
+export const signupRoute = createRoute({
+ getParentRoute: () => authLayoutRoute,
+ path: "/signup",
+ validateSearch: (search: Record): AuthSearch => ({
+ redirect: sanitizeRedirectPath(search.redirect),
+ }),
+ beforeLoad: async ({ context, search }) => {
+ await redirectAuthenticated(context.queryClient, sanitizeRedirectPath(search.redirect));
+ },
+ component: SignupPage,
+});
diff --git a/frontend/apps/apollo/src/routes/dashboard.tsx b/frontend/apps/apollo/src/routes/dashboard.tsx
new file mode 100644
index 0000000..c9e8bab
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/dashboard.tsx
@@ -0,0 +1,10 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { DashboardPage } from "../features/dashboard/DashboardPage";
+import { protectedLayoutRoute } from "./protected";
+
+export const dashboardRoute = createRoute({
+ getParentRoute: () => protectedLayoutRoute,
+ path: "/dashboard",
+ component: DashboardPage,
+});
diff --git a/frontend/apps/apollo/src/routes/gradebook.tsx b/frontend/apps/apollo/src/routes/gradebook.tsx
new file mode 100644
index 0000000..b8cf307
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/gradebook.tsx
@@ -0,0 +1,10 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { GradebookPage } from "../features/gradebook/GradebookPage";
+import { protectedLayoutRoute } from "./protected";
+
+export const gradebookRoute = createRoute({
+ getParentRoute: () => protectedLayoutRoute,
+ path: "/gradebook",
+ component: GradebookPage,
+});
diff --git a/frontend/apps/apollo/src/routes/protected.tsx b/frontend/apps/apollo/src/routes/protected.tsx
new file mode 100644
index 0000000..3fa9fe9
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/protected.tsx
@@ -0,0 +1,14 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { requireAuthenticated } from "../features/auth/session";
+import { ProtectedAppLayout } from "../features/shell/ProtectedAppLayout";
+import { rootRoute } from "./root";
+
+export const protectedLayoutRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ id: "protected-layout",
+ beforeLoad: async ({ context, location }) => {
+ await requireAuthenticated(context.queryClient, location.pathname);
+ },
+ component: ProtectedAppLayout,
+});
diff --git a/frontend/apps/apollo/src/routes/root.tsx b/frontend/apps/apollo/src/routes/root.tsx
new file mode 100644
index 0000000..b434c13
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/root.tsx
@@ -0,0 +1,14 @@
+import type { QueryClient } from "@tanstack/react-query";
+import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
+
+export type AppContext = {
+ queryClient: QueryClient;
+};
+
+function RootRouteComponent() {
+ return ;
+}
+
+export const rootRoute = createRootRouteWithContext()({
+ component: RootRouteComponent,
+});
diff --git a/frontend/apps/apollo/src/routes/router.tsx b/frontend/apps/apollo/src/routes/router.tsx
new file mode 100644
index 0000000..d00e74b
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/router.tsx
@@ -0,0 +1,53 @@
+import { QueryClient } from "@tanstack/react-query";
+import { createRoute, createRouter, redirect } from "@tanstack/react-router";
+
+import { ensureSession } from "../features/auth/session";
+import { assignmentEditorRoute } from "./assignment-editor";
+import { assignmentsRoute } from "./assignments";
+import { authLayoutRoute, loginRoute, signupRoute } from "./auth";
+import { dashboardRoute } from "./dashboard";
+import { gradebookRoute } from "./gradebook";
+import { protectedLayoutRoute } from "./protected";
+import { rootRoute, type AppContext } from "./root";
+import { settingsRoute } from "./settings";
+import { studentsRoute } from "./students";
+import { submissionsRoute } from "./submissions";
+
+export const queryClient = new QueryClient();
+
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/",
+ beforeLoad: async ({ context }) => {
+ const session = await ensureSession(context.queryClient);
+ throw redirect({ to: session ? "/dashboard" : "/login" });
+ },
+});
+
+const routeTree = rootRoute.addChildren([
+ indexRoute,
+ authLayoutRoute.addChildren([loginRoute, signupRoute]),
+ protectedLayoutRoute.addChildren([
+ dashboardRoute,
+ assignmentsRoute,
+ assignmentEditorRoute,
+ submissionsRoute,
+ gradebookRoute,
+ studentsRoute,
+ settingsRoute,
+ ]),
+]);
+
+export const router = createRouter({
+ routeTree,
+ context: {
+ queryClient,
+ } satisfies AppContext,
+ defaultPreload: "intent",
+});
+
+declare module "@tanstack/react-router" {
+ interface Register {
+ router: typeof router;
+ }
+}
diff --git a/frontend/apps/apollo/src/routes/settings.tsx b/frontend/apps/apollo/src/routes/settings.tsx
new file mode 100644
index 0000000..b52eaeb
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/settings.tsx
@@ -0,0 +1,10 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { SettingsPage } from "../features/settings/SettingsPage";
+import { protectedLayoutRoute } from "./protected";
+
+export const settingsRoute = createRoute({
+ getParentRoute: () => protectedLayoutRoute,
+ path: "/settings",
+ component: SettingsPage,
+});
diff --git a/frontend/apps/apollo/src/routes/students.tsx b/frontend/apps/apollo/src/routes/students.tsx
new file mode 100644
index 0000000..fcceeb9
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/students.tsx
@@ -0,0 +1,10 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { StudentsPage } from "../features/students/StudentsPage";
+import { protectedLayoutRoute } from "./protected";
+
+export const studentsRoute = createRoute({
+ getParentRoute: () => protectedLayoutRoute,
+ path: "/students",
+ component: StudentsPage,
+});
diff --git a/frontend/apps/apollo/src/routes/submissions.tsx b/frontend/apps/apollo/src/routes/submissions.tsx
new file mode 100644
index 0000000..937623d
--- /dev/null
+++ b/frontend/apps/apollo/src/routes/submissions.tsx
@@ -0,0 +1,10 @@
+import { createRoute } from "@tanstack/react-router";
+
+import { SubmissionsPage } from "../features/submissions/SubmissionsPage";
+import { protectedLayoutRoute } from "./protected";
+
+export const submissionsRoute = createRoute({
+ getParentRoute: () => protectedLayoutRoute,
+ path: "/submissions",
+ component: SubmissionsPage,
+});
diff --git a/frontend/apps/apollo/src/styles.css b/frontend/apps/apollo/src/styles.css
index 025fee7..2766c7b 100644
--- a/frontend/apps/apollo/src/styles.css
+++ b/frontend/apps/apollo/src/styles.css
@@ -14,6 +14,10 @@
transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
+.app-spin {
+ animation: app-spin 900ms linear infinite;
+}
+
.app-primary-action {
padding: 0.95rem 1.2rem;
color: #eefaff;
@@ -66,6 +70,12 @@
border-top: 1px solid rgba(171, 180, 179, 0.18);
}
+.app-profile-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 0.8rem;
+}
+
.app-profile-avatar,
.app-topbar-avatar {
display: grid;
@@ -117,6 +127,16 @@
gap: 0.55rem;
}
+.app-topbar-session {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ padding-left: 0.45rem;
+ color: var(--calliope-text-muted);
+ font-size: 0.8rem;
+ font-weight: 700;
+}
+
.app-icon-button {
width: 2.4rem;
height: 2.4rem;
@@ -134,6 +154,171 @@
font-weight: 800;
}
+.app-auth-shell {
+ min-height: 100vh;
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+}
+
+.app-auth-background {
+ position: absolute;
+ inset: 0;
+ background:
+ radial-gradient(circle at top left, rgba(182, 235, 254, 0.3), transparent 28rem),
+ radial-gradient(circle at bottom right, rgba(48, 101, 118, 0.12), transparent 24rem),
+ linear-gradient(180deg, rgba(234, 239, 238, 0.9), rgba(249, 249, 248, 0.96));
+}
+
+.app-auth-grid {
+ position: relative;
+ z-index: 1;
+ width: min(78rem, 100%);
+ display: grid;
+ grid-template-columns: minmax(20rem, 1.05fr) minmax(22rem, 0.95fr);
+ gap: 2rem;
+ align-items: stretch;
+}
+
+.app-auth-story,
+.app-auth-card {
+ padding: 2rem;
+ border-radius: 1.6rem;
+}
+
+.app-auth-story {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 2rem;
+ background: rgba(241, 244, 243, 0.75);
+ box-shadow: inset 0 0 0 1px rgba(171, 180, 179, 0.14);
+}
+
+.app-auth-copy h2,
+.app-auth-card h1 {
+ margin: 0.6rem 0 0;
+ font: 800 clamp(2.5rem, 5vw, 4.2rem)/0.96 Manrope, sans-serif;
+ letter-spacing: -0.06em;
+}
+
+.app-auth-copy p,
+.app-auth-card p,
+.app-auth-note p,
+.app-auth-footer,
+.app-inline-error {
+ color: var(--calliope-text-muted);
+}
+
+.app-auth-copy p,
+.app-auth-card p {
+ max-width: 34rem;
+ margin: 1rem 0 0;
+ font-size: 1rem;
+ line-height: 1.75;
+}
+
+.app-auth-highlights {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+}
+
+.app-auth-note {
+ padding: 1.25rem;
+}
+
+.app-auth-note strong {
+ display: block;
+ font-size: 0.95rem;
+}
+
+.app-auth-note p {
+ margin: 0.45rem 0 0;
+ font-size: 0.9rem;
+ line-height: 1.6;
+}
+
+.app-auth-panel {
+ display: flex;
+ align-items: center;
+}
+
+.app-auth-card {
+ width: 100%;
+ background: rgba(255, 255, 255, 0.86);
+ backdrop-filter: blur(12px);
+ box-shadow: var(--calliope-shadow);
+ border: 1px solid rgba(171, 180, 179, 0.14);
+}
+
+.app-auth-card-header {
+ margin-bottom: 1.5rem;
+}
+
+.app-auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.app-auth-input {
+ width: 100%;
+ min-height: 3.25rem;
+ padding: 0.95rem 1rem;
+ border-radius: 0.9rem;
+ border: 0;
+ outline: 0;
+ background: rgba(241, 244, 243, 0.86);
+ box-shadow: inset 0 0 0 1px rgba(171, 180, 179, 0.16);
+ color: var(--calliope-text);
+}
+
+.app-auth-input:focus {
+ background: rgba(255, 255, 255, 0.95);
+ box-shadow: inset 0 0 0 1px var(--calliope-primary);
+}
+
+.app-auth-submit {
+ width: 100%;
+ margin-top: 0.5rem;
+}
+
+.app-auth-error {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ padding: 0.95rem 1rem;
+ border-radius: 0.95rem;
+ background: rgba(254, 137, 131, 0.14);
+ color: var(--calliope-error);
+ font-size: 0.9rem;
+ font-weight: 700;
+}
+
+.app-auth-footer {
+ margin: 1.3rem 0 0;
+ font-size: 0.88rem;
+}
+
+.app-auth-footer a,
+.app-inline-error {
+ color: var(--calliope-primary-dim);
+}
+
+.app-sidebar-logout {
+ width: calc(100% - 2rem);
+ margin: 0 1rem;
+}
+
+.app-inline-error {
+ margin: 0 1rem;
+ font-size: 0.82rem;
+}
+
.app-metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -575,6 +760,11 @@
.app-filter-actions {
justify-content: flex-start;
}
+
+ .app-auth-grid,
+ .app-auth-highlights {
+ grid-template-columns: 1fr;
+ }
}
@media (max-width: 780px) {
@@ -587,4 +777,27 @@
.app-search {
width: 100%;
}
+
+ .app-auth-shell {
+ padding: 1rem;
+ }
+
+ .app-auth-story,
+ .app-auth-card {
+ padding: 1.4rem;
+ }
+
+ .app-topbar-session span {
+ display: none;
+ }
+}
+
+@keyframes app-spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
}
diff --git a/frontend/apps/apollo/vite.config.ts b/frontend/apps/apollo/vite.config.ts
index 23e64d2..58549d7 100644
--- a/frontend/apps/apollo/vite.config.ts
+++ b/frontend/apps/apollo/vite.config.ts
@@ -6,5 +6,11 @@ export default defineConfig({
server: {
host: "127.0.0.1",
port: 3000,
+ proxy: {
+ "/api": {
+ target: "http://127.0.0.1:8000",
+ changeOrigin: true,
+ },
+ },
},
});