v0.20.0 — Pantry: multi-tag items (e.g. Pre-cooked + Poultry)
Uses existing Label/LabelOnItem tables — no schema change. Tags field in the add/edit dialog: type + Enter/comma to add, backspace to remove, autocomplete from previously-used labels. Tags display as chips on each item row. API: POST/PATCH accept tags[], upsert Label rows, sync LabelOnItem. Catalog endpoint also returns all label names for autocomplete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ export default async function PantryPage() {
|
|||||||
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
||||||
location: { select: { id: true, name: true } },
|
location: { select: { id: true, name: true } },
|
||||||
plant: { select: { id: true, commonName: true, variety: true } },
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
|
labels: { select: { label: { select: { name: true } } } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.location.findMany({
|
db.location.findMany({
|
||||||
@@ -50,6 +51,7 @@ export default async function PantryPage() {
|
|||||||
locationName: i.location?.name ?? null,
|
locationName: i.location?.name ?? null,
|
||||||
plantId: i.plant?.id ?? null,
|
plantId: i.plant?.id ?? null,
|
||||||
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
|
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
|
||||||
|
tags: i.labels.map((l: { label: { name: string } }) => l.label.name),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return <PantryClient initialItems={serialized} locations={locations} plants={plants} />;
|
return <PantryClient initialItems={serialized} locations={locations} plants={plants} />;
|
||||||
|
|||||||
@@ -4,6 +4,29 @@ import { ItemSource } from "@prisma/client";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { requireAuth, isAdmin } from "@/lib/auth";
|
import { requireAuth, isAdmin } from "@/lib/auth";
|
||||||
|
|
||||||
|
const PANTRY_SELECT = {
|
||||||
|
id: true, name: true, description: true, quantity: true, unit: true,
|
||||||
|
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
||||||
|
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
||||||
|
location: { select: { id: true, name: true } },
|
||||||
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
|
labels: { select: { label: { select: { id: true, name: true } } } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
async function syncTags(itemId: string, tags: string[]) {
|
||||||
|
const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
|
||||||
|
const labels = await Promise.all(
|
||||||
|
names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }))
|
||||||
|
);
|
||||||
|
await db.labelOnItem.deleteMany({ where: { itemId } });
|
||||||
|
if (labels.length) {
|
||||||
|
await db.labelOnItem.createMany({
|
||||||
|
data: labels.map(l => ({ itemId, labelId: l.id })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateSchema = z.object({
|
const updateSchema = z.object({
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
description: z.string().nullable().optional(),
|
description: z.string().nullable().optional(),
|
||||||
@@ -20,6 +43,7 @@ const updateSchema = z.object({
|
|||||||
pricePaid: z.number().nullable().optional(),
|
pricePaid: z.number().nullable().optional(),
|
||||||
priceUnit: z.string().nullable().optional(),
|
priceUnit: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
|
||||||
@@ -27,7 +51,7 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
|||||||
const session = await requireAuth();
|
const session = await requireAuth();
|
||||||
const data = updateSchema.parse(await req.json());
|
const data = updateSchema.parse(await req.json());
|
||||||
|
|
||||||
const item = await db.item.update({
|
await db.item.update({
|
||||||
where: { id: params.id },
|
where: { id: params.id },
|
||||||
data: {
|
data: {
|
||||||
...(data.name !== undefined && { name: data.name }),
|
...(data.name !== undefined && { name: data.name }),
|
||||||
@@ -45,20 +69,22 @@ export async function PATCH(req: Request, { params }: { params: { id: string } }
|
|||||||
...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? null }),
|
...(data.priceUnit !== undefined && { priceUnit: data.priceUnit ?? null }),
|
||||||
...(data.notes !== undefined && { notes: data.notes ?? null }),
|
...(data.notes !== undefined && { notes: data.notes ?? null }),
|
||||||
},
|
},
|
||||||
select: {
|
|
||||||
id: true, name: true, description: true, quantity: true, unit: true,
|
|
||||||
storedAt: true, bestBefore: true, source: true, plantId: true, notes: true,
|
|
||||||
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
|
||||||
location: { select: { id: true, name: true } },
|
|
||||||
plant: { select: { id: true, commonName: true, variety: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.tags !== undefined) await syncTags(params.id, data.tags);
|
||||||
|
|
||||||
|
const item = await db.item.findUniqueOrThrow({ where: { id: params.id }, select: PANTRY_SELECT });
|
||||||
|
|
||||||
await db.auditEvent.create({
|
await db.auditEvent.create({
|
||||||
data: { action: "pantry.update", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
|
data: { action: "pantry.update", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(item);
|
return NextResponse.json({
|
||||||
|
...item,
|
||||||
|
quantity: Number(item.quantity),
|
||||||
|
pricePaid: item.pricePaid != null ? Number(item.pricePaid) : null,
|
||||||
|
tags: item.labels.map(l => l.label.name),
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|||||||
@@ -32,12 +32,15 @@ export async function GET(req: Request) {
|
|||||||
if (!seen.has(key)) seen.set(key, item);
|
if (!seen.has(key)) seen.set(key, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also return distinct categories/subcategories for autocomplete dropdowns.
|
// Distinct categories/subcategories and all label names for autocomplete.
|
||||||
const allCats = await db.item.findMany({
|
const [allCats, allLabels] = await Promise.all([
|
||||||
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
|
db.item.findMany({
|
||||||
select: { pantryCategory: true, pantrySubcategory: true },
|
where: { kind: "CONSUMABLE", pantryCategory: { not: null } },
|
||||||
distinct: ["pantryCategory", "pantrySubcategory"],
|
select: { pantryCategory: true, pantrySubcategory: true },
|
||||||
});
|
distinct: ["pantryCategory", "pantrySubcategory"],
|
||||||
|
}),
|
||||||
|
db.label.findMany({ select: { name: true }, orderBy: { name: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
|
const categories = Array.from(new Set(allCats.map(c => c.pantryCategory).filter(Boolean))) as string[];
|
||||||
const subcategories = allCats
|
const subcategories = allCats
|
||||||
@@ -60,5 +63,6 @@ export async function GET(req: Request) {
|
|||||||
})),
|
})),
|
||||||
categories,
|
categories,
|
||||||
subcategories,
|
subcategories,
|
||||||
|
labels: allLabels.map(l => l.name),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const PANTRY_SELECT = {
|
|||||||
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
pantryCategory: true, pantrySubcategory: true, pricePaid: true, priceUnit: true,
|
||||||
location: { select: { id: true, name: true } },
|
location: { select: { id: true, name: true } },
|
||||||
plant: { select: { id: true, commonName: true, variety: true } },
|
plant: { select: { id: true, commonName: true, variety: true } },
|
||||||
|
labels: { select: { label: { select: { id: true, name: true } } } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function serialize(i: any) {
|
function serialize(i: any) {
|
||||||
@@ -27,9 +28,25 @@ function serialize(i: any) {
|
|||||||
locationName: i.location?.name ?? null,
|
locationName: i.location?.name ?? null,
|
||||||
plantId: i.plant?.id ?? null,
|
plantId: i.plant?.id ?? null,
|
||||||
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
|
plantName: i.plant ? `${i.plant.commonName}${i.plant.variety ? ` (${i.plant.variety})` : ""}` : null,
|
||||||
|
tags: (i.labels ?? []).map((l: any) => l.label.name) as string[],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncTags(itemId: string, tags: string[]) {
|
||||||
|
const names = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean)));
|
||||||
|
// Upsert each label, then replace all LabelOnItem rows for this item.
|
||||||
|
const labels = await Promise.all(
|
||||||
|
names.map(name => db.label.upsert({ where: { name }, update: {}, create: { name }, select: { id: true } }))
|
||||||
|
);
|
||||||
|
await db.labelOnItem.deleteMany({ where: { itemId } });
|
||||||
|
if (labels.length) {
|
||||||
|
await db.labelOnItem.createMany({
|
||||||
|
data: labels.map(l => ({ itemId, labelId: l.id })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
await requireAuth();
|
await requireAuth();
|
||||||
const items = await db.item.findMany({
|
const items = await db.item.findMany({
|
||||||
@@ -56,6 +73,7 @@ const createSchema = z.object({
|
|||||||
pricePaid: z.number().nullable().optional(),
|
pricePaid: z.number().nullable().optional(),
|
||||||
priceUnit: z.string().nullable().optional(),
|
priceUnit: z.string().nullable().optional(),
|
||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -84,11 +102,15 @@ export async function POST(req: Request) {
|
|||||||
select: PANTRY_SELECT,
|
select: PANTRY_SELECT,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.tags?.length) await syncTags(item.id, data.tags);
|
||||||
|
|
||||||
await db.auditEvent.create({
|
await db.auditEvent.create({
|
||||||
data: { action: "pantry.create", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
|
data: { action: "pantry.create", entityId: item.id, actorId: session.user.id, payload: { name: item.name } },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(serialize(item), { status: 201 });
|
// Re-fetch with labels
|
||||||
|
const full = await db.item.findUniqueOrThrow({ where: { id: item.id }, select: PANTRY_SELECT });
|
||||||
|
return NextResponse.json(serialize(full), { status: 201 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
if (err instanceof z.ZodError) return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
||||||
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (err instanceof Error && err.message === "Unauthorized") return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type PantryItem = {
|
|||||||
pricePaid: number | null;
|
pricePaid: number | null;
|
||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
tags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LocationOption = { id: string; name: string; path: string | null };
|
export type LocationOption = { id: string; name: string; path: string | null };
|
||||||
@@ -71,6 +72,13 @@ function ItemRow({ item, locations, plants, onRefresh }: {
|
|||||||
{item.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>}
|
{item.storedAt && <span className="text-xs text-muted-foreground">Stored {new Date(item.storedAt).toLocaleDateString()}</span>}
|
||||||
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
|
{item.bestBefore && <ExpiryBadge date={item.bestBefore} />}
|
||||||
</div>
|
</div>
|
||||||
|
{item.tags?.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{item.tags.map(tag => (
|
||||||
|
<span key={tag} className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{item.notes && <p className="text-xs text-muted-foreground mt-0.5 italic">{item.notes}</p>}
|
{item.notes && <p className="text-xs text-muted-foreground mt-0.5 italic">{item.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type CatalogData = {
|
|||||||
suggestions: Suggestion[];
|
suggestions: Suggestion[];
|
||||||
categories: string[];
|
categories: string[];
|
||||||
subcategories: { category: string; subcategory: string }[];
|
subcategories: { category: string; subcategory: string }[];
|
||||||
|
labels: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Form = {
|
type Form = {
|
||||||
@@ -74,7 +75,12 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
const [nameSuggestions, setNameSuggestions] = useState<Suggestion[]>([]);
|
const [nameSuggestions, setNameSuggestions] = useState<Suggestion[]>([]);
|
||||||
const [showDropdown, setShowDropdown] = useState(false);
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
const [autofillBanner, setAutofillBanner] = useState<Suggestion | null>(null);
|
const [autofillBanner, setAutofillBanner] = useState<Suggestion | null>(null);
|
||||||
|
const [tags, setTags] = useState<string[]>(item?.tags ?? []);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [tagSuggestions, setTagSuggestions] = useState<string[]>([]);
|
||||||
|
const [showTagDropdown, setShowTagDropdown] = useState(false);
|
||||||
const nameRef = useRef<HTMLInputElement>(null);
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const blankForm = (): Form => ({
|
const blankForm = (): Form => ({
|
||||||
@@ -100,6 +106,35 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
setForm(f => ({ ...f, [key]: val }));
|
setForm(f => ({ ...f, [key]: val }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addTag(name: string) {
|
||||||
|
const t = name.trim();
|
||||||
|
if (!t || tags.includes(t)) return;
|
||||||
|
setTags(prev => [...prev, t]);
|
||||||
|
setTagInput("");
|
||||||
|
setShowTagDropdown(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTag(name: string) {
|
||||||
|
setTags(prev => prev.filter(t => t !== name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTagKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if ((e.key === "Enter" || e.key === ",") && tagInput.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag(tagInput);
|
||||||
|
} else if (e.key === "Backspace" && !tagInput && tags.length) {
|
||||||
|
removeTag(tags[tags.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset tag state when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setTags(item?.tags ?? []);
|
||||||
|
setTagInput("");
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// Load catalog on open (for name autocomplete suggestions)
|
// Load catalog on open (for name autocomplete suggestions)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -109,6 +144,15 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Tag autocomplete from catalog labels
|
||||||
|
useEffect(() => {
|
||||||
|
const q = tagInput.trim().toLowerCase();
|
||||||
|
if (!q || !catalog?.labels) { setTagSuggestions([]); setShowTagDropdown(false); return; }
|
||||||
|
const matches = catalog.labels.filter(l => l.toLowerCase().includes(q) && !tags.includes(l));
|
||||||
|
setTagSuggestions(matches.slice(0, 6));
|
||||||
|
setShowTagDropdown(matches.length > 0 || q.length > 0);
|
||||||
|
}, [tagInput, catalog, tags]);
|
||||||
|
|
||||||
// Debounced name search for autocomplete
|
// Debounced name search for autocomplete
|
||||||
function handleNameChange(val: string) {
|
function handleNameChange(val: string) {
|
||||||
set("name", val);
|
set("name", val);
|
||||||
@@ -186,6 +230,7 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
priceUnit: form.priceUnit || null,
|
priceUnit: form.priceUnit || null,
|
||||||
notes: form.notes.trim() || null,
|
notes: form.notes.trim() || null,
|
||||||
kind: "CONSUMABLE",
|
kind: "CONSUMABLE",
|
||||||
|
tags,
|
||||||
};
|
};
|
||||||
const res = await fetch(item ? `/api/pantry/${item.id}` : "/api/pantry", {
|
const res = await fetch(item ? `/api/pantry/${item.id}` : "/api/pantry", {
|
||||||
method: item ? "PATCH" : "POST",
|
method: item ? "PATCH" : "POST",
|
||||||
@@ -196,6 +241,8 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
setForm(blankForm());
|
setForm(blankForm());
|
||||||
setAutofillBanner(null);
|
setAutofillBanner(null);
|
||||||
|
setTags([]);
|
||||||
|
setTagInput("");
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err).replace("Error: ", ""));
|
setError(String(err).replace("Error: ", ""));
|
||||||
@@ -383,6 +430,49 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
|||||||
<textarea className={inputCls} rows={2} value={form.notes} onChange={e => set("notes", e.target.value)} placeholder="Any notes…" />
|
<textarea className={inputCls} rows={2} value={form.notes} onChange={e => set("notes", e.target.value)} placeholder="Any notes…" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tags — multi-label, e.g. "Pre-cooked" + "Poultry" */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Tags</Label>
|
||||||
|
<div className={`${inputCls} flex flex-wrap gap-1.5 min-h-[40px] cursor-text`} onClick={() => tagInputRef.current?.focus()}>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<span key={tag} className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary text-xs px-2 py-0.5">
|
||||||
|
{tag}
|
||||||
|
<button type="button" onClick={e => { e.stopPropagation(); removeTag(tag); }} className="hover:text-destructive">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<div className="relative flex-1 min-w-[120px]">
|
||||||
|
<input
|
||||||
|
ref={tagInputRef}
|
||||||
|
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder={tags.length ? "" : "e.g. Pre-cooked, Poultry…"}
|
||||||
|
value={tagInput}
|
||||||
|
onChange={e => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={handleTagKeyDown}
|
||||||
|
onBlur={() => setTimeout(() => setShowTagDropdown(false), 150)}
|
||||||
|
/>
|
||||||
|
{showTagDropdown && (
|
||||||
|
<div className="absolute z-50 left-0 top-full mt-1 bg-popover border rounded-md shadow-md overflow-hidden min-w-[160px]">
|
||||||
|
{tagSuggestions.map(s => (
|
||||||
|
<button key={s} type="button" onMouseDown={() => addTag(s)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent">
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{tagInput.trim() && !tagSuggestions.some(s => s.toLowerCase() === tagInput.trim().toLowerCase()) && (
|
||||||
|
<button type="button" onMouseDown={() => addTag(tagInput)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center gap-2 text-primary border-t">
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Add “{tagInput.trim()}”
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Press Enter or comma to add. Tag things multiple ways, like “Pre-cooked” and “Poultry”.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.20.0",
|
||||||
|
date: "2026-06-29",
|
||||||
|
changes: [
|
||||||
|
"Pantry: items can now have multiple tags — type them in the new Tags field when adding or editing an item (press Enter or comma to add each one, like \"Pre-cooked\" and \"Poultry\"). Tags show up as small chips on each item row. Previously-used tags autocomplete as you type.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.19.2",
|
version: "0.19.2",
|
||||||
date: "2026-06-29",
|
date: "2026-06-29",
|
||||||
|
|||||||
@@ -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.19.2";
|
export const APP_VERSION = "0.20.0";
|
||||||
|
|||||||
Reference in New Issue
Block a user