Initial commit: Kinship Directory application

This commit is contained in:
2026-09-05 10:33:45 -05:00
commit 0eb4240f0a
51 changed files with 4682 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { Plus, Save, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import type { Family, Person } from "@/lib/types";
import { ImageUploader } from "./ImageUploader";
const blankPerson = (): Person => ({ id: crypto.randomUUID(), firstName: "", lastName: "", email: "", phone: "", birthday: "" });
const usRegions = [
["AL", "Alabama"], ["AK", "Alaska"], ["AZ", "Arizona"], ["AR", "Arkansas"], ["CA", "California"], ["CO", "Colorado"], ["CT", "Connecticut"], ["DE", "Delaware"], ["DC", "District of Columbia"], ["FL", "Florida"], ["GA", "Georgia"], ["HI", "Hawaii"], ["ID", "Idaho"], ["IL", "Illinois"], ["IN", "Indiana"], ["IA", "Iowa"], ["KS", "Kansas"], ["KY", "Kentucky"], ["LA", "Louisiana"], ["ME", "Maine"], ["MD", "Maryland"], ["MA", "Massachusetts"], ["MI", "Michigan"], ["MN", "Minnesota"], ["MS", "Mississippi"], ["MO", "Missouri"], ["MT", "Montana"], ["NE", "Nebraska"], ["NV", "Nevada"], ["NH", "New Hampshire"], ["NJ", "New Jersey"], ["NM", "New Mexico"], ["NY", "New York"], ["NC", "North Carolina"], ["ND", "North Dakota"], ["OH", "Ohio"], ["OK", "Oklahoma"], ["OR", "Oregon"], ["PA", "Pennsylvania"], ["RI", "Rhode Island"], ["SC", "South Carolina"], ["SD", "South Dakota"], ["TN", "Tennessee"], ["TX", "Texas"], ["UT", "Utah"], ["VT", "Vermont"], ["VA", "Virginia"], ["WA", "Washington"], ["WV", "West Virginia"], ["WI", "Wisconsin"], ["WY", "Wyoming"], ["AS", "American Samoa"], ["GU", "Guam"], ["MP", "Northern Mariana Islands"], ["PR", "Puerto Rico"], ["VI", "U.S. Virgin Islands"],
] as const;
const canadianRegions = [["AB", "Alberta"], ["BC", "British Columbia"], ["MB", "Manitoba"], ["NB", "New Brunswick"], ["NL", "Newfoundland and Labrador"], ["NS", "Nova Scotia"], ["NT", "Northwest Territories"], ["NU", "Nunavut"], ["ON", "Ontario"], ["PE", "Prince Edward Island"], ["QC", "Quebec"], ["SK", "Saskatchewan"], ["YT", "Yukon"]] as const;
function PersonFields({ person, onChange, onRemove }: { person: Person; onChange: (person: Person) => void; onRemove: () => void }) {
return <div className="rounded-2xl border border-black/8 bg-white p-4 sm:p-5"><div className="flex gap-4"><ImageUploader compact label="Photo" value={person.photoId} onChange={(photoId) => onChange({ ...person, photoId })}/><div className="grid flex-1 gap-3 sm:grid-cols-2"><div><label className="label">First name</label><input className="field" value={person.firstName} onChange={(event) => onChange({ ...person, firstName: event.target.value })}/></div><div><label className="label">Last name</label><input className="field" value={person.lastName} onChange={(event) => onChange({ ...person, lastName: event.target.value })}/></div></div><button type="button" aria-label="Remove person" onClick={onRemove} className="self-start rounded-full p-2 text-[#9a5b50] hover:bg-red-50"><Trash2 size={18}/></button></div>
<div className="mt-4 grid gap-3 sm:grid-cols-3"><div><label className="label">Email</label><input className="field" type="email" value={person.email || ""} onChange={(event) => onChange({ ...person, email: event.target.value })}/></div><div><label className="label">Phone</label><input className="field" type="tel" value={person.phone || ""} onChange={(event) => onChange({ ...person, phone: event.target.value })}/></div><div><label className="label">Birthday</label><input className="field" type="text" placeholder="MM/DD or YYYY-MM-DD" value={person.birthday || ""} onChange={(event) => onChange({ ...person, birthday: event.target.value })}/></div></div>
</div>;
}
export function FamilyEditor({ initialFamily }: { initialFamily: Family }) {
const router = useRouter();
const [family, setFamily] = useState(initialFamily);
const [pending, setPending] = useState(false);
const [message, setMessage] = useState("");
function updatePerson(group: "adults" | "children", index: number, person?: Person) {
const next = [...family[group]];
if (person) next[index] = person; else next.splice(index, 1);
setFamily({ ...family, [group]: next });
}
async function save(event: React.FormEvent) {
event.preventDefault(); setPending(true); setMessage("");
try {
const response = await fetch("/api/family", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(family) });
const result = await response.json();
if (response.ok) { setFamily(result.family); setMessage("Your family profile has been saved."); router.refresh(); } else setMessage(result.error);
} catch {
setMessage("Your changes could not be saved. Check your connection and try again.");
} finally {
setPending(false);
}
}
return <form onSubmit={save} className="space-y-9">
<section className="grid gap-6 rounded-[1.75rem] bg-white p-5 card-shadow sm:p-7 md:grid-cols-[220px_1fr]"><ImageUploader label="Upload a family photo" value={family.photoId} onChange={(photoId) => setFamily({ ...family, photoId })}/><div><div><label className="label">Family display name</label><input className="field text-lg font-bold" required value={family.familyName} onChange={(event) => setFamily({ ...family, familyName: event.target.value })}/></div><div className="mt-4 grid gap-3 sm:grid-cols-2"><div className="sm:col-span-2"><label className="label">Street address</label><input className="field" value={family.address || ""} onChange={(event) => setFamily({ ...family, address: event.target.value })}/></div><div><label className="label">City</label><input className="field" value={family.city || ""} onChange={(event) => setFamily({ ...family, city: event.target.value })}/></div><div className="grid grid-cols-2 gap-3"><div><label className="label">State / Province</label><select className="field field-select" value={family.state || ""} onChange={(event) => setFamily({ ...family, state: event.target.value })}><option value="">Select</option><optgroup label="United States">{usRegions.map(([code, name]) => <option key={code} value={code}>{name}</option>)}</optgroup><optgroup label="Canada">{canadianRegions.map(([code, name]) => <option key={code} value={code}>{name}</option>)}</optgroup></select></div><div><label className="label">Postal code</label><input className="field" value={family.postalCode || ""} onChange={(event) => setFamily({ ...family, postalCode: event.target.value })}/></div></div><div><label className="label">Home phone</label><input className="field" type="tel" value={family.homePhone || ""} onChange={(event) => setFamily({ ...family, homePhone: event.target.value })}/></div></div></div></section>
<section><div className="mb-4 flex items-center justify-between"><div><h2 className="font-warm text-2xl font-bold text-[var(--brand)]">Parents & adults</h2><p className="text-sm text-[#66716d]">At least one adult is required.</p></div><button type="button" className="btn-secondary" onClick={() => setFamily({ ...family, adults: [...family.adults, blankPerson()] })}><Plus size={17}/> Add</button></div><div className="space-y-4">{family.adults.map((person, index) => <PersonFields key={person.id} person={person} onChange={(next) => updatePerson("adults", index, next)} onRemove={() => updatePerson("adults", index)}/>)}</div></section>
<section><div className="mb-4 flex items-center justify-between"><div><h2 className="font-warm text-2xl font-bold text-[var(--brand)]">Children</h2><p className="text-sm text-[#66716d]">Full birth dates are checked to ensure children are under 18.</p></div><button type="button" className="btn-secondary" onClick={() => setFamily({ ...family, children: [...family.children, blankPerson()] })}><Plus size={17}/> Add</button></div><div className="space-y-4">{family.children.map((person, index) => <PersonFields key={person.id} person={person} onChange={(next) => updatePerson("children", index, next)} onRemove={() => updatePerson("children", index)}/>)}</div></section>
<div className="sticky bottom-4 flex items-center justify-between gap-4 rounded-2xl border border-black/10 bg-white/95 p-4 shadow-xl backdrop-blur"><p className={`text-sm font-medium ${message.includes("saved") ? "text-green-700" : "text-red-700"}`}>{message}</p><button disabled={pending} className="btn-primary shrink-0"><Save size={17}/>{pending ? "Saving..." : "Save family"}</button></div>
</form>;
}