Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Original file line number Diff line number Diff line change
@@ -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 (
<PageShell>
<PageHeader eyebrow="Assignment Editor" title="Homework 4: Linked Lists" />

<div className="app-editor-grid">
<div className="app-editor-main">
<SectionHeading
icon={<FileCode2 size={16} />}
title="Description"
actions={
<div className="app-inline-group">
<button className="app-inline-action app-inline-action-active" type="button">
Write
</button>
<button className="app-inline-action" type="button">
Preview
</button>
</div>
}
/>

<SurfacePanel muted className="app-editor-block">
<pre>{`## 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).`}</pre>
</SurfacePanel>

<SectionHeading icon={<FolderOpen size={16} />} title="Starter Files" />
<div className="app-file-stack">
{starterFiles.map((file) => (
<SurfacePanel key={file.name} muted className="app-file-row">
<div className="app-file-main">
<div className="app-file-icon">{file.icon}</div>
<div>
<strong>{file.name}</strong>
<p>{file.meta}</p>
</div>
</div>
</SurfacePanel>
))}
<div className="app-upload-zone">
<Upload size={18} />
Upload Additional Files
</div>
</div>
</div>

<SurfacePanel muted className="app-editor-inspector">
<SectionHeading title="Metadata" />
<div className="app-inspector-grid">
<FormFieldScaffold label="Due Date">
<div className="app-field-value">Oct 15, 2026</div>
</FormFieldScaffold>
<FormFieldScaffold label="Points">
<div className="app-field-value">100</div>
</FormFieldScaffold>
<FormFieldScaffold label="Language">
<div className="app-field-value">Python 3.10</div>
</FormFieldScaffold>
<FormFieldScaffold label="Submission Limit" support="3 attempts">
<div className="app-slider-track">
<span />
</div>
</FormFieldScaffold>
</div>
<div className="app-inspector-meta">
<div className="app-meta-row">
<span>Visibility</span>
<StatusPill>Hidden</StatusPill>
</div>
<div className="app-meta-row">
<span>Category</span>
<StatusPill tone="primary">Coding</StatusPill>
</div>
</div>
<button className="app-verify-card" type="button">
<Sparkles size={22} />
Verify Environment
</button>
</SurfacePanel>
</div>

<BottomActionBar
leading={
<div className="app-status-cluster">
<span className="app-smallcaps">Current Status</span>
<strong>Draft Saving...</strong>
</div>
}
actions={
<>
<button className="app-inline-action" type="button">Save Draft</button>
<button className="app-primary-action" type="button">Publish</button>
</>
}
/>
</PageShell>
);
}
25 changes: 25 additions & 0 deletions frontend/apps/apollo/src/features/assignments/AssignmentsPage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PageShell>
<PageHeader
eyebrow="Foundation Surface"
title="Assignment workflows"
subtitle="The design system now supports calm inspector layouts, file rails, and editorial form treatment for future instructor flows."
actions={
<Link to="/assignments/new" className="app-primary-action">
Open Editor
</Link>
}
/>
<EmptyState
icon={<FolderOpen size={18} />}
title="Reusable assignment patterns are ready"
description="Issue #8 can plug actual assignment data and actions into this shared shell without restyling the workspace."
/>
</PageShell>
);
}
14 changes: 14 additions & 0 deletions frontend/apps/apollo/src/features/assignments/mock-data.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { FileCode2, SquareTerminal } from "lucide-react";

export const starterFiles = [
{
name: "linked_list.py",
meta: "Python Source • 2.4 KB",
icon: <FileCode2 size={18} />,
},
{
name: "test_suite.py",
meta: "Python Test • 5.1 KB",
icon: <SquareTerminal size={18} />,
},
] as const;
36 changes: 36 additions & 0 deletions frontend/apps/apollo/src/features/auth/api.ts
Original file line number Diff line number Diff line change
@@ -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<SessionResponse> {
return requestJson<SessionResponse>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(payload),
});
}

export async function signupRequest(payload: SignupPayload): Promise<SessionResponse> {
return requestJson<SessionResponse>("/api/v1/auth/signup", {
method: "POST",
body: JSON.stringify(payload),
});
}

export async function logoutRequest(): Promise<void> {
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.");
}
}
29 changes: 29 additions & 0 deletions frontend/apps/apollo/src/features/auth/components/AuthCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SurfacePanel className="app-auth-card">
<div className="app-auth-card-header">
<span className="app-smallcaps">{eyebrow}</span>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
{children}
{footer}
</SurfacePanel>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<div className="app-auth-error" role="alert">
<CircleAlert size={16} />
<span>{detail}</span>
</div>
);
}
86 changes: 86 additions & 0 deletions frontend/apps/apollo/src/features/auth/routes/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) {
event.preventDefault();
login.mutate({ email, password });
}

return (
<AuthCard
eyebrow="Sign In"
title="Resume your hosted workspace."
subtitle="Use the account created through the backend auth foundation to restore your session."
footer={
<p className="app-auth-footer">
Need an account?{" "}
<Link to="/signup" search={{ redirect: search.redirect }}>
Create one
</Link>
</p>
}
>
<form className="app-auth-form" onSubmit={onSubmit}>
<FormFieldScaffold label="Email Address">
<input
className="app-auth-input"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="instructor@university.edu"
required
/>
</FormFieldScaffold>
<FormFieldScaffold label="Password">
<input
className="app-auth-input"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="At least 12 characters"
required
/>
</FormFieldScaffold>
{login.isError ? <AuthErrorBanner error={login.error} /> : null}
<button
className="app-primary-action app-auth-submit"
type="submit"
disabled={login.isPending}
>
{login.isPending ? <LoaderCircle size={16} className="app-spin" /> : null}
Sign In
</button>
</form>
</AuthCard>
);
}
Loading
Loading