v0.1.0 — Moon Base initial scaffold: auth, garden plant registry + log
This commit is contained in:
146
src/components/garden/add-log-button.tsx
Normal file
146
src/components/garden/add-log-button.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { PlantLogType } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Plus } from "lucide-react";
|
||||
import { LOG_TYPE_LABELS } from "@/lib/garden/categories";
|
||||
|
||||
const LOG_TYPE_ORDER: PlantLogType[] = [
|
||||
"OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED",
|
||||
];
|
||||
|
||||
const schema = z.object({
|
||||
type: z.nativeEnum(PlantLogType),
|
||||
date: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
quantity: z.string().optional(),
|
||||
});
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
plantId: string;
|
||||
plantName: string;
|
||||
}
|
||||
|
||||
export function AddLogButton({ plantId, plantName }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [logType, setLogType] = useState<PlantLogType>("OBSERVATION");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: "OBSERVATION",
|
||||
date: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const res = await fetch(`/api/plants/${plantId}/logs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast({ title: "Error", description: err.error ?? "Could not save log entry.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
toast({ title: "Log entry saved!" });
|
||||
setOpen(false);
|
||||
form.reset({ type: "OBSERVATION", date: new Date().toISOString().split("T")[0] });
|
||||
setLogType("OBSERVATION");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)} size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add log entry
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Log entry — {plantName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
defaultValue="OBSERVATION"
|
||||
onValueChange={(v) => {
|
||||
const t = v as PlantLogType;
|
||||
form.setValue("type", t);
|
||||
setLogType(t);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_TYPE_ORDER.map((t) => (
|
||||
<SelectItem key={t} value={t}>{LOG_TYPE_LABELS[t]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="log-date">Date</Label>
|
||||
<Input id="log-date" type="date" {...form.register("date")} />
|
||||
</div>
|
||||
|
||||
{logType === "HARVEST" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="quantity">How much did you harvest?</Label>
|
||||
<Input id="quantity" placeholder="2 lbs, 1 gallon, a big handful…" {...form.register("quantity")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="log-notes">Notes</Label>
|
||||
<Textarea
|
||||
id="log-notes"
|
||||
placeholder={
|
||||
logType === "OBSERVATION" ? "What did you notice?" :
|
||||
logType === "CARE" ? "What did you do? (watered, mulched, pruned…)" :
|
||||
logType === "HARVEST" ? "Any notes about the harvest?" :
|
||||
logType === "TREATMENT" ? "What pest / disease? What did you apply?" :
|
||||
"Notes…"
|
||||
}
|
||||
rows={3}
|
||||
{...form.register("notes")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
src/components/garden/add-plant-button.tsx
Normal file
146
src/components/garden/add-plant-button.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { PlantCategory } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Plus } from "lucide-react";
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/garden/categories";
|
||||
|
||||
const schema = z.object({
|
||||
commonName: z.string().min(1, "Name is required"),
|
||||
species: z.string().optional(),
|
||||
variety: z.string().optional(),
|
||||
category: z.nativeEnum(PlantCategory),
|
||||
zone: z.string().optional(),
|
||||
plantedAt: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function AddPlantButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { category: "CANOPY_TREE" },
|
||||
});
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
const res = await fetch("/api/plants", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast({ title: "Error", description: err.error ?? "Could not save plant.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const plant = await res.json();
|
||||
toast({ title: "Plant added!", description: `${values.commonName} is now in your food forest.` });
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
router.push(`/garden/${plant.id}`);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add plant
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a plant</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label htmlFor="commonName">Common name *</Label>
|
||||
<Input id="commonName" placeholder="Apple, Comfrey, Raspberry…" {...form.register("commonName")} />
|
||||
{form.formState.errors.commonName && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.commonName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="variety">Variety / cultivar</Label>
|
||||
<Input id="variety" placeholder="Honeycrisp, Bob Gordon…" {...form.register("variety")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Category *</Label>
|
||||
<Select
|
||||
defaultValue="CANOPY_TREE"
|
||||
onValueChange={(v) => form.setValue("category", v as PlantCategory)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_ORDER.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{CATEGORY_LABELS[cat]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label htmlFor="species">Scientific name (optional)</Label>
|
||||
<Input id="species" placeholder="Malus domestica" className="italic" {...form.register("species")} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label htmlFor="zone">Location on your property</Label>
|
||||
<Input id="zone" placeholder="Back yard north fence, Front guild, Raised bed #1…" {...form.register("zone")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="plantedAt">Date planted</Label>
|
||||
<Input id="plantedAt" type="date" {...form.register("plantedAt")} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="source">Where it came from</Label>
|
||||
<Input id="source" placeholder="Jung's Nursery, from seed…" {...form.register("source")} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea id="notes" placeholder="Any notes about this plant…" rows={3} {...form.register("notes")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? "Saving…" : "Add plant"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user