diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx index 83e1807..6e89537 100644 --- a/apps/web/app/(app)/shopping-lists/[id]/page.tsx +++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx @@ -50,7 +50,7 @@ export default async function ShoppingListPage({ params }: Params) {
- {access.role === "owner" && } + {access.role === "owner" && } {m.common.print} diff --git a/apps/web/app/api/v1/shopping-lists/[id]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/route.ts index 54f4cf8..70efeac 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/route.ts @@ -26,6 +26,7 @@ export async function GET(_req: NextRequest, { params }: Params) { const PatchSchema = z.object({ completed: z.boolean().optional(), name: z.string().min(1).max(100).optional(), + isPublic: z.boolean().optional(), }); export async function PATCH(req: NextRequest, { params }: Params) { @@ -45,6 +46,11 @@ export async function PATCH(req: NextRequest, { params }: Params) { await db.update(shoppingLists).set({ name: body.data.name }).where(eq(shoppingLists.id, id)); } + if (body.data.isPublic !== undefined) { + if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + await db.update(shoppingLists).set({ isPublic: body.data.isPublic }).where(eq(shoppingLists.id, id)); + } + if (body.data.completed) { if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); // Mark all items as checked diff --git a/apps/web/app/s/[id]/page.tsx b/apps/web/app/s/[id]/page.tsx new file mode 100644 index 0000000..8012ede --- /dev/null +++ b/apps/web/app/s/[id]/page.tsx @@ -0,0 +1,99 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { headers } from "next/headers"; +import Link from "next/link"; +import { Globe } 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"; + +type Params = { params: Promise<{ id: string }> }; + +export async function generateMetadata({ params }: Params): Promise { + 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>((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 ( +
+
+ + {m.publicShoppingList.sharedList} + {!session && ( +
+ {m.publicRecipe.signUpFree} + {m.publicRecipe.logIn} +
+ )} +
+ +
+

{list.name}

+

+ {formatMessage(m.shoppingLists.itemsChecked, { + checked: list.items.filter((i) => i.checked).length, + total: list.items.length, + })} +

+
+ + {aisles.map((aisle) => ( +
+ {aisles.length > 1 && ( +

{aisle}

+ )} +
    + {byAisle[aisle]!.map((item) => ( +
  • + + + {[item.quantity, item.unit].filter(Boolean).join(" ")} + + {item.rawName} +
  • + ))} +
+
+ ))} + + {!session && ( +
+

{m.publicShoppingList.wantYourOwn}

+

{m.publicRecipe.wantToSaveDescription}

+ {m.publicRecipe.getStartedFree} +
+ )} +
+ ); +} diff --git a/apps/web/components/shopping-lists/share-shopping-list-button.tsx b/apps/web/components/shopping-lists/share-shopping-list-button.tsx index 11aff71..cf63740 100644 --- a/apps/web/components/shopping-lists/share-shopping-list-button.tsx +++ b/apps/web/components/shopping-lists/share-shopping-list-button.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; -import { UserPlus, X } from "lucide-react"; +import { UserPlus, X, Link2, Copy, Check } from "lucide-react"; import { toast } from "sonner"; import { Dialog, @@ -13,6 +13,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -21,6 +22,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; type Role = "viewer" | "editor"; @@ -38,9 +40,10 @@ interface Member { interface Props { listId: string; + initialIsPublic: boolean; } -export function ShareShoppingListButton({ listId }: Props) { +export function ShareShoppingListButton({ listId, initialIsPublic }: Props) { const t = useTranslations("shoppingLists"); const ts = useTranslations("shareDialog"); const tCommon = useTranslations("common"); @@ -50,6 +53,42 @@ export function ShareShoppingListButton({ listId }: Props) { const [members, setMembers] = useState([]); const [loading, setLoading] = useState(false); const [inviting, setInviting] = useState(false); + const [isPublic, setIsPublic] = useState(initialIsPublic); + const [savingPublic, setSavingPublic] = useState(false); + const [copied, setCopied] = useState(false); + + async function togglePublic(checked: boolean) { + setSavingPublic(true); + const previous = isPublic; + setIsPublic(checked); + try { + const res = await fetch(`/api/v1/shopping-lists/${listId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isPublic: checked }), + }); + if (!res.ok) { + setIsPublic(previous); + toast.error(ts("publicLinkToggleFailed")); + } + } catch { + setIsPublic(previous); + toast.error(ts("publicLinkToggleFailed")); + } finally { + setSavingPublic(false); + } + } + + async function copyLink() { + const url = `${window.location.origin}/s/${listId}`; + try { + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error(ts("copyLinkFailed")); + } + } async function fetchMembers() { setLoading(true); @@ -130,6 +169,25 @@ export function ShareShoppingListButton({ listId }: Props) { +
+
+ +
+

{ts("publicLinkTitle")}

+

{ts("publicLinkDescription")}

+
+
+ { void togglePublic(v); }} /> +
+ {isPublic && ( + + )} + + +
users.id, { onDelete: "cascade" }), name: text("name").notNull(), + isPublic: boolean("is_public").notNull().default(false), generatedAt: timestamp("generated_at"), createdAt: timestamp("created_at").notNull().defaultNow(), });