feat: show an "imported from" badge on recipe cards
sourceUrl was already persisted and shown on the recipe detail page, but never surfaced on cards in the grid/list/compact views or the simpler RecipeCard (collections) — added a small external-link icon with a tooltip naming the source hostname, sized identically to the existing batch-cook badge so it doesn't reintroduce the compact-view alignment bug fixed earlier this session.
This commit is contained in:
@@ -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 } from "lucide-react";
|
import { Clock, Users, Lock, Globe, Link2, ExternalLink } 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";
|
||||||
@@ -19,6 +19,7 @@ type Recipe = {
|
|||||||
visibility: "private" | "unlisted" | "public";
|
visibility: "private" | "unlisted" | "public";
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
photos?: Array<{ storageKey: string; isCover: boolean }>;
|
||||||
|
sourceUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISIBILITY_ICON = {
|
const VISIBILITY_ICON = {
|
||||||
@@ -85,7 +86,10 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{recipe.sourceUrl && <ExternalLink className="h-3 w-3" />}
|
||||||
<VisibilityIcon className="h-3 w-3" />
|
<VisibilityIcon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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 } from "lucide-react";
|
import { Trash2, Globe, Lock, Link2, X, Check, ListChecks, LayoutGrid, List, Rows3, FolderPlus, ChefHat, ExternalLink } from "lucide-react";
|
||||||
import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog";
|
import { AddToCollectionDialog } from "@/components/recipe/add-to-collection-dialog";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,7 @@ type Recipe = {
|
|||||||
isFavorited?: boolean;
|
isFavorited?: boolean;
|
||||||
isBatchCook?: boolean;
|
isBatchCook?: boolean;
|
||||||
dishCount?: number;
|
dishCount?: number;
|
||||||
|
sourceUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Stops the click from bubbling to the card's own Link/select handler. */
|
/** Stops the click from bubbling to the card's own Link/select handler. */
|
||||||
@@ -63,6 +64,14 @@ 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 };
|
||||||
|
|
||||||
|
function hostnameOf(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Recipe; selectMode: boolean; selected: boolean; className?: string }) {
|
function RecipeThumb({ recipe, selectMode, selected, className }: { recipe: Recipe; selectMode: boolean; selected: boolean; className?: string }) {
|
||||||
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
||||||
return (
|
return (
|
||||||
@@ -152,6 +161,14 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{recipe.sourceUrl && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={<span className="p-1.5 inline-flex"><ExternalLink className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
|
||||||
|
<TooltipContent>{t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{recipe.isBatchCook && (
|
{recipe.isBatchCook && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -199,6 +216,14 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b
|
|||||||
{recipe.title}
|
{recipe.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{recipe.sourceUrl && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={<span className="p-1.5 inline-flex"><ExternalLink className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
|
||||||
|
<TooltipContent>{t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{recipe.isBatchCook && (
|
{recipe.isBatchCook && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -276,6 +301,14 @@ function CompactRow({ recipe, selected, selectMode }: { recipe: Recipe; selected
|
|||||||
<span className="hidden md:inline text-xs text-muted-foreground shrink-0 w-16 text-right">
|
<span className="hidden md:inline text-xs text-muted-foreground shrink-0 w-16 text-right">
|
||||||
{recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })}
|
{recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
|
{recipe.sourceUrl && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger render={<span className="p-1.5 inline-flex shrink-0"><ExternalLink className="h-3.5 w-3.5 text-muted-foreground" /></span>} />
|
||||||
|
<TooltipContent>{t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
{recipe.isBatchCook && (
|
{recipe.isBatchCook && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"recipe": {
|
"recipe": {
|
||||||
"byAuthor": "by {name}",
|
"byAuthor": "by {name}",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
|
"importedFrom": "Imported from {host}",
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
"shareLinkCopied": "Link copied to clipboard",
|
"shareLinkCopied": "Link copied to clipboard",
|
||||||
"shareCopyFailed": "Failed to copy link",
|
"shareCopyFailed": "Failed to copy link",
|
||||||
@@ -351,6 +352,10 @@
|
|||||||
"sun": "Sunday"
|
"sun": "Sunday"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"publicShoppingList": {
|
||||||
|
"sharedList": "Shared shopping list",
|
||||||
|
"wantYourOwn": "Want your own shopping lists?"
|
||||||
|
},
|
||||||
"publicRecipe": {
|
"publicRecipe": {
|
||||||
"publicRecipeBy": "Public recipe by",
|
"publicRecipeBy": "Public recipe by",
|
||||||
"viewInLibrary": "View in your library",
|
"viewInLibrary": "View in your library",
|
||||||
@@ -960,7 +965,13 @@
|
|||||||
"loadMembersFailed": "Could not load members",
|
"loadMembersFailed": "Could not load members",
|
||||||
"memberRemoved": "Member removed",
|
"memberRemoved": "Member removed",
|
||||||
"removeMemberFailed": "Could not remove member",
|
"removeMemberFailed": "Could not remove member",
|
||||||
"enterEmail": "Enter an email address"
|
"enterEmail": "Enter an email address",
|
||||||
|
"publicLinkTitle": "Public link",
|
||||||
|
"publicLinkDescription": "Anyone with the link can view this list.",
|
||||||
|
"publicLinkToggleFailed": "Could not update the public link",
|
||||||
|
"copyLink": "Copy link",
|
||||||
|
"linkCopied": "Link copied",
|
||||||
|
"copyLinkFailed": "Could not copy link"
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
"followed": "Following",
|
"followed": "Following",
|
||||||
@@ -1055,6 +1066,11 @@
|
|||||||
},
|
},
|
||||||
"settingsForm": {
|
"settingsForm": {
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
|
"changePhoto": "Change photo",
|
||||||
|
"removePhoto": "Remove photo",
|
||||||
|
"avatarUploadSuccess": "Profile photo updated",
|
||||||
|
"avatarUploadFailed": "Failed to update profile photo",
|
||||||
|
"avatarRemoved": "Profile photo removed",
|
||||||
"displayName": "Display name",
|
"displayName": "Display name",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"emailReadOnly": "Email cannot be changed here.",
|
"emailReadOnly": "Email cannot be changed here.",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"recipe": {
|
"recipe": {
|
||||||
"byAuthor": "par {name}",
|
"byAuthor": "par {name}",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
|
"importedFrom": "Importée depuis {host}",
|
||||||
"share": "Partager",
|
"share": "Partager",
|
||||||
"shareLinkCopied": "Lien copié dans le presse-papiers",
|
"shareLinkCopied": "Lien copié dans le presse-papiers",
|
||||||
"shareCopyFailed": "Échec de la copie du lien",
|
"shareCopyFailed": "Échec de la copie du lien",
|
||||||
@@ -351,6 +352,10 @@
|
|||||||
"sun": "Dimanche"
|
"sun": "Dimanche"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"publicShoppingList": {
|
||||||
|
"sharedList": "Liste de courses partagée",
|
||||||
|
"wantYourOwn": "Envie de vos propres listes de courses ?"
|
||||||
|
},
|
||||||
"publicRecipe": {
|
"publicRecipe": {
|
||||||
"publicRecipeBy": "Recette publique par",
|
"publicRecipeBy": "Recette publique par",
|
||||||
"viewInLibrary": "Voir dans votre bibliothèque",
|
"viewInLibrary": "Voir dans votre bibliothèque",
|
||||||
@@ -948,7 +953,13 @@
|
|||||||
"loadMembersFailed": "Impossible de charger les membres",
|
"loadMembersFailed": "Impossible de charger les membres",
|
||||||
"memberRemoved": "Membre retiré",
|
"memberRemoved": "Membre retiré",
|
||||||
"removeMemberFailed": "Impossible de retirer le membre",
|
"removeMemberFailed": "Impossible de retirer le membre",
|
||||||
"enterEmail": "Saisissez une adresse e-mail"
|
"enterEmail": "Saisissez une adresse e-mail",
|
||||||
|
"publicLinkTitle": "Lien public",
|
||||||
|
"publicLinkDescription": "Toute personne disposant du lien peut voir cette liste.",
|
||||||
|
"publicLinkToggleFailed": "Impossible de mettre à jour le lien public",
|
||||||
|
"copyLink": "Copier le lien",
|
||||||
|
"linkCopied": "Lien copié",
|
||||||
|
"copyLinkFailed": "Impossible de copier le lien"
|
||||||
},
|
},
|
||||||
"social": {
|
"social": {
|
||||||
"followed": "Abonné",
|
"followed": "Abonné",
|
||||||
@@ -1043,6 +1054,11 @@
|
|||||||
},
|
},
|
||||||
"settingsForm": {
|
"settingsForm": {
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
|
"changePhoto": "Changer la photo",
|
||||||
|
"removePhoto": "Supprimer la photo",
|
||||||
|
"avatarUploadSuccess": "Photo de profil mise à jour",
|
||||||
|
"avatarUploadFailed": "Échec de la mise à jour de la photo de profil",
|
||||||
|
"avatarRemoved": "Photo de profil supprimée",
|
||||||
"displayName": "Nom d'affichage",
|
"displayName": "Nom d'affichage",
|
||||||
"email": "E-mail",
|
"email": "E-mail",
|
||||||
"emailReadOnly": "L'e-mail ne peut pas être modifié ici.",
|
"emailReadOnly": "L'e-mail ne peut pas être modifié ici.",
|
||||||
|
|||||||
Reference in New Issue
Block a user