commit 99918fffbc3edfe699c0aa3c248032dcec4fb3ac Author: Bonna Moon Date: Mon Jun 15 16:14:48 2026 -0500 v0.1.0 — Moon Base initial scaffold: auth, garden plant registry + log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c67422e --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Copy to .env and fill in values for local dev. + +DATABASE_URL="postgresql://tonym@localhost:5432/moonbase_dev" + +NEXTAUTH_SECRET="change-me-generate-with-openssl-rand-base64-32" +NEXTAUTH_URL="http://localhost:3000" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec0645c --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# deps +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# env +.env +.env.local +.env.*.local + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# uploads +/public/uploads/ diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..68a8854 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [{ protocol: "https", hostname: "**" }], + }, + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..ad50729 --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "moonbase", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate dev", + "db:migrate:deploy": "prisma migrate deploy", + "db:seed": "tsx prisma/seed.ts", + "db:studio": "prisma studio", + "db:reset": "prisma migrate reset --force" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@paralleldrive/cuid2": "^3.3.0", + "@prisma/client": "^5.22.0", + "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", + "@radix-ui/react-toast": "^1.2.2", + "bcryptjs": "^2.4.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "lucide-react": "^0.441.0", + "next": "14.2.29", + "next-auth": "^4.24.11", + "next-themes": "^0.4.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.2", + "tailwind-merge": "^2.5.4", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20.17.6", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "autoprefixer": "^10.4.20", + "eslint": "^8.57.1", + "eslint-config-next": "14.2.29", + "postcss": "^8.4.47", + "prisma": "^5.22.0", + "tailwindcss": "^3.4.14", + "tailwindcss-animate": "^1.0.7", + "tsx": "^4.19.1", + "typescript": "^5.6.3", + "vitest": "^4.1.7" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prisma/migrations/20260615000000_init/migration.sql b/prisma/migrations/20260615000000_init/migration.sql new file mode 100644 index 0000000..4dd1e33 --- /dev/null +++ b/prisma/migrations/20260615000000_init/migration.sql @@ -0,0 +1,92 @@ +-- Moon Base v0.1 — initial schema +-- Creates auth, audit log, and the garden plant registry. + +-- Enums +CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'MEMBER'); +CREATE TYPE "PlantCategory" AS ENUM ( + 'CANOPY_TREE', 'UNDERSTORY_TREE', 'LARGE_SHRUB', 'SHRUB', + 'HERB', 'GROUNDCOVER', 'VINE', 'ANNUAL', 'PERENNIAL', + 'BULB', 'MUSHROOM', 'OTHER' +); +CREATE TYPE "PlantLogType" AS ENUM ( + 'OBSERVATION', 'CARE', 'HARVEST', 'TREATMENT', 'TRANSPLANT', 'DIED' +); + +-- User +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "username" TEXT, + "name" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "role" "UserRole" NOT NULL DEFAULT 'MEMBER', + "active" BOOLEAN NOT NULL DEFAULT true, + "mustChangePassword" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- AuditEvent +CREATE TABLE "AuditEvent" ( + "id" TEXT NOT NULL, + "action" TEXT NOT NULL, + "entityId" TEXT, + "actorId" TEXT, + "payload" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "AuditEvent_entityId_idx" ON "AuditEvent"("entityId"); +CREATE INDEX "AuditEvent_createdAt_idx" ON "AuditEvent"("createdAt"); + +-- Plant +CREATE TABLE "Plant" ( + "id" TEXT NOT NULL, + "commonName" TEXT NOT NULL, + "species" TEXT, + "variety" TEXT, + "category" "PlantCategory" NOT NULL, + "zone" TEXT, + "plantedAt" TIMESTAMP(3), + "source" TEXT, + "notes" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Plant_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "Plant_category_idx" ON "Plant"("category"); +CREATE INDEX "Plant_zone_idx" ON "Plant"("zone"); +CREATE INDEX "Plant_active_idx" ON "Plant"("active"); + +-- PlantLog +CREATE TABLE "PlantLog" ( + "id" TEXT NOT NULL, + "plantId" TEXT NOT NULL, + "type" "PlantLogType" NOT NULL, + "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "notes" TEXT, + "quantity" TEXT, + "actorId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PlantLog_pkey" PRIMARY KEY ("id"), + CONSTRAINT "PlantLog_plantId_fkey" FOREIGN KEY ("plantId") REFERENCES "Plant"("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +CREATE INDEX "PlantLog_plantId_idx" ON "PlantLog"("plantId"); +CREATE INDEX "PlantLog_date_idx" ON "PlantLog"("date"); + +-- Provision admin accounts (bonna + tony) +-- Passwords are bcrypt hashes of temporary values — change on first login. +-- bonna: moonbase! | tony: moonbase! +INSERT INTO "User" ("id", "email", "username", "name", "passwordHash", "role", "active", "mustChangePassword", "createdAt", "updatedAt") +VALUES + ('cluser0000000bonna', 'bonna@moonbase.local', 'bonna', 'Bonna Moon', + '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'ADMIN', true, true, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP), + ('cluser0000000tony0', 'tony@moonbase.local', 'tony', 'Tony Moon', + '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'ADMIN', true, true, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT ("username") DO UPDATE SET "updatedAt" = CURRENT_TIMESTAMP; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..175e881 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,113 @@ +// Moon Base — property management for the Moon homestead. +// Tracks the food forest (plants, care logs, harvests), home maintenance, +// and equipment. v0.1 ships the garden module; home + equipment come next. + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +enum UserRole { + ADMIN // full access — add/edit/delete anything + MEMBER // can log observations and care, but not delete records +} + +model User { + id String @id @default(cuid()) + email String @unique + username String? @unique + name String + passwordHash String + role UserRole @default(MEMBER) + active Boolean @default(true) + mustChangePassword Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// --------------------------------------------------------------------------- +// Audit log — append-only, every mutation lands here +// --------------------------------------------------------------------------- + +model AuditEvent { + id String @id @default(cuid()) + action String // e.g. "plant.create", "plant.log.add" + entityId String? + actorId String? + payload Json? + createdAt DateTime @default(now()) + + @@index([entityId]) + @@index([createdAt]) +} + +// --------------------------------------------------------------------------- +// Garden — food forest plant registry +// --------------------------------------------------------------------------- + +enum PlantCategory { + CANOPY_TREE // apple, pear, cherry, walnut — the tall ones + UNDERSTORY_TREE // serviceberry, elderberry, pawpaw + LARGE_SHRUB // hazelnuts, currants, gooseberries + SHRUB // roses, raspberries, blueberries + HERB // comfrey, yarrow, mint, chives + GROUNDCOVER // strawberries, clover, creeping thyme + VINE // grapes, hops, kiwi + ANNUAL // tomatoes, squash, beans — seasonal + PERENNIAL // asparagus, rhubarb, perennial veg + BULB // garlic, onions, tulips + MUSHROOM // shiitake log, oyster bed, etc. + OTHER +} + +model Plant { + id String @id @default(cuid()) + commonName String + species String? // scientific name (optional) + variety String? // cultivar / variety name + category PlantCategory + zone String? // location on the property, freeform ("Back fence guild", "Front yard") + plantedAt DateTime? // when it went in the ground + source String? // where it came from ("Jung's Nursery", "from seed", "division from Mom's") + notes String? // general notes about this plant + active Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + logs PlantLog[] + + @@index([category]) + @@index([zone]) + @@index([active]) +} + +enum PlantLogType { + OBSERVATION // general note — "looking healthy", "saw first flowers" + CARE // watering, mulching, pruning, fertilizing, chop-and-drop + HARVEST // picked fruit, veg, herb, flower + TREATMENT // pest or disease treatment + TRANSPLANT // moved to a different spot + DIED // plant died or was removed +} + +model PlantLog { + id String @id @default(cuid()) + plantId String + plant Plant @relation(fields: [plantId], references: [id]) + type PlantLogType + date DateTime @default(now()) + notes String? + quantity String? // freeform for harvests: "2 lbs", "1 gallon", "a big handful" + actorId String? + createdAt DateTime @default(now()) + + @@index([plantId]) + @@index([date]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..482f65e --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,75 @@ +import { PrismaClient, PlantCategory } from "@prisma/client"; +import { hashSync } from "bcryptjs"; + +const db = new PrismaClient(); + +async function main() { + console.log("Seeding Moon Base..."); + + // Admin accounts — only created if they don't exist yet. + const password = hashSync("moonbase!", 10); + + await db.user.upsert({ + where: { username: "bonna" }, + update: {}, + create: { + id: "cluser0000000bonna", + email: "bonna@moonbase.local", + username: "bonna", + name: "Bonna Moon", + passwordHash: password, + role: "ADMIN", + mustChangePassword: true, + }, + }); + + await db.user.upsert({ + where: { username: "tony" }, + update: {}, + create: { + id: "cluser0000000tony0", + email: "tony@moonbase.local", + username: "tony", + name: "Tony Moon", + passwordHash: password, + role: "ADMIN", + mustChangePassword: true, + }, + }); + + if (process.env.SEED_MODE === "minimal") { + console.log("Minimal seed complete (admin accounts only)."); + return; + } + + // Sample plants for dev — a small starter food forest + const plants = [ + { commonName: "Apple", variety: "Honeycrisp", category: PlantCategory.CANOPY_TREE, zone: "Back yard, north fence", source: "Jung's Nursery", plantedAt: new Date("2024-04-15") }, + { commonName: "Apple", variety: "Liberty", category: PlantCategory.CANOPY_TREE, zone: "Back yard, north fence", source: "Jung's Nursery", plantedAt: new Date("2024-04-15") }, + { commonName: "Pear", variety: "Bartlett", category: PlantCategory.CANOPY_TREE, zone: "Back yard, east side", source: "Jung's Nursery", plantedAt: new Date("2024-04-20") }, + { commonName: "Elderberry", variety: "Bob Gordon", category: PlantCategory.UNDERSTORY_TREE, zone: "Back fence, south", source: "Raintree Nursery", plantedAt: new Date("2024-05-01") }, + { commonName: "Serviceberry", species: "Amelanchier canadensis", category: PlantCategory.UNDERSTORY_TREE, zone: "Front yard", source: "local nursery", plantedAt: new Date("2023-04-10") }, + { commonName: "Hazelnut", variety: "American", category: PlantCategory.LARGE_SHRUB, zone: "Back yard, west fence", source: "Oikos Tree Crops", plantedAt: new Date("2024-04-15") }, + { commonName: "Comfrey", species: "Symphytum officinale", category: PlantCategory.HERB, zone: "Under apple trees", source: "from seed", plantedAt: new Date("2024-05-15"), notes: "Chop-and-drop around the apples. Cut before it flowers to prevent spreading." }, + { commonName: "Raspberry", variety: "Heritage", category: PlantCategory.SHRUB, zone: "Back fence, east", source: "division", plantedAt: new Date("2023-05-01") }, + { commonName: "Strawberry", variety: "Ozark Beauty", category: PlantCategory.GROUNDCOVER, zone: "Under pear tree", source: "Jung's Nursery", plantedAt: new Date("2024-05-10") }, + { commonName: "Garlic", variety: "Music", category: PlantCategory.BULB, zone: "Raised bed #1", source: "Keene Organics", plantedAt: new Date("2024-10-10") }, + ]; + + for (const p of plants) { + await db.plant.upsert({ + where: { id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}` }, + update: {}, + create: { + id: `seed-${p.commonName.toLowerCase().replace(/\s/g, "-")}-${p.variety?.toLowerCase().replace(/\s/g, "-") ?? "0"}`, + ...p, + }, + }); + } + + console.log("Demo seed complete."); +} + +main() + .catch(console.error) + .finally(() => db.$disconnect()); diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..6363388 --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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 ( +
+
+

