Files
Epicure/apps/web/app/s/[id]/page.tsx
T
Arnaud 2beb23b360 feat: public shopping list links can allow editing
Owner opts in per-list via a new "Allow editing" toggle next to the
existing public-link switch. Anonymous writes are scoped to that one
list only — the link id is the sole credential, enforced in
getShoppingListAccess and the item routes (no session required there
now), with an IP rate limit on genuinely anonymous requests. Turning
off the public link also revokes editing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 12:53:52 +02:00

117 lines
4.7 KiB
TypeScript

import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { Globe, Pencil } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, and } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
type Params = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Params): Promise<Metadata> {
const { id } = await params;
const list = await db.query.shoppingLists.findFirst({
where: and(eq(shoppingLists.id, id), eq(shoppingLists.isPublic, true)),
});
if (!list) return {};
return { title: list.name };
}
export default async function PublicShoppingListPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() }).catch(() => null);
const m = getMessages((session?.user as { locale?: string } | undefined)?.locale);
const OTHER = m.mealPlan.aisleOther;
const list = await db.query.shoppingLists.findFirst({
where: and(eq(shoppingLists.id, id), eq(shoppingLists.isPublic, true)),
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.sortOrder), asc(t.rawName)] } },
});
if (!list) notFound();
const byAisle = list.items.reduce<Record<string, typeof list.items>>((acc, item) => {
const key = item.aisle ?? OTHER;
(acc[key] ??= []).push(item);
return acc;
}, {});
const aisles = Object.keys(byAisle).sort((a, b) => (a === OTHER ? 1 : b === OTHER ? -1 : a.localeCompare(b)));
return (
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{list.publicEditable ? <Pencil className="h-3.5 w-3.5" /> : <Globe className="h-3.5 w-3.5" />}
<span>{list.publicEditable ? m.publicShoppingList.editableBadge : m.publicShoppingList.sharedList}</span>
{!session && (
<div className="ml-auto flex items-center gap-2">
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>{m.publicRecipe.signUpFree}</Link>
<Link href="/login" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>{m.publicRecipe.logIn}</Link>
</div>
)}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
<p className="text-muted-foreground mt-1">
{formatMessage(m.shoppingLists.itemsChecked, {
checked: list.items.filter((i) => i.checked).length,
total: list.items.length,
})}
</p>
</div>
{list.publicEditable ? (
<ShoppingListView
listId={id}
readOnly={false}
initialItems={list.items.map((i) => ({
id: i.id,
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
aisle: i.aisle,
checked: i.checked,
sortOrder: i.sortOrder,
}))}
/>
) : (
aisles.map((aisle) => (
<div key={aisle} className="space-y-2">
{aisles.length > 1 && (
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b pb-1">{aisle}</h2>
)}
<ul className="space-y-1.5">
{byAisle[aisle]!.map((item) => (
<li key={item.id} className="flex items-baseline gap-2 text-sm">
<span
className={cn(
"inline-block h-4 w-4 shrink-0 rounded border translate-y-0.5",
item.checked ? "bg-foreground border-foreground" : "border-input"
)}
/>
<span className="text-muted-foreground tabular-nums min-w-[3.5rem]">
{[item.quantity, item.unit].filter(Boolean).join(" ")}
</span>
<span className={cn(item.checked && "line-through text-muted-foreground")}>{item.rawName}</span>
</li>
))}
</ul>
</div>
))
)}
{!session && (
<div className="rounded-xl border bg-muted/40 p-6 text-center space-y-3">
<p className="font-semibold">{m.publicShoppingList.wantYourOwn}</p>
<p className="text-sm text-muted-foreground">{m.publicRecipe.wantToSaveDescription}</p>
<Link href="/signup" className={cn(buttonVariants())}>{m.publicRecipe.getStartedFree}</Link>
</div>
)}
</div>
);
}