v0.29.0 — Ramble box, dark-mode toggle, pantry pull reasons
- #10 Ramble box: new /ramble sticky-note board (post/pin/archive/delete), ported from FMB. New Ramble model + migration, /api/rambles routes, nav entry. - #6 Dark-mode toggle: sun/moon button in the top bar (dark palette already existed in CSS, just had no control). - #9 Pantry pull reason: structured disposition (Immediate use / Preserved / Gave away / Waste) on ItemLog.reason, added to single + bulk pull dialogs and the audit payload. New migration. - #2 Suggestions: drop the stale white-card wrapper; render SuggestionsPanel bare like FMB so it's readable and thumbnails show. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "ItemLog" ADD COLUMN "reason" TEXT;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Ramble" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"body" TEXT NOT NULL,
|
||||||
|
"pinned" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"archivedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Ramble_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Ramble_pinned_archivedAt_createdAt_idx" ON "Ramble"("pinned", "archivedAt", "createdAt");
|
||||||
@@ -72,6 +72,20 @@ model AuditEvent {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ramble box — sticky-note brain dump for stray homestead thoughts ("move the
|
||||||
|
// rhubarb", "oil the chainsaw"). Not a Task and not a Location leaf; just a
|
||||||
|
// free-text jot you pin, archive (soft), or delete (hard). Ported from FMB.
|
||||||
|
model Ramble {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
body String
|
||||||
|
pinned Boolean @default(false)
|
||||||
|
archivedAt DateTime? // soft-archive; null = active
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([pinned, archivedAt, createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Location — shared hierarchical place tree (garden areas, buildings, rooms,
|
// Location — shared hierarchical place tree (garden areas, buildings, rooms,
|
||||||
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
|
// storage bins). The spine for Garden, Inventory, and Pantry. Plants/items/
|
||||||
@@ -437,6 +451,7 @@ model ItemLog {
|
|||||||
notes String?
|
notes String?
|
||||||
cost Decimal? @db.Decimal(10, 2)
|
cost Decimal? @db.Decimal(10, 2)
|
||||||
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
|
amount Decimal? @db.Decimal(10, 2) // quantity consumed, for USED logs (same unit as the item)
|
||||||
|
reason String? // disposition for USED logs: consumed | preserved | gifted | waste (see pantry/core.ts)
|
||||||
actorId String?
|
actorId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
|||||||
41
src/app/(dashboard)/ramble/page.tsx
Normal file
41
src/app/(dashboard)/ramble/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSession } from "@/lib/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { RambleClient } from "./ramble-client";
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Ramble" };
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function RamblePage() {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const [active, archived] = await Promise.all([
|
||||||
|
db.ramble.findMany({
|
||||||
|
where: { archivedAt: null },
|
||||||
|
orderBy: [{ pinned: "desc" }, { createdAt: "desc" }],
|
||||||
|
}),
|
||||||
|
db.ramble.findMany({
|
||||||
|
where: { archivedAt: { not: null } },
|
||||||
|
orderBy: { archivedAt: "desc" },
|
||||||
|
take: 30,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Prisma Dates don't serialize across the server/client boundary — send ISO strings.
|
||||||
|
const serialize = (r: (typeof active)[number]) => ({
|
||||||
|
id: r.id,
|
||||||
|
body: r.body,
|
||||||
|
pinned: r.pinned,
|
||||||
|
archivedAt: r.archivedAt ? r.archivedAt.toISOString() : null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RambleClient
|
||||||
|
initialActive={active.map(serialize)}
|
||||||
|
initialArchived={archived.map(serialize)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
src/app/(dashboard)/ramble/ramble-client.tsx
Normal file
212
src/app/(dashboard)/ramble/ramble-client.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { Pin, PinOff, Archive, ArchiveRestore, Trash2, StickyNote } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
type Ramble = {
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
pinned: boolean;
|
||||||
|
archivedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rotating sticky-note colors (light + dark variants), keyed off note id so a
|
||||||
|
// note keeps its color across refreshes rather than reshuffling.
|
||||||
|
const STICKY = [
|
||||||
|
"bg-amber-50 border-amber-200 dark:bg-amber-950/40 dark:border-amber-900",
|
||||||
|
"bg-rose-50 border-rose-200 dark:bg-rose-950/40 dark:border-rose-900",
|
||||||
|
"bg-emerald-50 border-emerald-200 dark:bg-emerald-950/40 dark:border-emerald-900",
|
||||||
|
"bg-sky-50 border-sky-200 dark:bg-sky-950/40 dark:border-sky-900",
|
||||||
|
"bg-violet-50 border-violet-200 dark:bg-violet-950/40 dark:border-violet-900",
|
||||||
|
];
|
||||||
|
function stickyColor(id: string): string {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
|
return STICKY[h % STICKY.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RambleClient({
|
||||||
|
initialActive,
|
||||||
|
initialArchived,
|
||||||
|
}: {
|
||||||
|
initialActive: Ramble[];
|
||||||
|
initialArchived: Ramble[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [posting, setPosting] = useState(false);
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
|
||||||
|
const active = initialActive;
|
||||||
|
const archived = initialArchived;
|
||||||
|
|
||||||
|
async function post(pinned: boolean) {
|
||||||
|
const text = body.trim();
|
||||||
|
if (!text) return;
|
||||||
|
setPosting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/rambles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ body: text, pinned }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error ?? "Failed to save");
|
||||||
|
setBody("");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: "Couldn't save", description: String(err).replace("Error: ", "") });
|
||||||
|
} finally {
|
||||||
|
setPosting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mutate(id: string, patch: Record<string, unknown>) {
|
||||||
|
const res = await fetch(`/api/rambles/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: "Couldn't update", description: (await res.json()).error ?? "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!window.confirm("Delete this note? This can't be undone.")) return;
|
||||||
|
const res = await fetch(`/api/rambles/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: "Couldn't delete", description: (await res.json()).error ?? "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-2xl font-semibold flex items-center gap-2">
|
||||||
|
<StickyNote className="h-6 w-6 text-[hsl(var(--leaf))]" />
|
||||||
|
Ramble
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
A place to dump stray thoughts — chores, ideas, reminders that aren't tasks yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||||
|
<Textarea
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") post(false);
|
||||||
|
}}
|
||||||
|
placeholder="What's on your mind? (⌘/Ctrl+Enter to post)"
|
||||||
|
rows={3}
|
||||||
|
className="resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => post(true)} disabled={posting || !body.trim()}>
|
||||||
|
<Pin className="h-4 w-4" /> Post pinned
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => post(false)} disabled={posting || !body.trim()}>
|
||||||
|
{posting ? "Posting…" : "Post"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active notes */}
|
||||||
|
{active.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-10">
|
||||||
|
No notes yet. Jot something above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{active.map((r) => (
|
||||||
|
<NoteCard key={r.id} r={r} onMutate={mutate} onRemove={remove} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Archived */}
|
||||||
|
{archived.length > 0 && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowArchived((v) => !v)}
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{showArchived ? "Hide" : "Show"} archived ({archived.length})
|
||||||
|
</button>
|
||||||
|
{showArchived && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 mt-3 opacity-70">
|
||||||
|
{archived.map((r) => (
|
||||||
|
<NoteCard key={r.id} r={r} archivedView onMutate={mutate} onRemove={remove} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteCard({
|
||||||
|
r,
|
||||||
|
archivedView,
|
||||||
|
onMutate,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
r: Ramble;
|
||||||
|
archivedView?: boolean;
|
||||||
|
onMutate: (id: string, patch: Record<string, unknown>) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("group relative rounded-lg border p-3 shadow-sm", stickyColor(r.id))}>
|
||||||
|
{r.pinned && !archivedView && (
|
||||||
|
<Pin className="absolute right-2 top-2 h-3.5 w-3.5 text-[hsl(var(--leaf))] fill-current" />
|
||||||
|
)}
|
||||||
|
<p className="text-sm whitespace-pre-wrap break-words pr-4 min-h-[2rem]">{r.body}</p>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatDistanceToNow(new Date(r.createdAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{!archivedView && (
|
||||||
|
<button
|
||||||
|
title={r.pinned ? "Unpin" : "Pin"}
|
||||||
|
onClick={() => onMutate(r.id, { pinned: !r.pinned })}
|
||||||
|
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{r.pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
title={archivedView ? "Restore" : "Archive"}
|
||||||
|
onClick={() => onMutate(r.id, { archived: !archivedView })}
|
||||||
|
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{archivedView ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => onRemove(r.id)}
|
||||||
|
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 text-[hsl(var(--ember))]"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,12 +9,8 @@ export const dynamic = "force-dynamic";
|
|||||||
export default async function SuggestionsPage() {
|
export default async function SuggestionsPage() {
|
||||||
const session = await getSession();
|
const session = await getSession();
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
// The panel uses light-gray (neutral-*) text that assumes a white backdrop, so
|
// Render the panel bare (matches FMB). Earlier versions needed a white-card
|
||||||
// render it on a white card for readable contrast in light or dark theme.
|
// wrapper for contrast, but the current @otm/account-panel is theme-aware —
|
||||||
// color-scheme:light keeps its native inputs/textarea light-filled too.
|
// the wrapper was fighting its own styles and hiding image thumbnails.
|
||||||
return (
|
return <SuggestionsPanel />;
|
||||||
<div className="rounded-lg border bg-white text-neutral-900 p-4 md:p-6 shadow-sm [color-scheme:light]">
|
|
||||||
<SuggestionsPanel />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/app/api/rambles/[id]/route.ts
Normal file
65
src/app/api/rambles/[id]/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const patchSchema = z.object({
|
||||||
|
body: z.string().trim().min(1).max(5000).optional(),
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
|
archived: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/rambles/:id — edit body, pin/unpin, or archive/restore.
|
||||||
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let input;
|
||||||
|
try {
|
||||||
|
input = patchSchema.parse(await req.json());
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof z.ZodError ? err.errors[0].message : "Invalid input";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: { body?: string; pinned?: boolean; archivedAt?: Date | null } = {};
|
||||||
|
if (input.body !== undefined) data.body = input.body;
|
||||||
|
if (input.pinned !== undefined) data.pinned = input.pinned;
|
||||||
|
if (input.archived !== undefined) data.archivedAt = input.archived ? new Date() : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ramble = await db.ramble.update({ where: { id: params.id }, data });
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "ramble.update", entityId: ramble.id, actorId: session.user.id, payload: input },
|
||||||
|
});
|
||||||
|
return NextResponse.json(ramble);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/rambles/:id — hard delete (a ramble is disposable by design).
|
||||||
|
export async function DELETE(_req: Request, { params }: { params: { id: string } }) {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.ramble.delete({ where: { id: params.id } });
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "ramble.delete", entityId: params.id, actorId: session.user.id },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/api/rambles/route.ts
Normal file
38
src/app/api/rambles/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { requireAuth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const createSchema = z.object({
|
||||||
|
body: z.string().trim().min(1, "Write something first.").max(5000),
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/rambles — jot a new note.
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireAuth();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let input;
|
||||||
|
try {
|
||||||
|
input = createSchema.parse(await req.json());
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof z.ZodError ? err.errors[0].message : "Invalid input";
|
||||||
|
return NextResponse.json({ error: msg }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ramble = await db.ramble.create({
|
||||||
|
data: { body: input.body, pinned: input.pinned ?? false },
|
||||||
|
});
|
||||||
|
await db.auditEvent.create({
|
||||||
|
data: { action: "ramble.create", entityId: ramble.id, actorId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(ramble, { status: 201 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell,
|
LayoutDashboard, Leaf, BookOpen, Package, PawPrint, MapPin, Bell,
|
||||||
Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, CalendarDays,
|
Lightbulb, KeyRound, HardDrive, RefreshCw, UtensilsCrossed, CalendarDays,
|
||||||
|
StickyNote,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export const navItems: NavItem[] = [
|
|||||||
{ label: "Locations", href: "/locations", icon: MapPin },
|
{ label: "Locations", href: "/locations", icon: MapPin },
|
||||||
{ label: "Reminders", href: "/tasks", icon: Bell },
|
{ label: "Reminders", href: "/tasks", icon: Bell },
|
||||||
{ label: "Calendar", href: "/calendar", icon: CalendarDays },
|
{ label: "Calendar", href: "/calendar", icon: CalendarDays },
|
||||||
|
{ label: "Ramble", href: "/ramble", icon: StickyNote },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const kitchenItems: NavItem[] = [
|
export const kitchenItems: NavItem[] = [
|
||||||
|
|||||||
29
src/components/layout/theme-toggle.tsx
Normal file
29
src/components/layout/theme-toggle.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Sun, Moon } from "lucide-react";
|
||||||
|
|
||||||
|
// Light ↔ dark toggle. The dark palette already lives in globals.css (.dark);
|
||||||
|
// this just gives it a control. The icon shows the mood you'll switch *to*.
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
// Avoid a hydration mismatch: theme isn't known until mounted on the client.
|
||||||
|
if (!mounted) return <div className="h-9 w-9" aria-hidden />;
|
||||||
|
|
||||||
|
const isDark = resolvedTheme === "dark";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
|
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
className="p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
|
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||||
import { initials } from "@/lib/utils";
|
import { initials } from "@/lib/utils";
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
@@ -27,6 +28,8 @@ export function TopNav({ user }: TopNavProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/suggestions"
|
href="/suggestions"
|
||||||
aria-label="Suggestions & feedback"
|
aria-label="Suggestions & feedback"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
|
||||||
import type { PantryItem } from "./pantry-client";
|
import type { PantryItem } from "./pantry-client";
|
||||||
|
|
||||||
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
|
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
|
||||||
@@ -17,6 +18,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [pulls, setPulls] = useState<Record<string, Pull>>({});
|
const [pulls, setPulls] = useState<Record<string, Pull>>({});
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
|
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
|
||||||
const [selectedReason, setSelectedReason] = useState<string | null>(null);
|
const [selectedReason, setSelectedReason] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -51,6 +53,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
|
|||||||
setSearch("");
|
setSearch("");
|
||||||
setPulls({});
|
setPulls({});
|
||||||
setReason("");
|
setReason("");
|
||||||
|
setDisposition("consumed");
|
||||||
setSelectedReason(null);
|
setSelectedReason(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
@@ -78,6 +81,7 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pulls: activePulls.map(([id, p]) => ({ id, amount: parseFloat(p.amount) })),
|
pulls: activePulls.map(([id, p]) => ({ id, amount: parseFloat(p.amount) })),
|
||||||
notes: reason.trim() || null,
|
notes: reason.trim() || null,
|
||||||
|
reason: disposition,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -122,6 +126,17 @@ export function PullDialog({ items, onSuccess }: { items: PantryItem[]; onSucces
|
|||||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
|
||||||
{/* Reason */}
|
{/* Reason */}
|
||||||
<div className="px-6 pt-4 pb-3 border-b space-y-2 shrink-0">
|
<div className="px-6 pt-4 pb-3 border-b space-y-2 shrink-0">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{PANTRY_USE_REASONS.map(r => (
|
||||||
|
<button key={r.value} type="button" onClick={() => setDisposition(r.value)}
|
||||||
|
className={cn("rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||||
|
disposition === r.value ? "bg-primary text-primary-foreground border-primary" : "bg-background hover:bg-accent"
|
||||||
|
)}>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Label className="text-xs text-muted-foreground">What's it for? (optional)</Label>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{QUICK_REASONS.map(r => (
|
{QUICK_REASONS.map(r => (
|
||||||
<button key={r} type="button" onClick={() => pickReason(r)}
|
<button key={r} type="button" onClick={() => pickReason(r)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { PANTRY_USE_REASONS, type PantryUseReason } from "@/lib/pantry/core";
|
||||||
import type { PantryItem } from "./pantry-client";
|
import type { PantryItem } from "./pantry-client";
|
||||||
|
|
||||||
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
|
const QUICK_REASONS = ["Family dinner", "Dinner party", "Taking on a trip", "Meal prep", "Gave it away"];
|
||||||
@@ -14,6 +15,7 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [amount, setAmount] = useState("1");
|
const [amount, setAmount] = useState("1");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [disposition, setDisposition] = useState<PantryUseReason>("consumed");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedReason, setSelectedReason] = useState<string | null>(null);
|
const [selectedReason, setSelectedReason] = useState<string | null>(null);
|
||||||
@@ -46,12 +48,13 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
|
|||||||
const res = await fetch(`/api/v1/pantry/${item.id}/use`, {
|
const res = await fetch(`/api/v1/pantry/${item.id}/use`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null }),
|
body: JSON.stringify({ amount: parseFloat(amount), notes: notes.trim() || null, reason: disposition }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
if (!res.ok) throw new Error((await res.json()).error ?? "Failed");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setAmount("1");
|
setAmount("1");
|
||||||
setNotes("");
|
setNotes("");
|
||||||
|
setDisposition("consumed");
|
||||||
setSelectedReason(null);
|
setSelectedReason(null);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -78,6 +81,26 @@ export function UseItemDialog({ item, onSuccess }: { item: PantryItem; onSuccess
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Currently: <strong>{item.quantity} {item.unit ?? ""}</strong>
|
Currently: <strong>{item.quantity} {item.unit ?? ""}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Reason</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{PANTRY_USE_REASONS.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDisposition(r.value)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3 py-1 text-xs transition-colors",
|
||||||
|
disposition === r.value
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "bg-background hover:bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>How much are you using? ({item.unit ?? "units"})</Label>
|
<Label>How much are you using? ({item.unit ?? "units"})</Label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Zod schemas shared by the REST API and (later) the MCP tool definitions, so
|
// Zod schemas shared by the REST API and (later) the MCP tool definitions, so
|
||||||
// validation lives in exactly one place.
|
// validation lives in exactly one place.
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { PANTRY_USE_REASON_VALUES } from "@/lib/pantry/core";
|
||||||
|
|
||||||
export const plantLogInput = z.object({
|
export const plantLogInput = z.object({
|
||||||
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
type: z.enum(["OBSERVATION", "CARE", "HARVEST", "TREATMENT", "TRANSPLANT", "DIED"]),
|
||||||
@@ -101,11 +102,13 @@ export const pantryUpdateInput = pantryCreateInput.partial();
|
|||||||
export const pantryUseInput = z.object({
|
export const pantryUseInput = z.object({
|
||||||
amount: z.coerce.number().positive(),
|
amount: z.coerce.number().positive(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
|
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pantryBulkUseInput = z.object({
|
export const pantryBulkUseInput = z.object({
|
||||||
pulls: z.array(z.object({ id: z.string(), amount: z.coerce.number().positive() })).min(1),
|
pulls: z.array(z.object({ id: z.string(), amount: z.coerce.number().positive() })).min(1),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
|
reason: z.enum(PANTRY_USE_REASON_VALUES).nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type PantryCreateInput = z.infer<typeof pantryCreateInput>;
|
export type PantryCreateInput = z.infer<typeof pantryCreateInput>;
|
||||||
|
|||||||
@@ -8,6 +8,16 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.29.0",
|
||||||
|
date: "2026-07-12",
|
||||||
|
changes: [
|
||||||
|
"New Ramble page — a sticky-note board for dumping stray thoughts and ideas that aren't tasks yet. Pin the important ones, archive the ones you're done with.",
|
||||||
|
"Dark mode is back: a sun/moon button in the top bar flips the whole app between light and dark.",
|
||||||
|
"When you pull something from the pantry you can now say why — Immediate use, Preserved, Gave away, or Waste — so we can actually see what's getting eaten vs. thrown out.",
|
||||||
|
"Cleaned up the Suggestions page so it's readable and photo thumbnails show up.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.28.1",
|
version: "0.28.1",
|
||||||
date: "2026-07-10",
|
date: "2026-07-10",
|
||||||
|
|||||||
@@ -2,6 +2,23 @@
|
|||||||
// planning. No DB access; the service layer (src/lib/services/pantry.ts)
|
// planning. No DB access; the service layer (src/lib/services/pantry.ts)
|
||||||
// applies these against Prisma.
|
// applies these against Prisma.
|
||||||
|
|
||||||
|
// Disposition for a pantry pull — WHY it left inventory, kept structured so
|
||||||
|
// waste vs. use is queryable (don't fold this into freeform notes). Stored as
|
||||||
|
// the slug string on ItemLog.reason; null for old rows and API pulls that omit it.
|
||||||
|
export const PANTRY_USE_REASONS = [
|
||||||
|
{ value: "consumed", label: "Immediate use" },
|
||||||
|
{ value: "preserved", label: "Preserved / stored" },
|
||||||
|
{ value: "gifted", label: "Gave away" },
|
||||||
|
{ value: "waste", label: "Waste / spoiled" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PantryUseReason = (typeof PANTRY_USE_REASONS)[number]["value"];
|
||||||
|
|
||||||
|
export const PANTRY_USE_REASON_VALUES = PANTRY_USE_REASONS.map(r => r.value) as [
|
||||||
|
PantryUseReason,
|
||||||
|
...PantryUseReason[],
|
||||||
|
];
|
||||||
|
|
||||||
/** Trim, drop empties, and dedupe a raw tag list (first occurrence wins). */
|
/** Trim, drop empties, and dedupe a raw tag list (first occurrence wins). */
|
||||||
export function normalizeTags(tags: string[]): string[] {
|
export function normalizeTags(tags: string[]): string[] {
|
||||||
return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
|
return Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export async function usePantryItem(
|
|||||||
const result = await bulkUsePantry(ctx, {
|
const result = await bulkUsePantry(ctx, {
|
||||||
pulls: [{ id, amount: input.amount }],
|
pulls: [{ id, amount: input.amount }],
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
|
reason: input.reason ?? null,
|
||||||
});
|
});
|
||||||
if (result.skipped.length) throw new ApiError(404, "Pantry item not found");
|
if (result.skipped.length) throw new ApiError(404, "Pantry item not found");
|
||||||
return { remaining: result.results[0].remaining };
|
return { remaining: result.results[0].remaining };
|
||||||
@@ -183,6 +184,7 @@ export async function bulkUsePantry(
|
|||||||
): Promise<BulkUseResult> {
|
): Promise<BulkUseResult> {
|
||||||
const ids = input.pulls.map(p => p.id);
|
const ids = input.pulls.map(p => p.id);
|
||||||
const notes = input.notes?.trim() || null;
|
const notes = input.notes?.trim() || null;
|
||||||
|
const reason = input.reason ?? null;
|
||||||
|
|
||||||
return db.$transaction(async tx => {
|
return db.$transaction(async tx => {
|
||||||
const items = await tx.item.findMany({
|
const items = await tx.item.findMany({
|
||||||
@@ -203,6 +205,7 @@ export async function bulkUsePantry(
|
|||||||
type: "USED",
|
type: "USED",
|
||||||
amount: u.amount,
|
amount: u.amount,
|
||||||
notes,
|
notes,
|
||||||
|
reason,
|
||||||
actorId: ctx.userId,
|
actorId: ctx.userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -216,6 +219,7 @@ export async function bulkUsePantry(
|
|||||||
pulls: plan.updates.map(u => ({ id: u.id, name: u.name, amount: u.amount })),
|
pulls: plan.updates.map(u => ({ id: u.id, name: u.name, amount: u.amount })),
|
||||||
skipped: plan.skipped,
|
skipped: plan.skipped,
|
||||||
notes,
|
notes,
|
||||||
|
reason,
|
||||||
via: ctx.via,
|
via: ctx.via,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||||
export const APP_VERSION = "0.28.1";
|
export const APP_VERSION = "0.29.0";
|
||||||
|
|||||||
Reference in New Issue
Block a user