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

6
.env.example Normal file
View File

@@ -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"

29
.gitignore vendored Normal file
View File

@@ -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/

9
next.config.mjs Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [{ protocol: "https", hostname: "**" }],
},
reactStrictMode: true,
};
export default nextConfig;

66
package.json Normal file
View File

@@ -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"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -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;

View File

@@ -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"

113
prisma/schema.prisma Normal file
View File

@@ -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])
}

75
prisma/seed.ts Normal file
View File

@@ -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());

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";

103
tailwind.config.ts Normal file
View File

@@ -0,0 +1,103 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: { "2xl": "1400px" },
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
// Brand palette — forest / earth / sky
soil: "hsl(var(--soil))",
"soil-muted": "hsl(var(--soil-muted))",
leaf: "hsl(var(--leaf))",
"leaf-soft": "hsl(var(--leaf-soft))",
canopy: {
DEFAULT: "hsl(var(--canopy))",
foreground: "hsl(var(--canopy-foreground))",
accent: "hsl(var(--canopy-accent))",
"accent-foreground": "hsl(var(--canopy-accent-foreground))",
border: "hsl(var(--canopy-border))",
},
dew: "hsl(var(--dew))",
ember: "hsl(var(--ember))",
},
fontFamily: {
display: ["var(--font-display)", "ui-serif", "Georgia", "serif"],
sans: ["var(--font-sans)", "ui-sans-serif", "system-ui", "sans-serif"],
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
wiggle: {
"0%, 100%": { transform: "translateX(0)" },
"25%": { transform: "translateX(-5px)" },
"50%": { transform: "translateX(5px)" },
"75%": { transform: "translateX(-3px)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
wiggle: "wiggle 0.4s ease-in-out",
},
},
},
// eslint-disable-next-line @typescript-eslint/no-require-imports
plugins: [require("tailwindcss-animate")],
};
export default config;

22
tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "prisma/seed.ts"]
}

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
globals: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});