- 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>
170 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|