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:
2026-09-16 19:00:18 +08:00
parent 478cb360d2
commit a21c76f029
15 changed files with 950 additions and 19 deletions
+115
View File
@@ -0,0 +1,115 @@
import type { ReactNode } from "react";
import {
COURSES,
type StudentField,
type StudentFormErrors,
type StudentFormValues,
} from "../lib/students";
type Props = {
values: StudentFormValues;
errors: StudentFormErrors;
onChange: (field: StudentField, value: string) => void;
disabled?: boolean;
};
const inputClass =
"w-full rounded-lg border border-white/10 bg-neutral-900/60 px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none transition-colors focus:border-white/40 disabled:opacity-50";
export const buttonPrimaryClass =
"inline-flex items-center justify-center gap-2 rounded-lg bg-white px-4 py-2 text-sm font-semibold text-neutral-900 transition-colors hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer";
export const buttonGhostClass =
"inline-flex items-center justify-center gap-2 rounded-lg border border-white/15 px-4 py-2 text-sm font-medium text-white/70 transition-colors hover:border-white/30 hover:text-white cursor-pointer";
export function Field({
label,
error,
hint,
children,
}: {
label: string;
error?: string;
hint?: string;
children: ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wide text-white/50">{label}</span>
{children}
{error ? (
<span className="text-xs text-red-400">{error}</span>
) : hint ? (
<span className="text-xs text-white/30">{hint}</span>
) : null}
</label>
);
}
/** The four student inputs, shared by the create form and the edit panel. */
export default function StudentFields({ values, errors, onChange, disabled }: Props) {
return (
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Field label="Full name" error={errors.name}>
<input
className={inputClass}
type="text"
name="name"
value={values.name}
placeholder="Juan Dela Cruz"
autoComplete="name"
disabled={disabled}
onChange={(event) => onChange("name", event.target.value)}
/>
</Field>
</div>
<div className="sm:col-span-2">
<Field label="Email" error={errors.email}>
<input
className={inputClass}
type="email"
name="email"
value={values.email}
placeholder="juan@example.com"
autoComplete="email"
disabled={disabled}
onChange={(event) => onChange("email", event.target.value)}
/>
</Field>
</div>
<Field label="Course" error={errors.course}>
<select
className={inputClass}
name="course"
value={values.course}
disabled={disabled}
onChange={(event) => onChange("course", event.target.value)}
>
<option value="">Select a course</option>
{COURSES.map((course) => (
<option key={course} value={course}>
{course}
</option>
))}
</select>
</Field>
<Field label="Year level" error={errors.yearLevel}>
<input
className={inputClass}
type="number"
name="yearLevel"
min={1}
max={5}
value={values.yearLevel}
disabled={disabled}
onChange={(event) => onChange("yearLevel", event.target.value)}
/>
</Field>
</div>
);
}