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