feat(recipes): refond le panneau de correction des métadonnées de tech-step #30
7 changed files with 1285 additions and 341 deletions
|
|
@ -16,6 +16,9 @@ const butter = { id: 10, key: "butter" };
|
||||||
const pan = { id: 20, key: "pan" };
|
const pan = { id: 20, key: "pan" };
|
||||||
const gram = { id: 30, key: "gram" };
|
const gram = { id: 30, key: "gram" };
|
||||||
|
|
||||||
|
/** Long enough that `range={start:0,end:5}` ("Cuire") and a resolved metadata span at `[20,26)` ("Beurre") both fall within it — see {@link Harness}'s fixed `onRequestSpan` stub. */
|
||||||
|
const DESCRIPTION = "Cuire un peu de beurre.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A real `StepDescription` resolves `onRequestSpan` into a fresh
|
* A real `StepDescription` resolves `onRequestSpan` into a fresh
|
||||||
* `resolvedMetadataSpan` via an actual browser text selection — out of
|
* `resolvedMetadataSpan` via an actual browser text selection — out of
|
||||||
|
|
@ -29,18 +32,20 @@ function Harness({
|
||||||
previousTechStepId = null,
|
previousTechStepId = null,
|
||||||
existingIngredients = [],
|
existingIngredients = [],
|
||||||
existingUtensils = [],
|
existingUtensils = [],
|
||||||
|
existingTemperatures = [],
|
||||||
onClose = () => {},
|
onClose = () => {},
|
||||||
onSubmitted = () => {},
|
onSubmitted = () => {},
|
||||||
}: Partial<{
|
}: Partial<{
|
||||||
previousTechStepId: number | null;
|
previousTechStepId: number | null;
|
||||||
existingIngredients: unknown[];
|
existingIngredients: unknown[];
|
||||||
existingUtensils: unknown[];
|
existingUtensils: unknown[];
|
||||||
|
existingTemperatures: unknown[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmitted: (result: unknown) => void;
|
onSubmitted: (result: unknown) => void;
|
||||||
}>) {
|
}>) {
|
||||||
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
||||||
nonce: number;
|
nonce: number;
|
||||||
kind: "ingredient" | "utensil";
|
kind: "ingredient" | "utensil" | "temperature";
|
||||||
range: { start: number; end: number };
|
range: { start: number; end: number };
|
||||||
text: string;
|
text: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
@ -52,13 +57,16 @@ function Harness({
|
||||||
<TechStepCorrectionPopover
|
<TechStepCorrectionPopover
|
||||||
recipeId={2}
|
recipeId={2}
|
||||||
stepId={2}
|
stepId={2}
|
||||||
|
description={DESCRIPTION}
|
||||||
selectedText="Cuire"
|
selectedText="Cuire"
|
||||||
range={{ start: 0, end: 5 }}
|
range={{ start: 0, end: 5 }}
|
||||||
previousTechStepId={previousTechStepId}
|
previousTechStepId={previousTechStepId}
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView props — precise typing isn't the point here.
|
// biome-ignore lint/suspicious/noExplicitAny: test harness stands in for real StepTechStepIngredientView/UtensilView/TemperatureView props — precise typing isn't the point here.
|
||||||
existingIngredients={existingIngredients as any}
|
existingIngredients={existingIngredients as any}
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
existingUtensils={existingUtensils as any}
|
existingUtensils={existingUtensils as any}
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: see above.
|
||||||
|
existingTemperatures={existingTemperatures as any}
|
||||||
resolvedMetadataSpan={resolvedMetadataSpan}
|
resolvedMetadataSpan={resolvedMetadataSpan}
|
||||||
onRequestSpan={(kind) =>
|
onRequestSpan={(kind) =>
|
||||||
setResolvedMetadataSpan({
|
setResolvedMetadataSpan({
|
||||||
|
|
@ -90,34 +98,40 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the selected text, the technique catalog (searchable) and the metadata sections all together", () => {
|
it("shows the selected text, the technique picker (searchable) and all three metadata sections together", () => {
|
||||||
cy.mount(<Harness />);
|
cy.mount(<Harness />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
cy.contains(".tech-step-correction-popover__selection", "Cuire").should("be.visible");
|
||||||
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
// Merged editor (see TechStepCorrectionPopover's own doc comment) — no
|
||||||
// separate "pick, then metadata reveals itself" step, both render at
|
// separate "pick, then metadata reveals itself" step, all four
|
||||||
// once, and the technique catalog goes through the same searchable
|
// sections render at once, and the technique catalog goes through the
|
||||||
// `CatalogSearchPicker` as the ingredient/utensil sub-flows (a plain
|
// same searchable `CatalogSearchPicker` as the ingredient/utensil
|
||||||
// unfiltered list of the real ~74-entry catalog isn't browsable).
|
// sub-flows (a plain unfiltered list of the real ~74-entry catalog
|
||||||
|
// isn't browsable) — visible by default since nothing's picked yet.
|
||||||
cy.get(
|
cy.get(
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
).should("have.length", 2);
|
).should("have.length", 2);
|
||||||
cy.contains("h4", "Ingrédients").should("be.visible");
|
cy.contains(".tech-step-correction-popover__section-title", "Ingrédients").should("be.visible");
|
||||||
cy.contains("h4", "Ustensiles").should("be.visible");
|
cy.contains(".tech-step-correction-popover__section-title", "Ustensiles").should("be.visible");
|
||||||
|
cy.contains(".tech-step-correction-popover__section-title", "Température").should("be.visible");
|
||||||
cy.contains("button", "Valider").should("be.visible");
|
cy.contains("button", "Valider").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
|
it("offers a 'no technique here' option, and marks the current pick, only when correcting an existing match", () => {
|
||||||
cy.mount(<Harness previousTechStepId={null} />);
|
cy.mount(<Harness previousTechStepId={null} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.get(".tech-step-correction-popover__remove").should("not.exist");
|
cy.get(".tech-step-correction-popover__remove-match").should("not.exist");
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Aucune technique sélectionnée");
|
||||||
|
|
||||||
cy.mount(<Harness previousTechStepId={cook.id} />);
|
cy.mount(<Harness previousTechStepId={cook.id} />);
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.get(".tech-step-correction-popover__remove").should("exist");
|
cy.get(".tech-step-correction-popover__remove-match").should("exist");
|
||||||
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
|
cy.contains(".tech-step-correction-popover__chosen-technique", "Cuire");
|
||||||
|
// The picker itself hides behind "Changer" once something's already
|
||||||
|
// picked (see TechStepCorrectionPopover's own doc comment) — reopened
|
||||||
|
// here just to confirm it still marks the current pick correctly.
|
||||||
|
cy.contains("button", "Changer").click();
|
||||||
cy.contains(".catalog-search-picker__list button", "Cuire").should(
|
cy.contains(".catalog-search-picker__list button", "Cuire").should(
|
||||||
"have.class",
|
"have.class",
|
||||||
"catalog-search-picker__item--selected",
|
"catalog-search-picker__item--selected",
|
||||||
|
|
@ -149,7 +163,7 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
cy.contains("button", "Valider").should("not.be.disabled");
|
cy.contains("button", "Valider").should("not.be.disabled");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("submits the selected technique (no metadata touched) with ingredients/utensils omitted from the request", () => {
|
it("submits the selected technique (no metadata touched) with ingredients/utensils/temperatures omitted from the request", () => {
|
||||||
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
statusCode: 201,
|
statusCode: 201,
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -200,14 +214,16 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
"Mijoter",
|
"Mijoter",
|
||||||
).click();
|
).click();
|
||||||
cy.contains("button", "+ Ajouter un ingrédient").click();
|
cy.contains("button", "Ajouter un ingrédient").click();
|
||||||
|
|
||||||
cy.contains(".catalog-search-picker button", "Beurre").click();
|
cy.contains(".catalog-search-picker button", "Beurre").click();
|
||||||
cy.get('input[type="number"]').type("50");
|
cy.get('input[type="number"]').type("50");
|
||||||
cy.get("select").select(String(gram.id));
|
cy.get("select").select(String(gram.id));
|
||||||
cy.contains("button", "Ajouter").click();
|
cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click();
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").should("be.visible");
|
cy.contains(".tech-step-correction-popover__row", "Beurre")
|
||||||
|
.should("contain.text", "Beurre")
|
||||||
|
.and("contain.text", "50 g");
|
||||||
cy.contains("button", "Valider").click();
|
cy.contains("button", "Valider").click();
|
||||||
|
|
||||||
cy.wait("@submitCorrection")
|
cy.wait("@submitCorrection")
|
||||||
|
|
@ -221,28 +237,85 @@ describe("TechStepCorrectionPopover", () => {
|
||||||
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
|
{ ingredientId: butter.id, quantity: 50, unitId: gram.id, start: 20, end: 26 },
|
||||||
],
|
],
|
||||||
utensils: [],
|
utensils: [],
|
||||||
|
temperatures: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pre-seeds existing ingredients/utensils, removable via their own chip", () => {
|
it("adds a temperature via the span-selection flow and its 3-way mode switch, included in the submitted request", () => {
|
||||||
|
cy.intercept("POST", "**/recipes/2/steps/2/corrections", {
|
||||||
|
statusCode: 201,
|
||||||
|
body: {
|
||||||
|
id: 1,
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
previousTechStep: null,
|
||||||
|
correctedTechStep: simmer,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}).as("submitCorrection");
|
||||||
|
cy.mount(<Harness />);
|
||||||
|
cy.wait("@getTechSteps");
|
||||||
|
|
||||||
|
cy.contains(
|
||||||
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
|
"Mijoter",
|
||||||
|
).click();
|
||||||
|
cy.contains("button", "Ajouter une température").click();
|
||||||
|
|
||||||
|
// Defaults to the qualitative mode — switching to °C swaps the input
|
||||||
|
// shown, never both at once (mutually exclusive, see
|
||||||
|
// `manualStepTechStepTemperatureInputSchema`'s own doc comment,
|
||||||
|
// packages/shared).
|
||||||
|
cy.contains(".tech-step-correction-popover__mode-switch button", "°C").click();
|
||||||
|
cy.get('.tech-step-correction-popover__value-line input[type="number"]').type("180");
|
||||||
|
cy.contains(".tech-step-correction-popover__add-form button", "Ajouter").click();
|
||||||
|
|
||||||
|
cy.contains(".tech-step-correction-popover__row", "180")
|
||||||
|
.should("contain.text", "°C")
|
||||||
|
.and("contain.text", "Beurre"); // the (stubbed) source quote
|
||||||
|
cy.contains("button", "Valider").click();
|
||||||
|
|
||||||
|
cy.wait("@submitCorrection")
|
||||||
|
.its("request.body")
|
||||||
|
.should("deep.equal", {
|
||||||
|
start: 0,
|
||||||
|
end: 5,
|
||||||
|
previousTechStepId: null,
|
||||||
|
correctedTechStepId: simmer.id,
|
||||||
|
ingredients: [],
|
||||||
|
utensils: [],
|
||||||
|
temperatures: [{ celsius: 180, gasMark: null, qualitative: null, start: 20, end: 26 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-seeds existing ingredients/utensils/temperatures, each removable via its own row", () => {
|
||||||
cy.mount(
|
cy.mount(
|
||||||
<Harness
|
<Harness
|
||||||
previousTechStepId={cook.id}
|
previousTechStepId={cook.id}
|
||||||
existingIngredients={[
|
existingIngredients={[
|
||||||
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
|
{ ingredient: butter, quantity: 50, unit: gram, start: 0, end: 6, source: "auto" },
|
||||||
]}
|
]}
|
||||||
existingUtensils={[{ utensil: pan, start: 14, end: 23, source: "auto" }]}
|
existingUtensils={[{ utensil: pan, start: 9, end: 15, source: "auto" }]}
|
||||||
|
existingTemperatures={[
|
||||||
|
{ celsius: null, gasMark: null, qualitative: "low", start: 16, end: 22, source: "auto" },
|
||||||
|
]}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// An existing match starts pre-selected on itself (see
|
// An existing match starts pre-selected on itself (see
|
||||||
// TechStepCorrectionPopover's own doc comment) — the metadata sections,
|
// TechStepCorrectionPopover's own doc comment) — the metadata
|
||||||
// pre-seeded from `existingIngredients`/`existingUtensils`, are visible
|
// sections, pre-seeded from `existingIngredients`/`existingUtensils`/
|
||||||
// immediately, no need to re-pick "Cuire" from a list first.
|
// `existingTemperatures`, are visible immediately, no need to re-pick
|
||||||
|
// "Cuire" from a list first.
|
||||||
cy.wait("@getTechSteps");
|
cy.wait("@getTechSteps");
|
||||||
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
cy.wait(["@getIngredients", "@getUnits", "@getUtensils"]);
|
||||||
|
|
||||||
cy.contains(".tech-step-correction-popover__chip", "50 g Beurre").find("button").click();
|
cy.contains(".tech-step-correction-popover__row", "Beurre").should("be.visible");
|
||||||
cy.contains(".tech-step-correction-popover__chip", "Beurre").should("not.exist");
|
cy.contains(".tech-step-correction-popover__row", "Feu doux").should("be.visible");
|
||||||
|
|
||||||
|
cy.contains(".tech-step-correction-popover__row", "Beurre")
|
||||||
|
.find(".tech-step-correction-popover__row-remove")
|
||||||
|
.click();
|
||||||
|
cy.contains(".tech-step-correction-popover__row", "Beurre").should("not.exist");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an error message and stays open when the submission fails", () => {
|
it("shows an error message and stays open when the submission fails", () => {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,21 @@ Feature: Managing a recipe from the catalog
|
||||||
When I focus the highlighted technique "Cuire"
|
When I focus the highlighted technique "Cuire"
|
||||||
Then the tooltip should show "Mijoter (correction manuelle)"
|
Then the tooltip should show "Mijoter (correction manuelle)"
|
||||||
|
|
||||||
|
Scenario: Clicking a highlighted temperature reopens its technique's correction popover
|
||||||
|
# Same master-detail room requirement as recipes.cy.ts's own equivalent
|
||||||
|
# test — Cypress's default 1000×660 doesn't leave the detail panel
|
||||||
|
# (with the popover open below its step) enough height to show
|
||||||
|
# everything without its own scroll.
|
||||||
|
Given the viewport is 1600 by 900
|
||||||
|
And the recipe catalog contains "Gratin"
|
||||||
|
And recipe 4's detail is available
|
||||||
|
And the tech steps reference list includes preheat
|
||||||
|
When I visit "/recettes/4"
|
||||||
|
Then I should see the highlighted temperature "feu moyen"
|
||||||
|
When I click the highlighted temperature "feu moyen"
|
||||||
|
Then I should see the technique correction options
|
||||||
|
And the chosen technique should be "Préchauffer"
|
||||||
|
|
||||||
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
|
Scenario: Deletes a recipe after a two-step confirmation, then clears the selection
|
||||||
Given the recipe catalog contains "Omelette"
|
Given the recipe catalog contains "Omelette"
|
||||||
And recipe 2's detail is available
|
And recipe 2's detail is available
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,50 @@ const omeletteDetail = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A separate, self-contained fixture for the "clicking a temperature
|
||||||
|
* highlight" scenario — kept apart from `omeletteDetail` above rather than
|
||||||
|
* adding a temperature to its "Cuire" occurrence, so this doesn't entangle
|
||||||
|
* with that fixture's own "Corrects a detected technique" scenario (which
|
||||||
|
* relabels the same occurrence without touching metadata, and must keep
|
||||||
|
* meaning "nothing here changed").
|
||||||
|
*/
|
||||||
|
const gratinDetail = {
|
||||||
|
id: 4,
|
||||||
|
name: "Gratin",
|
||||||
|
description: null,
|
||||||
|
picture: null,
|
||||||
|
portions: 4,
|
||||||
|
authorId: 1,
|
||||||
|
visibility: "PERSONAL",
|
||||||
|
allergens: [],
|
||||||
|
diets: [],
|
||||||
|
isFavorite: false,
|
||||||
|
ingredients: [],
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
// "Préchauffez" = [0, 11), "à feu moyen" = [20, 31) — counted char by
|
||||||
|
// char (accented/multi-byte characters here are each still one JS
|
||||||
|
// string index, same convention `start`/`end` use everywhere else).
|
||||||
|
description: "Préchauffez le four à feu moyen.",
|
||||||
|
picture: null,
|
||||||
|
order: 1,
|
||||||
|
techSteps: [
|
||||||
|
{
|
||||||
|
techStep: { id: 9, key: "preheat" },
|
||||||
|
start: 0,
|
||||||
|
end: 11,
|
||||||
|
source: "auto",
|
||||||
|
temperatures: [
|
||||||
|
{ celsius: null, gasMark: null, qualitative: "medium", start: 20, end: 31 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
Given("the disliked ingredients list is empty", () => {
|
Given("the disliked ingredients list is empty", () => {
|
||||||
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
cy.intercept("GET", "**/profile/disliked-ingredients", { statusCode: 200, body: [] });
|
||||||
});
|
});
|
||||||
|
|
@ -68,6 +112,23 @@ Given("deleting recipe 2 will succeed", () => {
|
||||||
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
cy.intercept("DELETE", "**/recipes/2", { statusCode: 204 }).as("deleteRecipe");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Given("recipe 4's detail is available", () => {
|
||||||
|
cy.intercept("GET", "**/recipes/4", { statusCode: 200, body: gratinDetail });
|
||||||
|
});
|
||||||
|
|
||||||
|
// TechStepCorrectionPopover fetches the full technique catalog on mount
|
||||||
|
// (to resolve the chosen technique's own label) — a dedicated fixture
|
||||||
|
// naming just `preheat` (`gratinDetail`'s own technique), kept separate
|
||||||
|
// from "the tech steps reference list has options" above (that one's
|
||||||
|
// `cook`/`simmer` pair is purpose-built for the *other* scenario's
|
||||||
|
// relabel, not a generic catalog stand-in).
|
||||||
|
Given("the tech steps reference list includes preheat", () => {
|
||||||
|
cy.intercept("GET", "**/reference/tech-steps", {
|
||||||
|
statusCode: 200,
|
||||||
|
body: [{ id: 9, key: "preheat" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
|
// Step 2 is `omeletteDetail`'s "Cuire à la poêle." step, whose only
|
||||||
// existing match is `cook` (id 1) — see that fixture above. The response
|
// existing match is `cook` (id 1) — see that fixture above. The response
|
||||||
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
|
// mirrors `SubmitTechStepCorrectionResult` (packages/shared): the audit
|
||||||
|
|
@ -96,6 +157,7 @@ Given('correcting step 2\'s "Cuire" match will succeed', () => {
|
||||||
source: "manual",
|
source: "manual",
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
utensils: [],
|
utensils: [],
|
||||||
|
temperatures: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -110,6 +172,23 @@ Then("I should see the technique correction options", () => {
|
||||||
cy.get(".tech-step-correction-popover").should("be.visible");
|
cy.get(".tech-step-correction-popover").should("be.visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Then("I should see the highlighted temperature {string}", (text: string) => {
|
||||||
|
// Same "off the fold, needs an explicit scroll" reasoning as
|
||||||
|
// common.steps.ts's "I should see the highlighted technique" — a bare
|
||||||
|
// `.should("be.visible")` doesn't auto-scroll.
|
||||||
|
cy.contains(".step-temperature", text).scrollIntoView().should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Temperature is corrected through its owning technique's own popover, not
|
||||||
|
// on its own — see `StepDescription.tsx`'s `findTechStepForTemperature`.
|
||||||
|
When("I click the highlighted temperature {string}", (text: string) => {
|
||||||
|
cy.contains(".step-temperature", text).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then("the chosen technique should be {string}", (label: string) => {
|
||||||
|
cy.contains(".tech-step-correction-popover__chosen-technique", label).should("be.visible");
|
||||||
|
});
|
||||||
|
|
||||||
// Picking a technique only *selects* it — it takes a separate "Valider"
|
// Picking a technique only *selects* it — it takes a separate "Valider"
|
||||||
// click to actually submit (room was made for attaching ingredient/utensil
|
// click to actually submit (room was made for attaching ingredient/utensil
|
||||||
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
|
// metadata alongside it, see `TechStepCorrectionPopover.tsx`'s own doc
|
||||||
|
|
@ -118,8 +197,13 @@ Then("I should see the technique correction options", () => {
|
||||||
// technique catalog is picked via the same searchable `CatalogSearchPicker`
|
// technique catalog is picked via the same searchable `CatalogSearchPicker`
|
||||||
// the ingredient/utensil sub-flows use, scoped to
|
// the ingredient/utensil sub-flows use, scoped to
|
||||||
// `__technique-section` since that same search-and-pick component is
|
// `__technique-section` since that same search-and-pick component is
|
||||||
// reused inside this popover for more than just techniques.
|
// reused inside this popover for more than just techniques. This step is
|
||||||
|
// only ever used to *relabel* an existing match (this feature's one
|
||||||
|
// scenario using it) — the picker hides behind "Changer" in that case
|
||||||
|
// (nothing to decide once a technique's already confirmed correct, see
|
||||||
|
// TechStepCorrectionPopover's own doc comment), so it's reopened first.
|
||||||
When("I choose {string} as the correct technique", (label: string) => {
|
When("I choose {string} as the correct technique", (label: string) => {
|
||||||
|
cy.contains(".tech-step-correction-popover__technique-change", "Changer").click();
|
||||||
cy.contains(
|
cy.contains(
|
||||||
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
".tech-step-correction-popover__technique-section .catalog-search-picker__list button",
|
||||||
label,
|
label,
|
||||||
|
|
|
||||||
|
|
@ -673,7 +673,10 @@
|
||||||
text-decoration: underline dotted var(--color-primary);
|
text-decoration: underline dotted var(--color-primary);
|
||||||
text-decoration-thickness: 1px;
|
text-decoration-thickness: 1px;
|
||||||
text-underline-offset: 0.15em;
|
text-underline-offset: 0.15em;
|
||||||
cursor: help;
|
// A real clickable trigger (opens `TechStepCorrectionPopover`), not just
|
||||||
|
// a hover-for-a-tooltip target — `pointer`, not `help`, same reasoning
|
||||||
|
// now shared by `.step-temperature` below.
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
|
|
@ -702,17 +705,31 @@
|
||||||
// `--color-primary`, so a temperature reads as its own kind of metadata at
|
// `--color-primary`, so a temperature reads as its own kind of metadata at
|
||||||
// a glance. `--low`/`--medium`/`--high` only vary the underline weight —
|
// a glance. `--low`/`--medium`/`--high` only vary the underline weight —
|
||||||
// the qualitative scale is a nuance, not three different colors.
|
// the qualitative scale is a nuance, not three different colors.
|
||||||
|
//
|
||||||
|
// Renders as a `<button>` (not a `<span>`) in editable mode when it has an
|
||||||
|
// owning technique occurrence to reopen (see `StepDescription.tsx`'s
|
||||||
|
// `findTechStepForTemperature`) — same button-reset-to-inline-text
|
||||||
|
// treatment as `.step-tech-step` above (`font`/`color`/`margin`/`border`),
|
||||||
|
// so the two read identically whether they're a `<button>` or a plain
|
||||||
|
// `<span>` (read-only, or no owner to reopen).
|
||||||
.step-temperature {
|
.step-temperature {
|
||||||
|
display: inline;
|
||||||
padding: 0 0.15em;
|
padding: 0 0.15em;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||||
|
border: none;
|
||||||
border-radius: 0.2em;
|
border-radius: 0.2em;
|
||||||
text-decoration: underline dotted var(--color-accent);
|
text-decoration: underline dotted var(--color-accent);
|
||||||
text-decoration-thickness: 1px;
|
text-decoration-thickness: 1px;
|
||||||
text-underline-offset: 0.15em;
|
text-underline-offset: 0.15em;
|
||||||
cursor: help;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
|
background: color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--high {
|
&--high {
|
||||||
|
|
@ -746,6 +763,9 @@
|
||||||
// in the document flow right below the step's own description instead of
|
// in the document flow right below the step's own description instead of
|
||||||
// floating anchored at the selection.
|
// floating anchored at the selection.
|
||||||
.tech-step-correction-popover {
|
.tech-step-correction-popover {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-md);
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
background: var(--color-surface-alt);
|
background: var(--color-surface-alt);
|
||||||
|
|
@ -754,30 +774,94 @@
|
||||||
|
|
||||||
&__selection {
|
&__selection {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 var(--space-sm);
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Technique picker + Ingrédients/Ustensiles render together as one
|
// One shared shape for every section (Technique/Ingrédients/Ustensiles/
|
||||||
// screen now (see `TechStepCorrectionPopover.tsx`'s own doc comment) —
|
// Température) — see `TechStepCorrectionPopover.tsx`'s own doc comment
|
||||||
// this section just needs its own small header row, the actual picker
|
// for why all four render together, always, rather than one at a time.
|
||||||
// is `.catalog-search-picker` (below), reused as-is from the ingredient/
|
&__section {
|
||||||
// utensil sub-flows.
|
|
||||||
&__technique-section {
|
|
||||||
h4 {
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__technique-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__remove {
|
&__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding-bottom: var(--space-xs);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same "kicker" label language as `.cooking-phase__recipe-group-title`
|
||||||
|
// (cooking-session-page.scss) — a small bold uppercase eyebrow, not a
|
||||||
|
// full heading, since four of these stack in one popover.
|
||||||
|
&__section-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__section-count {
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One icon color per category — set on the icon itself (not the whole
|
||||||
|
// section) so the title text next to it stays neutral/muted.
|
||||||
|
&__technique-section &__section-header svg {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
&__ingredients-section &__section-header svg,
|
||||||
|
&__utensils-section &__section-header svg {
|
||||||
|
color: var(--color-tag);
|
||||||
|
}
|
||||||
|
&__temperature-section &__section-header svg {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chosen-technique {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__technique-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-surface);
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__technique-change {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remove-match {
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
|
|
@ -792,7 +876,8 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cancel {
|
&__cancel,
|
||||||
|
&__cancel-mini {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|
@ -802,113 +887,262 @@
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shown in place of the technique list/metadata sections while
|
// Shown in place of a section's own "+ Ajouter…" button while
|
||||||
// `StepDescription` is waiting on a second text selection (see
|
// `StepDescription` is waiting on a second text selection for *that*
|
||||||
// `TechStepCorrectionPopover.tsx`'s own doc comment) — same styling
|
// section (see `TechStepCorrectionPopover.tsx`'s own doc comment) — same
|
||||||
// intent as `.recipe-detail-panel__tech-step-hint`, a small muted aside.
|
// styling intent as `.recipe-detail-panel__tech-step-hint`, a small
|
||||||
|
// muted aside.
|
||||||
&__hint {
|
&__hint {
|
||||||
margin: 0 0 var(--space-sm);
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One attached ingredient/utensil/temperature — a small card of its own
|
||||||
|
// (not a chip pill anymore: showing the source quote alongside the name
|
||||||
|
// needs more room than a pill comfortably gives).
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-quote {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__span-picker {
|
&__row-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 2px var(--space-sm);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
color: var(--color-error);
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "+ Ajouter…" — a dashed ghost row, not a plain text link: at-a-glance
|
||||||
|
// an "add a new item" affordance, same weight as the rows it adds to.
|
||||||
|
&__add-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
border: 1.5px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A section's own inline "adding a new mention" sub-form — replaces just
|
||||||
|
// that section's `__add-row` button, every other section (and the
|
||||||
|
// technique picker) stays visible and untouched alongside it.
|
||||||
|
&__add-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
padding: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__quantity-line {
|
&__add-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__quantity-line,
|
||||||
|
&__value-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
|
||||||
input[type="number"] {
|
input[type="number"] {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
}
|
padding: var(--space-xs) var(--space-sm);
|
||||||
}
|
|
||||||
|
|
||||||
&__confirm {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Aucune technique sélectionnée."/"Technique retenue : X" — no button
|
|
||||||
// here anymore (re-picking happens directly through the search picker
|
|
||||||
// right below, see `TechStepCorrectionPopover.tsx`'s doc comment on the
|
|
||||||
// merged editor), just a small status line.
|
|
||||||
&__chosen-technique {
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__metadata-section {
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
// "+ Ajouter…" button — deliberately a plain text-link style, not
|
|
||||||
// another pill button (`.catalog-search-picker__list button`) — this
|
|
||||||
// is a secondary action inside an already-open popover, not a
|
|
||||||
// top-level choice competing with the chips above it.
|
|
||||||
> button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
list-style: none;
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__chip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
background: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value-unit {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Température's 3-way mode switch (°C / Thermostat / Qualitatif) — a
|
||||||
|
// segmented control, same connected-pill language as the qualitative
|
||||||
|
// sub-choice below, in the accent color (Vermillion, the "heat" accent —
|
||||||
|
// same as `.step-temperature`) so the active mode reads at a glance.
|
||||||
|
&__mode-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--color-surface-alt);
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: none;
|
flex: 1;
|
||||||
border: none;
|
border: none;
|
||||||
|
background: none;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&.active {
|
||||||
color: var(--color-error);
|
background: var(--color-accent);
|
||||||
|
color: var(--color-surface);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__qual-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
|
||||||
|
button {
|
||||||
|
flex: 1;
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__confirm-mini {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-surface);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-base);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 700;
|
||||||
|
padding: var(--space-xs) var(--space-md);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
&__confirm-button {
|
&__confirm-button {
|
||||||
align-self: flex-start;
|
padding: var(--space-sm) var(--space-lg);
|
||||||
padding: 0.4rem 1rem;
|
font-family: var(--font-display);
|
||||||
font-size: var(--font-size-sm);
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
color: var(--color-surface);
|
color: var(--color-surface);
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
import type { StepTechStepView, SubmitTechStepCorrectionResult } from "@batch-cooking/shared";
|
import type {
|
||||||
|
StepTechStepTemperatureView,
|
||||||
|
StepTechStepView,
|
||||||
|
SubmitTechStepCorrectionResult,
|
||||||
|
} from "@batch-cooking/shared";
|
||||||
import { Fragment, useEffect, useRef, useState } from "react";
|
import { Fragment, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Tooltip } from "../../../components/ui/Tooltip";
|
import { Tooltip } from "../../../components/ui/Tooltip";
|
||||||
|
|
@ -7,6 +11,27 @@ import { TechStepCorrectionPopover } from "./TechStepCorrectionPopover";
|
||||||
import { temperatureLabel } from "./temperature-label";
|
import { temperatureLabel } from "./temperature-label";
|
||||||
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
import { type TextSelectionRange, useTextSelection } from "./use-text-selection";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds which of `techSteps`' occurrences a given temperature mention
|
||||||
|
* belongs to — the read-only highlight flattens every occurrence's
|
||||||
|
* `temperatures` together (see this file's own `temperatures` above),
|
||||||
|
* losing that association, so a click handler that needs to reopen "the
|
||||||
|
* technique this temperature is attached to" re-derives it by matching
|
||||||
|
* span. `undefined` when nothing matches (shouldn't happen for a real
|
||||||
|
* persisted temperature, but degrades to "not clickable" rather than a
|
||||||
|
* crash — same posture as `use-text-selection.ts`'s `resolveOffset`).
|
||||||
|
*/
|
||||||
|
function findTechStepForTemperature(
|
||||||
|
techSteps: StepTechStepView[],
|
||||||
|
temperature: StepTechStepTemperatureView,
|
||||||
|
): StepTechStepView | undefined {
|
||||||
|
return techSteps.find((techStep) =>
|
||||||
|
(techStep.temperatures ?? []).some(
|
||||||
|
(candidate) => candidate.start === temperature.start && candidate.end === temperature.end,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A recipe step's description, with every detected technique's exact
|
* A recipe step's description, with every detected technique's exact
|
||||||
* matched words highlighted and given a {@link Tooltip} naming the
|
* matched words highlighted and given a {@link Tooltip} naming the
|
||||||
|
|
@ -32,10 +57,15 @@ import { type TextSelectionRange, useTextSelection } from "./use-text-selection"
|
||||||
* color, so the two are visually distinguishable at a glance rather than
|
* color, so the two are visually distinguishable at a glance rather than
|
||||||
* only via the tooltip text.
|
* only via the tooltip text.
|
||||||
*
|
*
|
||||||
* `editable` (off by default) additionally lets the viewer select text or
|
* `editable` (off by default) additionally lets the viewer select text, or
|
||||||
* click an existing highlight to open a {@link TechStepCorrectionPopover} —
|
* click an existing technique or temperature highlight, to open a
|
||||||
* see `use-text-selection.ts` for how a browser selection is translated
|
* {@link TechStepCorrectionPopover} — a temperature highlight reopens
|
||||||
* into an absolute `[start, end)` span. When `!editable`, every segment
|
* whichever technique occurrence it's attached to (see
|
||||||
|
* `findTechStepForTemperature` above), since temperature is corrected
|
||||||
|
* through that same popover, not on its own (same as ingredients/
|
||||||
|
* utensils). See `use-text-selection.ts` for how a browser selection is
|
||||||
|
* translated into an absolute `[start, end)` span. When `!editable`, every
|
||||||
|
* segment
|
||||||
* renders exactly as before (no extra wrapping elements, no `data-offset`,
|
* renders exactly as before (no extra wrapping elements, no `data-offset`,
|
||||||
* no click handlers) — this mode is purely additive, not a rewrite of the
|
* no click handlers) — this mode is purely additive, not a rewrite of the
|
||||||
* read-only rendering.
|
* read-only rendering.
|
||||||
|
|
@ -90,18 +120,18 @@ export function StepDescription({
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
|
// Routes the *next* text selection to the open `TechStepCorrectionPopover`
|
||||||
// (as an ingredient/utensil mention span) instead of opening a brand-new
|
// (as an ingredient/utensil/temperature mention span) instead of opening
|
||||||
// correction — set when that popover calls `onRequestSpan`, cleared once
|
// a brand-new correction — set when that popover calls `onRequestSpan`, cleared once
|
||||||
// `handleMouseUp` resolves the selection below. See
|
// `handleMouseUp` resolves the selection below. See
|
||||||
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
|
// `TechStepCorrectionPopover.tsx`'s own doc comment for why this can live
|
||||||
// entirely alongside the still-visible, still-selectable description
|
// entirely alongside the still-visible, still-selectable description
|
||||||
// rather than needing the popover itself to move/hide.
|
// rather than needing the popover itself to move/hide.
|
||||||
const [pendingSpanRequest, setPendingSpanRequest] = useState<"ingredient" | "utensil" | null>(
|
const [pendingSpanRequest, setPendingSpanRequest] = useState<
|
||||||
null,
|
"ingredient" | "utensil" | "temperature" | null
|
||||||
);
|
>(null);
|
||||||
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
const [resolvedMetadataSpan, setResolvedMetadataSpan] = useState<{
|
||||||
nonce: number;
|
nonce: number;
|
||||||
kind: "ingredient" | "utensil";
|
kind: "ingredient" | "utensil" | "temperature";
|
||||||
range: TextSelectionRange;
|
range: TextSelectionRange;
|
||||||
text: string;
|
text: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
@ -185,23 +215,59 @@ export function StepDescription({
|
||||||
if (segment.temperature) {
|
if (segment.temperature) {
|
||||||
// A detected temperature mention ("180°C", "feu doux") — its
|
// A detected temperature mention ("180°C", "feu doux") — its
|
||||||
// own subtle highlight + a Tooltip with the normalized value.
|
// own subtle highlight + a Tooltip with the normalized value.
|
||||||
// Not correctable (no popover), same as utensils; still gets a
|
// In editable mode, clicking it reopens the correction popover
|
||||||
// `data-offset` in editable mode so a text selection spanning
|
// for whichever technique occurrence it's attached to (found by
|
||||||
// it resolves correctly.
|
// span, see `findTechStepForTemperature` — temperature is a
|
||||||
|
// per-clause metadata like ingredients/utensils, corrected
|
||||||
|
// through that technique's own popover, not on its own) — same
|
||||||
|
// `data-offset` treatment either way, so a text selection
|
||||||
|
// spanning it still resolves correctly.
|
||||||
const label = temperatureLabel(segment.temperature, t);
|
const label = temperatureLabel(segment.temperature, t);
|
||||||
const modifier = segment.temperature.qualitative ?? "value";
|
const modifier = segment.temperature.qualitative ?? "value";
|
||||||
|
const owner = editable
|
||||||
|
? findTechStepForTemperature(liveTechSteps, segment.temperature)
|
||||||
|
: undefined;
|
||||||
|
const className = `step-temperature step-temperature--${modifier}`;
|
||||||
|
|
||||||
|
if (owner === undefined || owner.start === null || owner.end === null) {
|
||||||
|
// Read-only, or (for a persisted temperature this shouldn't
|
||||||
|
// happen, see `findTechStepForTemperature`'s own doc comment)
|
||||||
|
// no owning occurrence with a real span to reopen — degrades
|
||||||
|
// to the same non-interactive span as before rather than a
|
||||||
|
// dead click.
|
||||||
return (
|
return (
|
||||||
<Tooltip key={key} content={label}>
|
<Tooltip key={key} content={label}>
|
||||||
<span
|
<span className={className} data-offset={editable ? start : undefined}>
|
||||||
className={`step-temperature step-temperature--${modifier}`}
|
|
||||||
data-offset={editable ? start : undefined}
|
|
||||||
>
|
|
||||||
{segment.text}
|
{segment.text}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ownerStart = owner.start;
|
||||||
|
const ownerEnd = owner.end;
|
||||||
|
return (
|
||||||
|
<Tooltip key={key} content={label}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={className}
|
||||||
|
data-offset={start}
|
||||||
|
onClick={() => {
|
||||||
|
setPendingSpanRequest(null);
|
||||||
|
setResolvedMetadataSpan(null);
|
||||||
|
setActiveCorrection({
|
||||||
|
range: { start: ownerStart, end: ownerEnd },
|
||||||
|
selectedText: description.slice(ownerStart, ownerEnd),
|
||||||
|
previousTechStepId: owner.techStep.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!segment.techStep || !segment.isKeyword) {
|
if (!segment.techStep || !segment.isKeyword) {
|
||||||
// Context-only or plain run — rendered as plain text in
|
// Context-only or plain run — rendered as plain text in
|
||||||
// read-only mode, same as before this component supported
|
// read-only mode, same as before this component supported
|
||||||
|
|
@ -263,11 +329,13 @@ export function StepDescription({
|
||||||
<TechStepCorrectionPopover
|
<TechStepCorrectionPopover
|
||||||
recipeId={recipeId}
|
recipeId={recipeId}
|
||||||
stepId={stepId}
|
stepId={stepId}
|
||||||
|
description={description}
|
||||||
range={activeCorrection.range}
|
range={activeCorrection.range}
|
||||||
selectedText={activeCorrection.selectedText}
|
selectedText={activeCorrection.selectedText}
|
||||||
previousTechStepId={activeCorrection.previousTechStepId}
|
previousTechStepId={activeCorrection.previousTechStepId}
|
||||||
existingIngredients={activeStepTechStep?.ingredients ?? []}
|
existingIngredients={activeStepTechStep?.ingredients ?? []}
|
||||||
existingUtensils={activeStepTechStep?.utensils ?? []}
|
existingUtensils={activeStepTechStep?.utensils ?? []}
|
||||||
|
existingTemperatures={activeStepTechStep?.temperatures ?? []}
|
||||||
resolvedMetadataSpan={resolvedMetadataSpan}
|
resolvedMetadataSpan={resolvedMetadataSpan}
|
||||||
onRequestSpan={setPendingSpanRequest}
|
onRequestSpan={setPendingSpanRequest}
|
||||||
onClose={closeActiveCorrection}
|
onClose={closeActiveCorrection}
|
||||||
|
|
|
||||||
|
|
@ -2,46 +2,255 @@ import {
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
type IngredientView,
|
type IngredientView,
|
||||||
type StepTechStepIngredientView,
|
type StepTechStepIngredientView,
|
||||||
|
type StepTechStepTemperatureView,
|
||||||
type StepTechStepUtensilView,
|
type StepTechStepUtensilView,
|
||||||
type SubmitTechStepCorrectionResult,
|
type SubmitTechStepCorrectionResult,
|
||||||
type TechStepView,
|
type TechStepView,
|
||||||
type UnitView,
|
type UnitView,
|
||||||
type UtensilView,
|
type UtensilView,
|
||||||
} from "@batch-cooking/shared";
|
} from "@batch-cooking/shared";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ApiError, apiClient } from "../../../api/client";
|
import { ApiError, apiClient } from "../../../api/client";
|
||||||
import { errorMessageService } from "../../../services/error-message.service";
|
import { errorMessageService } from "../../../services/error-message.service";
|
||||||
import { ingredientLabel } from "../ingredients/ingredient-label";
|
import { ingredientLabel } from "../ingredients/ingredient-label";
|
||||||
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
import { CatalogSearchPicker } from "./CatalogSearchPicker";
|
||||||
|
import { temperatureLabel } from "./temperature-label";
|
||||||
import type { TextSelectionRange } from "./use-text-selection";
|
import type { TextSelectionRange } from "./use-text-selection";
|
||||||
|
|
||||||
/** One ingredient the viewer has attached (or is about to submit) — the trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept separately from `StepTechStepIngredientView` since a pending one has no resolved `IngredientView`/`UnitView` to carry yet, only ids. */
|
/** Which of the three metadata categories a span-selection sub-flow is currently for — see `onRequestSpan`/`resolvedMetadataSpan` below, and `StepDescription.tsx`'s own doc comment for the round-trip this drives. */
|
||||||
|
type MetadataKind = "ingredient" | "utensil" | "temperature";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ingredient the viewer has attached (or is about to submit) — the
|
||||||
|
* trimmed-down shape `POST .../corrections`'s `ingredients[]` expects, kept
|
||||||
|
* separately from `StepTechStepIngredientView` since a pending one has no
|
||||||
|
* resolved `IngredientView`/`UnitView` to carry yet, only ids. `quote` is
|
||||||
|
* purely for this popover's own row display (the exact description text at
|
||||||
|
* `[start, end)`, resolved once at seed/add time) — never sent over the
|
||||||
|
* wire, the server already knows its own text (see {@link confirm}).
|
||||||
|
*/
|
||||||
interface PendingIngredient {
|
interface PendingIngredient {
|
||||||
ingredientId: number;
|
ingredientId: number;
|
||||||
quantity: number | null;
|
quantity: number | null;
|
||||||
unitId: number | null;
|
unitId: number | null;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
|
quote: string;
|
||||||
}
|
}
|
||||||
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
|
/** Same as {@link PendingIngredient}, for a utensil (no quantity/unit — nothing to measure). */
|
||||||
interface PendingUtensil {
|
interface PendingUtensil {
|
||||||
utensilId: number;
|
utensilId: number;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
|
quote: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Same as {@link PendingIngredient}, for a temperature — mutually-exclusive
|
||||||
|
* `celsius`/`gasMark`/`qualitative`, same convention as
|
||||||
|
* `StepTechStepTemperatureView`/`manualStepTechStepTemperatureInputSchema`
|
||||||
|
* (`packages/shared`). No `raw` here either: the server derives it from the
|
||||||
|
* step's own description at submit time, see that schema's own doc comment
|
||||||
|
* for why a client-supplied copy isn't trusted.
|
||||||
|
*/
|
||||||
|
interface PendingTemperature {
|
||||||
|
celsius: number | null;
|
||||||
|
gasMark: number | null;
|
||||||
|
qualitative: "low" | "medium" | "high" | null;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
quote: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPendingIngredient(view: StepTechStepIngredientView): PendingIngredient {
|
function toPendingIngredient(
|
||||||
|
view: StepTechStepIngredientView,
|
||||||
|
description: string,
|
||||||
|
): PendingIngredient {
|
||||||
return {
|
return {
|
||||||
ingredientId: view.ingredient.id,
|
ingredientId: view.ingredient.id,
|
||||||
quantity: view.quantity,
|
quantity: view.quantity,
|
||||||
unitId: view.unit?.id ?? null,
|
unitId: view.unit?.id ?? null,
|
||||||
start: view.start,
|
start: view.start,
|
||||||
end: view.end,
|
end: view.end,
|
||||||
|
quote: description.slice(view.start, view.end),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
|
function toPendingUtensil(view: StepTechStepUtensilView, description: string): PendingUtensil {
|
||||||
return { utensilId: view.utensil.id, start: view.start, end: view.end };
|
return {
|
||||||
|
utensilId: view.utensil.id,
|
||||||
|
start: view.start,
|
||||||
|
end: view.end,
|
||||||
|
quote: description.slice(view.start, view.end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function toPendingTemperature(
|
||||||
|
view: StepTechStepTemperatureView,
|
||||||
|
description: string,
|
||||||
|
): PendingTemperature {
|
||||||
|
return {
|
||||||
|
celsius: view.celsius,
|
||||||
|
gasMark: view.gasMark,
|
||||||
|
qualitative: view.qualitative,
|
||||||
|
start: view.start,
|
||||||
|
end: view.end,
|
||||||
|
quote: description.slice(view.start, view.end),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Icons ------------------------------------------------------------------
|
||||||
|
// Stroke-based, 24×24, matching the `lucide-react` line-icon language the
|
||||||
|
// rest of the app draws its icons from (see `layouts/nav-icons.tsx`'s own
|
||||||
|
// doc comment) — hand-drawn here rather than pulled from that shared barrel
|
||||||
|
// since these four are only ever used inside this one component, not worth
|
||||||
|
// promoting to an app-wide icon.
|
||||||
|
|
||||||
|
function TagIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M11 3H5a2 2 0 0 0-2 2v6l9.6 9.6a2 2 0 0 0 2.8 0l6-6a2 2 0 0 0 0-2.8L11 3Z" />
|
||||||
|
<circle cx="7.5" cy="7.5" r="1.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function LeafIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z" />
|
||||||
|
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function PanIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="14" r="6" />
|
||||||
|
<line x1="15.5" y1="9.5" x2="21" y2="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function ThermometerIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M10 13.5V4a2 2 0 1 1 4 0v9.5a4 4 0 1 1-4 0Z" />
|
||||||
|
<line x1="12" y1="8" x2="14" y2="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function PlusIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function RemoveIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A metadata section's icon + uppercase label + item count — Technique/Ingrédients/Ustensiles/Température all share this same header shape. */
|
||||||
|
function SectionHeader({ icon, title, count }: { icon: ReactNode; title: string; count?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="tech-step-correction-popover__section-header">
|
||||||
|
{icon}
|
||||||
|
<span className="tech-step-correction-popover__section-title">
|
||||||
|
{title}
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="tech-step-correction-popover__section-count"> · {count}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One attached ingredient/utensil/temperature — name/value, the exact description text it was resolved from, an optional trailing badge (an ingredient's quantity), and a remove button. Ingrédients/Ustensiles/Température all render their pending list through this same row shape. */
|
||||||
|
function MetadataRow({
|
||||||
|
label,
|
||||||
|
quote,
|
||||||
|
badge,
|
||||||
|
onRemove,
|
||||||
|
removeLabel,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
quote: string;
|
||||||
|
badge?: string;
|
||||||
|
onRemove: () => void;
|
||||||
|
removeLabel: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="tech-step-correction-popover__row">
|
||||||
|
<div className="tech-step-correction-popover__row-body">
|
||||||
|
<span className="tech-step-correction-popover__row-name">{label}</span>
|
||||||
|
<span className="tech-step-correction-popover__row-quote">« {quote} »</span>
|
||||||
|
</div>
|
||||||
|
{badge !== undefined && (
|
||||||
|
<span className="tech-step-correction-popover__row-badge">{badge}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__row-remove"
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={disabled}
|
||||||
|
title={removeLabel}
|
||||||
|
aria-label={removeLabel}
|
||||||
|
>
|
||||||
|
<RemoveIcon />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,37 +266,43 @@ function toPendingUtensil(view: StepTechStepUtensilView): PendingUtensil {
|
||||||
* position — simpler and more robust than tracking a caret-anchored
|
* position — simpler and more robust than tracking a caret-anchored
|
||||||
* position across scroll/resize, at the cost of a little visual distance
|
* position across scroll/resize, at the cost of a little visual distance
|
||||||
* from the selected text itself. That placement matters beyond cosmetics
|
* from the selected text itself. That placement matters beyond cosmetics
|
||||||
* here: it's *why* the "attach an ingredient/utensil" flow below can ask
|
* here: it's *why* the "attach an ingredient/utensil/temperature" flow
|
||||||
* the viewer to select a second span of text without closing this popover
|
* below can ask the viewer to select a second span of text without closing
|
||||||
* first — the description stays fully visible and selectable the whole
|
* this popover first — the description stays fully visible and selectable
|
||||||
* time, nothing overlays it.
|
* the whole time, nothing overlays it.
|
||||||
*
|
*
|
||||||
* **One merged editor, not a wizard**: picking a technique
|
* **One merged editor, not a wizard**: the technique picker
|
||||||
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
|
* (`CatalogSearchPicker`, searchable — the reference catalog is ~74
|
||||||
* entries, an unfiltered flat list wasn't browsable) and editing its
|
* entries, an unfiltered flat list wasn't browsable) and its Ingrédients/
|
||||||
* Ingrédients/Ustensiles metadata render together on the same screen,
|
* Ustensiles/Température metadata all render on the same screen, always —
|
||||||
* always — there's no separate "pick, then a metadata step reveals
|
* there's no separate "pick, then a metadata step reveals itself" sequence,
|
||||||
* itself" sequence to go through, and no dead end where metadata is
|
* and adding one metadata mention never hides the other sections or the
|
||||||
* technically attachable but not visible until some other action happens
|
* technique picker while it's in progress (each section manages its own
|
||||||
* first. A single "Valider" submits everything at once; disabled until a
|
* "adding" sub-form independently, see `activeSpan`/`awaitingSpanFor`
|
||||||
|
* below). A single "Valider" submits everything at once; disabled until a
|
||||||
* technique is actually selected (there's nothing to attach metadata to
|
* technique is actually selected (there's nothing to attach metadata to
|
||||||
* otherwise). **Removing** a match (`submit(null)`) stays its own
|
* otherwise). **Removing** a match (`submit(null)`) stays its own
|
||||||
* immediate action next to the picker — nothing to attach when removing.
|
* immediate action next to the picker — nothing to attach when removing.
|
||||||
|
* The technique picker itself hides behind "Changer" once a technique is
|
||||||
|
* already picked (there's nothing to decide once it's confirmed correct) —
|
||||||
|
* shown by default only while nothing's picked yet.
|
||||||
*
|
*
|
||||||
* The two metadata sections are pre-seeded from `existingIngredients`/
|
* The three metadata sections are pre-seeded from `existingIngredients`/
|
||||||
* `existingUtensils` (whatever's already attached to this occurrence, auto-
|
* `existingUtensils`/`existingTemperatures` (whatever's already attached to
|
||||||
* or manually-sourced — `[]` for a brand-new technique) and editable via
|
* this occurrence, auto- or manually-sourced — `[]` for a brand-new
|
||||||
* add/remove — see `metadataTouched` below for why what's *displayed* here
|
* technique) and editable via add/remove — see `metadataTouched` below for
|
||||||
* isn't automatically what gets *submitted*.
|
* why what's *displayed* here isn't automatically what gets *submitted*.
|
||||||
*/
|
*/
|
||||||
export function TechStepCorrectionPopover({
|
export function TechStepCorrectionPopover({
|
||||||
recipeId,
|
recipeId,
|
||||||
stepId,
|
stepId,
|
||||||
|
description,
|
||||||
selectedText,
|
selectedText,
|
||||||
range,
|
range,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
existingIngredients,
|
existingIngredients,
|
||||||
existingUtensils,
|
existingUtensils,
|
||||||
|
existingTemperatures,
|
||||||
resolvedMetadataSpan,
|
resolvedMetadataSpan,
|
||||||
onRequestSpan,
|
onRequestSpan,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -95,14 +310,17 @@ export function TechStepCorrectionPopover({
|
||||||
}: {
|
}: {
|
||||||
recipeId: number;
|
recipeId: number;
|
||||||
stepId: number;
|
stepId: number;
|
||||||
|
/** The step's full, original text — needed to show each attached ingredient/utensil/temperature's own source quote (`description.slice(start, end)`), not just the technique's own `selectedText`. */
|
||||||
|
description: string;
|
||||||
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
|
/** The selected span's own text — shown so the user confirms what they're tagging before picking a technique. */
|
||||||
selectedText: string;
|
selectedText: string;
|
||||||
range: TextSelectionRange;
|
range: TextSelectionRange;
|
||||||
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
|
/** Set when correcting an already-detected match (opened from clicking its highlight) rather than a fresh selection — passed through as-is on submit, and offers a "remove" option `null` doesn't. */
|
||||||
previousTechStepId: number | null;
|
previousTechStepId: number | null;
|
||||||
/** Whatever ingredients/utensils already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
|
/** Whatever ingredients/utensils/temperatures already sit on this occurrence (both `"auto"` and `"manual"` sourced) — `[]` for a brand-new technique, nothing to pre-seed. */
|
||||||
existingIngredients: StepTechStepIngredientView[];
|
existingIngredients: StepTechStepIngredientView[];
|
||||||
existingUtensils: StepTechStepUtensilView[];
|
existingUtensils: StepTechStepUtensilView[];
|
||||||
|
existingTemperatures: StepTechStepTemperatureView[];
|
||||||
/**
|
/**
|
||||||
* A text span `StepDescription` just resolved on this popover's behalf,
|
* A text span `StepDescription` just resolved on this popover's behalf,
|
||||||
* after a call to `onRequestSpan` below — `null` until then. Identified
|
* after a call to `onRequestSpan` below — `null` until then. Identified
|
||||||
|
|
@ -112,26 +330,23 @@ export function TechStepCorrectionPopover({
|
||||||
*/
|
*/
|
||||||
resolvedMetadataSpan: {
|
resolvedMetadataSpan: {
|
||||||
nonce: number;
|
nonce: number;
|
||||||
kind: "ingredient" | "utensil";
|
kind: MetadataKind;
|
||||||
range: TextSelectionRange;
|
range: TextSelectionRange;
|
||||||
text: string;
|
text: string;
|
||||||
} | null;
|
} | null;
|
||||||
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil mention, not a new technique correction" — see this component's own doc comment. */
|
/** Tells `StepDescription` "the next text selection in the description is for an ingredient/utensil/temperature mention, not a new technique correction" — see this component's own doc comment. */
|
||||||
onRequestSpan: (kind: "ingredient" | "utensil") => void;
|
onRequestSpan: (kind: MetadataKind) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
|
onSubmitted: (result: SubmitTechStepCorrectionResult) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
const popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
const [techSteps, setTechSteps] = useState<TechStepView[] | null>(null);
|
||||||
// Opening this popover on an *already-detected* match (`previousTechStepId
|
|
||||||
// !== null`, i.e. the user clicked an existing highlight rather than
|
|
||||||
// selecting fresh text) starts pre-selected on that same technique, its
|
|
||||||
// name shown next to the picker right away — since the picker and the
|
|
||||||
// metadata sections render together regardless (see this component's own
|
|
||||||
// doc comment), this just saves re-picking the technique that's already
|
|
||||||
// correct before its metadata becomes editable.
|
|
||||||
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
const [selectedTechStepId, setSelectedTechStepId] = useState<number | null>(previousTechStepId);
|
||||||
|
// The technique picker hides behind "Changer" once something's already
|
||||||
|
// picked (see this component's own doc comment) — starts open only when
|
||||||
|
// there's nothing to show yet (a brand-new selection).
|
||||||
|
const [isChangingTechnique, setIsChangingTechnique] = useState(previousTechStepId === null);
|
||||||
const [catalogs, setCatalogs] = useState<{
|
const [catalogs, setCatalogs] = useState<{
|
||||||
ingredients: IngredientView[];
|
ingredients: IngredientView[];
|
||||||
units: UnitView[];
|
units: UnitView[];
|
||||||
|
|
@ -139,24 +354,28 @@ export function TechStepCorrectionPopover({
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
|
const [pendingIngredients, setPendingIngredients] = useState<PendingIngredient[]>(() =>
|
||||||
existingIngredients.map(toPendingIngredient),
|
existingIngredients.map((view) => toPendingIngredient(view, description)),
|
||||||
);
|
);
|
||||||
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
|
const [pendingUtensils, setPendingUtensils] = useState<PendingUtensil[]>(() =>
|
||||||
existingUtensils.map(toPendingUtensil),
|
existingUtensils.map((view) => toPendingUtensil(view, description)),
|
||||||
);
|
);
|
||||||
// Flips true the moment the viewer adds/removes a pending entry — never
|
const [pendingTemperatures, setPendingTemperatures] = useState<PendingTemperature[]>(() =>
|
||||||
// from the initial seeding above. `submit()` below only includes
|
existingTemperatures.map((view) => toPendingTemperature(view, description)),
|
||||||
// `ingredients`/`utensils` in the request when this is true, so
|
);
|
||||||
// relabeling/confirming a technique without ever opening either section
|
// Flips true the moment the viewer adds/removes a pending entry in *any*
|
||||||
// leaves existing metadata completely alone server-side (see
|
// of the three metadata sections — never from the initial seeding above.
|
||||||
// `submitTechStepCorrectionSchema`'s own doc comment, `packages/shared`,
|
// `confirm()` below only includes ingredients/utensils/temperatures in
|
||||||
// for why an *omitted* field — not an empty array — is what "don't
|
// the request when this is true, so relabeling/confirming a technique
|
||||||
// touch it" means over the wire).
|
// without ever touching a metadata section leaves existing metadata
|
||||||
|
// completely alone server-side (see `submitTechStepCorrectionSchema`'s
|
||||||
|
// own doc comment, `packages/shared`, for why an *omitted* field — not
|
||||||
|
// an empty array — is what "don't touch it" means over the wire). The
|
||||||
|
// three travel as one bundle: touching any one resends all three.
|
||||||
const [metadataTouched, setMetadataTouched] = useState(false);
|
const [metadataTouched, setMetadataTouched] = useState(false);
|
||||||
|
|
||||||
const [awaitingSpanFor, setAwaitingSpanFor] = useState<"ingredient" | "utensil" | null>(null);
|
const [awaitingSpanFor, setAwaitingSpanFor] = useState<MetadataKind | null>(null);
|
||||||
const [activeSpan, setActiveSpan] = useState<{
|
const [activeSpan, setActiveSpan] = useState<{
|
||||||
kind: "ingredient" | "utensil";
|
kind: MetadataKind;
|
||||||
range: TextSelectionRange;
|
range: TextSelectionRange;
|
||||||
text: string;
|
text: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
@ -167,6 +386,14 @@ export function TechStepCorrectionPopover({
|
||||||
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
|
const [pickedIngredientId, setPickedIngredientId] = useState<number | null>(null);
|
||||||
const [spanQuantity, setSpanQuantity] = useState("");
|
const [spanQuantity, setSpanQuantity] = useState("");
|
||||||
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
|
const [spanUnitId, setSpanUnitId] = useState<number | null>(null);
|
||||||
|
// Only meaningful while `activeSpan?.kind === "temperature"` — which of
|
||||||
|
// the three mutually-exclusive representations the mini-form currently
|
||||||
|
// shows an input for (see `manualStepTechStepTemperatureInputSchema`'s
|
||||||
|
// own doc comment, `packages/shared`, for why only one at a time).
|
||||||
|
const [tempMode, setTempMode] = useState<"celsius" | "gasMark" | "qualitative">("qualitative");
|
||||||
|
const [tempCelsiusValue, setTempCelsiusValue] = useState("");
|
||||||
|
const [tempGasMarkValue, setTempGasMarkValue] = useState("");
|
||||||
|
const [tempQualChoice, setTempQualChoice] = useState<"low" | "medium" | "high">("low");
|
||||||
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -186,8 +413,8 @@ export function TechStepCorrectionPopover({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetched unconditionally on mount — the Ingrédients/Ustensiles sections
|
// Fetched unconditionally on mount — the Ingrédients/Ustensiles/Température
|
||||||
// render alongside the technique picker from the start (see this
|
// sections render alongside the technique picker from the start (see this
|
||||||
// component's own doc comment on the merged editor), so there's no later
|
// component's own doc comment on the merged editor), so there's no later
|
||||||
// point to defer this to anymore.
|
// point to defer this to anymore.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -207,7 +434,7 @@ export function TechStepCorrectionPopover({
|
||||||
|
|
||||||
// Consumes a span `StepDescription` just resolved on this popover's
|
// Consumes a span `StepDescription` just resolved on this popover's
|
||||||
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
|
// behalf (see `resolvedMetadataSpan`'s own doc comment above) — opens the
|
||||||
// matching sub-picker and clears the "awaiting a selection" hint.
|
// matching section's sub-form and clears the "awaiting a selection" hint.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resolvedMetadataSpan === null) return;
|
if (resolvedMetadataSpan === null) return;
|
||||||
setActiveSpan({
|
setActiveSpan({
|
||||||
|
|
@ -219,6 +446,10 @@ export function TechStepCorrectionPopover({
|
||||||
setPickedIngredientId(null);
|
setPickedIngredientId(null);
|
||||||
setSpanQuantity("");
|
setSpanQuantity("");
|
||||||
setSpanUnitId(null);
|
setSpanUnitId(null);
|
||||||
|
setTempMode("qualitative");
|
||||||
|
setTempCelsiusValue("");
|
||||||
|
setTempGasMarkValue("");
|
||||||
|
setTempQualChoice("low");
|
||||||
// Depends on the whole object, not just `.nonce` — `StepDescription`
|
// Depends on the whole object, not just `.nonce` — `StepDescription`
|
||||||
// only ever calls its setter with a brand-new object (never mutates
|
// only ever calls its setter with a brand-new object (never mutates
|
||||||
// one in place), so reference equality alone already gives this the
|
// one in place), so reference equality alone already gives this the
|
||||||
|
|
@ -265,7 +496,13 @@ export function TechStepCorrectionPopover({
|
||||||
end: range.end,
|
end: range.end,
|
||||||
previousTechStepId,
|
previousTechStepId,
|
||||||
correctedTechStepId: selectedTechStepId,
|
correctedTechStepId: selectedTechStepId,
|
||||||
...(metadataTouched ? { ingredients: pendingIngredients, utensils: pendingUtensils } : {}),
|
...(metadataTouched
|
||||||
|
? {
|
||||||
|
ingredients: pendingIngredients.map(({ quote: _quote, ...rest }) => rest),
|
||||||
|
utensils: pendingUtensils.map(({ quote: _quote, ...rest }) => rest),
|
||||||
|
temperatures: pendingTemperatures.map(({ quote: _quote, ...rest }) => rest),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
onSubmitted(result);
|
onSubmitted(result);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
@ -276,7 +513,7 @@ export function TechStepCorrectionPopover({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestSpan(kind: "ingredient" | "utensil") {
|
function requestSpan(kind: MetadataKind) {
|
||||||
setAwaitingSpanFor(kind);
|
setAwaitingSpanFor(kind);
|
||||||
onRequestSpan(kind);
|
onRequestSpan(kind);
|
||||||
}
|
}
|
||||||
|
|
@ -300,6 +537,7 @@ export function TechStepCorrectionPopover({
|
||||||
unitId: spanUnitId,
|
unitId: spanUnitId,
|
||||||
start: activeSpan.range.start,
|
start: activeSpan.range.start,
|
||||||
end: activeSpan.range.end,
|
end: activeSpan.range.end,
|
||||||
|
quote: activeSpan.text,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setMetadataTouched(true);
|
setMetadataTouched(true);
|
||||||
|
|
@ -311,7 +549,33 @@ export function TechStepCorrectionPopover({
|
||||||
if (activeSpan === null) return;
|
if (activeSpan === null) return;
|
||||||
setPendingUtensils((prev) => [
|
setPendingUtensils((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ utensilId, start: activeSpan.range.start, end: activeSpan.range.end },
|
{
|
||||||
|
utensilId,
|
||||||
|
start: activeSpan.range.start,
|
||||||
|
end: activeSpan.range.end,
|
||||||
|
quote: activeSpan.text,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setMetadataTouched(true);
|
||||||
|
setActiveSpan(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmTemperatureSpan() {
|
||||||
|
if (activeSpan === null) return;
|
||||||
|
const value =
|
||||||
|
tempMode === "celsius"
|
||||||
|
? { celsius: Number(tempCelsiusValue) || null, gasMark: null, qualitative: null }
|
||||||
|
: tempMode === "gasMark"
|
||||||
|
? { celsius: null, gasMark: Number(tempGasMarkValue) || null, qualitative: null }
|
||||||
|
: { celsius: null, gasMark: null, qualitative: tempQualChoice };
|
||||||
|
setPendingTemperatures((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...value,
|
||||||
|
start: activeSpan.range.start,
|
||||||
|
end: activeSpan.range.end,
|
||||||
|
quote: activeSpan.text,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
setMetadataTouched(true);
|
setMetadataTouched(true);
|
||||||
setActiveSpan(null);
|
setActiveSpan(null);
|
||||||
|
|
@ -325,10 +589,18 @@ export function TechStepCorrectionPopover({
|
||||||
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
|
setPendingUtensils((prev) => prev.filter((_, i) => i !== index));
|
||||||
setMetadataTouched(true);
|
setMetadataTouched(true);
|
||||||
}
|
}
|
||||||
|
function removeTemperature(index: number) {
|
||||||
|
setPendingTemperatures((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
setMetadataTouched(true);
|
||||||
|
}
|
||||||
|
|
||||||
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
|
const ingredientById = new Map((catalogs?.ingredients ?? []).map((i) => [i.id, i]));
|
||||||
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
|
const unitById = new Map((catalogs?.units ?? []).map((u) => [u.id, u]));
|
||||||
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
|
const utensilById = new Map((catalogs?.utensils ?? []).map((u) => [u.id, u]));
|
||||||
|
const currentTechniqueLabel =
|
||||||
|
selectedTechStepId !== null
|
||||||
|
? t(`catalog.techSteps.${techSteps?.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tech-step-correction-popover" ref={popoverRef}>
|
<div className="tech-step-correction-popover" ref={popoverRef}>
|
||||||
|
|
@ -336,17 +608,100 @@ export function TechStepCorrectionPopover({
|
||||||
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
|
{t("recipes.techStepCorrection.selectionLabel", { text: selectedText })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{awaitingSpanFor !== null ? (
|
{techSteps === null ? (
|
||||||
|
<p>{t("recipes.loading")}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="tech-step-correction-popover__section tech-step-correction-popover__technique-section">
|
||||||
|
<SectionHeader
|
||||||
|
icon={<TagIcon />}
|
||||||
|
title={t("recipes.techStepCorrection.techniqueSection")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="tech-step-correction-popover__chosen-technique">
|
||||||
|
{currentTechniqueLabel !== null ? (
|
||||||
|
<span className="tech-step-correction-popover__technique-pill">
|
||||||
|
{currentTechniqueLabel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t("recipes.techStepCorrection.noTechniqueSelected")
|
||||||
|
)}
|
||||||
|
{currentTechniqueLabel !== null && !isChangingTechnique && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__technique-change"
|
||||||
|
onClick={() => setIsChangingTechnique(true)}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.changeTechnique")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{previousTechStepId !== null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onClick={removeMatch}
|
||||||
|
className="tech-step-correction-popover__remove-match"
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.removeMatch")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(currentTechniqueLabel === null || isChangingTechnique) && (
|
||||||
|
<CatalogSearchPicker
|
||||||
|
items={techSteps.map((techStep) => ({
|
||||||
|
id: techStep.id,
|
||||||
|
label: t(`catalog.techSteps.${techStep.key}`),
|
||||||
|
}))}
|
||||||
|
selectedId={selectedTechStepId ?? undefined}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setSelectedTechStepId(id);
|
||||||
|
setIsChangingTechnique(false);
|
||||||
|
}}
|
||||||
|
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
||||||
|
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tech-step-correction-popover__section tech-step-correction-popover__ingredients-section">
|
||||||
|
<SectionHeader
|
||||||
|
icon={<LeafIcon />}
|
||||||
|
title={t("recipes.techStepCorrection.ingredientsSection")}
|
||||||
|
count={pendingIngredients.length}
|
||||||
|
/>
|
||||||
|
{pendingIngredients.length > 0 && (
|
||||||
|
<ul className="tech-step-correction-popover__rows">
|
||||||
|
{pendingIngredients.map((ingredient, index) => {
|
||||||
|
const view = ingredientById.get(ingredient.ingredientId);
|
||||||
|
const unit =
|
||||||
|
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
|
||||||
|
return (
|
||||||
|
<MetadataRow
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
|
||||||
|
key={index}
|
||||||
|
label={view ? ingredientLabel(view, t) : "…"}
|
||||||
|
quote={ingredient.quote}
|
||||||
|
badge={
|
||||||
|
ingredient.quantity !== null
|
||||||
|
? `${ingredient.quantity}${unit ? ` ${t(`catalog.units.${unit.key}`)}` : ""}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onRemove={() => removeIngredient(index)}
|
||||||
|
removeLabel={t("recipes.techStepCorrection.removeIngredient")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{awaitingSpanFor === "ingredient" ? (
|
||||||
<p className="tech-step-correction-popover__hint">
|
<p className="tech-step-correction-popover__hint">
|
||||||
{t("recipes.techStepCorrection.selectSpanHint")}
|
{t("recipes.techStepCorrection.selectSpanHint")}
|
||||||
</p>
|
</p>
|
||||||
) : activeSpan !== null ? (
|
) : activeSpan?.kind === "ingredient" ? (
|
||||||
<div className="tech-step-correction-popover__span-picker">
|
<div className="tech-step-correction-popover__add-form">
|
||||||
<p className="tech-step-correction-popover__selection">
|
{pickedIngredientId === null ? (
|
||||||
{t("recipes.techStepCorrection.selectionLabel", { text: activeSpan.text })}
|
|
||||||
</p>
|
|
||||||
{activeSpan.kind === "ingredient" ? (
|
|
||||||
pickedIngredientId === null ? (
|
|
||||||
<CatalogSearchPicker
|
<CatalogSearchPicker
|
||||||
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
|
items={(catalogs?.ingredients ?? []).map((ingredient) => ({
|
||||||
id: ingredient.id,
|
id: ingredient.id,
|
||||||
|
|
@ -368,7 +723,9 @@ export function TechStepCorrectionPopover({
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={spanUnitId ?? ""}
|
value={spanUnitId ?? ""}
|
||||||
onChange={(e) => setSpanUnitId(e.target.value ? Number(e.target.value) : null)}
|
onChange={(e) =>
|
||||||
|
setSpanUnitId(e.target.value ? Number(e.target.value) : null)
|
||||||
|
}
|
||||||
aria-label={t("recipes.form.unitLabel")}
|
aria-label={t("recipes.form.unitLabel")}
|
||||||
>
|
>
|
||||||
<option value="">{t("recipes.form.unitPlaceholder")}</option>
|
<option value="">{t("recipes.form.unitPlaceholder")}</option>
|
||||||
|
|
@ -378,12 +735,66 @@ export function TechStepCorrectionPopover({
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button type="button" onClick={confirmIngredientSpan}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__confirm-mini"
|
||||||
|
onClick={confirmIngredientSpan}
|
||||||
|
>
|
||||||
{t("recipes.techStepCorrection.addToList")}
|
{t("recipes.techStepCorrection.addToList")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__cancel-mini"
|
||||||
|
onClick={cancelSpanSelection}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.cancelSpanSelection")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__add-row"
|
||||||
|
onClick={() => requestSpan("ingredient")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
{t("recipes.techStepCorrection.addIngredient")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="tech-step-correction-popover__section tech-step-correction-popover__utensils-section">
|
||||||
|
<SectionHeader
|
||||||
|
icon={<PanIcon />}
|
||||||
|
title={t("recipes.techStepCorrection.utensilsSection")}
|
||||||
|
count={pendingUtensils.length}
|
||||||
|
/>
|
||||||
|
{pendingUtensils.length > 0 && (
|
||||||
|
<ul className="tech-step-correction-popover__rows">
|
||||||
|
{pendingUtensils.map((utensil, index) => {
|
||||||
|
const view = utensilById.get(utensil.utensilId);
|
||||||
|
return (
|
||||||
|
<MetadataRow
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient row list above.
|
||||||
|
key={index}
|
||||||
|
label={view ? t(`catalog.utensils.${view.key}`) : "…"}
|
||||||
|
quote={utensil.quote}
|
||||||
|
onRemove={() => removeUtensil(index)}
|
||||||
|
removeLabel={t("recipes.techStepCorrection.removeUtensil")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{awaitingSpanFor === "utensil" ? (
|
||||||
|
<p className="tech-step-correction-popover__hint">
|
||||||
|
{t("recipes.techStepCorrection.selectSpanHint")}
|
||||||
|
</p>
|
||||||
|
) : activeSpan?.kind === "utensil" ? (
|
||||||
|
<div className="tech-step-correction-popover__add-form">
|
||||||
<CatalogSearchPicker
|
<CatalogSearchPicker
|
||||||
items={(catalogs?.utensils ?? []).map((utensil) => ({
|
items={(catalogs?.utensils ?? []).map((utensil) => ({
|
||||||
id: utensil.id,
|
id: utensil.id,
|
||||||
|
|
@ -393,111 +804,169 @@ export function TechStepCorrectionPopover({
|
||||||
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
|
placeholder={t("recipes.techStepCorrection.searchUtensilPlaceholder")}
|
||||||
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
|
emptyLabel={t("recipes.techStepCorrection.noUtensilFound")}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tech-step-correction-popover__cancel"
|
className="tech-step-correction-popover__cancel-mini"
|
||||||
onClick={cancelSpanSelection}
|
onClick={cancelSpanSelection}
|
||||||
>
|
>
|
||||||
{t("recipes.techStepCorrection.cancelSpanSelection")}
|
{t("recipes.techStepCorrection.cancelSpanSelection")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : techSteps === null ? (
|
|
||||||
<p>{t("recipes.loading")}</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="tech-step-correction-popover__confirm">
|
|
||||||
<section className="tech-step-correction-popover__technique-section">
|
|
||||||
<div className="tech-step-correction-popover__technique-header">
|
|
||||||
<h4>{t("recipes.techStepCorrection.techniqueSection")}</h4>
|
|
||||||
{previousTechStepId !== null && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isSubmitting}
|
className="tech-step-correction-popover__add-row"
|
||||||
onClick={removeMatch}
|
onClick={() => requestSpan("utensil")}
|
||||||
className="tech-step-correction-popover__remove"
|
|
||||||
>
|
|
||||||
{t("recipes.techStepCorrection.removeMatch")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="tech-step-correction-popover__chosen-technique">
|
|
||||||
{selectedTechStepId !== null
|
|
||||||
? t("recipes.techStepCorrection.currentTechnique", {
|
|
||||||
technique: t(
|
|
||||||
`catalog.techSteps.${techSteps.find((ts) => ts.id === selectedTechStepId)?.key ?? ""}`,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
: t("recipes.techStepCorrection.noTechniqueSelected")}
|
|
||||||
</p>
|
|
||||||
<CatalogSearchPicker
|
|
||||||
items={techSteps.map((techStep) => ({
|
|
||||||
id: techStep.id,
|
|
||||||
label: t(`catalog.techSteps.${techStep.key}`),
|
|
||||||
}))}
|
|
||||||
selectedId={selectedTechStepId ?? undefined}
|
|
||||||
onSelect={setSelectedTechStepId}
|
|
||||||
placeholder={t("recipes.techStepCorrection.searchTechniquePlaceholder")}
|
|
||||||
emptyLabel={t("recipes.techStepCorrection.noTechniqueFound")}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="tech-step-correction-popover__metadata-section">
|
|
||||||
<h4>{t("recipes.techStepCorrection.ingredientsSection")}</h4>
|
|
||||||
<ul className="tech-step-correction-popover__chips">
|
|
||||||
{pendingIngredients.map((ingredient, index) => {
|
|
||||||
const view = ingredientById.get(ingredient.ingredientId);
|
|
||||||
const unit =
|
|
||||||
ingredient.unitId !== null ? unitById.get(ingredient.unitId) : undefined;
|
|
||||||
const label = view ? ingredientLabel(view, t) : "…";
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: `pendingIngredients` has no other stable identity (an ingredient can appear more than once, each with its own span) — always fully rebuilt on add/remove, never reordered in place.
|
|
||||||
<li key={index} className="tech-step-correction-popover__chip">
|
|
||||||
{ingredient.quantity !== null ? `${ingredient.quantity} ` : ""}
|
|
||||||
{unit ? `${t(`catalog.units.${unit.key}`)} ` : ""}
|
|
||||||
{label}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeIngredient(index)}
|
|
||||||
title={t("recipes.techStepCorrection.removeIngredient")}
|
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
✕
|
<PlusIcon />
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<button type="button" onClick={() => requestSpan("ingredient")} disabled={isSubmitting}>
|
|
||||||
{t("recipes.techStepCorrection.addIngredient")}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="tech-step-correction-popover__metadata-section">
|
|
||||||
<h4>{t("recipes.techStepCorrection.utensilsSection")}</h4>
|
|
||||||
<ul className="tech-step-correction-popover__chips">
|
|
||||||
{pendingUtensils.map((utensil, index) => {
|
|
||||||
const view = utensilById.get(utensil.utensilId);
|
|
||||||
return (
|
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient chip list above.
|
|
||||||
<li key={index} className="tech-step-correction-popover__chip">
|
|
||||||
{view ? t(`catalog.utensils.${view.key}`) : "…"}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeUtensil(index)}
|
|
||||||
title={t("recipes.techStepCorrection.removeUtensil")}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
<button type="button" onClick={() => requestSpan("utensil")} disabled={isSubmitting}>
|
|
||||||
{t("recipes.techStepCorrection.addUtensil")}
|
{t("recipes.techStepCorrection.addUtensil")}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="tech-step-correction-popover__section tech-step-correction-popover__temperature-section">
|
||||||
|
<SectionHeader
|
||||||
|
icon={<ThermometerIcon />}
|
||||||
|
title={t("recipes.techStepCorrection.temperatureSection")}
|
||||||
|
count={pendingTemperatures.length}
|
||||||
|
/>
|
||||||
|
{pendingTemperatures.length > 0 && (
|
||||||
|
<ul className="tech-step-correction-popover__rows">
|
||||||
|
{pendingTemperatures.map((temperature, index) => (
|
||||||
|
<MetadataRow
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: same reasoning as the ingredient row list above.
|
||||||
|
key={index}
|
||||||
|
label={temperatureLabel(temperature, t)}
|
||||||
|
quote={temperature.quote}
|
||||||
|
onRemove={() => removeTemperature(index)}
|
||||||
|
removeLabel={t("recipes.techStepCorrection.removeTemperature")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{awaitingSpanFor === "temperature" ? (
|
||||||
|
<p className="tech-step-correction-popover__hint">
|
||||||
|
{t("recipes.techStepCorrection.selectSpanHint")}
|
||||||
|
</p>
|
||||||
|
) : activeSpan?.kind === "temperature" ? (
|
||||||
|
<div className="tech-step-correction-popover__add-form">
|
||||||
|
<div className="tech-step-correction-popover__mode-switch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tempMode === "celsius" ? "active" : ""}
|
||||||
|
onClick={() => setTempMode("celsius")}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.temperatureMode.celsius")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tempMode === "gasMark" ? "active" : ""}
|
||||||
|
onClick={() => setTempMode("gasMark")}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.temperatureMode.gasMark")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tempMode === "qualitative" ? "active" : ""}
|
||||||
|
onClick={() => setTempMode("qualitative")}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.temperatureMode.qualitative")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tempMode === "celsius" && (
|
||||||
|
<div className="tech-step-correction-popover__value-line">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={tempCelsiusValue}
|
||||||
|
onChange={(e) => setTempCelsiusValue(e.target.value)}
|
||||||
|
aria-label={t("recipes.techStepCorrection.temperatureMode.celsius")}
|
||||||
|
/>
|
||||||
|
<span className="tech-step-correction-popover__value-unit">°C</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tempMode === "gasMark" && (
|
||||||
|
<div className="tech-step-correction-popover__value-line">
|
||||||
|
<span className="tech-step-correction-popover__value-unit">
|
||||||
|
{t("recipes.techStepCorrection.temperatureMode.gasMark")}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={tempGasMarkValue}
|
||||||
|
onChange={(e) => setTempGasMarkValue(e.target.value)}
|
||||||
|
aria-label={t("recipes.techStepCorrection.temperatureMode.gasMark")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tempMode === "qualitative" && (
|
||||||
|
<div className="tech-step-correction-popover__qual-switch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tempQualChoice === "low" ? "active" : ""}
|
||||||
|
onClick={() => setTempQualChoice("low")}
|
||||||
|
>
|
||||||
|
{t("recipes.temperature.qualitative.low")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tempQualChoice === "medium" ? "active" : ""}
|
||||||
|
onClick={() => setTempQualChoice("medium")}
|
||||||
|
>
|
||||||
|
{t("recipes.temperature.qualitative.medium")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={tempQualChoice === "high" ? "active" : ""}
|
||||||
|
onClick={() => setTempQualChoice("high")}
|
||||||
|
>
|
||||||
|
{t("recipes.temperature.qualitative.high")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="tech-step-correction-popover__add-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__cancel-mini"
|
||||||
|
onClick={cancelSpanSelection}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.cancelSpanSelection")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__confirm-mini"
|
||||||
|
onClick={confirmTemperatureSpan}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.addToList")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__add-row"
|
||||||
|
onClick={() => requestSpan("temperature")}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
{t("recipes.techStepCorrection.addTemperature")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="tech-step-correction-popover__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="tech-step-correction-popover__cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{t("recipes.techStepCorrection.cancel")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="tech-step-correction-popover__confirm-button"
|
className="tech-step-correction-popover__confirm-button"
|
||||||
|
|
@ -507,17 +976,10 @@ export function TechStepCorrectionPopover({
|
||||||
{t("recipes.techStepCorrection.confirm")}
|
{t("recipes.techStepCorrection.confirm")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && <p className="field-error">{error}</p>}
|
{error && <p className="field-error">{error}</p>}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="tech-step-correction-popover__cancel"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("recipes.techStepCorrection.cancel")}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,21 +183,29 @@
|
||||||
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
|
"discoverabilityHint": "💡 Sélectionnez du texte, ou cliquez sur une technique surlignée, pour la corriger.",
|
||||||
"confirm": "Valider",
|
"confirm": "Valider",
|
||||||
"techniqueSection": "Technique",
|
"techniqueSection": "Technique",
|
||||||
"currentTechnique": "Technique retenue : {{technique}}",
|
"changeTechnique": "Changer",
|
||||||
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
"noTechniqueSelected": "Aucune technique sélectionnée.",
|
||||||
"searchTechniquePlaceholder": "Rechercher une technique…",
|
"searchTechniquePlaceholder": "Rechercher une technique…",
|
||||||
"noTechniqueFound": "Aucune technique trouvée.",
|
"noTechniqueFound": "Aucune technique trouvée.",
|
||||||
"ingredientsSection": "Ingrédients",
|
"ingredientsSection": "Ingrédients",
|
||||||
"utensilsSection": "Ustensiles",
|
"utensilsSection": "Ustensiles",
|
||||||
"addIngredient": "+ Ajouter un ingrédient",
|
"temperatureSection": "Température",
|
||||||
"addUtensil": "+ Ajouter un ustensile",
|
"addIngredient": "Ajouter un ingrédient",
|
||||||
|
"addUtensil": "Ajouter un ustensile",
|
||||||
|
"addTemperature": "Ajouter une température",
|
||||||
"removeIngredient": "Retirer cet ingrédient",
|
"removeIngredient": "Retirer cet ingrédient",
|
||||||
"removeUtensil": "Retirer cet ustensile",
|
"removeUtensil": "Retirer cet ustensile",
|
||||||
|
"removeTemperature": "Retirer cette température",
|
||||||
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
|
"selectSpanHint": "Sélectionnez le passage de texte concerné dans la description ci-dessus…",
|
||||||
"cancelSpanSelection": "Annuler la sélection",
|
"cancelSpanSelection": "Annuler la sélection",
|
||||||
"searchUtensilPlaceholder": "Rechercher un ustensile…",
|
"searchUtensilPlaceholder": "Rechercher un ustensile…",
|
||||||
"noUtensilFound": "Aucun ustensile trouvé.",
|
"noUtensilFound": "Aucun ustensile trouvé.",
|
||||||
"addToList": "Ajouter"
|
"addToList": "Ajouter",
|
||||||
|
"temperatureMode": {
|
||||||
|
"celsius": "°C",
|
||||||
|
"gasMark": "Thermostat",
|
||||||
|
"qualitative": "Qualitatif"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"favoris": "Favoris",
|
"favoris": "Favoris",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue