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
@@ -0,0 +1,66 @@
"use client";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth/client";
type Provider = {
id: string;
label: string;
icon: React.ReactNode;
};
const GithubIcon = () => (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
const DiscordIcon = () => (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.11 18.1.128 18.11a19.9 19.9 0 0 0 5.993 3.03.077.077 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
);
const authentikIcon = (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
type Props = {
showGithub?: boolean;
showDiscord?: boolean;
showAuthentik?: boolean;
};
export function SocialLoginButtons({ showGithub, showDiscord, showAuthentik }: Props) {
const providers: Provider[] = [
...(showGithub ? [{ id: "github", label: "Continue with GitHub", icon: <GithubIcon /> }] : []),
...(showDiscord ? [{ id: "discord", label: "Continue with Discord", icon: <DiscordIcon /> }] : []),
...(showAuthentik ? [{ id: "authentik", label: "Continue with Authentik", icon: authentikIcon }] : []),
];
if (providers.length === 0) return null;
async function handleSocial(providerId: string) {
if (providerId === "authentik") {
await (authClient as unknown as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise<void> } }).signIn.genericOAuth({
providerId: "authentik",
callbackURL: "/recipes",
});
} else {
await authClient.signIn.social({ provider: providerId as "github" | "discord" | "google", callbackURL: "/recipes" });
}
}
return (
<>
{providers.map((p) => (
<Button key={p.id} variant="outline" className="w-full gap-2" type="button" onClick={() => handleSocial(p.id)}>
{p.icon}
{p.label}
</Button>
))}
</>
);
}
+2 -34
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Languages, Search, Compass } from "lucide-react";
import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import {
@@ -14,13 +14,11 @@ import {
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { authClient } from "@/lib/auth/client";
import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider";
import { useTranslations } from "next-intl";
const NAV_ITEMS = [
{ href: "/recipes", key: "recipes", icon: BookOpen },
{ href: "/explore", key: "explore", icon: Compass },
{ href: "/search", key: "search", icon: Search },
{ href: "/explore", key: "explore", icon: Search },
{ href: "/feed", key: "feed", icon: Rss },
{ href: "/collections", key: "collections", icon: FolderOpen },
{ href: "/meal-plan", key: "mealPlan", icon: Calendar },
@@ -32,7 +30,6 @@ export function Nav() {
const pathname = usePathname();
const { data: session } = authClient.useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
const { locale, setLocale } = useLocale();
const t = useTranslations("nav");
return (
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -70,35 +67,6 @@ export function Nav() {
<DropdownMenuItem>
<Link href="/settings" className="w-full">{t("settings")}</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href="/settings/api-keys" className="w-full">{t("apiKeys")}</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href="/settings/webhooks" className="w-full">{t("webhooks")}</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1.5">
<p className="text-xs text-muted-foreground flex items-center gap-1.5 mb-1.5">
<Languages className="h-3.5 w-3.5" />
{t("language")}
</p>
<div className="flex gap-1">
{SUPPORTED_LOCALES.map((l) => (
<button
key={l.code}
onClick={() => setLocale(l.code as Locale)}
className={cn(
"flex-1 rounded px-2 py-1 text-xs transition-colors",
locale === l.code
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-accent"
)}
>
{l.label}
</button>
))}
</div>
</div>
{isAdmin && (
<>
<DropdownMenuSeparator />
+30 -11
View File
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useTranslations } from "next-intl";
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
@@ -130,6 +131,7 @@ export function MealPlanner({
const [aiServings, setAiServings] = useState("2");
const [usePantry, setUsePantry] = useState(true);
const [pantryMode, setPantryMode] = useState(false);
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
const t = useTranslations("mealPlan");
async function generateWithAi() {
@@ -154,6 +156,7 @@ export function MealPlanner({
servings: parseInt(aiServings) || 2,
usePantry,
pantryMode,
difficulty: aiDifficulty || undefined,
}),
});
if (!res.ok) {
@@ -235,7 +238,7 @@ export function MealPlanner({
<>
{/* AI Generate button */}
<div className="flex justify-end mb-4">
<Button variant="outline" size="sm" onClick={() => setShowAiModal(true)} className="gap-2">
<Button variant="ghost" size="sm" onClick={() => setShowAiModal(true)} className="gap-2">
<Sparkles className="h-4 w-4" />
{t("generateWithAi")}
</Button>
@@ -329,16 +332,32 @@ export function MealPlanner({
disabled={aiGenerating}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("servings")}</label>
<Input
type="number"
min={1}
max={20}
value={aiServings}
onChange={(e) => setAiServings(e.target.value)}
disabled={aiGenerating}
/>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">{t("servings")}</label>
<Input
type="number"
min={1}
max={20}
value={aiServings}
onChange={(e) => setAiServings(e.target.value)}
disabled={aiGenerating}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Difficulty</label>
<Select value={aiDifficulty} onValueChange={(v) => setAiDifficulty(v as typeof aiDifficulty)} disabled={aiGenerating}>
<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 className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
@@ -1,7 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { ChefHat } from "lucide-react";
import { ChefHat, Printer } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -13,10 +13,16 @@ export function PantryPageHeader() {
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
<Link href="/recipes/can-cook" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChefHat className="h-4 w-4" />
{t("canCook")}
</Link>
<div className="flex items-center gap-2">
<Link href="/recipes/can-cook" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChefHat className="h-4 w-4" />
{t("canCook")}
</Link>
<a href="/print/pantry" target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
Print
</a>
</div>
</div>
);
}
@@ -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>
</>
);
}
@@ -1,12 +1,14 @@
"use client";
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Flame, Clock, ChefHat, Search } from "lucide-react";
import type { RecipeResult } from "@/app/(app)/explore/page";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight } from "lucide-react";
import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page";
const DIFFICULTY_COLORS: Record<string, string> = {
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
@@ -14,8 +16,34 @@ const DIFFICULTY_COLORS: Record<string, string> = {
hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
function RecipeCard({ recipe }: { recipe: RecipeResult }) {
type SearchRecipeResult = {
id: string;
title: string;
description: string | null;
difficulty: string | null;
prepMins: number | null;
cookMins: number | null;
authorName: string | null;
};
type SearchResponse = {
data: SearchRecipeResult[];
total: number;
limit: number;
offset: number;
};
type RecipeIdea = {
title: string;
description: string;
tags: string[];
difficulty: "easy" | "medium" | "hard";
totalMins?: number;
};
function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResult }) {
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const description = "description" in recipe ? recipe.description : null;
return (
<Link
href={`/recipes/${recipe.id}`}
@@ -34,6 +62,9 @@ function RecipeCard({ recipe }: { recipe: RecipeResult }) {
</Badge>
)}
</div>
{description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{description}</p>
)}
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
{totalMins > 0 && (
<span className="flex items-center gap-1">
@@ -61,75 +92,370 @@ function HorizontalScroll({ children }: { children: React.ReactNode }) {
}
type Props = {
trending: RecipeResult[];
recent: RecipeResult[];
trending: ExploreRecipeResult[];
recent: ExploreRecipeResult[];
initialQuery: string;
};
export function ExplorePageContent({ trending, recent }: Props) {
export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const searchParams = useSearchParams();
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
const q = inputRef.current?.value.trim();
if (q) router.push(`/search?q=${encodeURIComponent(q)}`);
else router.push("/search");
const [inputValue, setInputValue] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);
const [difficulty, setDifficulty] = useState("any");
const [maxMins, setMaxMins] = useState("");
const [results, setResults] = useState<SearchRecipeResult[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [ideasPrompt, setIdeasPrompt] = useState("");
const [ideas, setIdeas] = useState<RecipeIdea[]>([]);
const [ideasLoading, setIdeasLoading] = useState(false);
const [generatingId, setGeneratingId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchResults = useCallback(
async (q: string, diff: string, maxM: string, off: number, append = false) => {
if (!q.trim()) {
setResults([]);
setTotal(0);
setOffset(0);
return;
}
if (off === 0) setLoading(true);
else setLoadingMore(true);
try {
const params = new URLSearchParams({ q, limit: "20", offset: String(off) });
if (diff && diff !== "any") params.set("difficulty", diff);
if (maxM) params.set("maxMins", maxM);
const res = await fetch(`/api/v1/search?${params}`);
if (!res.ok) return;
const json = await res.json() as SearchResponse;
if (append) setResults((prev) => [...prev, ...json.data]);
else setResults(json.data);
setTotal(json.total);
setOffset(off + json.data.length);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[]
);
useEffect(() => {
if (initialQuery) fetchResults(initialQuery, "any", "", 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updateUrl = useCallback(
(q: string) => {
const params = new URLSearchParams(searchParams.toString());
if (q) params.set("q", q);
else params.delete("q");
router.replace(`/explore?${params}`, { scroll: false });
},
[router, searchParams]
);
const fetchIdeas = useCallback(async (prompt: string) => {
setIdeasLoading(true);
setIdeas([]);
try {
const res = await fetch("/api/v1/ai/recipe-ideas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!res.ok) return;
const data = await res.json() as RecipeIdea[];
setIdeas(data);
} finally {
setIdeasLoading(false);
}
}, []);
const generateFromIdea = useCallback(async (idea: RecipeIdea) => {
const key = idea.title;
setGeneratingId(key);
try {
const res = await fetch("/api/v1/ai/generate-from-idea", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: idea.title }),
});
if (!res.ok) return;
const { id } = await res.json() as { id: string };
router.push(`/recipes/${id}`);
} finally {
setGeneratingId(null);
}
}, [router]);
const handleInputChange = (value: string) => {
setInputValue(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setQuery(value);
updateUrl(value);
fetchResults(value, difficulty, maxMins, 0);
}, 300);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (debounceRef.current) clearTimeout(debounceRef.current);
setQuery(inputValue);
updateUrl(inputValue);
fetchResults(inputValue, difficulty, maxMins, 0);
};
const handleDifficultyChange = (value: string | null) => {
const v = value ?? "any";
setDifficulty(v);
fetchResults(query, v, maxMins, 0);
};
const handleMaxMinsChange = (value: string) => {
setMaxMins(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetchResults(query, difficulty, value, 0);
}, 300);
};
const hasQuery = query.trim().length > 0;
const hasMore = results.length < total;
return (
<div className="max-w-5xl mx-auto space-y-10">
{/* Search bar */}
<div>
<h1 className="text-3xl font-bold mb-6">Explore</h1>
<form onSubmit={handleSearchSubmit} className="relative">
<div className="space-y-3">
<h1 className="text-3xl font-bold">Explore</h1>
<form onSubmit={handleSubmit} className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
placeholder="Search recipes..."
value={inputValue}
onChange={(e) => handleInputChange(e.target.value)}
placeholder="Search public recipes…"
className="pl-10 h-12 text-base"
autoFocus={!!initialQuery}
/>
</form>
{hasQuery && (
<div className="flex flex-wrap items-center gap-3">
<Select value={difficulty} onValueChange={handleDifficultyChange}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Difficulty" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any difficulty</SelectItem>
<SelectItem value="easy">Easy</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="hard">Hard</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={1}
placeholder="Max minutes"
value={maxMins}
onChange={(e) => handleMaxMinsChange(e.target.value)}
className="w-36"
/>
{!loading && (
<span className="text-sm text-muted-foreground ml-auto">
{total} {total === 1 ? "recipe" : "recipes"} found
</span>
)}
</div>
)}
</div>
{/* Trending this week */}
<section>
<div className="flex items-center gap-2 mb-4">
<Flame className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-semibold">Trending this week</h2>
{/* Search results */}
{hasQuery && loading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-4 space-y-3">
<div className="h-5 bg-muted rounded animate-pulse" />
<div className="h-3 bg-muted rounded animate-pulse w-3/4" />
<div className="h-3 bg-muted rounded animate-pulse w-1/2" />
</div>
))}
</div>
{trending.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
No trending recipes yet. Be the first to share one!
</p>
) : (
<HorizontalScroll>
{trending.map((recipe) => (
<div key={recipe.id} className="snap-start shrink-0 w-56">
<RecipeCard recipe={recipe} />
</div>
))}
</HorizontalScroll>
)}
</section>
)}
{/* Recently added */}
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold">Recently added</h2>
{hasQuery && !loading && results.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<ChefHat className="h-12 w-12 mb-4 opacity-30" />
<p className="text-lg font-medium">No recipes found for &ldquo;{query}&rdquo;</p>
<p className="text-sm mt-1">Try different keywords or remove filters</p>
</div>
{recent.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
No public recipes yet.
</p>
) : (
)}
{hasQuery && !loading && results.length > 0 && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{recent.map((recipe) => (
{results.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</section>
{hasMore && (
<div className="flex justify-center pt-4">
<Button
variant="outline"
onClick={() => fetchResults(query, difficulty, maxMins, offset, true)}
disabled={loadingMore}
className="min-w-32"
>
{loadingMore ? "Loading…" : "Load more"}
</Button>
</div>
)}
</>
)}
{/* Explore sections — shown when no query */}
{!hasQuery && (
<>
{/* AI Recipe Ideas */}
<section className="rounded-xl border bg-gradient-to-br from-violet-50 to-indigo-50 dark:from-violet-950/30 dark:to-indigo-950/30 p-6 space-y-4">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-violet-500" />
<h2 className="text-xl font-semibold">Recipe ideas</h2>
</div>
<p className="text-sm text-muted-foreground">Tell the AI what you&apos;re in the mood for, or let it surprise you.</p>
<form
onSubmit={(e) => {
e.preventDefault();
fetchIdeas(ideasPrompt);
}}
className="flex gap-2"
>
<Input
value={ideasPrompt}
onChange={(e) => setIdeasPrompt(e.target.value)}
placeholder="e.g. quick weeknight dinners, Italian comfort food…"
className="bg-background"
/>
<Button type="submit" disabled={ideasLoading} variant="secondary" className="shrink-0">
{ideasLoading ? (
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4 animate-pulse" /> Generating</span>
) : (
<span className="flex items-center gap-2"><Wand2 className="h-4 w-4" /> Get ideas</span>
)}
</Button>
<Button
type="button"
variant="ghost"
disabled={ideasLoading}
onClick={() => { setIdeasPrompt(""); fetchIdeas(""); }}
className="shrink-0"
>
Surprise me
</Button>
</form>
{ideasLoading && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-background p-4 space-y-2">
<div className="h-4 bg-muted rounded animate-pulse" />
<div className="h-3 bg-muted rounded animate-pulse w-5/6" />
<div className="h-3 bg-muted rounded animate-pulse w-2/3" />
</div>
))}
</div>
)}
{!ideasLoading && ideas.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 pt-2">
{ideas.map((idea) => (
<div key={idea.title} className="rounded-lg border bg-background p-4 space-y-2 flex flex-col">
<div className="flex items-start justify-between gap-2">
<h3 className="font-medium text-sm leading-snug flex-1">{idea.title}</h3>
<Badge
variant="secondary"
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[idea.difficulty] ?? ""}`}
>
{idea.difficulty}
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 flex-1">{idea.description}</p>
<div className="flex flex-wrap gap-1">
{idea.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
{idea.totalMins && (
<Badge variant="outline" className="text-xs flex items-center gap-1">
<Clock className="h-3 w-3" />{idea.totalMins}m
</Badge>
)}
</div>
<Button
size="sm"
className="w-full mt-auto"
disabled={generatingId !== null}
onClick={() => generateFromIdea(idea)}
>
{generatingId === idea.title ? (
<span className="flex items-center gap-2"><Wand2 className="h-3.5 w-3.5 animate-pulse" /> Generating</span>
) : (
<span className="flex items-center gap-2"><ArrowRight className="h-3.5 w-3.5" /> Generate full recipe</span>
)}
</Button>
</div>
))}
</div>
)}
</section>
<section>
<div className="flex items-center gap-2 mb-4">
<Flame className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-semibold">Trending this week</h2>
</div>
{trending.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
No trending recipes yet. Be the first to share one!
</p>
) : (
<HorizontalScroll>
{trending.map((recipe) => (
<div key={recipe.id} className="snap-start shrink-0 w-56">
<RecipeCard recipe={recipe} />
</div>
))}
</HorizontalScroll>
)}
</section>
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold">Recently added</h2>
</div>
{recent.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
No public recipes yet.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{recent.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</section>
</>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useTranslations } from "next-intl";
import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider";
@@ -14,6 +15,8 @@ type UserProps = {
email: string;
image: string | null;
locale: string;
bio: string | null;
privateBio: string | null;
};
export function SettingsForm({ user }: { user: UserProps }) {
@@ -22,7 +25,10 @@ export function SettingsForm({ user }: { user: UserProps }) {
const { setLocale } = useLocale();
const [name, setName] = useState(user.name);
const [bio, setBio] = useState(user.bio ?? "");
const [privateBio, setPrivateBio] = useState(user.privateBio ?? "");
const [saving, setSaving] = useState(false);
const [savingBio, setSavingBio] = useState(false);
async function saveProfile() {
setSaving(true);
@@ -39,6 +45,26 @@ export function SettingsForm({ user }: { user: UserProps }) {
}
}
async function saveBios() {
setSavingBio(true);
try {
const res = await fetch("/api/v1/users/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bio: bio.trim() || null,
privateBio: privateBio.trim() || null,
}),
});
if (res.ok) toast.success(t_common("saved"));
else toast.error(t_common("saveFailed"));
} finally {
setSavingBio(false);
}
}
const bioUnchanged = bio === (user.bio ?? "") && privateBio === (user.privateBio ?? "");
return (
<div className="space-y-6">
<section className="rounded-xl border p-6 space-y-4">
@@ -56,6 +82,39 @@ export function SettingsForm({ user }: { user: UserProps }) {
</Button>
</section>
<section className="rounded-xl border p-6 space-y-4">
<h2 className="font-semibold text-lg">{t("bio")}</h2>
<div className="space-y-2">
<Label>{t("publicBio")}</Label>
<p className="text-xs text-muted-foreground">{t("publicBioDescription")}</p>
<Textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={t("publicBioPlaceholder")}
rows={3}
maxLength={500}
className="resize-none"
/>
<p className="text-xs text-muted-foreground text-right">{bio.length}/500</p>
</div>
<div className="space-y-2">
<Label>{t("privateBio")}</Label>
<p className="text-xs text-muted-foreground">{t("privateBioDescription")}</p>
<Textarea
value={privateBio}
onChange={(e) => setPrivateBio(e.target.value)}
placeholder={t("privateBioPlaceholder")}
rows={5}
maxLength={2000}
className="resize-none"
/>
<p className="text-xs text-muted-foreground text-right">{privateBio.length}/2000</p>
</div>
<Button onClick={saveBios} disabled={savingBio || bioUnchanged}>
{savingBio ? t("saving") : t_common("save")}
</Button>
</section>
<section className="rounded-xl border p-6 space-y-4">
<h2 className="font-semibold text-lg">{t("language")}</h2>
<p className="text-sm text-muted-foreground">{t("languageDescription")}</p>
+11 -5
View File
@@ -2,8 +2,8 @@
import { useState } from "react";
import { Heart } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export function FavoriteButton({
@@ -30,9 +30,15 @@ export function FavoriteButton({
}
return (
<Button variant="ghost" size="sm" onClick={toggle} disabled={loading} className="gap-1.5">
<Heart className={cn("h-4 w-4", favorited && "fill-red-500 text-red-500")} />
{favorited ? "Saved" : "Save"}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={toggle} disabled={loading}>
<Heart className={cn("h-4 w-4", favorited && "fill-red-500 text-red-500")} />
</Button>
} />
<TooltipContent>{favorited ? "Saved" : "Save"}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}