fix: audit fixes — tier-quota bypass, webhook SSRF, auth hardening, pagination, a11y

Full audit (bugs/UI-UX/backend/feature-gap) turned up a money-leak AI quota
bypass, webhook SSRF, and a long tail of missing pagination/auth/a11y work.
Fixes land together since HANDOFF.md tracked them as one backlog.

- AI routes charge tier quota before generating; nutrition POST is author-only
- Webhook dispatch re-validates URL per delivery (SSRF/DNS-rebinding), treats
  redirects as failures; recipe.published now actually dispatches
- New indexes/unique constraints on recipes, meal-planning, comments FK cascade
- Recipe PUT/restore snapshot only inside the transaction, after validation
- Recipe DELETE cleans up S3 objects (recipe + review photos)
- Optimistic UI (favorite/star/follow/shopping-list) rolls back on failure
- Upload presign enforces file size cap + per-tier storage quota
- Route-level loading/error/not-found states across (app), admin, and root
- middleware.ts guards (app)/admin; requireAdmin checks DB role, not cached
  session; rate limiting applied to both session and API-key branches,
  bucketed per key; Stripe webhook dedupes by event id
- Pagination added to recipes, feed, profile, comments, pantry, admin tables
- Nav shows real avatar + profile link + dark-mode toggle; destructive actions
  standardized on AlertDialog
- Unsaved-changes guard + real ingredient/step validation on recipe form;
  canonical /recipes/[id] used in-app; next/image migration; aria-labels and
  alt text across icon buttons, avatars, recipe photos
