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
+4 -3
View File
@@ -67,7 +67,7 @@ type RecipeFormProps = {
description?: string;
baseServings?: number;
recipeType?: "dish" | "drink";
visibility?: "private" | "unlisted" | "public";
visibility?: "private" | "unlisted" | "public" | "followers";
difficulty?: "easy" | "medium" | "hard" | null;
prepMins?: number | null;
cookMins?: number | null;
@@ -125,7 +125,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
const [description, setDescription] = useState(defaultValues?.description ?? "");
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
const [recipeType, setRecipeType] = useState<"dish" | "drink">(defaultValues?.recipeType ?? "dish");
const [visibility, setVisibility] = useState<"private" | "unlisted" | "public">(defaultValues?.visibility ?? "private");
const [visibility, setVisibility] = useState<"private" | "unlisted" | "public" | "followers">(defaultValues?.visibility ?? "private");
const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? ""));
@@ -486,10 +486,11 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
<select
id="visibility"
value={visibility}
onChange={(e) => setVisibility(e.target.value as "private" | "unlisted" | "public")}
onChange={(e) => setVisibility(e.target.value as "private" | "unlisted" | "public" | "followers")}
className="flex h-8 w-full max-w-[200px] rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
>
<option value="private">{t_recipe("visibility.private")}</option>
<option value="followers">{t_recipe("visibility.followers")}</option>
<option value="unlisted">{t_recipe("visibility.unlisted")}</option>
<option value="public">{t_recipe("visibility.public")}</option>
</select>