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:
Arnaud
2026-07-02 15:13:51 +02:00
parent b07bada291
commit eb424d8c04
44 changed files with 932 additions and 376 deletions
@@ -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>