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:
Arnaud
2026-07-17 17:05:10 +02:00
parent 23babd4ee9
commit 77c739960d
29 changed files with 5183 additions and 43 deletions
+7 -4
View File
@@ -3,7 +3,7 @@
import { useState, useCallback, useEffect } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus, ChefHat, ExternalLink, Tag, Download } from "lucide-react";
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus, ChefHat, ExternalLink, Tag, Download, UserCheck } from "lucide-react";
import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog";
import { BulkTagsDialog } from "@/components/recipe/bulk-tags-dialog";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -41,7 +41,7 @@ type Recipe = {
prepMins: number | null;
cookMins: number | null;
difficulty: "easy" | "medium" | "hard" | null;
visibility: "private" | "unlisted" | "public";
visibility: "private" | "unlisted" | "public" | "followers";
tags: string[];
updatedAt: Date;
photos?: Array<{ storageKey: string; isCover: boolean }>;
@@ -64,7 +64,7 @@ type ViewMode = "grid" | "list" | "compact";
const VIEW_STORAGE_KEY = "epicure-recipes-view";
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe, followers: UserCheck };
function hostnameOf(url: string): string {
try {
@@ -458,7 +458,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
}
}
async function bulkSetVisibility(visibility: "private" | "unlisted" | "public") {
async function bulkSetVisibility(visibility: "private" | "unlisted" | "public" | "followers") {
setBusy(true);
try {
const res = await fetch("/api/v1/recipes/bulk", {
@@ -588,6 +588,9 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> {t("makeUnlisted")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { void bulkSetVisibility("followers"); }}>
<UserCheck className="h-4 w-4 mr-2 text-blue-500" /> {t("makeFollowersOnly")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { void bulkSetVisibility("private"); }}>
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> {t("makePrivate")}
</DropdownMenuItem>