add dragging to step list
This commit is contained in:
parent
646bd573cf
commit
575eecfc69
13 changed files with 406 additions and 60 deletions
62
frontend/package-lock.json
generated
62
frontend/package-lock.json
generated
|
|
@ -8,6 +8,9 @@
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.8.2"
|
"react-router-dom": "^7.8.2"
|
||||||
|
|
@ -327,6 +330,59 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.9",
|
"version": "0.25.9",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
|
||||||
|
|
@ -3990,6 +4046,12 @@
|
||||||
"typescript": ">=4.8.4"
|
"typescript": ">=4.8.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.8.2"
|
"react-router-dom": "^7.8.2"
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
@apply text-gray-600
|
@apply text-gray-600
|
||||||
}
|
}
|
||||||
.primary-button-bg {
|
.primary-button-bg {
|
||||||
@apply bg-blue-300 hover:bg-blue-400 text-gray-600
|
@apply bg-blue-300 hover:bg-blue-400
|
||||||
}
|
}
|
||||||
.primary-button-text {
|
.primary-button-text {
|
||||||
@apply text-gray-600
|
@apply text-gray-600
|
||||||
|
|
@ -55,6 +55,13 @@
|
||||||
@apply text-white
|
@apply text-white
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transparent-button-bg {
|
||||||
|
@apply bg-transparent hover:bg-transparent
|
||||||
|
}
|
||||||
|
.transparent-button-text {
|
||||||
|
@apply text-gray-600
|
||||||
|
}
|
||||||
|
|
||||||
/* input fields like input and textarea */
|
/* input fields like input and textarea */
|
||||||
.input-field {
|
.input-field {
|
||||||
@apply p-2 w-full border rounded-md placeholder-gray-400 border-gray-600 hover:border-blue-600 transition-colors text-gray-600 focus:outline-none focus:border-blue-800;
|
@apply p-2 w-full border rounded-md placeholder-gray-400 border-gray-600 hover:border-blue-600 transition-colors text-gray-600 focus:outline-none focus:border-blue-800;
|
||||||
|
|
@ -79,7 +86,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.enumeration-indicator{
|
.enumeration-indicator{
|
||||||
@apply flex-shrink-0 w-7 h-7 rounded-full bg-blue-300 text-white flex items-center justify-center
|
@apply flex-shrink-0 w-7 h-7 rounded-full bg-blue-300 text-white flex items-center justify-center shadow-sm
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -20,6 +20,10 @@ export const ButtonType = {
|
||||||
DefaultButton: {
|
DefaultButton: {
|
||||||
textColor: "default-button-text",
|
textColor: "default-button-text",
|
||||||
backgroundColor: "default-button-bg"
|
backgroundColor: "default-button-bg"
|
||||||
|
},
|
||||||
|
TransparentButton: {
|
||||||
|
textColor: "transparent-button-text",
|
||||||
|
backgroundColor: "transparent-button-bg"
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@
|
||||||
export const Icon = {
|
export const Icon = {
|
||||||
LookingGlass: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
LookingGlass: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||||
X: "M6 18L18 6M6 6l12 12",
|
X: "M6 18L18 6M6 6l12 12",
|
||||||
Plus: "M3 12L21 12M12 3L12 21"
|
Plus: "M3 12L21 12M12 3L12 21",
|
||||||
|
ArrowUp: "M3 18L12 6L21 18",
|
||||||
|
ArrowDown: "M3 6L12 18L21 6",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Icon = typeof Icon[keyof typeof Icon];
|
export type Icon = typeof Icon[keyof typeof Icon];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* Single step component for Desktop editor with drag-and-drop support.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { InstructionStepModel } from "../../models/InstructionStepModel";
|
||||||
|
import Button, { ButtonType } from "../basics/Button";
|
||||||
|
import { Icon } from "../basics/SvgIcon";
|
||||||
|
|
||||||
|
type InstructionStepDesktopListItemProps = {
|
||||||
|
id: string | number;
|
||||||
|
index: number;
|
||||||
|
step: InstructionStepModel;
|
||||||
|
onUpdate: (index: number, value: string) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstructionStepDesktopListItem({
|
||||||
|
id,
|
||||||
|
index,
|
||||||
|
step,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
}: InstructionStepDesktopListItemProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
|
||||||
|
|
||||||
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className="flex items-start gap-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="enumeration-indicator cursor-grab"
|
||||||
|
title="Ziehen, um neu zu ordnen"
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="input-field w-full min-h-[60px] resize-none overflow-hidden"
|
||||||
|
placeholder={`Schritt ${index + 1}`}
|
||||||
|
value={step.text}
|
||||||
|
onChange={(e) => onUpdate(index, e.target.value)}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.target as HTMLTextAreaElement;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
icon={Icon.X}
|
||||||
|
buttonType={ButtonType.DarkButton}
|
||||||
|
title="Schritt löschen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* Desktop editor using drag-and-drop via @dnd-kit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import type { InstructionStepModel } from "../../models/InstructionStepModel";
|
||||||
|
import { InstructionStepDesktopListItem } from "./InstructionStepDesktopListItem";
|
||||||
|
import { useInstructionStepListEditor } from "./InstructionStepListEditor";
|
||||||
|
import Button, { ButtonType } from "../basics/Button";
|
||||||
|
import { Icon } from "../basics/SvgIcon";
|
||||||
|
|
||||||
|
type InstructionStepListDesktopEditorProps = {
|
||||||
|
instructionStepList: InstructionStepModel[];
|
||||||
|
onChange: (steps: InstructionStepModel[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstructionStepListDesktopEditor({
|
||||||
|
instructionStepList,
|
||||||
|
onChange,
|
||||||
|
}: InstructionStepListDesktopEditorProps) {
|
||||||
|
const { handleUpdate, handleAdd, handleRemove } = useInstructionStepListEditor(
|
||||||
|
instructionStepList,
|
||||||
|
onChange
|
||||||
|
);
|
||||||
|
|
||||||
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||||
|
|
||||||
|
const handleDragEnd = (event: any) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (active.id !== over.id) {
|
||||||
|
/* find element by internal id
|
||||||
|
* Caution! Due to new elements not having a real ID yet, each list item has an internal ID
|
||||||
|
* in order to give it a unique identifier before saving it to the backend. We have to compare
|
||||||
|
* the id of the drag item to the internalId of the list item here!
|
||||||
|
*/
|
||||||
|
const oldIndex = instructionStepList.findIndex((i) => i.internalId === active.id);
|
||||||
|
const newIndex = instructionStepList.findIndex((i) => i.internalId === over.id);
|
||||||
|
// move element to new position
|
||||||
|
const newOrder = arrayMove(instructionStepList, oldIndex, newIndex);
|
||||||
|
onChange(newOrder);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="subsection-heading mb-3">Zubereitung</h3>
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext
|
||||||
|
items={instructionStepList.map((i) => i.internalId)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{instructionStepList.map((step, index) => (
|
||||||
|
<InstructionStepDesktopListItem
|
||||||
|
key={step.internalId}
|
||||||
|
id={step.internalId}
|
||||||
|
index={index}
|
||||||
|
step={step}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
icon={Icon.Plus}
|
||||||
|
text="Schritt hinzufügen"
|
||||||
|
buttonType={ButtonType.PrimaryButton}
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,56 +1,86 @@
|
||||||
import type { InstructionStepModel } from "../../models/InstructionStepModel"
|
/**
|
||||||
import Button, { ButtonType } from "../basics/Button"
|
* InstructionStepListEditor.tsx
|
||||||
import { Icon } from "../basics/SvgIcon"
|
*
|
||||||
|
* Hybrid editor for recipe instruction steps:
|
||||||
|
* - Detects device type and renders Desktop or Mobile editor accordingly.
|
||||||
|
* - Common functionality for adding, removing, and updating steps is centralized.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { InstructionStepModel } from "../../models/InstructionStepModel";
|
||||||
|
import { InstructionStepListDesktopEditor } from "./InstructionStepListDesktopEditor";
|
||||||
|
import { InstructionStepListMobileEditor } from "./InstructionStepListMobileEditor";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Editor for handling the instruction step list
|
* Props for the hybrid InstructionStepListEditor
|
||||||
* Ingredients can be edited, added and removed
|
|
||||||
*/
|
*/
|
||||||
type InstructionStepListEditorProps = {
|
type InstructionStepListEditorProps = {
|
||||||
instructionStepList: InstructionStepModel[]
|
instructionStepList: InstructionStepModel[];
|
||||||
onChange: (ingredients: InstructionStepModel[]) => void
|
onChange: (steps: InstructionStepModel[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid wrapper that decides between desktop (drag-and-drop) or mobile (buttons)
|
||||||
|
*/
|
||||||
|
export function InstructionStepListEditor({
|
||||||
|
instructionStepList,
|
||||||
|
onChange,
|
||||||
|
}: InstructionStepListEditorProps) {
|
||||||
|
const [isTouch, setIsTouch] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsTouch("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isTouch ? (
|
||||||
|
<InstructionStepListMobileEditor
|
||||||
|
instructionStepList={instructionStepList}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<InstructionStepListDesktopEditor
|
||||||
|
instructionStepList={instructionStepList}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InstructionStepListEditor({ instructionStepList, onChange }: InstructionStepListEditorProps) {
|
/**
|
||||||
const handleUpdate = (index: number, field: keyof InstructionStepModel, value: string) => {
|
* ======= Shared hook for common logic =======
|
||||||
const updated = instructionStepList.map((ing, i) =>
|
*
|
||||||
i === index ? { ...ing, [field]: value } : ing
|
* Provides add/remove/update functionality used by both mobile and desktop editors.
|
||||||
)
|
*/
|
||||||
onChange(updated)
|
export function useInstructionStepListEditor(
|
||||||
}
|
instructionStepList: InstructionStepModel[],
|
||||||
|
onChange: (steps: InstructionStepModel[]) => void
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Update the text of a specific step
|
||||||
|
*/
|
||||||
|
const handleUpdate = (index: number, value: string) => {
|
||||||
|
const updated = instructionStepList.map((step, i) =>
|
||||||
|
i === index ? { ...step, text: value } : step
|
||||||
|
);
|
||||||
|
onChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new step at the end
|
||||||
|
*/
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
onChange([...instructionStepList, { text: "" }])
|
onChange([...instructionStepList,
|
||||||
}
|
{
|
||||||
|
text: "",
|
||||||
|
internalId: crypto.randomUUID() // add internalId required for drag & drop
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a step by index
|
||||||
|
*/
|
||||||
const handleRemove = (index: number) => {
|
const handleRemove = (index: number) => {
|
||||||
onChange(instructionStepList.filter((_, i) => i !== index))
|
onChange(instructionStepList.filter((_, i) => i !== index));
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return { handleUpdate, handleAdd, handleRemove };
|
||||||
<div>
|
|
||||||
<h3 className="subsection-heading">Zubereitung</h3>
|
|
||||||
{instructionStepList.map((step, index) => (
|
|
||||||
<div key={index} className="horizontal-input-group">
|
|
||||||
<input
|
|
||||||
className="input-field"
|
|
||||||
placeholder="Description"
|
|
||||||
value={step.text}
|
|
||||||
onChange={e => handleUpdate(index, "text", e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleRemove(index)}
|
|
||||||
icon={Icon.X}
|
|
||||||
buttonType={ButtonType.DarkButton}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
onClick={handleAdd}
|
|
||||||
icon={Icon.Plus}
|
|
||||||
text={"Schritt hinzufügen"}
|
|
||||||
buttonType={ButtonType.PrimaryButton}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
/**
|
||||||
|
* Mobile editor using Up/Down buttons for reordering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { InstructionStepModel } from "../../models/InstructionStepModel";
|
||||||
|
import Button, { ButtonType } from "../basics/Button";
|
||||||
|
import { Icon } from "../basics/SvgIcon";
|
||||||
|
import { useInstructionStepListEditor } from "./InstructionStepListEditor";
|
||||||
|
|
||||||
|
type InstructionStepListEditorMobileProps = {
|
||||||
|
instructionStepList: InstructionStepModel[];
|
||||||
|
onChange: (steps: InstructionStepModel[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstructionStepListMobileEditor({
|
||||||
|
instructionStepList,
|
||||||
|
onChange,
|
||||||
|
}: InstructionStepListEditorMobileProps) {
|
||||||
|
const { handleUpdate, handleAdd, handleRemove } = useInstructionStepListEditor(
|
||||||
|
instructionStepList,
|
||||||
|
onChange
|
||||||
|
);
|
||||||
|
|
||||||
|
const moveStep = (index: number, direction: "up" | "down") => {
|
||||||
|
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
|
if (newIndex < 0 || newIndex >= instructionStepList.length) return;
|
||||||
|
const newList = [...instructionStepList];
|
||||||
|
const [moved] = newList.splice(index, 1);
|
||||||
|
newList.splice(newIndex, 0, moved);
|
||||||
|
onChange(newList);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{instructionStepList.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className="flex items-start gap-3 bg-gray-50 rounded-xl p-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center pt-1">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-sm font-semibold text-gray-800">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mt-2">
|
||||||
|
<Button
|
||||||
|
icon={Icon.ArrowUp}
|
||||||
|
onClick={() => moveStep(index, "up")}
|
||||||
|
buttonType={ButtonType.TransparentButton}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={Icon.ArrowDown}
|
||||||
|
onClick={() => moveStep(index, "down")}
|
||||||
|
buttonType={ButtonType.TransparentButton}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="input-field w-full min-h-[60px] resize-none overflow-hidden"
|
||||||
|
placeholder={`Schritt ${index + 1}`}
|
||||||
|
value={step.text}
|
||||||
|
onChange={(e) => handleUpdate(index, e.target.value)}
|
||||||
|
onInput={(e) => {
|
||||||
|
const el = e.target as HTMLTextAreaElement;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRemove(index)}
|
||||||
|
icon={Icon.X}
|
||||||
|
buttonType={ButtonType.DarkButton}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
icon={Icon.Plus}
|
||||||
|
text="Schritt hinzufügen"
|
||||||
|
buttonType={ButtonType.PrimaryButton}
|
||||||
|
className="mt-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
|
||||||
import { fetchRecipe } from "../../api/points/RecipePoint"
|
import { fetchRecipe } from "../../api/points/RecipePoint"
|
||||||
import { getRecipeEditUrl, getRecipeListUrl } from "../../routes"
|
import { getRecipeEditUrl, getRecipeListUrl } from "../../routes"
|
||||||
import ButtonLink from "../basics/ButtonLink"
|
import ButtonLink from "../basics/ButtonLink"
|
||||||
import { mapRecipeDtoToModel } from "../../mappers/recipeMapper"
|
import { mapRecipeDtoToModel } from "../../mappers/RecipeMapper"
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,14 +123,6 @@ export default function RecipeDetailPage() {
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Instructions - @todo add reasonable list delegate component*/}
|
{/* Instructions - @todo add reasonable list delegate component*/}
|
||||||
{/*<h2 className="section-heading">Zubereitung</h2>
|
|
||||||
<ol className="default-list mb-6">
|
|
||||||
{recipe.instructionStepList.map((step,j) =>(
|
|
||||||
<li key={j}>
|
|
||||||
{step.text}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>*/}
|
|
||||||
<ol className="space-y-4">
|
<ol className="space-y-4">
|
||||||
{recipe.instructionStepList.map((step, j) => (
|
{recipe.instructionStepList.map((step, j) => (
|
||||||
<li key={j} className="flex items-start gap-4">
|
<li key={j} className="flex items-start gap-4">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { RecipeModel } from "../../models/RecipeModel"
|
||||||
import RecipeEditor from "./RecipeEditor"
|
import RecipeEditor from "./RecipeEditor"
|
||||||
import { fetchRecipe, createOrUpdateRecipe } from "../../api/points/RecipePoint"
|
import { fetchRecipe, createOrUpdateRecipe } from "../../api/points/RecipePoint"
|
||||||
import { getRecipeDetailUrl, getRecipeListUrl } from "../../routes"
|
import { getRecipeDetailUrl, getRecipeListUrl } from "../../routes"
|
||||||
import { mapRecipeDtoToModel, mapRecipeModelToDto } from "../../mappers/recipeMapper"
|
import { mapRecipeDtoToModel, mapRecipeModelToDto } from "../../mappers/RecipeMapper"
|
||||||
import type { RecipeDto } from "../../api/dtos/RecipeDto"
|
import type { RecipeDto } from "../../api/dtos/RecipeDto"
|
||||||
|
|
||||||
export default function RecipeEditPage() {
|
export default function RecipeEditPage() {
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,17 @@ export function mapRecipeDtoToModel(dto: RecipeDto): RecipeModel {
|
||||||
amount: dto.amount ?? 1,
|
amount: dto.amount ?? 1,
|
||||||
unit: dto.amountDescription ?? "",
|
unit: dto.amountDescription ?? "",
|
||||||
},
|
},
|
||||||
// @todo implement steps in frontend
|
|
||||||
// join all instruction step texts into a single string for display
|
// join all instruction step texts into a single string for display
|
||||||
instructionStepList: dto.instructions
|
instructionStepList: dto.instructions
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure correct order
|
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure correct order
|
||||||
.map(step => ({
|
.map(step => ({
|
||||||
text: step.text,
|
text: step.text,
|
||||||
id: step.id
|
id: step.id,
|
||||||
|
/* When mapping a stepDTO, it should already contain a UUID. If
|
||||||
|
* If, however, for some reason, it does not, add a UUID as sorting
|
||||||
|
* steps in teh GUI requires a unique identifier for each item.
|
||||||
|
*/
|
||||||
|
internalId: step.id !== undefined? step.id : crypto.randomUUID()
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -3,5 +3,10 @@
|
||||||
*/
|
*/
|
||||||
export interface InstructionStepModel{
|
export interface InstructionStepModel{
|
||||||
id?: string;
|
id?: string;
|
||||||
|
/**
|
||||||
|
* Unique id required for sorting via drag & drop
|
||||||
|
* Local to the frontend!
|
||||||
|
*/
|
||||||
|
internalId: string;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue