Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5561aaf
build: point the everyday commands at the local stack
HarryRandall Sep 22, 2026
b047884
feat: let sign-in work before its JavaScript arrives
HarryRandall Sep 22, 2026
99cd518
fix: keep an admin inside the admin shell when a page fails
HarryRandall Sep 22, 2026
e984468
feat: name the admin sections after what they hold
HarryRandall Sep 22, 2026
aca06df
fix: make a draft mean unpublished edits, not an opened page
HarryRandall Sep 22, 2026
c0afc39
feat: read a catalogue record until you ask to edit it
HarryRandall Sep 22, 2026
6cdafbf
feat: give menus, toasts and the directory a surface that works in dark
HarryRandall Sep 22, 2026
926a925
feat: split sync diagnostics by the question being asked
HarryRandall Sep 22, 2026
e36a950
feat: read a record as what it says, not as an empty form
HarryRandall Sep 22, 2026
8058708
refactor: read both operations details in the same shapes
HarryRandall Sep 22, 2026
99df765
feat: say where a change came from, and make the overview one grid
HarryRandall Sep 22, 2026
d22bcf8
fix: make hover visible on the surfaces menus actually sit on
HarryRandall Sep 22, 2026
03acf6b
fix: record a source page once instead of restamping it
HarryRandall Sep 22, 2026
90a6d85
fix: let a running toast be dismissed and give up on a stalled one
HarryRandall Sep 22, 2026
182259c
feat: let a tab, a breadcrumb and the sidebar name a place the same way
HarryRandall Sep 22, 2026
bc7adc1
feat: open a draft on purpose, and say which state a record is in
HarryRandall Sep 22, 2026
25ab8ba
fix: build hosted previews against production services
HarryRandall Sep 22, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

# Editor and agent session state
# Shared instructions live in the tracked .agents directory.
/.claude/
/.codex/
/.cursor/

