// === DetailEditor — 상품 상세설명 블록 에디터 === // 상품관리 → 행 클릭 → 상세설명 탭에서 사용. // // 블록 모델: // { id, type:'image', url, source:'naver'|'isvm'|'pc'|'external', pending?:boolean, error?:string } // { id, type:'text', text } // { id, type:'html', html } (round-trip 보존용 — 사용자가 직접 편집 가능) // // 외부 인터페이스: // setEdit("detailContent", html)} // 블록 변경 즉시 직렬화해서 호출 // /> // 사용자 입력이 한 글자 바뀔 때마다 호출되므로 onChange는 가벼워야 함. function _uid() { return "b-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8); } function DetailEditor({ initialHtml, isvmCode, onChange }) { // 초기 파싱 — initialHtml은 처음 한 번만 (사용자가 편집 중인데 외부에서 덮으면 안 됨). const [blocks, setBlocks] = useState(() => { try { const parsed = window.API.parseSmartEditorHtml(initialHtml || ""); return parsed.map(b => ({ ...b, id: b.id || _uid() })); } catch (err) { console.warn("[DetailEditor] 초기 파싱 실패", err); return []; } }); const [busy, setBusy] = useState(null); // { done, total, label } const [showPreview, setShowPreview] = useState(false); const [showSourceHtml, setShowSourceHtml] = useState(false); const fileInputRef = useRef(null); const dragSrcId = useRef(null); // blocks → HTML 직렬화 후 부모에게 알림 (pending 이미지는 빈 URL로 보내지 말고 그대로 두되 저장 시점에 막음) useEffect(() => { if (!onChange) return; // pending/error는 URL이 있으면 포함, 없으면 스킵 const cleaned = blocks .filter(b => b.type !== "image" || (b.url && !b.error)) .map(b => { if (b.type === "image") return { type: "image", url: b.url }; if (b.type === "text") return { type: "text", text: b.text || "" }; if (b.type === "html") return { type: "html", html: b.html || "" }; return b; }); const html = window.API.buildSmartEditorHtml(cleaned); onChange(html); }, [blocks]); const updateBlock = (id, patch) => setBlocks(prev => prev.map(b => b.id === id ? { ...b, ...patch } : b)); const removeBlock = (id) => setBlocks(prev => prev.filter(b => b.id !== id)); const moveBlock = (id, dir) => setBlocks(prev => { const idx = prev.findIndex(b => b.id === id); if (idx < 0) return prev; const target = dir === "up" ? idx - 1 : idx + 1; if (target < 0 || target >= prev.length) return prev; const next = prev.slice(); [next[idx], next[target]] = [next[target], next[idx]]; return next; }); const insertBlockAt = (block, position = -1) => setBlocks(prev => { const next = prev.slice(); if (position < 0 || position > next.length) next.push(block); else next.splice(position, 0, block); return next; }); // === 블록 추가 액션들 === const addText = () => { insertBlockAt({ id: _uid(), type: "text", text: "" }); }; const addHtmlBlock = () => { insertBlockAt({ id: _uid(), type: "html", html: "" }); }; // PC 파일 즉시 업로드 → 네이버 URL 받아서 image 블록 추가 const handlePcFiles = async (fileList) => { const files = Array.from(fileList || []); if (files.length === 0) return; // 우선 pending 블록들로 placeholder 추가 (사용자가 진행 상황 확인 가능) const pendings = files.map(f => ({ id: _uid(), type: "image", url: "", source: "pc", pending: true, fileName: f.name, })); setBlocks(prev => [...prev, ...pendings]); setBusy({ done: 0, total: files.length, label: "PC 이미지 업로드 중" }); let done = 0; for (let i = 0; i < files.length; i++) { const f = files[i]; const id = pendings[i].id; try { const url = await window.API.naverUploadImageFile(f); updateBlock(id, { url, pending: false, error: undefined }); } catch (err) { console.error("[PC 업로드 실패]", f.name, err); updateBlock(id, { pending: false, error: err.message || "업로드 실패" }); toast("이미지 업로드 실패: " + f.name); } done++; setBusy({ done, total: files.length, label: "PC 이미지 업로드 중" }); } setBusy(null); toast(files.length + "장 업로드 완료"); }; // URL로 이미지 추가 (외부 URL이면 네이버 업로드 거쳐서 변환) const addImageFromUrl = async () => { const url = window.prompt("이미지 URL을 입력하세요"); if (!url || !url.trim()) return; const trimmed = url.trim(); const isNaver = /(shop-phinf\.pstatic\.net|phinf\.pstatic\.net|naver\.com)/.test(trimmed); if (isNaver) { insertBlockAt({ id: _uid(), type: "image", url: trimmed, source: "naver" }); return; } // 외부 URL — 프록시 거쳐 네이버로 업로드 const id = _uid(); insertBlockAt({ id, type: "image", url: "", source: "external", pending: true }); setBusy({ done: 0, total: 1, label: "외부 이미지 네이버 업로드 중" }); try { const naverUrl = await window.API.naverUploadImageFromUrl(trimmed); updateBlock(id, { url: naverUrl, pending: false, error: undefined }); toast("이미지 업로드 완료"); } catch (err) { console.error("[URL 업로드 실패]", err); updateBlock(id, { pending: false, error: err.message || "업로드 실패" }); toast("이미지 업로드 실패: " + err.message); } setBusy(null); }; // ISVM 코드로 다중 이미지 가져오기 — 외부 URL이라 네이버 업로드는 사용자가 "저장 누르기 전 일괄" 또는 즉시 const importIsvmImages = async () => { if (!isvmCode) { toast("ISVM 상품 코드가 없습니다"); return; } setBusy({ done: 0, total: 1, label: "ISVM 이미지 조회 중" }); let isvmImgs = []; try { isvmImgs = await window.API.isvmFetchProductImages(isvmCode); } catch (err) { console.error("[ISVM 조회 실패]", err); toast("ISVM 이미지 조회 실패: " + err.message); setBusy(null); return; } if (!isvmImgs || isvmImgs.length === 0) { toast("ISVM에서 가져온 이미지가 없습니다"); setBusy(null); return; } // 모두 pending 블록으로 일괄 추가 const pendings = isvmImgs.map(img => ({ id: _uid(), type: "image", url: "", source: "isvm", pending: true, _source_url: img.url, })); setBlocks(prev => [...prev, ...pendings]); // 네이버에 순차 업로드 let done = 0; setBusy({ done: 0, total: isvmImgs.length, label: "ISVM 이미지 네이버에 업로드 중" }); for (let i = 0; i < isvmImgs.length; i++) { const id = pendings[i].id; const src = isvmImgs[i].url; try { const naverUrl = await window.API.naverUploadImageFromUrl(src); updateBlock(id, { url: naverUrl, pending: false, error: undefined }); } catch (err) { console.warn("[ISVM→네이버 업로드 실패]", src, err.message); updateBlock(id, { pending: false, error: err.message || "업로드 실패" }); } done++; setBusy({ done, total: isvmImgs.length, label: "ISVM 이미지 네이버에 업로드 중" }); } setBusy(null); toast("ISVM 이미지 " + isvmImgs.length + "장 처리 완료"); }; // === 드래그 앤 드롭 (HTML5 native) — 블록 순서 변경 === const onDragStart = (e, id) => { dragSrcId.current = id; e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", id); } catch {} }; const onDragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }; const onDropOn = (e, targetId) => { e.preventDefault(); const srcId = dragSrcId.current || e.dataTransfer.getData("text/plain"); if (!srcId || srcId === targetId) return; setBlocks(prev => { const srcIdx = prev.findIndex(b => b.id === srcId); const tgtIdx = prev.findIndex(b => b.id === targetId); if (srcIdx < 0 || tgtIdx < 0) return prev; const next = prev.slice(); const [moved] = next.splice(srcIdx, 1); next.splice(tgtIdx, 0, moved); return next; }); dragSrcId.current = null; }; // === 본체 === const hasPending = blocks.some(b => b.pending); const hasError = blocks.some(b => b.error); const imageCount = blocks.filter(b => b.type === "image" && b.url).length; const previewHtml = window.API.buildSmartEditorHtml( blocks.filter(b => b.type !== "image" || (b.url && !b.error)) .map(b => b.type === "image" ? { type: "image", url: b.url } : b) ); return (
{ handlePcFiles(e.target.files); e.target.value = ""; }} /> {isvmCode && ( )}
블록 {blocks.length}개 · 이미지 {imageCount}장 {hasPending && <> · 업로드 중} {hasError && <> · 오류 있음}
{busy && (
{busy.label}: {busy.done} / {busy.total}
0 ? Math.round(busy.done / busy.total * 100) : 0) + "%" }} />
)} {showPreview && (
미리보기 (네이버 표시와 유사)
)} {showSourceHtml && (
SmartEditor HTML (저장 직전 형태)