diff --git a/apps/web/components/recipe/recipe-card.tsx b/apps/web/components/recipe/recipe-card.tsx index e5e367b..be75b0e 100644 --- a/apps/web/components/recipe/recipe-card.tsx +++ b/apps/web/components/recipe/recipe-card.tsx @@ -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 } from "lucide-react"; +import { Clock, Users, Lock, Globe, Link2, ExternalLink } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; import { getPublicUrl } from "@/lib/storage"; @@ -19,6 +19,7 @@ type Recipe = { visibility: "private" | "unlisted" | "public"; updatedAt: Date; photos?: Array<{ storageKey: string; isCover: boolean }>; + sourceUrl?: string | null; }; const VISIBILITY_ICON = { @@ -85,7 +86,10 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) { )} - +
+ {recipe.sourceUrl && } + +
diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 99178c6..ffe93d7 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -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 } 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 { Button, buttonVariants } from "@/components/ui/button"; import { @@ -47,6 +47,7 @@ type Recipe = { isFavorited?: boolean; isBatchCook?: boolean; dishCount?: number; + sourceUrl?: string | null; }; /** 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 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 }) { const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0]; return ( @@ -152,6 +161,14 @@ function GridCard({ recipe, selected, selectMode }: { recipe: Recipe; selected: )}
+ {recipe.sourceUrl && ( + + + } /> + {t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })} + + + )} {recipe.isBatchCook && ( @@ -199,6 +216,14 @@ function ListRow({ recipe, selected, selectMode }: { recipe: Recipe; selected: b {recipe.title}
+ {recipe.sourceUrl && ( + + + } /> + {t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })} + + + )} {recipe.isBatchCook && ( @@ -276,6 +301,14 @@ function CompactRow({ recipe, selected, selectMode }: { recipe: Recipe; selected {recipe.updatedAt.toLocaleDateString(locale, { month: "short", day: "numeric" })} + {recipe.sourceUrl && ( + + + } /> + {t("importedFrom", { host: hostnameOf(recipe.sourceUrl) })} + + + )} {recipe.isBatchCook && ( diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index b201792..e8a6e92 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -56,6 +56,7 @@ "recipe": { "byAuthor": "by {name}", "source": "Source", + "importedFrom": "Imported from {host}", "share": "Share", "shareLinkCopied": "Link copied to clipboard", "shareCopyFailed": "Failed to copy link", @@ -351,6 +352,10 @@ "sun": "Sunday" } }, + "publicShoppingList": { + "sharedList": "Shared shopping list", + "wantYourOwn": "Want your own shopping lists?" + }, "publicRecipe": { "publicRecipeBy": "Public recipe by", "viewInLibrary": "View in your library", @@ -960,7 +965,13 @@ "loadMembersFailed": "Could not load members", "memberRemoved": "Member removed", "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": { "followed": "Following", @@ -1055,6 +1066,11 @@ }, "settingsForm": { "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", "email": "Email", "emailReadOnly": "Email cannot be changed here.", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index d128e2c..34e0b46 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -56,6 +56,7 @@ "recipe": { "byAuthor": "par {name}", "source": "Source", + "importedFrom": "Importée depuis {host}", "share": "Partager", "shareLinkCopied": "Lien copié dans le presse-papiers", "shareCopyFailed": "Échec de la copie du lien", @@ -351,6 +352,10 @@ "sun": "Dimanche" } }, + "publicShoppingList": { + "sharedList": "Liste de courses partagée", + "wantYourOwn": "Envie de vos propres listes de courses ?" + }, "publicRecipe": { "publicRecipeBy": "Recette publique par", "viewInLibrary": "Voir dans votre bibliothèque", @@ -948,7 +953,13 @@ "loadMembersFailed": "Impossible de charger les membres", "memberRemoved": "Membre retiré", "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": { "followed": "Abonné", @@ -1043,6 +1054,11 @@ }, "settingsForm": { "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", "email": "E-mail", "emailReadOnly": "L'e-mail ne peut pas être modifié ici.",