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).
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|