v0.1.0 — Moon Base initial scaffold: auth, garden plant registry + log
This commit is contained in:
85
src/app/(dashboard)/dashboard/page.tsx
Normal file
85
src/app/(dashboard)/dashboard/page.tsx
Normal 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's what'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>
|
||||
);
|
||||
}
|
||||
104
src/app/(dashboard)/garden/[id]/page.tsx
Normal file
104
src/app/(dashboard)/garden/[id]/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
97
src/app/(dashboard)/garden/page.tsx
Normal file
97
src/app/(dashboard)/garden/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/(dashboard)/layout.tsx
Normal file
23
src/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/api/auth/[...nextauth]/route.ts
Normal file
5
src/app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
55
src/app/api/plants/[id]/logs/route.ts
Normal file
55
src/app/api/plants/[id]/logs/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
57
src/app/api/plants/route.ts
Normal file
57
src/app/api/plants/route.ts
Normal 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
102
src/app/globals.css
Normal 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
42
src/app/layout.tsx
Normal 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
91
src/app/login/page.tsx
Normal 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 & 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
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
19
src/app/providers.tsx
Normal file
19
src/app/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user