- packages/api-types removed (zero callers, too drifted to safely rewire);
  openapi.ts and ai-keys error shape drift fixed; BYOK decrypt failures now
  surface instead of silently falling back to the platform key

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-09 21:50:35 +02:00
parent b4b964aafb
commit 362f65656b
128 changed files with 11271 additions and 970 deletions
+1 -2
View File
@@ -36,7 +36,6 @@ pnpm db:studio # open Drizzle Studio
**Monorepo** (pnpm workspaces):
- `apps/web` — Next.js 15 App Router app (`@epicure/web`)
- `packages/db` — Drizzle ORM schema + client (`@epicure/db`)
- `packages/api-types` — Zod schemas, single source of truth for API types + OpenAPI (`@epicure/api-types`)
**Route groups in `apps/web/app/`**:
- `(auth)/` — login, signup, verify-email (no auth required)
@@ -66,7 +65,7 @@ cd apps/web && pnpm dlx shadcn@latest add <component-name>
1. Edit `packages/db/src/schema/*.ts`
2. `pnpm db:generate` — creates migration file
3. `pnpm db:migrate` — applies it
4. Update corresponding Zod schemas in `packages/api-types/src/`
4. Update the corresponding inline Zod schemas in the affected `apps/web/app/api/v1/**/route.ts` files (and `apps/web/lib/openapi.ts` if documented there)
## Environment Variables
+127
View File
@@ -0,0 +1,127 @@
# Audit fixes handoff (2026-07-09) — for next session (Sonnet 5)
A full 4-agent audit (bugs / UI-UX / backend consistency / feature gaps) was run, then fixes were fanned out to parallel agents. Session token limit killed 3 fix agents mid-flight. This file records: what's DONE, what's PARTIAL/UNVERIFIED, what's NOT STARTED. Nothing is committed. (Prior resolved backlog lives in TODO.md — don't confuse the two.)
**First step for next session:** `pnpm typecheck && pnpm lint`, then verify the PARTIAL items below by reading `git diff` before doing anything else.
---
## ✅ DONE (in working tree, uncommitted, reported complete by agents)
### AI tier-quota bypass (critical — money leak)
- `apps/web/app/api/v1/ai/recipe-ideas/route.ts``generateObject` wrapped in `withAiQuota`.
- `apps/web/app/api/v1/ai/recipe-chat/route.ts``generateText` wrapped in `withAiQuota` (also maps provider errors).
- `apps/web/app/api/v1/ai/meal-plan/generate/route.ts``withAiQuota` added; recipe tier limit now charged per inserted draft recipe via `checkAndIncrementTierLimit(..., "recipe")` loop with refund (`incrementUsage(userId, "recipe", -charged)`) on `TierLimitError`; returns same 403 shape as `recipes/route.ts` POST.
- `apps/web/app/api/v1/recipes/[id]/nutrition/route.ts` — POST now author-only (was: any authed user could burn paid AI calls on public recipes), rate-limited (`rl:ai:${userId}`, 10/60), wrapped in `withAiQuota`. GET unchanged.
### Webhooks (security + drift)
- `apps/web/lib/webhooks.ts` — SSRF fix: `dispatchWebhook` re-runs `validateWebhookUrl` per delivery (DNS re-resolution, private/link-local/loopback/metadata ranges rejected); `redirect: "manual"` on fetch (3xx = failed delivery); validation failure recorded as delivery failure (statusCode 0). Exported `WEBHOOK_EVENTS` as const array; `WebhookEvent` type derived from it.
- `apps/web/app/api/v1/webhooks/route.ts` + `[id]/route.ts` — drifted local `VALID_EVENTS` (4 events) removed; both use `z.enum(WEBHOOK_EVENTS)` (7 events). Users can now subscribe to `meal_plan.updated`, `shopping_list.completed`, `comment.added`.
- Leftover: `recipe.published` subscribable but never dispatched. Either dispatch on private→public transition in `recipes/[id]/route.ts` PUT, or drop the event.
### DB schema + migration (generated, NOT applied)
- `packages/db/src/schema/recipes.ts` — indexes on `recipe_ingredients.recipe_id`, `recipe_steps.recipe_id`.
- `packages/db/src/schema/meal-planning.ts` — indexes on `meal_plans.user_id`, `meal_plan_entries.meal_plan_id`, `pantry_items.user_id`; UNIQUE `(user_id, week_start)` on meal_plans; UNIQUE `(meal_plan_id, day, meal_type)` on meal_plan_entries (verified intentional: entries route delete-then-insert + meal-planner.tsx `.find()` = one entry per slot).
- `packages/db/src/schema/social.ts``comments.parentId` now self-FK with `onDelete: "cascade"` (AnyPgColumn pattern).
- Migration generated: `packages/db/src/migrations/0023_medical_dark_beast.sql` (1 FK + 5 indexes + 2 unique indexes).
- ⚠️ **Before `pnpm db:migrate`, clean existing data or migration fails:**
1. Dangling parents: `UPDATE comments SET parent_id = NULL WHERE parent_id IS NOT NULL AND parent_id NOT IN (SELECT id FROM comments);`
2. Duplicate `(user_id, week_start)` meal_plans → merge/delete dupes.
3. Duplicate `(meal_plan_id, day, meal_type)` entries → keep newest, delete rest.
### Recipe route transactions
- `apps/web/app/api/v1/recipes/[id]/route.ts` PUT — snapshot/version-bump moved after Zod validation, inside the update transaction (invalid body no longer creates phantom snapshots).
- `apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts` — pre-restore snapshot moved inside the restore transaction.
---
## ⚠️ PARTIAL / UNVERIFIED — agent died mid-work, verify diffs first
1. **`recipes/[id]/route.ts` DELETE — S3 object cleanup.** Agent died MID-EDIT here ("Now bug B — DELETE with S3 cleanup"). File is modified — check whether DELETE now collects photo storage keys and calls `deleteObject` from `apps/web/lib/storage.ts` (which had zero callers). May be absent or half-written. Intended: gather keys from recipe photos + step photo columns (check `packages/db/src/schema/recipes.ts` for key vs full-URL columns), `deleteObject` per key in try/catch — storage failure must NOT fail the API response.
2. **Optimistic buttons — all 4 files edited, agent died before reporting. Review each diff:**
- `apps/web/components/social/favorite-button.tsx` — should be: optimistic flip, rollback + error toast (was: await-then-flip, silent fail).
- `apps/web/components/collections/collection-star-button.tsx` — same pattern.
- `apps/web/components/social/follow-button.tsx` — optimistic flip, keep existing error toast.
- `apps/web/components/meal-plan/shopping-list-view.tsx` — toggleItem: keep optimistic update, ADD rollback on `!res.ok`/throw with functional setState (rollback must not clobber other items' newer toggles).
- If toasts reference new i18n keys: neither `apps/web/messages/en.json` nor `fr.json` was modified — add keys or the UI breaks.
3. **`apps/web/components/shared/skeletons.tsx` — created, but ZERO loading.tsx/error.tsx/not-found.tsx files exist yet.** Agent died after the shared building blocks. Verify the file, then do item 5 below.
---
## ❌ NOT STARTED — Wave 1 remainder
4. **Upload presign size + storage quota** (`apps/web/app/api/v1/upload/presign/route.ts`, `apps/web/lib/storage.ts`): validates content-type only, no size limit; `"storage"` LimitKey in `lib/tiers.ts` is dead code. Fix: require `fileSize` (bytes) in Zod body, reject >10MB, check+increment storage tier usage (MB) before presigning. Also grep client components for the presign fetch and add `fileSize` there.
5. **Route-level UI states** (reuse `components/shared/skeletons.tsx`):
- `app/(app)/error.tsx` ("use client", reset() button), `app/(app)/not-found.tsx`, `app/(app)/loading.tsx`
- Per-route loading.tsx mirroring real layouts: recipes, feed, collections, meal-plan, recipes/[id], u/[username], search, explore, shopping-lists, pantry
- `app/admin/error.tsx` + `app/admin/loading.tsx`
- Root `app/error.tsx` + `app/not-found.tsx` (dependency-light; may render outside providers)
## ❌ NOT STARTED — Wave 2 (planned batches, disjoint file sets)
6. **Auth hardening:**
- No `middleware.ts` exists (CLAUDE.md claims otherwise). Add root middleware guarding `(app)` + `admin` (Better Auth session cookie). Unguarded pages today: `/recipes/new`, `/explore`, `/search`, `/settings/webhooks/docs`.
- `lib/api-auth.ts:76` — rate limit only on API-key branch of `requireSessionOrApiKey`; cookie-session branch (~103) skips it. Apply to both.
- `lib/tiers.ts:41``if (!tierDef) return;` = unknown/custom tier gets NO limits. Deny instead (or fall back to free limits).
- Admin role staleness: `lib/api-auth.ts:19` trusts `session.user.role` (5-min cookieCache, `auth/server.ts:78-81`). Make `requireAdmin` query DB role.
- `app/api/v1/meal-plans/[weekStart]/members/route.ts:106-118` — self-leave unreachable: plan lookup scoped to owner, members get 404, `isSelf` branch dead. Lookup must also match membership.
- `app/api/v1/conversations/[id]/messages/route.ts:26-31``asc + limit(200)`: conversations >200 messages lose all NEWER messages. Paginate (desc + cursor, reverse client-side); update `components/social/message-thread.tsx`. Also add `aria-label` to its icon-only send button (~112).
- `lib/rate-limit.ts:23` — off-by-one: allows limit+1 requests.
- `app/api/webhooks/stripe/route.ts` — no event-ID dedup (replay within tolerance re-runs tier update).
7. **Pagination:**
- `app/(app)/recipes/page.tsx:47` — findMany with NO limit, loads every recipe.
- `app/(app)/feed/page.tsx:46` — cap 40, no pagination; trending/for-you single batch. Also `components/feed/feed-page-content.tsx:86,112``.catch(() => {})` renders network failure as empty state; add error branch + retry.
- `app/(app)/u/[username]/page.tsx:55` — 24 recipes then dead end.
- `app/api/v1/recipes/[id]/comments/route.ts:28-43` — unbounded; paginate + update `components/social/comments-section.tsx` (also no error branch ~218; comment delete ~134 has no confirm).
- `app/api/v1/pantry/route.ts:17-22` — unbounded.
- `app/admin/users/page.tsx:28,36` — limit(100) no next-page; table lacks `overflow-x-auto`. Same for other admin tables.
- Reuse `total/limit/offset` pattern from `components/search/search-page-content.tsx:29-34`.
8. **Nav/theme/confirms:**
- `components/layout/nav.tsx:110``<AvatarImage src="" alt="" />` hardcoded; render session user image. Add "View profile" link to dropdown.
- Dark mode wired (`components/providers.tsx:20`, `.dark` palette) but NO toggle. Add Sun/Moon in nav dropdown (`next-themes` setTheme).
- Confirm consistency: comment delete (`comments-section.tsx:134`) + pantry delete (`pantry-manager.tsx:55`) fire instantly; `recipes-grid.tsx:296`, `block-button.tsx:21`, `invites-manager.tsx:49` use `window.confirm`. Standardize on AlertDialog (pattern: `delete-recipe-button.tsx:62`).
9. **Forms/a11y/images/URLs:**
- `components/recipe/recipe-form.tsx` — no unsaved-changes guard; only title validated, empty ingredients/steps silently dropped (~:132,149,158).
- Canonical URL split: cards → `/recipes/[id]` (`recipe-card.tsx:42`, `recipes-grid.tsx:218`) vs `/r/[id]` (`feed-page-content.tsx:60`, `u/[username]/page.tsx:151`, `search-page-content.tsx:50`). Use `/recipes/[id]` in-app, keep `/r/[id]` for public share.
- 4+ recipe-card implementations (recipe-card.tsx; GridCard/ListRow/CompactRow in recipes-grid.tsx; inline in feed-page-content.tsx:33 + search-page-content.tsx:42; tiles in u/[username]/page.tsx:149) — consolidate where cheap.
- a11y: `aria-label` on 28 `size="icon"` buttons; meaningful `alt` on recipe images (`recipes/[id]/page.tsx:378`, `can-cook-content.tsx:39`, `expiring-soon-suggestions.tsx:39`, `cooked-it-review.tsx:149,209`); `alt` on AvatarImage everywhere; color-only signalling on difficulty badges + pantry expiry (`pantry-manager.tsx:96`) — add icon/text.
- Zero `next/image` (12 raw `<img>`) — migrate grids/heroes, width/height set, remotePatterns for MinIO/S3 host in next.config.
10. **api-types/OpenAPI:**
- `packages/api-types` 100% orphaned — zero imports; every route inlines Zod. Wire recipes routes to it (align drift first) or delete the package + update CLAUDE.md.
- `apps/web/lib/openapi.ts` — ~20 of 90 routes documented; recipes list documented `page/limit` but real is `limit/offset` returning `{data, limit, offset}` (`recipes/route.ts:48-64`).
- Error shape: `ai-keys/route.ts:35` returns `{error: <flatten object>}` vs `{error: string}` elsewhere.
11. **Misc low:**
- `app/api/v1/search/route.ts:72` — escape `%`/`_` in ilike; `feed/trending/route.ts:6` — parseInt NaN reaches `.limit()`.
- `app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts:17` — body cast without Zod.
- `app/api/v1/meal-plans/[weekStart]/entries/route.ts:48``recipeId` unvalidated → FK violation 500 (sibling shared route validates; copy it).
- Recipe usage counter monthly + never decremented on delete → `maxRecipes` = "creations per month". Decide semantics.
- `lib/ai/resolve-user-key.ts:18-23,44-62` — BYOK decrypt failures silently fall back to platform key; surface error.
- API keys: no expiry/scopes; all of a user's keys share one rate bucket (`api-auth.ts:79`).
## Final phase
- Clean dev-DB dupes (see migration warning) → `pnpm db:migrate`.
- `pnpm typecheck && pnpm lint && pnpm build`, fix fallout.
- Smoke: favorite/star/follow, shopping toggle rollback (network off in devtools), AI 403 at quota, webhook to private IP rejected, recipe delete removes MinIO objects.
## New features backlog (NOT fixes — not yet greenlit)
"S" items mostly wire existing orphaned infra:
1. Push+email for ALL notification types (S) — `createNotification` writes rows; only comments route calls `sendPushNotification` (`lib/push.ts`).
2. Personal recipe notes UI (S) — `recipeNotes` table exists (`recipes.ts:94`), zero API/UI.
3. Cooking history page (S/M) — `cookingHistory` written by `/cooked`, never read. Profile tab: counts, last-cooked, streaks.
4. Notifications inbox page (S) — API + bell exist.
5. Pantry-aware shopping lists (S).
6. Unit conversion from `users.unitPref` (M).
7. Nutrition daily diary (M).
8. Weekly digest email (M, needs cron).
9. Recipe fork/clone via `recipeVariations` (S).
10. GDPR data export (S/M). 11. Pantry barcode/photo scan (M).
12. "Cooked it" photo gallery (M).
13. Meal-plan generation targeting nutrition goals (M).
## Gotchas (from project memory)
- Import drizzle operators (`eq, and, or, desc, sql, count`…) from `@epicure/db`, NEVER `drizzle-orm` directly in apps/web (dual-instance bug).
- shadcn Button: NO `asChild` prop — use `buttonVariants()` + `<Link>`.
- `session.user.tier` is `string` — cast `as "free" | "pro"` at call sites.
- `requireSessionOrApiKey` takes `NextRequest`; `requireSession` doesn't.
- Better Auth `changeEmail`/`changePassword`: call on `authClient` object, don't destructure.
- New user-facing strings go in BOTH `apps/web/messages/en.json` and `fr.json`.
@@ -0,0 +1,14 @@
import { PageHeaderSkeleton, InfoCardSkeleton } from "@/components/shared/skeletons";
export default function CollectionsLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={1} />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<InfoCardSkeleton key={i} />
))}
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function AppError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-center">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground max-w-md">
An unexpected error occurred while loading this page. You can try again, or head back
later.
</p>
<Button onClick={() => reset()}>Try again</Button>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Skeleton } from "@/components/ui/skeleton";
import { InfoCardSkeleton } from "@/components/shared/skeletons";
function ExploreSection() {
return (
<div className="space-y-4">
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<InfoCardSkeleton key={i} />
))}
</div>
</div>
);
}
export default function ExploreLoading() {
return (
<div className="space-y-8">
<Skeleton className="h-12 w-full max-w-xl rounded-md" />
<ExploreSection />
<ExploreSection />
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { FeedItemSkeleton } from "@/components/shared/skeletons";
export default function FeedLoading() {
return (
<div className="max-w-2xl mx-auto space-y-8">
{Array.from({ length: 4 }).map((_, i) => (
<FeedItemSkeleton key={i} />
))}
</div>
);
}
+2 -37
View File
@@ -1,8 +1,7 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, users, userFollows, eq, desc, inArray } from "@epicure/db";
import { getPublicUrl } from "@/lib/storage";
import { db, userFollows, eq } from "@epicure/db";
import { FeedPageContent } from "@/components/feed/feed-page-content";
export const metadata: Metadata = {};
@@ -16,39 +15,5 @@ export default async function FeedPage() {
.from(userFollows)
.where(eq(userFollows.followerId, session.user.id));
const followedIds = followedRows.map((r) => r.followingId);
if (followedIds.length === 0) {
return <FeedPageContent followedCount={0} feedRecipes={[]} />;
}
const feedRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
aiGenerated: recipes.aiGenerated,
createdAt: recipes.createdAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(inArray(recipes.authorId, followedIds))
.orderBy(desc(recipes.createdAt))
.limit(40);
return (
<FeedPageContent
followedCount={followedIds.length}
feedRecipes={feedRecipes.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))}
/>
);
return <FeedPageContent followedCount={followedRows.length} />;
}
+10
View File
@@ -0,0 +1,10 @@
import { PageHeaderSkeleton, RecipeCardGridSkeleton } from "@/components/shared/skeletons";
export default function AppLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={1} />
<RecipeCardGridSkeleton />
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Skeleton } from "@/components/ui/skeleton";
import { PageHeaderSkeleton } from "@/components/shared/skeletons";
export default function MealPlanLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={4} subtitle />
<Skeleton className="h-16 w-full rounded-xl" />
<div className="grid grid-cols-1 md:grid-cols-7 gap-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full rounded-xl" />
))}
</div>
</div>
);
}
+1 -1
View File
@@ -31,7 +31,7 @@ export default async function ConversationPage({ params }: Params) {
<div className="border-b p-3 flex items-center gap-3">
<Link href={`/u/${other.username}`} className="flex items-center gap-3">
<Avatar className="h-8 w-8">
{other.avatarUrl && <AvatarImage src={other.avatarUrl} />}
{other.avatarUrl && <AvatarImage src={other.avatarUrl} alt={other.name} />}
<AvatarFallback className="text-xs">{other.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="font-medium text-sm hover:underline">{other.name}</span>
+16
View File
@@ -0,0 +1,16 @@
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
export default function AppNotFound() {
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-center">
<h1 className="text-2xl font-bold tracking-tight">Page not found</h1>
<p className="text-muted-foreground max-w-md">
The page you&apos;re looking for doesn&apos;t exist or may have been moved.
</p>
<Link href="/recipes" className={buttonVariants({ variant: "default" })}>
Back home
</Link>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { Skeleton } from "@/components/ui/skeleton";
import { PageHeaderSkeleton, ListRowSkeleton } from "@/components/shared/skeletons";
export default function PantryLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={1} />
<Skeleton className="h-20 w-full rounded-xl" />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
</div>
);
}
@@ -0,0 +1,29 @@
import { Skeleton } from "@/components/ui/skeleton";
import { ListRowSkeleton } from "@/components/shared/skeletons";
export default function RecipeDetailLoading() {
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="space-y-4">
<Skeleton className="h-9 w-2/3" />
<div className="flex items-center gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-8 rounded-lg" />
))}
</div>
<Skeleton className="h-4 w-full max-w-lg" />
<div className="flex flex-wrap items-center gap-3">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
<Skeleton className="h-5 w-20" />
</div>
</div>
<Skeleton className="aspect-video w-full rounded-xl" />
<div className="space-y-4">
<Skeleton className="h-6 w-32" />
<ListRowSkeleton />
<ListRowSkeleton />
</div>
</div>
);
}
+13 -6
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
@@ -287,11 +288,12 @@ export default async function RecipePage({ params }: Params) {
{/* Cover photo */}
{cover && (
<div className="aspect-video overflow-hidden rounded-xl bg-muted">
<img
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
<Image
src={getPublicUrl(cover.storageKey)}
alt={recipe.title}
className="w-full h-full object-cover"
fill
className="object-cover"
/>
</div>
)}
@@ -373,9 +375,14 @@ export default async function RecipePage({ params }: Params) {
<div className="space-y-3">
<h2 className="text-xl font-semibold">Photos</h2>
<div className="grid grid-cols-3 gap-3">
{recipe.photos.map((photo) => (
<div key={photo.id} className="aspect-square rounded-lg overflow-hidden bg-muted">
<img src={getPublicUrl(photo.storageKey)} alt="" className="w-full h-full object-cover" />
{recipe.photos.map((photo, i) => (
<div key={photo.id} className="relative aspect-square rounded-lg overflow-hidden bg-muted">
<Image
src={getPublicUrl(photo.storageKey)}
alt={`${recipe.title} photo ${i + 1}`}
fill
className="object-cover"
/>
</div>
))}
</div>
+10
View File
@@ -0,0 +1,10 @@
import { PageHeaderSkeleton, RecipeCardGridSkeleton } from "@/components/shared/skeletons";
export default function RecipesLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={2} subtitle />
<RecipeCardGridSkeleton />
</div>
);
}
+64 -18
View File
@@ -1,7 +1,8 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/lib/auth/server";
import { db, recipes, sql } from "@epicure/db";
import { db, recipes, sql, count } 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";
@@ -9,12 +10,15 @@ import { RecipesGrid } from "@/components/recipe/recipes-grid";
export const metadata: Metadata = {};
const PAGE_SIZE = 24;
type SearchParams = Promise<{
q?: string;
sort?: string;
visibility?: string;
difficulty?: string;
tag?: string;
page?: string;
}>;
const SORT_MAP = {
@@ -32,10 +36,12 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const { q, sort, visibility, difficulty, tag } = await searchParams;
const { q, sort, visibility, difficulty, tag, page: pageParam } = 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 page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
? (visibility as "private" | "unlisted" | "public")
@@ -44,32 +50,72 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
? (difficulty as "easy" | "medium" | "hard")
: undefined;
const userRecipes = await db.query.recipes.findMany({
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 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,
);
const [userRecipes, totalRow] = await Promise.all([
db.query.recipes.findMany({
where,
orderBy: SORT_MAP[sortKey],
with: { photos: true },
limit: PAGE_SIZE,
offset,
}),
db.select({ count: count() }).from(recipes).where(where),
]);
const total = totalRow[0]?.count ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const pageHref = (p: number) => {
const params = new URLSearchParams();
if (query) params.set("q", query);
if (sortKey !== "updated_desc") params.set("sort", sortKey);
if (visibilityFilter) params.set("visibility", visibilityFilter);
if (difficultyFilter) params.set("difficulty", difficultyFilter);
if (tagFilter) params.set("tag", tagFilter);
if (p > 1) params.set("page", String(p));
const qs = params.toString();
return qs ? `/recipes?${qs}` : "/recipes";
};
return (
<div className="space-y-6">
<RecipesHeader
count={userRecipes.length}
count={total}
initialQuery={query}
initialSort={sortKey}
initialVisibility={visibilityFilter ?? ""}
initialDifficulty={difficultyFilter ?? ""}
initialTag={tagFilter ?? ""}
/>
<RecipesEmptyState query={query} count={userRecipes.length} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}`} recipes={userRecipes} />
<RecipesEmptyState query={query} count={total} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}-${page}`} recipes={userRecipes} />
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
{page > 1 && (
<Link href={pageHref(page - 1)} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
Previous
</Link>
)}
<span className="text-sm text-muted-foreground px-2">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link href={pageHref(page + 1)} className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent">
Next
</Link>
)}
</div>
)}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { Skeleton } from "@/components/ui/skeleton";
import { InfoCardSkeleton } from "@/components/shared/skeletons";
export default function SearchLoading() {
return (
<div className="max-w-5xl mx-auto space-y-6">
<div>
<Skeleton className="h-9 w-56 mb-6" />
<Skeleton className="h-12 w-full rounded-md" />
<div className="mt-3 flex flex-wrap items-center gap-3">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-9 w-36" />
</div>
</div>
<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) => (
<InfoCardSkeleton key={i} />
))}
</div>
</div>
);
}
@@ -0,0 +1,14 @@
import { PageHeaderSkeleton, ListRowSkeleton } from "@/components/shared/skeletons";
export default function ShoppingListsLoading() {
return (
<div className="space-y-6">
<PageHeaderSkeleton actions={1} />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<ListRowSkeleton key={i} />
))}
</div>
</div>
);
}
@@ -0,0 +1,32 @@
import { Skeleton } from "@/components/ui/skeleton";
import { SquareTileSkeleton } from "@/components/shared/skeletons";
export default function UserProfileLoading() {
return (
<div className="max-w-4xl mx-auto space-y-10">
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center">
<Skeleton className="h-24 w-24 shrink-0 rounded-full" />
<div className="flex-1 space-y-3">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-4 w-64" />
<div className="flex flex-wrap gap-2">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
</div>
</div>
</div>
<div className="space-y-4">
<Skeleton className="h-6 w-24" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<SquareTileSkeleton key={i} />
))}
</div>
</div>
</div>
);
}
+44 -7
View File
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import Image from "next/image";
import { auth } from "@/lib/auth/server";
import {
db,
@@ -20,7 +21,12 @@ import { BlockButton } from "@/components/social/block-button";
import { MessageButton } from "@/components/social/message-button";
import { getPublicUrl } from "@/lib/storage";
type Params = { params: Promise<{ username: string }> };
const PAGE_SIZE = 24;
type Params = {
params: Promise<{ username: string }>;
searchParams: Promise<{ page?: string }>;
};
export async function generateMetadata({ params }: Params) {
const { username } = await params;
@@ -28,8 +34,11 @@ export async function generateMetadata({ params }: Params) {
return { title: user ? `${user.name} (@${user.username})` : "Profile" };
}
export default async function UserProfilePage({ params }: Params) {
export default async function UserProfilePage({ params, searchParams }: Params) {
const { username } = await params;
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const session = await auth.api.getSession({ headers: await headers() });
@@ -52,7 +61,8 @@ export default async function UserProfilePage({ params }: Params) {
db.query.recipes.findMany({
where: and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public")),
orderBy: desc(recipes.createdAt),
limit: 24,
limit: PAGE_SIZE,
offset,
with: {
photos: { orderBy: (t, { asc }) => asc(t.order), limit: 1 },
},
@@ -62,6 +72,7 @@ export default async function UserProfilePage({ params }: Params) {
const followerCount = followerCountRow[0]?.count ?? 0;
const followingCount = followingCountRow[0]?.count ?? 0;
const recipeCount = recipeCountRow[0]?.count ?? 0;
const totalPages = Math.max(1, Math.ceil(recipeCount / PAGE_SIZE));
let isFollowing = false;
let isBlocked = false;
@@ -148,15 +159,17 @@ export default async function UserProfilePage({ params }: Params) {
return (
<Link
key={recipe.id}
href={`/r/${recipe.id}`}
href={`/recipes/${recipe.id}`}
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
>
<div className="aspect-square bg-muted overflow-hidden">
<div className="relative aspect-square bg-muted overflow-hidden">
{cover ? (
<img
<Image
src={getPublicUrl(cover.storageKey)}
alt={recipe.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, 25vw"
className="object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-3xl">
@@ -171,6 +184,30 @@ export default async function UserProfilePage({ params }: Params) {
);
})}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-2">
{page > 1 && (
<Link
href={`/u/${username}${page - 1 > 1 ? `?page=${page - 1}` : ""}`}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
>
Previous
</Link>
)}
<span className="text-sm text-muted-foreground px-2">
Page {page} of {totalPages}
</span>
{page < totalPages && (
<Link
href={`/u/${username}?page=${page + 1}`}
className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent"
>
Next
</Link>
)}
</div>
)}
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
+26
View File
@@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-center">
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-muted-foreground max-w-md">
An unexpected error occurred in the admin panel. You can try again.
</p>
<Button onClick={() => reset()}>Try again</Button>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Skeleton } from "@/components/ui/skeleton";
import { StatCardSkeleton } from "@/components/shared/skeletons";
export default function AdminLoading() {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-32" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<StatCardSkeleton key={i} />
))}
</div>
</div>
);
}
+51 -17
View File
@@ -1,31 +1,46 @@
import type { Metadata } from "next";
import { db, recipes, users, eq, desc } from "@epicure/db";
import { db, recipes, users, eq, desc, count } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
export const metadata: Metadata = {};
const PAGE_SIZE = 100;
const VISIBILITY_COLORS = {
public: "default",
unlisted: "outline",
private: "secondary",
} as const;
export default async function AdminRecipesPage() {
const publicRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
visibility: recipes.visibility,
createdAt: recipes.createdAt,
authorName: users.name,
authorId: users.id,
})
.from(recipes)
.leftJoin(users, eq(recipes.authorId, users.id))
.where(eq(recipes.visibility, "public"))
.orderBy(desc(recipes.createdAt))
.limit(200);
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
export default async function AdminRecipesPage({ searchParams }: PageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const [publicRecipes, totalRow] = await Promise.all([
db
.select({
id: recipes.id,
title: recipes.title,
visibility: recipes.visibility,
createdAt: recipes.createdAt,
authorName: users.name,
authorId: users.id,
})
.from(recipes)
.leftJoin(users, eq(recipes.authorId, users.id))
.where(eq(recipes.visibility, "public"))
.orderBy(desc(recipes.createdAt))
.limit(PAGE_SIZE)
.offset(offset),
db.select({ count: count() }).from(recipes).where(eq(recipes.visibility, "public")),
]);
const total = totalRow[0]?.count ?? 0;
return (
<div className="space-y-6">
@@ -36,7 +51,7 @@ export default async function AdminRecipesPage() {
</p>
</div>
<div className="rounded-md border">
<div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
@@ -94,6 +109,25 @@ export default async function AdminRecipesPage() {
</tbody>
</table>
</div>
<div className="flex gap-2">
{page > 1 && (
<Link
href={`/admin/recipes?page=${page - 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Previous
</Link>
)}
{offset + publicRecipes.length < total && (
<Link
href={`/admin/recipes?page=${page + 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Next
</Link>
)}
</div>
</div>
);
}
+52 -17
View File
@@ -1,25 +1,41 @@
import type { Metadata } from "next";
import { db, reports, users, eq, desc } from "@epicure/db";
import Link from "next/link";
import { db, reports, users, eq, desc, count } from "@epicure/db";
import { ReportsQueue } from "@/components/admin/reports-queue";
export const metadata: Metadata = {};
export default async function AdminReportsPage() {
const rows = await db
.select({
id: reports.id,
targetType: reports.targetType,
targetId: reports.targetId,
reason: reports.reason,
createdAt: reports.createdAt,
reporterName: users.name,
reporterEmail: users.email,
})
.from(reports)
.innerJoin(users, eq(reports.reporterId, users.id))
.where(eq(reports.status, "pending"))
.orderBy(desc(reports.createdAt))
.limit(100);
const PAGE_SIZE = 100;
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
export default async function AdminReportsPage({ searchParams }: PageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const [rows, totalRow] = await Promise.all([
db
.select({
id: reports.id,
targetType: reports.targetType,
targetId: reports.targetId,
reason: reports.reason,
createdAt: reports.createdAt,
reporterName: users.name,
reporterEmail: users.email,
})
.from(reports)
.innerJoin(users, eq(reports.reporterId, users.id))
.where(eq(reports.status, "pending"))
.orderBy(desc(reports.createdAt))
.limit(PAGE_SIZE)
.offset(offset),
db.select({ count: count() }).from(reports).where(eq(reports.status, "pending")),
]);
const total = totalRow[0]?.count ?? 0;
return (
<div className="space-y-6">
@@ -32,6 +48,25 @@ export default async function AdminReportsPage() {
<ReportsQueue
reports={rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))}
/>
<div className="flex gap-2">
{page > 1 && (
<Link
href={`/admin/reports?page=${page - 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Previous
</Link>
)}
{offset + rows.length < total && (
<Link
href={`/admin/reports?page=${page + 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Next
</Link>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -72,7 +72,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.avatarUrl ?? ""} />
<AvatarImage src={user.avatarUrl ?? ""} alt={user.name} />
<AvatarFallback className="text-lg">
{user.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
+43 -9
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { db } from "@epicure/db";
import { db, count } from "@epicure/db";
import { users } from "@epicure/db";
import { desc } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
@@ -9,6 +9,12 @@ import Link from "next/link";
export const metadata: Metadata = {};
const PAGE_SIZE = 100;
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
const ROLE_COLORS = {
user: "secondary",
moderator: "outline",
@@ -20,12 +26,21 @@ const TIER_COLORS = {
pro: "default",
} as const;
export default async function AdminUsersPage() {
const allUsers = await db
.select()
.from(users)
.orderBy(desc(users.createdAt))
.limit(100);
export default async function AdminUsersPage({ searchParams }: PageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
const offset = (page - 1) * PAGE_SIZE;
const [allUsers, totalRow] = await Promise.all([
db
.select()
.from(users)
.orderBy(desc(users.createdAt))
.limit(PAGE_SIZE)
.offset(offset),
db.select({ count: count() }).from(users),
]);
const total = totalRow[0]?.count ?? 0;
return (
<div className="space-y-6">
@@ -33,7 +48,7 @@ export default async function AdminUsersPage() {
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<CreateUserDialog />
</div>
<div className="rounded-md border">
<div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
@@ -49,7 +64,7 @@ export default async function AdminUsersPage() {
<td className="px-4 py-3">
<Link href={`/admin/users/${user.id}`} className="flex items-center gap-3 group">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatarUrl ?? ""} />
<AvatarImage src={user.avatarUrl ?? ""} alt={user.name} />
<AvatarFallback className="text-xs">
{user.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
@@ -74,6 +89,25 @@ export default async function AdminUsersPage() {
</tbody>
</table>
</div>
<div className="flex gap-2">
{page > 1 && (
<Link
href={`/admin/users?page=${page - 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Previous
</Link>
)}
{offset + allUsers.length < total && (
<Link
href={`/admin/users?page=${page + 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Next
</Link>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export async function POST(req: Request) {
if (limited) return limited;
const body = PostSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
if (!body.success) return NextResponse.json({ error: "Validation error", issues: body.error.issues }, { status: 400 });
const { provider, apiKey } = body.data;
const userId = session!.user.id;
+5 -3
View File
@@ -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 { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
@@ -43,10 +43,12 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
}
const [aiConfig, privateBio] = await Promise.all([
withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model }),
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model })),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
adaptRecipe(
+7 -3
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
@@ -34,10 +34,14 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
),
getUserPrivateBio(session!.user.id),
]);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
suggestDrinks(
+4 -2
View File
@@ -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 { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { importFromPhoto } from "@/lib/ai/features/import-photo";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
@@ -28,7 +28,9 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const aiConfig = await getModelConfigForUseCase(userId, "vision");
const configResult = await resolveAiConfigOrError(() => getModelConfigForUseCase(userId, "vision"));
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
// Fall back to vision-capable defaults if no explicit model configured
if (!aiConfig.model) {
@@ -4,7 +4,8 @@ import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { aiErrorResponse } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { checkAndIncrementTierLimit, incrementUsage, TierLimitError } from "@/lib/tiers";
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
@@ -35,10 +36,12 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const [config, privateBio] = await Promise.all([
getDefaultProviderWithKey(userId),
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getDefaultProviderWithKey(userId)),
getUserPrivateBio(userId),
]);
if (!configResult.ok) return configResult.response;
const config = configResult.data;
// pantryMode forces usePantry on so pantry items are always fetched when maximizing pantry use
const effectiveUsePantry = parsed.data.usePantry || parsed.data.pantryMode;
@@ -53,9 +56,8 @@ export async function POST(req: NextRequest) {
pantryItemNames = pantry.map((p) => p.rawName);
}
let plan;
try {
plan = await generateMealPlan(
const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () =>
generateMealPlan(
{
dietaryPrefs: parsed.data.dietaryPrefs,
servings: parsed.data.servings,
@@ -66,9 +68,26 @@ export async function POST(req: NextRequest) {
},
{ ...config, userContext: privateBio ?? undefined },
locale
);
)
);
if (!result.ok) return result.response;
const plan = result.data;
// Each plan entry creates a draft recipe — charge the recipe limit for all
// of them before inserting anything, refunding on breach so a rejected plan
// doesn't consume quota.
let chargedRecipes = 0;
try {
for (let i = 0; i < plan.entries.length; i++) {
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "recipe");
chargedRecipes++;
}
} catch (err) {
return aiErrorResponse(err);
if (err instanceof TierLimitError) {
if (chargedRecipes > 0) await incrementUsage(userId, "recipe", -chargedRecipes);
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
// Ensure meal plan row exists for the week
@@ -3,7 +3,7 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
@@ -35,10 +35,14 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
),
getUserPrivateBio(session!.user.id),
]);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
suggestPairings(
+14 -9
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { generateText } from "ai";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { db, recipes, eq, and } from "@epicure/db";
@@ -62,22 +63,26 @@ STEPS:
${stepList || "None listed"}
`.trim();
const [config, privateBio] = await Promise.all([
getModelConfigForUseCase(session!.user.id, "text"),
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "text")),
getUserPrivateBio(session!.user.id),
]);
const model = resolveModel(config);
if (!configResult.ok) return configResult.response;
const model = resolveModel(configResult.data);
const bioContext = buildUserBioContext(privateBio);
const locale = (session!.user as { locale?: string }).locale ?? "en";
const lang = LANG[locale] ?? "English";
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. Respond in ${lang}.
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
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. Respond in ${lang}.
${recipeContext}${bioContext}`,
prompt: parsed.data.question,
});
prompt: parsed.data.question,
})
);
if (!result.ok) return result.response;
return NextResponse.json({ answer: text });
return NextResponse.json({ answer: result.data.text });
}
+15 -10
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { generateObject } from "ai";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
@@ -36,11 +37,12 @@ export async function POST(req: NextRequest) {
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"),
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() => getModelConfigForUseCase(session!.user.id, "text")),
getUserPrivateBio(session!.user.id),
]);
const model = resolveModel(config);
if (!configResult.ok) return configResult.response;
const model = resolveModel(configResult.data);
const bioContext = buildUserBioContext(privateBio);
const userContext = bioContext
@@ -54,12 +56,15 @@ export async function POST(req: NextRequest) {
? `${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,
system: `Respond in ${lang}.`,
prompt,
});
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
generateObject({
model,
schema: IdeasSchema,
system: `Respond in ${lang}.`,
prompt,
})
);
if (!result.ok) return result.response;
return NextResponse.json(object.ideas);
return NextResponse.json(result.data.object.ideas);
}
+4 -2
View File
@@ -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 { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { scaleRecipe } from "@/lib/ai/features/scale-recipe";
const Schema = z.object({
@@ -40,7 +40,9 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const aiConfig = await getDefaultProviderWithKey(session!.user.id);
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id));
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
scaleRecipe(
+9 -4
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
@@ -28,9 +28,14 @@ export async function POST(req: NextRequest) {
? `recipe "${parsed.data.recipeTitle}"`
: "a general recipe";
const aiConfig = parsed.data.provider
? { provider: parsed.data.provider, model: parsed.data.model }
: await getDefaultProviderWithKey(session!.user.id);
let aiConfig;
if (parsed.data.provider) {
aiConfig = { provider: parsed.data.provider, model: parsed.data.model };
} else {
const configResult = await resolveAiConfigOrError(() => getDefaultProviderWithKey(session!.user.id));
if (!configResult.ok) return configResult.response;
aiConfig = configResult.data;
}
const locale = (session!.user as { locale?: string }).locale ?? "en";
@@ -3,7 +3,7 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { withAiQuota } from "@/lib/ai/ai-error";
import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error";
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
@@ -38,10 +38,14 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
const [configResult, privateBio] = await Promise.all([
resolveAiConfigOrError(() =>
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model })
),
getUserPrivateBio(session!.user.id),
]);
if (!configResult.ok) return configResult.response;
const aiConfig = configResult.data;
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
suggestVariations(
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, conversations, conversationReads, messages, eq, asc } from "@epicure/db";
import { db, conversations, conversationReads, messages, eq, and, desc, lt } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { isParticipant, otherParticipantId } from "@/lib/messaging";
@@ -13,7 +13,9 @@ interface RouteContext {
const Schema = z.object({ content: z.string().min(1).max(4000) });
export async function GET(_req: NextRequest, { params }: RouteContext) {
const PAGE_SIZE = 50;
export async function GET(req: NextRequest, { params }: RouteContext) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
@@ -23,22 +25,42 @@ export async function GET(_req: NextRequest, { params }: RouteContext) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Cursor-based pagination: fetch the page ending just before `before`
// (an ISO createdAt timestamp), newest-first, then reverse so the client
// still receives oldest-first order for the page. This avoids the old
// `asc + limit(200)` bug, which permanently hid every message past the
// 200th once a conversation grew beyond that.
const before = req.nextUrl.searchParams.get("before");
const beforeDate = before ? new Date(before) : null;
const validBefore = beforeDate && !isNaN(beforeDate.getTime()) ? beforeDate : null;
const rows = await db
.select()
.from(messages)
.where(eq(messages.conversationId, id))
.orderBy(asc(messages.createdAt))
.limit(200);
.where(
validBefore
? and(eq(messages.conversationId, id), lt(messages.createdAt, validBefore))
: eq(messages.conversationId, id)
)
.orderBy(desc(messages.createdAt))
.limit(PAGE_SIZE);
await db
.insert(conversationReads)
.values({ conversationId: id, userId: session!.user.id, lastReadAt: new Date() })
.onConflictDoUpdate({
target: [conversationReads.conversationId, conversationReads.userId],
set: { lastReadAt: new Date() },
});
const ordered = rows.slice().reverse();
const nextCursor = rows.length === PAGE_SIZE ? rows[rows.length - 1]!.createdAt.toISOString() : null;
return NextResponse.json({ messages: rows });
// Only mark the conversation read when loading the latest page, not when
// paging through history.
if (!validBefore) {
await db
.insert(conversationReads)
.values({ conversationId: id, userId: session!.user.id, lastReadAt: new Date() })
.onConflictDoUpdate({
target: [conversationReads.conversationId, conversationReads.userId],
set: { lastReadAt: new Date() },
});
}
return NextResponse.json({ messages: ordered, nextCursor });
}
export async function POST(req: NextRequest, { params }: RouteContext) {
+6 -2
View File
@@ -10,6 +10,8 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50);
const offsetRaw = parseInt(searchParams.get("offset") ?? "0");
const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(0, offsetRaw);
// Recipes the user has favorited, or rated 4+, define their taste profile.
const [favoritedRows, highRatedRows] = await Promise.all([
@@ -60,10 +62,12 @@ export async function GET(req: NextRequest) {
? rankForYou(candidates, preferences)
: [...candidates].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const data = ranked.slice(0, limit).map(({ tags: _tags, dietaryTags: _dietaryTags, ...r }) => ({
const data = ranked.slice(offset, offset + limit).map(({ tags: _tags, dietaryTags: _dietaryTags, ...r }) => ({
...r,
createdAt: r.createdAt.toISOString(),
}));
return NextResponse.json({ data });
// `ranked` is capped to a bounded recent window (see `candidates` query above), so this
// total reflects the size of that ranked window rather than every eligible recipe.
return NextResponse.json({ data, total: ranked.length, limit, offset });
}
+37 -26
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, users, userFollows, eq, and, ne } from "@epicure/db";
import { db, recipes, users, userFollows, eq, and, ne, sql } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { desc, inArray } from "@epicure/db";
@@ -20,32 +20,43 @@ export async function GET(req: NextRequest) {
const followedIds = followedRows.map((r) => r.followingId);
if (followedIds.length === 0) {
return NextResponse.json({ data: [], limit, offset, message: "Follow some users to see their recipes here." });
return NextResponse.json({ data: [], total: 0, limit, offset, message: "Follow some users to see their recipes here." });
}
const feedRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
createdAt: recipes.createdAt,
updatedAt: recipes.updatedAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where((t) => and(inArray(t.authorId, followedIds), ne(recipes.visibility, "private")))
.orderBy(desc(recipes.createdAt))
.limit(limit)
.offset(offset);
const where = and(inArray(recipes.authorId, followedIds), ne(recipes.visibility, "private"));
return NextResponse.json({ data: feedRecipes, limit, offset });
const [feedRecipes, totalRow] = await Promise.all([
db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
createdAt: recipes.createdAt,
updatedAt: recipes.updatedAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(where)
.orderBy(desc(recipes.createdAt))
.limit(limit)
.offset(offset),
db
.select({ total: sql<number>`count(*)::int` })
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(where),
]);
const total = totalRow[0]?.total ?? 0;
return NextResponse.json({ data: feedRecipes, total, limit, offset });
}
+44 -29
View File
@@ -3,40 +3,55 @@ import { db, recipes, users, favorites, eq, desc, sql, and, gte } from "@epicure
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50);
const limitRaw = parseInt(searchParams.get("limit") ?? "20");
const limit = Math.min(Number.isNaN(limitRaw) ? 20 : Math.max(1, limitRaw), 50);
const offsetRaw = parseInt(searchParams.get("offset") ?? "0");
const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(0, offsetRaw);
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const trending = await db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
aiGenerated: recipes.aiGenerated,
createdAt: recipes.createdAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
favoriteCount: sql<number>`cast(count(${favorites.recipeId}) as int)`,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin(
favorites,
and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, sevenDaysAgo))
)
.where(eq(recipes.visibility, "public"))
.groupBy(recipes.id, users.id)
.orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt))
.limit(limit);
const [trending, totalRow] = await Promise.all([
db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
aiGenerated: recipes.aiGenerated,
createdAt: recipes.createdAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
favoriteCount: sql<number>`cast(count(${favorites.recipeId}) as int)`,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin(
favorites,
and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, sevenDaysAgo))
)
.where(eq(recipes.visibility, "public"))
.groupBy(recipes.id, users.id)
.orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt))
.limit(limit)
.offset(offset),
db
.select({ total: sql<number>`count(*)::int` })
.from(recipes)
.where(eq(recipes.visibility, "public")),
]);
const total = totalRow[0]?.total ?? 0;
return NextResponse.json({
data: trending.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
total,
limit,
offset,
});
}
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, mealPlans, mealPlanEntries, eq, and } from "@epicure/db";
import { db, mealPlans, mealPlanEntries, recipes, eq, and, or, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
@@ -34,6 +34,16 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
if (parsed.data.recipeId) {
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, parsed.data.recipeId),
or(eq(recipes.authorId, session!.user.id), ne(recipes.visibility, "private"))
),
});
if (!recipe) return NextResponse.json({ error: "Recipe not found" }, { status: 404 });
}
const plan = await getOrCreatePlan(session!.user.id, weekStart);
// Remove existing entry for same day+mealType before inserting
@@ -103,16 +103,20 @@ export async function DELETE(req: NextRequest, { params }: Params) {
const memberId = req.nextUrl.searchParams.get("memberId");
if (!memberId) return NextResponse.json({ error: "memberId required" }, { status: 400 });
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)),
});
if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Look up the member row first (not scoped to plans owned by the caller) —
// a member removing themselves isn't the plan owner, so scoping the plan
// lookup to `ownerId = session.user.id` would 404 before we ever reach the
// self-leave check below.
const member = await db.query.mealPlanMembers.findFirst({
where: and(eq(mealPlanMembers.id, memberId), eq(mealPlanMembers.mealPlanId, plan.id)),
where: eq(mealPlanMembers.id, memberId),
});
if (!member) return NextResponse.json({ error: "Not found" }, { status: 404 });
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.id, member.mealPlanId), eq(mealPlans.weekStart, weekStart)),
});
if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 });
const isOwner = plan.userId === session!.user.id;
const isSelf = member.userId === session!.user.id;
if (!isOwner && !isSelf) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+23 -7
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, pantryItems, eq, desc } from "@epicure/db";
import { db, pantryItems, eq, desc, sql } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const Schema = z.object({
@@ -10,16 +10,32 @@ const Schema = z.object({
expiresAt: z.string().datetime().optional(),
});
export async function GET(_req: NextRequest) {
export async function GET(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const items = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session!.user.id),
orderBy: desc(pantryItems.createdAt),
});
const { searchParams } = req.nextUrl;
const limitRaw = parseInt(searchParams.get("limit") ?? "50");
const limit = Math.min(Number.isNaN(limitRaw) ? 50 : Math.max(1, limitRaw), 100);
const offsetRaw = parseInt(searchParams.get("offset") ?? "0");
const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(0, offsetRaw);
return NextResponse.json(items);
const [items, totalRow] = await Promise.all([
db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session!.user.id),
orderBy: desc(pantryItems.createdAt),
limit,
offset,
}),
db
.select({ total: sql<number>`count(*)::int` })
.from(pantryItems)
.where(eq(pantryItems.userId, session!.user.id)),
]);
const total = totalRow[0]?.total ?? 0;
return NextResponse.json({ data: items, total, limit, offset });
}
export async function POST(req: NextRequest) {
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, comments, users, userBlocks, eq, and, inArray } from "@epicure/db";
import { db, recipes, comments, users, userBlocks, eq, and, inArray, isNull, sql } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { dispatchWebhook } from "@/lib/webhooks";
@@ -16,7 +16,19 @@ const Schema = z.object({
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const COMMENT_COLUMNS = {
id: comments.id,
content: comments.content,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
userId: comments.userId,
userName: users.name,
userUsername: users.username,
userAvatarUrl: users.avatarUrl,
} as const;
export async function GET(req: NextRequest, { params }: Params) {
const { id } = await params;
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
if (!recipe || recipe.visibility === "private") {
@@ -25,32 +37,55 @@ export async function GET(_req: NextRequest, { params }: Params) {
const { session } = await requireSession();
const rows = await db
.select({
id: comments.id,
content: comments.content,
parentId: comments.parentId,
createdAt: comments.createdAt,
updatedAt: comments.updatedAt,
userId: comments.userId,
userName: users.name,
userUsername: users.username,
userAvatarUrl: users.avatarUrl,
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.where(eq(comments.recipeId, id))
.orderBy(comments.createdAt);
const { searchParams } = req.nextUrl;
const limitRaw = parseInt(searchParams.get("limit") ?? "20");
const limit = Math.min(Number.isNaN(limitRaw) ? 20 : Math.max(1, limitRaw), 50);
const offsetRaw = parseInt(searchParams.get("offset") ?? "0");
const offset = Number.isNaN(offsetRaw) ? 0 : Math.max(0, offsetRaw);
if (!session) return NextResponse.json(rows);
// Paginate top-level comments; replies to a loaded top-level comment are always
// fetched alongside it so threads render complete (threads are typically small).
const [topLevel, totalRow] = await Promise.all([
db
.select(COMMENT_COLUMNS)
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.where(and(eq(comments.recipeId, id), isNull(comments.parentId)))
.orderBy(comments.createdAt)
.limit(limit)
.offset(offset),
db
.select({ total: sql<number>`count(*)::int` })
.from(comments)
.where(and(eq(comments.recipeId, id), isNull(comments.parentId))),
]);
const blocked = await db
.select({ blockedId: userBlocks.blockedId })
.from(userBlocks)
.where(eq(userBlocks.blockerId, session.user.id));
const blockedIds = new Set(blocked.map((b) => b.blockedId));
const total = totalRow[0]?.total ?? 0;
const topLevelIds = topLevel.map((c) => c.id);
return NextResponse.json(rows.filter((r) => !blockedIds.has(r.userId)));
const replies = topLevelIds.length > 0
? await db
.select(COMMENT_COLUMNS)
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.where(inArray(comments.parentId, topLevelIds))
.orderBy(comments.createdAt)
: [];
const rows = [...topLevel, ...replies];
const data = session
? await (async () => {
const blocked = await db
.select({ blockedId: userBlocks.blockedId })
.from(userBlocks)
.where(eq(userBlocks.blockerId, session.user.id));
const blockedIds = new Set(blocked.map((b) => b.blockedId));
return rows.filter((r) => !blockedIds.has(r.userId));
})()
: rows;
return NextResponse.json({ data, total, limit, offset });
}
export async function POST(req: NextRequest, { params }: Params) {
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients } from "@epicure/db";
import { eq, and, or, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { withAiQuota } from "@/lib/ai/ai-error";
import { estimateNutrition } from "@/lib/ai/features/estimate-nutrition";
type Params = { params: Promise<{ id: string }> };
@@ -31,14 +33,12 @@ export async function POST(_req: NextRequest, { params }: Params) {
if (response) return response;
const { id } = await params;
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
// Only the recipe author may trigger a (paid) nutrition estimate
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
or(
eq(recipes.authorId, session!.user.id),
ne(recipes.visibility, "private")
)
),
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
},
@@ -46,17 +46,20 @@ export async function POST(_req: NextRequest, { params }: Params) {
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const result = await estimateNutrition({
title: recipe.title,
baseServings: recipe.baseServings,
ingredients: recipe.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
})),
});
const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () =>
estimateNutrition({
title: recipe.title,
baseServings: recipe.baseServings,
ingredients: recipe.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
})),
})
);
if (!result.ok) return result.response;
await db.update(recipes).set({ nutritionData: result }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
await db.update(recipes).set({ nutritionData: result.data }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ nutrition: result });
return NextResponse.json({ nutrition: result.data });
}
+64 -37
View File
@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db";
import { eq, and, max } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots, ratings } from "@epicure/db";
import { eq, and, max, isNotNull } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { deleteObject } from "@/lib/storage";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
@@ -69,41 +70,6 @@ export async function PUT(req: NextRequest, { params }: Params) {
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Create a snapshot of the current state before updating
const [maxVersionRow] = await db
.select({ v: max(recipeSnapshots.version) })
.from(recipeSnapshots)
.where(eq(recipeSnapshots.recipeId, id));
const nextVersion = (maxVersionRow?.v ?? 0) + 1;
await db.insert(recipeSnapshots).values({
id: crypto.randomUUID(),
recipeId: id,
authorId: session!.user.id,
version: nextVersion,
title: existing.title,
snapshotData: {
title: existing.title,
description: existing.description,
baseServings: existing.baseServings,
difficulty: existing.difficulty,
prepMins: existing.prepMins,
cookMins: existing.cookMins,
dietaryTags: existing.dietaryTags ?? {},
ingredients: existing.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
note: i.note,
order: i.order,
})),
steps: existing.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: s.order,
})),
},
});
const body = await req.json() as unknown;
const parsed = UpdateRecipeSchema.safeParse(body);
if (!parsed.success) {
@@ -113,6 +79,41 @@ export async function PUT(req: NextRequest, { params }: Params) {
const data = parsed.data;
await db.transaction(async (tx) => {
// Create a snapshot of the current state before updating
const [maxVersionRow] = await tx
.select({ v: max(recipeSnapshots.version) })
.from(recipeSnapshots)
.where(eq(recipeSnapshots.recipeId, id));
const nextVersion = (maxVersionRow?.v ?? 0) + 1;
await tx.insert(recipeSnapshots).values({
id: crypto.randomUUID(),
recipeId: id,
authorId: session!.user.id,
version: nextVersion,
title: existing.title,
snapshotData: {
title: existing.title,
description: existing.description,
baseServings: existing.baseServings,
difficulty: existing.difficulty,
prepMins: existing.prepMins,
cookMins: existing.cookMins,
dietaryTags: existing.dietaryTags ?? {},
ingredients: existing.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
note: i.note,
order: i.order,
})),
steps: existing.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: s.order,
})),
},
});
const updates: Partial<typeof recipes.$inferInsert> = { updatedAt: new Date() };
if (data.title !== undefined) updates.title = data.title;
if (data.description !== undefined) updates.description = data.description;
@@ -161,6 +162,9 @@ export async function PUT(req: NextRequest, { params }: Params) {
const updated = await getOwnedRecipe(id, session!.user.id);
void dispatchWebhook(session!.user.id, "recipe.updated", { id, title: updated?.title });
if (existing.visibility === "private" && data.visibility === "public") {
void dispatchWebhook(session!.user.id, "recipe.published", { id, title: updated?.title });
}
return NextResponse.json(updated);
}
@@ -171,10 +175,33 @@ export async function DELETE(req: NextRequest, { params }: Params) {
const existing = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
with: { photos: true },
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Collect storage keys before the cascade delete removes the rows.
// recipeSteps.photoUrl stores a full URL rather than a storage key, so it
// is intentionally not deleted here — only objects this app stored by key.
const reviewPhotos = await db
.select({ photoKey: ratings.photoKey })
.from(ratings)
.where(and(eq(ratings.recipeId, id), isNotNull(ratings.photoKey)));
const storageKeys = [
...existing.photos.map((p) => p.storageKey),
...reviewPhotos.map((r) => r.photoKey).filter((k): k is string => k !== null),
];
await db.delete(recipes).where(eq(recipes.id, id));
// Best-effort object cleanup: a storage failure must not fail the response.
for (const key of storageKeys) {
try {
await deleteObject(key);
} catch (err) {
console.error(`Failed to delete storage object ${key} for recipe ${id}`, err);
}
}
void dispatchWebhook(session!.user.id, "recipe.deleted", { id });
return new NextResponse(null, { status: 204 });
}
@@ -55,41 +55,6 @@ export async function POST(req: NextRequest, { params }: Params) {
});
if (!snapshot) return NextResponse.json({ error: "Snapshot not found" }, { status: 404 });
// Create a snapshot of the current state before restoring
const [maxVersionRow] = await db
.select({ v: max(recipeSnapshots.version) })
.from(recipeSnapshots)
.where(eq(recipeSnapshots.recipeId, id));
const nextVersion = (maxVersionRow?.v ?? 0) + 1;
await db.insert(recipeSnapshots).values({
id: crypto.randomUUID(),
recipeId: id,
authorId: session!.user.id,
version: nextVersion,
title: currentRecipe.title,
snapshotData: {
title: currentRecipe.title,
description: currentRecipe.description,
baseServings: currentRecipe.baseServings,
difficulty: currentRecipe.difficulty,
prepMins: currentRecipe.prepMins,
cookMins: currentRecipe.cookMins,
dietaryTags: currentRecipe.dietaryTags ?? {},
ingredients: currentRecipe.ingredients.map((i) => ({
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
note: i.note,
order: i.order,
})),
steps: currentRecipe.steps.map((s) => ({
instruction: s.instruction,
timerSeconds: s.timerSeconds,
order: s.order,
})),
},
});
// Restore the recipe from the snapshot
const data = snapshot.snapshotData as {
title: string;
+5 -2
View File
@@ -66,11 +66,14 @@ export async function GET(req: NextRequest) {
: [];
// --- Build WHERE conditions ---
// Escape ilike wildcard chars (% and _) so user input like "100%" is matched literally.
const escapedQ = q.replace(/[\\%_]/g, (c) => `\\${c}`);
const conditions = [
eq(recipes.visibility, "public"),
or(
ilike(recipes.title, `%${q}%`),
ilike(recipes.description, `%${q}%`)
ilike(recipes.title, `%${escapedQ}%`),
ilike(recipes.description, `%${escapedQ}%`)
)!,
];
@@ -1,10 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
type Params = { params: Promise<{ id: string; itemId: string }> };
const UpdateItemSchema = z.object({
checked: z.boolean().optional(),
});
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
@@ -14,9 +19,13 @@ export async function PUT(req: NextRequest, { params }: Params) {
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await req.json() as { checked?: boolean };
const parsed = UpdateItemSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await db.update(shoppingListItems)
.set({ checked: body.checked ?? false })
.set({ checked: parsed.data.checked ?? false })
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
return NextResponse.json({ updated: true });
+14 -1
View File
@@ -3,9 +3,11 @@ import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { createPresignedUploadUrl } from "@/lib/storage";
import { db, recipes, eq, and } from "@epicure/db";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"] as const;
type AllowedType = (typeof ALLOWED_TYPES)[number];
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const Schema = z.object({
contentType: z.string().refine((t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t), {
@@ -13,6 +15,7 @@ const Schema = z.object({
}),
recipeId: z.string().uuid(),
purpose: z.enum(["recipe", "review"]).default("recipe"),
fileSize: z.number().int().positive().max(MAX_FILE_SIZE, "File exceeds 10MB limit"),
});
export async function POST(req: NextRequest) {
@@ -25,7 +28,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const { recipeId, contentType, purpose } = parsed.data;
const { recipeId, contentType, purpose, fileSize } = parsed.data;
const recipe = await db.query.recipes.findFirst({
where: purpose === "recipe"
? and(eq(recipes.id, recipeId), eq(recipes.authorId, session!.user.id))
@@ -37,6 +40,16 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
try {
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "storage", sizeMb);
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
}
throw err;
}
const ext = contentType.split("/")[1] ?? "jpg";
const folder = purpose === "review" ? "reviews" : "photos";
const key = `recipes/${recipeId}/${folder}/${session!.user.id}-${crypto.randomUUID()}.${ext}`;
+2 -3
View File
@@ -3,12 +3,11 @@ 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;
import { WEBHOOK_EVENTS } from "@/lib/webhooks";
const UpdateWebhookBody = z.object({
url: z.string().min(1).max(2048).optional(),
events: z.array(z.enum(VALID_EVENTS)).optional(),
events: z.array(z.enum(WEBHOOK_EVENTS)).optional(),
active: z.boolean().optional(),
});
+3 -4
View File
@@ -4,17 +4,16 @@ 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;
import { WEBHOOK_EVENTS } from "@/lib/webhooks";
const CreateWebhookBody = z.object({
url: z.string().min(1).max(2048),
events: z.array(z.enum(VALID_EVENTS)).default([]),
events: z.array(z.enum(WEBHOOK_EVENTS)).default([]),
});
const UpdateWebhookBody = z.object({
url: z.string().min(1).max(2048).optional(),
events: z.array(z.enum(VALID_EVENTS)).optional(),
events: z.array(z.enum(WEBHOOK_EVENTS)).optional(),
active: z.boolean().optional(),
});
+20 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { db, users, eq } from "@epicure/db";
import { db, users, processedStripeEvents, eq } from "@epicure/db";
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
// Handles:
@@ -68,13 +68,31 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
let event: { type: string; data: { object: Record<string, unknown> } };
let event: { id: string; type: string; data: { object: Record<string, unknown> } };
try {
event = JSON.parse(body) as typeof event;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!event.id) {
return NextResponse.json({ error: "Missing event id" }, { status: 400 });
}
// Dedup: Stripe may redeliver the same event within its retry/tolerance
// window. Record the event id before processing; if it's already been
// recorded, skip processing (but still ack with 200 so Stripe stops
// retrying).
const [inserted] = await db
.insert(processedStripeEvents)
.values({ id: event.id, type: event.type })
.onConflictDoNothing()
.returning({ id: processedStripeEvents.id });
if (!inserted) {
return NextResponse.json({ received: true, duplicate: true });
}
switch (event.type) {
case "checkout.session.completed": {
// client_reference_id is set to our internal userId when the Checkout Session is created.
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect } from "react";
export default function RootError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
minHeight: "60vh",
padding: "2rem",
textAlign: "center",
fontFamily: "system-ui, sans-serif",
}}
>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600 }}>Something went wrong</h2>
<p style={{ color: "#666", maxWidth: "28rem" }}>
An unexpected error occurred. Please try again.
</p>
<button
onClick={() => reset()}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.5rem",
border: "1px solid #ccc",
background: "#111",
color: "#fff",
cursor: "pointer",
fontSize: "0.875rem",
}}
>
Try again
</button>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
export default function RootNotFound() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
minHeight: "60vh",
padding: "2rem",
textAlign: "center",
fontFamily: "system-ui, sans-serif",
}}
>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700 }}>Page not found</h1>
<p style={{ color: "#666", maxWidth: "28rem" }}>
The page you&apos;re looking for doesn&apos;t exist or may have been moved.
</p>
<a
href="/"
style={{
padding: "0.5rem 1rem",
borderRadius: "0.5rem",
background: "#111",
color: "#fff",
textDecoration: "none",
fontSize: "0.875rem",
}}
>
Back home
</a>
</div>
);
}
+3 -2
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Image from "next/image";
import Script from "next/script";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
@@ -124,8 +125,8 @@ export default async function PublicRecipePage({ params }: Params) {
</div>
{cover && (
<div className="aspect-video overflow-hidden rounded-xl bg-muted">
<img src={getPublicUrl(cover.storageKey)} alt={recipe.title} className="w-full h-full object-cover" />
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
<Image src={getPublicUrl(cover.storageKey)} alt={recipe.title} fill className="object-cover" />
</div>
)}
@@ -102,6 +102,7 @@ export function AdminSettingsForm({
variant="outline"
size="icon"
onClick={() => setShowSecret((prev) => ({ ...prev, [key]: !prev[key] }))}
aria-label={revealed ? "Hide value" : "Show value"}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
+33 -2
View File
@@ -8,6 +8,16 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Copy, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
type Invite = {
id: string;
@@ -25,6 +35,7 @@ export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl:
const [role, setRole] = useState<"user" | "moderator" | "admin">("user");
const [tier, setTier] = useState<"free" | "pro">("free");
const [creating, setCreating] = useState(false);
const [revokeId, setRevokeId] = useState<string | null>(null);
async function handleCreate() {
setCreating(true);
@@ -46,7 +57,6 @@ export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl:
}
async function handleRevoke(id: string) {
if (!confirm("Revoke this invite? The link will stop working.")) return;
try {
const res = await fetch(`/api/v1/admin/invites/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Failed to revoke");
@@ -141,7 +151,7 @@ export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl:
variant="outline"
size="icon-sm"
className="text-destructive hover:text-destructive"
onClick={() => { void handleRevoke(invite.id); }}
onClick={() => setRevokeId(invite.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -152,6 +162,27 @@ export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl:
</tbody>
</table>
</div>
<AlertDialog open={revokeId !== null} onOpenChange={(open) => !open && setRevokeId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke invite?</AlertDialogTitle>
<AlertDialogDescription>The link will stop working.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (revokeId) void handleRevoke(revokeId);
setRevokeId(null);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Star } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -14,6 +16,7 @@ export function CollectionStarButton({
initialStarred?: boolean;
starCount: number;
}) {
const tCommon = useTranslations("common");
const [starred, setStarred] = useState(initialStarred);
const [count, setCount] = useState(starCount);
const [loading, setLoading] = useState(false);
@@ -21,14 +24,19 @@ export function CollectionStarButton({
async function toggle(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
const next = !starred;
setStarred(next);
setCount((c) => (next ? c + 1 : c - 1));
setLoading(true);
try {
const res = await fetch(`/api/v1/collections/${collectionId}/favorite`, {
method: starred ? "DELETE" : "POST",
method: next ? "POST" : "DELETE",
});
if (!res.ok) return;
setStarred(!starred);
setCount((c) => (starred ? c - 1 : c + 1));
if (!res.ok) throw new Error();
} catch {
setStarred(!next);
setCount((c) => (next ? c - 1 : c + 1));
toast.error(tCommon("updateFailed"));
} finally {
setLoading(false);
}
+84 -49
View File
@@ -1,12 +1,15 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Clock, Users, ChefHat, Flame, Heart, Sparkles } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useLocale } from "@/lib/i18n/provider";
const PAGE_SIZE = 20;
type FeedRecipe = {
id: string;
title: string;
@@ -25,9 +28,15 @@ type FeedRecipe = {
favoriteCount?: number;
};
type FeedResponse = {
data: FeedRecipe[];
total: number;
limit: number;
offset: number;
};
type Props = {
followedCount: number;
feedRecipes: FeedRecipe[];
};
function RecipeCard({ recipe, locale }: { recipe: FeedRecipe; locale: string }) {
@@ -35,7 +44,7 @@ function RecipeCard({ recipe, locale }: { recipe: FeedRecipe; locale: string })
<article className="rounded-xl border p-4 hover:shadow-sm transition-shadow">
<div className="flex items-center gap-3 mb-3">
<Avatar className="h-7 w-7">
<AvatarImage src={recipe.authorAvatarUrl ?? ""} />
<AvatarImage src={recipe.authorAvatarUrl ?? ""} alt={recipe.authorName} />
<AvatarFallback className="text-xs">{recipe.authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex items-baseline gap-2 text-sm">
@@ -57,7 +66,7 @@ function RecipeCard({ recipe, locale }: { recipe: FeedRecipe; locale: string })
</div>
</div>
<Link href={`/r/${recipe.id}`} className="group block space-y-2">
<Link href={`/recipes/${recipe.id}`} className="group block space-y-2">
<h2 className="font-semibold text-lg group-hover:text-primary transition-colors">{recipe.title}</h2>
{recipe.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{recipe.description}</p>
@@ -73,61 +82,93 @@ function RecipeCard({ recipe, locale }: { recipe: FeedRecipe; locale: string })
);
}
function TrendingTab() {
/** Shared paginated tab: fetches `endpoint`, supports "load more", and surfaces network
* failures as a real error state (with retry) instead of silently rendering an empty list. */
function PaginatedFeedTab({
endpoint,
emptyMessage,
}: {
endpoint: string;
emptyMessage: string;
}) {
const { locale } = useLocale();
const t = useTranslations("feed");
const [recipes, setRecipes] = useState<FeedRecipe[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState(false);
const fetchPage = useCallback(
async (off: number, append: boolean) => {
if (append) setLoadingMore(true);
else setLoading(true);
setError(false);
try {
const res = await fetch(`${endpoint}?limit=${PAGE_SIZE}&offset=${off}`);
if (!res.ok) throw new Error("Request failed");
const json = (await res.json()) as FeedResponse;
setRecipes((prev) => (append ? [...prev, ...json.data] : json.data));
setTotal(json.total);
setOffset(off + json.data.length);
} catch {
setError(true);
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[endpoint]
);
useEffect(() => {
fetch("/api/v1/feed/trending")
.then((r) => r.json() as Promise<{ data: FeedRecipe[] }>)
.then(({ data }) => setRecipes(data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
void fetchPage(0, false);
}, [fetchPage]);
if (loading) return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
if (recipes.length === 0) return <p className="text-sm text-muted-foreground">{t("trendingEmpty")}</p>;
if (error && recipes.length === 0) {
return (
<div className="flex flex-col items-start gap-2">
<p className="text-sm text-destructive">{t("loadFailed")}</p>
<Button variant="outline" size="sm" onClick={() => void fetchPage(0, false)}>
{t("retry")}
</Button>
</div>
);
}
if (recipes.length === 0) return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
const hasMore = recipes.length < total;
return (
<div className="space-y-4">
{recipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} locale={locale} />
))}
{error && (
<p className="text-sm text-destructive">{t("loadFailed")}</p>
)}
{hasMore || error ? (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() => void fetchPage(offset, true)}
disabled={loadingMore}
>
{loadingMore ? t("loading") : error ? t("retry") : t("loadMore")}
</Button>
</div>
) : null}
</div>
);
}
function ForYouTab() {
const { locale } = useLocale();
export function FeedPageContent({ followedCount }: Props) {
const t = useTranslations("feed");
const [recipes, setRecipes] = useState<FeedRecipe[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/v1/feed/for-you")
.then((r) => r.json() as Promise<{ data: FeedRecipe[] }>)
.then(({ data }) => setRecipes(data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
if (recipes.length === 0) return <p className="text-sm text-muted-foreground">{t("forYouEmpty")}</p>;
return (
<div className="space-y-4">
{recipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} locale={locale} />
))}
</div>
);
}
export function FeedPageContent({ followedCount, feedRecipes }: Props) {
const t = useTranslations("feed");
const { locale } = useLocale();
const [tab, setTab] = useState<"following" | "trending" | "forYou">("following");
return (
@@ -175,19 +216,13 @@ export function FeedPageContent({ followedCount, feedRecipes }: Props) {
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
<p className="text-muted-foreground text-sm">{t("followEmpty")}</p>
</div>
) : feedRecipes.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noNew")}</p>
) : (
<div className="space-y-4">
{feedRecipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} locale={locale} />
))}
</div>
<PaginatedFeedTab endpoint="/api/v1/feed" emptyMessage={t("noNew")} />
)
) : tab === "trending" ? (
<TrendingTab />
<PaginatedFeedTab endpoint="/api/v1/feed/trending" emptyMessage={t("trendingEmpty")} />
) : (
<ForYouTab />
<PaginatedFeedTab endpoint="/api/v1/feed/for-you" emptyMessage={t("forYouEmpty")} />
)}
</div>
);
+18 -3
View File
@@ -2,7 +2,8 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu } from "lucide-react";
import { useTheme } from "next-themes";
import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass, Menu, Sun, Moon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
import {
@@ -41,13 +42,15 @@ export function Nav() {
const router = useRouter();
const { data: session } = authClient.useSession();
const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin";
const username = (session?.user as { username?: string } | undefined)?.username;
const { resolvedTheme, setTheme } = useTheme();
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">
<div className="container mx-auto flex h-14 items-center gap-6 px-4">
<Sheet>
<SheetTrigger
render={<Button variant="ghost" size="icon" className="md:hidden" />}
render={<Button variant="ghost" size="icon" className="md:hidden" aria-label={t("menu")} />}
>
<Menu className="h-5 w-5" />
<span className="sr-only">{t("menu")}</span>
@@ -107,16 +110,28 @@ export function Nav() {
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="h-8 w-8">
<AvatarImage src="" alt="" />
<AvatarImage src={session?.user?.image ?? ""} alt={session?.user?.name ?? ""} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{username && (
<DropdownMenuItem>
<Link href={`/u/${username}`} className="w-full">{t("viewProfile")}</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem>
<Link href="/settings" className="w-full">{t("settings")}</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
className="flex items-center gap-2"
>
{resolvedTheme === "dark" ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
{resolvedTheme === "dark" ? t("lightMode") : t("darkMode")}
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
@@ -6,6 +6,16 @@ import { Plus, Trash2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
type PantryItem = {
id: string;
@@ -22,12 +32,14 @@ function daysUntilExpiry(dateStr: string): number {
export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {
const t = useTranslations("pantry");
const tCommon = useTranslations("common");
const [items, setItems] = useState<PantryItem[]>(initialItems);
const [name, setName] = useState("");
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [adding, setAdding] = useState(false);
const [confirmId, setConfirmId] = useState<string | null>(null);
async function add() {
if (!name.trim()) return;
@@ -58,6 +70,8 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
else toast.error(t("removeFailed"));
}
const itemPendingDelete = items.find((i) => i.id === confirmId) ?? null;
const sorted = [...items].sort((a, b) => {
if (a.expiresAt && b.expiresAt) return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
if (a.expiresAt) return -1;
@@ -100,7 +114,7 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
<p className="text-xs text-muted-foreground">{t("expiresOn", { date: new Date(item.expiresAt).toLocaleDateString() })}</p>
)}
</div>
<button onClick={() => remove(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<button onClick={() => setConfirmId(item.id)} className="text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="h-4 w-4" />
</button>
</div>
@@ -108,6 +122,29 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] })
})}
</div>
)}
<AlertDialog open={confirmId !== null} onOpenChange={(open) => !open && setConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("removeConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{itemPendingDelete ? t("removeConfirmDescription", { name: itemPendingDelete.rawName }) : ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (confirmId) void remove(confirmId);
setConfirmId(null);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -90,7 +90,7 @@ export function SharedMealPlanView({
<div className="flex items-start justify-between gap-1 rounded-lg border p-2">
<span className="text-xs">{entry.recipe?.title ?? "—"}</span>
{canEdit && (
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => void removeEntry(entry)}>
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => void removeEntry(entry)} aria-label={t("removeEntry")}>
<Trash2 className="h-3 w-3" />
</Button>
)}
@@ -28,6 +28,7 @@ export function ShoppingListView({
}) {
const t = useTranslations("mealPlan");
const tShopping = useTranslations("shoppingLists");
const tCommon = useTranslations("common");
const [items, setItems] = useState<Item[]>(initialItems);
const [movingToPantry, setMovingToPantry] = useState(false);
@@ -59,11 +60,17 @@ export function ShoppingListView({
if (readOnly) return;
const next = !item.checked;
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ checked: next }),
});
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ checked: next }),
});
if (!res.ok) throw new Error();
} catch {
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: !next } : i));
toast.error(tCommon("updateFailed"));
}
}
const grouped = items.reduce<Record<string, Item[]>>((acc, item) => {
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -36,7 +37,9 @@ export function ExpiringSoonSuggestions({ suggestions }: { suggestions: Suggesti
className="rounded-lg border overflow-hidden hover:bg-accent transition-colors"
>
{s.photoUrl ? (
<img src={s.photoUrl} alt="" className="h-24 w-full object-cover" />
<div className="relative h-24 w-full">
<Image src={s.photoUrl} alt={s.title} fill className="object-cover" />
</div>
) : (
<div className="h-24 w-full bg-muted" />
)}
@@ -93,7 +93,7 @@ export function AdaptRecipeButton({
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} aria-label={t("adaptTooltip")}>
<Wand2 className="h-4 w-4" />
</Button>
} />
@@ -121,7 +121,7 @@ export function AddToShoppingListButton({
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} aria-label={tShopping("addToList")}>
<ShoppingCart className="h-4 w-4" />
</Button>
} />
@@ -153,11 +153,13 @@ export function AddToShoppingListButton({
type="button" variant="ghost" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => Math.max(1, s - 1))}
disabled={servings <= 1}
aria-label={tCommon("decrease")}
></Button>
<span className="w-8 text-center font-medium">{servings}</span>
<Button
type="button" variant="ghost" size="icon" className="h-7 w-7"
onClick={() => setServings((s) => s + 1)}
aria-label={tCommon("increase")}
>+</Button>
</div>
{scale !== 1 && (
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { Sparkles, Loader2, Camera, Type, Upload, X, Shuffle } from "lucide-react";
@@ -280,11 +281,12 @@ export function AiGenerateDialog({
onChange={handleFileChange}
/>
{photoPreview ? (
<div className="relative rounded-xl overflow-hidden border bg-muted">
<img
<div className="relative rounded-xl overflow-hidden border bg-muted aspect-video">
<Image
src={photoPreview}
alt="Dish preview"
className="w-full max-h-64 object-cover"
alt="Preview of uploaded dish photo for AI recipe generation"
fill
className="object-cover"
/>
<button
onClick={() => { setPhotoPreview(null); setPhotoBase64(null); }}
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Package, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -36,7 +37,9 @@ function RecipeRow({ s }: { s: ScoredItem }) {
className="flex items-center gap-4 rounded-xl border p-3 hover:bg-accent transition-colors"
>
{cover ? (
<img src={cover.url} alt="" className="h-14 w-14 rounded-lg object-cover shrink-0" />
<div className="relative h-14 w-14 rounded-lg overflow-hidden shrink-0">
<Image src={cover.url} alt={s.recipe.title} fill className="object-cover" />
</div>
) : (
<div className="h-14 w-14 rounded-lg bg-muted shrink-0" />
)}
@@ -52,6 +52,7 @@ export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
size="icon"
className={cn("text-destructive hover:text-destructive")}
onClick={() => setOpen(true)}
aria-label={tCommon("delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -81,7 +81,7 @@ export function DrinkPairingButton({ recipeId }: { recipeId: string }) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={handleOpen}>
<Button variant="ghost" size="icon" onClick={handleOpen} aria-label={t("drinksTooltip")}>
<Wine className="h-4 w-4" />
</Button>
} />
@@ -141,7 +141,7 @@ export function MealPairingButton({ recipeId }: { recipeId: string }) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => { setOpen(true); if (pairings.length === 0) suggest(); }}>
<Button variant="ghost" size="icon" onClick={() => { setOpen(true); if (pairings.length === 0) suggest(); }} aria-label={t("pairMealTooltip")}>
<UtensilsCrossed className="h-4 w-4" />
</Button>
} />
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Upload, X, Star } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -32,7 +33,7 @@ export function PhotoUploader({
const res = await fetch("/api/v1/upload/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeId, contentType: file.type }),
body: JSON.stringify({ recipeId, contentType: file.type, fileSize: file.size }),
});
if (!res.ok) continue;
const { url, key } = await res.json() as { url: string; key: string };
@@ -61,11 +62,12 @@ export function PhotoUploader({
<div className="space-y-3">
<div className="flex flex-wrap gap-3">
{photos.map((photo) => (
<div key={photo.key} className="relative group">
<img
<div key={photo.key} className="relative group h-24 w-24">
<Image
src={photo.preview || getPublicUrl(photo.key)}
alt=""
className={`h-24 w-24 rounded-lg object-cover border-2 ${
alt="Recipe photo"
fill
className={`rounded-lg object-cover border-2 ${
photo.isCover ? "border-primary" : "border-transparent"
}`}
/>
+1 -1
View File
@@ -16,7 +16,7 @@ export function PrintButton({ recipeId }: { recipeId: string }) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={handlePrint}>
<Button variant="ghost" size="icon" onClick={handlePrint} aria-label={t("print")}>
<Printer className="h-4 w-4" />
</Button>
} />
+6 -3
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Clock, Users, Lock, Globe, Link2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -42,11 +43,13 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
<Link href={`/recipes/${recipe.id}`}>
<Card className="group overflow-hidden hover:shadow-md transition-shadow h-full flex flex-col">
{cover ? (
<div className="aspect-video overflow-hidden bg-muted">
<img
<div className="relative aspect-video overflow-hidden bg-muted">
<Image
src={getPublicUrl(cover.storageKey)}
alt={recipe.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
className="object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
) : (
@@ -170,7 +170,7 @@ export function RecipeChatPanel({ recipeId, recipeTitle }: Props) {
className="flex-1"
autoComplete="off"
/>
<Button type="submit" size="icon" disabled={loading || !input.trim()}>
<Button type="submit" size="icon" disabled={loading || !input.trim()} aria-label={t("send")}>
<Send className="h-4 w-4" />
</Button>
</form>
+110 -18
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, KeyboardEvent } from "react";
import { useState, useRef, useEffect, KeyboardEvent } from "react";
import { useRouter } from "next/navigation";
import { Plus, Trash2, GripVertical, X, Tag } from "lucide-react";
import { toast } from "sonner";
@@ -10,6 +10,16 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { DietaryTagPicker } from "./dietary-tag-picker";
import { PhotoUploader, type PhotoEntry } from "./photo-uploader";
@@ -90,6 +100,51 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
);
const [photos, setPhotos] = useState<PhotoEntry[]>(defaultValues?.photos ?? []);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
const hasMountedRef = useRef(false);
// Mark the form dirty on any edit after the initial mount, so we can warn the
// user before they navigate away and silently lose their changes.
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
return;
}
setDirty(true);
}, [
title,
description,
baseServings,
visibility,
difficulty,
prepMins,
cookMins,
tags,
dietaryTags,
ingredients,
steps,
photos,
]);
// Browser-level guard: warn on tab close / reload / external navigation while dirty.
useEffect(() => {
if (!dirty || saving) return;
function handleBeforeUnload(e: BeforeUnloadEvent) {
e.preventDefault();
e.returnValue = "";
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [dirty, saving]);
function handleCancelClick() {
if (dirty) {
setDiscardConfirmOpen(true);
} else {
router.back();
}
}
function updateIngredient(i: number, patch: Partial<IngredientRow>) {
setIngredients((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row));
@@ -134,6 +189,34 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
return;
}
const filteredIngredients = ingredients
.filter((ing) => ing.rawName.trim())
.map((ing, i) => ({
rawName: ing.rawName.trim(),
quantity: ing.quantity.trim() || undefined,
unit: ing.unit.trim() || undefined,
note: ing.note.trim() || undefined,
order: i,
}));
if (filteredIngredients.length === 0) {
toast.error(t("ingredientsRequired"));
return;
}
const filteredSteps = steps
.filter((s) => s.instruction.trim())
.map((s, i) => ({
instruction: s.instruction.trim(),
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
order: i,
}));
if (filteredSteps.length === 0) {
toast.error(t("stepsRequired"));
return;
}
setSaving(true);
try {
const payload = {
@@ -146,22 +229,8 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
cookMins: cookMins ? parseInt(cookMins) : undefined,
tags,
dietaryTags,
ingredients: ingredients
.filter((ing) => ing.rawName.trim())
.map((ing, i) => ({
rawName: ing.rawName.trim(),
quantity: ing.quantity.trim() || undefined,
unit: ing.unit.trim() || undefined,
note: ing.note.trim() || undefined,
order: i,
})),
steps: steps
.filter((s) => s.instruction.trim())
.map((s, i) => ({
instruction: s.instruction.trim(),
timerSeconds: s.timerSeconds ? parseInt(s.timerSeconds) : undefined,
order: i,
})),
ingredients: filteredIngredients,
steps: filteredSteps,
};
const url = isEdit ? `/api/v1/recipes/${id}` : "/api/v1/recipes";
@@ -181,6 +250,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const saved = await res.json() as { id: string };
toast.success(isEdit ? t("updateSuccess") : t("createSuccess"));
setDirty(false);
router.push(`/recipes/${saved.id}`);
router.refresh();
} finally {
@@ -443,10 +513,32 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<Button type="submit" disabled={saving}>
{saving ? t("saving") : isEdit ? t("saveChanges") : t("createRecipe")}
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
<Button type="button" variant="outline" onClick={handleCancelClick}>
{t_common("cancel")}
</Button>
</div>
<AlertDialog open={discardConfirmOpen} onOpenChange={setDiscardConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("discardChangesTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("discardChangesDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("keepEditing")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setDiscardConfirmOpen(false);
setDirty(false);
router.back();
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("discardChanges")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</form>
);
}
+35 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus } from "lucide-react";
import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog";
@@ -11,6 +12,16 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
import { getPublicUrl } from "@/lib/storage";
@@ -45,11 +56,13 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci
return (
<div className={cn("relative overflow-hidden bg-muted shrink-0", className)}>
{cover ? (
<img
<Image
src={getPublicUrl(cover.storageKey)}
alt={recipe.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
className={cn(
"w-full h-full object-cover transition-transform duration-300",
"object-cover transition-transform duration-300",
!selectMode && "group-hover:scale-105",
selectMode && selected && "brightness-75"
)}
@@ -264,6 +277,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
const [busy, setBusy] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>("grid");
const [addToCollectionOpen, setAddToCollectionOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
useEffect(() => {
const stored = localStorage.getItem(VIEW_STORAGE_KEY) as ViewMode | null;
@@ -293,7 +307,6 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
};
async function bulkDelete() {
if (!confirm(t("bulkDeleteConfirm", { count: selected.size }))) return;
setBusy(true);
try {
const res = await fetch("/api/v1/recipes/bulk", {
@@ -415,7 +428,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
<FolderPlus className="h-4 w-4" />
{t("addToCollection")}
</Button>
<Button variant="destructive" size="sm" onClick={() => { void bulkDelete(); }} disabled={busy} className="gap-1.5">
<Button variant="destructive" size="sm" onClick={() => setDeleteConfirmOpen(true)} disabled={busy} className="gap-1.5">
<Trash2 className="h-4 w-4" />
{tCommon("delete")}
</Button>
@@ -429,6 +442,24 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
recipeIds={[...selected]}
onDone={exitSelect}
/>
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("bulkDeleteConfirmTitle", { count: selected.size })}</AlertDialogTitle>
<AlertDialogDescription>{t("bulkDeleteConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => { setDeleteConfirmOpen(false); void bulkDelete(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Clock, ChefHat } from "lucide-react";
import { cn } from "@/lib/utils";
const DIFFICULTY_COLORS: Record<string, string> = {
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
export type SearchResultRecipe = {
id: string;
title: string;
description?: string | null;
difficulty: string | null;
prepMins: number | null;
cookMins: number | null;
authorName: string | null;
};
/**
* Shared card for lightweight recipe search results (no cover photo/visibility
* data available) used by the search and explore pages, which previously
* each had their own near-identical inline copy of this component.
*/
export function SearchResultCard({ recipe, className }: { recipe: SearchResultRecipe; className?: string }) {
const t = useTranslations("recipe");
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard"
? t(`difficulty.${recipe.difficulty}`)
: recipe.difficulty;
return (
<Link
href={`/recipes/${recipe.id}`}
className={cn(
"group block rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md",
className
)}
>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-card-foreground group-hover:text-primary line-clamp-2 flex-1">
{recipe.title}
</h3>
{recipe.difficulty && (
<Badge
variant="secondary"
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
>
{difficultyLabel}
</Badge>
)}
</div>
{recipe.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{recipe.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">
<Clock className="h-3.5 w-3.5" />
{t("total", { mins: totalMins })}
</span>
)}
{recipe.authorName && (
<span className="flex items-center gap-1">
<ChefHat className="h-3.5 w-3.5" />
{recipe.authorName}
</span>
)}
</div>
</Link>
);
}
@@ -28,7 +28,7 @@ export function ShareRecipeButton({ recipeId, visibility }: { recipeId: string;
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => void handleShare()}>
<Button variant="ghost" size="icon" onClick={() => void handleShare()} aria-label={t("share")}>
<Share2 className="h-4 w-4" />
</Button>
} />
@@ -69,7 +69,7 @@ export function TranslateButton({ recipeId }: { recipeId: string }) {
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} aria-label={tRecipe("translateTooltip")}>
<Languages className="h-4 w-4" />
</Button>
} />
@@ -32,7 +32,7 @@ export function VariationsButton({
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} aria-label={t("variationsTooltip")}>
<GitBranch className="h-4 w-4" />
</Button>
} />
@@ -133,7 +133,7 @@ export function VersionHistoryButton({
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => handleOpen(true)}>
<Button variant="ghost" size="icon" onClick={() => handleOpen(true)} aria-label={t("historyTooltip")}>
<History className="h-4 w-4" />
</Button>
} />
@@ -175,6 +175,7 @@ export function VersionHistoryButton({
size="icon"
className="h-7 w-7 shrink-0 sm:hidden"
title={tForm("expand")}
aria-label={tForm("expand")}
onClick={() => void handleExpand(v)}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
@@ -186,6 +187,7 @@ export function VersionHistoryButton({
size="icon"
className="h-7 w-7 shrink-0 hidden sm:inline-flex"
title={tForm("expand")}
aria-label={tForm("expand")}
onClick={() => void handleExpand(v)}
>
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page";
import { SearchResultCard } from "@/components/recipe/search-result-card";
const DIFFICULTY_COLORS: Record<string, string> = {
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
@@ -42,51 +43,6 @@ type RecipeIdea = {
totalMins?: number;
};
function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResult }) {
const t = useTranslations("recipe");
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const description = "description" in recipe ? recipe.description : null;
const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard"
? t(`difficulty.${recipe.difficulty}`)
: recipe.difficulty;
return (
<Link
href={`/recipes/${recipe.id}`}
className="group block rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md min-w-[200px]"
>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-card-foreground group-hover:text-primary line-clamp-2 flex-1">
{recipe.title}
</h3>
{recipe.difficulty && (
<Badge
variant="secondary"
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
>
{difficultyLabel}
</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">
<Clock className="h-3.5 w-3.5" />
{t("total", { mins: totalMins })}
</span>
)}
{recipe.authorName && (
<span className="flex items-center gap-1">
<ChefHat className="h-3.5 w-3.5" />
{recipe.authorName}
</span>
)}
</div>
</Link>
);
}
function HorizontalScroll({ children }: { children: React.ReactNode }) {
return (
@@ -317,7 +273,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{results.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
<SearchResultCard key={recipe.id} recipe={recipe} />
))}
</div>
{hasMore && (
@@ -443,7 +399,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
<HorizontalScroll>
{trending.map((recipe) => (
<div key={recipe.id} className="snap-start shrink-0 w-56">
<RecipeCard recipe={recipe} />
<SearchResultCard recipe={recipe} className="min-w-[200px]" />
</div>
))}
</HorizontalScroll>
@@ -462,7 +418,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) {
) : (
<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} />
<SearchResultCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
@@ -2,11 +2,8 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
@@ -14,17 +11,10 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search, Clock, ChefHat } from "lucide-react";
import { Search, ChefHat } from "lucide-react";
import { SearchResultCard, type SearchResultRecipe } from "@/components/recipe/search-result-card";
type RecipeResult = {
id: string;
title: string;
description: string | null;
difficulty: string | null;
prepMins: number | null;
cookMins: number | null;
authorName: string | null;
};
type RecipeResult = SearchResultRecipe;
type SearchResponse = {
data: RecipeResult[];
@@ -33,57 +23,6 @@ type SearchResponse = {
offset: number;
};
const DIFFICULTY_COLORS: Record<string, string> = {
easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
medium: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
function RecipeCard({ recipe }: { recipe: RecipeResult }) {
const t = useTranslations("recipe");
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
const difficultyLabel = recipe.difficulty === "easy" || recipe.difficulty === "medium" || recipe.difficulty === "hard"
? t(`difficulty.${recipe.difficulty}`)
: recipe.difficulty;
return (
<Link
href={`/recipes/${recipe.id}`}
className="group block rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md"
>
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-card-foreground group-hover:text-primary line-clamp-2">
{recipe.title}
</h3>
{recipe.difficulty && (
<Badge
variant="secondary"
className={`shrink-0 capitalize text-xs ${DIFFICULTY_COLORS[recipe.difficulty] ?? ""}`}
>
{difficultyLabel}
</Badge>
)}
</div>
{recipe.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{recipe.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">
<Clock className="h-3.5 w-3.5" />
{t("total", { mins: totalMins })}
</span>
)}
{recipe.authorName && (
<span className="flex items-center gap-1">
<ChefHat className="h-3.5 w-3.5" />
{recipe.authorName}
</span>
)}
</div>
</Link>
);
}
export function SearchPageContent({ initialQuery }: { initialQuery: string }) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -259,7 +198,7 @@ export function SearchPageContent({ initialQuery }: { initialQuery: string }) {
<div className="flex flex-col items-center justify-center py-24 text-muted-foreground">
<Search className="h-12 w-12 mb-4 opacity-30" />
<p className="text-lg">Search for recipes...</p>
<p className="text-sm mt-1">Try "pasta", "vegan dessert", or "quick dinner"</p>
<p className="text-sm mt-1">Try &ldquo;pasta&rdquo;, &ldquo;vegan dessert&rdquo;, or &ldquo;quick dinner&rdquo;</p>
</div>
)}
@@ -287,7 +226,7 @@ export function SearchPageContent({ initialQuery }: { initialQuery: string }) {
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{results.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
<SearchResultCard key={recipe.id} recipe={recipe} />
))}
</div>
@@ -48,7 +48,7 @@ export function ExportMarkdownButton({
<Tooltip>
<TooltipTrigger render={
<DropdownMenuTrigger render={
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" aria-label={t("exportMarkdown")}>
<FileDown className="h-4 w-4" />
</Button>
} />
+154
View File
@@ -0,0 +1,154 @@
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/** Page title + optional action buttons, matching the common page header layout. */
export function PageHeaderSkeleton({
actions = 0,
subtitle = false,
}: {
actions?: number;
subtitle?: boolean;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
{subtitle && <Skeleton className="h-4 w-64" />}
</div>
{actions > 0 && (
<div className="flex flex-wrap items-center gap-2">
{Array.from({ length: actions }).map((_, i) => (
<Skeleton key={i} className="h-8 w-24" />
))}
</div>
)}
</div>
);
}
/** Mirrors the recipe GridCard: aspect-video thumb, title, description, meta row. */
export function RecipeCardSkeleton() {
return (
<div className="overflow-hidden rounded-xl border bg-card flex flex-col h-full">
<Skeleton className="aspect-video w-full rounded-none" />
<div className="flex flex-col flex-1 p-3 gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-1/2" />
</div>
<div className="px-3 pb-3 flex items-center justify-between">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-3" />
</div>
</div>
);
}
/** Grid of RecipeCardSkeletons; default classes match RecipesGrid's grid view. */
export function RecipeCardGridSkeleton({
count = 8,
className,
}: {
count?: number;
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 items-start",
className,
)}
>
{Array.from({ length: count }).map((_, i) => (
<RecipeCardSkeleton key={i} />
))}
</div>
);
}
/** Mirrors a feed article: avatar + author line, title, description, meta chips. */
export function FeedItemSkeleton() {
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-1/2" />
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-10" />
<Skeleton className="h-4 w-10" />
</div>
</div>
);
}
/** Bordered row card, matching list rows (shopping lists, pantry items, etc.). */
export function ListRowSkeleton() {
return (
<div className="rounded-xl border p-4 space-y-2">
<Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-16" />
</div>
);
}
/** Small bordered info card, matching search/explore result cards. */
export function InfoCardSkeleton() {
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-3 w-2/3" />
</div>
);
}
/** Square photo tile with a caption line, matching profile recipe tiles. */
export function SquareTileSkeleton() {
return (
<div className="rounded-xl overflow-hidden border bg-card">
<Skeleton className="aspect-square w-full rounded-none" />
<div className="p-2">
<Skeleton className="h-4 w-3/4" />
</div>
</div>
);
}
/** Admin overview stat card. */
export function StatCardSkeleton() {
return (
<div className="rounded-xl border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4" />
</div>
<Skeleton className="h-8 w-16" />
</div>
);
}
/** Simple bordered table: one header row and `rows` body rows. */
export function TableSkeleton({ rows = 8 }: { rows?: number }) {
return (
<div className="rounded-xl border divide-y overflow-hidden">
<div className="flex items-center gap-4 px-4 py-3 bg-muted/40">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20 ml-auto" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16 ml-auto" />
</div>
))}
</div>
);
}
+43 -13
View File
@@ -2,9 +2,20 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Ban } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export function BlockButton({
targetUsername,
@@ -14,13 +25,13 @@ export function BlockButton({
initialBlocked?: boolean;
}) {
const router = useRouter();
const t = useTranslations("social");
const tCommon = useTranslations("common");
const [blocked, setBlocked] = useState(initialBlocked);
const [loading, setLoading] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
async function toggle() {
if (!blocked && !confirm(`Block @${targetUsername}? They won't be able to follow you or comment on your recipes.`)) {
return;
}
setLoading(true);
try {
const res = await fetch(`/api/v1/users/${targetUsername}/block`, {
@@ -40,15 +51,34 @@ export function BlockButton({
}
return (
<Button
variant="outline"
size="sm"
onClick={() => { void toggle(); }}
disabled={loading}
className={blocked ? "" : "text-destructive hover:text-destructive"}
>
<Ban className="h-3.5 w-3.5" />
{loading ? "…" : blocked ? "Unblock" : "Block"}
</Button>
<>
<Button
variant="outline"
size="sm"
onClick={() => (blocked ? void toggle() : setConfirmOpen(true))}
disabled={loading}
className={blocked ? "" : "text-destructive hover:text-destructive"}
>
<Ban className="h-3.5 w-3.5" />
{loading ? "…" : blocked ? "Unblock" : "Block"}
</Button>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("blockConfirmTitle", { username: targetUsername })}</AlertDialogTitle>
<AlertDialogDescription>{t("blockConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => { setConfirmOpen(false); void toggle(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("blockConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -9,6 +9,16 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { CommentReactions } from "@/components/social/comment-reactions";
import { ReportButton } from "@/components/social/report-button";
import { cn } from "@/lib/utils";
@@ -24,6 +34,15 @@ type Comment = {
userAvatarUrl: string | null;
};
const COMMENTS_PAGE_SIZE = 20;
type CommentsResponse = {
data: Comment[];
total: number;
limit: number;
offset: number;
};
const MAX_VISUAL_INDENT = 4;
const MENTION_REGEX = /@([a-z0-9_-]{3,30})/gi;
@@ -125,6 +144,7 @@ function CommentItem({
onRefresh: () => void;
}) {
const [showReply, setShowReply] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const isOwn = comment.userId === currentUserId;
const t = useTranslations("social");
const tCommon = useTranslations("common");
@@ -141,7 +161,7 @@ function CommentItem({
<div className={cn("space-y-3", indented && "ml-10 border-l pl-4")}>
<div className="flex gap-3">
<Avatar className={cn("shrink-0 mt-0.5", depth === 0 ? "h-7 w-7" : "h-6 w-6")}>
<AvatarImage src={comment.userAvatarUrl ?? ""} />
<AvatarImage src={comment.userAvatarUrl ?? ""} alt={comment.userName} />
<AvatarFallback className="text-xs">{comment.userName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
@@ -165,13 +185,30 @@ function CommentItem({
)}
{isOwn && (
<button
onClick={deleteComment}
onClick={() => setConfirmOpen(true)}
className="text-xs text-muted-foreground hover:text-destructive flex items-center gap-1"
>
<Trash2 className="h-3 w-3" /> {tCommon("delete")}
</button>
)}
</div>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteCommentTitle")}</AlertDialogTitle>
<AlertDialogDescription>{t("deleteCommentDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => { void deleteComment(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{showReply && (
<CommentForm
recipeId={recipeId}
@@ -212,15 +249,40 @@ export function CommentsSection({
}) {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [topLevelOffset, setTopLevelOffset] = useState(0);
const [topLevelTotal, setTopLevelTotal] = useState(0);
const t = useTranslations("social");
const tCommon = useTranslations("common");
// Full reload from the first page — used on mount and after any mutation (post/reply/delete)
// so the thread stays consistent rather than trying to patch pagination state in place.
const load = useCallback(async () => {
const res = await fetch(`/api/v1/recipes/${recipeId}/comments`);
if (res.ok) setComments(await res.json() as Comment[]);
const res = await fetch(`/api/v1/recipes/${recipeId}/comments?limit=${COMMENTS_PAGE_SIZE}&offset=0`);
if (res.ok) {
const json = await res.json() as CommentsResponse;
setComments(json.data);
setTopLevelTotal(json.total);
setTopLevelOffset(json.data.filter((c) => !c.parentId).length);
}
setLoading(false);
}, [recipeId]);
const loadMore = useCallback(async () => {
setLoadingMore(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/comments?limit=${COMMENTS_PAGE_SIZE}&offset=${topLevelOffset}`);
if (res.ok) {
const json = await res.json() as CommentsResponse;
setComments((prev) => [...prev, ...json.data]);
setTopLevelTotal(json.total);
setTopLevelOffset((prev) => prev + json.data.filter((c) => !c.parentId).length);
}
} finally {
setLoadingMore(false);
}
}, [recipeId, topLevelOffset]);
useEffect(() => { void load(); }, [load]);
const { topLevel, childrenByParent } = useMemo(() => {
@@ -269,6 +331,13 @@ export function CommentsSection({
/>
</div>
))}
{topLevelOffset < topLevelTotal && (
<div className="flex justify-center pt-2">
<Button size="sm" variant="outline" onClick={() => void loadMore()} disabled={loadingMore}>
{loadingMore ? t("loadingMoreComments") : t("loadMoreComments")}
</Button>
</div>
)}
</div>
)}
</div>
@@ -54,7 +54,7 @@ export function ConversationsList() {
)}
>
<Avatar className="h-10 w-10 shrink-0">
{c.otherUser?.avatarUrl && <AvatarImage src={c.otherUser.avatarUrl} />}
{c.otherUser?.avatarUrl && <AvatarImage src={c.otherUser.avatarUrl} alt={c.otherUser.name ?? ""} />}
<AvatarFallback>{(c.otherUser?.name ?? "?").slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
+16 -11
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Image from "next/image";
import { useTranslations, useLocale } from "next-intl";
import { Star, Camera, X, ChefHat } from "lucide-react";
import { toast } from "sonner";
@@ -86,7 +87,7 @@ export function CookedItReview({
const res = await fetch("/api/v1/upload/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipeId, contentType: file.type, purpose: "review" }),
body: JSON.stringify({ recipeId, contentType: file.type, purpose: "review", fileSize: file.size }),
});
if (!res.ok) {
toast.error(t("reviewPhotoFailed"));
@@ -143,11 +144,12 @@ export function CookedItReview({
/>
<div className="flex items-center gap-3">
{(preview || photoKey) && (
<div className="relative">
<img
<div className="relative h-16 w-16">
<Image
src={preview ?? getPublicUrl(photoKey!)}
alt=""
className="h-16 w-16 rounded-lg object-cover border"
alt="Your cooked-it photo"
fill
className="rounded-lg object-cover border"
/>
<button
type="button"
@@ -187,7 +189,7 @@ export function CookedItReview({
{reviews.map((r) => (
<div key={r.id} className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={r.user.avatarUrl ?? undefined} />
<AvatarImage src={r.user.avatarUrl ?? undefined} alt={r.user.name} />
<AvatarFallback>{r.user.name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1.5">
@@ -204,11 +206,14 @@ export function CookedItReview({
</div>
{r.reviewText && <p className="text-sm text-muted-foreground">{r.reviewText}</p>}
{r.photoKey && (
<img
src={getPublicUrl(r.photoKey)}
alt=""
className="h-32 w-32 rounded-lg object-cover border"
/>
<div className="relative h-32 w-32">
<Image
src={getPublicUrl(r.photoKey)}
alt={`${r.user.name}'s cooked-it photo`}
fill
className="rounded-lg object-cover border"
/>
</div>
)}
</div>
</div>
+18 -4
View File
@@ -1,7 +1,9 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
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";
@@ -13,17 +15,23 @@ export function FavoriteButton({
recipeId: string;
initialFavorited?: boolean;
}) {
const tCommon = useTranslations("common");
const tSocial = useTranslations("social");
const [favorited, setFavorited] = useState(initialFavorited);
const [loading, setLoading] = useState(false);
async function toggle() {
const next = !favorited;
setFavorited(next);
setLoading(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/favorite`, {
method: favorited ? "DELETE" : "POST",
method: next ? "POST" : "DELETE",
});
if (!res.ok) return;
setFavorited(!favorited);
if (!res.ok) throw new Error();
} catch {
setFavorited(!next);
toast.error(tCommon("updateFailed"));
} finally {
setLoading(false);
}
@@ -33,7 +41,13 @@ export function FavoriteButton({
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={toggle} disabled={loading}>
<Button
variant="ghost"
size="icon"
onClick={toggle}
disabled={loading}
aria-label={favorited ? tSocial("favoriteRemove") : tSocial("favoriteAdd")}
>
<Heart className={cn("h-4 w-4", favorited && "fill-red-500 text-red-500")} />
</Button>
} />
+9 -6
View File
@@ -17,18 +17,21 @@ export function FollowButton({
const [loading, setLoading] = useState(false);
async function toggle() {
const next = !following;
setFollowing(next);
setLoading(true);
try {
const res = await fetch(`/api/v1/users/${targetUsername}/follow`, {
method: following ? "DELETE" : "POST",
method: next ? "POST" : "DELETE",
});
if (!res.ok) {
const err = await res.json() as { error?: string };
toast.error(err.error ?? "Failed");
return;
throw new Error(err.error);
}
setFollowing(!following);
toast.success(following ? "Unfollowed" : "Following");
toast.success(next ? "Following" : "Unfollowed");
} catch (err) {
setFollowing(!next);
toast.error(err instanceof Error && err.message ? err.message : "Failed");
} finally {
setLoading(false);
}
@@ -36,7 +39,7 @@ export function FollowButton({
return (
<Button variant={following ? "outline" : "default"} size="sm" onClick={toggle} disabled={loading}>
{loading ? "…" : following ? "Following" : "Follow"}
{following ? "Following" : "Follow"}
</Button>
);
}
+66 -16
View File
@@ -25,19 +25,50 @@ export function MessageThread({
const t = useTranslations("messages");
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// Loads the latest page of messages. On the very first load this replaces
// the (empty) list outright; on subsequent polls it merges in only the
// messages we don't already have, so it doesn't clobber older history the
// user paged back through via loadMore().
const load = useCallback(async () => {
const res = await fetch(`/api/v1/conversations/${conversationId}/messages`);
if (res.ok) {
const data = (await res.json()) as { messages: Message[] };
setMessages(data.messages);
const data = (await res.json()) as { messages: Message[]; nextCursor: string | null };
setMessages((prev) => {
if (prev.length === 0) return data.messages;
const existingIds = new Set(prev.map((m) => m.id));
const fresh = data.messages.filter((m) => !existingIds.has(m.id));
return fresh.length > 0 ? [...prev, ...fresh] : prev;
});
setNextCursor((prev) => prev ?? data.nextCursor);
}
setLoading(false);
}, [conversationId]);
// Loads an older page (before the oldest message currently loaded) and
// prepends it, using the server-provided cursor.
const loadMore = useCallback(async () => {
if (!nextCursor || loadingMore) return;
setLoadingMore(true);
try {
const res = await fetch(
`/api/v1/conversations/${conversationId}/messages?before=${encodeURIComponent(nextCursor)}`
);
if (res.ok) {
const data = (await res.json()) as { messages: Message[]; nextCursor: string | null };
setMessages((prev) => [...data.messages, ...prev]);
setNextCursor(data.nextCursor);
}
} finally {
setLoadingMore(false);
}
}, [conversationId, nextCursor, loadingMore]);
useEffect(() => {
void load();
const interval = setInterval(() => { void load(); }, 5000);
@@ -77,21 +108,35 @@ export function MessageThread({
) : messages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">{t("noMessagesYet")}</p>
) : (
messages.map((m) => {
const isOwn = m.senderId === currentUserId;
return (
<div key={m.id} className={cn("flex", isOwn ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[70%] rounded-2xl px-4 py-2 text-sm whitespace-pre-wrap",
isOwn ? "bg-primary text-primary-foreground" : "bg-muted"
)}
<>
{nextCursor && (
<div className="flex justify-center pb-2">
<Button
variant="ghost"
size="sm"
onClick={() => { void loadMore(); }}
disabled={loadingMore}
>
{m.content}
</div>
{loadingMore ? t("loadingOlder") : t("loadOlder")}
</Button>
</div>
);
})
)}
{messages.map((m) => {
const isOwn = m.senderId === currentUserId;
return (
<div key={m.id} className={cn("flex", isOwn ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[70%] rounded-2xl px-4 py-2 text-sm whitespace-pre-wrap",
isOwn ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{m.content}
</div>
</div>
);
})}
</>
)}
<div ref={bottomRef} />
</div>
@@ -109,7 +154,12 @@ export function MessageThread({
rows={1}
className="resize-none"
/>
<Button size="icon" onClick={() => { void send(); }} disabled={!content.trim() || sending}>
<Button
size="icon"
aria-label={t("send")}
onClick={() => { void send(); }}
disabled={!content.trim() || sending}
>
<Send className="h-4 w-4" />
</Button>
</div>
@@ -27,7 +27,7 @@ export function MessagesNavLink() {
}, []);
return (
<Button variant="ghost" size="icon" className="relative" nativeButton={false} render={<Link href="/messages" />}>
<Button variant="ghost" size="icon" className="relative" nativeButton={false} aria-label="Messages" render={<Link href="/messages" />}>
<MessageCircle className="h-4 w-4" />
{unreadTotal > 0 && (
<Badge
@@ -64,7 +64,7 @@ export function NotificationBell() {
return (
<DropdownMenu open={open} onOpenChange={(next) => { setOpen(next); if (next) void load(); }}>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="relative" />}>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="relative" aria-label={t("title")} />}>
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<Badge
+2 -2
View File
@@ -54,7 +54,7 @@ export function ReportButton({
<Flag className="h-3 w-3" /> Report
</button>
) : (
<Button variant="ghost" size="icon" onClick={() => setOpen(true)}>
<Button variant="ghost" size="icon" onClick={() => setOpen(true)} aria-label="Report">
<Flag className="h-4 w-4" />
</Button>
)}
@@ -66,7 +66,7 @@ export function ReportButton({
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="report-reason">What's wrong with this?</Label>
<Label htmlFor="report-reason">What&apos;s wrong with this?</Label>
<Textarea
id="report-reason"
value={reason}
@@ -20,7 +20,7 @@ vi.mock("@epicure/db", () => ({
and: vi.fn((...args) => args),
}));
const { getDefaultProviderWithKey, getModelConfigForUseCase, withUserKey } = await import("../resolve-user-key");
const { getDefaultProviderWithKey, getModelConfigForUseCase, withUserKey, ByokDecryptError } = await import("../resolve-user-key");
beforeEach(() => {
vi.clearAllMocks();
@@ -74,15 +74,13 @@ describe("getDefaultProviderWithKey", () => {
expect(config.apiKey).toBe("sk-from-site-settings");
});
it("skips corrupted BYOK key and tries next provider", async () => {
it("throws ByokDecryptError instead of silently falling back on a corrupted BYOK key", 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");
await expect(getDefaultProviderWithKey("user1")).rejects.toThrow(ByokDecryptError);
});
});
+17
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { APICallError } from "ai";
import { checkAndIncrementTierLimit, refundAiCall, TierLimitError } from "@/lib/tiers";
import { ByokDecryptError } from "@/lib/ai/resolve-user-key";
/**
* Maps an AI-call failure to a clean JSON response instead of letting the
@@ -23,6 +24,22 @@ export function aiErrorResponse(err: unknown): NextResponse {
return NextResponse.json({ error: "AI request failed. Please try again." }, { status: 502 });
}
/**
* Resolves a BYOK/model config and converts a decrypt failure into a clean
* 400 response instead of letting it throw uncaught (or letting the caller
* silently fall back to the platform key/billing).
*/
export async function resolveAiConfigOrError<T>(resolve: () => Promise<T>): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
try {
return { ok: true, data: await resolve() };
} catch (err) {
if (err instanceof ByokDecryptError) {
return { ok: false, response: NextResponse.json({ error: err.message }, { status: 400 }) };
}
throw err;
}
}
type QuotaResult<T> = { ok: true; data: T } | { ok: false; response: NextResponse };
/**

Some files were not shown because too many files have changed in this diff Show More