add dragging to step list

This commit is contained in:
Anika Raemer 2025-10-11 12:08:56 +02:00
parent 646bd573cf
commit 575eecfc69
13 changed files with 406 additions and 60 deletions

View file

@ -8,6 +8,9 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2"
@ -327,6 +330,59 @@
"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": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz",
@ -3990,6 +4046,12 @@
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View file

@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2"

View file

@ -43,7 +43,7 @@
@apply text-gray-600
}
.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 {
@apply text-gray-600
@ -55,6 +55,13 @@
@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-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;
@ -79,7 +86,7 @@
}
.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
}
}

View file

@ -20,6 +20,10 @@ export const ButtonType = {
DefaultButton: {
textColor: "default-button-text",
backgroundColor: "default-button-bg"
},
TransparentButton: {
textColor: "transparent-button-text",
backgroundColor: "transparent-button-bg"
}
} as const;

View file

@ -9,7 +9,9 @@
export const Icon = {
LookingGlass: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
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;
export type Icon = typeof Icon[keyof typeof Icon];

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -1,56 +1,86 @@
import type { InstructionStepModel } from "../../models/InstructionStepModel"
import Button, { ButtonType } from "../basics/Button"
import { Icon } from "../basics/SvgIcon"
/**
* InstructionStepListEditor.tsx
*
* 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.
*/
/**
* Editor for handling the instruction step list
* Ingredients can be edited, added and removed
import { useState, useEffect } from "react";
import type { InstructionStepModel } from "../../models/InstructionStepModel";
import { InstructionStepListDesktopEditor } from "./InstructionStepListDesktopEditor";
import { InstructionStepListMobileEditor } from "./InstructionStepListMobileEditor";
/**
* Props for the hybrid InstructionStepListEditor
*/
type InstructionStepListEditorProps = {
instructionStepList: InstructionStepModel[]
onChange: (ingredients: InstructionStepModel[]) => void
instructionStepList: InstructionStepModel[];
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) => {
const updated = instructionStepList.map((ing, i) =>
i === index ? { ...ing, [field]: value } : ing
)
onChange(updated)
}
/**
* ======= Shared hook for common logic =======
*
* Provides add/remove/update functionality used by both mobile and desktop editors.
*/
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 = () => {
onChange([...instructionStepList, { text: "" }])
}
onChange([...instructionStepList,
{
text: "",
internalId: crypto.randomUUID() // add internalId required for drag & drop
}]);
};
/**
* Remove a step by index
*/
const handleRemove = (index: number) => {
onChange(instructionStepList.filter((_, i) => i !== index))
}
onChange(instructionStepList.filter((_, i) => i !== index));
};
return (
<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>
)
return { handleUpdate, handleAdd, handleRemove };
}

View file

@ -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>
);
}

View file

@ -4,7 +4,7 @@ import { useEffect, useState } from "react"
import { fetchRecipe } from "../../api/points/RecipePoint"
import { getRecipeEditUrl, getRecipeListUrl } from "../../routes"
import ButtonLink from "../basics/ButtonLink"
import { mapRecipeDtoToModel } from "../../mappers/recipeMapper"
import { mapRecipeDtoToModel } from "../../mappers/RecipeMapper"
/**
@ -123,14 +123,6 @@ export default function RecipeDetailPage() {
</ul>
{/* 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">
{recipe.instructionStepList.map((step, j) => (
<li key={j} className="flex items-start gap-4">

View file

@ -4,7 +4,7 @@ import type { RecipeModel } from "../../models/RecipeModel"
import RecipeEditor from "./RecipeEditor"
import { fetchRecipe, createOrUpdateRecipe } from "../../api/points/RecipePoint"
import { getRecipeDetailUrl, getRecipeListUrl } from "../../routes"
import { mapRecipeDtoToModel, mapRecipeModelToDto } from "../../mappers/recipeMapper"
import { mapRecipeDtoToModel, mapRecipeModelToDto } from "../../mappers/RecipeMapper"
import type { RecipeDto } from "../../api/dtos/RecipeDto"
export default function RecipeEditPage() {

View file

@ -16,13 +16,17 @@ export function mapRecipeDtoToModel(dto: RecipeDto): RecipeModel {
amount: dto.amount ?? 1,
unit: dto.amountDescription ?? "",
},
// @todo implement steps in frontend
// join all instruction step texts into a single string for display
instructionStepList: dto.instructions
.sort((a, b) => a.sortOrder - b.sortOrder) // ensure correct order
.map(step => ({
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()
})
),

View file

@ -3,5 +3,10 @@
*/
export interface InstructionStepModel{
id?: string;
/**
* Unique id required for sorting via drag & drop
* Local to the frontend!
*/
internalId: string;
text: string;
}