feat: localStorage-backed student CRUD with zod schemas
Split the app into the three pages the navbar promises, each with one job: - / (home) -> read-only roster, no mutation controls - /add -> create only, redirects to / on success - /edit -> update + delete only (picker, plus /edit?id=<uuid> deep links) Data layer (src/lib/students.ts) is the single source of truth: - zod studentDraftSchema validates form input (trims name, lowercases email, coerces yearLevel to an int 1-5, restricts course to a known list) - studentSchema = draft + id + createdAt, so types come from z.infer - versioned key it3206.students.v1, validated on read *and* write; corrupt or missing data degrades to an empty list instead of throwing - typed CRUD helpers; useSyncExternalStore hook with a stable snapshot cache, a window event and the storage event so every island and other tabs re-render Footer "clear local storage" is now wired to clearStudents() (confirm dialog, label feedback) instead of being inert, and the layout switched to min-h-svh + an in-flow footer so it no longer overlaps long lists. Footer padding clears the Astro dev toolbar, which was covering the link in dev. Also adds zod, plus @astrojs/check + typescript@^6 as dev deps so astro check runs (TS 7 does not expose the API astro check needs yet).
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
createStudent,
|
||||
emptyFormValues,
|
||||
studentDraftSchema,
|
||||
toFieldErrors,
|
||||
type StudentField,
|
||||
type StudentFormErrors,
|
||||
type StudentFormValues,
|
||||
} from "../lib/students";
|
||||
import StudentFields, { buttonGhostClass, buttonPrimaryClass } from "./StudentFields";
|
||||
|
||||
/** CREATE-only island for /add. On success it hands the user back to the list. */
|
||||
export default function CreateStudentForm() {
|
||||
const [values, setValues] = useState<StudentFormValues>(emptyFormValues);
|
||||
const [errors, setErrors] = useState<StudentFormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function handleChange(field: StudentField, value: string) {
|
||||
setValues((current) => ({ ...current, [field]: value }));
|
||||
// Clear the message as soon as the field is touched again.
|
||||
setErrors((current) => (field in current ? { ...current, [field]: undefined } : current));
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.SubmitEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (saving) return;
|
||||
|
||||
const parsed = studentDraftSchema.safeParse(values);
|
||||
if (!parsed.success) {
|
||||
setErrors(toFieldErrors(parsed.error));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setErrors({});
|
||||
createStudent(parsed.data);
|
||||
window.location.assign("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-6" onSubmit={handleSubmit} noValidate>
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Add a student</h1>
|
||||
<p className="text-sm text-white/50">
|
||||
Saved to local storage, then you go straight back to the list.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-5">
|
||||
<StudentFields
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={handleChange}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button className={buttonPrimaryClass} type="submit" disabled={saving}>
|
||||
{saving ? "Saving…" : "Create student"}
|
||||
</button>
|
||||
<a className={buttonGhostClass} href="/">
|
||||
cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user