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 Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Clock, Users, Lock, Globe, Link2, ExternalLink } from "lucide-react";
|
||||
import { Clock, Users, Lock, Globe, Link2, ExternalLink, UserCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
@@ -16,7 +16,7 @@ type Recipe = {
|
||||
prepMins: number | null;
|
||||
cookMins: number | null;
|
||||
difficulty: "easy" | "medium" | "hard" | null;
|
||||
visibility: "private" | "unlisted" | "public";
|
||||
visibility: "private" | "unlisted" | "public" | "followers";
|
||||
updatedAt: Date;
|
||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||
sourceUrl?: string | null;
|
||||
@@ -27,6 +27,7 @@ const VISIBILITY_ICON = {
|
||||
private: Lock,
|
||||
unlisted: Link2,
|
||||
public: Globe,
|
||||
followers: UserCheck,
|
||||
};
|
||||
|
||||
const DIFFICULTY_COLOR = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Globe, Lock, Link2, ExternalLink, ChefHat, Clock, Users, Utensils } from "lucide-react";
|
||||
import { Globe, Lock, Link2, ExternalLink, ChefHat, Clock, Users, Utensils, UserCheck } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||
@@ -17,7 +17,7 @@ export type GridCardRecipe = {
|
||||
prepMins: number | null;
|
||||
cookMins: number | null;
|
||||
difficulty: "easy" | "medium" | "hard" | null;
|
||||
visibility: "private" | "unlisted" | "public";
|
||||
visibility: "private" | "unlisted" | "public" | "followers";
|
||||
tags: string[];
|
||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||
isFavorited?: boolean;
|
||||
@@ -29,7 +29,7 @@ export type GridCardRecipe = {
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,6 +54,7 @@ const SORT_KEYS: Record<string, string> = {
|
||||
const VISIBILITY_KEYS: Record<string, string> = {
|
||||
"": "all",
|
||||
private: "private",
|
||||
followers: "followers",
|
||||
unlisted: "unlisted",
|
||||
public: "public",
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ShareRecipeButton({ recipeId, visibility }: { recipeId: string;
|
||||
toast.error(t("shareCopyFailed"));
|
||||
return;
|
||||
}
|
||||
if (visibility === "private") {
|
||||
if (visibility === "private" || visibility === "followers") {
|
||||
toast.info(t("shareNotPublicNotice"));
|
||||
} else {
|
||||
toast.success(t("shareLinkCopied"));
|
||||
|
||||
Reference in New Issue
Block a user