Expand Down
34 changes: 22 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,16 @@ pnpm install
cp apps/web/.env.example apps/web/.env.local
pnpm db:start # local Supabase stack
pnpm db:reset # migrations plus demonstration fixtures
pnpm dev:local # http://127.0.0.1:3000
pnpm dev # http://127.0.0.1:3000
# Or build and run the production server against the same local stack:
pnpm build
pnpm start
```

Sign up at `/signup` and the local stack issues a session straight away. To run
against a hosted Supabase project instead, configure its URL, publishable key
and your application origin in `apps/web/.env.local`, then use `pnpm dev`.
and your application origin in `apps/web/.env.local`, then use the explicit
`pnpm dev:prod`, `pnpm build:prod` and `pnpm start:prod` commands.
The [environment template](apps/web/.env.example) explains the required settings,
optional import credentials and map-service defaults.

Expand Down Expand Up @@ -94,16 +98,22 @@ Level Security, and the service-role key never reaches the browser.

## Commands

| Command | What it does |
| ---------------- | ---------------------------------------------- |
| `pnpm dev:local` | Development server against local Supabase |
| `pnpm check` | Formatting, lint and strict types |
| `pnpm test` | Unit and component tests |
| `pnpm test:e2e` | Authenticated browser journeys |
| `pnpm db:reset` | Rebuild the local database and reseed fixtures |
| `pnpm db:test` | pgTAP database tests |
| `pnpm db:types` | Regenerate committed database types |
| `pnpm verify` | Local application delivery checks |
| Command | What it does |
| -------------------- | ------------------------------------------------------ |
| `pnpm dev` | Development server against local Supabase |
| `pnpm dev:prod` | Development server using configured hosted credentials |
| `pnpm build` | Production build against local Supabase |
| `pnpm start` | Built production server against local Supabase |
| `pnpm preview:local` | Build and start against local Supabase in one step |
| `pnpm build:prod` | Production build using configured hosted credentials |
| `pnpm start:prod` | Built server using configured hosted credentials |
| `pnpm check` | Formatting, lint and strict types |
| `pnpm test` | Unit and component tests |
| `pnpm test:e2e` | Authenticated browser journeys |
| `pnpm db:reset` | Rebuild the local database and reseed fixtures |
| `pnpm db:test` | pgTAP database tests |
| `pnpm db:types` | Regenerate committed database types |
| `pnpm verify` | Local application delivery checks |

Run `pnpm verify` before opening a pull request. CI additionally runs database
checks, authenticated browser journeys and a production dependency audit. See
Expand Down
15 changes: 8 additions & 7 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
# NEXT_PUBLIC_* values are browser-visible and baked into production builds.
# Rebuild after changing them. Other values below are server-only.
#
# Local setup: pnpm db:start, pnpm db:reset, then pnpm dev:local.
# dev:local reads the local Supabase URL and keys automatically and supplies the
# site URL. When using pnpm dev directly, fill in the required section yourself.
# Local setup: pnpm db:start, pnpm db:reset, then pnpm dev.
# dev, build, start and preview:local read the local Supabase URL and keys
# automatically and supply the site URL. Only the explicit :prod commands read
# the hosted values below.
# Optional overrides stay commented out until needed.

# =============================================================================
Expand All @@ -21,14 +22,14 @@
# project's Connect dialog. Keep this URL and the public key on the same project.
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321

# Browser-safe publishable key. Required when using pnpm dev directly.
# dev:local supplies this from Supabase; never substitute a server secret here.
# Browser-safe publishable key. Required by the explicit :prod commands.
# Local commands supply this from Supabase; never substitute a server secret.
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=

# Public application origin for authentication redirects and cookie settings.
# Use HTTPS outside localhost/127.0.0.1, with no path, query or fragment.
# Match the allowed authentication URLs in your Supabase configuration.
# dev:local overrides this with http://127.0.0.1:3000.
# Local commands override this with http://127.0.0.1:3000.
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# =============================================================================
Expand All @@ -38,7 +39,7 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Configure them when checking detailed ANU source material.

# Privileged Supabase server key for local preview and browser test user setup.
# dev:local supplies the local server key automatically.
# Local commands supply the local server key automatically.
# Use a key from the same project as NEXT_PUBLIC_SUPABASE_URL. Keep it server-only.
# Used by: scripts/local/dev-preview.mjs and playwright/fixtures.ts.
# SUPABASE_SECRET_KEY=
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/academic/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default async function AcademicPage() {
} catch {
return (
<PlanningCatalogueError
pageTitle="Academic overview"
pageTitle="Academic history"
retryHref="/academic"
/>
);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/app/admin/operations/catalogue/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"use client";

export { CatalogueOperationsError as default } from "@/ui/admin/operations/operations-error";
81 changes: 81 additions & 0 deletions apps/web/app/auth/password/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { type NextRequest, NextResponse } from "next/server";

import { safeInternalRedirect } from "@/lib/auth/redirect";
import {
getSiteOriginForRequest,
getSupabaseConfig,
} from "@/lib/supabase/config";
import { createRequestClient } from "@/lib/supabase/request";

function noStore(response: NextResponse) {
response.headers.set(
"Cache-Control",
"private, no-cache, no-store, must-revalidate, max-age=0",
);
response.headers.set("Expires", "0");
response.headers.set("Pragma", "no-cache");
return response;
}

function loginRedirect(origin: string, next: string) {
const url = new URL("/login", origin);
url.searchParams.set("next", next);
url.searchParams.set("error", "invalid-login");
return noStore(NextResponse.redirect(url, 303));
}

export async function POST(request: NextRequest) {
const siteOrigin = getSiteOriginForRequest(
request.nextUrl,
request.headers.get("x-forwarded-host") ?? request.headers.get("host"),
request.headers.get("x-forwarded-proto"),
);
if (!siteOrigin || !getSupabaseConfig()) {
return new NextResponse("Coursemap authentication is not configured.", {
status: 503,
headers: { "Cache-Control": "private, no-store" },
});
}

if (request.headers.get("origin") !== siteOrigin) {
return new NextResponse("Invalid request origin.", {
status: 403,
headers: { "Cache-Control": "private, no-store" },
});
}

const formData = await request.formData();
const emails = formData.getAll("email");
const passwords = formData.getAll("password");
const nextValues = formData.getAll("next");
const email = emails.length === 1 ? emails[0] : null;
const password = passwords.length === 1 ? passwords[0] : null;
const next = safeInternalRedirect(
nextValues.length === 1 && typeof nextValues[0] === "string"
? nextValues[0]
: null,
);

if (
typeof email !== "string" ||
typeof password !== "string" ||
!email.trim() ||
email.length > 254 ||
password.length < 8 ||
password.length > 128
) {
return loginRedirect(siteOrigin, next);
}

const response = noStore(
NextResponse.redirect(new URL(next, siteOrigin), 303),
);
const { supabase, applyTo } = createRequestClient(request, response);
const { error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});

if (error) return applyTo(loginRedirect(siteOrigin, next));
return response;
}
11 changes: 9 additions & 2 deletions apps/web/app/auth/sign-in/sign-in-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ import { createClient } from "@/lib/supabase/browser";
export function SignInForm({
next,
configured,
initialError = null,
}: {
next: string;
configured: boolean;
initialError?: string | null;
}) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(initialError);

const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
Expand Down Expand Up @@ -56,7 +58,12 @@ export function SignInForm({
};

return (
<form onSubmit={submit} className="space-y-4">
<form
action="/auth/password"
method="post"
onSubmit={submit}
className="space-y-4"
>
<input type="hidden" name="next" value={next} />
<Field>
<label className="flex flex-col gap-2">
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/calendar/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default async function CalendarPage() {
catalogue = await loadCurrentUserPlanCatalogue();
} catch {
return (
<PlanningCatalogueError pageTitle="Plan calendar" retryHref="/calendar" />
<PlanningCatalogueError pageTitle="Calendar" retryHref="/calendar" />
);
}
const keyDates = await loadAllPublishedKeyDates();
Expand Down
44 changes: 28 additions & 16 deletions apps/web/app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import { Button } from "@coursemap/ui/primitives/button";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ErrorPageLayout } from "@/ui/common/error-page-layout";
import { useOnlineStatus } from "@/lib/browser/use-online-status";
import { OfflineError } from "@/ui/errors/offline-error";
import { ErrorState } from "@/ui/common/error-state";
import { AppShell } from "@/ui/shell";

export default function ErrorPage({
error,
Expand All @@ -15,23 +17,33 @@ export default function ErrorPage({
reset: () => void;
}) {
const online = useOnlineStatus();
const pathname = usePathname();
if (!online) return <OfflineError retry={reset} />;

return (
<ErrorPageLayout>
<ErrorState
code={error.digest ? "500 · Server error" : "Page error"}
title="We couldn't load this page"
description="Something went wrong on our side. Try again, or head home and come back later."
reference={error.digest}
>
<Button onClick={reset} type="button">
Try again
</Button>
<Button asChild variant="outline">
<Link href="/dashboard">Back to home</Link>
</Button>
</ErrorState>
</ErrorPageLayout>
const admin = pathname.startsWith("/admin");
const state = (
<ErrorState
code={error.digest ? "500 · Server error" : "Page error"}
title="We couldn't load this page"
description="Something went wrong on our side. Try again, or head home and come back later."
reference={error.digest}
>
<Button onClick={reset} type="button">
Try again
</Button>
<Button asChild variant="outline">
<Link href={admin ? "/admin/dashboard" : "/dashboard"}>
{admin ? "Back to overview" : "Back to home"}
</Link>
</Button>
</ErrorState>
);

return admin ? (
<AppShell admin fill>
{state}
</AppShell>
) : (
<ErrorPageLayout>{state}</ErrorPageLayout>
);
}
5 changes: 4 additions & 1 deletion apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,10 @@
--tooltip-foreground: oklch(1 0 0);
--tooltip-border: oklch(1 0 0 / 18%);
--card-foreground: oklch(0.985 0 0);
--popover: var(--background);
/* Menus and popovers lift off the page rather than sharing its ground. In
dark the border and shadow that carry elevation in light are both nearly
invisible, so the surface itself has to be the lighter one. */
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.606 0.25 292.717);
--primary-foreground: oklch(0.985 0 0);
Expand Down
10 changes: 9 additions & 1 deletion apps/web/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export default async function LoginPage({
}) {
const params = await searchParams;
const next = safeInternalRedirect(first(params.next));
const initialError =
first(params.error) === "invalid-login"
? "Email or password is incorrect."
: null;

const configured = Boolean(getSupabaseConfig());
const signUpHref = `/signup?next=${encodeURIComponent(next)}`;
Expand Down Expand Up @@ -56,7 +60,11 @@ export default async function LoginPage({
<span className="h-px flex-1 bg-zinc-200" />
</div>

<SignInForm next={next} configured={configured} />
<SignInForm
next={next}
configured={configured}
initialError={initialError}
/>

<p className="mt-6 text-center text-sm text-zinc-500">
New to Coursemap?{" "}
Expand Down
7 changes: 1 addition & 6 deletions apps/web/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,7 @@ export default async function ProfilePage() {
try {
catalogue = await loadOnboardingCatalogue();
} catch {
return (
<PlanningCatalogueError
pageTitle="Profile and study details"
retryHref="/profile"
/>
);
return <PlanningCatalogueError pageTitle="Profile" retryHref="/profile" />;
}
return <ProfileEditor catalogue={catalogue} />;
}
36 changes: 33 additions & 3 deletions apps/web/app/vendor.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,22 @@
own, so each rule notes what it is correcting. Imported by globals.css
after the stylesheets they override. */

/* Sonner keeps neutral surfaces with status borders and an inset close button. */
.toaster [data-sonner-toast][data-styled="true"] {
padding-right: 48px;
/* Sonner keeps neutral surfaces with status borders and an inset close button.
The close button sits over the first line only, so the title gives up the
room for it rather than the whole toast; that leaves descriptions and
progress bars the full width. */
.toaster
[data-sonner-toast][data-styled="true"]:has([data-close-button])
[data-title] {
padding-right: 32px;
}

/* An action sits on the same corner as the close button, so it steps aside by
the same amount the title does. */
.toaster
[data-sonner-toast][data-styled="true"]:has([data-close-button])
[data-button] {
margin-right: 24px;
}

.toaster [data-sonner-toast][data-type="success"] {
Expand Down Expand Up @@ -294,3 +307,20 @@
}
}
}

/* Nova's dropdown and select content carry its translucent menu preset, which
already highlights items with a foreground mix. Command does not, so a
command item falls back to --accent - and --accent is mixed against the page
ground, landing on oklch(~0.204) in dark while the popover surface a command
palette opens on is oklch(0.205). The highlight was invisible there. Mix
from the foreground instead: a translucent overlay reads on any ground. */
@layer base {
.style-nova
[data-slot="command-item"]:is(
:hover,
[data-selected="true"],
[aria-selected="true"]
) {
background-color: color-mix(in oklab, var(--foreground) 10%, transparent);
}
}
Loading
Loading