diff --git a/src/features/ootd/ui/OotdTagEditor.tsx b/src/features/ootd/ui/OotdTagEditor.tsx index 7958655..f0d8b8d 100644 --- a/src/features/ootd/ui/OotdTagEditor.tsx +++ b/src/features/ootd/ui/OotdTagEditor.tsx @@ -96,6 +96,7 @@ const OotdTagEditor = ({ }, [isPending]); const [addedItems, setAddedItems] = useState([]); // 새로 등록한 아이템 const [analyzedItems, setAnalyzedItems] = useState>({}); + const [analyzedCategory, setAnalyzedCategory] = useState>({}); const [sheetHeight, setSheetHeight] = useState(RESULT_SHEET_DEFAULT_H); // 태그된 아이템 객체 보관 — 추천 목록이 갱신돼도 활성 박스 아이템이 목록에서 사라지지 않게 const [tagged, setTagged] = useState>({}); @@ -149,7 +150,14 @@ const OotdTagEditor = ({ const tagCount = boxes.filter((b) => b.itemId).length; // 아이템 부착된 박스 = 완성 태그 const activeMatched = activeBoxId ? analyzedItems[activeBoxId] : undefined; - const baseItems = activeMatched ?? recommended; + const activeCategory = activeBoxId ? analyzedCategory[activeBoxId] : undefined; + const baseItems = useMemo(() => { + if (activeMatched) return activeMatched; + if (activeCategory !== undefined) { + return recommended.filter((item) => item.categoryName === activeCategory); + } + return recommended; + }, [activeMatched, activeCategory, recommended]); const items = useMemo(() => [...addedItems, ...baseItems], [addedItems, baseItems]); const itemLabel = (id?: string) => { @@ -189,40 +197,49 @@ const OotdTagEditor = ({ boxesRef.current = boxes; }, [boxes]); + const analysisSeqRef = useRef>({}); + const runAnalysis = async (boxId: string, bbox: Bbox) => { if (ootdId == null) return; + const seq = (analysisSeqRef.current[boxId] ?? 0) + 1; + analysisSeqRef.current[boxId] = seq; + const isStale = () => analysisSeqRef.current[boxId] !== seq; try { const res = await analyzeTagArea(ootdId, { bbox }); + if (isStale()) return; + setAnalyzedItems((prev) => { + if (!(boxId in prev)) return prev; + const next = { ...prev }; + delete next[boxId]; + return next; + }); if (res.matchedItemIds.length === 0) { - // 특정 아이템은 못 맞췄을 때: AI가 카테고리를 알아냈으면 그 카테고리 아이템을 앞에 두되, - // 나머지 보유 아이템도 모두 보여줘서 사용자가 다른 아이템도 고를 수 있게 한다. - const scoped = res.categorySmall - ? recommended.filter((item) => item.categoryName === res.categorySmall) - : []; - const scopedIds = new Set(scoped.map((it) => it.id)); - const rest = recommended.filter((it) => !scopedIds.has(it.id)); - const merged = [...scoped, ...rest]; - if (merged.length === 0) { - setAnalyzedItems((prev) => { - if (!(boxId in prev)) return prev; - const next = { ...prev }; - delete next[boxId]; - return next; - }); - } else { - setAnalyzedItems((prev) => ({ ...prev, [boxId]: merged })); - } + setAnalyzedCategory((prev) => ({ ...prev, [boxId]: res.categorySmall })); return; } + setAnalyzedCategory((prev) => { + if (!(boxId in prev)) return prev; + const next = { ...prev }; + delete next[boxId]; + return next; + }); const details = await Promise.all(res.matchedItemIds.map((id) => getItemDetail(id))); + if (isStale()) return; setAnalyzedItems((prev) => ({ ...prev, [boxId]: details.map(toOotdItemFromDetail) })); } catch { + if (isStale()) return; setAnalyzedItems((prev) => { if (!(boxId in prev)) return prev; const next = { ...prev }; delete next[boxId]; return next; }); + setAnalyzedCategory((prev) => { + if (!(boxId in prev)) return prev; + const next = { ...prev }; + delete next[boxId]; + return next; + }); } }; @@ -265,6 +282,9 @@ const OotdTagEditor = ({ }); setActiveBoxId(nextId); setShowBoxUi(false); // 말풍선을 통한 활성화라 박스 테두리는 안 띄운다 + if (nextId && !(nextId in analyzedItems) && !(nextId in analyzedCategory)) { + scheduleAnalysis(nextId); + } }; // 이미지 빈 곳 탭 → 그 위치에 박스 생성 + 활성화 + 분석 API 호출 @@ -347,11 +367,11 @@ const OotdTagEditor = ({ 촬영한 사진 )} - {/* 태그 박스 보기/숨기기 토글 */} + {/* 태그 말풍선 보기/숨기기 토글 */} - {/* 박스들: 표시 중일 때만. 활성 박스만 테두리+스포트라이트+이동/리사이즈 */} - {showBox && - boxes.map((b) => ( + {boxes.map((b) => { + const isActive = activeBoxId === b.id; + const showFrame = isActive && showBoxUi; + return ( activateBox(activeBoxId === b.id ? null : b.id)} + onActivate={() => activateBox(isActive ? null : b.id)} onChange={(bb) => updateBox(b.id, bb)} onSettle={() => scheduleAnalysis(b.id)} /> - ))} + ); + })} {/* 분석 결과 바텀시트 (활성 박스 기준) */} diff --git a/src/pages/camera/component/TagBBox.tsx b/src/pages/camera/component/TagBBox.tsx index b3638ab..af44594 100644 --- a/src/pages/camera/component/TagBBox.tsx +++ b/src/pages/camera/component/TagBBox.tsx @@ -16,8 +16,8 @@ type Props = { type Corner = 'nw' | 'ne' | 'sw' | 'se'; -const MIN_W = 0.2; // 박스 최소 가로(이미지 가로 대비 비율) -const MIN_H = 0.15; // 박스 최소 세로(이미지 세로 대비 비율) +const MIN_W = 0.08; // 박스 최소 가로(이미지 가로 대비 비율) +const MIN_H = 0.06; // 박스 최소 세로(이미지 세로 대비 비율) const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max); // 이미지 위 태그: 말풍선은 항상 표시, 누르면 활성화되어 박스(+스포트라이트)가 뜨고 diff --git a/src/pages/camera/component/TagResultSheet.tsx b/src/pages/camera/component/TagResultSheet.tsx index 4ad4f11..c55ff94 100644 --- a/src/pages/camera/component/TagResultSheet.tsx +++ b/src/pages/camera/component/TagResultSheet.tsx @@ -225,6 +225,10 @@ const TagResultSheet = ({ ))} + ) : filtered.length === 0 ? ( +
+

유사한 아이템이 없습니다.

+
) : (
{filtered.map((item) => ( diff --git a/src/pages/ootd/OotdDetailPage.tsx b/src/pages/ootd/OotdDetailPage.tsx index b01f9f0..07c6773 100644 --- a/src/pages/ootd/OotdDetailPage.tsx +++ b/src/pages/ootd/OotdDetailPage.tsx @@ -552,7 +552,11 @@ const OotdDetailPage = () => { onConfirm={handleDiscardConfirm} /> - {isSaving && } + {isSaving && ( +
+ +
+ )}