Update features and dependencies

This commit is contained in:
Arnaud
2026-07-01 11:10:37 +02:00
parent 9d9dfb46c6
commit 8b57a3fd87
107 changed files with 14654 additions and 458 deletions
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Wand2, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
@@ -86,10 +87,16 @@ export function AdaptRecipeButton({
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Wand2 className="h-4 w-4" />
Adapt
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Wand2 className="h-4 w-4" />
</Button>
} />
<TooltipContent>Adapt</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogContent className="max-w-2xl">
@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { ShoppingCart, Loader2, Plus, Check } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
@@ -111,10 +112,16 @@ export function AddToShoppingListButton({
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<ShoppingCart className="h-4 w-4" />
Add to list
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<ShoppingCart className="h-4 w-4" />
</Button>
} />
<TooltipContent>Add to list</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
@@ -134,13 +141,13 @@ export function AddToShoppingListButton({
<Label className="shrink-0">Servings</Label>
<div className="flex items-center gap-2">
<Button
type="button" variant="outline" size="icon" className="h-7 w-7"
type="button" variant="ghost" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => Math.max(1, s - 1))}
disabled={servings <= 1}
></Button>
<span className="w-8 text-center font-medium">{servings}</span>
<Button
type="button" variant="outline" size="icon" className="h-7 w-7"
type="button" variant="ghost" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => s + 1)}
>+</Button>
</div>
@@ -206,7 +213,7 @@ export function AddToShoppingListButton({
<p className="text-xs text-muted-foreground">{items.length} ingredient{items.length !== 1 ? "s" : ""} will be added.</p>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={() => setOpen(false)} disabled={adding}>Cancel</Button>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={adding}>Cancel</Button>
<Button onClick={handleAdd} disabled={adding || loadingLists || (mode === "existing" && !selectedListId)}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? "Adding…" : "Add ingredients"}
@@ -2,7 +2,7 @@
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import { Sparkles, Loader2, Camera, Type, Upload, X } from "lucide-react";
import { Sparkles, Loader2, Camera, Type, Upload, X, Shuffle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -30,6 +30,24 @@ const LANGUAGES = [
{ code: "ar", label: "Arabic" },
];
const SURPRISE_PROMPTS = [
"A cozy one-pot dish with whatever is in my pantry on a rainy evening",
"A vibrant street food recipe from Southeast Asia",
"A classic French bistro dish reimagined as a weeknight dinner",
"A hearty plant-based bowl with bold spices",
"A 5-ingredient pasta with pantry staples",
"An unexpected fusion of Japanese and Mexican flavors",
"A light summer salad with fresh herbs and a citrus dressing",
"A slow-cooked braise that fills the house with aroma",
"A festive appetizer that looks impressive but is easy to make",
"A warming spiced soup from the Middle East",
"A crowd-pleasing comfort food with a twist",
"A 20-minute weeknight dinner using chicken thighs",
"A rich chocolate dessert with a molten center",
"A crispy baked dish that tastes deep-fried",
"A refreshing no-cook meal for hot summer days",
];
type Tab = "describe" | "photo";
type GeneratedRecipe = {
@@ -57,6 +75,7 @@ export function AiGenerateDialog({
// describe tab
const [prompt, setPrompt] = useState("");
const [language, setLanguage] = useState("en");
const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
// photo tab
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
@@ -99,7 +118,7 @@ export function AiGenerateDialog({
const res = await fetch("/api/v1/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: prompt.trim(), language }),
body: JSON.stringify({ prompt: prompt.trim(), language, difficulty: difficulty || undefined }),
});
if (!res.ok) {
const err = await res.json() as { error?: string };
@@ -110,7 +129,7 @@ export function AiGenerateDialog({
const saveRes = await fetch("/api/v1/recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true }),
body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true, language }),
});
if (!saveRes.ok) { toast.error("Failed to save generated recipe"); return; }
const saved = await saveRes.json() as { id: string };
@@ -188,7 +207,21 @@ export function AiGenerateDialog({
{tab === "describe" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ai-prompt">What do you want to cook?</Label>
<div className="flex items-center justify-between">
<Label htmlFor="ai-prompt">What do you want to cook?</Label>
<button
type="button"
onClick={() => {
const idx = Math.floor(Math.random() * SURPRISE_PROMPTS.length);
setPrompt(SURPRISE_PROMPTS[idx]!);
}}
disabled={busy}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors disabled:opacity-50"
>
<Shuffle className="h-3 w-3" />
Surprise me
</button>
</div>
<Textarea
id="ai-prompt"
value={prompt}
@@ -198,18 +231,34 @@ export function AiGenerateDialog({
disabled={busy}
/>
</div>
<div className="space-y-2">
<Label>Recipe language</Label>
<Select value={language} onValueChange={(v) => v && setLanguage(v)} disabled={busy}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.code} value={l.code}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Recipe language</Label>
<Select value={language} onValueChange={(v) => v && setLanguage(v)} disabled={busy}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((l) => (
<SelectItem key={l.code} value={l.code}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Difficulty</Label>
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
<SelectTrigger>
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any</SelectItem>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
@@ -267,7 +316,7 @@ export function AiGenerateDialog({
/>
{/* Actions */}
<div className="flex gap-2 justify-end pt-1">
<Button variant="outline" onClick={handleClose} disabled={busy}>
<Button variant="ghost" onClick={handleClose} disabled={busy}>
Cancel
</Button>
<Button
@@ -13,11 +13,13 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const router = useRouter();
@@ -38,30 +40,42 @@ export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete recipe?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The recipe and all its data will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => { void handleDelete(); }}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button
variant="ghost"
size="icon"
className={cn("text-destructive hover:text-destructive")}
onClick={() => setOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
} />
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete recipe?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The recipe and all its data will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => { void handleDelete(); }}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -5,6 +5,7 @@ import { Wine, Sparkles, Loader2, Coffee, Beer, GlassWater, Leaf } from "lucide-
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
@@ -75,10 +76,16 @@ export function DrinkPairingButton({ recipeId }: { recipeId: string }) {
return (
<>
<Button variant="outline" size="sm" onClick={handleOpen}>
<Wine className="h-4 w-4" />
Drinks
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={handleOpen}>
<Wine className="h-4 w-4" />
</Button>
} />
<TooltipContent>Drinks</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
@@ -142,7 +149,7 @@ export function DrinkPairingButton({ recipeId }: { recipeId: string }) {
);
})}
<Button variant="outline" className="w-full" onClick={suggest} disabled={loading}>
<Button variant="ghost" className="w-full" onClick={suggest} disabled={loading}>
<Sparkles className="h-4 w-4" />
Regenerate
</Button>
@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Sparkles, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
type GeneratedRecipe = {
ingredients: Array<{ rawName: string; quantity?: number; unit?: string; note?: string }>;
steps: Array<{ instruction: string; timerSeconds?: number }>;
};
export function GenerateContentButton({
recipeId,
title,
description,
}: {
recipeId: string;
title: string;
description?: string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function generate() {
setBusy(true);
try {
const prompt = description?.trim()
? `${title}: ${description.trim()}`
: title;
const genRes = await fetch("/api/v1/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!genRes.ok) {
const err = await genRes.json() as { error?: string };
toast.error(err.error ?? "Generation failed");
return;
}
const generated = await genRes.json() as GeneratedRecipe;
const saveRes = await fetch(`/api/v1/recipes/${recipeId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
ingredients: generated.ingredients.map((ing, i) => ({
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
order: i,
})),
steps: generated.steps.map((s, i) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: i,
})),
}),
});
if (!saveRes.ok) {
toast.error("Failed to save generated content");
return;
}
toast.success("Ingredients and steps generated!");
router.refresh();
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col items-center gap-3">
<FakeProgressBar active={busy} durationMs={10000} label={busy ? "Generating recipe content…" : undefined} />
<Button
variant="outline"
size="sm"
onClick={() => { void generate(); }}
disabled={busy}
className="gap-1.5"
>
{busy ? (
<><Loader2 className="h-4 w-4 animate-spin" />Generating</>
) : (
<><Sparkles className="h-4 w-4" />Generate with AI</>
)}
</Button>
</div>
);
}
@@ -6,6 +6,7 @@ import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { UtensilsCrossed, Sparkles, Loader2, ChefHat, Salad, Wine, Cake, Sandwich, Soup, Check } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
@@ -135,10 +136,16 @@ export function MealPairingButton({ recipeId }: { recipeId: string }) {
return (
<>
<Button variant="outline" size="sm" onClick={() => { setOpen(true); if (pairings.length === 0) suggest(); }}>
<UtensilsCrossed className="h-4 w-4" />
Pair meal
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => { setOpen(true); if (pairings.length === 0) suggest(); }}>
<UtensilsCrossed className="h-4 w-4" />
</Button>
} />
<TooltipContent>Pair meal</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-5xl max-h-[80vh] overflow-y-auto">
+12 -5
View File
@@ -2,17 +2,24 @@
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export function PrintButton({ recipeId }: { recipeId: string }) {
function handlePrint() {
const win = window.open(`/recipes/${recipeId}/print`, "_blank", "width=800,height=900");
const win = window.open(`/print/${recipeId}`, "_blank", "width=800,height=900");
win?.focus();
}
return (
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4" />
Print
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={handlePrint}>
<Printer className="h-4 w-4" />
</Button>
} />
<TooltipContent>Print</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,179 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { MessageCircle, Send, X, Bot, User } from "lucide-react";
import { cn } from "@/lib/utils";
type Message = {
role: "user" | "assistant";
content: string;
};
type Props = {
recipeId: string;
recipeTitle: string;
};
export function RecipeChatPanel({ recipeId, recipeTitle }: Props) {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open && bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages, open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const question = input.trim();
if (!question || loading) return;
setInput("");
setMessages((prev) => [...prev, { role: "user", content: question }]);
setLoading(true);
try {
const res = await fetch("/api/v1/ai/recipe-chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeId, question }),
});
const data = await res.json() as { answer?: string; error?: string };
setMessages((prev) => [
...prev,
{ role: "assistant", content: data.answer ?? "Sorry, I couldn't answer that." },
]);
} catch {
setMessages((prev) => [
...prev,
{ role: "assistant", content: "Something went wrong. Please try again." },
]);
} finally {
setLoading(false);
}
};
const suggestions = [
"Can I substitute any ingredients?",
"How do I know when it's done?",
"Can I make this ahead of time?",
"What can I serve with this?",
];
return (
<>
<Button
variant="default"
size="icon"
className="fixed bottom-6 right-6 h-12 w-12 rounded-full shadow-lg z-40"
onClick={() => setOpen(true)}
aria-label="Ask AI about this recipe"
>
<MessageCircle className="h-5 w-5" />
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-full sm:w-[420px] flex flex-col p-0">
<SheetHeader className="px-4 py-3 border-b shrink-0">
<div className="flex items-center justify-between">
<SheetTitle className="text-base flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
Ask about this recipe
</SheetTitle>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground text-left">{recipeTitle}</p>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4 min-h-0">
{messages.length === 0 && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground text-center py-4">
Ask anything about this recipe ingredients, techniques, substitutions, timing
</p>
<div className="space-y-2">
{suggestions.map((s) => (
<button
key={s}
onClick={() => setInput(s)}
className="w-full text-left text-sm px-3 py-2 rounded-lg border bg-muted/50 hover:bg-muted transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={cn(
"flex gap-2 items-start",
msg.role === "user" && "flex-row-reverse"
)}
>
<div className={cn(
"h-7 w-7 shrink-0 rounded-full flex items-center justify-center",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}>
{msg.role === "user" ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
</div>
<div className={cn(
"rounded-xl px-3 py-2 text-sm max-w-[80%] leading-relaxed whitespace-pre-wrap",
msg.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted"
)}>
{msg.content}
</div>
</div>
))}
{loading && (
<div className="flex gap-2 items-start">
<div className="h-7 w-7 shrink-0 rounded-full flex items-center justify-center bg-muted text-muted-foreground">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="rounded-xl px-3 py-2 bg-muted">
<span className="flex gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:0ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:150ms]" />
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:300ms]" />
</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
<form onSubmit={handleSubmit} className="px-4 py-3 border-t shrink-0 flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a question…"
disabled={loading}
className="flex-1"
autoComplete="off"
/>
<Button type="submit" size="icon" disabled={loading || !input.trim()}>
<Send className="h-4 w-4" />
</Button>
</form>
</SheetContent>
</Sheet>
</>
);
}
+66 -2
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useState, useRef, KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { Plus, Trash2, GripVertical, X, Tag } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@@ -48,6 +48,7 @@ type RecipeFormProps = {
prepMins?: number | null;
cookMins?: number | null;
dietaryTags?: DietaryTags;
tags?: string[];
ingredients?: IngredientRow[];
steps?: StepRow[];
photos?: PhotoEntry[];
@@ -77,6 +78,9 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? ""));
const [tags, setTags] = useState<string[]>(defaultValues?.tags ?? []);
const [tagInput, setTagInput] = useState("");
const tagInputRef = useRef<HTMLInputElement>(null);
const [dietaryTags, setDietaryTags] = useState<DietaryTags>(defaultValues?.dietaryTags ?? {});
const [ingredients, setIngredients] = useState<IngredientRow[]>(
defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()]
@@ -95,6 +99,26 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
setIngredients((prev) => prev.filter((_, idx) => idx !== i));
}
function addTag(raw: string) {
const tag = raw.trim().toLowerCase().slice(0, 50);
if (!tag || tags.includes(tag) || tags.length >= 20) return;
setTags((prev) => [...prev, tag]);
setTagInput("");
}
function removeTag(tag: string) {
setTags((prev) => prev.filter((t) => t !== tag));
}
function handleTagKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
addTag(tagInput);
} else if (e.key === "Backspace" && !tagInput && tags.length > 0) {
setTags((prev) => prev.slice(0, -1));
}
}
function updateStep(i: number, patch: Partial<StepRow>) {
setSteps((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
}
@@ -120,6 +144,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
difficulty: difficulty || undefined,
prepMins: prepMins ? parseInt(prepMins) : undefined,
cookMins: cookMins ? parseInt(cookMins) : undefined,
tags,
dietaryTags,
ingredients: ingredients
.filter((ing) => ing.rawName.trim())
@@ -252,6 +277,45 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<option value="public">{t_recipe("visibility.public")}</option>
</select>
</div>
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<div
className="flex flex-wrap gap-1.5 min-h-[36px] rounded-lg border border-input bg-transparent px-2.5 py-1.5 cursor-text focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50"
onClick={() => tagInputRef.current?.focus()}
>
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs bg-muted text-muted-foreground"
>
<Tag className="h-2.5 w-2.5" />
{tag}
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeTag(tag); }}
className="hover:text-foreground transition-colors"
aria-label={`Remove tag ${tag}`}
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
{tags.length < 20 && (
<input
ref={tagInputRef}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
onBlur={() => { if (tagInput.trim()) addTag(tagInput); }}
placeholder={tags.length === 0 ? "Add tags… (press Enter)" : ""}
className="flex-1 min-w-[120px] bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
)}
</div>
<p className="text-xs text-muted-foreground">Press Enter to add · Backspace to remove last · max 20 tags</p>
</div>
</div>
<Separator />
@@ -25,6 +25,7 @@ type Recipe = {
cookMins: number | null;
difficulty: "easy" | "medium" | "hard" | null;
visibility: "private" | "unlisted" | "public";
tags: string[];
updatedAt: Date;
photos?: Array<{ storageKey: string; isCover: boolean }>;
};
@@ -111,6 +112,20 @@ function SelectableRecipeCard({
{recipe.description}
</p>
)}
{recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{recipe.tags.slice(0, 3).map((tag) => (
<span key={tag} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-muted text-muted-foreground">
{tag}
</span>
))}
{recipe.tags.length > 3 && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs text-muted-foreground">
+{recipe.tags.length - 3}
</span>
)}
</div>
)}
</div>
{/* Footer */}
+202 -26
View File
@@ -3,16 +3,76 @@
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { PlusCircle, Sparkles, Link2, Search, X } from "lucide-react";
import { PlusCircle, Sparkles, Link2, Search, X, SlidersHorizontal, ArrowUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { buttonVariants } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AiGenerateDialog } from "./ai-generate-dialog";
import { UrlImportDialog } from "./url-import-dialog";
function TagFilterInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [local, setLocal] = useState(value);
return (
<input
value={local}
onChange={(e) => setLocal(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") onChange(local); }}
onBlur={() => onChange(local)}
placeholder="Filter by tag…"
className="w-full h-7 px-2 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring"
/>
);
}
import { cn } from "@/lib/utils";
export function RecipesHeader({ count, initialQuery = "" }: { count: number; initialQuery?: string }) {
const SORT_LABELS: Record<string, string> = {
updated_desc: "Recently updated",
updated_asc: "Oldest updated",
created_desc: "Newest first",
created_asc: "Oldest first",
title_asc: "Title AZ",
title_desc: "Title ZA",
};
const VISIBILITY_LABELS: Record<string, string> = {
"": "All",
private: "Private",
unlisted: "Unlisted",
public: "Public",
};
const DIFFICULTY_LABELS: Record<string, string> = {
"": "Any difficulty",
easy: "Easy",
medium: "Medium",
hard: "Hard",
};
export function RecipesHeader({
count,
initialQuery = "",
initialSort = "updated_desc",
initialVisibility = "",
initialDifficulty = "",
initialTag = "",
}: {
count: number;
initialQuery?: string;
initialSort?: string;
initialVisibility?: string;
initialDifficulty?: string;
initialTag?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("recipes");
@@ -21,15 +81,32 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini
const [query, setQuery] = useState(initialQuery);
const [, startTransition] = useTransition();
function pushParams(overrides: Record<string, string>) {
const params = new URLSearchParams();
const current = {
q: query,
sort: initialSort,
visibility: initialVisibility,
difficulty: initialDifficulty,
tag: initialTag,
...overrides,
};
if (current.q?.trim()) params.set("q", current.q.trim());
if (current.sort && current.sort !== "updated_desc") params.set("sort", current.sort);
if (current.visibility) params.set("visibility", current.visibility);
if (current.difficulty) params.set("difficulty", current.difficulty);
if (current.tag?.trim()) params.set("tag", current.tag.trim());
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
function handleSearch(value: string) {
setQuery(value);
startTransition(() => {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
router.push(`${pathname}?${params.toString()}`);
});
pushParams({ q: value });
}
const activeFilterCount = [initialVisibility, initialDifficulty, initialTag].filter(Boolean).length;
const sortChanged = initialSort !== "updated_desc";
return (
<>
<div className="space-y-4">
@@ -43,11 +120,11 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setUrlOpen(true)}>
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
<Link2 className="h-4 w-4" />
{t("importUrl")}
</Button>
<Button variant="outline" size="sm" onClick={() => setAiOpen(true)}>
<Button variant="ghost" size="sm" onClick={() => setAiOpen(true)}>
<Sparkles className="h-4 w-4" />
{t("generate")}
</Button>
@@ -58,22 +135,121 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini
</div>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t("search")}
className="pl-9 pr-9"
/>
{query && (
<button
onClick={() => handleSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
<div className="flex flex-wrap gap-2">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t("search")}
className="pl-9 pr-9"
/>
{query && (
<button
onClick={() => handleSearch("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Sort */}
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
buttonVariants({ variant: sortChanged ? "secondary" : "outline", size: "sm" }),
"gap-1.5"
)}
>
<X className="h-4 w-4" />
</button>
)}
<ArrowUpDown className="h-4 w-4" />
{SORT_LABELS[initialSort] ?? "Sort"}
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuGroup>
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(SORT_LABELS).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ sort: value })}
className={cn(initialSort === value && "font-medium text-primary")}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Filters */}
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
buttonVariants({ variant: activeFilterCount > 0 ? "secondary" : "outline", size: "sm" }),
"gap-1.5"
)}
>
<SlidersHorizontal className="h-4 w-4" />
Filter
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1 h-4 min-w-4 px-1 text-xs">
{activeFilterCount}
</Badge>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuGroup>
<DropdownMenuLabel>Visibility</DropdownMenuLabel>
{Object.entries(VISIBILITY_LABELS).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ visibility: value })}
className={cn(initialVisibility === value && "font-medium text-primary")}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Difficulty</DropdownMenuLabel>
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => pushParams({ difficulty: value })}
className={cn(initialDifficulty === value && "font-medium text-primary")}
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Tag</DropdownMenuLabel>
<div className="px-2 py-1">
<TagFilterInput
value={initialTag}
onChange={(v) => pushParams({ tag: v })}
/>
</div>
</DropdownMenuGroup>
{activeFilterCount > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "" })}
className="text-muted-foreground"
>
<X className="h-3 w-3 mr-1.5" /> Clear filters
</DropdownMenuItem>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Languages, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
@@ -62,10 +63,16 @@ export function TranslateButton({ recipeId }: { recipeId: string }) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Languages className="h-4 w-4" />
Translate
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Languages className="h-4 w-4" />
</Button>
} />
<TooltipContent>Translate</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
@@ -3,6 +3,7 @@
import { useState } from "react";
import { GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { VariationsDialog } from "./variations-dialog";
export function VariationsButton({
@@ -26,10 +27,16 @@ export function VariationsButton({
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<GitBranch className="h-4 w-4" />
Variations
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<GitBranch className="h-4 w-4" />
</Button>
} />
<TooltipContent>Variations</TooltipContent>
</Tooltip>
</TooltipProvider>
<VariationsDialog
recipeId={recipeId}
baseServings={baseServings}
@@ -10,8 +10,8 @@ import {
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
type VersionSummary = {
id: string;
@@ -99,19 +99,24 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
}
return (
<Sheet open={open} onOpenChange={handleOpen}>
<SheetTrigger render={
<Button variant="ghost" size="sm">
<History className="h-4 w-4" />
History
</Button>
} />
<SheetContent>
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => handleOpen(true)}>
<History className="h-4 w-4" />
</Button>
} />
<TooltipContent>History</TooltipContent>
</Tooltip>
</TooltipProvider>
<Sheet open={open} onOpenChange={handleOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Version History</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-2">
<div className="p-2 mt-6 space-y-2">
{loading && (
<p className="text-sm text-muted-foreground">Loading versions...</p>
)}
@@ -173,5 +178,6 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
</div>
</SheetContent>
</Sheet>
</>
);
}