9da57dd1d0
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>
156 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|