Trailing slashes on /plant/search/ and /plant/detail/ caused Django to return an HTML redirect page instead of JSON. SDK uses no trailing slashes. Also updates the helper text to reference OpenPlantBook instead of Perenual.
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useTransition, useRef } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { createPlant, updatePlant } from "../server/actions";
|
|
import { SpeciesSearch } from "./species-search";
|
|
import type { PlantDetailDto, ContainerDto } from "../server/queries";
|
|
import type { SpeciesSuggestion } from "../server/species-lookup";
|
|
|
|
const CATEGORIES = [
|
|
{ value: "vegetable", label: "Vegetable" },
|
|
{ value: "fruit", label: "Fruit" },
|
|
{ value: "herb", label: "Herb" },
|
|
{ value: "flower", label: "Flower" },
|
|
{ value: "succulent", label: "Succulent" },
|
|
{ value: "cactus", label: "Cactus" },
|
|
{ value: "tropical", label: "Tropical" },
|
|
{ value: "tree", label: "Tree" },
|
|
{ value: "shrub", label: "Shrub" },
|
|
{ value: "other", label: "Other" },
|
|
];
|
|
|
|
const HEALTH_STATUSES = [
|
|
{ value: "healthy", label: "Healthy" },
|
|
{ value: "needs-attention", label: "Needs attention" },
|
|
{ value: "sick", label: "Sick" },
|
|
{ value: "dormant", label: "Dormant" },
|
|
];
|
|
|
|
const GROWTH_STAGES = [
|
|
{ value: "", label: "Unknown" },
|
|
{ value: "seedling", label: "Seedling" },
|
|
{ value: "sprout", label: "Sprout" },
|
|
{ value: "vegetative", label: "Vegetative" },
|
|
{ value: "budding", label: "Budding" },
|
|
{ value: "flowering", label: "Flowering" },
|
|
{ value: "ripening", label: "Ripening" },
|
|
{ value: "dormant", label: "Dormant" },
|
|
];
|
|
|
|
type Props = {
|
|
existingPlant?: PlantDetailDto;
|
|
defaultContainerId?: string | null;
|
|
containers: ContainerDto[];
|
|
};
|
|
|
|
export function PlantForm({ existingPlant, defaultContainerId, containers }: Props) {
|
|
const router = useRouter();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isPending, startTransition] = useTransition();
|
|
const [images, setImages] = useState<string[]>(existingPlant?.images ?? []);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [selectedSpeciesId, setSelectedSpeciesId] = useState(existingPlant?.speciesId ?? "");
|
|
|
|
const scientificNameRef = useRef<HTMLInputElement>(null);
|
|
const sunlightRef = useRef<HTMLInputElement>(null);
|
|
const wateringNotesRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
function handleSpeciesSelect(species: SpeciesSuggestion) {
|
|
setSelectedSpeciesId(species.id);
|
|
if (scientificNameRef.current) scientificNameRef.current.value = species.scientific_name;
|
|
if (sunlightRef.current) sunlightRef.current.value = species.sunlight.join(", ");
|
|
if (wateringNotesRef.current) wateringNotesRef.current.value = species.watering;
|
|
}
|
|
|
|
async function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const files = Array.from(e.target.files ?? []);
|
|
if (images.length >= 10) {
|
|
setError("Maximum 10 images allowed.");
|
|
return;
|
|
}
|
|
setUploading(true);
|
|
setError(null);
|
|
try {
|
|
const urls: string[] = [];
|
|
for (const file of files) {
|
|
if (images.length + urls.length >= 10) break;
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
const res = await fetch("/api/uploads", { method: "POST", body: fd });
|
|
if (!res.ok) throw new Error("Upload failed");
|
|
const data = (await res.json()) as { url: string };
|
|
urls.push(data.url);
|
|
}
|
|
setImages((prev) => [...prev, ...urls]);
|
|
} catch {
|
|
setError("Image upload failed. Please try again.");
|
|
} finally {
|
|
setUploading(false);
|
|
e.target.value = "";
|
|
}
|
|
}
|
|
|
|
function handleRemoveImage(url: string) {
|
|
setImages((prev) => prev.filter((u) => u !== url));
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.currentTarget);
|
|
|
|
const input = {
|
|
name: fd.get("name") as string,
|
|
category: (fd.get("category") as string) || "other",
|
|
containerId: (fd.get("containerId") as string) || null,
|
|
healthStatus: (fd.get("healthStatus") as string) || "healthy",
|
|
growthStage: (fd.get("growthStage") as string) || null,
|
|
scientificName: scientificNameRef.current?.value || null,
|
|
speciesId: selectedSpeciesId || null,
|
|
sunlight: sunlightRef.current?.value || null,
|
|
wateringNotes: wateringNotesRef.current?.value || null,
|
|
fertilizingNotes: (fd.get("fertilizingNotes") as string) || null,
|
|
notes: (fd.get("notes") as string) || null,
|
|
acquisitionDate: (fd.get("acquisitionDate") as string) || null,
|
|
images,
|
|
primaryImageUrl: existingPlant?.primaryImageUrl ?? images[0] ?? null,
|
|
};
|
|
|
|
setError(null);
|
|
startTransition(async () => {
|
|
try {
|
|
if (existingPlant) {
|
|
await updatePlant({ id: existingPlant.id, ...input });
|
|
router.push(`/garden/plants/${existingPlant.id}`);
|
|
router.refresh();
|
|
} else {
|
|
const plant = await createPlant(input);
|
|
router.push(`/garden/plants/${plant.id}`);
|
|
}
|
|
} catch {
|
|
setError("Something went wrong. Please try again.");
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-xl">
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-name" className="text-sm font-medium">
|
|
Name <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
id="plant-name"
|
|
name="name"
|
|
required
|
|
maxLength={120}
|
|
defaultValue={existingPlant?.name}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label className="text-sm font-medium">Species lookup (optional)</label>
|
|
<SpeciesSearch
|
|
onSelect={handleSpeciesSelect}
|
|
initialValue={existingPlant?.scientificName ?? ""}
|
|
/>
|
|
<p className="text-xs text-[var(--ink-mute)]">
|
|
Search OpenPlantBook to auto-fill scientific name and care notes.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-scientific-name" className="text-sm font-medium">
|
|
Scientific name
|
|
</label>
|
|
<input
|
|
id="plant-scientific-name"
|
|
name="scientificName"
|
|
ref={scientificNameRef}
|
|
maxLength={200}
|
|
defaultValue={existingPlant?.scientificName ?? ""}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-category" className="text-sm font-medium">
|
|
Category
|
|
</label>
|
|
<select
|
|
id="plant-category"
|
|
name="category"
|
|
defaultValue={existingPlant?.category ?? "other"}
|
|
>
|
|
{CATEGORIES.map((c) => (
|
|
<option key={c.value} value={c.value}>
|
|
{c.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-container" className="text-sm font-medium">
|
|
Container
|
|
</label>
|
|
<select
|
|
id="plant-container"
|
|
name="containerId"
|
|
defaultValue={existingPlant?.containerId ?? defaultContainerId ?? ""}
|
|
>
|
|
<option value="">None</option>
|
|
{containers.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-health" className="text-sm font-medium">
|
|
Health status
|
|
</label>
|
|
<select
|
|
id="plant-health"
|
|
name="healthStatus"
|
|
defaultValue={existingPlant?.healthStatus ?? "healthy"}
|
|
>
|
|
{HEALTH_STATUSES.map((h) => (
|
|
<option key={h.value} value={h.value}>
|
|
{h.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-stage" className="text-sm font-medium">
|
|
Growth stage
|
|
</label>
|
|
<select
|
|
id="plant-stage"
|
|
name="growthStage"
|
|
defaultValue={existingPlant?.growthStage ?? ""}
|
|
>
|
|
{GROWTH_STAGES.map((g) => (
|
|
<option key={g.value} value={g.value}>
|
|
{g.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-acquired" className="text-sm font-medium">
|
|
Acquisition date
|
|
</label>
|
|
<input
|
|
id="plant-acquired"
|
|
type="date"
|
|
name="acquisitionDate"
|
|
defaultValue={existingPlant?.acquisitionDate ?? ""}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-sunlight" className="text-sm font-medium">
|
|
Sunlight requirements
|
|
</label>
|
|
<input
|
|
id="plant-sunlight"
|
|
name="sunlight"
|
|
ref={sunlightRef}
|
|
maxLength={200}
|
|
defaultValue={existingPlant?.sunlight ?? ""}
|
|
placeholder="e.g. full sun, part shade"
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-watering" className="text-sm font-medium">
|
|
Watering notes
|
|
</label>
|
|
<textarea
|
|
id="plant-watering"
|
|
name="wateringNotes"
|
|
ref={wateringNotesRef}
|
|
maxLength={2000}
|
|
rows={2}
|
|
defaultValue={existingPlant?.wateringNotes ?? ""}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-fertilizing" className="text-sm font-medium">
|
|
Fertilizing notes
|
|
</label>
|
|
<textarea
|
|
id="plant-fertilizing"
|
|
name="fertilizingNotes"
|
|
maxLength={2000}
|
|
rows={2}
|
|
defaultValue={existingPlant?.fertilizingNotes ?? ""}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
<label htmlFor="plant-notes" className="text-sm font-medium">
|
|
General notes
|
|
</label>
|
|
<textarea
|
|
id="plant-notes"
|
|
name="notes"
|
|
maxLength={2000}
|
|
rows={3}
|
|
defaultValue={existingPlant?.notes ?? ""}
|
|
className="input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<label className="text-sm font-medium">Photos ({images.length}/10)</label>
|
|
{images.length > 0 && (
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{images.map((url) => (
|
|
<div key={url} className="relative group">
|
|
<img src={url} alt="" className="w-full aspect-square object-cover rounded" />
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveImage(url)}
|
|
className="absolute top-0.5 right-0.5 bg-black/60 text-white rounded text-xs px-1 opacity-0 group-hover:opacity-100"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{images.length < 10 && (
|
|
<label className="btn btn-ghost btn-sm w-fit cursor-pointer">
|
|
{uploading ? "Uploading…" : "Add photos"}
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleImageSelect}
|
|
disabled={uploading}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
|
|
<div className="flex gap-2 justify-end">
|
|
<a
|
|
href={existingPlant ? `/garden/plants/${existingPlant.id}` : "/garden"}
|
|
className="btn btn-ghost"
|
|
>
|
|
Cancel
|
|
</a>
|
|
<button type="submit" className="btn btn-primary" disabled={isPending || uploading}>
|
|
{isPending ? "Saving…" : existingPlant ? "Save" : "Create"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|