fix: mobile layout fixes, i18n coverage, and recipe share link
Mobile:
- Recipes search bar full-width on mobile instead of capped narrow
- Cook mode ingredients panel stacks above the step instead of
squeezing it into a narrow column
- Version history Compare/Restore buttons wrap onto their own row
- Recipe edit ingredient fields wrap instead of forcing horizontal
scroll on narrow viewports
i18n: translates remaining hardcoded strings across recipes
filter/sort, adapt-recipe and AI variations dialogs, the full
settings section (sidebar + 6 sub-pages + BYOK/model-prefs/
API-keys/webhooks managers), explore tab, collections (new/fork/
share dialogs), meal planning (planner, AI generation phases, new
shopping list, shared-plan view), photo import, recipe bulk-select
toolbar, and recipe action-button tooltips. Also fixes the recipes
page subtitle, which wasn't just unworded but missing its {count}
interpolation entirely — it always rendered as the bare word
"results" regardless of how many recipes existed.
Feature: adds a ShareRecipeButton that copies the public /r/{id}
link to the clipboard, with a notice when the recipe isn't Public
yet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ export function AdaptRecipeButton({
|
||||
ingredients: Ingredient[];
|
||||
}) {
|
||||
const t = useTranslations("recipe");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [excluded, setExcluded] = useState<Set<string>>(new Set());
|
||||
@@ -76,7 +77,7 @@ export function AdaptRecipeButton({
|
||||
|
||||
const { id, adaptationNotes: notes } = await res.json() as { id: string; adaptationNotes: string };
|
||||
setAdaptationNotes(notes);
|
||||
toast.success("Adapted recipe saved as draft");
|
||||
toast.success(t("adapted"));
|
||||
setOpen(false);
|
||||
reset();
|
||||
router.push(`/recipes/${id}/edit`);
|
||||
@@ -96,7 +97,7 @@ export function AdaptRecipeButton({
|
||||
<Wand2 className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>Adapt</TooltipContent>
|
||||
<TooltipContent>{t("adaptTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -105,10 +106,10 @@ export function AdaptRecipeButton({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Wand2 className="h-5 w-5 text-primary" />
|
||||
Adapt this recipe
|
||||
{t("adaptTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tap ingredients to exclude them. AI will find the best substitutes while preserving the dish.
|
||||
{t("adaptDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -116,10 +117,10 @@ export function AdaptRecipeButton({
|
||||
{/* Ingredient chips */}
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Exclude ingredients
|
||||
{t("excludeIngredients")}
|
||||
{excluded.size > 0 && (
|
||||
<span className="ml-2 text-xs text-destructive font-normal">
|
||||
{excluded.size} excluded
|
||||
{t("excludedCount", { count: excluded.size })}
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
@@ -149,8 +150,8 @@ export function AdaptRecipeButton({
|
||||
{/* Free-text constraints */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extra-constraints">
|
||||
Additional constraints
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">optional</span>
|
||||
{t("additionalConstraints")}
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">{t("optional")}</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="extra-constraints"
|
||||
@@ -164,7 +165,7 @@ export function AdaptRecipeButton({
|
||||
|
||||
{adapting && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Adapting recipe — this may take 20–30 seconds…
|
||||
{t("adapting")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -174,17 +175,17 @@ export function AdaptRecipeButton({
|
||||
onClick={() => { setOpen(false); reset(); }}
|
||||
disabled={adapting}
|
||||
>
|
||||
Cancel
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
{excluded.size > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setExcluded(new Set())} disabled={adapting}>
|
||||
Clear
|
||||
{t("clearExclusions")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleAdapt} disabled={adapting || !hasConstraints}>
|
||||
{adapting
|
||||
? <><Loader2 className="h-4 w-4 animate-spin" />Adapting…</>
|
||||
: <><Wand2 className="h-4 w-4" />Adapt recipe</>
|
||||
? <><Loader2 className="h-4 w-4 animate-spin" />{t("adaptingButton")}</>
|
||||
: <><Wand2 className="h-4 w-4" />{t("adaptButton")}</>
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -72,6 +72,8 @@ export function AiGenerateDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations("ai.generate");
|
||||
const tCommon = useTranslations("common");
|
||||
const tRecipe = useTranslations("recipe");
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const [tab, setTab] = useState<Tab>("describe");
|
||||
@@ -177,18 +179,18 @@ export function AiGenerateDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
Generate recipe with AI
|
||||
{t("title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Describe a dish in words or snap a photo — AI builds the full recipe.
|
||||
{t("descriptionFull")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-1 p-1 bg-muted rounded-lg">
|
||||
{([
|
||||
{ key: "describe", label: "Describe", icon: Type },
|
||||
{ key: "photo", label: "From photo", icon: Camera },
|
||||
{ key: "describe", label: t("tabDescribe"), icon: Type },
|
||||
{ key: "photo", label: t("tabPhoto"), icon: Camera },
|
||||
] as { key: Tab; label: string; icon: React.ElementType }[]).map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
@@ -212,7 +214,7 @@ export function AiGenerateDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="ai-prompt">What do you want to cook?</Label>
|
||||
<Label htmlFor="ai-prompt">{t("prompt")}</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -223,7 +225,7 @@ export function AiGenerateDialog({
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Shuffle className="h-3 w-3" />
|
||||
Surprise me
|
||||
{t("surpriseMe")}
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -237,7 +239,7 @@ export function AiGenerateDialog({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Recipe language</Label>
|
||||
<Label>{t("language")}</Label>
|
||||
<Select value={language} onValueChange={(v) => v && setLanguage(v)} disabled={busy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -250,16 +252,16 @@ export function AiGenerateDialog({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Difficulty</Label>
|
||||
<Label>{t("difficulty")}</Label>
|
||||
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as typeof difficulty)} disabled={busy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("anyDifficulty")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Any</SelectItem>
|
||||
<SelectItem value="easy">Easy</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="hard">Hard</SelectItem>
|
||||
<SelectItem value="">{t("anyDifficulty")}</SelectItem>
|
||||
<SelectItem value="easy">{tRecipe("difficulty.easy")}</SelectItem>
|
||||
<SelectItem value="medium">{tRecipe("difficulty.medium")}</SelectItem>
|
||||
<SelectItem value="hard">{tRecipe("difficulty.hard")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -300,14 +302,14 @@ export function AiGenerateDialog({
|
||||
>
|
||||
<Upload className="h-8 w-8" />
|
||||
<div className="text-sm text-center">
|
||||
<span className="font-medium text-foreground">Click to upload</span> or drag a food photo
|
||||
<p className="text-xs mt-1">JPEG, PNG or WebP · AI will reverse-engineer the recipe</p>
|
||||
<span className="font-medium text-foreground">{t("uploadClick")}</span> {t("uploadDrag")}
|
||||
<p className="text-xs mt-1">{t("uploadFormats")}</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{photoPreview && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
AI will analyze this dish and infer ingredients, quantities, and cooking steps.
|
||||
{t("photoAnalysisNote")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -316,21 +318,21 @@ export function AiGenerateDialog({
|
||||
<FakeProgressBar
|
||||
active={busy}
|
||||
durationMs={tab === "photo" ? 12000 : 10000}
|
||||
label={busy ? (tab === "photo" ? "Analyzing dish photo…" : "Generating recipe…") : undefined}
|
||||
label={busy ? (tab === "photo" ? t("analyzePhoto") : t("generating")) : undefined}
|
||||
/>
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 justify-end pt-1">
|
||||
<Button variant="ghost" onClick={handleClose} disabled={busy}>
|
||||
Cancel
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={tab === "describe" ? () => { void handleDescribeGenerate(); } : () => { void handlePhotoGenerate(); }}
|
||||
disabled={!canSubmit || busy}
|
||||
>
|
||||
{busy ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />Analyzing…</>
|
||||
<><Loader2 className="h-4 w-4 animate-spin" />{t("analyzing")}</>
|
||||
) : (
|
||||
<><Sparkles className="h-4 w-4" />{tab === "photo" ? "Recognize dish" : "Generate"}</>
|
||||
<><Sparkles className="h-4 w-4" />{tab === "photo" ? t("recognizeDish") : t("button")}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
|
||||
const t = useTranslations("recipe");
|
||||
const tCommon = useTranslations("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
@@ -55,7 +56,7 @@ export function DeleteRecipeButton({ recipeId }: { recipeId: string }) {
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
<TooltipContent>{tCommon("delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Wine, Sparkles, Loader2, Coffee, Beer, GlassWater, Leaf } from "lucide-react";
|
||||
import { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||
import { toast } from "sonner";
|
||||
@@ -45,6 +46,7 @@ const TYPE_LABEL: Record<Drink["type"], string> = {
|
||||
};
|
||||
|
||||
export function DrinkPairingButton({ recipeId }: { recipeId: string }) {
|
||||
const t = useTranslations("recipe");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [drinks, setDrinks] = useState<Drink[]>([]);
|
||||
@@ -83,7 +85,7 @@ export function DrinkPairingButton({ recipeId }: { recipeId: string }) {
|
||||
<Wine className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>Drinks</TooltipContent>
|
||||
<TooltipContent>{t("drinksTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ export function MealPairingButton({ recipeId }: { recipeId: string }) {
|
||||
<UtensilsCrossed className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>Pair meal</TooltipContent>
|
||||
<TooltipContent>{t("pairMealTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
@@ -77,9 +77,9 @@ export function PhotoImportButton() {
|
||||
) : (
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Import from Photo
|
||||
{t("importFromPhoto")}
|
||||
</Button>
|
||||
<FakeProgressBar active={loading} durationMs={12000} label={loading ? "Analyzing photo…" : undefined} />
|
||||
<FakeProgressBar active={loading} durationMs={12000} label={loading ? t("analyzingPhoto") : undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Printer } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export function PrintButton({ recipeId }: { recipeId: string }) {
|
||||
const t = useTranslations("recipe");
|
||||
function handlePrint() {
|
||||
const win = window.open(`/print/${recipeId}`, "_blank", "width=800,height=900");
|
||||
win?.focus();
|
||||
@@ -18,7 +20,7 @@ export function PrintButton({ recipeId }: { recipeId: string }) {
|
||||
<Printer className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>Print</TooltipContent>
|
||||
<TooltipContent>{t("print")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -341,31 +341,31 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) {
|
||||
<Label>{t("ingredients")}</Label>
|
||||
<div className="space-y-2">
|
||||
{ingredients.map((ing, i) => (
|
||||
<div key={ing.id} className="flex gap-2 items-start">
|
||||
<div key={ing.id} className="flex flex-wrap gap-2 items-start">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground mt-2 cursor-grab shrink-0" />
|
||||
<Input
|
||||
value={ing.quantity}
|
||||
onChange={(e) => updateIngredient(i, { quantity: e.target.value })}
|
||||
placeholder={t("amount")}
|
||||
className="w-16 shrink-0"
|
||||
className="w-14 sm:w-16 shrink-0"
|
||||
/>
|
||||
<Input
|
||||
value={ing.unit}
|
||||
onChange={(e) => updateIngredient(i, { unit: e.target.value })}
|
||||
placeholder={t("unit")}
|
||||
className="w-24 shrink-0"
|
||||
className="w-16 sm:w-24 shrink-0"
|
||||
/>
|
||||
<Input
|
||||
value={ing.rawName}
|
||||
onChange={(e) => updateIngredient(i, { rawName: e.target.value })}
|
||||
placeholder={t("ingredient")}
|
||||
className="flex-1 min-w-0"
|
||||
className="flex-1 min-w-[100px]"
|
||||
/>
|
||||
<Input
|
||||
value={ing.note}
|
||||
onChange={(e) => updateIngredient(i, { note: e.target.value })}
|
||||
placeholder={t("note")}
|
||||
className="w-48 shrink-0"
|
||||
className="w-full sm:w-48 sm:shrink-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -176,6 +176,7 @@ function SelectableRecipeCard({
|
||||
|
||||
export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] }) {
|
||||
const t = useTranslations("recipe");
|
||||
const tCommon = useTranslations("common");
|
||||
const [recipes, setRecipes] = useState(initialRecipes);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -201,7 +202,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
||||
};
|
||||
|
||||
async function bulkDelete() {
|
||||
if (!confirm(`Delete ${selected.size} recipe${selected.size !== 1 ? "s" : ""}? This cannot be undone.`)) return;
|
||||
if (!confirm(t("bulkDeleteConfirm", { count: selected.size }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/recipes/bulk", {
|
||||
@@ -255,12 +256,12 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
||||
onClick={toggleAll}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{allSelected ? "Deselect all" : "Select all"}
|
||||
{allSelected ? t("deselectAll") : t("selectAll")}
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selected.size > 0
|
||||
? `${selected.size} selected`
|
||||
: "Click cards to select"}
|
||||
? t("selectedCount", { count: selected.size })
|
||||
: t("clickToSelect")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -273,9 +274,9 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
||||
className={cn("gap-1.5", selectMode && "text-muted-foreground")}
|
||||
>
|
||||
{selectMode ? (
|
||||
<><X className="h-4 w-4" />Cancel</>
|
||||
<><X className="h-4 w-4" />{tCommon("cancel")}</>
|
||||
) : (
|
||||
<><ListChecks className="h-4 w-4" />Select</>
|
||||
<><ListChecks className="h-4 w-4" />{t("select")}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -298,7 +299,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-4 duration-200">
|
||||
<div className="flex items-center gap-2 bg-popover border shadow-2xl rounded-2xl px-5 py-3">
|
||||
<span className="text-sm font-semibold tabular-nums mr-1">
|
||||
{selected.size} selected
|
||||
{t("selectedCount", { count: selected.size })}
|
||||
</span>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<DropdownMenu>
|
||||
@@ -307,17 +308,17 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
||||
className={buttonVariants({ variant: "ghost", size: "sm" }) + " gap-1.5"}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
Visibility
|
||||
{t("visibility")}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" side="top">
|
||||
<DropdownMenuItem onClick={() => { void bulkSetVisibility("public"); }}>
|
||||
<Globe className="h-4 w-4 mr-2 text-green-500" /> Make public
|
||||
<Globe className="h-4 w-4 mr-2 text-green-500" /> {t("makePublic")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { void bulkSetVisibility("unlisted"); }}>
|
||||
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> Make unlisted
|
||||
<Link2 className="h-4 w-4 mr-2 text-yellow-500" /> {t("makeUnlisted")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => { void bulkSetVisibility("private"); }}>
|
||||
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> Make private
|
||||
<Lock className="h-4 w-4 mr-2 text-muted-foreground" /> {t("makePrivate")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -329,7 +330,7 @@ export function RecipesGrid({ recipes: initialRecipes }: { recipes: Recipe[] })
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
{tCommon("delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,27 +36,27 @@ function TagFilterInput({ value, onChange }: { value: string; onChange: (v: stri
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SORT_LABELS: Record<string, string> = {
|
||||
updated_desc: "Recently updated",
|
||||
updated_asc: "Oldest updated",
|
||||
created_desc: "Newest first",
|
||||
created_asc: "Oldest first",
|
||||
title_asc: "Title A–Z",
|
||||
title_desc: "Title Z–A",
|
||||
const SORT_KEYS: Record<string, string> = {
|
||||
updated_desc: "sortRecentlyUpdated",
|
||||
updated_asc: "sortOldestUpdated",
|
||||
created_desc: "sortNewestFirst",
|
||||
created_asc: "sortOldestFirst",
|
||||
title_asc: "sortTitleAZ",
|
||||
title_desc: "sortTitleZA",
|
||||
};
|
||||
|
||||
const VISIBILITY_LABELS: Record<string, string> = {
|
||||
"": "All",
|
||||
private: "Private",
|
||||
unlisted: "Unlisted",
|
||||
public: "Public",
|
||||
const VISIBILITY_KEYS: Record<string, string> = {
|
||||
"": "all",
|
||||
private: "private",
|
||||
unlisted: "unlisted",
|
||||
public: "public",
|
||||
};
|
||||
|
||||
const DIFFICULTY_LABELS: Record<string, string> = {
|
||||
"": "Any difficulty",
|
||||
easy: "Easy",
|
||||
medium: "Medium",
|
||||
hard: "Hard",
|
||||
const DIFFICULTY_KEYS: Record<string, string> = {
|
||||
"": "anyDifficulty",
|
||||
easy: "easy",
|
||||
medium: "medium",
|
||||
hard: "hard",
|
||||
};
|
||||
|
||||
export function RecipesHeader({
|
||||
@@ -77,6 +77,7 @@ export function RecipesHeader({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("recipes");
|
||||
const tRecipe = useTranslations("recipe");
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
const [urlOpen, setUrlOpen] = useState(false);
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
@@ -138,7 +139,7 @@ export function RecipesHeader({
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<div className="relative w-full sm:flex-1 sm:min-w-[200px] sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
value={query}
|
||||
@@ -165,19 +166,19 @@ export function RecipesHeader({
|
||||
)}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
{SORT_LABELS[initialSort] ?? "Sort"}
|
||||
{SORT_KEYS[initialSort] ? t(SORT_KEYS[initialSort]) : t("sort")}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>{t("sortBy")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{Object.entries(SORT_LABELS).map(([value, label]) => (
|
||||
{Object.entries(SORT_KEYS).map(([value, key]) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => pushParams({ sort: value })}
|
||||
className={cn(initialSort === value && "font-medium text-primary")}
|
||||
>
|
||||
{label}
|
||||
{t(key)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
@@ -193,7 +194,7 @@ export function RecipesHeader({
|
||||
)}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Filter
|
||||
{t("filter")}
|
||||
{activeFilterCount > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-4 min-w-4 px-1 text-xs">
|
||||
{activeFilterCount}
|
||||
@@ -202,33 +203,33 @@ export function RecipesHeader({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Visibility</DropdownMenuLabel>
|
||||
{Object.entries(VISIBILITY_LABELS).map(([value, label]) => (
|
||||
<DropdownMenuLabel>{t("visibilityLabel")}</DropdownMenuLabel>
|
||||
{Object.entries(VISIBILITY_KEYS).map(([value, key]) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => pushParams({ visibility: value })}
|
||||
className={cn(initialVisibility === value && "font-medium text-primary")}
|
||||
>
|
||||
{label}
|
||||
{value === "" ? t(key) : tRecipe(`visibility.${key}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Difficulty</DropdownMenuLabel>
|
||||
{Object.entries(DIFFICULTY_LABELS).map(([value, label]) => (
|
||||
<DropdownMenuLabel>{t("difficultyLabel")}</DropdownMenuLabel>
|
||||
{Object.entries(DIFFICULTY_KEYS).map(([value, key]) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => pushParams({ difficulty: value })}
|
||||
className={cn(initialDifficulty === value && "font-medium text-primary")}
|
||||
>
|
||||
{label}
|
||||
{value === "" ? t(key) : tRecipe(`difficulty.${key}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Tag</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>{t("tagLabel")}</DropdownMenuLabel>
|
||||
<div className="px-2 py-1">
|
||||
<TagFilterInput
|
||||
value={initialTag}
|
||||
@@ -244,7 +245,7 @@ export function RecipesHeader({
|
||||
onClick={() => pushParams({ visibility: "", difficulty: "", tag: "" })}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1.5" /> Clear filters
|
||||
<X className="h-3 w-3 mr-1.5" /> {t("clearFilters")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Share2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
export function ShareRecipeButton({ recipeId, visibility }: { recipeId: string; visibility: string }) {
|
||||
const t = useTranslations("recipe");
|
||||
|
||||
async function handleShare() {
|
||||
const url = `${window.location.origin}/r/${recipeId}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
toast.error(t("shareCopyFailed"));
|
||||
return;
|
||||
}
|
||||
if (visibility === "public") {
|
||||
toast.success(t("shareLinkCopied"));
|
||||
} else {
|
||||
toast.info(t("shareNotPublicNotice"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<Button variant="ghost" size="icon" onClick={() => void handleShare()}>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>{t("share")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ const LANGUAGES = [
|
||||
|
||||
export function TranslateButton({ recipeId }: { recipeId: string }) {
|
||||
const t = useTranslations("ai.translate");
|
||||
const tRecipe = useTranslations("recipe");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [targetLanguage, setTargetLanguage] = useState("French");
|
||||
@@ -72,7 +73,7 @@ export function TranslateButton({ recipeId }: { recipeId: string }) {
|
||||
<Languages className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>Translate</TooltipContent>
|
||||
<TooltipContent>{tRecipe("translateTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -23,6 +24,7 @@ export function VariationsButton({
|
||||
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null; note?: string | null; order: number }>;
|
||||
steps: Array<{ instruction: string; timerSeconds?: number | null; order: number }>;
|
||||
}) {
|
||||
const t = useTranslations("recipe");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -34,7 +36,7 @@ export function VariationsButton({
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>Variations</TooltipContent>
|
||||
<TooltipContent>{t("variationsTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<VariationsDialog
|
||||
|
||||
@@ -63,6 +63,7 @@ export function VariationsDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations("recipe");
|
||||
const tv = useTranslations("ai.variations");
|
||||
const router = useRouter();
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [applying, setApplying] = useState<number | null>(null);
|
||||
@@ -80,7 +81,7 @@ export function VariationsDialog({
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json() as { error?: string };
|
||||
toast.error(err.error ?? "Failed to generate variations");
|
||||
toast.error(err.error ?? tv("error"));
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as { variations: Variation[] };
|
||||
@@ -140,12 +141,12 @@ export function VariationsDialog({
|
||||
});
|
||||
|
||||
if (!saveRes.ok) {
|
||||
toast.error("Failed to save variation");
|
||||
toast.error(tv("saveError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await saveRes.json() as { id: string };
|
||||
toast.success("Variation created — review and edit before publishing");
|
||||
toast.success(tv("success"));
|
||||
onOpenChange(false);
|
||||
router.push(`/recipes/${saved.id}/edit`);
|
||||
} finally {
|
||||
@@ -159,17 +160,17 @@ export function VariationsDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
AI Recipe Variations
|
||||
{tv("title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Generate creative variations of this recipe — dietary adaptations, flavor twists, technique changes.
|
||||
{tv("description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{variations.length === 0 ? (
|
||||
<div className="flex flex-col gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="directions">Directions <span className="text-muted-foreground font-normal">(optional)</span></Label>
|
||||
<Label htmlFor="directions">{tv("directions")} <span className="text-muted-foreground font-normal">({tv("optional")})</span></Label>
|
||||
<Textarea
|
||||
id="directions"
|
||||
placeholder={t("variationsDirectionsPlaceholder")}
|
||||
@@ -184,16 +185,16 @@ export function VariationsDialog({
|
||||
{generating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Generating variations…
|
||||
{tv("generating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Generate 3 variations
|
||||
{tv("generate")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<FakeProgressBar active={generating} durationMs={22000} label={generating ? "Generating 3 creative variations…" : undefined} />
|
||||
<FakeProgressBar active={generating} durationMs={22000} label={generating ? tv("generating") : undefined} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -214,13 +215,13 @@ export function VariationsDialog({
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Apply
|
||||
{tv("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{v.changedIngredients.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ingredient changes</p>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{tv("ingredientChanges")}</p>
|
||||
<div className="space-y-1">
|
||||
{v.changedIngredients.map((c, j) => (
|
||||
<div key={j} className="flex items-center gap-2 text-sm">
|
||||
@@ -237,11 +238,11 @@ export function VariationsDialog({
|
||||
|
||||
{v.changedSteps && v.changedSteps.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Step changes</p>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{tv("stepChanges")}</p>
|
||||
<div className="space-y-1">
|
||||
{v.changedSteps.map((c, j) => (
|
||||
<div key={j} className="flex gap-2 text-sm">
|
||||
<Badge variant="outline" className="text-xs shrink-0">Step {c.stepNumber}</Badge>
|
||||
<Badge variant="outline" className="text-xs shrink-0">{tv("stepLabel", { n: c.stepNumber })}</Badge>
|
||||
<span className="text-muted-foreground">{c.change}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -265,7 +266,7 @@ export function VariationsDialog({
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={generate} disabled={generating}>
|
||||
{generating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
Regenerate
|
||||
{tv("regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function VersionHistoryButton({
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
} />
|
||||
<TooltipContent>History</TooltipContent>
|
||||
<TooltipContent>{t("historyTooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Sheet open={open} onOpenChange={handleOpen}>
|
||||
@@ -160,42 +160,55 @@ export function VersionHistoryButton({
|
||||
|
||||
return (
|
||||
<div key={v.id} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<button
|
||||
className="flex-1 text-left text-sm"
|
||||
onClick={() => void handleExpand(v)}
|
||||
>
|
||||
<span className="font-medium">v{v.version}</span>
|
||||
<span className="text-muted-foreground"> — {v.title} — {formatDate(v.createdAt)}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
title={tForm("expand")}
|
||||
onClick={() => void handleExpand(v)}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => void handleCompare(v)}
|
||||
>
|
||||
<GitCompare className="h-3 w-3" />
|
||||
Compare
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={restoringId === v.id}
|
||||
onClick={() => void handleRestore(v)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restore
|
||||
</Button>
|
||||
<div className="flex flex-col gap-2 p-3 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="flex-1 text-left text-sm"
|
||||
onClick={() => void handleExpand(v)}
|
||||
>
|
||||
<span className="font-medium">v{v.version}</span>
|
||||
<span className="text-muted-foreground"> — {v.title} — {formatDate(v.createdAt)}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 sm:hidden"
|
||||
title={tForm("expand")}
|
||||
onClick={() => void handleExpand(v)}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 hidden sm:inline-flex"
|
||||
title={tForm("expand")}
|
||||
onClick={() => void handleExpand(v)}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-initial shrink-0"
|
||||
onClick={() => void handleCompare(v)}
|
||||
>
|
||||
<GitCompare className="h-3 w-3" />
|
||||
Compare
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-initial shrink-0"
|
||||
disabled={restoringId === v.id}
|
||||
onClick={() => void handleRestore(v)}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
|
||||
Reference in New Issue
Block a user