Update features and dependencies
This commit is contained in:
+27
-1
@@ -15,10 +15,36 @@ STORAGE_REGION=us-east-1
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# OAuth
|
||||
# Encryption key for BYOK AI keys stored in DB (generate with: openssl rand -base64 32)
|
||||
# Separate from BETTER_AUTH_SECRET for key separation. Falls back to BETTER_AUTH_SECRET if unset.
|
||||
ENCRYPTION_SECRET=
|
||||
|
||||
# OAuth — Google (always available)
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# OAuth — GitHub (optional; set NEXT_PUBLIC_GITHUB_ENABLED=true to show button in UI)
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
NEXT_PUBLIC_GITHUB_ENABLED=
|
||||
|
||||
# OAuth — Discord (optional; set NEXT_PUBLIC_DISCORD_ENABLED=true to show button in UI)
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
NEXT_PUBLIC_DISCORD_ENABLED=
|
||||
|
||||
# OIDC — Authentik (or any OIDC provider)
|
||||
# AUTHENTIK_BASE_URL: base URL including application slug, e.g.
|
||||
# https://auth.example.com/application/o/epicure
|
||||
# The discovery document is fetched from $AUTHENTIK_BASE_URL/.well-known/openid-configuration
|
||||
# In authentik: create an OAuth2/OpenID provider, set redirect URI to
|
||||
# $BETTER_AUTH_URL/api/auth/callback/authentik
|
||||
AUTHENTIK_CLIENT_ID=
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
AUTHENTIK_BASE_URL=
|
||||
# Set to true to show Authentik login button in UI
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||
|
||||
# SMTP (leave blank to log emails to console in dev)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
|
||||
@@ -24,7 +24,12 @@ export type RecipeResult = {
|
||||
cookMins: number | null;
|
||||
};
|
||||
|
||||
export default async function ExplorePage() {
|
||||
export default async function ExplorePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const { q } = await searchParams;
|
||||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Trending: public recipes ordered by favorite count in last 7 days
|
||||
@@ -71,5 +76,5 @@ export default async function ExplorePage() {
|
||||
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
|
||||
const recent: RecipeResult[] = recentRows;
|
||||
|
||||
return <ExplorePageContent trending={trending} recent={recent} />;
|
||||
return <ExplorePageContent trending={trending} recent={recent} initialQuery={q ?? ""} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight, ShoppingCart } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, mealPlans, recipes, eq, and, desc } from "@epicure/db";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -86,6 +86,10 @@ export default async function MealPlanPage({
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Shopping lists
|
||||
</Link>
|
||||
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Print
|
||||
</Link>
|
||||
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
@@ -35,6 +35,7 @@ export default async function EditRecipePage({ params }: Params) {
|
||||
difficulty: recipe.difficulty,
|
||||
prepMins: recipe.prepMins,
|
||||
cookMins: recipe.cookMins,
|
||||
tags: recipe.tags ?? [],
|
||||
dietaryTags: (recipe.dietaryTags as Record<string, boolean | undefined>) ?? {},
|
||||
ingredients: recipe.ingredients.map((ing) => ({
|
||||
id: ing.id,
|
||||
|
||||
@@ -13,18 +13,21 @@ import { PrintButton } from "@/components/recipe/print-button";
|
||||
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
|
||||
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
|
||||
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
|
||||
import { and, eq, count } from "@epicure/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ServingScaler } from "@/components/recipe/serving-scaler";
|
||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||
import { RatingStars } from "@/components/social/rating-stars";
|
||||
import { CommentsSection } from "@/components/social/comments-section";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -86,14 +89,30 @@ export default async function RecipePage({ params }: Params) {
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
{recipe.steps.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||
<Play className="h-4 w-4" />
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>Cook</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||
<MealPairingButton recipeId={id} />
|
||||
<DrinkPairingButton recipeId={id} />
|
||||
{recipe.visibility === "public" && (
|
||||
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>View publicly</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<AddToShoppingListButton
|
||||
@@ -107,7 +126,9 @@ export default async function RecipePage({ params }: Params) {
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<TranslateButton recipeId={id} />
|
||||
{(!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && (
|
||||
<TranslateButton recipeId={id} />
|
||||
)}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<AdaptRecipeButton
|
||||
recipeId={id}
|
||||
@@ -133,20 +154,19 @@ export default async function RecipePage({ params }: Params) {
|
||||
order: s.order,
|
||||
}))}
|
||||
/>
|
||||
{recipe.steps.length > 0 && (
|
||||
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "default", size: "sm" }))}>
|
||||
<Play className="h-4 w-4" />
|
||||
Cook
|
||||
</Link>
|
||||
)}
|
||||
<PrintButton recipeId={id} />
|
||||
<VersionHistoryButton recipeId={id} />
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<DeleteRecipeButton recipeId={id} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{avgScore !== null && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -210,6 +230,24 @@ export default async function RecipePage({ params }: Params) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Empty state */}
|
||||
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<p className="text-muted-foreground">No ingredients or steps yet.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<GenerateContentButton
|
||||
recipeId={id}
|
||||
title={recipe.title}
|
||||
description={recipe.description}
|
||||
/>
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit manually
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serving scaler + ingredients */}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
@@ -285,6 +323,8 @@ export default async function RecipePage({ params }: Params) {
|
||||
<CommentsSection recipeId={id} currentUserId={session.user.id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,75 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, recipeIngredients } from "@epicure/db";
|
||||
import { eq, desc, and, ilike, or, sql } from "@epicure/db";
|
||||
import { db, recipes, sql } from "@epicure/db";
|
||||
import { eq, desc, asc, and, ilike, or } from "@epicure/db";
|
||||
import { RecipesHeader } from "@/components/recipe/recipes-header";
|
||||
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
|
||||
import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
||||
|
||||
export const metadata: Metadata = { title: "Recipes" };
|
||||
|
||||
type SearchParams = Promise<{ q?: string }>;
|
||||
type SearchParams = Promise<{
|
||||
q?: string;
|
||||
sort?: string;
|
||||
visibility?: string;
|
||||
difficulty?: string;
|
||||
tag?: string;
|
||||
}>;
|
||||
|
||||
const SORT_MAP = {
|
||||
updated_desc: desc(recipes.updatedAt),
|
||||
updated_asc: asc(recipes.updatedAt),
|
||||
created_desc: desc(recipes.createdAt),
|
||||
created_asc: asc(recipes.createdAt),
|
||||
title_asc: asc(recipes.title),
|
||||
title_desc: desc(recipes.title),
|
||||
} as const;
|
||||
|
||||
type SortKey = keyof typeof SORT_MAP;
|
||||
|
||||
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const { q } = await searchParams;
|
||||
const query = q?.trim() ?? "";
|
||||
const { q, sort, visibility, difficulty, tag } = await searchParams;
|
||||
const query = (q ?? "").trim().slice(0, 200);
|
||||
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
|
||||
const tagFilter = tag?.trim().slice(0, 50) || undefined;
|
||||
|
||||
const whereClause = query
|
||||
? and(
|
||||
eq(recipes.authorId, session.user.id),
|
||||
or(
|
||||
ilike(recipes.title, `%${query}%`),
|
||||
ilike(recipes.description, `%${query}%`)
|
||||
)
|
||||
)
|
||||
: eq(recipes.authorId, session.user.id);
|
||||
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
|
||||
? (visibility as "private" | "unlisted" | "public")
|
||||
: undefined;
|
||||
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
|
||||
? (difficulty as "easy" | "medium" | "hard")
|
||||
: undefined;
|
||||
|
||||
const userRecipes = await db.query.recipes.findMany({
|
||||
where: whereClause,
|
||||
orderBy: desc(recipes.updatedAt),
|
||||
where: and(
|
||||
eq(recipes.authorId, session.user.id),
|
||||
query
|
||||
? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`))
|
||||
: undefined,
|
||||
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
|
||||
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
|
||||
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
|
||||
),
|
||||
orderBy: SORT_MAP[sortKey],
|
||||
with: { photos: true },
|
||||
});
|
||||
|
||||
const totalCount = query ? undefined : userRecipes.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RecipesHeader count={totalCount ?? userRecipes.length} initialQuery={query} />
|
||||
|
||||
<RecipesHeader
|
||||
count={userRecipes.length}
|
||||
initialQuery={query}
|
||||
initialSort={sortKey}
|
||||
initialVisibility={visibilityFilter ?? ""}
|
||||
initialDifficulty={difficultyFilter ?? ""}
|
||||
initialTag={tagFilter ?? ""}
|
||||
/>
|
||||
<RecipesEmptyState query={query} count={userRecipes.length} />
|
||||
|
||||
<RecipesGrid recipes={userRecipes} />
|
||||
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}`} recipes={userRecipes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, users, eq } from "@epicure/db";
|
||||
import { SettingsForm } from "@/components/settings/settings-form";
|
||||
|
||||
export const metadata: Metadata = { title: "Profile – Settings" };
|
||||
@@ -9,6 +10,11 @@ export default async function SettingsPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const dbUser = await db.query.users.findFirst({
|
||||
where: eq(users.id, session.user.id),
|
||||
columns: { bio: true, privateBio: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsForm
|
||||
user={{
|
||||
@@ -16,6 +22,8 @@ export default async function SettingsPage() {
|
||||
email: session.user.email,
|
||||
image: session.user.image ?? null,
|
||||
locale: (session.user as { locale?: string }).locale ?? "en",
|
||||
bio: dbUser?.bio ?? null,
|
||||
privateBio: dbUser?.privateBio ?? null,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { Printer } from "lucide-react";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, shoppingLists, eq, and } from "@epicure/db";
|
||||
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -23,11 +27,17 @@ export default async function ShoppingListPage({ params }: Params) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
|
||||
</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Printer className="h-4 w-4" />
|
||||
Print
|
||||
</Link>
|
||||
</div>
|
||||
<ShoppingListView
|
||||
listId={id}
|
||||
|
||||
@@ -53,10 +53,6 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoogle() {
|
||||
await authClient.signIn.social({ provider: "google", callbackURL: "/recipes" });
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-1">
|
||||
@@ -65,9 +61,15 @@ export default function LoginPage() {
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<Button variant="outline" className="w-full" type="button" onClick={handleGoogle}>
|
||||
{t("continueWithGoogle")}
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider: "google", callbackURL: "/recipes" })}>
|
||||
<GoogleIcon />
|
||||
{t("continueWithGoogle")}
|
||||
</Button>
|
||||
<SocialButton provider="github" label={t("continueWithGithub")} icon={<GithubIcon />} />
|
||||
<SocialButton provider="discord" label={t("continueWithDiscord")} icon={<DiscordIcon />} />
|
||||
<AuthentikButton label={t("continueWithAuthentik")} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">{t("or")}</span>
|
||||
@@ -118,3 +120,59 @@ export default function LoginPage() {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialButton({ provider, label, icon }: { provider: "github" | "discord"; label: string; icon: React.ReactNode }) {
|
||||
const envKey = provider === "github" ? process.env["NEXT_PUBLIC_GITHUB_ENABLED"] : process.env["NEXT_PUBLIC_DISCORD_ENABLED"];
|
||||
if (!envKey) return null;
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider, callbackURL: "/recipes" })}>
|
||||
{icon}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthentikButton({ label }: { label: string }) {
|
||||
if (!process.env["NEXT_PUBLIC_AUTHENTIK_ENABLED"]) return null;
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => (authClient as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise<void> } }).signIn.genericOAuth({ providerId: "authentik", callbackURL: "/recipes" })}>
|
||||
<AuthentikIcon />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
|
||||
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GithubIcon() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function DiscordIcon() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthentikIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,7 @@ export async function POST(req: NextRequest) {
|
||||
subject: "Epicure — test email",
|
||||
html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`),
|
||||
});
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
smtp_host: process.env["SMTP_HOST"] ?? null,
|
||||
smtp_user: process.env["SMTP_USER"] ?? null,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
|
||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||
|
||||
const Schema = z.object({
|
||||
excludeIngredients: z.array(z.string()).default([]),
|
||||
@@ -42,9 +43,12 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
|
||||
}
|
||||
|
||||
await checkTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
|
||||
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model });
|
||||
const [aiConfig, privateBio] = await Promise.all([
|
||||
withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model }),
|
||||
getUserPrivateBio(userId),
|
||||
]);
|
||||
const adapted = await adaptRecipe(
|
||||
{
|
||||
title: recipe.title,
|
||||
@@ -57,12 +61,10 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
excludeIngredients: parsed.data.excludeIngredients,
|
||||
extraConstraints: parsed.data.extraConstraints,
|
||||
},
|
||||
aiConfig,
|
||||
{ ...aiConfig, userContext: privateBio ?? undefined },
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(userId, "aiCall");
|
||||
|
||||
const newId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
|
||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||
|
||||
const Schema = z.object({
|
||||
count: z.number().int().min(1).max(6).default(4),
|
||||
@@ -33,9 +34,12 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
const parsed = Schema.safeParse(body ?? {});
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
|
||||
const [aiConfig, privateBio] = await Promise.all([
|
||||
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
|
||||
getUserPrivateBio(session!.user.id),
|
||||
]);
|
||||
const drinks = await suggestDrinks(
|
||||
{
|
||||
title: recipe.title,
|
||||
@@ -45,11 +49,9 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
ingredients: recipe.ingredients,
|
||||
},
|
||||
parsed.data.count,
|
||||
aiConfig,
|
||||
{ ...aiConfig, userContext: privateBio ?? undefined },
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json({ drinks });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
|
||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
|
||||
const Schema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const privateBio = await getUserPrivateBio(session!.user.id);
|
||||
|
||||
const recipe = await generateRecipe(parsed.data.title, {
|
||||
provider: parsed.data.provider,
|
||||
model: parsed.data.model,
|
||||
userContext: privateBio ?? undefined,
|
||||
});
|
||||
|
||||
const recipeId = crypto.randomUUID();
|
||||
|
||||
await db.insert(recipes).values({
|
||||
id: recipeId,
|
||||
authorId: session!.user.id,
|
||||
title: recipe.title,
|
||||
description: recipe.description ?? null,
|
||||
baseServings: recipe.baseServings,
|
||||
prepMins: recipe.prepMins ?? null,
|
||||
cookMins: recipe.cookMins ?? null,
|
||||
difficulty: recipe.difficulty ?? null,
|
||||
visibility: "private",
|
||||
aiGenerated: true,
|
||||
language: "en",
|
||||
dietaryTags: recipe.dietaryTags ?? {},
|
||||
tags: [],
|
||||
});
|
||||
|
||||
if (recipe.ingredients?.length) {
|
||||
await db.insert(recipeIngredients).values(
|
||||
recipe.ingredients.map((ing, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId,
|
||||
rawName: ing.rawName,
|
||||
quantity: parseQuantity(ing.quantity) ?? null,
|
||||
unit: ing.unit ?? null,
|
||||
note: ing.note ?? null,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (recipe.steps?.length) {
|
||||
await db.insert(recipeSteps).values(
|
||||
recipe.steps.map((step, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId,
|
||||
instruction: step.instruction,
|
||||
timerSeconds: step.timerSeconds ?? null,
|
||||
order: i,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: recipeId });
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
|
||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||
|
||||
const Schema = z.object({
|
||||
prompt: z.string().min(3).max(500),
|
||||
language: z.string().max(10).default("en"),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
|
||||
model: z.string().optional(),
|
||||
});
|
||||
@@ -25,15 +27,17 @@ export async function POST(req: NextRequest) {
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const privateBio = await getUserPrivateBio(session!.user.id);
|
||||
|
||||
const recipe = await generateRecipe(parsed.data.prompt, {
|
||||
provider: parsed.data.provider,
|
||||
model: parsed.data.model,
|
||||
language: parsed.data.language,
|
||||
difficulty: parsed.data.difficulty,
|
||||
userContext: privateBio ?? undefined,
|
||||
});
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json(recipe);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
||||
import { importFromPhoto } from "@/lib/ai/features/import-photo";
|
||||
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||
|
||||
@@ -25,16 +25,18 @@ export async function POST(req: NextRequest) {
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 5, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
try {
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Tier limit reached";
|
||||
return NextResponse.json({ error: msg }, { status: 403 });
|
||||
}
|
||||
|
||||
const userId = session!.user.id;
|
||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||
|
||||
try {
|
||||
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TierLimitError) {
|
||||
return NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const aiConfig = await getModelConfigForUseCase(userId, "vision");
|
||||
|
||||
// Fall back to vision-capable defaults if no explicit model configured
|
||||
@@ -51,8 +53,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
|
||||
await incrementUsage(userId, "aiCall");
|
||||
|
||||
const newRecipeId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { importFromUrl } from "@/lib/ai/features/import-url";
|
||||
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
||||
|
||||
const Schema = z.object({
|
||||
url: z.string().url(),
|
||||
@@ -21,17 +22,20 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const ssrfError = await validateWebhookUrl(parsed.data.url);
|
||||
if (ssrfError) {
|
||||
return NextResponse.json({ error: ssrfError }, { status: 400 });
|
||||
}
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const recipe = await importFromUrl(parsed.data.url, {
|
||||
provider: parsed.data.provider,
|
||||
model: parsed.data.model,
|
||||
});
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json(recipe);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
|
||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||
|
||||
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
|
||||
@@ -15,6 +16,7 @@ const Schema = z.object({
|
||||
days: z.array(z.enum(DAYS)).min(1).max(7).default([...DAYS]),
|
||||
usePantry: z.boolean().default(false),
|
||||
pantryMode: z.boolean().default(false),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -32,7 +34,10 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const userId = session!.user.id;
|
||||
const locale = (session!.user as { locale?: string }).locale ?? "en";
|
||||
const config = await getDefaultProviderWithKey(userId);
|
||||
const [config, privateBio] = await Promise.all([
|
||||
getDefaultProviderWithKey(userId),
|
||||
getUserPrivateBio(userId),
|
||||
]);
|
||||
|
||||
// pantryMode forces usePantry on so pantry items are always fetched when maximizing pantry use
|
||||
const effectiveUsePantry = parsed.data.usePantry || parsed.data.pantryMode;
|
||||
@@ -54,8 +59,9 @@ export async function POST(req: NextRequest) {
|
||||
pantryItems: pantryItemNames,
|
||||
days: parsed.data.days,
|
||||
pantryMode: parsed.data.pantryMode,
|
||||
difficulty: parsed.data.difficulty,
|
||||
},
|
||||
config,
|
||||
{ ...config, userContext: privateBio ?? undefined },
|
||||
locale
|
||||
);
|
||||
|
||||
@@ -94,7 +100,7 @@ export async function POST(req: NextRequest) {
|
||||
id: crypto.randomUUID(),
|
||||
recipeId,
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity ?? null,
|
||||
quantity: ing.quantity != null ? String(ing.quantity) : null,
|
||||
unit: ing.unit ?? null,
|
||||
order: i,
|
||||
}))
|
||||
|
||||
@@ -3,9 +3,10 @@ import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
|
||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||
|
||||
const Schema = z.object({
|
||||
count: z.number().int().min(1).max(6).default(4),
|
||||
@@ -34,9 +35,12 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
const parsed = Schema.safeParse(body ?? {});
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
|
||||
const [aiConfig, privateBio] = await Promise.all([
|
||||
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
|
||||
getUserPrivateBio(session!.user.id),
|
||||
]);
|
||||
const pairings = await suggestPairings(
|
||||
{
|
||||
title: recipe.title,
|
||||
@@ -46,11 +50,9 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
ingredients: recipe.ingredients,
|
||||
},
|
||||
parsed.data.count,
|
||||
aiConfig,
|
||||
{ ...aiConfig, userContext: privateBio ?? undefined },
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json({ pairings });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { generateText } from "ai";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||
import { resolveModel } from "@/lib/ai/factory";
|
||||
import { db, recipes, eq, and } from "@epicure/db";
|
||||
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
|
||||
|
||||
const Schema = z.object({
|
||||
recipeId: z.string().uuid(),
|
||||
question: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 30, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) {
|
||||
return NextResponse.json({ error: "Recipe not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const ingredientList = recipe.ingredients
|
||||
.map((ing) => [ing.quantity, ing.unit, ing.rawName, ing.note ? `(${ing.note})` : ""].filter(Boolean).join(" "))
|
||||
.join("\n");
|
||||
|
||||
const stepList = recipe.steps
|
||||
.map((s, i) => `${i + 1}. ${s.instruction}`)
|
||||
.join("\n");
|
||||
|
||||
const recipeContext = `
|
||||
Recipe: ${recipe.title}
|
||||
${recipe.description ? `Description: ${recipe.description}` : ""}
|
||||
Difficulty: ${recipe.difficulty ?? "unknown"}
|
||||
Prep: ${recipe.prepMins ?? 0}min | Cook: ${recipe.cookMins ?? 0}min | Servings: ${recipe.baseServings}
|
||||
|
||||
INGREDIENTS:
|
||||
${ingredientList || "None listed"}
|
||||
|
||||
STEPS:
|
||||
${stepList || "None listed"}
|
||||
`.trim();
|
||||
|
||||
const [config, privateBio] = await Promise.all([
|
||||
getModelConfigForUseCase(session!.user.id, "text"),
|
||||
getUserPrivateBio(session!.user.id),
|
||||
]);
|
||||
const model = resolveModel(config);
|
||||
const bioContext = buildUserBioContext(privateBio);
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words.
|
||||
|
||||
${recipeContext}${bioContext}`,
|
||||
prompt: parsed.data.question,
|
||||
});
|
||||
|
||||
return NextResponse.json({ answer: text });
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { generateObject } from "ai";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
|
||||
import { resolveModel } from "@/lib/ai/factory";
|
||||
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
|
||||
|
||||
const InputSchema = z.object({
|
||||
prompt: z.string().max(300).optional(),
|
||||
});
|
||||
|
||||
const IdeasSchema = z.object({
|
||||
ideas: z.array(z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
tags: z.array(z.string()).max(4),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]),
|
||||
totalMins: z.number().int().optional(),
|
||||
})).length(6),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = InputSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const [config, privateBio] = await Promise.all([
|
||||
getModelConfigForUseCase(session!.user.id, "text"),
|
||||
getUserPrivateBio(session!.user.id),
|
||||
]);
|
||||
const model = resolveModel(config);
|
||||
const bioContext = buildUserBioContext(privateBio);
|
||||
|
||||
const userContext = bioContext
|
||||
? `Consider the following user preferences when suggesting ideas:${bioContext}\n\n`
|
||||
: "";
|
||||
|
||||
const prompt = parsed.data.prompt?.trim()
|
||||
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
|
||||
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: IdeasSchema,
|
||||
prompt,
|
||||
});
|
||||
|
||||
return NextResponse.json(object.ideas);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { db, recipes, eq, and, or } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { scaleRecipe } from "@/lib/ai/features/scale-recipe";
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await getDefaultProviderWithKey(session!.user.id);
|
||||
|
||||
@@ -56,7 +56,5 @@ export async function POST(req: NextRequest) {
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json({ ingredients: scaledIngredients });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -19,7 +20,17 @@ export async function POST(req: NextRequest) {
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
const rateLimitRes = await applyRateLimit(`ai:substitute:${session!.user.id}`, 10, 60);
|
||||
if (rateLimitRes) return rateLimitRes;
|
||||
|
||||
try {
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
} catch (err) {
|
||||
if (err instanceof TierLimitError) {
|
||||
return NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const context = parsed.data.recipeTitle
|
||||
? `recipe "${parsed.data.recipeTitle}"`
|
||||
@@ -31,6 +42,5 @@ export async function POST(req: NextRequest) {
|
||||
{ provider: parsed.data.provider, model: parsed.data.model }
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
return NextResponse.json({ substitutions });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { translateRecipe } from "@/lib/ai/features/translate-recipe";
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const translation = await translateRecipe(
|
||||
{
|
||||
@@ -48,8 +48,6 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
{ provider: parsed.data.provider, model: parsed.data.model }
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
// Save as new draft recipe
|
||||
const newId = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
@@ -3,9 +3,10 @@ import { z } from "zod";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { db, recipes } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit } from "@/lib/tiers";
|
||||
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
|
||||
import { withUserKey } from "@/lib/ai/resolve-user-key";
|
||||
import { getUserPrivateBio } from "@/lib/ai/user-bio";
|
||||
|
||||
const Schema = z.object({
|
||||
count: z.number().int().min(1).max(5).default(3),
|
||||
@@ -37,9 +38,12 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
|
||||
|
||||
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
|
||||
const [aiConfig, privateBio] = await Promise.all([
|
||||
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
|
||||
getUserPrivateBio(session!.user.id),
|
||||
]);
|
||||
const variations = await suggestVariations(
|
||||
{
|
||||
title: recipe.title,
|
||||
@@ -48,12 +52,10 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
steps: recipe.steps,
|
||||
},
|
||||
parsed.data.count,
|
||||
aiConfig,
|
||||
{ ...aiConfig, userContext: privateBio ?? undefined },
|
||||
parsed.data.directions,
|
||||
(session!.user as { locale?: string }).locale ?? "en"
|
||||
);
|
||||
|
||||
await incrementUsage(session!.user.id, "aiCall");
|
||||
|
||||
return NextResponse.json({ variations });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { db, apiKeys, eq } from "@epicure/db";
|
||||
import { db, apiKeys, eq, sql } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
const CreateApiKeyBody = z.object({
|
||||
@@ -38,6 +38,14 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(apiKeys)
|
||||
.where(eq(apiKeys.userId, session!.user.id));
|
||||
if ((row?.count ?? 0) >= 10) {
|
||||
return NextResponse.json({ error: "API key limit reached (max 10)" }, { status: 403 });
|
||||
}
|
||||
|
||||
const rawKey = "ek_" + crypto.randomBytes(32).toString("hex");
|
||||
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, collections, collectionRecipes, recipes, eq, and } from "@epicure/db";
|
||||
import { db, collections, collectionRecipes, recipes, eq, and, or, ne } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
@@ -50,7 +50,12 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
}
|
||||
|
||||
if (data.addRecipeId) {
|
||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, data.addRecipeId) });
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, data.addRecipeId),
|
||||
or(eq(recipes.authorId, session!.user.id), ne(recipes.visibility, "private"))
|
||||
),
|
||||
});
|
||||
if (recipe) {
|
||||
await db.insert(collectionRecipes).values({ collectionId: id, recipeId: data.addRecipeId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, users, userFollows, eq } from "@epicure/db";
|
||||
import { db, recipes, users, userFollows, eq, and, ne } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { desc, inArray } from "@epicure/db";
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function GET(req: NextRequest) {
|
||||
})
|
||||
.from(recipes)
|
||||
.innerJoin(users, eq(recipes.authorId, users.id))
|
||||
.where((t) => inArray(t.authorId, followedIds))
|
||||
.where((t) => and(inArray(t.authorId, followedIds), ne(recipes.visibility, "private")))
|
||||
.orderBy(desc(recipes.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
@@ -7,8 +7,8 @@ const Schema = z.object({
|
||||
items: z.array(z.object({
|
||||
rawName: z.string().min(1).max(200),
|
||||
quantity: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
})).min(1),
|
||||
unit: z.string().max(50).optional(),
|
||||
})).min(1).max(100),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
||||
@@ -39,10 +39,10 @@ export async function POST(request: Request) {
|
||||
.onConflictDoUpdate({
|
||||
target: pushSubscriptions.endpoint,
|
||||
set: {
|
||||
userId: session!.user.id,
|
||||
p256dh: body.keys.p256dh,
|
||||
auth: body.keys.auth,
|
||||
},
|
||||
setWhere: eq(pushSubscriptions.userId, session!.user.id),
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -30,7 +30,8 @@ export async function GET(req: NextRequest, { params }: Params) {
|
||||
counts[row.type] = row.cnt;
|
||||
}
|
||||
|
||||
// Check for session to return user's reactions
|
||||
// Optional auth — GET reactions is public; session present means also return user's own reactions
|
||||
// response intentionally ignored: unauthenticated callers get counts only with empty userReactions
|
||||
const { session } = await requireSession();
|
||||
let userReactions: string[] = [];
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { requireSession } from "@/lib/api-auth";
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
const Schema = z.object({
|
||||
servings: z.number().int().min(1).optional(),
|
||||
servings: z.number().int().min(1).max(1000).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
deductFromPantry: z.boolean().default(true),
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function POST(_req: NextRequest, { params }: Params) {
|
||||
})),
|
||||
});
|
||||
|
||||
await db.update(recipes).set({ nutritionData: result }).where(eq(recipes.id, id));
|
||||
await db.update(recipes).set({ nutritionData: result }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
|
||||
|
||||
return NextResponse.json({ nutrition: result });
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import { eq, and, max } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
|
||||
const UpdateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
baseServings: z.number().int().min(1).max(100).optional(),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
||||
prepMins: z.number().int().min(0).nullable().optional(),
|
||||
cookMins: z.number().int().min(0).nullable().optional(),
|
||||
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
@@ -23,17 +25,17 @@ const UpdateRecipeSchema = z.object({
|
||||
kosher: z.boolean().optional(),
|
||||
}).optional(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string().min(1),
|
||||
quantity: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
rawName: z.string().min(1).max(200),
|
||||
quantity: z.union([z.number(), z.string().max(50)]).optional().transform(parseQuantity),
|
||||
unit: z.string().max(50).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
order: z.number().int().default(0),
|
||||
})).optional(),
|
||||
})).max(100).optional(),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
instruction: z.string().min(1).max(2000),
|
||||
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||
order: z.number().int(),
|
||||
})).optional(),
|
||||
})).max(100).optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
@@ -119,6 +121,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
|
||||
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
|
||||
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
|
||||
if (data.tags !== undefined) updates.tags = data.tags;
|
||||
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
||||
|
||||
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mock dependencies ---
|
||||
const mockSession = {
|
||||
user: { id: "user-1", name: "Test User", email: "test@test.com", tier: "free", role: "user" },
|
||||
};
|
||||
|
||||
vi.mock("@/lib/api-auth", () => ({
|
||||
requireSessionOrApiKey: vi.fn(),
|
||||
requireSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tiers", () => ({
|
||||
checkTierLimit: vi.fn(),
|
||||
incrementUsage: vi.fn(),
|
||||
TierLimitError: class TierLimitError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/webhooks", () => ({
|
||||
dispatchWebhook: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const { mockTransaction, mockInsertValues, mockSelectChain } = vi.hoisted(() => {
|
||||
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
return { mockTransaction: vi.fn(), mockInsertValues, mockSelectChain };
|
||||
});
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => mockSelectChain),
|
||||
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||
transaction: mockTransaction,
|
||||
query: {
|
||||
recipes: { findFirst: vi.fn().mockResolvedValue({ id: "new-id", title: "Test Recipe" }) },
|
||||
},
|
||||
},
|
||||
recipes: { id: "id", authorId: "author_id", title: "title", visibility: "visibility" },
|
||||
recipeIngredients: {},
|
||||
recipeSteps: {},
|
||||
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||
desc: vi.fn((a) => ({ a, op: "desc" })),
|
||||
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||
count: vi.fn(() => ({ op: "count" })),
|
||||
ilike: vi.fn((a, b) => ({ a, b, op: "ilike" })),
|
||||
or: vi.fn((...args) => ({ args, op: "or" })),
|
||||
sql: vi.fn(),
|
||||
}));
|
||||
|
||||
const { requireSessionOrApiKey } = await import("@/lib/api-auth");
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
|
||||
function makeRequest(method: string, body?: unknown, search = "") {
|
||||
const url = `http://localhost/api/v1/recipes${search}`;
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireSessionOrApiKey).mockResolvedValue({ session: mockSession as never, response: null });
|
||||
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })),
|
||||
};
|
||||
return fn(tx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/recipes", () => {
|
||||
it("returns 200 with empty list", async () => {
|
||||
mockSelectChain.offset.mockResolvedValue([]);
|
||||
const res = await GET(makeRequest("GET"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { data: unknown[] };
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
|
||||
session: null,
|
||||
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||
} as never);
|
||||
|
||||
const res = await GET(makeRequest("GET"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/recipes", () => {
|
||||
const validRecipe = {
|
||||
title: "Test Recipe",
|
||||
baseServings: 4,
|
||||
visibility: "private",
|
||||
ingredients: [{ rawName: "flour", quantity: "2", unit: "cups" }],
|
||||
steps: [{ instruction: "Mix everything" }],
|
||||
};
|
||||
|
||||
it("returns 201 on valid recipe creation", async () => {
|
||||
const res = await POST(makeRequest("POST", validRecipe));
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json() as { id: string };
|
||||
expect(body.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 400 on invalid body (missing title)", async () => {
|
||||
const res = await POST(makeRequest("POST", { baseServings: 4 }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when title is empty", async () => {
|
||||
const res = await POST(makeRequest("POST", { ...validRecipe, title: "" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when title is too long", async () => {
|
||||
const res = await POST(makeRequest("POST", { ...validRecipe, title: "x".repeat(201) }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 403 when tier limit reached", async () => {
|
||||
const { checkTierLimit } = await import("@/lib/tiers");
|
||||
const { TierLimitError } = await import("@/lib/tiers");
|
||||
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
|
||||
|
||||
const res = await POST(makeRequest("POST", validRecipe));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
|
||||
session: null,
|
||||
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest("POST", validRecipe));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mockSession = {
|
||||
user: { id: "user-1", name: "Test", email: "t@t.com", tier: "free" },
|
||||
};
|
||||
|
||||
const { mockRequireSession } = vi.hoisted(() => ({
|
||||
mockRequireSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-auth", () => ({
|
||||
requireSession: mockRequireSession,
|
||||
}));
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
})),
|
||||
},
|
||||
recipes: { id: "id", authorId: "author_id", visibility: "visibility" },
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
}));
|
||||
|
||||
import { DELETE, PATCH } from "../route";
|
||||
|
||||
function makeRequest(method: string, body: unknown) {
|
||||
return new NextRequest(`http://localhost/api/v1/recipes/bulk`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireSession.mockResolvedValue({ session: mockSession, response: null });
|
||||
});
|
||||
|
||||
describe("DELETE /api/v1/recipes/bulk", () => {
|
||||
it("returns 200 on valid ids", async () => {
|
||||
const res = await DELETE(makeRequest("DELETE", { ids: ["r1", "r2"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when ids is empty array", async () => {
|
||||
const res = await DELETE(makeRequest("DELETE", { ids: [] }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when body is invalid", async () => {
|
||||
const res = await DELETE(makeRequest("DELETE", { notIds: "wrong" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
mockRequireSession.mockResolvedValue({
|
||||
session: null,
|
||||
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||
});
|
||||
const res = await DELETE(makeRequest("DELETE", { ids: ["r1"] }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/v1/recipes/bulk", () => {
|
||||
it("returns 200 on valid visibility update", async () => {
|
||||
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 when visibility is missing", async () => {
|
||||
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"] }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when ids is empty", async () => {
|
||||
const res = await PATCH(makeRequest("PATCH", { ids: [], visibility: "public" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
mockRequireSession.mockResolvedValue({
|
||||
session: null,
|
||||
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||
});
|
||||
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -13,22 +13,22 @@ const BulkUpdateSchema = z.object({
|
||||
});
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (session instanceof NextResponse) return session;
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = BulkDeleteSchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
|
||||
await db
|
||||
.delete(recipes)
|
||||
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session.user.id)));
|
||||
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session!.user.id)));
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (session instanceof NextResponse) return session;
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = BulkUpdateSchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
@@ -39,7 +39,7 @@ export async function PATCH(req: NextRequest) {
|
||||
await db
|
||||
.update(recipes)
|
||||
.set({ visibility, updatedAt: new Date() })
|
||||
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session.user.id)));
|
||||
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)));
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,56 +3,21 @@ import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { eq, desc, and } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
|
||||
const UNICODE_FRACTIONS: Record<string, number> = {
|
||||
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
|
||||
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
|
||||
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
|
||||
};
|
||||
|
||||
function parseQuantity(v: string | number | undefined): string | undefined {
|
||||
if (v === undefined || v === "") return undefined;
|
||||
const s = String(v).trim();
|
||||
if (!s) return undefined;
|
||||
|
||||
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
|
||||
|
||||
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
|
||||
if (s.endsWith(frac)) {
|
||||
const whole = s.slice(0, -frac.length).trim();
|
||||
if (!whole) return String(val);
|
||||
const w = parseFloat(whole);
|
||||
if (!isNaN(w)) return String(w + val);
|
||||
}
|
||||
}
|
||||
|
||||
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
|
||||
if (mixed) {
|
||||
const den = parseInt(mixed[3]!);
|
||||
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
|
||||
}
|
||||
|
||||
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
|
||||
if (slash) {
|
||||
const den = parseInt(slash[2]!);
|
||||
if (den !== 0) return String(parseInt(slash[1]!) / den);
|
||||
}
|
||||
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? undefined : String(n);
|
||||
}
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
|
||||
const CreateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
baseServings: z.number().int().min(1).max(100).default(4),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
prepMins: z.number().int().min(0).optional(),
|
||||
cookMins: z.number().int().min(0).optional(),
|
||||
prepMins: z.number().int().min(0).max(1440).optional(),
|
||||
cookMins: z.number().int().min(0).max(1440).optional(),
|
||||
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
|
||||
aiGenerated: z.boolean().optional(),
|
||||
language: z.string().max(10).optional(),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
@@ -63,17 +28,17 @@ const CreateRecipeSchema = z.object({
|
||||
kosher: z.boolean().optional(),
|
||||
}).optional(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string().min(1),
|
||||
rawName: z.string().min(1).max(200),
|
||||
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
unit: z.string().max(50).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
order: z.number().int().default(0),
|
||||
})).default([]),
|
||||
})).max(100).default([]),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
instruction: z.string().min(1).max(2000),
|
||||
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||
order: z.number().int().optional(),
|
||||
})).default([]),
|
||||
})).max(100).default([]),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -109,7 +74,14 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
|
||||
try {
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
|
||||
} catch (err) {
|
||||
if (err instanceof TierLimitError) {
|
||||
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
@@ -126,8 +98,10 @@ export async function POST(req: NextRequest) {
|
||||
difficulty: data.difficulty,
|
||||
prepMins: data.prepMins,
|
||||
cookMins: data.cookMins,
|
||||
tags: data.tags,
|
||||
dietaryTags: data.dietaryTags ?? {},
|
||||
aiGenerated: data.aiGenerated ?? false,
|
||||
language: data.language,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
@@ -159,8 +133,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
});
|
||||
|
||||
await incrementUsage(session!.user.id, "recipe");
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
|
||||
return NextResponse.json(recipe, { status: 201 });
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl;
|
||||
|
||||
// --- Parse & validate required param ---
|
||||
const q = searchParams.get("q")?.trim() ?? "";
|
||||
const q = (searchParams.get("q") ?? "").trim().slice(0, 200);
|
||||
if (!q) {
|
||||
return NextResponse.json(
|
||||
{ error: "Query parameter 'q' is required and must not be empty." },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { createPresignedUploadUrl } from "@/lib/storage";
|
||||
import { db, recipes, eq, and } from "@epicure/db";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"] as const;
|
||||
type AllowedType = (typeof ALLOWED_TYPES)[number];
|
||||
@@ -23,6 +24,12 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const owned = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)),
|
||||
columns: { id: true },
|
||||
});
|
||||
if (!owned) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const ext = parsed.data.contentType.split("/")[1] ?? "jpg";
|
||||
const key = `recipes/${parsed.data.recipeId}/photos/${session!.user.id}-${crypto.randomUUID()}.${ext}`;
|
||||
const url = await createPresignedUploadUrl(key, parsed.data.contentType);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { z } from "zod";
|
||||
const PatchSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
locale: z.string().max(10).optional(),
|
||||
bio: z.string().max(500).optional().nullable(),
|
||||
privateBio: z.string().max(2000).optional().nullable(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, webhooks, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
||||
|
||||
const VALID_EVENTS = ["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted"] as const;
|
||||
|
||||
@@ -66,10 +67,9 @@ export async function PATCH(
|
||||
}
|
||||
|
||||
if (parsed.data.url) {
|
||||
try {
|
||||
new URL(parsed.data.url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||
const ssrfError = await validateWebhookUrl(parsed.data.url);
|
||||
if (ssrfError) {
|
||||
return NextResponse.json({ error: ssrfError }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { db, webhooks, eq } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
||||
|
||||
const VALID_EVENTS = ["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted"] as const;
|
||||
|
||||
@@ -49,11 +50,10 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(parsed.data.url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
|
||||
// Validate URL — enforce https/http only and block SSRF targets
|
||||
const ssrfError = await validateWebhookUrl(parsed.data.url);
|
||||
if (ssrfError) {
|
||||
return NextResponse.json({ error: ssrfError }, { status: 400 });
|
||||
}
|
||||
|
||||
const secret = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
@@ -1,19 +1,93 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
// Stripe webhook stub — wire up when adding Stripe
|
||||
// Verifies stripe-signature header (using raw body), handles:
|
||||
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
|
||||
// Handles:
|
||||
// - checkout.session.completed → upgrade user to pro
|
||||
// - customer.subscription.deleted → downgrade user to free
|
||||
|
||||
const STRIPE_TOLERANCE_SECONDS = 300; // 5 minutes
|
||||
|
||||
function verifyStripeSignature(
|
||||
rawBody: string,
|
||||
sigHeader: string,
|
||||
secret: string
|
||||
): { valid: boolean; payload: string | null } {
|
||||
// sigHeader format: "t=<timestamp>,v1=<hmac>[,v1=<hmac>...]"
|
||||
const parts = sigHeader.split(",");
|
||||
const tPart = parts.find((p) => p.startsWith("t="));
|
||||
const v1Parts = parts.filter((p) => p.startsWith("v1="));
|
||||
|
||||
if (!tPart || v1Parts.length === 0) {
|
||||
return { valid: false, payload: null };
|
||||
}
|
||||
|
||||
const timestamp = tPart.slice(2);
|
||||
const tsNum = parseInt(timestamp, 10);
|
||||
if (isNaN(tsNum)) return { valid: false, payload: null };
|
||||
|
||||
// Reject stale webhooks
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(nowSec - tsNum) > STRIPE_TOLERANCE_SECONDS) {
|
||||
return { valid: false, payload: null };
|
||||
}
|
||||
|
||||
const signedPayload = `${timestamp}.${rawBody}`;
|
||||
const expected = crypto
|
||||
.createHmac("sha256", secret)
|
||||
.update(signedPayload, "utf8")
|
||||
.digest();
|
||||
|
||||
const matched = v1Parts.some((v1Part) => {
|
||||
const provided = v1Part.slice(3); // strip "v1="
|
||||
let providedBuf: Buffer;
|
||||
try {
|
||||
providedBuf = Buffer.from(provided, "hex");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (providedBuf.length !== expected.length) return false;
|
||||
return crypto.timingSafeEqual(expected, providedBuf);
|
||||
});
|
||||
|
||||
return { valid: matched, payload: matched ? rawBody : null };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
const sig = req.headers.get("stripe-signature");
|
||||
const webhookSecret = process.env["STRIPE_WEBHOOK_SECRET"];
|
||||
|
||||
if (!sig || !process.env["STRIPE_WEBHOOK_SECRET"]) {
|
||||
if (!sig || !webhookSecret) {
|
||||
return NextResponse.json({ error: "Stripe not configured" }, { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: const event = stripe.webhooks.constructEvent(body, sig, process.env["STRIPE_WEBHOOK_SECRET"]);
|
||||
// For now just return 200 to acknowledge receipt
|
||||
console.log("[stripe-webhook] received event, sig:", sig.slice(0, 20));
|
||||
const { valid } = verifyStripeSignature(body, sig, webhookSecret);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
||||
}
|
||||
|
||||
let event: { type: string; data: { object: Record<string, unknown> } };
|
||||
try {
|
||||
event = JSON.parse(body) as typeof event;
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: wire up DB calls when Stripe billing is fully configured
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
// upgrade user to pro
|
||||
console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
// downgrade user to free
|
||||
console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]);
|
||||
break;
|
||||
default:
|
||||
// ignore unhandled event types
|
||||
break;
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, mealPlans, eq, and } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
|
||||
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
|
||||
const DAY_LABELS: Record<string, string> = {
|
||||
mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday",
|
||||
fri: "Friday", sat: "Saturday", sun: "Sunday",
|
||||
};
|
||||
const MEAL_ORDER = ["breakfast", "lunch", "dinner", "snack"] as const;
|
||||
const MEAL_LABELS: Record<string, string> = {
|
||||
breakfast: "Breakfast", lunch: "Lunch", dinner: "Dinner", snack: "Snack",
|
||||
};
|
||||
|
||||
function getMonday(dateStr?: string): Date {
|
||||
const d = dateStr ? new Date(dateStr) : new Date();
|
||||
const day = d.getDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
d.setDate(d.getDate() + diff);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export default async function MealPlanPrintPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ week?: string }>;
|
||||
}) {
|
||||
const { week } = await searchParams;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const monday = getMonday(week);
|
||||
const weekStart = monday.toISOString().slice(0, 10);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(sunday.getDate() + 6);
|
||||
|
||||
const plan = await db.query.mealPlans.findFirst({
|
||||
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
|
||||
with: { entries: { with: { recipe: true } } },
|
||||
});
|
||||
|
||||
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
|
||||
|
||||
const entries = plan?.entries ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 900px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1 { font-size: 1.8em; margin: 0 0 2px; }
|
||||
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
th { text-align: left; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.06em; color: #888; padding: 6px 8px; border-bottom: 2px solid #ccc; }
|
||||
td { padding: 6px 8px; border-bottom: 1px solid #eee; border-right: 1px solid #eee; vertical-align: top; min-height: 40px; }
|
||||
td:last-child { border-right: none; }
|
||||
.meal-label { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #aaa; display: block; margin-bottom: 2px; }
|
||||
.recipe-title { font-weight: 500; }
|
||||
.servings { font-size: 0.8em; color: #888; }
|
||||
.empty { color: #ccc; font-size: 0.85em; }
|
||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
|
||||
.print-btn {
|
||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.print-btn:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
|
||||
<PrintTrigger />
|
||||
|
||||
<h1>Meal Plan</h1>
|
||||
<p className="subtitle">{label}</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "100px" }}>Day</th>
|
||||
{MEAL_ORDER.map((m) => (
|
||||
<th key={m}>{MEAL_LABELS[m]}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{DAYS.map((day) => (
|
||||
<tr key={day}>
|
||||
<td style={{ fontWeight: 600, color: "#555" }}>{DAY_LABELS[day]}</td>
|
||||
{MEAL_ORDER.map((mealType) => {
|
||||
const entry = entries.find((e) => e.day === day && e.mealType === mealType);
|
||||
return (
|
||||
<td key={mealType}>
|
||||
{entry ? (
|
||||
<>
|
||||
<span className="recipe-title">{entry.recipe?.title ?? entry.note ?? "—"}</span>
|
||||
{entry.servings && (
|
||||
<span className="servings"> · {entry.servings} srv</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="empty">—</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<footer>Printed from Epicure</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, pantryItems, eq, asc } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
|
||||
export default async function PantryPrintPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const items = await db.query.pantryItems.findMany({
|
||||
where: eq(pantryItems.userId, session.user.id),
|
||||
orderBy: asc(pantryItems.rawName),
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 680px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 1.8em; margin: 0 0 4px; }
|
||||
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.06em; color: #888; padding: 4px 8px 4px 0; border-bottom: 1px solid #ccc; }
|
||||
td { padding: 6px 8px 6px 0; border-bottom: 1px dotted #eee; font-size: 0.9em; vertical-align: top; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
.expiry-soon { color: #b45309; }
|
||||
.expiry-expired { color: #dc2626; }
|
||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
|
||||
.print-btn {
|
||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.print-btn:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
|
||||
<PrintTrigger />
|
||||
|
||||
<h1>Pantry</h1>
|
||||
<p className="subtitle">{items.length} item{items.length !== 1 ? "s" : ""}</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p style={{ color: "#888" }}>No items in pantry.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Quantity</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const exp = item.expiresAt ? new Date(item.expiresAt) : null;
|
||||
const daysLeft = exp ? Math.ceil((exp.getTime() - now.getTime()) / 86400000) : null;
|
||||
const expiryClass = daysLeft === null ? "" : daysLeft < 0 ? "expiry-expired" : daysLeft <= 3 ? "expiry-soon" : "";
|
||||
return (
|
||||
<tr key={item.id}>
|
||||
<td>{item.rawName}</td>
|
||||
<td>{[item.quantity, item.unit].filter(Boolean).join(" ") || "—"}</td>
|
||||
<td className={expiryClass}>
|
||||
{exp
|
||||
? daysLeft !== null && daysLeft < 0
|
||||
? `Expired ${Math.abs(daysLeft)}d ago`
|
||||
: exp.toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric" })
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<footer>Printed from Epicure</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, shoppingLists, eq, and } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function ShoppingListPrintPage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const list = await db.query.shoppingLists.findFirst({
|
||||
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session.user.id)),
|
||||
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
|
||||
});
|
||||
|
||||
if (!list) notFound();
|
||||
|
||||
const byAisle = list.items.reduce<Record<string, typeof list.items>>((acc, item) => {
|
||||
const key = item.aisle ?? "Other";
|
||||
(acc[key] ??= []).push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const aisles = Object.keys(byAisle).sort((a, b) =>
|
||||
a === "Other" ? 1 : b === "Other" ? -1 : a.localeCompare(b)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 680px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 1.8em; margin: 0 0 4px; }
|
||||
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
|
||||
h2 { font-size: 0.9em; text-transform: uppercase; letter-spacing: 0.08em; color: #888; border-bottom: 1px solid #e0e0e0; padding-bottom: 4px; margin: 20px 0 8px; }
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
li { display: flex; align-items: baseline; gap: 8px; padding: 5px 0; border-bottom: 1px dotted #eee; font-size: 0.95em; }
|
||||
li:last-child { border-bottom: none; }
|
||||
.check { width: 16px; height: 16px; border: 1.5px solid #999; border-radius: 3px; flex-shrink: 0; display: inline-block; }
|
||||
.checked .check { background: #18181b; border-color: #18181b; }
|
||||
.checked span { text-decoration: line-through; color: #999; }
|
||||
.qty { color: #666; min-width: 70px; font-variant-numeric: tabular-nums; }
|
||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
|
||||
.print-btn {
|
||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.print-btn:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
|
||||
<PrintTrigger />
|
||||
|
||||
<h1>{list.name}</h1>
|
||||
<p className="subtitle">
|
||||
{list.items.filter(i => i.checked).length}/{list.items.length} items checked
|
||||
{list.generatedAt ? " · From meal plan" : ""}
|
||||
</p>
|
||||
|
||||
{aisles.map((aisle) => (
|
||||
<section key={aisle}>
|
||||
{aisles.length > 1 && <h2>{aisle}</h2>}
|
||||
<ul>
|
||||
{byAisle[aisle]!.map((item) => (
|
||||
<li key={item.id} className={item.checked ? "checked" : ""}>
|
||||
<span className="check" />
|
||||
<span className="qty">
|
||||
{[item.quantity, item.unit].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
<span>{item.rawName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<footer>Printed from Epicure</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { encrypt, decrypt } from "../encrypt";
|
||||
|
||||
describe("encrypt / decrypt", () => {
|
||||
it("round-trips a short string", () => {
|
||||
const plain = "hello world";
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("round-trips an empty string", () => {
|
||||
expect(decrypt(encrypt(""))).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips a unicode string", () => {
|
||||
const plain = "café ☕ résumé";
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("round-trips a long string (API key-sized)", () => {
|
||||
const plain = "sk-proj-" + "a".repeat(128);
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("produces different ciphertexts for same input (random IV)", () => {
|
||||
const plain = "same input";
|
||||
const c1 = encrypt(plain);
|
||||
const c2 = encrypt(plain);
|
||||
expect(c1).not.toBe(c2);
|
||||
// but both decrypt to same value
|
||||
expect(decrypt(c1)).toBe(plain);
|
||||
expect(decrypt(c2)).toBe(plain);
|
||||
});
|
||||
|
||||
it("ciphertext format is iv:authTag:encrypted (3 hex segments)", () => {
|
||||
const ct = encrypt("test");
|
||||
const parts = ct.split(":");
|
||||
expect(parts).toHaveLength(3);
|
||||
parts.forEach((p) => expect(p).toMatch(/^[0-9a-f]+$/));
|
||||
});
|
||||
|
||||
it("throws on malformed ciphertext (wrong segments)", () => {
|
||||
expect(() => decrypt("notvalid")).toThrow("Invalid ciphertext format");
|
||||
expect(() => decrypt("a:b")).toThrow("Invalid ciphertext format");
|
||||
});
|
||||
|
||||
it("throws on tampered ciphertext (auth tag mismatch)", () => {
|
||||
const ct = encrypt("sensitive");
|
||||
const parts = ct.split(":");
|
||||
// flip a byte in the encrypted payload
|
||||
const tampered = parts[0] + ":" + parts[1] + ":" + "00" + parts[2]!.slice(2);
|
||||
expect(() => decrypt(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatQuantity, scaleQuantity } from "../fractions";
|
||||
|
||||
describe("formatQuantity", () => {
|
||||
it("returns '0' for 0", () => {
|
||||
expect(formatQuantity(0)).toBe("0");
|
||||
});
|
||||
|
||||
it("returns whole numbers as strings", () => {
|
||||
expect(formatQuantity(1)).toBe("1");
|
||||
expect(formatQuantity(4)).toBe("4");
|
||||
});
|
||||
|
||||
it("rounds near-integer values up", () => {
|
||||
expect(formatQuantity(0.97)).toBe("1");
|
||||
expect(formatQuantity(2.96)).toBe("3");
|
||||
});
|
||||
|
||||
it("formats known fractions with unicode symbols", () => {
|
||||
expect(formatQuantity(0.5)).toBe("½");
|
||||
expect(formatQuantity(0.25)).toBe("¼");
|
||||
expect(formatQuantity(0.75)).toBe("¾");
|
||||
expect(formatQuantity(0.333)).toBe("⅓");
|
||||
expect(formatQuantity(0.667)).toBe("⅔");
|
||||
expect(formatQuantity(0.125)).toBe("⅛");
|
||||
});
|
||||
|
||||
it("formats mixed numbers", () => {
|
||||
expect(formatQuantity(1.5)).toBe("1 ½");
|
||||
expect(formatQuantity(2.25)).toBe("2 ¼");
|
||||
expect(formatQuantity(3.75)).toBe("3 ¾");
|
||||
});
|
||||
|
||||
it("falls back to one decimal for values that don't match a fraction", () => {
|
||||
// 0.4375 is midpoint between ⅜ (0.375) and ½ (0.5), diff=0.0625 from both — exceeds 0.06 threshold
|
||||
expect(formatQuantity(0.4375)).toMatch(/^0\.\d$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleQuantity", () => {
|
||||
it("returns empty string for null/empty base quantity", () => {
|
||||
expect(scaleQuantity(null, 4, 2)).toBe("");
|
||||
expect(scaleQuantity("", 4, 2)).toBe("");
|
||||
});
|
||||
|
||||
it("returns base quantity unchanged for non-numeric strings", () => {
|
||||
expect(scaleQuantity("to taste", 4, 2)).toBe("to taste");
|
||||
});
|
||||
|
||||
it("scales down by half", () => {
|
||||
expect(scaleQuantity("4", 4, 2)).toBe("2");
|
||||
});
|
||||
|
||||
it("scales up by double", () => {
|
||||
expect(scaleQuantity("2", 2, 4)).toBe("4");
|
||||
});
|
||||
|
||||
it("handles base=0 gracefully (no division by zero)", () => {
|
||||
expect(scaleQuantity("3", 0, 4)).toBe("3");
|
||||
});
|
||||
|
||||
it("produces fraction symbols when result is fractional", () => {
|
||||
expect(scaleQuantity("1", 2, 1)).toBe("½");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const pipelineChain = vi.hoisted(() => ({
|
||||
zadd: vi.fn().mockReturnThis(),
|
||||
zremrangebyscore: vi.fn().mockReturnThis(),
|
||||
zcard: vi.fn().mockReturnThis(),
|
||||
expire: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../redis", () => ({
|
||||
getRedis: vi.fn(() => ({ pipeline: vi.fn(() => pipelineChain) })),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: vi.fn((body: unknown, init?: { status?: number }) => ({ body, init, isNextResponse: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
const { applyRateLimit } = await import("../rate-limit");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("applyRateLimit", () => {
|
||||
it("returns null when under the limit", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 3], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 429 response when over the limit", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 11], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).not.toBeNull();
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(String) }),
|
||||
expect.objectContaining({ status: 429 })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when exactly at limit (count === limit is allowed)", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 10], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("calls pipeline with zadd, zremrangebyscore, zcard, expire", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 1], null]);
|
||||
await applyRateLimit("my-key", 5, 30);
|
||||
expect(pipelineChain.zadd).toHaveBeenCalled();
|
||||
expect(pipelineChain.zremrangebyscore).toHaveBeenCalled();
|
||||
expect(pipelineChain.zcard).toHaveBeenCalled();
|
||||
expect(pipelineChain.expire).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const mockFindFirst = vi.fn();
|
||||
const mockFindMany = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
siteSettings: {
|
||||
findFirst: mockFindFirst,
|
||||
findMany: mockFindMany,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
delete: mockDelete,
|
||||
},
|
||||
siteSettings: { key: "key", value: "value", isSecret: "is_secret", updatedAt: "updated_at", updatedById: "updated_by_id" },
|
||||
eq: vi.fn((a, b) => ({ a, b })),
|
||||
}));
|
||||
|
||||
const insertChain = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const deleteChain = {
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const { getSiteSetting, setSiteSetting, isSecretKey } = await import("../site-settings");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInsert.mockReturnValue(insertChain);
|
||||
mockDelete.mockReturnValue(deleteChain);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
delete process.env["ANTHROPIC_API_KEY"];
|
||||
});
|
||||
|
||||
describe("isSecretKey", () => {
|
||||
it("marks API keys as secret", () => {
|
||||
expect(isSecretKey("OPENAI_API_KEY")).toBe(true);
|
||||
expect(isSecretKey("ANTHROPIC_API_KEY")).toBe(true);
|
||||
expect(isSecretKey("VAPID_PRIVATE_KEY")).toBe(true);
|
||||
});
|
||||
|
||||
it("marks non-secret settings as public", () => {
|
||||
expect(isSecretKey("OPENROUTER_DEFAULT_MODEL")).toBe(false);
|
||||
expect(isSecretKey("OLLAMA_BASE_URL")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSiteSetting", () => {
|
||||
it("returns DB value when present (plain)", async () => {
|
||||
mockFindFirst.mockResolvedValue({ value: "gpt-4o", isSecret: false });
|
||||
const val = await getSiteSetting("OPENROUTER_DEFAULT_MODEL");
|
||||
expect(val).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("decrypts DB value when secret", async () => {
|
||||
// Pre-encrypt something using the real encrypt function
|
||||
const { encrypt } = await import("../encrypt");
|
||||
const ciphertext = encrypt("sk-real-key");
|
||||
mockFindFirst.mockResolvedValue({ value: ciphertext, isSecret: true });
|
||||
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBe("sk-real-key");
|
||||
});
|
||||
|
||||
it("falls back to env var when no DB row", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
process.env["OPENAI_API_KEY"] = "sk-from-env";
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBe("sk-from-env");
|
||||
});
|
||||
|
||||
it("returns null when neither DB nor env is set", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to env when DB throws", async () => {
|
||||
mockFindFirst.mockRejectedValue(new Error("DB error"));
|
||||
process.env["ANTHROPIC_API_KEY"] = "sk-anthropic-env";
|
||||
const val = await getSiteSetting("ANTHROPIC_API_KEY");
|
||||
expect(val).toBe("sk-anthropic-env");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSiteSetting", () => {
|
||||
it("deletes the row when value is null", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", null, "admin-user");
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
expect(mockInsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the row when value is empty string", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", "", "admin-user");
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inserts encrypted value for secret key", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", "sk-test", "admin-user");
|
||||
expect(mockInsert).toHaveBeenCalled();
|
||||
const inserted = insertChain.values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(inserted.isSecret).toBe(true);
|
||||
// stored value should not be plain text
|
||||
expect(inserted.value).not.toBe("sk-test");
|
||||
expect(typeof inserted.value).toBe("string");
|
||||
});
|
||||
|
||||
it("inserts plain value for non-secret key", async () => {
|
||||
await setSiteSetting("OPENROUTER_DEFAULT_MODEL", "gpt-4o", "admin-user");
|
||||
const inserted = insertChain.values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(inserted.isSecret).toBe(false);
|
||||
expect(inserted.value).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TierLimitError } from "../tiers";
|
||||
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: mockDb,
|
||||
tierDefinitions: { tier: "tier" },
|
||||
userUsage: {
|
||||
userId: "user_id",
|
||||
month: "month",
|
||||
aiCallsUsed: "ai_calls_used",
|
||||
recipeCount: "recipe_count",
|
||||
storageUsedMb: "storage_used_mb",
|
||||
},
|
||||
eq: vi.fn((col, val) => ({ col, val, op: "eq" })),
|
||||
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||
sql: vi.fn((strings, ...values) => ({ strings, values, op: "sql" })),
|
||||
}));
|
||||
|
||||
// Import after mock
|
||||
const { checkTierLimit, incrementUsage } = await import("../tiers");
|
||||
|
||||
function makeChain(finalValue: unknown) {
|
||||
const chain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(finalValue),
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeInsertChain() {
|
||||
const chain = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("TierLimitError", () => {
|
||||
it("has correct name and message", () => {
|
||||
const err = new TierLimitError("aiCall", "free");
|
||||
expect(err.name).toBe("TierLimitError");
|
||||
expect(err.message).toContain("aiCall");
|
||||
expect(err.message).toContain("free");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkTierLimit", () => {
|
||||
const tierDef = {
|
||||
tier: "free",
|
||||
maxRecipes: 10,
|
||||
aiCallsPerMonth: 5,
|
||||
storageMb: 100,
|
||||
maxPublicRecipes: 3,
|
||||
};
|
||||
|
||||
it("does not throw when usage is under limit", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws TierLimitError when aiCall limit reached", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("throws TierLimitError when recipe limit reached", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("does not throw when no usage row exists (treats as zero)", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw when tier definition does not exist", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw for storage key (no limit enforced)", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 9999 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "storage")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementUsage", () => {
|
||||
it("calls insert with correct aiCall initial values", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "aiCall");
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user1",
|
||||
aiCallsUsed: 1,
|
||||
recipeCount: 0,
|
||||
storageUsedMb: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("calls insert with correct recipe initial values", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "recipe");
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipeCount: 1,
|
||||
aiCallsUsed: 0,
|
||||
storageUsedMb: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("respects custom amount", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "storage", 50);
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storageUsedMb: 50 })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses onConflictDoUpdate to increment (not overwrite)", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "aiCall");
|
||||
|
||||
expect(chain.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({ aiCallsUsed: expect.anything() }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import crypto from "crypto";
|
||||
|
||||
const mockWebhookSelect = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: mockWebhookSelect,
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||
},
|
||||
webhooks: {},
|
||||
webhookDeliveries: {},
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
}));
|
||||
|
||||
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = [];
|
||||
|
||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
const body = (init?.body as string) ?? "";
|
||||
fetchCalls.push({
|
||||
url: url as string,
|
||||
body,
|
||||
headers: (init?.headers as Record<string, string>) ?? {},
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
|
||||
const { dispatchWebhook } = await import("../webhooks");
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls = [];
|
||||
vi.clearAllMocks();
|
||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
const body = (init?.body as string) ?? "";
|
||||
fetchCalls.push({
|
||||
url: url as string,
|
||||
body,
|
||||
headers: (init?.headers as Record<string, string>) ?? {},
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchWebhook", () => {
|
||||
it("does not fetch when no matching webhooks", async () => {
|
||||
mockWebhookSelect.mockResolvedValue([]);
|
||||
await dispatchWebhook("user1", "recipe.created", { id: "r1" });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends POST to webhook URL with event and payload", async () => {
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret: "mysecret", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.created", { id: "r1", title: "Test" });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledOnce();
|
||||
const call = fetchCalls[0]!;
|
||||
expect(call.url).toBe("https://example.com/hook");
|
||||
const parsed = JSON.parse(call.body) as { event: string; payload: unknown };
|
||||
expect(parsed.event).toBe("recipe.created");
|
||||
expect(parsed.payload).toMatchObject({ id: "r1", title: "Test" });
|
||||
});
|
||||
|
||||
it("includes valid HMAC-SHA256 signature header", async () => {
|
||||
const secret = "test-webhook-secret";
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret, events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.updated", { id: "r2" });
|
||||
|
||||
const call = fetchCalls[0]!;
|
||||
const sigHeader = call.headers["X-Epicure-Signature"];
|
||||
expect(sigHeader).toBeTruthy();
|
||||
expect(sigHeader).toMatch(/^sha256=/);
|
||||
|
||||
const expectedSig = "sha256=" + crypto.createHmac("sha256", secret).update(call.body).digest("hex");
|
||||
expect(sigHeader).toBe(expectedSig);
|
||||
});
|
||||
|
||||
it("sends X-Epicure-Event header", async () => {
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "comment.added", {});
|
||||
|
||||
expect(fetchCalls[0]!.headers["X-Epicure-Event"]).toBe("comment.added");
|
||||
});
|
||||
|
||||
it("filters webhooks by event subscription", async () => {
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://a.com/hook", secret: "s", events: ["recipe.deleted"], active: true },
|
||||
{ id: "wh2", url: "https://b.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.created", {});
|
||||
|
||||
// wh1 only subscribes to recipe.deleted, wh2 subscribes to all (empty = all)
|
||||
expect(global.fetch).toHaveBeenCalledOnce();
|
||||
expect(fetchCalls[0]!.url).toBe("https://b.com/hook");
|
||||
});
|
||||
|
||||
it("records delivery success in DB", async () => {
|
||||
const { db } = await import("@epicure/db");
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.created", {});
|
||||
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
const insertedRow = mockInsertValues.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(insertedRow.success).toBe(true);
|
||||
expect(insertedRow.event).toBe("recipe.created");
|
||||
});
|
||||
|
||||
it("records delivery failure when fetch throws", async () => {
|
||||
const { db } = await import("@epicure/db");
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.created", {});
|
||||
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
const insertedRow = mockInsertValues.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(insertedRow.success).toBe(false);
|
||||
expect(insertedRow.statusCode).toBe(0);
|
||||
});
|
||||
|
||||
it("still dispatches remaining hooks if one fails (allSettled)", async () => {
|
||||
global.fetch = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("Timeout"))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://fail.com/hook", secret: "s", events: [], active: true },
|
||||
{ id: "wh2", url: "https://ok.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await expect(dispatchWebhook("user1", "recipe.created", {})).resolves.toBeUndefined();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock AI SDK providers
|
||||
const mockOpenAiModel = vi.fn(() => "openai-model-instance");
|
||||
const mockAnthropicModel = vi.fn(() => "anthropic-model-instance");
|
||||
const mockOpenAiProvider = vi.fn(() => mockOpenAiModel);
|
||||
const mockAnthropicProvider = vi.fn(() => mockAnthropicModel);
|
||||
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => mockOpenAiProvider),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/anthropic", () => ({
|
||||
createAnthropic: vi.fn(() => mockAnthropicProvider),
|
||||
}));
|
||||
|
||||
const { createOpenAI } = await import("@ai-sdk/openai");
|
||||
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
||||
const { resolveModel } = await import("../factory");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
delete process.env["ANTHROPIC_API_KEY"];
|
||||
delete process.env["OPENROUTER_API_KEY"];
|
||||
delete process.env["OLLAMA_BASE_URL"];
|
||||
});
|
||||
|
||||
describe("resolveModel", () => {
|
||||
it("uses openai provider when specified", () => {
|
||||
resolveModel({ provider: "openai", apiKey: "sk-test" });
|
||||
expect(createOpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-test" }));
|
||||
});
|
||||
|
||||
it("uses anthropic provider when specified", () => {
|
||||
resolveModel({ provider: "anthropic", apiKey: "sk-ant-test" });
|
||||
expect(createAnthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-ant-test" }));
|
||||
});
|
||||
|
||||
it("uses openrouter base URL when provider is openrouter", () => {
|
||||
resolveModel({ provider: "openrouter", apiKey: "or-key" });
|
||||
expect(createOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://openrouter.ai/api/v1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ollama base URL from env or default", () => {
|
||||
process.env["OLLAMA_BASE_URL"] = "http://custom:11434/v1";
|
||||
resolveModel({ provider: "ollama" });
|
||||
expect(createOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "http://custom:11434/v1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to ollama default URL when env not set", () => {
|
||||
resolveModel({ provider: "ollama" });
|
||||
expect(createOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "http://localhost:11434/v1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom model when specified", () => {
|
||||
resolveModel({ provider: "openai", model: "gpt-4-turbo", apiKey: "sk-test" });
|
||||
expect(mockOpenAiProvider).toHaveBeenCalledWith("gpt-4-turbo");
|
||||
});
|
||||
|
||||
it("uses default openai model when none specified", () => {
|
||||
resolveModel({ provider: "openai", apiKey: "sk-test" });
|
||||
expect(mockOpenAiProvider).toHaveBeenCalledWith("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("uses default anthropic model when none specified", () => {
|
||||
resolveModel({ provider: "anthropic", apiKey: "sk-ant" });
|
||||
expect(mockAnthropicProvider).toHaveBeenCalledWith("claude-haiku-4-5-20251001");
|
||||
});
|
||||
|
||||
it("defaults to openrouter when OPENROUTER_API_KEY is set (no explicit provider)", () => {
|
||||
process.env["OPENROUTER_API_KEY"] = "or-key";
|
||||
resolveModel({});
|
||||
expect(createOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://openrouter.ai/api/v1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to openai when OPENAI_API_KEY set and no openrouter", () => {
|
||||
process.env["OPENAI_API_KEY"] = "sk-test";
|
||||
resolveModel({});
|
||||
expect(createOpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-test" }));
|
||||
});
|
||||
|
||||
it("throws on unknown provider", () => {
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => resolveModel({ provider: "nonexistent" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { encrypt } from "../../encrypt";
|
||||
|
||||
const mockUserAiKeysFindMany = vi.fn();
|
||||
const mockUserModelPrefsFindFirst = vi.fn();
|
||||
const mockSiteSettingFindFirst = vi.fn();
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
userAiKeys: { findMany: mockUserAiKeysFindMany },
|
||||
userModelPrefs: { findFirst: mockUserModelPrefsFindFirst },
|
||||
siteSettings: { findFirst: mockSiteSettingFindFirst },
|
||||
},
|
||||
},
|
||||
userAiKeys: {},
|
||||
userModelPrefs: {},
|
||||
siteSettings: {},
|
||||
eq: vi.fn((a, b) => ({ a, b })),
|
||||
and: vi.fn((...args) => args),
|
||||
}));
|
||||
|
||||
const { getDefaultProviderWithKey, getModelConfigForUseCase, withUserKey } = await import("../resolve-user-key");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
delete process.env["ANTHROPIC_API_KEY"];
|
||||
delete process.env["OPENROUTER_API_KEY"];
|
||||
// Make getSiteSetting return null by default
|
||||
mockSiteSettingFindFirst.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("getDefaultProviderWithKey", () => {
|
||||
it("returns empty config when no keys at all", async () => {
|
||||
mockUserAiKeysFindMany.mockResolvedValue([]);
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it("returns openrouter key first (priority order)", async () => {
|
||||
const encOpenrouter = encrypt("or-key");
|
||||
const encOpenai = encrypt("sk-test");
|
||||
mockUserAiKeysFindMany.mockResolvedValue([
|
||||
{ provider: "openai", encryptedKey: encOpenai },
|
||||
{ provider: "openrouter", encryptedKey: encOpenrouter },
|
||||
]);
|
||||
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config.provider).toBe("openrouter");
|
||||
expect(config.apiKey).toBe("or-key");
|
||||
});
|
||||
|
||||
it("falls back to openai when no openrouter", async () => {
|
||||
const encOpenai = encrypt("sk-openai");
|
||||
mockUserAiKeysFindMany.mockResolvedValue([
|
||||
{ provider: "openai", encryptedKey: encOpenai },
|
||||
]);
|
||||
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config.provider).toBe("openai");
|
||||
expect(config.apiKey).toBe("sk-openai");
|
||||
});
|
||||
|
||||
it("uses site settings when no BYOK key exists", async () => {
|
||||
mockUserAiKeysFindMany.mockResolvedValue([]);
|
||||
// Mock getSiteSetting via siteSettings.findFirst to return encrypted key for OPENAI
|
||||
const encKey = encrypt("sk-from-site-settings");
|
||||
mockSiteSettingFindFirst.mockResolvedValueOnce(null) // openrouter
|
||||
.mockResolvedValueOnce({ value: encKey, isSecret: true }); // openai
|
||||
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config.provider).toBe("openai");
|
||||
expect(config.apiKey).toBe("sk-from-site-settings");
|
||||
});
|
||||
|
||||
it("skips corrupted BYOK key and tries next provider", async () => {
|
||||
mockUserAiKeysFindMany.mockResolvedValue([
|
||||
{ provider: "openrouter", encryptedKey: "CORRUPT:NOT:VALID" },
|
||||
{ provider: "openai", encryptedKey: encrypt("sk-valid") },
|
||||
]);
|
||||
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config.provider).toBe("openai");
|
||||
expect(config.apiKey).toBe("sk-valid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withUserKey", () => {
|
||||
it("injects BYOK key when user has one for this provider", async () => {
|
||||
const encKey = encrypt("sk-user-key");
|
||||
vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready
|
||||
// withUserKey uses findFirst via userAiKeys
|
||||
const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey });
|
||||
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
||||
|
||||
const config = await withUserKey("user1", { provider: "openai" });
|
||||
expect(config.apiKey).toBe("sk-user-key");
|
||||
});
|
||||
|
||||
it("returns config unchanged when no user key for provider", async () => {
|
||||
const mockFindFirst = vi.fn().mockResolvedValue(null);
|
||||
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
||||
|
||||
const config = await withUserKey("user1", { provider: "anthropic" });
|
||||
expect(config.apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getModelConfigForUseCase", () => {
|
||||
it("uses user model prefs when set", async () => {
|
||||
mockUserModelPrefsFindFirst.mockResolvedValue({
|
||||
textProvider: "anthropic",
|
||||
textModel: "claude-sonnet-4-6",
|
||||
visionProvider: null,
|
||||
visionModel: null,
|
||||
mealPlanProvider: null,
|
||||
mealPlanModel: null,
|
||||
});
|
||||
const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key
|
||||
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
||||
|
||||
const config = await getModelConfigForUseCase("user1", "text");
|
||||
expect(config.provider).toBe("anthropic");
|
||||
expect(config.model).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("falls back to getDefaultProviderWithKey when no prefs", async () => {
|
||||
mockUserModelPrefsFindFirst.mockResolvedValue(null);
|
||||
mockUserAiKeysFindMany.mockResolvedValue([
|
||||
{ provider: "openai", encryptedKey: encrypt("sk-default") },
|
||||
]);
|
||||
|
||||
const config = await getModelConfigForUseCase("user1", "vision");
|
||||
expect(config.provider).toBe("openai");
|
||||
expect(config.apiKey).toBe("sk-default");
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export async function adaptRecipe(
|
||||
excludeIngredients: string[];
|
||||
extraConstraints?: string;
|
||||
},
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<AdaptedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
@@ -74,7 +74,7 @@ export async function adaptRecipe(
|
||||
model,
|
||||
schema: AdaptedRecipeSchema,
|
||||
system:
|
||||
`You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.`,
|
||||
`You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
||||
prompt: `Adapt the following recipe with these constraints:\n\n${constraintText}\n\nOriginal recipe:\n${recipeText}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const MealPlanSchema = z.object({
|
||||
description: z.string().max(300),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.string().optional(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
})).max(20),
|
||||
steps: z.array(z.object({
|
||||
@@ -36,8 +36,9 @@ export async function generateMealPlan(
|
||||
pantryItems?: string[];
|
||||
days?: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
|
||||
pantryMode?: boolean;
|
||||
difficulty?: "easy" | "medium" | "hard";
|
||||
},
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<GeneratedMealPlan> {
|
||||
const model = resolveModel(config);
|
||||
@@ -45,28 +46,35 @@ export async function generateMealPlan(
|
||||
const servings = options.servings ?? 2;
|
||||
const days = options.days ?? ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||
const pantryMode = options.pantryMode ?? false;
|
||||
const difficulty = options.difficulty;
|
||||
|
||||
const dietaryClause = options.dietaryPrefs?.trim()
|
||||
? `Dietary requirements: ${options.dietaryPrefs.trim()}.`
|
||||
: "";
|
||||
const difficultyClause = difficulty
|
||||
? `All recipes must be ${difficulty} difficulty: ${{ easy: "simple techniques, few steps, everyday ingredients", medium: "moderate skill, standard techniques", hard: "advanced techniques, multiple components" }[difficulty]}.`
|
||||
: "";
|
||||
const pantryClause =
|
||||
options.pantryItems && options.pantryItems.length > 0
|
||||
? `Available pantry ingredients to use: ${options.pantryItems.slice(0, 20).join(", ")}.`
|
||||
: "";
|
||||
|
||||
const systemPrompt = pantryMode
|
||||
? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. Respond in ${lang}.`
|
||||
: `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. Respond in ${lang}.`;
|
||||
? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`
|
||||
: `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`;
|
||||
|
||||
const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: MealPlanSchema,
|
||||
system: systemPrompt,
|
||||
system: systemPromptWithContext,
|
||||
prompt: [
|
||||
`Generate a meal plan for ${days.length} day(s): ${days.join(", ")}.`,
|
||||
`Include breakfast, lunch, and dinner for each day.`,
|
||||
`Plan for ${servings} servings per meal.`,
|
||||
dietaryClause,
|
||||
difficultyClause,
|
||||
pantryClause,
|
||||
pantryMode
|
||||
? "Prioritize using the listed pantry ingredients across as many meals as possible. Minimize ingredients that need to be purchased."
|
||||
|
||||
@@ -34,17 +34,19 @@ export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
|
||||
|
||||
export async function generateRecipe(
|
||||
prompt: string,
|
||||
config?: AiConfig & { language?: string }
|
||||
config?: AiConfig & { language?: string; difficulty?: "easy" | "medium" | "hard"; userContext?: string }
|
||||
): Promise<GeneratedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
const lang = config?.language ?? "en";
|
||||
const langInstruction = lang !== "en" ? ` Write the entire recipe (title, description, ingredient names, step instructions) in ${lang}.` : "";
|
||||
const difficultyInstruction = config?.difficulty ? ` The recipe must be ${config.difficulty} difficulty: ${{ easy: "simple techniques, minimal steps, common ingredients", medium: "moderate skill required, standard techniques", hard: "advanced techniques, multiple components, skilled cook required" }[config.difficulty]}.` : "";
|
||||
const userInstruction = config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "";
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: RecipeOutputSchema,
|
||||
system:
|
||||
`You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant.${langInstruction}`,
|
||||
`You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant.${langInstruction}${difficultyInstruction}${userInstruction}`,
|
||||
prompt: `Create a recipe for: ${prompt}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,64 @@
|
||||
import { generateObject } from "ai";
|
||||
import dns from "node:dns/promises";
|
||||
import { z } from "zod";
|
||||
import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
/**
|
||||
* Resolves the hostname in `rawUrl` and returns an error string if the
|
||||
* URL targets a private/reserved address range (SSRF guard), or null if safe.
|
||||
*/
|
||||
async function validateImportUrl(rawUrl: string): Promise<string | null> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return "Invalid URL";
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
return "URL must use http or https";
|
||||
}
|
||||
|
||||
let addresses: string[];
|
||||
try {
|
||||
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
|
||||
addresses = results.map((r) => r.address);
|
||||
} catch {
|
||||
return "Unable to resolve hostname";
|
||||
}
|
||||
|
||||
for (const addr of addresses) {
|
||||
if (isPrivateAddress(addr)) {
|
||||
return "URL must not point to a private or reserved address";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPrivateAddress(ip: string): boolean {
|
||||
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (v4) {
|
||||
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
|
||||
if (a === 127) return true;
|
||||
if (a === 10) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a >= 224) return true;
|
||||
return false;
|
||||
}
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === "::1") return true;
|
||||
if (lower === "::") return true;
|
||||
if (lower.startsWith("fe80:")) return true;
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
||||
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
const ImportedRecipeSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
@@ -33,6 +90,9 @@ const ImportedRecipeSchema = z.object({
|
||||
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
||||
|
||||
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
|
||||
const ssrfError = await validateImportUrl(url);
|
||||
if (ssrfError) throw new Error(ssrfError);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function suggestDrinks(
|
||||
ingredients: Array<{ rawName: string }>;
|
||||
},
|
||||
count = 4,
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<DrinkSuggestion[]> {
|
||||
const model = resolveModel(config);
|
||||
@@ -41,7 +41,7 @@ export async function suggestDrinks(
|
||||
model,
|
||||
schema: DrinksOutputSchema,
|
||||
system:
|
||||
`You are a sommelier and beverage expert. For each suggestion, lead with a style or category (e.g. 'Dry white Burgundy', 'Session IPA', 'Sparkling water with citrus') rather than a specific product. Then provide 2–3 concrete examples to illustrate the style. Consider flavor profiles, acidity, tannins, sweetness, weight, and cultural pairing traditions. Include a mix of alcoholic and non-alcoholic options. Respond in ${lang}.`,
|
||||
`You are a sommelier and beverage expert. For each suggestion, lead with a style or category (e.g. 'Dry white Burgundy', 'Session IPA', 'Sparkling water with citrus') rather than a specific product. Then provide 2–3 concrete examples to illustrate the style. Consider flavor profiles, acidity, tannins, sweetness, weight, and cultural pairing traditions. Include a mix of alcoholic and non-alcoholic options. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
||||
prompt: `Suggest ${count} drinks to serve with this recipe.\n\nRecipe: ${recipe.title}\n${recipe.description ? `Description: ${recipe.description}` : ""}\n${dietaryContext ? `Dietary: ${dietaryContext}` : ""}\nKey ingredients: ${recipe.ingredients.slice(0, 8).map((i) => i.rawName).join(", ")}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function suggestPairings(
|
||||
ingredients: Array<{ rawName: string }>;
|
||||
},
|
||||
count = 4,
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<PairingSuggestion[]> {
|
||||
const model = resolveModel(config);
|
||||
@@ -40,7 +40,7 @@ export async function suggestPairings(
|
||||
model,
|
||||
schema: PairingsOutputSchema,
|
||||
system:
|
||||
`You are an expert chef and menu planner. Suggest complementary dishes that form a balanced, cohesive complete meal. Consider flavor profiles, cooking techniques, dietary restrictions, and cultural harmony. Avoid duplicating ingredients heavily. Suggest a balanced mix of roles (starter, sides, dessert, drinks). Respond in ${lang}.`,
|
||||
`You are an expert chef and menu planner. Suggest complementary dishes that form a balanced, cohesive complete meal. Consider flavor profiles, cooking techniques, dietary restrictions, and cultural harmony. Avoid duplicating ingredients heavily. Suggest a balanced mix of roles (starter, sides, dessert, drinks). Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
||||
prompt: `Suggest ${count} dishes that pair well with this recipe to form a complete meal:\n\nRecipe: ${recipe.title}\n${recipe.description ? `Description: ${recipe.description}` : ""}\n${dietaryContext ? `Dietary: ${dietaryContext}` : ""}\nKey ingredients: ${recipe.ingredients.slice(0, 8).map((i) => i.rawName).join(", ")}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function suggestVariations(
|
||||
steps: Array<{ instruction: string }>;
|
||||
},
|
||||
count = 3,
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
directions?: string,
|
||||
locale?: string
|
||||
): Promise<VariationSuggestion[]> {
|
||||
@@ -61,7 +61,7 @@ export async function suggestVariations(
|
||||
model,
|
||||
schema: VariationsOutputSchema,
|
||||
system:
|
||||
`You are a creative chef. Suggest meaningful variations of recipes — dietary adaptations, flavor profiles, technique changes. Each variation should be distinct and practical. Respond in ${lang}.`,
|
||||
`You are a creative chef. Suggest meaningful variations of recipes — dietary adaptations, flavor profiles, technique changes. Each variation should be distinct and practical. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
||||
prompt: `Suggest ${count} variations of this recipe:\n\n${recipeText}${directionsClause}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { db, users, eq } from "@epicure/db";
|
||||
|
||||
export async function getUserPrivateBio(userId: string): Promise<string | null> {
|
||||
const row = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { privateBio: true },
|
||||
});
|
||||
return row?.privateBio ?? null;
|
||||
}
|
||||
|
||||
export function buildUserBioContext(privateBio: string | null): string {
|
||||
if (!privateBio?.trim()) return "";
|
||||
return `\n\nUser preferences and context:\n${privateBio.trim()}`;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
});
|
||||
|
||||
export const {
|
||||
signIn,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db";
|
||||
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
|
||||
@@ -38,8 +39,37 @@ export const auth = betterAuth({
|
||||
clientId: process.env["GOOGLE_CLIENT_ID"] ?? "",
|
||||
clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "",
|
||||
},
|
||||
...(process.env["GITHUB_CLIENT_ID"] && {
|
||||
github: {
|
||||
clientId: process.env["GITHUB_CLIENT_ID"],
|
||||
clientSecret: process.env["GITHUB_CLIENT_SECRET"] ?? "",
|
||||
},
|
||||
}),
|
||||
...(process.env["DISCORD_CLIENT_ID"] && {
|
||||
discord: {
|
||||
clientId: process.env["DISCORD_CLIENT_ID"],
|
||||
clientSecret: process.env["DISCORD_CLIENT_SECRET"] ?? "",
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
plugins: [
|
||||
...(process.env["AUTHENTIK_CLIENT_ID"] && process.env["AUTHENTIK_BASE_URL"] ? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: process.env["AUTHENTIK_CLIENT_ID"],
|
||||
clientSecret: process.env["AUTHENTIK_CLIENT_SECRET"] ?? "",
|
||||
// Authentik OIDC discovery URL: https://<your-authentik-domain>/application/o/<slug>/
|
||||
discoveryUrl: `${process.env["AUTHENTIK_BASE_URL"]}/.well-known/openid-configuration`,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
] : []),
|
||||
],
|
||||
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
|
||||
+24
-3
@@ -1,10 +1,31 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
hkdfSync,
|
||||
} from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
|
||||
// Static salt — committed to source so it is stable across deployments.
|
||||
// It does NOT need to be secret; its purpose is domain-separation and to
|
||||
// prevent the raw secret from being used directly as a key.
|
||||
const HKDF_SALT = Buffer.from("epicure-encryption-v1-salt", "utf8");
|
||||
const HKDF_INFO = Buffer.from("epicure-aes-256-gcm-key", "utf8");
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env["BETTER_AUTH_SECRET"] ?? "dev-secret-needs-32-bytes-padding!";
|
||||
return createHash("sha256").update(secret).digest();
|
||||
// Prefer a dedicated encryption secret; fall back to BETTER_AUTH_SECRET so
|
||||
// existing deployments keep working without an immediate migration.
|
||||
const secret =
|
||||
process.env["ENCRYPTION_SECRET"] ?? process.env["BETTER_AUTH_SECRET"];
|
||||
if (!secret) throw new Error("ENCRYPTION_SECRET (or BETTER_AUTH_SECRET) is required");
|
||||
|
||||
// HKDF-SHA256 provides proper key derivation with domain separation.
|
||||
// This replaces the previous raw SHA-256 hash which offered no stretching
|
||||
// or salt, making brute-force against leaked ciphertexts trivial.
|
||||
return Buffer.from(
|
||||
hkdfSync("sha256", secret, HKDF_SALT, HKDF_INFO, 32)
|
||||
);
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const UNICODE_FRACTIONS: Record<string, number> = {
|
||||
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
|
||||
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
|
||||
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
|
||||
};
|
||||
|
||||
export function parseQuantity(v: string | number | undefined): string | undefined {
|
||||
if (v === undefined || v === "") return undefined;
|
||||
const s = String(v).trim();
|
||||
if (!s) return undefined;
|
||||
|
||||
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
|
||||
|
||||
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
|
||||
if (s.endsWith(frac)) {
|
||||
const whole = s.slice(0, -frac.length).trim();
|
||||
if (!whole) return String(val);
|
||||
const w = parseFloat(whole);
|
||||
if (!isNaN(w)) return String(w + val);
|
||||
}
|
||||
}
|
||||
|
||||
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
|
||||
if (mixed) {
|
||||
const den = parseInt(mixed[3]!);
|
||||
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
|
||||
}
|
||||
|
||||
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
|
||||
if (slash) {
|
||||
const den = parseInt(slash[2]!);
|
||||
if (den !== 0) return String(parseInt(slash[1]!) / den);
|
||||
}
|
||||
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? undefined : String(n);
|
||||
}
|
||||
@@ -16,6 +16,60 @@ export class TierLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically checks the tier limit and increments the usage counter in a
|
||||
* single SQL statement, eliminating the TOCTOU race that existed when
|
||||
* checkTierLimit and incrementUsage were called separately.
|
||||
*
|
||||
* Throws TierLimitError if the limit has already been reached.
|
||||
* Use this instead of the separate checkTierLimit + incrementUsage pair
|
||||
* for "recipe" and "aiCall" keys.
|
||||
*/
|
||||
export async function checkAndIncrementTierLimit(
|
||||
userId: string,
|
||||
userTier: "free" | "pro",
|
||||
key: "recipe" | "aiCall"
|
||||
): Promise<void> {
|
||||
const [tierDef] = await db
|
||||
.select()
|
||||
.from(tierDefinitions)
|
||||
.where(eq(tierDefinitions.tier, userTier));
|
||||
|
||||
if (!tierDef) return;
|
||||
|
||||
const month = currentMonth();
|
||||
const id = `${userId}-${month}`;
|
||||
|
||||
if (key === "aiCall") {
|
||||
const limit = tierDef.aiCallsPerMonth;
|
||||
const result = await db.execute(sql`
|
||||
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
|
||||
VALUES (${id}, ${userId}, ${month}, 1, 0, 0)
|
||||
ON CONFLICT (user_id, month) DO UPDATE
|
||||
SET ai_calls_used = user_usage.ai_calls_used + 1
|
||||
WHERE user_usage.ai_calls_used < ${limit}
|
||||
RETURNING ai_calls_used
|
||||
`);
|
||||
if (result.length === 0) {
|
||||
throw new TierLimitError("aiCall", userTier);
|
||||
}
|
||||
} else {
|
||||
const limit = tierDef.maxRecipes;
|
||||
const result = await db.execute(sql`
|
||||
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
|
||||
VALUES (${id}, ${userId}, ${month}, 0, 1, 0)
|
||||
ON CONFLICT (user_id, month) DO UPDATE
|
||||
SET recipe_count = user_usage.recipe_count + 1
|
||||
WHERE user_usage.recipe_count < ${limit}
|
||||
RETURNING recipe_count
|
||||
`);
|
||||
if (result.length === 0) {
|
||||
throw new TierLimitError("recipe", userTier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use checkAndIncrementTierLimit for recipe/aiCall keys to avoid TOCTOU races. */
|
||||
export async function checkTierLimit(
|
||||
userId: string,
|
||||
userTier: "free" | "pro",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import dns from "node:dns/promises";
|
||||
|
||||
function isPrivateAddress(ip: string): boolean {
|
||||
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (v4) {
|
||||
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
|
||||
if (a === 127) return true;
|
||||
if (a === 10) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 169 && b === 254) return true;
|
||||
if (a >= 224) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === "::1" || lower === "::") return true;
|
||||
if (lower.startsWith("fe80:")) return true;
|
||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
||||
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error string if the URL is disallowed (SSRF), or null if safe.
|
||||
* Blocks non-http(s) schemes, RFC-1918, loopback, link-local, cloud metadata.
|
||||
*/
|
||||
export async function validateWebhookUrl(rawUrl: string): Promise<string | null> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
return "Invalid URL";
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
||||
return "Webhook URL must use http or https";
|
||||
}
|
||||
|
||||
let addresses: string[];
|
||||
try {
|
||||
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
|
||||
addresses = results.map((r) => r.address);
|
||||
} catch {
|
||||
return "Unable to resolve webhook hostname";
|
||||
}
|
||||
|
||||
for (const addr of addresses) {
|
||||
if (isPrivateAddress(addr)) {
|
||||
return "Webhook URL must not point to a private or reserved address";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+104
-4
@@ -28,6 +28,28 @@
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"noIngredientsSteps": "No ingredients or steps yet.",
|
||||
"generateContent": "Generate with AI",
|
||||
"editManually": "Edit manually",
|
||||
"deleted": "Recipe deleted",
|
||||
"deleteFailed": "Delete failed",
|
||||
"imported": "Recipe imported! Review before publishing.",
|
||||
"adapted": "Adapted recipe saved as draft",
|
||||
"adaptFailed": "Failed to adapt recipe",
|
||||
"adaptConstraintRequired": "Select at least one ingredient to exclude or add a constraint",
|
||||
"variationsDirectionsPlaceholder": "e.g. make it vegan, use only pantry staples, reduce sugar…",
|
||||
"historyRestoreFailed": "Failed to restore version",
|
||||
"pairingMealFailed": "Failed to suggest pairings",
|
||||
"pairingDrinkFailed": "Failed to suggest drinks",
|
||||
"pairingGenerateFailed": "Failed to generate \"{name}\"",
|
||||
"pairingSaveFailed": "Failed to save \"{name}\"",
|
||||
"pairingSuccess": "Recipe generated — review before publishing",
|
||||
"shoppingListCreateFailed": "Failed to create list",
|
||||
"shoppingListAddFailed": "Failed to add ingredients",
|
||||
"bulkDeleted": "{count, plural, one {1 recipe deleted} other {{count} recipes deleted}}",
|
||||
"bulkVisibility": "{count, plural, one {1 recipe set to {visibility}} other {{count} recipes set to {visibility}}}",
|
||||
"bulkDeleteFailed": "Delete failed",
|
||||
"bulkUpdateFailed": "Update failed",
|
||||
"visibility": {
|
||||
"private": "Private",
|
||||
"unlisted": "Unlisted",
|
||||
@@ -52,14 +74,32 @@
|
||||
"generate": {
|
||||
"title": "Generate recipe with AI",
|
||||
"description": "Describe what you want to cook and AI will create a complete recipe.",
|
||||
"descriptionFull": "Describe a dish in words or snap a photo — AI builds the full recipe.",
|
||||
"tabDescribe": "Describe",
|
||||
"tabPhoto": "From photo",
|
||||
"surpriseMe": "Surprise me",
|
||||
"prompt": "What do you want to cook?",
|
||||
"placeholder": "e.g. A creamy mushroom risotto with parmesan, serves 4, easy difficulty",
|
||||
"language": "Language",
|
||||
"language": "Recipe language",
|
||||
"difficulty": "Difficulty",
|
||||
"anyDifficulty": "Any",
|
||||
"button": "Generate",
|
||||
"generating": "Generating…",
|
||||
"success": "Recipe generated! Review and edit before publishing.",
|
||||
"error": "Failed to generate recipe",
|
||||
"saveError": "Failed to save generated recipe"
|
||||
"saveError": "Failed to save generated recipe",
|
||||
"uploadClick": "Click to upload",
|
||||
"uploadDrag": "or drag a food photo",
|
||||
"uploadFormats": "JPEG, PNG or WebP · AI will reverse-engineer the recipe",
|
||||
"analyzing": "Analyzing…",
|
||||
"analyzePhoto": "Analyzing dish photo…",
|
||||
"recognizeDish": "Recognize dish",
|
||||
"photoSuccess": "Recipe recognized! Review and edit before publishing.",
|
||||
"photoError": "Failed to analyze photo",
|
||||
"contentGenerated": "Ingredients and steps generated!",
|
||||
"contentGenerateFailed": "Generation failed",
|
||||
"contentSaveFailed": "Failed to save generated content",
|
||||
"generatingContent": "Generating recipe content…"
|
||||
},
|
||||
"variations": {
|
||||
"title": "AI Recipe Variations",
|
||||
@@ -102,19 +142,31 @@
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Failed to save",
|
||||
"deleteFailed": "Delete failed",
|
||||
"updateFailed": "Update failed",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
"deleted": "Deleted",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back"
|
||||
"back": "Back",
|
||||
"difficulty": "Difficulty",
|
||||
"anyDifficulty": "Any",
|
||||
"editManually": "Edit manually",
|
||||
"surpriseMe": "Surprise me",
|
||||
"generatingContent": "Generating recipe content…"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign in",
|
||||
"signInTitle": "Sign in to your recipe library",
|
||||
"signInLoading": "Signing in…",
|
||||
"signInFailed": "Sign in failed",
|
||||
"signUp": "Sign up",
|
||||
"signUpTitle": "Create account",
|
||||
"signUpSubtitle": "Start building your recipe library",
|
||||
"signUpLoading": "Creating account…",
|
||||
"signUpFailed": "Sign up failed",
|
||||
"signUpSuccess": "Account created — check your email to verify",
|
||||
"continueWithGoogle": "Continue with Google",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
@@ -126,6 +178,7 @@
|
||||
"forgotPasswordSent": "Check your inbox",
|
||||
"forgotPasswordDescription": "Enter your email and we'll send you a reset link.",
|
||||
"forgotPasswordSentDescription": "If an account exists for that email, you'll receive a reset link shortly.",
|
||||
"forgotPasswordFailed": "Failed to send reset email",
|
||||
"sendResetLink": "Send reset link",
|
||||
"sendingLink": "Sending…",
|
||||
"resetPasswordTitle": "Reset password",
|
||||
@@ -135,6 +188,9 @@
|
||||
"updatePassword": "Update password",
|
||||
"updatingPassword": "Updating…",
|
||||
"invalidToken": "Invalid or missing reset token.",
|
||||
"resetPasswordMismatch": "Passwords don't match",
|
||||
"resetPasswordFailed": "Reset failed — link may have expired",
|
||||
"resetPasswordSuccess": "Password updated",
|
||||
"noAccount": "No account? Sign up",
|
||||
"alreadyHaveAccount": "Already have an account? Sign in",
|
||||
"backToSignIn": "Back to sign in",
|
||||
@@ -142,6 +198,8 @@
|
||||
"checkInboxVerification": "Check your inbox for a verification email.",
|
||||
"resendVerification": "Resend verification email",
|
||||
"resendingSending": "Sending…",
|
||||
"resendFailed": "Failed to resend",
|
||||
"resendSuccess": "Verification email sent — check your inbox",
|
||||
"or": "or"
|
||||
},
|
||||
"recipes": {
|
||||
@@ -172,6 +230,9 @@
|
||||
"cookMins": "Cook (min)",
|
||||
"difficulty": "Difficulty",
|
||||
"visibility": "Visibility",
|
||||
"tags": "Tags",
|
||||
"tagsHelp": "Press Enter to add · Backspace to remove last · max 20 tags",
|
||||
"tagsPlaceholder": "Add tags… (press Enter)",
|
||||
"dietaryTags": "Dietary tags",
|
||||
"photos": "Photos",
|
||||
"ingredients": "Ingredients",
|
||||
@@ -209,7 +270,11 @@
|
||||
"addTo": "Add to {day} – {meal}",
|
||||
"searchRecipes": "Search recipes…",
|
||||
"servings": "Servings",
|
||||
"difficulty": "Difficulty",
|
||||
"noRecipes": "No recipes found",
|
||||
"addFailed": "Failed to add",
|
||||
"addedToPantry": "{count, plural, one {1 item added to pantry} other {{count} items added to pantry}}",
|
||||
"listCreated": "List created",
|
||||
"days": {
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
@@ -268,6 +333,17 @@
|
||||
"recipeCount": "{count} recipe",
|
||||
"recipeCountPlural": "{count} recipes"
|
||||
},
|
||||
"social": {
|
||||
"followed": "Following",
|
||||
"unfollowed": "Unfollowed",
|
||||
"commentFailed": "Failed to post comment",
|
||||
"deleted": "Deleted",
|
||||
"ratingSaved": "Rating saved",
|
||||
"collectionCreated": "Collection created",
|
||||
"collectionForked": "Collection forked to your library",
|
||||
"inviteSent": "Invitation sent",
|
||||
"memberRemoved": "Member removed"
|
||||
},
|
||||
"cookingMode": {
|
||||
"cooking": "Cooking",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
@@ -336,6 +412,30 @@
|
||||
"changingPassword": "Changing…",
|
||||
"changePasswordButton": "Change password",
|
||||
"passwordChanged": "Password changed successfully.",
|
||||
"passwordChangeFailed": "Failed to change password."
|
||||
"passwordChangeFailed": "Failed to change password.",
|
||||
"webhookCreateFailed": "Failed to create webhook",
|
||||
"webhookDeleteFailed": "Failed to delete webhook",
|
||||
"webhookUpdateFailed": "Failed to update webhook",
|
||||
"redeliverySuccess": "Redelivery queued",
|
||||
"redeliveryFailed": "Failed to redeliver",
|
||||
"apiKeyCreateFailed": "Failed to create API key",
|
||||
"apiKeyRevokeFailed": "Failed to revoke API key",
|
||||
"copyFailed": "Failed to copy to clipboard",
|
||||
"byokSaveFailed": "Failed to save key",
|
||||
"byokRemoveFailed": "Failed to remove key",
|
||||
"modelSaved": "Model preferences saved",
|
||||
"modelSaveFailed": "Failed to save",
|
||||
"userUpdated": "User updated successfully",
|
||||
"userUpdateFailed": "Failed to save",
|
||||
"adminSettingsSaved": "Settings saved",
|
||||
"adminSettingsFailed": "Failed to save settings",
|
||||
"nutritionGoalsSaved": "Nutrition goals saved",
|
||||
"bio": "Bio",
|
||||
"publicBio": "Public bio",
|
||||
"publicBioDescription": "Shown on your public profile.",
|
||||
"publicBioPlaceholder": "Tell other cooks about yourself…",
|
||||
"privateBio": "AI context (private)",
|
||||
"privateBioDescription": "Never shown publicly. Injected into AI prompts to personalise suggestions — add your dietary preferences, kitchen equipment, cooking skill level, allergies, etc.",
|
||||
"privateBioPlaceholder": "e.g. I'm vegetarian, have a stand mixer and an air fryer, intermediate cook, allergic to tree nuts, prefer Mediterranean flavours…"
|
||||
}
|
||||
}
|
||||
@@ -336,6 +336,13 @@
|
||||
"changingPassword": "Modification…",
|
||||
"changePasswordButton": "Changer le mot de passe",
|
||||
"passwordChanged": "Mot de passe modifié avec succès.",
|
||||
"passwordChangeFailed": "Échec de la modification du mot de passe."
|
||||
"passwordChangeFailed": "Échec de la modification du mot de passe.",
|
||||
"bio": "Bio",
|
||||
"publicBio": "Bio publique",
|
||||
"publicBioDescription": "Affichée sur votre profil public.",
|
||||
"publicBioPlaceholder": "Parlez de vous aux autres cuisiniers…",
|
||||
"privateBio": "Contexte IA (privé)",
|
||||
"privateBioDescription": "Jamais visible publiquement. Injecté dans les prompts IA pour personnaliser les suggestions — ajoutez vos préférences alimentaires, équipements, niveau de cuisine, allergies, etc.",
|
||||
"privateBioPlaceholder": "ex. Je suis végétarien, j'ai un robot pâtissier et une friteuse à air, niveau intermédiaire, allergique aux fruits à coque, je préfère les saveurs méditerranéennes…"
|
||||
}
|
||||
}
|
||||
+49
-1
@@ -1,7 +1,55 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
},
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "on",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed by Next.js dev; tighten in prod
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; "),
|
||||
},
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
+11
-2
@@ -6,7 +6,10 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.86",
|
||||
@@ -42,14 +45,20 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20.19.43",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "lcov", "html"],
|
||||
include: ["lib/**/*.ts"],
|
||||
exclude: [
|
||||
"lib/auth/**",
|
||||
"lib/i18n/**",
|
||||
"lib/ai/features/**",
|
||||
"lib/openapi.ts",
|
||||
"lib/push.ts",
|
||||
"lib/redis.ts",
|
||||
"lib/storage.ts",
|
||||
"lib/utils.ts",
|
||||
"lib/email.ts",
|
||||
"**/*.d.ts",
|
||||
"**/__tests__/**",
|
||||
],
|
||||
thresholds: {
|
||||
lines: 60,
|
||||
functions: 60,
|
||||
branches: 55,
|
||||
statements: 60,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
"@epicure/db": path.resolve(__dirname, "../../packages/db/src/index.ts"),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Silence Next.js server-only imports in tests
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
// Default env for encrypt
|
||||
process.env["BETTER_AUTH_SECRET"] = "test-secret-for-vitest-32-bytes!!";
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "recipes" ADD COLUMN "tags" text[] DEFAULT '{}' NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "recipes" ADD COLUMN "language" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN "private_bio" text;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user