fix: fehlende Dateien fuer Etiketten-Elemente-Umbau nachreichen
Backend Image / build-and-push (push) Successful in 2m19s
Frontend Image / build-and-push (push) Successful in 1m55s

git add -u im letzten Commit hat neue (untracked) Dateien übersprungen:
die Migration für LabelTemplate.elements, die neuen Typ-/DTO-Dateien und
den kompletten Drag-and-Drop-Editor fehlten dadurch im Build.
This commit is contained in:
Nick Szaunig committed 2026-09-01 16:59:48 +02:00
1 parent 5f4e1a7ba9
commit 938239e16e
9 files changed
+1134

No files matched your search

@@ -0,0 +1,51 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Tag } from "lucide-react";
import { fetchLabelTemplates, updateLabelTemplate } from "@/lib/label-templates/api";
import { PageHeader } from "@/components/page-header";
import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer";
export default function EditLabelTemplatePage() {
const params = useParams<{ id: string }>();
const { data: templates, isLoading, isError } = useQuery({
queryKey: ["label-templates"],
queryFn: fetchLabelTemplates,
});
const template = templates?.find((entry) => entry.id === params.id);
async function handleSubmit(value: LabelDesignerValue) {
await updateLabelTemplate(params.id, value);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Tag}
title="Etikettenvorlage bearbeiten"
description="Etikett per Drag & Drop gestalten"
gradient="var(--gradient-sky)"
/>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
{isError || (!isLoading && !template) ? (
<p role="alert" className="text-sm text-destructive">
Etikettenvorlage konnte nicht geladen werden.
</p>
) : null}
{template ? (
<LabelDesigner
initial={{
name: template.name,
elements: template.elements,
widthMm: template.widthMm,
heightMm: template.heightMm,
isDefault: template.isDefault,
}}
onSubmit={handleSubmit}
submitLabel="Speichern"
/>
) : null}
</main>
);
}
@@ -0,0 +1,305 @@
"use client";
import { Bold, Italic, Trash2 } from "lucide-react";
import {
LabelElement,
LabelFontFamily,
LabelTextAlign,
PLACEHOLDER_KEYS,
PLACEHOLDER_LABELS,
PlaceholderKey,
} from "@/lib/label-templates/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const FONT_FAMILY_LABELS: Record<LabelFontFamily, string> = {
sans: "Serifenlos",
serif: "Serifenschrift",
mono: "Festbreite",
};
const ALIGN_LABELS: Record<LabelTextAlign, string> = {
left: "Links",
center: "Zentriert",
right: "Rechts",
};
interface ElementPropertiesPanelProps {
element: LabelElement;
onChange: (patch: Partial<LabelElement>) => void;
onDelete: () => void;
}
export function ElementPropertiesPanel({
element,
onChange,
onDelete,
}: ElementPropertiesPanelProps) {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">
{element.type === "text" ? "Text" : element.type === "qrCode" ? "QR-Code" : element.type === "image" ? "Bild" : "Linie"}
</p>
<Button type="button" variant="ghost" size="icon-sm" onClick={onDelete}>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
{element.type === "text" ? <TextFields element={element} onChange={onChange} /> : null}
{element.type === "qrCode" ? (
<div className="flex flex-col gap-2">
<Label htmlFor="element-qr-size">Größe (mm)</Label>
<Input
id="element-qr-size"
type="number"
inputMode="decimal"
value={element.size}
onChange={(event) => onChange({ size: Number(event.target.value) })}
/>
</div>
) : null}
{element.type === "image" ? (
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="element-image-width">Breite (mm)</Label>
<Input
id="element-image-width"
type="number"
inputMode="decimal"
value={element.width}
onChange={(event) => onChange({ width: Number(event.target.value) })}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="element-image-height">Höhe (mm)</Label>
<Input
id="element-image-height"
type="number"
inputMode="decimal"
value={element.height}
onChange={(event) => onChange({ height: Number(event.target.value) })}
/>
</div>
</div>
) : null}
{element.type === "line" ? <LineFields element={element} onChange={onChange} /> : null}
</div>
);
}
function TextFields({
element,
onChange,
}: {
element: Extract<LabelElement, { type: "text" }>;
onChange: (patch: Partial<LabelElement>) => void;
}) {
return (
<>
<div className="flex flex-col gap-2">
<Label htmlFor="element-text-source">Inhalt</Label>
<Select
value={element.placeholderKey ?? "__literal"}
onValueChange={(value) =>
onChange({
placeholderKey: value === "__literal" ? null : (value as PlaceholderKey),
})
}
>
<SelectTrigger className="w-full" id="element-text-source">
<SelectValue>
{element.placeholderKey ? PLACEHOLDER_LABELS[element.placeholderKey] : "Fester Text"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__literal">Fester Text</SelectItem>
{PLACEHOLDER_KEYS.map((key) => (
<SelectItem key={key} value={key}>
{PLACEHOLDER_LABELS[key]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!element.placeholderKey ? (
<div className="flex flex-col gap-2">
<Label htmlFor="element-text-content">Text</Label>
<Input
id="element-text-content"
value={element.text}
onChange={(event) => onChange({ text: event.target.value })}
/>
</div>
) : null}
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="element-text-width">Breite (mm)</Label>
<Input
id="element-text-width"
type="number"
inputMode="decimal"
value={element.width}
onChange={(event) => onChange({ width: Number(event.target.value) })}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="element-text-height">Höhe (mm)</Label>
<Input
id="element-text-height"
type="number"
inputMode="decimal"
value={element.height}
onChange={(event) => onChange({ height: Number(event.target.value) })}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="element-text-font">Schriftart</Label>
<Select
value={element.fontFamily}
onValueChange={(value) => onChange({ fontFamily: value as LabelFontFamily })}
>
<SelectTrigger className="w-full" id="element-text-font">
<SelectValue>{FONT_FAMILY_LABELS[element.fontFamily]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(FONT_FAMILY_LABELS) as LabelFontFamily[]).map((family) => (
<SelectItem key={family} value={family}>
{FONT_FAMILY_LABELS[family]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="element-text-size">Schriftgröße (pt)</Label>
<Input
id="element-text-size"
type="number"
inputMode="decimal"
value={element.fontSize}
onChange={(event) => onChange({ fontSize: Number(event.target.value) })}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="element-text-color">Farbe</Label>
<input
id="element-text-color"
type="color"
className="h-8 w-full cursor-pointer rounded-md border border-input"
value={element.color}
onChange={(event) => onChange({ color: event.target.value })}
/>
</div>
</div>
<div className="flex gap-1.5">
<Button
type="button"
variant={element.bold ? "default" : "outline"}
size="icon-sm"
onClick={() => onChange({ bold: !element.bold })}
>
<Bold className="size-3.5" />
</Button>
<Button
type="button"
variant={element.italic ? "default" : "outline"}
size="icon-sm"
onClick={() => onChange({ italic: !element.italic })}
>
<Italic className="size-3.5" />
</Button>
<Select
value={element.align}
onValueChange={(value) => onChange({ align: value as LabelTextAlign })}
>
<SelectTrigger className="flex-1" id="element-text-align">
<SelectValue>{ALIGN_LABELS[element.align]}</SelectValue>
</SelectTrigger>
<SelectContent>
{(Object.keys(ALIGN_LABELS) as LabelTextAlign[]).map((align) => (
<SelectItem key={align} value={align}>
{ALIGN_LABELS[align]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
);
}
function LineFields({
element,
onChange,
}: {
element: Extract<LabelElement, { type: "line" }>;
onChange: (patch: Partial<LabelElement>) => void;
}) {
return (
<>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="element-line-length">Länge (mm)</Label>
<Input
id="element-line-length"
type="number"
inputMode="decimal"
value={element.length}
onChange={(event) => onChange({ length: Number(event.target.value) })}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="element-line-thickness">Dicke (pt)</Label>
<Input
id="element-line-thickness"
type="number"
inputMode="decimal"
value={element.thickness}
onChange={(event) => onChange({ thickness: Number(event.target.value) })}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="element-line-color">Farbe</Label>
<input
id="element-line-color"
type="color"
className="h-8 w-full cursor-pointer rounded-md border border-input"
value={element.color}
onChange={(event) => onChange({ color: event.target.value })}
/>
</div>
<div className="flex gap-1.5">
<Button
type="button"
variant={element.orientation === "horizontal" ? "default" : "outline"}
size="sm"
className="flex-1"
onClick={() => onChange({ orientation: "horizontal" })}
>
Waagerecht
</Button>
<Button
type="button"
variant={element.orientation === "vertical" ? "default" : "outline"}
size="sm"
className="flex-1"
onClick={() => onChange({ orientation: "vertical" })}
>
Senkrecht
</Button>
</div>
</>
);
}
@@ -0,0 +1,208 @@
"use client";
import { PointerEvent as ReactPointerEvent } from "react";
import { QrCode } from "lucide-react";
import {
LabelElement,
LabelFontFamily,
} from "@/lib/label-templates/types";
import { cn } from "@/lib/utils";
export const CANVAS_SCALE = 4; // px per mm
const PT_TO_MM = 25.4 / 72;
const MIN_SIZE_MM = 5;
const FONT_CSS: Record<LabelFontFamily, string> = {
sans: "Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
mono: "'Courier New', monospace",
};
function round(value: number): number {
return Math.round(value * 10) / 10;
}
interface LabelCanvasProps {
widthMm: number;
heightMm: number;
elements: LabelElement[];
selectedId: string | null;
onSelect: (id: string | null) => void;
onChangeElement: (id: string, patch: Partial<LabelElement>) => void;
}
export function LabelCanvas({
widthMm,
heightMm,
elements,
selectedId,
onSelect,
onChangeElement,
}: LabelCanvasProps) {
function startDrag(
event: ReactPointerEvent,
element: LabelElement,
mode: "move" | "resize",
) {
event.stopPropagation();
event.preventDefault();
onSelect(element.id);
const startX = event.clientX;
const startY = event.clientY;
const origin = element;
function onMove(moveEvent: PointerEvent) {
const dxMm = (moveEvent.clientX - startX) / CANVAS_SCALE;
const dyMm = (moveEvent.clientY - startY) / CANVAS_SCALE;
if (mode === "move") {
onChangeElement(origin.id, {
x: Math.max(0, round(origin.x + dxMm)),
y: Math.max(0, round(origin.y + dyMm)),
});
return;
}
if (origin.type === "text" || origin.type === "image") {
onChangeElement(origin.id, {
width: Math.max(MIN_SIZE_MM, round(origin.width + dxMm)),
height: Math.max(MIN_SIZE_MM, round(origin.height + dyMm)),
});
} else if (origin.type === "qrCode") {
onChangeElement(origin.id, {
size: Math.max(MIN_SIZE_MM, round(origin.size + dxMm)),
});
} else if (origin.type === "line") {
const delta = origin.orientation === "horizontal" ? dxMm : dyMm;
onChangeElement(origin.id, {
length: Math.max(MIN_SIZE_MM, round(origin.length + delta)),
});
}
}
function onUp() {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
}
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
}
return (
<div
className="relative overflow-hidden rounded-lg border border-dashed border-border bg-white shadow-sm"
style={{ width: widthMm * CANVAS_SCALE, height: heightMm * CANVAS_SCALE }}
onPointerDown={() => onSelect(null)}
>
{elements.map((element) => (
<ElementBox
key={element.id}
element={element}
selected={element.id === selectedId}
onStartMove={(event) => startDrag(event, element, "move")}
onStartResize={(event) => startDrag(event, element, "resize")}
/>
))}
</div>
);
}
function ElementBox({
element,
selected,
onStartMove,
onStartResize,
}: {
element: LabelElement;
selected: boolean;
onStartMove: (event: ReactPointerEvent) => void;
onStartResize: (event: ReactPointerEvent) => void;
}) {
const box = boxFor(element);
return (
<div
className={cn(
"absolute cursor-move touch-none select-none",
selected && "outline outline-2 outline-primary",
)}
style={{
left: box.left,
top: box.top,
width: box.width,
height: box.height,
}}
onPointerDown={onStartMove}
>
{renderContent(element)}
{selected ? (
<div
className="absolute right-0 bottom-0 size-3 translate-x-1/2 translate-y-1/2 cursor-nwse-resize rounded-full border-2 border-primary bg-background"
onPointerDown={(event) => {
event.stopPropagation();
onStartResize(event);
}}
/>
) : null}
</div>
);
}
function boxFor(element: LabelElement): {
left: number;
top: number;
width: number;
height: number;
} {
const left = element.x * CANVAS_SCALE;
const top = element.y * CANVAS_SCALE;
if (element.type === "qrCode") {
return { left, top, width: element.size * CANVAS_SCALE, height: element.size * CANVAS_SCALE };
}
if (element.type === "line") {
const thicknessPx = Math.max(2, element.thickness * PT_TO_MM * CANVAS_SCALE);
return element.orientation === "horizontal"
? { left, top: top - thicknessPx / 2, width: element.length * CANVAS_SCALE, height: thicknessPx }
: { left: left - thicknessPx / 2, top, width: thicknessPx, height: element.length * CANVAS_SCALE };
}
return { left, top, width: element.width * CANVAS_SCALE, height: element.height * CANVAS_SCALE };
}
function renderContent(element: LabelElement) {
switch (element.type) {
case "text": {
const display = element.placeholderKey
? `{{${element.placeholderKey}}}`
: element.text || "Text";
return (
<div
className="size-full overflow-hidden whitespace-pre-wrap"
style={{
fontFamily: FONT_CSS[element.fontFamily],
fontSize: element.fontSize * PT_TO_MM * CANVAS_SCALE,
fontWeight: element.bold ? 700 : 400,
fontStyle: element.italic ? "italic" : "normal",
color: element.color,
textAlign: element.align,
}}
>
{display}
</div>
);
}
case "qrCode":
return (
<div className="flex size-full items-center justify-center bg-muted/40">
<QrCode className="size-2/3 text-muted-foreground" />
</div>
);
case "image":
return (
// eslint-disable-next-line @next/next/no-img-element -- data: URL preview, next/image can't handle this
<img src={element.src} alt="" className="size-full object-contain" />
);
case "line":
return <div className="size-full" style={{ background: element.color }} />;
}
}
@@ -0,0 +1,290 @@
"use client";
import { ChangeEvent, useState } from "react";
import { useRouter } from "next/navigation";
import { Image as ImageIcon, Minus, QrCode, Type } from "lucide-react";
import { toast } from "sonner";
import {
LabelElement,
LabelTextElement,
PLACEHOLDER_KEYS,
PLACEHOLDER_LABELS,
PlaceholderKey,
} from "@/lib/label-templates/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LabelCanvas } from "./label-canvas";
import { ElementPropertiesPanel } from "./element-properties-panel";
function newId(): string {
return crypto.randomUUID();
}
function newTextElement(overrides: Partial<LabelTextElement> = {}): LabelTextElement {
return {
id: newId(),
type: "text",
x: 5,
y: 5,
width: 40,
height: 10,
text: "Text",
placeholderKey: null,
fontSize: 10,
fontFamily: "sans",
bold: false,
italic: false,
color: "#000000",
align: "left",
...overrides,
};
}
export interface LabelDesignerValue {
name: string;
elements: LabelElement[];
widthMm: number;
heightMm: number;
isDefault: boolean;
}
interface LabelDesignerProps {
initial?: LabelDesignerValue;
onSubmit: (value: LabelDesignerValue) => Promise<void>;
submitLabel: string;
}
export function LabelDesigner({ initial, onSubmit, submitLabel }: LabelDesignerProps) {
const router = useRouter();
const [name, setName] = useState(initial?.name ?? "");
const [widthMm, setWidthMm] = useState(initial?.widthMm ?? 90);
const [heightMm, setHeightMm] = useState(initial?.heightMm ?? 35);
const [isDefault, setIsDefault] = useState(initial?.isDefault ?? false);
const [elements, setElements] = useState<LabelElement[]>(initial?.elements ?? []);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const selectedElement = elements.find((element) => element.id === selectedId) ?? null;
function addElement(element: LabelElement) {
setElements((current) => [...current, element]);
setSelectedId(element.id);
}
function changeElement(id: string, patch: Partial<LabelElement>) {
setElements((current) =>
current.map((element) =>
element.id === id ? ({ ...element, ...patch } as LabelElement) : element,
),
);
}
function deleteSelected() {
if (!selectedId) return;
setElements((current) => current.filter((element) => element.id !== selectedId));
setSelectedId(null);
}
function handlePlaceholderInsert(key: PlaceholderKey) {
addElement(newTextElement({ placeholderKey: key, text: "" }));
}
function handleImageUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
addElement({
id: newId(),
type: "image",
x: 5,
y: 5,
width: 20,
height: 20,
src: String(reader.result),
});
};
reader.readAsDataURL(file);
}
async function handleSubmit() {
if (!name.trim()) {
toast.error("Bitte einen Namen für die Vorlage angeben.");
return;
}
setIsSubmitting(true);
try {
await onSubmit({ name, elements, widthMm, heightMm, isDefault });
router.push("/settings/label-templates");
} catch {
toast.error("Vorlage konnte nicht gespeichert werden.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="grid grid-cols-1 items-start gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Vorlage</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="designer-name">Name</Label>
<Input
id="designer-name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex gap-3">
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-width">Breite (mm)</Label>
<Input
id="designer-width"
type="number"
inputMode="decimal"
value={widthMm}
onChange={(event) => setWidthMm(Number(event.target.value))}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<Label htmlFor="designer-height">Höhe (mm)</Label>
<Input
id="designer-height"
type="number"
inputMode="decimal"
value={heightMm}
onChange={(event) => setHeightMm(Number(event.target.value))}
/>
</div>
</div>
<label className="flex items-center gap-2 text-sm" htmlFor="designer-default">
<input
id="designer-default"
type="checkbox"
checked={isDefault}
onChange={(event) => setIsDefault(event.target.checked)}
/>
Als Standardvorlage verwenden
</label>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Elemente</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addElement(newTextElement())}
>
<Type className="size-4" />
Text
</Button>
<Select onValueChange={(value) => handlePlaceholderInsert(value as PlaceholderKey)}>
<SelectTrigger className="w-auto" size="sm">
<SelectValue placeholder="Platzhalter einfügen" />
</SelectTrigger>
<SelectContent>
{PLACEHOLDER_KEYS.map((key) => (
<SelectItem key={key} value={key}>
{PLACEHOLDER_LABELS[key]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
addElement({ id: newId(), type: "qrCode", x: 5, y: 5, size: 20 })
}
>
<QrCode className="size-4" />
QR-Code
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
addElement({
id: newId(),
type: "line",
x: 5,
y: 5,
length: 40,
thickness: 1,
color: "#c9a34e",
orientation: "horizontal",
})
}
>
<Minus className="size-4" />
Linie
</Button>
<Button type="button" variant="outline" size="sm" render={
<label className="cursor-pointer">
<ImageIcon className="size-4" />
Bild/Logo
<input type="file" accept="image/png,image/jpeg" className="hidden" onChange={handleImageUpload} />
</label>
} />
</CardContent>
</Card>
<div className="overflow-auto rounded-lg bg-muted/30 p-6">
<LabelCanvas
widthMm={widthMm}
heightMm={heightMm}
elements={elements}
selectedId={selectedId}
onSelect={setSelectedId}
onChangeElement={changeElement}
/>
</div>
<Button type="button" onClick={() => void handleSubmit()} disabled={isSubmitting} className="self-start">
{isSubmitting ? "Speichert…" : submitLabel}
</Button>
</div>
<Card className="sticky top-4">
<CardHeader>
<CardTitle className="text-base">Eigenschaften</CardTitle>
</CardHeader>
<CardContent>
{selectedElement ? (
<ElementPropertiesPanel
element={selectedElement}
onChange={(patch) => changeElement(selectedElement.id, patch)}
onDelete={deleteSelected}
/>
) : (
<p className="text-sm text-muted-foreground">
Element im Etikett anklicken, um es zu bearbeiten.
</p>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,24 @@
"use client";
import { Tag } from "lucide-react";
import { createLabelTemplate } from "@/lib/label-templates/api";
import { PageHeader } from "@/components/page-header";
import { LabelDesigner, LabelDesignerValue } from "../designer/label-designer";
export default function NewLabelTemplatePage() {
async function handleSubmit(value: LabelDesignerValue) {
await createLabelTemplate(value);
}
return (
<main className="flex flex-col gap-4 p-4 sm:p-6">
<PageHeader
icon={Tag}
title="Neue Etikettenvorlage"
description="Etikett per Drag & Drop gestalten"
gradient="var(--gradient-sky)"
/>
<LabelDesigner onSubmit={handleSubmit} submitLabel="Anlegen" />
</main>
);
}