v0.1.0 — Moon Base initial scaffold: auth, garden plant registry + log

This commit is contained in:
Bonna Moon
2026-06-15 16:14:48 -05:00
commit 99918fffbc
47 changed files with 2764 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Leaf, CalendarDays, Sprout } from "lucide-react";
import { formatDate } from "@/lib/utils";
export const metadata = { title: "Dashboard" };
export default async function DashboardPage() {
const session = await getSession();
const [plantCount, recentLogs] = await Promise.all([
db.plant.count({ where: { active: true } }),
db.plantLog.findMany({
orderBy: { date: "desc" },
take: 5,
include: { plant: { select: { commonName: true, variety: true } } },
}),
]);
const firstName = session?.user.name.split(" ")[0] ?? "there";
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-2xl font-semibold">Welcome back, {firstName}</h1>
<p className="text-muted-foreground text-sm mt-1">Here&apos;s what&apos;s happening on your acre.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Link href="/garden">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Leaf className="h-4 w-4 text-[hsl(var(--leaf))]" />
Plants in the food forest
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-display font-semibold">{plantCount}</p>
</CardContent>
</Card>
</Link>
</div>
{recentLogs.length > 0 && (
<div>
<h2 className="font-display text-lg font-semibold mb-3">Recent garden activity</h2>
<div className="space-y-2">
{recentLogs.map((log) => (
<Link key={log.id} href={`/garden/${log.plantId}`}>
<div className="flex items-start gap-3 p-3 rounded-lg border bg-card hover:bg-muted/50 transition-colors">
<Sprout className="h-4 w-4 mt-0.5 text-[hsl(var(--leaf))] shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium">
{log.plant.commonName}{log.plant.variety ? ` (${log.plant.variety})` : ""}
<span className="font-normal text-muted-foreground ml-2 capitalize">{log.type.toLowerCase()}</span>
</p>
{log.notes && (
<p className="text-xs text-muted-foreground truncate">{log.notes}</p>
)}
</div>
<span className="text-xs text-muted-foreground shrink-0 ml-auto">
{formatDate(log.date)}
</span>
</div>
</Link>
))}
</div>
</div>
)}
{recentLogs.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Leaf className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">No garden activity yet add your first plant to get started.</p>
<Link href="/garden" className="text-sm text-[hsl(var(--leaf))] underline mt-2 inline-block">
Go to garden
</Link>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { MapPin, CalendarDays, ChevronLeft, Sprout, Package } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { CATEGORY_LABELS, LOG_TYPE_LABELS, LOG_TYPE_COLORS } from "@/lib/garden/categories";
import { AddLogButton } from "@/components/garden/add-log-button";
export async function generateMetadata({ params }: { params: { id: string } }) {
const plant = await db.plant.findUnique({ where: { id: params.id }, select: { commonName: true } });
return { title: plant?.commonName ?? "Plant" };
}
export default async function PlantDetailPage({ params }: { params: { id: string } }) {
const plant = await db.plant.findUnique({
where: { id: params.id },
include: {
logs: { orderBy: { date: "desc" } },
},
});
if (!plant || !plant.active) notFound();
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/garden" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">
{plant.commonName}
{plant.variety && <span className="text-muted-foreground font-normal"> · {plant.variety}</span>}
</h1>
{plant.species && <p className="text-sm text-muted-foreground italic">{plant.species}</p>}
</div>
<Badge variant="secondary" className="ml-auto shrink-0">
{CATEGORY_LABELS[plant.category]}
</Badge>
</div>
<Card>
<CardContent className="pt-4 space-y-2 text-sm">
{plant.zone && (
<div className="flex items-center gap-2 text-muted-foreground">
<MapPin className="h-4 w-4 shrink-0" />
<span>{plant.zone}</span>
</div>
)}
{plant.plantedAt && (
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="h-4 w-4 shrink-0" />
<span>Planted {formatDate(plant.plantedAt)}</span>
</div>
)}
{plant.source && (
<div className="flex items-center gap-2 text-muted-foreground">
<Package className="h-4 w-4 shrink-0" />
<span>From {plant.source}</span>
</div>
)}
{plant.notes && (
<p className="text-sm pt-1 border-t mt-2">{plant.notes}</p>
)}
</CardContent>
</Card>
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-lg font-semibold">Care &amp; harvest log</h2>
<AddLogButton plantId={plant.id} plantName={plant.commonName} />
</div>
{plant.logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
No log entries yet record your first observation or harvest above.
</div>
) : (
<div className="space-y-2">
{plant.logs.map((log) => (
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<Sprout className="h-4 w-4 mt-0.5 text-[hsl(var(--leaf))] shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold uppercase tracking-wide ${LOG_TYPE_COLORS[log.type]}`}>
{LOG_TYPE_LABELS[log.type]}
</span>
{log.quantity && (
<span className="text-xs text-muted-foreground">· {log.quantity}</span>
)}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div>
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { db } from "@/lib/db";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Leaf, Plus, MapPin, CalendarDays } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { AddPlantButton } from "@/components/garden/add-plant-button";
import { CATEGORY_LABELS } from "@/lib/garden/categories";
export const metadata = { title: "Garden" };
export default async function GardenPage() {
const plants = await db.plant.findMany({
where: { active: true },
orderBy: [{ category: "asc" }, { commonName: "asc" }],
include: {
_count: { select: { logs: true } },
logs: { orderBy: { date: "desc" }, take: 1, select: { date: true, type: true } },
},
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Food Forest</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre
</p>
</div>
<AddPlantButton />
</div>
{plants.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Leaf className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No plants yet</p>
<p className="text-sm mt-1">Add your first plant to start building your food forest registry.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{plants.map((plant) => (
<Link key={plant.id} href={`/garden/${plant.id}`}>
<Card className="hover:shadow-md transition-shadow h-full cursor-pointer">
<CardContent className="pt-4 pb-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium truncate">
{plant.commonName}
{plant.variety && (
<span className="text-muted-foreground font-normal"> · {plant.variety}</span>
)}
</p>
{plant.species && (
<p className="text-xs text-muted-foreground italic truncate">{plant.species}</p>
)}
</div>
<Badge variant="secondary" className="shrink-0 text-xs">
{CATEGORY_LABELS[plant.category]}
</Badge>
</div>
<div className="space-y-1 text-xs text-muted-foreground">
{plant.zone && (
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3 shrink-0" />
<span className="truncate">{plant.zone}</span>
</div>
)}
{plant.plantedAt && (
<div className="flex items-center gap-1.5">
<CalendarDays className="h-3 w-3 shrink-0" />
<span>Planted {formatDate(plant.plantedAt)}</span>
</div>
)}
{plant.logs[0] && (
<div className="flex items-center gap-1.5">
<Leaf className="h-3 w-3 shrink-0 text-[hsl(var(--leaf))]" />
<span>
Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)}
</span>
</div>
)}
</div>
<div className="pt-1 text-xs text-muted-foreground/60">
{plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { Sidebar } from "@/components/layout/sidebar";
import { TopNav } from "@/components/layout/topnav";
import { Footer } from "@/components/layout/footer";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await getSession();
if (!session) redirect("/login");
return (
<div className="flex h-screen flex-col">
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<TopNav user={session.user} />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</main>
</div>
</div>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { PlantLogType } from "@prisma/client";
const createSchema = z.object({
type: z.nativeEnum(PlantLogType),
date: z.string().optional(),
notes: z.string().optional(),
quantity: z.string().optional(),
});
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const session = await requireAuth();
const plant = await db.plant.findUnique({ where: { id: params.id }, select: { id: true } });
if (!plant) return NextResponse.json({ error: "Plant not found" }, { status: 404 });
const body = await req.json();
const data = createSchema.parse(body);
const log = await db.plantLog.create({
data: {
plantId: params.id,
type: data.type,
date: data.date ? new Date(data.date) : new Date(),
notes: data.notes?.trim() || null,
quantity: data.quantity?.trim() || null,
actorId: session.user.id,
},
});
await db.auditEvent.create({
data: {
action: "plant.log.add",
entityId: params.id,
actorId: session.user.id,
payload: { logId: log.id, type: log.type },
},
});
return NextResponse.json(log, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) {
return NextResponse.json({ error: err.issues }, { status: 400 });
}
if (err instanceof Error && err.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db";
import { requireAuth } from "@/lib/auth";
import { PlantCategory } from "@prisma/client";
const createSchema = z.object({
commonName: z.string().min(1),
species: z.string().optional(),
variety: z.string().optional(),
category: z.nativeEnum(PlantCategory),
zone: z.string().optional(),
plantedAt: z.string().optional(), // ISO date string
source: z.string().optional(),
notes: z.string().optional(),
});
export async function POST(req: Request) {
try {
const session = await requireAuth();
const body = await req.json();
const data = createSchema.parse(body);
const plant = await db.plant.create({
data: {
commonName: data.commonName.trim(),
species: data.species?.trim() || null,
variety: data.variety?.trim() || null,
category: data.category,
zone: data.zone?.trim() || null,
plantedAt: data.plantedAt ? new Date(data.plantedAt) : null,
source: data.source?.trim() || null,
notes: data.notes?.trim() || null,
},
});
await db.auditEvent.create({
data: {
action: "plant.create",
entityId: plant.id,
actorId: session.user.id,
payload: { commonName: plant.commonName, category: plant.category },
},
});
return NextResponse.json(plant, { status: 201 });
} catch (err) {
if (err instanceof z.ZodError) {
return NextResponse.json({ error: err.issues }, { status: 400 });
}
if (err instanceof Error && err.message === "Unauthorized") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
console.error(err);
return NextResponse.json({ error: "Server error" }, { status: 500 });
}
}

102
src/app/globals.css Normal file
View File

@@ -0,0 +1,102 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Moon Base palette — forest / earth / sky
*
* --soil warm dark brown for body text (like rich earth)
* --leaf fresh green accent
* --canopy deep forest green sidebar
* --dew pale sage background
* --ember warm red for warnings / destructive
*/
@layer base {
:root {
--dew: 120 18% 94%; /* #EEF3EE — pale sage background */
--dew-deep: 120 20% 97%; /* slightly lighter for cards */
--soil: 25 35% 18%; /* #3A2B1A — warm dark brown text */
--soil-muted: 25 20% 40%; /* softer brown for muted copy */
--leaf: 130 40% 38%; /* #3D7A48 — forest green accent */
--leaf-soft: 130 30% 72%; /* lighter green for hovers */
--canopy: 140 30% 16%; /* #1E3328 — dark forest green sidebar */
--canopy-foreground: 120 18% 88%;
--canopy-accent: 140 28% 22%;
--canopy-accent-foreground: 130 30% 72%;
--canopy-border: 140 30% 12%;
--ember: 5 55% 36%; /* #8C3528 — deep red for destructive */
--ember-foreground: 44 80% 95%;
/* shadcn token bindings */
--background: var(--dew);
--foreground: var(--soil);
--card: var(--dew-deep);
--card-foreground: var(--soil);
--popover: var(--dew-deep);
--popover-foreground: var(--soil);
--primary: 140 30% 16%;
--primary-foreground: 120 18% 88%;
--secondary: 120 15% 85%;
--secondary-foreground: var(--soil);
--muted: 120 12% 87%;
--muted-foreground: var(--soil-muted);
--accent: var(--leaf);
--accent-foreground: 120 18% 96%;
--destructive: var(--ember);
--destructive-foreground: var(--ember-foreground);
--border: 120 15% 82%;
--input: 120 15% 82%;
--ring: var(--leaf);
--radius: 0.625rem;
--sidebar: var(--canopy);
--sidebar-foreground: var(--canopy-foreground);
--sidebar-accent: var(--canopy-accent);
--sidebar-accent-foreground: var(--canopy-accent-foreground);
--sidebar-border: var(--canopy-border);
}
.dark {
--dew: 140 18% 10%;
--dew-deep: 140 18% 12%;
--soil: 120 18% 88%;
--soil-muted: 120 12% 65%;
--leaf: 130 40% 50%;
--leaf-soft: 130 30% 65%;
--canopy: 140 25% 8%;
--canopy-foreground: 120 15% 78%;
--canopy-accent: 140 25% 13%;
--canopy-accent-foreground: 130 25% 80%;
--canopy-border: 140 25% 6%;
--ember: 5 60% 50%;
--ember-foreground: 120 18% 92%;
--background: var(--dew);
--foreground: var(--soil);
--card: var(--dew-deep);
--card-foreground: var(--soil);
--popover: var(--dew-deep);
--popover-foreground: var(--soil);
--primary: 130 40% 50%;
--primary-foreground: 140 18% 10%;
--secondary: 140 18% 18%;
--secondary-foreground: var(--soil);
--muted: 140 18% 16%;
--muted-foreground: var(--soil-muted);
--accent: var(--leaf);
--accent-foreground: 140 18% 10%;
--destructive: var(--ember);
--destructive-foreground: var(--ember-foreground);
--border: 140 18% 20%;
--input: 140 18% 20%;
--ring: var(--leaf);
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

42
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,42 @@
import type { Metadata, Viewport } from "next";
import { Inter, Lora } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import { Providers } from "./providers";
const sans = Inter({
subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
const display = Lora({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-display",
display: "swap",
});
export const metadata: Metadata = {
title: { default: "Moon Base", template: "%s — Moon Base" },
description: "Property management for the Moon homestead — garden, home, and equipment.",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className={`${sans.variable} ${display.variable}`}>
<body className="font-sans antialiased">
<Providers>
{children}
<Toaster />
</Providers>
</body>
</html>
);
}

91
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,91 @@
"use client";
import { Suspense, useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Leaf } from "lucide-react";
function LoginForm() {
const router = useRouter();
const search = useSearchParams();
const callbackUrl = search.get("callbackUrl") ?? "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const res = await signIn("credentials", { email, password, redirect: false });
setBusy(false);
if (res?.error) {
setError("Wrong username or password.");
return;
}
router.push(callbackUrl);
router.refresh();
}
return (
<div className="min-h-screen flex items-center justify-center px-4 bg-background">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-6 text-center">
<div className="h-14 w-14 rounded-full bg-[hsl(var(--canopy))] flex items-center justify-center mb-3">
<Leaf className="h-7 w-7 text-[hsl(var(--canopy-foreground))]" />
</div>
<h1 className="font-display text-3xl text-foreground tracking-tight">Moon Base</h1>
<p className="text-xs text-muted-foreground mt-1">
Property &amp; garden management
</p>
</div>
<Card>
<CardContent className="pt-6">
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="email">Username or email</Label>
<Input
id="email"
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
</CardContent>
</Card>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}

5
src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

19
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,19 @@
"use client";
import { SessionProvider } from "next-auth/react";
import { ThemeProvider } from "next-themes";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</SessionProvider>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View File

@@ -0,0 +1,12 @@
import Link from "next/link";
import { APP_VERSION } from "@/lib/version";
export function Footer() {
return (
<footer className="h-8 border-t flex items-center justify-end px-4 md:px-6 shrink-0">
<span className="text-[11px] text-muted-foreground/60">
Moon Base v{APP_VERSION}
</span>
</footer>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Leaf, Home, Wrench, LayoutDashboard } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ label: "Garden", href: "/garden", icon: Leaf },
// Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home },
// { label: "Equipment", href: "/equipment", icon: Wrench },
];
export function Sidebar() {
const path = usePathname();
return (
<aside className="hidden md:flex flex-col w-56 bg-[hsl(var(--canopy))] text-[hsl(var(--canopy-foreground))] shrink-0">
<div className="flex items-center gap-2.5 px-4 py-4 border-b border-[hsl(var(--canopy-border))]">
<div className="h-7 w-7 rounded-full bg-[hsl(var(--leaf))] flex items-center justify-center shrink-0">
<Leaf className="h-4 w-4 text-white" />
</div>
<span className="font-display text-lg font-semibold tracking-tight">Moon Base</span>
</div>
<nav className="flex-1 px-2 py-3 space-y-0.5">
{navItems.map(({ label, href, icon: Icon }) => {
const active = path === href || (href !== "/dashboard" && path.startsWith(href));
return (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors",
active
? "bg-[hsl(var(--canopy-accent))] text-[hsl(var(--canopy-accent-foreground))]"
: "hover:bg-[hsl(var(--canopy-accent))] hover:text-[hsl(var(--canopy-accent-foreground))] text-[hsl(var(--canopy-foreground))]/80"
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
})}
</nav>
<div className="px-4 py-3 border-t border-[hsl(var(--canopy-border))] text-[10px] text-[hsl(var(--canopy-foreground))]/40 uppercase tracking-widest">
Moon homestead · Eau Claire, WI
</div>
</aside>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { signOut } from "next-auth/react";
import { LogOut, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { initials } from "@/lib/utils";
interface TopNavProps {
user: { name: string; email: string; role: string };
}
export function TopNav({ user }: TopNavProps) {
return (
<header className="h-14 border-b flex items-center justify-between px-4 md:px-6 shrink-0 bg-background">
<div className="flex items-center gap-2 md:hidden">
<span className="font-display font-semibold">Moon Base</span>
</div>
<div className="hidden md:block" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<div className="h-6 w-6 rounded-full bg-[hsl(var(--leaf))] flex items-center justify-center text-white text-[10px] font-bold">
{initials(user.name)}
</div>
<span className="text-sm hidden sm:inline">{user.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{user.email}
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="h-4 w-4 mr-2" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</header>
);
}

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200",
warning:
"border-transparent bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200",
info: "border-transparent bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,100 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,173 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base sm:text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,149 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base sm:text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };

123
src/components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,123 @@
"use client";
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
success: "border-emerald-200 bg-emerald-50 text-emerald-900",
},
},
defaultVariants: { variant: "default" },
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
));
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@@ -0,0 +1,33 @@
"use client";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
import { useToast } from "@/hooks/use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

134
src/hooks/use-toast.ts Normal file
View File

@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY = 5000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
| { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
| { type: ActionType["DISMISS_TOAST"]; toastId?: string }
| { type: ActionType["REMOVE_TOAST"]; toastId?: string };
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) return;
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({ type: "REMOVE_TOAST", toastId });
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT) };
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((t) => addToRemoveQueue(t.id));
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined ? { ...t, open: false } : t
),
};
}
case "REMOVE_TOAST":
return {
...state,
toasts: action.toastId
? state.toasts.filter((t) => t.id !== action.toastId)
: [],
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((l) => l(memoryState));
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id } });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return { id, dismiss, update };
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) listeners.splice(index, 1);
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

110
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,110 @@
import { NextAuthOptions, getServerSession } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { db } from "@/lib/db";
import { UserRole } from "@prisma/client";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name: string;
role: UserRole;
mustChangePassword: boolean;
};
}
interface User {
id: string;
role: UserRole;
mustChangePassword: boolean;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: UserRole;
mustChangePassword: boolean;
}
}
export const authOptions: NextAuthOptions = {
session: { strategy: "jwt" },
pages: { signIn: "/login", error: "/login" },
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Username or email", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const id = credentials.email.trim();
const user = await db.user.findFirst({
where: {
active: true,
OR: [
{ email: id.toLowerCase() },
{ username: { equals: id, mode: "insensitive" } },
],
},
select: {
id: true,
name: true,
email: true,
passwordHash: true,
role: true,
mustChangePassword: true,
},
});
if (!user) return null;
const ok = await compare(credentials.password, user.passwordHash);
if (!ok) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
mustChangePassword: user.mustChangePassword,
};
},
}),
],
callbacks: {
async jwt({ token, user, trigger }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.mustChangePassword = user.mustChangePassword;
}
if (trigger === "update") {
const fresh = await db.user.findUnique({
where: { id: token.id },
select: { mustChangePassword: true },
});
if (fresh) token.mustChangePassword = fresh.mustChangePassword;
}
return token;
},
async session({ session, token }) {
session.user.id = token.id;
session.user.role = token.role;
session.user.mustChangePassword = token.mustChangePassword;
return session;
},
},
};
export const getSession = () => getServerSession(authOptions);
export async function requireAuth() {
const session = await getSession();
if (!session) throw new Error("Unauthorized");
return session;
}
export function isAdmin(role: UserRole): boolean {
return role === "ADMIN";
}