Welcome back, {firstName}

+

Here's what's happening on your acre.

+
+ +
+ + + + + + Plants in the food forest + + + +

{plantCount}

+
+
+ +
+ + {recentLogs.length > 0 && ( +
+

Recent garden activity

+
+ {recentLogs.map((log) => ( + +
+ +
+

+ {log.plant.commonName}{log.plant.variety ? ` (${log.plant.variety})` : ""} + {log.type.toLowerCase()} +

+ {log.notes && ( +

{log.notes}

+ )} +
+ + {formatDate(log.date)} + +
+ + ))} +
+
+ )} + + {recentLogs.length === 0 && ( +
+ +

No garden activity yet — add your first plant to get started.

+ + Go to garden + +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/garden/[id]/page.tsx b/src/app/(dashboard)/garden/[id]/page.tsx new file mode 100644 index 0000000..1f996c8 --- /dev/null +++ b/src/app/(dashboard)/garden/[id]/page.tsx @@ -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 ( +
+
+ + + +
+

+ {plant.commonName} + {plant.variety && · {plant.variety}} +

+ {plant.species &&

{plant.species}

} +
+ + {CATEGORY_LABELS[plant.category]} + +
+ + + + {plant.zone && ( +
+ + {plant.zone} +
+ )} + {plant.plantedAt && ( +
+ + Planted {formatDate(plant.plantedAt)} +
+ )} + {plant.source && ( +
+ + From {plant.source} +
+ )} + {plant.notes && ( +

{plant.notes}

+ )} +
+
+ +
+
+

Care & harvest log

+ +
+ + {plant.logs.length === 0 ? ( +
+ No log entries yet — record your first observation or harvest above. +
+ ) : ( +
+ {plant.logs.map((log) => ( +
+ +
+
+ + {LOG_TYPE_LABELS[log.type]} + + {log.quantity && ( + · {log.quantity} + )} + {formatDate(log.date)} +
+ {log.notes &&

{log.notes}

} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/garden/page.tsx b/src/app/(dashboard)/garden/page.tsx new file mode 100644 index 0000000..83a5ccf --- /dev/null +++ b/src/app/(dashboard)/garden/page.tsx @@ -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 ( +
+
+
+

Food Forest

+

+ {plants.length} {plants.length === 1 ? "plant" : "plants"} on your acre +

+
+ +
+ + {plants.length === 0 ? ( +
+ +

No plants yet

+

Add your first plant to start building your food forest registry.

+
+ ) : ( +
+ {plants.map((plant) => ( + + + +
+
+

+ {plant.commonName} + {plant.variety && ( + · {plant.variety} + )} +

+ {plant.species && ( +

{plant.species}

+ )} +
+ + {CATEGORY_LABELS[plant.category]} + +
+ +
+ {plant.zone && ( +
+ + {plant.zone} +
+ )} + {plant.plantedAt && ( +
+ + Planted {formatDate(plant.plantedAt)} +
+ )} + {plant.logs[0] && ( +
+ + + Last {plant.logs[0].type.toLowerCase()} {formatDate(plant.logs[0].date)} + +
+ )} +
+ +
+ {plant._count.logs} log {plant._count.logs === 1 ? "entry" : "entries"} +
+
+
+ + ))} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..f39ae0c --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -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 ( +
+
+ +
+ +
{children}
+
+
+
+
+ ); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..9cd7923 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/src/app/api/plants/[id]/logs/route.ts b/src/app/api/plants/[id]/logs/route.ts new file mode 100644 index 0000000..331a6ef --- /dev/null +++ b/src/app/api/plants/[id]/logs/route.ts @@ -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 }); + } +} diff --git a/src/app/api/plants/route.ts b/src/app/api/plants/route.ts new file mode 100644 index 0000000..8be7469 --- /dev/null +++ b/src/app/api/plants/route.ts @@ -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 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..d16406d --- /dev/null +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..5c06661 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + + {children} + + + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..06dc449 --- /dev/null +++ b/src/app/login/page.tsx @@ -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(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 ( +
+
+
+
+ +
+

Moon Base

+

+ Property & garden management +

+
+ + +
+
+ + setEmail(e.target.value)} + autoComplete="username" + autoFocus + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + required + /> +
+ {error &&

{error}

} + +
+
+
+
+
+ ); +} + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..c3a1c90 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function RootPage() { + redirect("/dashboard"); +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx new file mode 100644 index 0000000..2777914 --- /dev/null +++ b/src/app/providers.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/components/garden/add-log-button.tsx b/src/components/garden/add-log-button.tsx new file mode 100644 index 0000000..02ea4aa --- /dev/null +++ b/src/components/garden/add-log-button.tsx @@ -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; + +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("OBSERVATION"); + + const form = useForm({ + 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 ( + <> + + + + + + Log entry — {plantName} + + +
+
+ + +
+ +
+ + +
+ + {logType === "HARVEST" && ( +
+ + +
+ )} + +
+ +