Update features and dependencies
This commit is contained in:
@@ -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,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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 “{query}”</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'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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user