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={ +
+ + +
+ } + /> + + +
{`## 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 +
+
+ +
+
+ + + Current Status + Draft Saving... + + } + actions={ + <> + + + + } + /> +
+ ); +} 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 + +

+ } + > +
+ + setEmail(event.target.value)} + placeholder="instructor@university.edu" + required + /> + + + setPassword(event.target.value)} + placeholder="At least 12 characters" + required + /> + + {login.isError ? : null} + + +
+ ); +} 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 + +

+ } + > +
+ + setDisplayName(event.target.value)} + placeholder="Dr. Aris Thorne" + required + /> + + + setEmail(event.target.value)} + placeholder="instructor@university.edu" + required + /> + + + setPassword(event.target.value)} + placeholder="Choose a strong password" + required + /> + + {signup.isError ? : null} + + +
+ ); +} 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 ( + + + + + + } + /> + +
+ + + + +
+ +
+ + } + title="Active Coursework" + actions={ + + } + /> + + + + + + + + + + + + + {assignmentRows.map((row) => ( + + + + + + + + ))} + +
NameTypeStatusDue DateSubmissions
{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={ + <> + + + + } + /> +
+ ); +} 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 + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + {gradebookRows.map((row) => ( + + + {row.scores.map((score, index) => ( + + ))} + + + ))} + +
Student NameHW 1HW 2HW 3HW 4MidtermOverall
+
+
+ {row.initials} +
+
+ {row.student} +

{row.email}

+
+
+
+ {score.value} + {score.status} + + {row.overall} +
+
+
+ + + +
+ {gradeDistribution.map((bar) => ( +
+
+ {bar.label} +
+ ))} +
+ +
+ + + 3 students selected + Batch actions ready +
+ } + actions={ + <> + + + + + } + /> +
+ ); +} 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.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={ -
- - -
AT
-
- } - /> - ); -} - -function DashboardPage() { - return ( - - - - - - } - /> - -
- - - - -
- -
- - } - title="Active Coursework" - actions={ - - } - /> - - - - - - - - - - - - - {assignmentRows.map((row) => ( - - - - - - - - ))} - -
NameTypeStatusDue DateSubmissions
{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={ - <> - - - - } - /> -
- ); -} - -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={ -
- - -
- } - /> - - -
{`## 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 -
-
- -
-
- - - Current Status - Draft Saving... -
- } - actions={ - <> - - - - } - /> - - ); -} - -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 - -
-
-
- - -
-
- - - - - - - - - - - - - - - - {gradebookRows.map((row) => ( - - - {row.scores.map((score, index) => ( - - ))} - - - ))} - -
Student NameHW 1HW 2HW 3HW 4MidtermOverall
-
-
- {row.initials} -
-
- {row.student} -

{row.email}

-
-
-
- {score.value} - {score.status} - - {row.overall} -
-
-
- - - -
- {gradeDistribution.map((bar) => ( -
-
- {bar.label} -
- ))} -
- -
- - - 3 students selected - Batch actions ready -
- } - actions={ - <> - - - - - } - /> -
- ); -} - -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, + }, + }, }, });