Update features and dependencies
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 A–Z",
|
||||
title_desc: "Title Z–A",
|
||||
};
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user