feat: followers-only recipe visibility
Adds a fourth visibility tier alongside private/unlisted/public: "followers" — visible to the author and anyone who follows them, excluded from public search/explore/profile listings and from the anonymous /r/[id] share route, included in the Following feed. The in-app recipe detail gate and the public share route both check follow status directly against the DB rather than the union type alone, since neither previously had any per-viewer access logic for a non-public/non-owner case. Requires the generated migration (0041) to run against a live DB — not applied in this sandbox (no Docker here); run `pnpm db:migrate`. v0.41.0
This commit is contained in:
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { Clock, Users, Globe, Lock, Link2, Pencil, ChefHat, ExternalLink, Play } from "lucide-react";
|
||||
import { Clock, Users, Globe, Lock, Link2, Pencil, ChefHat, ExternalLink, Play, UserCheck } from "lucide-react";
|
||||
import { VariationsButton } from "@/components/recipe/variations-button";
|
||||
import { TranslateButton } from "@/components/recipe/translate-button";
|
||||
import { AddToShoppingListButton } from "@/components/recipe/add-to-shopping-list-button";
|
||||
@@ -19,7 +19,8 @@ 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, cookingHistory, avg } from "@epicure/db";
|
||||
import { and, eq, or, count, inArray, desc } from "@epicure/db";
|
||||
import { and, eq, count, desc } from "@epicure/db";
|
||||
import { visibleToViewer } from "@/lib/visibility";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -50,13 +51,13 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, id),
|
||||
or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"]))
|
||||
visibleToViewer(session.user.id)
|
||||
),
|
||||
});
|
||||
return { title: recipe?.title ?? "Recipe" };
|
||||
}
|
||||
|
||||
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
|
||||
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe, followers: UserCheck };
|
||||
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
||||
|
||||
export default async function RecipePage({ params }: Params) {
|
||||
@@ -73,7 +74,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, id),
|
||||
or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"]))
|
||||
visibleToViewer(session.user.id)
|
||||
),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
|
||||
Reference in New Issue
Block a user