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