Files
craft2prints/src/components/AdminDesignEditor.tsx
T
AndymickandClaude Haiku 4.5 5c4774be84 Add design approval workflow and per-image dimension tracking
- Add DesignApproval and DesignApprovalMessage models for design review workflow
- Add design approval admin interface (list, detail, chat, approve/reject)
- Add per-image dimension tracking to OrderItem
- Add design submission API endpoint
- Add AdminDesignEditor for admin-side design modifications
- Add DesignApprovalChat component for customer-admin communication
- Update design specification generation with image dimension details
- Add design persistence across login with next parameter redirect
- Add font properties UI with font size and color display

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-18 16:21:19 +01:00

170 lines
5.2 KiB
TypeScript

'use client';
import { useRef, useState, useEffect } from 'react';
import { Stage, Layer, Text, Image as KonvaImage, Transformer } from 'react-konva';
import type Konva from 'konva';
import type { DesignElement } from '@/lib/types';
interface AdminDesignEditorProps {
designId: string;
designJson: string;
designPreviewUrl: string;
placementPreviewUrl?: string | null;
onSave: (designJson: string, previewUrl: string) => void;
}
export default function AdminDesignEditor({
designId,
designJson,
designPreviewUrl,
placementPreviewUrl,
onSave,
}: AdminDesignEditorProps) {
const stageRef = useRef<Konva.Stage | null>(null);
const transformerRef = useRef<Konva.Transformer | null>(null);
const nodeRefs = useRef<Map<string, Konva.Node>>(new Map());
const [elements, setElements] = useState<DesignElement[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
try {
const design = JSON.parse(designJson);
setElements([...(design.front || []), ...(design.back || [])]);
} catch (e) {
console.error('Failed to parse design JSON:', e);
}
}, [designJson]);
const handleDelete = () => {
if (!selectedId) return;
setElements((els) => els.filter((el) => el.id !== selectedId));
setSelectedId(null);
};
const handleSave = async () => {
setSaving(true);
try {
const stage = stageRef.current;
if (!stage) return;
const previewUrl = stage.toDataURL({ pixelRatio: 2 });
await fetch(`/api/designs/${designId}/modify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
designJson: JSON.stringify({ front: elements, back: [] }),
designPreviewUrl: previewUrl,
}),
});
onSave(JSON.stringify({ front: elements, back: [] }), previewUrl);
} finally {
setSaving(false);
}
};
const DISPLAY = 560;
return (
<div className="space-y-4">
<div className="flex gap-3">
<button
onClick={handleDelete}
disabled={!selectedId}
className="text-sm px-4 py-2 border border-splash-pink text-splash-pink hover:bg-splash-pink/10 disabled:opacity-50"
>
Delete selected
</button>
<button
onClick={handleSave}
disabled={saving}
className="text-sm px-4 py-2 bg-clay text-paper hover:bg-clay-dark disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save changes'}
</button>
</div>
<div className="border border-line p-4 bg-surface overflow-auto" style={{ maxHeight: '600px' }}>
<Stage
ref={stageRef}
width={DISPLAY}
height={DISPLAY}
onMouseDown={(e) => {
if (e.target === e.target.getStage()) setSelectedId(null);
}}
>
<Layer>
{elements.map((el) => {
if (el.type === 'text') {
return (
<Text
key={el.id}
x={el.x}
y={el.y}
text={el.text}
fontSize={el.fontSize}
fontFamily={el.fontFamily}
fill={el.fill}
rotation={el.rotation}
draggable
selected={selectedId === el.id}
onClick={() => setSelectedId(el.id)}
onTap={() => setSelectedId(el.id)}
onDragEnd={(e) => {
const updated = elements.map((elem) =>
elem.id === el.id ? { ...elem, x: e.target.x(), y: e.target.y() } : elem
);
setElements(updated);
}}
onTransformEnd={() => {
const node = nodeRefs.current.get(el.id);
if (node instanceof Konva.Text) {
const updated = elements.map((elem) =>
elem.id === el.id
? {
...elem,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
}
: elem
);
setElements(updated);
}
}}
ref={(node) => {
if (node) nodeRefs.current.set(el.id, node);
}}
/>
);
}
return null;
})}
{selectedId && (
<Transformer
ref={transformerRef}
node={nodeRefs.current.get(selectedId) as any}
boundBoxFunc={(oldBox, newBox) => newBox}
/>
)}
</Layer>
</Stage>
</div>
{placementPreviewUrl && (
<div>
<p className="text-sm text-muted mb-2">Design on product:</p>
<img
src={placementPreviewUrl}
alt="design on product"
className="w-full max-w-xs border border-line"
/>
</div>
)}
</div>
);
}