diff --git a/CLAUDE.md b/CLAUDE.md index ce98a6f..bec379e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..75b57f6 --- /dev/null +++ b/HANDOFF.md @@ -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` — `` 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 ``) — 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: }` 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()` + ``. +- `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`. diff --git a/apps/web/app/(app)/collections/loading.tsx b/apps/web/app/(app)/collections/loading.tsx new file mode 100644 index 0000000..cb602c2 --- /dev/null +++ b/apps/web/app/(app)/collections/loading.tsx @@ -0,0 +1,14 @@ +import { PageHeaderSkeleton, InfoCardSkeleton } from "@/components/shared/skeletons"; + +export default function CollectionsLoading() { + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/error.tsx b/apps/web/app/(app)/error.tsx new file mode 100644 index 0000000..491eb8f --- /dev/null +++ b/apps/web/app/(app)/error.tsx @@ -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 ( +
+

Something went wrong

+

+ An unexpected error occurred while loading this page. You can try again, or head back + later. +

+ +
+ ); +} diff --git a/apps/web/app/(app)/explore/loading.tsx b/apps/web/app/(app)/explore/loading.tsx new file mode 100644 index 0000000..097043c --- /dev/null +++ b/apps/web/app/(app)/explore/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { InfoCardSkeleton } from "@/components/shared/skeletons"; + +function ExploreSection() { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); +} + +export default function ExploreLoading() { + return ( +
+ + + +
+ ); +} diff --git a/apps/web/app/(app)/feed/loading.tsx b/apps/web/app/(app)/feed/loading.tsx new file mode 100644 index 0000000..a2bf04f --- /dev/null +++ b/apps/web/app/(app)/feed/loading.tsx @@ -0,0 +1,11 @@ +import { FeedItemSkeleton } from "@/components/shared/skeletons"; + +export default function FeedLoading() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/apps/web/app/(app)/feed/page.tsx b/apps/web/app/(app)/feed/page.tsx index 3daaa2e..62e54b9 100644 --- a/apps/web/app/(app)/feed/page.tsx +++ b/apps/web/app/(app)/feed/page.tsx @@ -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 ; - } - - 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 ( - ({ ...r, createdAt: r.createdAt.toISOString() }))} - /> - ); + return ; } diff --git a/apps/web/app/(app)/loading.tsx b/apps/web/app/(app)/loading.tsx new file mode 100644 index 0000000..e15e1c8 --- /dev/null +++ b/apps/web/app/(app)/loading.tsx @@ -0,0 +1,10 @@ +import { PageHeaderSkeleton, RecipeCardGridSkeleton } from "@/components/shared/skeletons"; + +export default function AppLoading() { + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/(app)/meal-plan/loading.tsx b/apps/web/app/(app)/meal-plan/loading.tsx new file mode 100644 index 0000000..90de90b --- /dev/null +++ b/apps/web/app/(app)/meal-plan/loading.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { PageHeaderSkeleton } from "@/components/shared/skeletons"; + +export default function MealPlanLoading() { + return ( +
+ + +
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/messages/[id]/page.tsx b/apps/web/app/(app)/messages/[id]/page.tsx index abcd34b..750b21a 100644 --- a/apps/web/app/(app)/messages/[id]/page.tsx +++ b/apps/web/app/(app)/messages/[id]/page.tsx @@ -31,7 +31,7 @@ export default async function ConversationPage({ params }: Params) {
- {other.avatarUrl && } + {other.avatarUrl && } {other.name.slice(0, 2).toUpperCase()} {other.name} diff --git a/apps/web/app/(app)/not-found.tsx b/apps/web/app/(app)/not-found.tsx new file mode 100644 index 0000000..bfd2dd1 --- /dev/null +++ b/apps/web/app/(app)/not-found.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; + +export default function AppNotFound() { + return ( +
+

Page not found

+

+ The page you're looking for doesn't exist or may have been moved. +

+ + Back home + +
+ ); +} diff --git a/apps/web/app/(app)/pantry/loading.tsx b/apps/web/app/(app)/pantry/loading.tsx new file mode 100644 index 0000000..0c90f30 --- /dev/null +++ b/apps/web/app/(app)/pantry/loading.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { PageHeaderSkeleton, ListRowSkeleton } from "@/components/shared/skeletons"; + +export default function PantryLoading() { + return ( +
+ + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/recipes/[id]/loading.tsx b/apps/web/app/(app)/recipes/[id]/loading.tsx new file mode 100644 index 0000000..599cdb5 --- /dev/null +++ b/apps/web/app/(app)/recipes/[id]/loading.tsx @@ -0,0 +1,29 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { ListRowSkeleton } from "@/components/shared/skeletons"; + +export default function RecipeDetailLoading() { + return ( +
+
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ +
+ + + +
+
+ +
+ + + +
+
+ ); +} diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 2b89ef1..612903a 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -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 && ( -
- + {recipe.title}
)} @@ -373,9 +375,14 @@ export default async function RecipePage({ params }: Params) {

Photos

- {recipe.photos.map((photo) => ( -
- + {recipe.photos.map((photo, i) => ( +
+ {`${recipe.title}
))}
diff --git a/apps/web/app/(app)/recipes/loading.tsx b/apps/web/app/(app)/recipes/loading.tsx new file mode 100644 index 0000000..5fa2625 --- /dev/null +++ b/apps/web/app/(app)/recipes/loading.tsx @@ -0,0 +1,10 @@ +import { PageHeaderSkeleton, RecipeCardGridSkeleton } from "@/components/shared/skeletons"; + +export default function RecipesLoading() { + return ( +
+ + +
+ ); +} diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index 10524bf..4241f6e 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -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 (
- - + + + + {totalPages > 1 && ( +
+ {page > 1 && ( + + Previous + + )} + + Page {page} of {totalPages} + + {page < totalPages && ( + + Next + + )} +
+ )}
); } diff --git a/apps/web/app/(app)/search/loading.tsx b/apps/web/app/(app)/search/loading.tsx new file mode 100644 index 0000000..d120e84 --- /dev/null +++ b/apps/web/app/(app)/search/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { InfoCardSkeleton } from "@/components/shared/skeletons"; + +export default function SearchLoading() { + return ( +
+
+ + +
+ + +
+
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/shopping-lists/loading.tsx b/apps/web/app/(app)/shopping-lists/loading.tsx new file mode 100644 index 0000000..4c279bb --- /dev/null +++ b/apps/web/app/(app)/shopping-lists/loading.tsx @@ -0,0 +1,14 @@ +import { PageHeaderSkeleton, ListRowSkeleton } from "@/components/shared/skeletons"; + +export default function ShoppingListsLoading() { + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/(app)/u/[username]/loading.tsx b/apps/web/app/(app)/u/[username]/loading.tsx new file mode 100644 index 0000000..a927c1c --- /dev/null +++ b/apps/web/app/(app)/u/[username]/loading.tsx @@ -0,0 +1,32 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { SquareTileSkeleton } from "@/components/shared/skeletons"; + +export default function UserProfileLoading() { + return ( +
+
+ +
+
+ + +
+ +
+ + + +
+
+
+
+ +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/app/(app)/u/[username]/page.tsx b/apps/web/app/(app)/u/[username]/page.tsx index 4541b7b..9897135 100644 --- a/apps/web/app/(app)/u/[username]/page.tsx +++ b/apps/web/app/(app)/u/[username]/page.tsx @@ -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 ( -
+
{cover ? ( - {recipe.title} ) : (
@@ -171,6 +184,30 @@ export default async function UserProfilePage({ params }: Params) { ); })}
+ + {totalPages > 1 && ( +
+ {page > 1 && ( + 1 ? `?page=${page - 1}` : ""}`} + className="rounded-md border px-3 py-1.5 text-sm hover:bg-accent" + > + Previous + + )} + + Page {page} of {totalPages} + + {page < totalPages && ( + + Next + + )} +
+ )}
) : (
diff --git a/apps/web/app/admin/error.tsx b/apps/web/app/admin/error.tsx new file mode 100644 index 0000000..c28620d --- /dev/null +++ b/apps/web/app/admin/error.tsx @@ -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 ( +
+

Something went wrong

+

+ An unexpected error occurred in the admin panel. You can try again. +

+ +
+ ); +} diff --git a/apps/web/app/admin/loading.tsx b/apps/web/app/admin/loading.tsx new file mode 100644 index 0000000..cb87cf7 --- /dev/null +++ b/apps/web/app/admin/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { StatCardSkeleton } from "@/components/shared/skeletons"; + +export default function AdminLoading() { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/app/admin/recipes/page.tsx b/apps/web/app/admin/recipes/page.tsx index c3177f2..4c5eccf 100644 --- a/apps/web/app/admin/recipes/page.tsx +++ b/apps/web/app/admin/recipes/page.tsx @@ -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 (
@@ -36,7 +51,7 @@ export default async function AdminRecipesPage() {

-
+
@@ -94,6 +109,25 @@ export default async function AdminRecipesPage() {
+ +
+ {page > 1 && ( + + Previous + + )} + {offset + publicRecipes.length < total && ( + + Next + + )} +
); } diff --git a/apps/web/app/admin/reports/page.tsx b/apps/web/app/admin/reports/page.tsx index 35f6ada..c13d4b7 100644 --- a/apps/web/app/admin/reports/page.tsx +++ b/apps/web/app/admin/reports/page.tsx @@ -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 (
@@ -32,6 +48,25 @@ export default async function AdminReportsPage() { ({ ...r, createdAt: r.createdAt.toISOString() }))} /> + +
+ {page > 1 && ( + + Previous + + )} + {offset + rows.length < total && ( + + Next + + )} +
); } diff --git a/apps/web/app/admin/users/[id]/page.tsx b/apps/web/app/admin/users/[id]/page.tsx index 8c5db20..340b7cc 100644 --- a/apps/web/app/admin/users/[id]/page.tsx +++ b/apps/web/app/admin/users/[id]/page.tsx @@ -72,7 +72,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
- + {user.name.slice(0, 2).toUpperCase()} diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx index 3b1afb0..bf93b72 100644 --- a/apps/web/app/admin/users/page.tsx +++ b/apps/web/app/admin/users/page.tsx @@ -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 (
@@ -33,7 +48,7 @@ export default async function AdminUsersPage() {

Users

-
+
@@ -49,7 +64,7 @@ export default async function AdminUsersPage() {
- + {user.name.slice(0, 2).toUpperCase()} @@ -74,6 +89,25 @@ export default async function AdminUsersPage() {
+ +
+ {page > 1 && ( + + Previous + + )} + {offset + allUsers.length < total && ( + + Next + + )} +
); } diff --git a/apps/web/app/api/v1/ai-keys/route.ts b/apps/web/app/api/v1/ai-keys/route.ts index d951abb..1a8e5be 100644 --- a/apps/web/app/api/v1/ai-keys/route.ts +++ b/apps/web/app/api/v1/ai-keys/route.ts @@ -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; diff --git a/apps/web/app/api/v1/ai/adapt/[id]/route.ts b/apps/web/app/api/v1/ai/adapt/[id]/route.ts index 37b539e..813de76 100644 --- a/apps/web/app/api/v1/ai/adapt/[id]/route.ts +++ b/apps/web/app/api/v1/ai/adapt/[id]/route.ts @@ -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( diff --git a/apps/web/app/api/v1/ai/drinks/[id]/route.ts b/apps/web/app/api/v1/ai/drinks/[id]/route.ts index e138fc7..a6f8797 100644 --- a/apps/web/app/api/v1/ai/drinks/[id]/route.ts +++ b/apps/web/app/api/v1/ai/drinks/[id]/route.ts @@ -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( diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts index c2ed7ba..afcf3f1 100644 --- a/apps/web/app/api/v1/ai/import-photo/route.ts +++ b/apps/web/app/api/v1/ai/import-photo/route.ts @@ -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) { diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index 3fe3eab..d968b3f 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -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 diff --git a/apps/web/app/api/v1/ai/pairings/[id]/route.ts b/apps/web/app/api/v1/ai/pairings/[id]/route.ts index fda808a..687ecdb 100644 --- a/apps/web/app/api/v1/ai/pairings/[id]/route.ts +++ b/apps/web/app/api/v1/ai/pairings/[id]/route.ts @@ -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( diff --git a/apps/web/app/api/v1/ai/recipe-chat/route.ts b/apps/web/app/api/v1/ai/recipe-chat/route.ts index 7efd05c..8ad799d 100644 --- a/apps/web/app/api/v1/ai/recipe-chat/route.ts +++ b/apps/web/app/api/v1/ai/recipe-chat/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/ai/recipe-ideas/route.ts b/apps/web/app/api/v1/ai/recipe-ideas/route.ts index 6840063..167b721 100644 --- a/apps/web/app/api/v1/ai/recipe-ideas/route.ts +++ b/apps/web/app/api/v1/ai/recipe-ideas/route.ts @@ -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); } diff --git a/apps/web/app/api/v1/ai/scale/route.ts b/apps/web/app/api/v1/ai/scale/route.ts index 90f682d..82caeb1 100644 --- a/apps/web/app/api/v1/ai/scale/route.ts +++ b/apps/web/app/api/v1/ai/scale/route.ts @@ -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( diff --git a/apps/web/app/api/v1/ai/substitute/route.ts b/apps/web/app/api/v1/ai/substitute/route.ts index 0cf9cbc..5dd7dff 100644 --- a/apps/web/app/api/v1/ai/substitute/route.ts +++ b/apps/web/app/api/v1/ai/substitute/route.ts @@ -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"; diff --git a/apps/web/app/api/v1/ai/variations/[id]/route.ts b/apps/web/app/api/v1/ai/variations/[id]/route.ts index 049fcd5..be82ae7 100644 --- a/apps/web/app/api/v1/ai/variations/[id]/route.ts +++ b/apps/web/app/api/v1/ai/variations/[id]/route.ts @@ -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( diff --git a/apps/web/app/api/v1/conversations/[id]/messages/route.ts b/apps/web/app/api/v1/conversations/[id]/messages/route.ts index 4cfcd27..6fb9b19 100644 --- a/apps/web/app/api/v1/conversations/[id]/messages/route.ts +++ b/apps/web/app/api/v1/conversations/[id]/messages/route.ts @@ -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) { diff --git a/apps/web/app/api/v1/feed/for-you/route.ts b/apps/web/app/api/v1/feed/for-you/route.ts index 04cd924..3a8b334 100644 --- a/apps/web/app/api/v1/feed/for-you/route.ts +++ b/apps/web/app/api/v1/feed/for-you/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/feed/route.ts b/apps/web/app/api/v1/feed/route.ts index ff045d6..fe56184 100644 --- a/apps/web/app/api/v1/feed/route.ts +++ b/apps/web/app/api/v1/feed/route.ts @@ -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`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 }); } diff --git a/apps/web/app/api/v1/feed/trending/route.ts b/apps/web/app/api/v1/feed/trending/route.ts index db33087..7c9f099 100644 --- a/apps/web/app/api/v1/feed/trending/route.ts +++ b/apps/web/app/api/v1/feed/trending/route.ts @@ -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`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`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`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, }); } diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts index 01fd683..9d57cca 100644 --- a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/route.ts @@ -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 diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/members/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/members/route.ts index 4a638fa..b483bbc 100644 --- a/apps/web/app/api/v1/meal-plans/[weekStart]/members/route.ts +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/members/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/pantry/route.ts b/apps/web/app/api/v1/pantry/route.ts index ecb94ca..c37c2ac 100644 --- a/apps/web/app/api/v1/pantry/route.ts +++ b/apps/web/app/api/v1/pantry/route.ts @@ -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`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) { diff --git a/apps/web/app/api/v1/recipes/[id]/comments/route.ts b/apps/web/app/api/v1/recipes/[id]/comments/route.ts index 0b11d60..6cac29c 100644 --- a/apps/web/app/api/v1/recipes/[id]/comments/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/comments/route.ts @@ -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`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) { diff --git a/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts b/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts index 6c8ecce..d3900a1 100644 --- a/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/recipes/[id]/route.ts b/apps/web/app/api/v1/recipes/[id]/route.ts index 42afd8f..863d7b7 100644 --- a/apps/web/app/api/v1/recipes/[id]/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/route.ts @@ -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 = { 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 }); } diff --git a/apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts b/apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts index a284de1..03dddd0 100644 --- a/apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/versions/[versionId]/route.ts @@ -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; diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts index dc2346a..30acc62 100644 --- a/apps/web/app/api/v1/search/route.ts +++ b/apps/web/app/api/v1/search/route.ts @@ -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}%`) )!, ]; diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts index b092c77..b1df562 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/upload/presign/route.ts b/apps/web/app/api/v1/upload/presign/route.ts index c9c5e3e..c760544 100644 --- a/apps/web/app/api/v1/upload/presign/route.ts +++ b/apps/web/app/api/v1/upload/presign/route.ts @@ -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}`; diff --git a/apps/web/app/api/v1/webhooks/[id]/route.ts b/apps/web/app/api/v1/webhooks/[id]/route.ts index 093e7ed..4d563f1 100644 --- a/apps/web/app/api/v1/webhooks/[id]/route.ts +++ b/apps/web/app/api/v1/webhooks/[id]/route.ts @@ -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(), }); diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index 0e51512..397fbdd 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -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(), }); diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index d05cada..a0699fd 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -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 } }; + let event: { id: string; type: string; data: { object: Record } }; 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. diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx new file mode 100644 index 0000000..57a42ae --- /dev/null +++ b/apps/web/app/error.tsx @@ -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 ( +
+

Something went wrong

+

+ An unexpected error occurred. Please try again. +

+ +
+ ); +} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx new file mode 100644 index 0000000..d070239 --- /dev/null +++ b/apps/web/app/not-found.tsx @@ -0,0 +1,35 @@ +export default function RootNotFound() { + return ( +
+

Page not found

+

+ The page you're looking for doesn't exist or may have been moved. +

+ + Back home + +
+ ); +} diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx index 160128c..6c7ad9f 100644 --- a/apps/web/app/r/[id]/page.tsx +++ b/apps/web/app/r/[id]/page.tsx @@ -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) {
{cover && ( -
- {recipe.title} +
+ {recipe.title}
)} diff --git a/apps/web/components/admin/admin-settings-form.tsx b/apps/web/components/admin/admin-settings-form.tsx index 2e429fa..a75a3f8 100644 --- a/apps/web/components/admin/admin-settings-form.tsx +++ b/apps/web/components/admin/admin-settings-form.tsx @@ -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 ? : } diff --git a/apps/web/components/admin/invites-manager.tsx b/apps/web/components/admin/invites-manager.tsx index d18914f..decb905 100644 --- a/apps/web/components/admin/invites-manager.tsx +++ b/apps/web/components/admin/invites-manager.tsx @@ -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(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)} > @@ -152,6 +162,27 @@ export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl:
+ + !open && setRevokeId(null)}> + + + Revoke invite? + The link will stop working. + + + Cancel + { + if (revokeId) void handleRevoke(revokeId); + setRevokeId(null); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Revoke + + + +
); } diff --git a/apps/web/components/collections/collection-star-button.tsx b/apps/web/components/collections/collection-star-button.tsx index 483786b..044a666 100644 --- a/apps/web/components/collections/collection-star-button.tsx +++ b/apps/web/components/collections/collection-star-button.tsx @@ -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); } diff --git a/apps/web/components/feed/feed-page-content.tsx b/apps/web/components/feed/feed-page-content.tsx index b3251d9..882e62a 100644 --- a/apps/web/components/feed/feed-page-content.tsx +++ b/apps/web/components/feed/feed-page-content.tsx @@ -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 })
- + {recipe.authorName.slice(0, 2).toUpperCase()}
@@ -57,7 +66,7 @@ function RecipeCard({ recipe, locale }: { recipe: FeedRecipe; locale: string })
- +

{recipe.title}

{recipe.description && (

{recipe.description}

@@ -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([]); + 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

{t("loading")}

; - if (recipes.length === 0) return

{t("trendingEmpty")}

; + + if (error && recipes.length === 0) { + return ( +
+

{t("loadFailed")}

+ +
+ ); + } + + if (recipes.length === 0) return

{emptyMessage}

; + + const hasMore = recipes.length < total; return (
{recipes.map((recipe) => ( ))} + {error && ( +

{t("loadFailed")}

+ )} + {hasMore || error ? ( +
+ +
+ ) : null}
); } -function ForYouTab() { - const { locale } = useLocale(); +export function FeedPageContent({ followedCount }: Props) { const t = useTranslations("feed"); - const [recipes, setRecipes] = useState([]); - 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

{t("loading")}

; - if (recipes.length === 0) return

{t("forYouEmpty")}

; - - return ( -
- {recipes.map((recipe) => ( - - ))} -
- ); -} - -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) {

{t("followEmpty")}

- ) : feedRecipes.length === 0 ? ( -

{t("noNew")}

) : ( -
- {feedRecipes.map((recipe) => ( - - ))} -
+ ) ) : tab === "trending" ? ( - + ) : ( - + )}
); diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx index 570fe21..40da25b 100644 --- a/apps/web/components/layout/nav.tsx +++ b/apps/web/components/layout/nav.tsx @@ -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 (
} + render={
-
@@ -108,6 +122,29 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) })}
)} + + !open && setConfirmId(null)}> + + + {t("removeConfirmTitle")} + + {itemPendingDelete ? t("removeConfirmDescription", { name: itemPendingDelete.rawName }) : ""} + + + + {tCommon("cancel")} + { + if (confirmId) void remove(confirmId); + setConfirmId(null); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {tCommon("delete")} + + + +
); } diff --git a/apps/web/components/meal-plan/shared-meal-plan-view.tsx b/apps/web/components/meal-plan/shared-meal-plan-view.tsx index 2166b22..aa2884b 100644 --- a/apps/web/components/meal-plan/shared-meal-plan-view.tsx +++ b/apps/web/components/meal-plan/shared-meal-plan-view.tsx @@ -90,7 +90,7 @@ export function SharedMealPlanView({
{entry.recipe?.title ?? "—"} {canEdit && ( - )} diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx index e1470dd..c03a885 100644 --- a/apps/web/components/meal-plan/shopping-list-view.tsx +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -28,6 +28,7 @@ export function ShoppingListView({ }) { const t = useTranslations("mealPlan"); const tShopping = useTranslations("shoppingLists"); + const tCommon = useTranslations("common"); const [items, setItems] = useState(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>((acc, item) => { diff --git a/apps/web/components/pantry/expiring-soon-suggestions.tsx b/apps/web/components/pantry/expiring-soon-suggestions.tsx index 612aef1..2b64d60 100644 --- a/apps/web/components/pantry/expiring-soon-suggestions.tsx +++ b/apps/web/components/pantry/expiring-soon-suggestions.tsx @@ -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 ? ( - +
+ {s.title} +
) : (
)} diff --git a/apps/web/components/recipe/adapt-recipe-button.tsx b/apps/web/components/recipe/adapt-recipe-button.tsx index 1b8d55e..a045e87 100644 --- a/apps/web/components/recipe/adapt-recipe-button.tsx +++ b/apps/web/components/recipe/adapt-recipe-button.tsx @@ -93,7 +93,7 @@ export function AdaptRecipeButton({ setOpen(true)}> + } /> diff --git a/apps/web/components/recipe/add-to-shopping-list-button.tsx b/apps/web/components/recipe/add-to-shopping-list-button.tsx index 600fcd9..dac1767 100644 --- a/apps/web/components/recipe/add-to-shopping-list-button.tsx +++ b/apps/web/components/recipe/add-to-shopping-list-button.tsx @@ -121,7 +121,7 @@ export function AddToShoppingListButton({ setOpen(true)}> + } /> @@ -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")} >− {servings}
{scale !== 1 && ( diff --git a/apps/web/components/recipe/ai-generate-dialog.tsx b/apps/web/components/recipe/ai-generate-dialog.tsx index 164f819..feb750f 100644 --- a/apps/web/components/recipe/ai-generate-dialog.tsx +++ b/apps/web/components/recipe/ai-generate-dialog.tsx @@ -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 ? ( -
- + Dish preview diff --git a/apps/web/components/recipe/drink-pairing-button.tsx b/apps/web/components/recipe/drink-pairing-button.tsx index 96cadea..87c323d 100644 --- a/apps/web/components/recipe/drink-pairing-button.tsx +++ b/apps/web/components/recipe/drink-pairing-button.tsx @@ -81,7 +81,7 @@ export function DrinkPairingButton({ recipeId }: { recipeId: string }) { + } /> diff --git a/apps/web/components/recipe/meal-pairing-button.tsx b/apps/web/components/recipe/meal-pairing-button.tsx index 7c2d462..9e2dcae 100644 --- a/apps/web/components/recipe/meal-pairing-button.tsx +++ b/apps/web/components/recipe/meal-pairing-button.tsx @@ -141,7 +141,7 @@ export function MealPairingButton({ recipeId }: { recipeId: string }) { { setOpen(true); if (pairings.length === 0) suggest(); }}> + } /> diff --git a/apps/web/components/recipe/photo-uploader.tsx b/apps/web/components/recipe/photo-uploader.tsx index b352798..f470f2a 100644 --- a/apps/web/components/recipe/photo-uploader.tsx +++ b/apps/web/components/recipe/photo-uploader.tsx @@ -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({
{photos.map((photo) => ( -
- + diff --git a/apps/web/components/recipe/print-button.tsx b/apps/web/components/recipe/print-button.tsx index 72cf87f..376f12c 100644 --- a/apps/web/components/recipe/print-button.tsx +++ b/apps/web/components/recipe/print-button.tsx @@ -16,7 +16,7 @@ export function PrintButton({ recipeId }: { recipeId: string }) { + } /> diff --git a/apps/web/components/recipe/recipe-card.tsx b/apps/web/components/recipe/recipe-card.tsx index e464923..b97935f 100644 --- a/apps/web/components/recipe/recipe-card.tsx +++ b/apps/web/components/recipe/recipe-card.tsx @@ -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 }) { {cover ? ( -
- + {recipe.title}
) : ( diff --git a/apps/web/components/recipe/recipe-chat-panel.tsx b/apps/web/components/recipe/recipe-chat-panel.tsx index 0dfa40a..cdac16b 100644 --- a/apps/web/components/recipe/recipe-chat-panel.tsx +++ b/apps/web/components/recipe/recipe-chat-panel.tsx @@ -170,7 +170,7 @@ export function RecipeChatPanel({ recipeId, recipeTitle }: Props) { className="flex-1" autoComplete="off" /> - diff --git a/apps/web/components/recipe/recipe-form.tsx b/apps/web/components/recipe/recipe-form.tsx index 9ada97b..899918e 100644 --- a/apps/web/components/recipe/recipe-form.tsx +++ b/apps/web/components/recipe/recipe-form.tsx @@ -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(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) { 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) { -
+ + + + + {t("discardChangesTitle")} + {t("discardChangesDescription")} + + + {t("keepEditing")} + { + setDiscardConfirmOpen(false); + setDirty(false); + router.back(); + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("discardChanges")} + + + + ); } diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index c4e0ea4..0478176 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -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 (
{cover ? ( - {recipe.title}("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[] }) {t("addToCollection")} - @@ -429,6 +442,24 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) recipeIds={[...selected]} onDone={exitSelect} /> + + + + + {t("bulkDeleteConfirmTitle", { count: selected.size })} + {t("bulkDeleteConfirmDescription")} + + + {tCommon("cancel")} + { setDeleteConfirmOpen(false); void bulkDelete(); }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {tCommon("delete")} + + + +
); } diff --git a/apps/web/components/recipe/search-result-card.tsx b/apps/web/components/recipe/search-result-card.tsx new file mode 100644 index 0000000..dec9421 --- /dev/null +++ b/apps/web/components/recipe/search-result-card.tsx @@ -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 = { + 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 ( + +
+

+ {recipe.title} +

+ {recipe.difficulty && ( + + {difficultyLabel} + + )} +
+ {recipe.description && ( +

{recipe.description}

+ )} +
+ {totalMins > 0 && ( + + + {t("total", { mins: totalMins })} + + )} + {recipe.authorName && ( + + + {recipe.authorName} + + )} +
+ + ); +} diff --git a/apps/web/components/recipe/share-recipe-button.tsx b/apps/web/components/recipe/share-recipe-button.tsx index c14e2d1..cc87630 100644 --- a/apps/web/components/recipe/share-recipe-button.tsx +++ b/apps/web/components/recipe/share-recipe-button.tsx @@ -28,7 +28,7 @@ export function ShareRecipeButton({ recipeId, visibility }: { recipeId: string; void handleShare()}> + } /> diff --git a/apps/web/components/recipe/translate-button.tsx b/apps/web/components/recipe/translate-button.tsx index 93368dd..57bb02f 100644 --- a/apps/web/components/recipe/translate-button.tsx +++ b/apps/web/components/recipe/translate-button.tsx @@ -69,7 +69,7 @@ export function TranslateButton({ recipeId }: { recipeId: string }) { setOpen(true)}> + } /> diff --git a/apps/web/components/recipe/variations-button.tsx b/apps/web/components/recipe/variations-button.tsx index 1a21296..02a9c4c 100644 --- a/apps/web/components/recipe/variations-button.tsx +++ b/apps/web/components/recipe/variations-button.tsx @@ -32,7 +32,7 @@ export function VariationsButton({ setOpen(true)}> + } /> diff --git a/apps/web/components/recipe/version-history-button.tsx b/apps/web/components/recipe/version-history-button.tsx index c649443..9161fe7 100644 --- a/apps/web/components/recipe/version-history-button.tsx +++ b/apps/web/components/recipe/version-history-button.tsx @@ -133,7 +133,7 @@ export function VersionHistoryButton({ handleOpen(true)}> + } /> @@ -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)} > @@ -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)} > diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index b113ecc..c73efdb 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -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 = { 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 ( - -
-

- {recipe.title} -

- {recipe.difficulty && ( - - {difficultyLabel} - - )} -
- {description && ( -

{description}

- )} -
- {totalMins > 0 && ( - - - {t("total", { mins: totalMins })} - - )} - {recipe.authorName && ( - - - {recipe.authorName} - - )} -
- - ); -} function HorizontalScroll({ children }: { children: React.ReactNode }) { return ( @@ -317,7 +273,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { <>
{results.map((recipe) => ( - + ))}
{hasMore && ( @@ -443,7 +399,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { {trending.map((recipe) => (
- +
))}
@@ -462,7 +418,7 @@ export function ExplorePageContent({ trending, recent, initialQuery }: Props) { ) : (
{recent.map((recipe) => ( - + ))}
)} diff --git a/apps/web/components/search/search-page-content.tsx b/apps/web/components/search/search-page-content.tsx index 1cacf4c..0c1619f 100644 --- a/apps/web/components/search/search-page-content.tsx +++ b/apps/web/components/search/search-page-content.tsx @@ -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 = { - 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 ( - -
-

- {recipe.title} -

- {recipe.difficulty && ( - - {difficultyLabel} - - )} -
- {recipe.description && ( -

{recipe.description}

- )} -
- {totalMins > 0 && ( - - - {t("total", { mins: totalMins })} - - )} - {recipe.authorName && ( - - - {recipe.authorName} - - )} -
- - ); -} - export function SearchPageContent({ initialQuery }: { initialQuery: string }) { const router = useRouter(); const searchParams = useSearchParams(); @@ -259,7 +198,7 @@ export function SearchPageContent({ initialQuery }: { initialQuery: string }) {

Search for recipes...

-

Try "pasta", "vegan dessert", or "quick dinner"

+

Try “pasta”, “vegan dessert”, or “quick dinner”

)} @@ -287,7 +226,7 @@ export function SearchPageContent({ initialQuery }: { initialQuery: string }) { <>
{results.map((recipe) => ( - + ))}
diff --git a/apps/web/components/shared/export-markdown-button.tsx b/apps/web/components/shared/export-markdown-button.tsx index 76126af..e5f936f 100644 --- a/apps/web/components/shared/export-markdown-button.tsx +++ b/apps/web/components/shared/export-markdown-button.tsx @@ -48,7 +48,7 @@ export function ExportMarkdownButton({ + } /> diff --git a/apps/web/components/shared/skeletons.tsx b/apps/web/components/shared/skeletons.tsx new file mode 100644 index 0000000..66db324 --- /dev/null +++ b/apps/web/components/shared/skeletons.tsx @@ -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 ( +
+
+ + {subtitle && } +
+ {actions > 0 && ( +
+ {Array.from({ length: actions }).map((_, i) => ( + + ))} +
+ )} +
+ ); +} + +/** Mirrors the recipe GridCard: aspect-video thumb, title, description, meta row. */ +export function RecipeCardSkeleton() { + return ( +
+ +
+ + + +
+
+ + +
+
+ ); +} + +/** Grid of RecipeCardSkeletons; default classes match RecipesGrid's grid view. */ +export function RecipeCardGridSkeleton({ + count = 8, + className, +}: { + count?: number; + className?: string; +}) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} + +/** Mirrors a feed article: avatar + author line, title, description, meta chips. */ +export function FeedItemSkeleton() { + return ( +
+
+ + +
+ + + +
+ + + +
+
+ ); +} + +/** Bordered row card, matching list rows (shopping lists, pantry items, etc.). */ +export function ListRowSkeleton() { + return ( +
+ + + +
+ ); +} + +/** Small bordered info card, matching search/explore result cards. */ +export function InfoCardSkeleton() { + return ( +
+ + + +
+ ); +} + +/** Square photo tile with a caption line, matching profile recipe tiles. */ +export function SquareTileSkeleton() { + return ( +
+ +
+ +
+
+ ); +} + +/** Admin overview stat card. */ +export function StatCardSkeleton() { + return ( +
+
+ + +
+ +
+ ); +} + +/** Simple bordered table: one header row and `rows` body rows. */ +export function TableSkeleton({ rows = 8 }: { rows?: number }) { + return ( +
+
+ + + +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + +
+ ))} +
+ ); +} diff --git a/apps/web/components/social/block-button.tsx b/apps/web/components/social/block-button.tsx index eedcc3d..aee0757 100644 --- a/apps/web/components/social/block-button.tsx +++ b/apps/web/components/social/block-button.tsx @@ -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 ( - + <> + + + + + {t("blockConfirmTitle", { username: targetUsername })} + {t("blockConfirmDescription")} + + + {tCommon("cancel")} + { setConfirmOpen(false); void toggle(); }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {t("blockConfirmAction")} + + + + + ); } diff --git a/apps/web/components/social/comments-section.tsx b/apps/web/components/social/comments-section.tsx index 973f15a..4db1ea1 100644 --- a/apps/web/components/social/comments-section.tsx +++ b/apps/web/components/social/comments-section.tsx @@ -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({
- + {comment.userName.slice(0, 2).toUpperCase()}
@@ -165,13 +185,30 @@ function CommentItem({ )} {isOwn && ( )}
+ + + + {t("deleteCommentTitle")} + {t("deleteCommentDescription")} + + + {tCommon("cancel")} + { void deleteComment(); }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {tCommon("delete")} + + + + {showReply && ( ([]); 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({ />
))} + {topLevelOffset < topLevelTotal && ( +
+ +
+ )}
)}
diff --git a/apps/web/components/social/conversations-list.tsx b/apps/web/components/social/conversations-list.tsx index d7a425a..4fb88da 100644 --- a/apps/web/components/social/conversations-list.tsx +++ b/apps/web/components/social/conversations-list.tsx @@ -54,7 +54,7 @@ export function ConversationsList() { )} > - {c.otherUser?.avatarUrl && } + {c.otherUser?.avatarUrl && } {(c.otherUser?.name ?? "?").slice(0, 2).toUpperCase()}
diff --git a/apps/web/components/social/cooked-it-review.tsx b/apps/web/components/social/cooked-it-review.tsx index 801329e..0692012 100644 --- a/apps/web/components/social/cooked-it-review.tsx +++ b/apps/web/components/social/cooked-it-review.tsx @@ -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({ />
{(preview || photoKey) && ( -
- +
diff --git a/apps/web/components/social/favorite-button.tsx b/apps/web/components/social/favorite-button.tsx index 24e084f..6b888e5 100644 --- a/apps/web/components/social/favorite-button.tsx +++ b/apps/web/components/social/favorite-button.tsx @@ -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({ + } /> diff --git a/apps/web/components/social/follow-button.tsx b/apps/web/components/social/follow-button.tsx index 88f9923..d0d1056 100644 --- a/apps/web/components/social/follow-button.tsx +++ b/apps/web/components/social/follow-button.tsx @@ -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 ( ); } diff --git a/apps/web/components/social/message-thread.tsx b/apps/web/components/social/message-thread.tsx index e0423bb..8aec19c 100644 --- a/apps/web/components/social/message-thread.tsx +++ b/apps/web/components/social/message-thread.tsx @@ -25,19 +25,50 @@ export function MessageThread({ const t = useTranslations("messages"); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [nextCursor, setNextCursor] = useState(null); const [content, setContent] = useState(""); const [sending, setSending] = useState(false); const bottomRef = useRef(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 ? (

{t("noMessagesYet")}

) : ( - messages.map((m) => { - const isOwn = m.senderId === currentUserId; - return ( -
-
+ {nextCursor && ( +
+
+ {loadingMore ? t("loadingOlder") : t("loadOlder")} +
- ); - }) + )} + {messages.map((m) => { + const isOwn = m.senderId === currentUserId; + return ( +
+
+ {m.content} +
+
+ ); + })} + )}
@@ -109,7 +154,12 @@ export function MessageThread({ rows={1} className="resize-none" /> -
diff --git a/apps/web/components/social/messages-nav-link.tsx b/apps/web/components/social/messages-nav-link.tsx index 953e4a7..446a48b 100644 --- a/apps/web/components/social/messages-nav-link.tsx +++ b/apps/web/components/social/messages-nav-link.tsx @@ -27,7 +27,7 @@ export function MessagesNavLink() { }, []); return ( - ) : ( - )} @@ -66,7 +66,7 @@ export function ReportButton({ ) : (
- +