feat: batch-cook shopping list (already worked) + leftover expiry reminders
Shopping list add already worked generically for batch-cook recipes — no code needed there. New: mark a specific batch-cook dish as "cooked today", track its fridge expiry (cookingHistory.batchDishId), surface a "Leftovers expiring soon" widget on the pantry page, and send a daily push+email reminder via a new /api/internal/cron/leftover-reminders endpoint (mirrors the weekly-digest cron pattern; doesn't use the social notifications table, which requires a non-null actor and isn't built for self-reminders). Also fixes, from user-reported bugs: - Recipe cards showed no batch-cook badge/dish-count/prep-time in some views — added dishCount + prepMins/cookMins (now generated by the AI and persisted) to the card component and /recipes query. - Batch-cook descriptions occasionally contained raw markdown (**bold**) — added explicit "plain prose only" prompt instructions and a stripMarkdown() defensive fallback at render time. - Truncated/cut-off descriptions — the generateObject call had no maxOutputTokens set, so long structured responses could get cut off mid-field; now capped explicitly at 8000. - Generate dialogs (batch-cook + the main AI dialog) could show buttons unreachable once the progress bar appeared mid-generation — restructured so the action row is pinned outside the scrollable content area, not affected by content height changes. - /api/internal/* routes were being redirected to /login by middleware before their own CRON_SECRET check ever ran (pre-existing bug, affected the weekly-digest cron too) — added to PUBLIC_PATHS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -65,5 +65,6 @@ FROM base AS cron
|
||||
RUN apk add --no-cache curl
|
||||
COPY cron/crontab /etc/crontabs/root
|
||||
COPY cron/run-digest.sh /usr/local/bin/run-digest.sh
|
||||
RUN chmod +x /usr/local/bin/run-digest.sh
|
||||
COPY cron/run-leftover-reminders.sh /usr/local/bin/run-leftover-reminders.sh
|
||||
RUN chmod +x /usr/local/bin/run-digest.sh /usr/local/bin/run-leftover-reminders.sh
|
||||
CMD ["crond", "-f", "-l", "2"]
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, pantryItems, recipes, eq, or, inArray } from "@epicure/db";
|
||||
import { db, pantryItems, recipes, cookingHistory, eq, and, or, inArray, isNotNull } from "@epicure/db";
|
||||
import { asc } from "@epicure/db";
|
||||
import { PantryManager } from "@/components/meal-plan/pantry-manager";
|
||||
import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
|
||||
import { ExpiringSoonSuggestions } from "@/components/pantry/expiring-soon-suggestions";
|
||||
import { ExpiringLeftovers } from "@/components/pantry/expiring-leftovers";
|
||||
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
|
||||
import { dishExpiresAt, daysUntil, isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
|
||||
export const metadata: Metadata = {};
|
||||
@@ -15,7 +17,7 @@ export default async function PantryPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const [items, candidateRecipes] = await Promise.all([
|
||||
const [items, candidateRecipes, cookedDishes] = await Promise.all([
|
||||
db.query.pantryItems.findMany({
|
||||
where: eq(pantryItems.userId, session.user.id),
|
||||
orderBy: asc(pantryItems.rawName),
|
||||
@@ -24,6 +26,13 @@ export default async function PantryPage() {
|
||||
where: or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])),
|
||||
with: { ingredients: true, photos: true },
|
||||
}),
|
||||
db.query.cookingHistory.findMany({
|
||||
where: and(eq(cookingHistory.userId, session.user.id), isNotNull(cookingHistory.batchDishId)),
|
||||
with: {
|
||||
batchDish: { columns: { id: true, name: true, fridgeDays: true } },
|
||||
recipe: { columns: { id: true, title: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const mappedItems = items.map((i) => ({
|
||||
@@ -47,9 +56,27 @@ export default async function PantryPage() {
|
||||
};
|
||||
});
|
||||
|
||||
const latestByDish = new Map<string, (typeof cookedDishes)[number]>();
|
||||
for (const log of cookedDishes) {
|
||||
if (!log.batchDish) continue;
|
||||
const existing = latestByDish.get(log.batchDish.id);
|
||||
if (!existing || log.cookedAt > existing.cookedAt) latestByDish.set(log.batchDish.id, log);
|
||||
}
|
||||
const leftovers = [...latestByDish.values()]
|
||||
.filter((log) => log.batchDish && isLeftoverExpiringSoon(log.cookedAt, log.batchDish.fridgeDays))
|
||||
.map((log) => ({
|
||||
recipeId: log.recipe.id,
|
||||
dishName: log.batchDish!.name,
|
||||
recipeTitle: log.recipe.title,
|
||||
expiresAt: dishExpiresAt(log.cookedAt, log.batchDish!.fridgeDays).toISOString(),
|
||||
daysLeft: daysUntil(dishExpiresAt(log.cookedAt, log.batchDish!.fridgeDays)),
|
||||
}))
|
||||
.sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PantryPageHeader items={mappedItems} />
|
||||
<ExpiringLeftovers leftovers={leftovers} />
|
||||
<ExpiringSoonSuggestions suggestions={suggestions} />
|
||||
<PantryManager key={mappedItems.map((i) => i.id).join(",")} initialItems={mappedItems} />
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,8 @@ import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
|
||||
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, avg } from "@epicure/db";
|
||||
import { and, eq, or, count, inArray } from "@epicure/db";
|
||||
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
|
||||
import { and, eq, or, count, inArray, desc } from "@epicure/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -32,7 +32,7 @@ import { CookedItReview } from "@/components/social/cooked-it-review";
|
||||
import { RecipeNotes } from "@/components/recipe/recipe-notes";
|
||||
import { CommentsSection } from "@/components/social/comments-section";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, stripMarkdown } from "@/lib/utils";
|
||||
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
|
||||
import { BatchCookSteps } from "@/components/recipe/batch-cook-steps";
|
||||
import { BatchCookDishes } from "@/components/recipe/batch-cook-dishes";
|
||||
@@ -62,7 +62,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
const DIETARY_LABELS = m.recipe.dietary;
|
||||
const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric";
|
||||
|
||||
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote] = await Promise.all([
|
||||
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote, dishCookLog] = await Promise.all([
|
||||
db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, id),
|
||||
@@ -87,12 +87,24 @@ export default async function RecipePage({ params }: Params) {
|
||||
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)),
|
||||
columns: { content: true },
|
||||
}),
|
||||
db.query.cookingHistory.findMany({
|
||||
where: and(eq(cookingHistory.recipeId, id), eq(cookingHistory.userId, session.user.id)),
|
||||
orderBy: desc(cookingHistory.cookedAt),
|
||||
columns: { batchDishId: true, cookedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
const isOwner = recipe.authorId === session.user.id;
|
||||
|
||||
const dishCookedAtMap = new Map<string, string>();
|
||||
for (const log of dishCookLog) {
|
||||
if (log.batchDishId && !dishCookedAtMap.has(log.batchDishId)) {
|
||||
dishCookedAtMap.set(log.batchDishId, log.cookedAt.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
||||
const ratingCount = ratingData[0]?.total ?? 0;
|
||||
const isFavorited = !!favoriteData;
|
||||
@@ -267,7 +279,9 @@ export default async function RecipePage({ params }: Params) {
|
||||
)}
|
||||
|
||||
{recipe.description && (
|
||||
<p className="text-muted-foreground leading-relaxed">{recipe.description}</p>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{recipe.sourceUrl && (
|
||||
@@ -411,7 +425,10 @@ export default async function RecipePage({ params }: Params) {
|
||||
{recipe.isBatchCook && recipe.batchDishes.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<BatchCookDishes dishes={recipe.batchDishes} />
|
||||
<BatchCookDishes
|
||||
recipeId={id}
|
||||
dishes={recipe.batchDishes.map((d) => ({ ...d, cookedAt: dishCookedAtMap.get(d.id) ?? null }))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
db.query.recipes.findMany({
|
||||
where,
|
||||
orderBy: SORT_MAP[sortKey],
|
||||
with: { photos: true },
|
||||
with: { photos: true, batchDishes: { columns: { id: true } } },
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
}),
|
||||
@@ -87,7 +87,7 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
.where(and(eq(favorites.userId, session.user.id), inArray(favorites.recipeId, recipeIds)))
|
||||
: [];
|
||||
const favoritedIds = new Set(favoritedRows.map((r) => r.recipeId));
|
||||
const recipesWithFavorites = userRecipes.map((r) => ({ ...r, isFavorited: favoritedIds.has(r.id) }));
|
||||
const recipesWithFavorites = userRecipes.map((r) => ({ ...r, isFavorited: favoritedIds.has(r.id), dishCount: r.batchDishes.length }));
|
||||
|
||||
const pageHref = (p: number) => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "node:crypto";
|
||||
import { db, cookingHistory, eq, and, isNotNull, isNull } from "@epicure/db";
|
||||
import { sendEmail, notificationEmailHtml } from "@/lib/email";
|
||||
import { sendPushNotification } from "@/lib/push";
|
||||
import { isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
|
||||
// Internal cron endpoint — triggered daily by a cron container (see
|
||||
// compose.prod.yml / cron/crontab). Not part of the public API surface;
|
||||
// protected by a shared secret rather than user auth.
|
||||
//
|
||||
// For every cooking_history row tied to a batch-cook dish, checks whether it
|
||||
// expires soon (see lib/leftover-match.ts) and hasn't already been reminded
|
||||
// about, then sends one push + email and marks it reminded so it never fires
|
||||
// twice for the same cooked dish.
|
||||
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
const secret = process.env["CRON_SECRET"];
|
||||
if (!secret) return false;
|
||||
|
||||
const header = req.headers.get("authorization");
|
||||
if (!header?.startsWith("Bearer ")) return false;
|
||||
const provided = header.slice("Bearer ".length);
|
||||
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(secret);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const candidates = await db.query.cookingHistory.findMany({
|
||||
where: and(isNotNull(cookingHistory.batchDishId), isNull(cookingHistory.expiryReminderSentAt)),
|
||||
with: {
|
||||
batchDish: { columns: { id: true, name: true, fridgeDays: true } },
|
||||
recipe: { columns: { id: true, title: true } },
|
||||
user: { columns: { id: true, email: true, locale: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const log of candidates) {
|
||||
if (!log.batchDish || !log.user) continue;
|
||||
if (!isLeftoverExpiringSoon(log.cookedAt, log.batchDish.fridgeDays)) continue;
|
||||
|
||||
const messages = getMessages(log.user.locale);
|
||||
const template = messages.notifications.detail.leftoverExpiring;
|
||||
const title = messages.notifications.pushTitle.leftoverExpiring;
|
||||
const body = formatMessage(template, { dish: log.batchDish.name, title: log.recipe.title });
|
||||
const url = `/recipes/${log.recipe.id}`;
|
||||
|
||||
await Promise.all([
|
||||
sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
|
||||
console.error("[leftover-reminders] push failed", err);
|
||||
}),
|
||||
log.user.email
|
||||
? sendEmail({
|
||||
to: log.user.email,
|
||||
subject: title,
|
||||
html: notificationEmailHtml(title, body, `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}${url}`),
|
||||
}).catch((err) => {
|
||||
console.error("[leftover-reminders] email failed", err);
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
|
||||
await db.update(cookingHistory)
|
||||
.set({ expiryReminderSentAt: new Date() })
|
||||
.where(eq(cookingHistory.id, log.id));
|
||||
sent++;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, checked: candidates.length, sent });
|
||||
}
|
||||
@@ -71,6 +71,8 @@ export async function POST(req: NextRequest) {
|
||||
aiGenerated: true,
|
||||
isBatchCook: true,
|
||||
language: locale,
|
||||
prepMins: plan.prepMins,
|
||||
cookMins: plan.cookMins,
|
||||
});
|
||||
|
||||
if (plan.ingredients.length > 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, cookingHistory, pantryItems, recipeIngredients, eq, and } from "@epicure/db";
|
||||
import { db, recipes, cookingHistory, pantryItems, recipeIngredients, recipeBatchDishes, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
@@ -9,6 +9,7 @@ const Schema = z.object({
|
||||
servings: z.number().int().min(1).max(1000).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
deductFromPantry: z.boolean().default(true),
|
||||
batchDishId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
@@ -26,16 +27,24 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
const parsed = Schema.safeParse(body);
|
||||
const data = parsed.success ? parsed.data : { deductFromPantry: true };
|
||||
|
||||
if (data.batchDishId) {
|
||||
const dish = await db.query.recipeBatchDishes.findFirst({
|
||||
where: and(eq(recipeBatchDishes.id, data.batchDishId), eq(recipeBatchDishes.recipeId, id)),
|
||||
});
|
||||
if (!dish) return NextResponse.json({ error: "Dish not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
await db.insert(cookingHistory).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
recipeId: id,
|
||||
batchDishId: data.batchDishId,
|
||||
servings: data.servings,
|
||||
notes: data.notes,
|
||||
cookedAt: new Date(),
|
||||
});
|
||||
|
||||
if (data.deductFromPantry) {
|
||||
if (data.deductFromPantry && !data.batchDishId) {
|
||||
const ings = await db.query.recipeIngredients.findMany({
|
||||
where: eq(recipeIngredients.recipeId, id),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Leftover = {
|
||||
recipeId: string;
|
||||
dishName: string;
|
||||
recipeTitle: string;
|
||||
expiresAt: string;
|
||||
daysLeft: number;
|
||||
};
|
||||
|
||||
export function ExpiringLeftovers({ leftovers }: { leftovers: Leftover[] }) {
|
||||
const t = useTranslations("pantry");
|
||||
|
||||
if (leftovers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-4 space-y-3">
|
||||
<h2 className="font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
{t("expiringLeftoversTitle")}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{leftovers.map((l) => (
|
||||
<Link
|
||||
key={`${l.recipeId}-${l.dishName}`}
|
||||
href={`/recipes/${l.recipeId}`}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border p-2.5 hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{l.dishName}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{l.recipeTitle}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={l.daysLeft <= 0 ? "text-destructive border-destructive shrink-0" : "text-orange-500 border-orange-500 shrink-0"}
|
||||
>
|
||||
{l.daysLeft <= 0
|
||||
? t("expiringToday")
|
||||
: t("expiringInDays", { days: l.daysLeft })}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -216,7 +216,8 @@ export function AiGenerateDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-xl max-h-[85vh] flex flex-col gap-0 p-0">
|
||||
<div className="overflow-y-auto p-4 space-y-4 min-h-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
@@ -370,8 +371,9 @@ export function AiGenerateDialog({
|
||||
label={busy ? (tab === "photo" ? t("analyzePhoto") : tab === "batch" ? tBatch("generating") : t("generating")) : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<div className="flex gap-2 justify-end p-4 border-t shrink-0">
|
||||
<Button variant="ghost" onClick={handleClose} disabled={busy}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Snowflake, Refrigerator, ChefHat } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Snowflake, Refrigerator, ChefHat, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocale } from "@/lib/i18n/provider";
|
||||
import { stripMarkdown } from "@/lib/utils";
|
||||
|
||||
type Dish = {
|
||||
id: string;
|
||||
@@ -11,10 +16,40 @@ type Dish = {
|
||||
freezerFriendly: boolean;
|
||||
freezerNote: string | null;
|
||||
dayOfInstructions: string;
|
||||
cookedAt: string | null;
|
||||
};
|
||||
|
||||
export function BatchCookDishes({ dishes }: { dishes: Dish[] }) {
|
||||
function expiresOn(cookedAt: string, fridgeDays: number): Date {
|
||||
const d = new Date(cookedAt);
|
||||
d.setDate(d.getDate() + fridgeDays);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function BatchCookDishes({ recipeId, dishes: initialDishes }: { recipeId: string; dishes: Dish[] }) {
|
||||
const t = useTranslations("recipe");
|
||||
const { locale } = useLocale();
|
||||
const [dishes, setDishes] = useState(initialDishes);
|
||||
const [markingId, setMarkingId] = useState<string | null>(null);
|
||||
|
||||
async function markCooked(dishId: string) {
|
||||
setMarkingId(dishId);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ batchDishId: dishId, deductFromPantry: false }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast.error(t("batchCookMarkFailed"));
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
setDishes((prev) => prev.map((d) => (d.id === dishId ? { ...d, cookedAt: now } : d)));
|
||||
toast.success(t("batchCookMarkSuccess"));
|
||||
} finally {
|
||||
setMarkingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -24,7 +59,7 @@ export function BatchCookDishes({ dishes }: { dishes: Dish[] }) {
|
||||
<div key={dish.id} className="rounded-xl border p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{dish.name}</h3>
|
||||
{dish.description && <p className="text-sm text-muted-foreground mt-0.5">{dish.description}</p>}
|
||||
{dish.description && <p className="text-sm text-muted-foreground mt-0.5">{stripMarkdown(dish.description)}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
@@ -47,9 +82,29 @@ export function BatchCookDishes({ dishes }: { dishes: Dish[] }) {
|
||||
<ChefHat className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">{t("batchCookDayOf")}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{dish.dayOfInstructions}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{stripMarkdown(dish.dayOfInstructions)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dish.cookedAt ? (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
{t("batchCookExpiresOn", {
|
||||
date: expiresOn(dish.cookedAt, dish.fridgeDays).toLocaleDateString(locale, { month: "short", day: "numeric" }),
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={markingId === dish.id}
|
||||
onClick={() => { void markCooked(dish.id); }}
|
||||
>
|
||||
<ChefHat className="h-3.5 w-3.5" />
|
||||
{t("batchCookMarkCooked")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -73,22 +73,24 @@ export function BatchCookGenerateDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ChefHat className="h-5 w-5 text-primary" />
|
||||
{t("wizardTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("wizardDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md max-h-[85vh] flex flex-col gap-0 p-0">
|
||||
<div className="overflow-y-auto p-4 space-y-4 min-h-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ChefHat className="h-5 w-5 text-primary" />
|
||||
{t("wizardTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("wizardDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<BatchCookFields state={fields} onChange={setFields} disabled={busy} />
|
||||
<BatchCookFields state={fields} onChange={setFields} disabled={busy} />
|
||||
|
||||
<div className="min-h-[26px]">
|
||||
<FakeProgressBar active={busy} durationMs={20000} label={busy ? t("generating") : undefined} />
|
||||
<div className="min-h-[26px]">
|
||||
<FakeProgressBar active={busy} durationMs={20000} label={busy ? t("generating") : undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<div className="flex gap-2 justify-end p-4 border-t shrink-0">
|
||||
<Button variant="ghost" onClick={handleClose} disabled={busy}>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -28,9 +28,9 @@ import { toast } from "sonner";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { useLocale } from "@/lib/i18n/provider";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Clock, Users } from "lucide-react";
|
||||
import { Clock, Users, Utensils } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn, stripMarkdown } from "@/lib/utils";
|
||||
|
||||
type Recipe = {
|
||||
id: string;
|
||||
@@ -46,6 +46,7 @@ type Recipe = {
|
||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||
isFavorited?: boolean;
|
||||
isBatchCook?: boolean;
|
||||
dishCount?: number;
|
||||
};
|
||||
|
||||
/** Stops the click from bubbling to the card's own Link/select handler. */
|
||||
@@ -98,6 +99,7 @@ function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Reci
|
||||
|
||||
function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
|
||||
const t = useTranslations("recipe");
|
||||
const tBatch = useTranslations("batchCooking");
|
||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
||||
|
||||
@@ -122,7 +124,9 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
|
||||
{recipe.isBatchCook && <ChefHat className="h-3.5 w-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
{recipe.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.description}</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}
|
||||
</p>
|
||||
)}
|
||||
{recipe.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
@@ -139,7 +143,11 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
|
||||
</div>
|
||||
<div className="px-3 pb-3 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
||||
{recipe.isBatchCook && recipe.dishCount ? (
|
||||
<span className="flex items-center gap-1"><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
||||
)}
|
||||
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
|
||||
{recipe.difficulty && (
|
||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>
|
||||
@@ -160,6 +168,7 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
|
||||
|
||||
function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: boolean; selectMode: boolean }) {
|
||||
const t = useTranslations("recipe");
|
||||
const tBatch = useTranslations("batchCooking");
|
||||
const { locale } = useLocale();
|
||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
||||
@@ -188,9 +197,13 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b
|
||||
<VisibilityIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
{recipe.description && <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.description}</p>}
|
||||
{recipe.description && <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">{recipe.isBatchCook ? stripMarkdown(recipe.description) : recipe.description}</p>}
|
||||
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mt-auto flex-wrap">
|
||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
||||
{recipe.isBatchCook && recipe.dishCount ? (
|
||||
<span className="flex items-center gap-1"><Utensils className="h-3 w-3" />{tBatch("dishCount", { count: recipe.dishCount })}</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{recipe.baseServings}</span>
|
||||
)}
|
||||
{totalMins > 0 && <span className="flex items-center gap-1"><Clock className="h-3 w-3" />{t("total", { mins: totalMins })}</span>}
|
||||
{recipe.difficulty && <Badge variant={DIFFICULTY_COLOR[recipe.difficulty]} className="text-xs h-4 px-1.5">{t(`difficulty.${recipe.difficulty}`)}</Badge>}
|
||||
{recipe.tags.slice(0, 2).map((tag) => (
|
||||
|
||||
@@ -4,14 +4,16 @@ import { resolveModel, type AiConfig } from "../factory";
|
||||
|
||||
const BatchCookSchema = z.object({
|
||||
title: z.string().max(150),
|
||||
description: z.string().max(300),
|
||||
description: z.string().max(300).describe("Plain prose only — no markdown formatting (no **bold**, no bullet points, no headers)."),
|
||||
prepMins: z.number().int().min(0).max(300).describe("Total hands-on prep time for the whole session, in minutes."),
|
||||
cookMins: z.number().int().min(0).max(300).describe("Total passive cook/bake/simmer time for the whole session, in minutes."),
|
||||
dishes: z.array(z.object({
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(200),
|
||||
description: z.string().max(200).describe("Plain prose only — no markdown formatting."),
|
||||
fridgeDays: z.number().int().min(1).max(7),
|
||||
freezerFriendly: z.boolean(),
|
||||
freezerNote: z.string().max(150).optional(),
|
||||
dayOfInstructions: z.string().max(300),
|
||||
dayOfInstructions: z.string().max(300).describe("Plain prose only — no markdown formatting."),
|
||||
})).min(1).max(6),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string().describe("Ingredient name only — never include the quantity or unit here."),
|
||||
@@ -72,7 +74,11 @@ Dishes must be genuinely distinct — different techniques and flavor profiles (
|
||||
|
||||
Every dish needs: fridgeDays, freezerFriendly, and a freezerNote if applicable (duration + thawing tip). dayOfInstructions must name the exact reheat method and time, AND a concrete fresh finishing touch (stir in cream, cook fresh grain, add a fresh side, sear a protein, grate cheese) — never generic ("reheat and enjoy" is unacceptable).
|
||||
|
||||
For ingredients: quantity is a number only, unit is a separate string. Never combine them. Respond in ${lang}.`;
|
||||
For ingredients: quantity is a number only, unit is a separate string. Never combine them.
|
||||
|
||||
Estimate prepMins (total hands-on active work across the whole session) and cookMins (total passive oven/stovetop/simmer time) for the whole session, in minutes.
|
||||
|
||||
Write every description and instruction as plain prose — never use markdown formatting (no **bold**, no bullet lists, no headers, no asterisks). Keep every field within its stated length limit; a shorter, complete sentence is always better than a longer one that gets cut off. Respond in ${lang}.`;
|
||||
|
||||
const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
|
||||
|
||||
@@ -80,6 +86,7 @@ For ingredients: quantity is a number only, unit is a separate string. Never com
|
||||
model,
|
||||
schema: BatchCookSchema,
|
||||
system: systemPromptWithContext,
|
||||
maxOutputTokens: 8000,
|
||||
prompt: [
|
||||
`Generate a batch-cooking session covering ${mealParts.join(" and ") || `${totalDishes} meals`}.`,
|
||||
`Design exactly ${totalDishes} distinct dishes, each for ${servings} servings.`,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const LEFTOVER_EXPIRING_WITHIN_DAYS = 2;
|
||||
|
||||
export function daysUntil(expiresAt: Date): number {
|
||||
const ms = expiresAt.getTime() - Date.now();
|
||||
return Math.ceil(ms / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export function dishExpiresAt(cookedAt: Date, fridgeDays: number): Date {
|
||||
const d = new Date(cookedAt);
|
||||
d.setDate(d.getDate() + fridgeDays);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function isLeftoverExpiringSoon(cookedAt: Date, fridgeDays: number): boolean {
|
||||
const days = daysUntil(dishExpiresAt(cookedAt, fridgeDays));
|
||||
return days >= 0 && days <= LEFTOVER_EXPIRING_WITHIN_DAYS;
|
||||
}
|
||||
@@ -4,3 +4,13 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
/** Strips common markdown syntax for display in plain-text contexts (AI output occasionally ignores "no markdown" instructions). */
|
||||
export function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, "$1")
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/`(.+?)`/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^[-*+]\s+/gm, "");
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"mention": "{name} mentioned you in a comment",
|
||||
"detail": {
|
||||
"comment": "{name} commented on your recipe \"{title}\"",
|
||||
"rating": "{name} rated your recipe \"{title}\" {stars} stars"
|
||||
"rating": "{name} rated your recipe \"{title}\" {stars} stars",
|
||||
"leftoverExpiring": "Your \"{dish}\" from \"{title}\" is about to expire — eat it soon!"
|
||||
},
|
||||
"pushTitle": {
|
||||
"follow": "New follower",
|
||||
@@ -48,7 +49,8 @@
|
||||
"reply": "New reply",
|
||||
"reaction": "New reaction",
|
||||
"rating": "New rating",
|
||||
"mention": "You were mentioned"
|
||||
"mention": "You were mentioned",
|
||||
"leftoverExpiring": "Leftovers expiring soon"
|
||||
}
|
||||
},
|
||||
"recipe": {
|
||||
@@ -68,6 +70,10 @@
|
||||
"batchCookFridgeDays": "Fridge: {days}d",
|
||||
"batchCookFreezerFriendly": "Freezer-friendly",
|
||||
"batchCookDayOf": "On the day",
|
||||
"batchCookMarkCooked": "Mark as cooked today",
|
||||
"batchCookMarkSuccess": "Marked as cooked",
|
||||
"batchCookMarkFailed": "Failed to mark as cooked",
|
||||
"batchCookExpiresOn": "Cooked — good until {date}",
|
||||
"servings": "{count} servings",
|
||||
"prep": "{mins}m prep",
|
||||
"cook": "{mins}m cook",
|
||||
@@ -770,6 +776,9 @@
|
||||
"canCook": "What can I cook?",
|
||||
"expiringSoonSuggestionsTitle": "Use it up soon",
|
||||
"seeAll": "See all",
|
||||
"expiringLeftoversTitle": "Leftovers expiring soon",
|
||||
"expiringToday": "Eat today",
|
||||
"expiringInDays": "{days}d left",
|
||||
"itemNamePlaceholder": "Item name",
|
||||
"qtyPlaceholder": "Qty",
|
||||
"unitPlaceholder": "Unit",
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"mention": "{name} vous a mentionné dans un commentaire",
|
||||
"detail": {
|
||||
"comment": "{name} a commenté votre recette « {title} »",
|
||||
"rating": "{name} a noté votre recette « {title} » {stars} étoiles"
|
||||
"rating": "{name} a noté votre recette « {title} » {stars} étoiles",
|
||||
"leftoverExpiring": "Votre « {dish} » de « {title} » arrive à expiration — à manger vite !"
|
||||
},
|
||||
"pushTitle": {
|
||||
"follow": "Nouvel abonné",
|
||||
@@ -48,7 +49,8 @@
|
||||
"reply": "Nouvelle réponse",
|
||||
"reaction": "Nouvelle réaction",
|
||||
"rating": "Nouvelle note",
|
||||
"mention": "Vous avez été mentionné"
|
||||
"mention": "Vous avez été mentionné",
|
||||
"leftoverExpiring": "Restes bientôt périmés"
|
||||
}
|
||||
},
|
||||
"recipe": {
|
||||
@@ -68,6 +70,10 @@
|
||||
"batchCookFridgeDays": "Frigo : {days}j",
|
||||
"batchCookFreezerFriendly": "Se congèle",
|
||||
"batchCookDayOf": "Le jour J",
|
||||
"batchCookMarkCooked": "Marquer comme cuisiné aujourd'hui",
|
||||
"batchCookMarkSuccess": "Marqué comme cuisiné",
|
||||
"batchCookMarkFailed": "Échec du marquage",
|
||||
"batchCookExpiresOn": "Cuisiné — bon jusqu'au {date}",
|
||||
"servings": "{count} portions",
|
||||
"prep": "{mins}m prép.",
|
||||
"cook": "{mins}m cuisson",
|
||||
@@ -758,6 +764,9 @@
|
||||
"canCook": "Que puis-je cuisiner ?",
|
||||
"expiringSoonSuggestionsTitle": "À utiliser bientôt",
|
||||
"seeAll": "Tout voir",
|
||||
"expiringLeftoversTitle": "Restes bientôt périmés",
|
||||
"expiringToday": "À manger aujourd'hui",
|
||||
"expiringInDays": "{days}j restants",
|
||||
"itemNamePlaceholder": "Nom de l'article",
|
||||
"qtyPlaceholder": "Qté",
|
||||
"unitPlaceholder": "Unité",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionCookie } from "better-auth/cookies";
|
||||
|
||||
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/"];
|
||||
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
|
||||
const ADMIN_PATHS = ["/admin"];
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
# Weekly digest — every Monday at 08:00 UTC.
|
||||
0 8 * * 1 /usr/local/bin/run-digest.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
|
||||
|
||||
# Leftover-expiry reminders — daily at 09:00 UTC.
|
||||
0 9 * * * /usr/local/bin/run-leftover-reminders.sh >> /proc/1/fd/1 2>> /proc/1/fd/2
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Triggers the leftover-expiry reminder send on the `web` service. Run on a
|
||||
# schedule by busybox crond (see cron/crontab). Fails loudly (non-zero exit,
|
||||
# stderr) but crond just tries again tomorrow — no retry loop here on purpose,
|
||||
# keep this script trivial.
|
||||
set -eu
|
||||
|
||||
: "${WEB_INTERNAL_URL:=http://web:3000}"
|
||||
|
||||
if [ -z "${CRON_SECRET:-}" ]; then
|
||||
echo "run-leftover-reminders: CRON_SECRET is not set, refusing to call the endpoint" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${CRON_SECRET}" \
|
||||
"${WEB_INTERNAL_URL}/api/internal/cron/leftover-reminders"
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "cooking_history" ADD COLUMN "batch_dish_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "cooking_history" ADD COLUMN "expiry_reminder_sent_at" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "cooking_history" ADD CONSTRAINT "cooking_history_batch_dish_id_recipe_batch_dishes_id_fk" FOREIGN KEY ("batch_dish_id") REFERENCES "public"."recipe_batch_dishes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "cooking_history_batch_dish_idx" ON "cooking_history" USING btree ("batch_dish_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,13 @@
|
||||
"when": 1783814978348,
|
||||
"tag": "0030_kind_lightspeed",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1783841772890,
|
||||
"tag": "0031_brave_arclight",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { users } from "./users";
|
||||
import { recipes } from "./recipes";
|
||||
import { recipes, recipeBatchDishes } from "./recipes";
|
||||
|
||||
export const notificationTypeEnum = pgEnum("notification_type", [
|
||||
"follow",
|
||||
@@ -87,11 +87,14 @@ export const cookingHistory = pgTable("cooking_history", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
|
||||
batchDishId: text("batch_dish_id").references(() => recipeBatchDishes.id, { onDelete: "cascade" }),
|
||||
cookedAt: timestamp("cooked_at").notNull().defaultNow(),
|
||||
servings: integer("servings"),
|
||||
notes: text("notes"),
|
||||
expiryReminderSentAt: timestamp("expiry_reminder_sent_at"),
|
||||
}, (t) => [
|
||||
index("cooking_history_user_idx").on(t.userId),
|
||||
index("cooking_history_batch_dish_idx").on(t.batchDishId),
|
||||
]);
|
||||
|
||||
export const notifications = pgTable("notifications", {
|
||||
@@ -111,6 +114,7 @@ export const notifications = pgTable("notifications", {
|
||||
export const cookingHistoryRelations = relations(cookingHistory, ({ one }) => ({
|
||||
user: one(users, { fields: [cookingHistory.userId], references: [users.id] }),
|
||||
recipe: one(recipes, { fields: [cookingHistory.recipeId], references: [recipes.id] }),
|
||||
batchDish: one(recipeBatchDishes, { fields: [cookingHistory.batchDishId], references: [recipeBatchDishes.id] }),
|
||||
}));
|
||||
|
||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
|
||||
Reference in New Issue
Block a user