18
src/lib/changelog.ts Normal file
View File

@@ -0,0 +1,18 @@
// Append new entries to the TOP. Match APP_VERSION in version.ts.
// Write for Bonna, not engineers — plain English, what changed and why.
export type ChangelogEntry = {
version: string;
date: string; // ISO yyyy-mm-dd
changes: string[];
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.1.0",
date: "2026-06-15",
changes: [
"Moon Base is live! Garden module: add plants to your food forest, record where they are on your property, and keep a log of care, harvests, and observations.",
],
},
];

14
src/lib/db.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from "@prisma/client";
declare global {
// eslint-disable-next-line no-var
var __db: PrismaClient | undefined;
}
export const db =
global.__db ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") global.__db = db;

View File

@@ -0,0 +1,49 @@
import { PlantCategory, PlantLogType } from "@prisma/client";
export const CATEGORY_LABELS: Record<PlantCategory, string> = {
CANOPY_TREE: "Canopy tree",
UNDERSTORY_TREE: "Understory tree",
LARGE_SHRUB: "Large shrub",
SHRUB: "Shrub",
HERB: "Herb",
GROUNDCOVER: "Groundcover",
VINE: "Vine",
ANNUAL: "Annual",
PERENNIAL: "Perennial",
BULB: "Bulb",
MUSHROOM: "Mushroom",
OTHER: "Other",
};
export const CATEGORY_ORDER: PlantCategory[] = [
"CANOPY_TREE",
"UNDERSTORY_TREE",
"LARGE_SHRUB",
"SHRUB",
"HERB",
"GROUNDCOVER",
"VINE",
"PERENNIAL",
"ANNUAL",
"BULB",
"MUSHROOM",
"OTHER",
];
export const LOG_TYPE_LABELS: Record<PlantLogType, string> = {
OBSERVATION: "Observation",
CARE: "Care",
HARVEST: "Harvest",
TREATMENT: "Treatment",
TRANSPLANT: "Transplanted",
DIED: "Died / removed",
};
export const LOG_TYPE_COLORS: Record<PlantLogType, string> = {
OBSERVATION: "text-blue-600 dark:text-blue-400",
CARE: "text-[hsl(var(--leaf))]",
HARVEST: "text-amber-600 dark:text-amber-400",
TREATMENT: "text-orange-600 dark:text-orange-400",
TRANSPLANT: "text-purple-600 dark:text-purple-400",
DIED: "text-destructive",
};

22
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,22 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function initials(name: string): string {
return name
.split(/\s+/)
.map((p) => p[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
}
export function formatDate(date: Date | string | null | undefined): string {
if (!date) return "—";
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}

2
src/lib/version.ts Normal file
View File

@@ -0,0 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.1.0";