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:
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.41.0 — 2026-07-17 14:45
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New recipe visibility option: "Followers only" — visible to people who follow you, hidden from public browsing, search, and anonymous share links. Available in the recipe editor, bulk visibility actions, and the Recipes filter.
|
||||||
|
|
||||||
|
Note: public profile pages and search results don't yet surface a viewer's followers-only recipes from that author — those remain reachable only via the recipe's direct link or the Following feed.
|
||||||
|
|
||||||
## 0.40.0 — 2026-07-17 14:00
|
## 0.40.0 — 2026-07-17 14:00
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export type RecipeResult = {
|
|||||||
baseServings: number;
|
baseServings: number;
|
||||||
prepMins: number | null;
|
prepMins: number | null;
|
||||||
cookMins: number | null;
|
cookMins: number | null;
|
||||||
visibility: "private" | "unlisted" | "public";
|
visibility: "private" | "unlisted" | "public" | "followers";
|
||||||
tags: string[];
|
tags: string[];
|
||||||
isBatchCook: boolean;
|
isBatchCook: boolean;
|
||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import Link from "next/link";
|
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 { VariationsButton } from "@/components/recipe/variations-button";
|
||||||
import { TranslateButton } from "@/components/recipe/translate-button";
|
import { TranslateButton } from "@/components/recipe/translate-button";
|
||||||
import { AddToShoppingListButton } from "@/components/recipe/add-to-shopping-list-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 { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
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({
|
const recipe = await db.query.recipes.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(recipes.id, id),
|
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" };
|
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;
|
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
||||||
|
|
||||||
export default async function RecipePage({ params }: Params) {
|
export default async function RecipePage({ params }: Params) {
|
||||||
@@ -73,7 +74,7 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
db.query.recipes.findFirst({
|
db.query.recipes.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(recipes.id, id),
|
eq(recipes.id, id),
|
||||||
or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"]))
|
visibleToViewer(session.user.id)
|
||||||
),
|
),
|
||||||
with: {
|
with: {
|
||||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
|||||||
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
||||||
const offset = (page - 1) * PAGE_SIZE;
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
|
const visibilityFilter = visibility && ["private", "unlisted", "public", "followers"].includes(visibility)
|
||||||
? (visibility as "private" | "unlisted" | "public")
|
? (visibility as "private" | "unlisted" | "public" | "followers")
|
||||||
: undefined;
|
: undefined;
|
||||||
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
|
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
|
||||||
? (difficulty as "easy" | "medium" | "hard")
|
? (difficulty as "easy" | "medium" | "hard")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const PAGE_SIZE = 100;
|
|||||||
const VISIBILITY_COLORS = {
|
const VISIBILITY_COLORS = {
|
||||||
public: "default",
|
public: "default",
|
||||||
unlisted: "outline",
|
unlisted: "outline",
|
||||||
|
followers: "outline",
|
||||||
private: "secondary",
|
private: "secondary",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const UpdateRecipeSchema = z.object({
|
|||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
baseServings: z.number().int().min(1).max(100).optional(),
|
baseServings: z.number().int().min(1).max(100).optional(),
|
||||||
recipeType: z.enum(["dish", "drink"]).optional(),
|
recipeType: z.enum(["dish", "drink"]).optional(),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
||||||
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||||
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
|
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const BulkDeleteSchema = z.object({
|
|||||||
|
|
||||||
const BulkUpdateSchema = z.object({
|
const BulkUpdateSchema = z.object({
|
||||||
ids: z.array(z.string()).min(1).max(100),
|
ids: z.array(z.string()).min(1).max(100),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
|
||||||
addTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
addTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
||||||
removeTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
removeTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const CreateRecipeSchema = z.object({
|
|||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
baseServings: z.number().int().min(1).max(100).default(4),
|
baseServings: z.number().int().min(1).max(100).default(4),
|
||||||
recipeType: z.enum(["dish", "drink"]).default("dish"),
|
recipeType: z.enum(["dish", "drink"]).default("dish"),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
prepMins: z.number().int().min(0).max(1440).optional(),
|
prepMins: z.number().int().min(0).max(1440).optional(),
|
||||||
cookMins: z.number().int().min(0).max(1440).optional(),
|
cookMins: z.number().int().min(0).max(1440).optional(),
|
||||||
@@ -80,7 +80,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 100);
|
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 100);
|
||||||
const offset = parseInt(searchParams.get("offset") ?? "0");
|
const offset = parseInt(searchParams.get("offset") ?? "0");
|
||||||
const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | null;
|
const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | "followers" | null;
|
||||||
const recipeType = searchParams.get("recipeType") as "dish" | "drink" | null;
|
const recipeType = searchParams.get("recipeType") as "dish" | "drink" | null;
|
||||||
|
|
||||||
const conditions = [eq(recipes.authorId, session!.user.id)];
|
const conditions = [eq(recipes.authorId, session!.user.id)];
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ export default async function RecipePrintPage({ params }: Params) {
|
|||||||
|
|
||||||
if (!recipe) notFound();
|
if (!recipe) notFound();
|
||||||
|
|
||||||
// /r/[id] resolves for both "public" and "unlisted" (see app/r/[id]/page.tsx)
|
// /r/[id] resolves anonymously for "public" and "unlisted" only (see
|
||||||
// — only "private" has no scannable link to offer.
|
// app/r/[id]/page.tsx) — "private" and "followers" have no link a random
|
||||||
const shareUrl = recipe.visibility !== "private"
|
// scanner of this QR code could actually open.
|
||||||
|
const shareUrl = recipe.visibility === "public" || recipe.visibility === "unlisted"
|
||||||
? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/r/${id}`
|
? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/r/${id}`
|
||||||
: null;
|
: null;
|
||||||
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
|
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Link from "next/link";
|
|||||||
import { Clock, Users, ChefHat, Globe } from "lucide-react";
|
import { Clock, Users, ChefHat, Globe } from "lucide-react";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, eq } from "@epicure/db";
|
import { db, recipes, eq } from "@epicure/db";
|
||||||
|
import { isFollowing } from "@/lib/social-follows";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -54,6 +55,16 @@ export default async function PublicRecipePage({ params }: Params) {
|
|||||||
|
|
||||||
if (!recipe) notFound();
|
if (!recipe) notFound();
|
||||||
|
|
||||||
|
// "Followers only" recipes are excluded from the anonymous-accessible query
|
||||||
|
// above only by being neither public nor unlisted — but ne("private") still
|
||||||
|
// lets them through. This route has no session-based access control at all
|
||||||
|
// otherwise, so gate them explicitly: the author, or a signed-in follower.
|
||||||
|
if (recipe.visibility === "followers") {
|
||||||
|
const viewerId = session?.user?.id;
|
||||||
|
const allowed = !!viewerId && (viewerId === recipe.authorId || await isFollowing(viewerId, recipe.authorId));
|
||||||
|
if (!allowed) notFound();
|
||||||
|
}
|
||||||
|
|
||||||
const cover = recipe.photos.find((p) => p.isCover) ?? recipe.photos[0];
|
const cover = recipe.photos.find((p) => p.isCover) ?? recipe.photos[0];
|
||||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
const activeTags = Object.entries(recipe.dietaryTags ?? {})
|
const activeTags = Object.entries(recipe.dietaryTags ?? {})
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type Recipe = {
|
|||||||
prepMins: number | null;
|
prepMins: number | null;
|
||||||
cookMins: number | null;
|
cookMins: number | null;
|
||||||
difficulty: "easy" | "medium" | "hard" | null;
|
difficulty: "easy" | "medium" | "hard" | null;
|
||||||
visibility: "private" | "unlisted" | "public";
|
visibility: "private" | "unlisted" | "public" | "followers";
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
sourceUrl?: string | null;
|
sourceUrl?: string | null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
@@ -16,7 +16,7 @@ type Recipe = {
|
|||||||
prepMins: number | null;
|
prepMins: number | null;
|
||||||
cookMins: number | null;
|
cookMins: number | null;
|
||||||
difficulty: "easy" | "medium" | "hard" | null;
|
difficulty: "easy" | "medium" | "hard" | null;
|
||||||
visibility: "private" | "unlisted" | "public";
|
visibility: "private" | "unlisted" | "public" | "followers";
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
sourceUrl?: string | null;
|
sourceUrl?: string | null;
|
||||||
@@ -27,6 +27,7 @@ const VISIBILITY_ICON = {
|
|||||||
private: Lock,
|
private: Lock,
|
||||||
unlisted: Link2,
|
unlisted: Link2,
|
||||||
public: Globe,
|
public: Globe,
|
||||||
|
followers: UserCheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DIFFICULTY_COLOR = {
|
const DIFFICULTY_COLOR = {
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ type RecipeFormProps = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
baseServings?: number;
|
baseServings?: number;
|
||||||
recipeType?: "dish" | "drink";
|
recipeType?: "dish" | "drink";
|
||||||
visibility?: "private" | "unlisted" | "public";
|
visibility?: "private" | "unlisted" | "public" | "followers";
|
||||||
difficulty?: "easy" | "medium" | "hard" | null;
|
difficulty?: "easy" | "medium" | "hard" | null;
|
||||||
prepMins?: number | null;
|
prepMins?: number | null;
|
||||||
cookMins?: number | null;
|
cookMins?: number | null;
|
||||||
@@ -125,7 +125,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
const [description, setDescription] = useState(defaultValues?.description ?? "");
|
const [description, setDescription] = useState(defaultValues?.description ?? "");
|
||||||
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
|
const [baseServings, setBaseServings] = useState(String(defaultValues?.baseServings ?? 4));
|
||||||
const [recipeType, setRecipeType] = useState<"dish" | "drink">(defaultValues?.recipeType ?? "dish");
|
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 [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? "");
|
||||||
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
|
const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? ""));
|
||||||
const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? ""));
|
const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? ""));
|
||||||
@@ -486,10 +486,11 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
|||||||
<select
|
<select
|
||||||
id="visibility"
|
id="visibility"
|
||||||
value={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"
|
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="private">{t_recipe("visibility.private")}</option>
|
||||||
|
<option value="followers">{t_recipe("visibility.followers")}</option>
|
||||||
<option value="unlisted">{t_recipe("visibility.unlisted")}</option>
|
<option value="unlisted">{t_recipe("visibility.unlisted")}</option>
|
||||||
<option value="public">{t_recipe("visibility.public")}</option>
|
<option value="public">{t_recipe("visibility.public")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||||
@@ -17,7 +17,7 @@ export type GridCardRecipe = {
|
|||||||
prepMins: number | null;
|
prepMins: number | null;
|
||||||
cookMins: number | null;
|
cookMins: number | null;
|
||||||
difficulty: "easy" | "medium" | "hard" | null;
|
difficulty: "easy" | "medium" | "hard" | null;
|
||||||
visibility: "private" | "unlisted" | "public";
|
visibility: "private" | "unlisted" | "public" | "followers";
|
||||||
tags: string[];
|
tags: string[];
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
isFavorited?: boolean;
|
isFavorited?: boolean;
|
||||||
@@ -29,7 +29,7 @@ export type GridCardRecipe = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
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 {
|
function hostnameOf(url: string): string {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
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 { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog";
|
||||||
import { BulkTagsDialog } from "@/components/recipe/bulk-tags-dialog";
|
import { BulkTagsDialog } from "@/components/recipe/bulk-tags-dialog";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
@@ -41,7 +41,7 @@ type Recipe = {
|
|||||||
prepMins: number | null;
|
prepMins: number | null;
|
||||||
cookMins: number | null;
|
cookMins: number | null;
|
||||||
difficulty: "easy" | "medium" | "hard" | null;
|
difficulty: "easy" | "medium" | "hard" | null;
|
||||||
visibility: "private" | "unlisted" | "public";
|
visibility: "private" | "unlisted" | "public" | "followers";
|
||||||
tags: string[];
|
tags: string[];
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
@@ -64,7 +64,7 @@ type ViewMode = "grid" | "list" | "compact";
|
|||||||
const VIEW_STORAGE_KEY = "epicure-recipes-view";
|
const VIEW_STORAGE_KEY = "epicure-recipes-view";
|
||||||
|
|
||||||
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
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 {
|
function hostnameOf(url: string): string {
|
||||||
try {
|
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);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/v1/recipes/bulk", {
|
const res = await fetch("/api/v1/recipes/bulk", {
|
||||||
@@ -588,6 +588,9 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
|||||||
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
|
||||||
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> {t("makeUnlisted")}
|
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> {t("makeUnlisted")}
|
||||||
</DropdownMenuItem>
|
</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"); }}>
|
<DropdownMenuItem onClick={() => { void bulkSetVisibility("private"); }}>
|
||||||
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> {t("makePrivate")}
|
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> {t("makePrivate")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ const SORT_KEYS: Record<string, string> = {
|
|||||||
const VISIBILITY_KEYS: Record<string, string> = {
|
const VISIBILITY_KEYS: Record<string, string> = {
|
||||||
"": "all",
|
"": "all",
|
||||||
private: "private",
|
private: "private",
|
||||||
|
followers: "followers",
|
||||||
unlisted: "unlisted",
|
unlisted: "unlisted",
|
||||||
public: "public",
|
public: "public",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function ShareRecipeButton({ recipeId, visibility }: { recipeId: string;
|
|||||||
toast.error(t("shareCopyFailed"));
|
toast.error(t("shareCopyFailed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (visibility === "private") {
|
if (visibility === "private" || visibility === "followers") {
|
||||||
toast.info(t("shareNotPublicNotice"));
|
toast.info(t("shareNotPublicNotice"));
|
||||||
} else {
|
} else {
|
||||||
toast.success(t("shareLinkCopied"));
|
toast.success(t("shareLinkCopied"));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.40.0";
|
export const APP_VERSION = "0.41.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.41.0",
|
||||||
|
date: "2026-07-17 14:45",
|
||||||
|
added: [
|
||||||
|
"New recipe visibility option: \"Followers only\" — visible to people who follow you, hidden from public browsing, search, and anonymous share links. Available in the recipe editor, bulk visibility actions, and the Recipes filter.",
|
||||||
|
],
|
||||||
|
notes: "Recipes you already own show up in your own profile/recipe list regardless of visibility, as before. Public profile pages and search results don't yet surface a viewer's followers-only recipes from that author — those remain reachable only via the recipe's direct link or the Following feed.",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.40.0",
|
version: "0.40.0",
|
||||||
date: "2026-07-17 14:00",
|
date: "2026-07-17 14:00",
|
||||||
|
|||||||
+10
-10
@@ -58,7 +58,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
|
|
||||||
const RecipeRef = registry.register("Recipe", z.object({
|
const RecipeRef = registry.register("Recipe", z.object({
|
||||||
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
||||||
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
|
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
|
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
|
||||||
@@ -72,7 +72,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
|
|
||||||
const RecipeSummaryRef = registry.register("RecipeSummary", z.object({
|
const RecipeSummaryRef = registry.register("RecipeSummary", z.object({
|
||||||
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
||||||
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
|
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
||||||
@@ -87,7 +87,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
baseServings: z.number().int().min(1).max(100).default(4),
|
baseServings: z.number().int().min(1).max(100).default(4),
|
||||||
recipeType: z.enum(["dish", "drink"]).default("dish"),
|
recipeType: z.enum(["dish", "drink"]).default("dish"),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
prepMins: z.number().int().min(0).max(1440).optional(),
|
prepMins: z.number().int().min(0).max(1440).optional(),
|
||||||
cookMins: z.number().int().min(0).max(1440).optional(),
|
cookMins: z.number().int().min(0).max(1440).optional(),
|
||||||
@@ -130,7 +130,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
description: z.string().max(2000).nullable().optional(),
|
description: z.string().max(2000).nullable().optional(),
|
||||||
baseServings: z.number().int().min(1).max(100).optional(),
|
baseServings: z.number().int().min(1).max(100).optional(),
|
||||||
recipeType: z.enum(["dish", "drink"]).optional(),
|
recipeType: z.enum(["dish", "drink"]).optional(),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
||||||
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||||
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
|
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||||
@@ -247,7 +247,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
const idParam = z.object({ id: z.string() });
|
const idParam = z.object({ id: z.string() });
|
||||||
const weekStartParam = z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") });
|
const weekStartParam = z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") });
|
||||||
|
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public"]).optional(), recipeType: z.enum(["dish", "drink"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(), recipeType: z.enum(["dish", "drink"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
@@ -262,7 +262,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
const BulkIdsRef = registry.register("BulkIds", z.object({ ids: z.array(z.string()).min(1).max(100) }));
|
const BulkIdsRef = registry.register("BulkIds", z.object({ ids: z.array(z.string()).min(1).max(100) }));
|
||||||
const BulkUpdateRecipesRef = registry.register("BulkUpdateRecipes", z.object({
|
const BulkUpdateRecipesRef = registry.register("BulkUpdateRecipes", z.object({
|
||||||
ids: z.array(z.string()).min(1).max(100),
|
ids: z.array(z.string()).min(1).max(100),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
|
||||||
addTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
addTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
||||||
removeTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
removeTags: z.array(z.string().min(1).max(40)).max(20).optional(),
|
||||||
}));
|
}));
|
||||||
@@ -319,7 +319,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
const FeedRecipeRef = registry.register("FeedRecipe", z.object({
|
const FeedRecipeRef = registry.register("FeedRecipe", z.object({
|
||||||
id: z.string(), title: z.string(), description: z.string().nullable(),
|
id: z.string(), title: z.string(), description: z.string().nullable(),
|
||||||
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||||
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
||||||
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(),
|
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(),
|
||||||
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
||||||
@@ -597,7 +597,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
const TrendingRecipeRef = registry.register("TrendingRecipe", z.object({
|
const TrendingRecipeRef = registry.register("TrendingRecipe", z.object({
|
||||||
id: z.string(), title: z.string(), description: z.string().nullable(),
|
id: z.string(), title: z.string(), description: z.string().nullable(),
|
||||||
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||||
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
|
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
|
||||||
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
||||||
favoriteCount: z.number().int(),
|
favoriteCount: z.number().int(),
|
||||||
@@ -605,7 +605,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
const ForYouRecipeRef = registry.register("ForYouRecipe", z.object({
|
const ForYouRecipeRef = registry.register("ForYouRecipe", z.object({
|
||||||
id: z.string(), title: z.string(), description: z.string().nullable(),
|
id: z.string(), title: z.string(), description: z.string().nullable(),
|
||||||
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||||
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
|
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
|
||||||
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
||||||
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
||||||
@@ -620,7 +620,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
const SearchResultRef = registry.register("SearchResult", z.object({
|
const SearchResultRef = registry.register("SearchResult", z.object({
|
||||||
id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||||
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]), tags: z.array(z.string()), isBatchCook: z.boolean(),
|
visibility: z.enum(["private", "unlisted", "public", "followers"]), tags: z.array(z.string()), isBatchCook: z.boolean(),
|
||||||
sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
||||||
authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(),
|
authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(),
|
||||||
avgRating: z.number().nullable(), ratingCount: z.number().int(),
|
avgRating: z.number().nullable(), ratingCount: z.number().int(),
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { db, userFollows, and, eq } from "@epicure/db";
|
||||||
|
|
||||||
|
/** True if `followerId` follows `followingId`. Used to gate "followers only"
|
||||||
|
* visibility content — the same leftJoin+isNotNull idiom used elsewhere
|
||||||
|
* (e.g. the for-you feed's private-author check) collapsed into a single
|
||||||
|
* existence check for the single-recipe case. */
|
||||||
|
export async function isFollowing(followerId: string, followingId: string): Promise<boolean> {
|
||||||
|
if (followerId === followingId) return true;
|
||||||
|
const [row] = await db
|
||||||
|
.select({ followerId: userFollows.followerId })
|
||||||
|
.from(userFollows)
|
||||||
|
.where(and(eq(userFollows.followerId, followerId), eq(userFollows.followingId, followingId)))
|
||||||
|
.limit(1);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { recipes, userFollows, eq, or, and, inArray, sql } from "@epicure/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drizzle condition: true when `viewerId` may view a given recipe row — the
|
||||||
|
* author, always; `public`/`unlisted` recipes, to anyone; `followers`-only
|
||||||
|
* recipes, only to viewers who follow the author. Meant to be `and()`ed with
|
||||||
|
* an `eq(recipes.id, id)` (or similar) in a single query, so the DB does the
|
||||||
|
* filtering rather than fetching content the viewer can't see.
|
||||||
|
*/
|
||||||
|
export function visibleToViewer(viewerId: string) {
|
||||||
|
return or(
|
||||||
|
eq(recipes.authorId, viewerId),
|
||||||
|
inArray(recipes.visibility, ["public", "unlisted"]),
|
||||||
|
and(
|
||||||
|
eq(recipes.visibility, "followers"),
|
||||||
|
sql`exists (select 1 from ${userFollows} where ${userFollows.followerId} = ${viewerId} and ${userFollows.followingId} = ${recipes.authorId})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -223,9 +223,11 @@
|
|||||||
"visibilityMenuLabel": "Visibility",
|
"visibilityMenuLabel": "Visibility",
|
||||||
"makePublic": "Make public",
|
"makePublic": "Make public",
|
||||||
"makeUnlisted": "Make unlisted",
|
"makeUnlisted": "Make unlisted",
|
||||||
|
"makeFollowersOnly": "Make followers only",
|
||||||
"makePrivate": "Make private",
|
"makePrivate": "Make private",
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"private": "Private",
|
"private": "Private",
|
||||||
|
"followers": "Followers only",
|
||||||
"unlisted": "Unlisted",
|
"unlisted": "Unlisted",
|
||||||
"public": "Public"
|
"public": "Public"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -223,9 +223,11 @@
|
|||||||
"visibilityMenuLabel": "Visibilité",
|
"visibilityMenuLabel": "Visibilité",
|
||||||
"makePublic": "Rendre publique",
|
"makePublic": "Rendre publique",
|
||||||
"makeUnlisted": "Rendre non répertoriée",
|
"makeUnlisted": "Rendre non répertoriée",
|
||||||
|
"makeFollowersOnly": "Réserver aux abonnés",
|
||||||
"makePrivate": "Rendre privée",
|
"makePrivate": "Rendre privée",
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"private": "Privée",
|
"private": "Privée",
|
||||||
|
"followers": "Abonnés uniquement",
|
||||||
"unlisted": "Non répertoriée",
|
"unlisted": "Non répertoriée",
|
||||||
"public": "Publique"
|
"public": "Publique"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.40.0",
|
"version": "0.41.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.40.0",
|
"version": "0.41.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."visibility" ADD VALUE 'followers';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -288,6 +288,13 @@
|
|||||||
"when": 1784036519678,
|
"when": 1784036519678,
|
||||||
"tag": "0040_loving_spot",
|
"tag": "0040_loving_spot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784300027960,
|
||||||
|
"tag": "0041_perfect_dexter_bennett",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
|
|
||||||
export const visibilityEnum = pgEnum("visibility", ["private", "unlisted", "public"]);
|
export const visibilityEnum = pgEnum("visibility", ["private", "unlisted", "public", "followers"]);
|
||||||
export const difficultyEnum = pgEnum("difficulty", ["easy", "medium", "hard"]);
|
export const difficultyEnum = pgEnum("difficulty", ["easy", "medium", "hard"]);
|
||||||
export const recipeTypeEnum = pgEnum("recipe_type", ["dish", "drink"]);
|
export const recipeTypeEnum = pgEnum("recipe_type", ["dish", "drink"]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user