Files
Epicure/apps/web/components/collections/collections-page-content.tsx
T
Arnaud 9da57dd1d0 feat: collections visibility enum (matches recipes) + QR on collection PDF (v0.54.0)
Replaces collections.isPublic (boolean) with collections.visibility
(private/unlisted/public/followers — same enum recipes use). Two-step
migration (0050 adds+backfills, 0051 drops isPublic) since drizzle-kit's
add+drop-in-one-diff rename heuristic needs an interactive prompt we
can't satisfy here.

New collectionVisibleToViewer(viewerId) in lib/visibility.ts mirrors the
existing recipe helper (author always sees own; public/unlisted visible
to anyone; followers-only via the same user_follows EXISTS pattern) —
used by the collection detail page, its print view, fork, and favorite,
replacing their old `or(isPublic, own)` checks.

Create/edit collection dialogs get the same 4-option visibility select
as the recipe form instead of a public/private checkbox.

Collection PDF export now generates a QR code (qrcode, same as the
recipe PDF) linking to /collections/{id}, shown only when visibility is
public/unlisted — same "would an anonymous scanner actually resolve
this" rule as the recipe QR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:26:34 +02:00

156 lines
5.7 KiB
TypeScript

"use client";
import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { FolderOpen, Flame, Search } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { buttonVariants } from "@/components/ui/button";
import { NewCollectionButton } from "@/components/social/new-collection-button";
import { EmptyState } from "@/components/shared/empty-state";
import { RecipeCoverPlaceholder } from "@/components/recipe/recipe-cover-placeholder";
import { cn } from "@/lib/utils";
type Thumbnail = {
recipeId: string;
recipeType?: "dish" | "drink";
coverIcon: string | null;
coverColor: string | null;
photoUrl: string | null;
};
type Collection = {
id: string;
name: string;
description: string | null;
tags: string[];
visibility: "private" | "unlisted" | "public" | "followers";
recipeCount: number;
thumbnails: Thumbnail[];
};
type Props = {
collections: Collection[];
query: string;
};
function CollectionThumbCollage({ thumbnails }: { thumbnails: Thumbnail[] }) {
if (thumbnails.length === 0) {
return (
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
<FolderOpen className="h-8 w-8" strokeWidth={1.5} />
</div>
);
}
return (
<div className="grid grid-cols-2 grid-rows-2 gap-0.5 w-full h-full">
{Array.from({ length: 4 }).map((_, i) => {
const thumb = thumbnails[i];
return (
<div key={i} className="relative overflow-hidden bg-muted">
{thumb ? (
thumb.photoUrl ? (
<Image src={thumb.photoUrl} unoptimized alt="" fill className="object-cover" />
) : (
<RecipeCoverPlaceholder
recipe={{ id: thumb.recipeId, recipeType: thumb.recipeType, coverIcon: thumb.coverIcon, coverColor: thumb.coverColor }}
iconClassName="h-5 w-5"
/>
)
) : null}
</div>
);
})}
</div>
);
}
export function CollectionsPageContent({ collections, query }: Props) {
const t = useTranslations("collections");
const tRecipe = useTranslations("recipe");
const router = useRouter();
const pathname = usePathname();
const [search, setSearch] = useState(query);
const [, startTransition] = useTransition();
function handleSearch(value: string) {
setSearch(value);
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Link href="/collections/explore" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
<Flame className="h-4 w-4 text-orange-500" />
{t("discoverTitle")}
</Link>
<NewCollectionButton />
</div>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
value={search}
onChange={(e) => handleSearch(e.target.value)}
placeholder={t("searchPlaceholder")}
className="pl-9"
/>
</div>
{collections.length === 0 ? (
<EmptyState
icon={FolderOpen}
title={query ? t("noSearchResults") : t("empty")}
description={query ? undefined : t("emptyDescription")}
actionSlot={!query ? <NewCollectionButton /> : undefined}
/>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{collections.map((col) => (
<Link
key={col.id}
href={`/collections/${col.id}`}
className="group rounded-xl border overflow-hidden hover:shadow-md hover:border-muted-foreground/30 transition-all duration-200"
>
<div className="aspect-[2/1]">
<CollectionThumbCollage thumbnails={col.thumbnails} />
</div>
<div className="p-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<h2 className="font-semibold group-hover:text-primary transition-colors line-clamp-1">{col.name}</h2>
{col.visibility !== "private" && (
<Badge variant="secondary" className="text-xs shrink-0">{tRecipe(`visibility.${col.visibility}`)}</Badge>
)}
</div>
{col.description && <p className="text-sm text-muted-foreground line-clamp-2">{col.description}</p>}
{col.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{col.tags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
{col.recipeCount !== 1 ? t("recipeCountPlural", { count: col.recipeCount }) : t("recipeCount", { count: col.recipeCount })}
</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}