From a3fc72c64377c88eb70d0e53c7e847b88e6b81d5 Mon Sep 17 00:00:00 2001 From: ByungMMin <149650808+ByungMMin@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:20:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[fix/#261]=EB=A7=A4=EC=B9=AD=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=88=98=EC=A0=95=20=ED=99=94=EB=A9=B4=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95,=20=ED=83=9C=EA=B7=B8=20?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/ootd/ui/OotdTagEditor.tsx | 94 +++++++++++++------ src/pages/camera/component/TagBBox.tsx | 4 +- src/pages/camera/component/TagResultSheet.tsx | 4 + src/pages/ootd/OotdDetailPage.tsx | 6 +- 4 files changed, 76 insertions(+), 32 deletions(-) diff --git a/src/features/ootd/ui/OotdTagEditor.tsx b/src/features/ootd/ui/OotdTagEditor.tsx index 94832a2..1a1f449 100644 --- a/src/features/ootd/ui/OotdTagEditor.tsx +++ b/src/features/ootd/ui/OotdTagEditor.tsx @@ -93,6 +93,9 @@ const OotdTagEditor = ({ }, [isPending]); const [addedItems, setAddedItems] = useState([]); // 새로 등록한 아이템 const [analyzedItems, setAnalyzedItems] = useState>({}); + // 특정 아이템 매칭 실패 시(matchedItemIds 없음) 카테고리만 저장 → 최신 recommended를 기준으로 + // 매번 반응형으로 필터링(레이스/구식 스냅샷 방지). 카테고리를 못 알아냈으면 null. + const [analyzedCategory, setAnalyzedCategory] = useState>({}); const [sheetHeight, setSheetHeight] = useState(RESULT_SHEET_DEFAULT_H); // 태그된 아이템 객체 보관 — 추천 목록이 갱신돼도 활성 박스 아이템이 목록에서 사라지지 않게 const [tagged, setTagged] = useState>({}); @@ -145,7 +148,16 @@ const OotdTagEditor = ({ const tagCount = boxes.filter((b) => b.itemId).length; // 아이템 부착된 박스 = 완성 태그 const activeMatched = activeBoxId ? analyzedItems[activeBoxId] : undefined; - const baseItems = activeMatched ?? recommended; + // 박스가 아직 분석 전이면 undefined → 추천 전체. 분석했는데 특정 아이템을 못 맞췄으면 카테고리로 + // 최신 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) => { @@ -185,40 +197,53 @@ 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 })); - } + // 특정 아이템은 못 맞췄을 때: 카테고리만 기록해두고, 실제 목록은 최신 recommended를 + // 그 카테고리로 필터링해 반응형으로 보여준다(카테고리 아이템이 없으면 "유사한 아이템 없음"). + 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; + }); } }; @@ -261,6 +286,13 @@ const OotdTagEditor = ({ }); setActiveBoxId(nextId); setShowBoxUi(false); // 말풍선을 통한 활성화라 박스 테두리는 안 띄운다 + // 상세 편집에서 복원된 기존 태그는 이번 세션에서 한 번도 분석된 적이 없어서(생성 시에만 + // 분석이 도는 흐름), 그대로 두면 baseItems가 전체 recommended로 폴백돼 "처음 박스 했을 때 + // 나온 아이템들"이 아니라 전체 목록이 보인다. 처음 활성화되는 시점에 그 위치를 한 번 분석해서 + // 원래 태그할 때와 같은 후보 목록(+선택된 아이템)이 뜨게 한다. + if (nextId && !(nextId in analyzedItems) && !(nextId in analyzedCategory)) { + scheduleAnalysis(nextId); + } }; // 이미지 빈 곳 탭 → 그 위치에 박스 생성 + 활성화 + 분석 API 호출 @@ -338,11 +370,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..6128ec7 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 && ( +
+ +
+ )}
From c6d11989871ebb73eea3ab4675155c6bd3c28c87 Mon Sep 17 00:00:00 2001 From: ByungMMin <149650808+ByungMMin@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:27:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[fix/#261]=EB=A8=B8=EC=A7=80=20=ED=9B=84=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/ootd/ui/OotdTagEditor.tsx | 14 -------------- src/pages/camera/component/TagBBox.tsx | 2 +- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/features/ootd/ui/OotdTagEditor.tsx b/src/features/ootd/ui/OotdTagEditor.tsx index 8282840..f0d8b8d 100644 --- a/src/features/ootd/ui/OotdTagEditor.tsx +++ b/src/features/ootd/ui/OotdTagEditor.tsx @@ -96,8 +96,6 @@ const OotdTagEditor = ({ }, [isPending]); const [addedItems, setAddedItems] = useState([]); // 새로 등록한 아이템 const [analyzedItems, setAnalyzedItems] = useState>({}); - // 특정 아이템 매칭 실패 시(matchedItemIds 없음) 카테고리만 저장 → 최신 recommended를 기준으로 - // 매번 반응형으로 필터링(레이스/구식 스냅샷 방지). 카테고리를 못 알아냈으면 null. const [analyzedCategory, setAnalyzedCategory] = useState>({}); const [sheetHeight, setSheetHeight] = useState(RESULT_SHEET_DEFAULT_H); // 태그된 아이템 객체 보관 — 추천 목록이 갱신돼도 활성 박스 아이템이 목록에서 사라지지 않게 @@ -152,8 +150,6 @@ const OotdTagEditor = ({ const tagCount = boxes.filter((b) => b.itemId).length; // 아이템 부착된 박스 = 완성 태그 const activeMatched = activeBoxId ? analyzedItems[activeBoxId] : undefined; - // 박스가 아직 분석 전이면 undefined → 추천 전체. 분석했는데 특정 아이템을 못 맞췄으면 카테고리로 - // 최신 recommended를 필터링(둘 다 없으면 빈 배열 → "유사한 아이템 없음" 상태로 이어짐). const activeCategory = activeBoxId ? analyzedCategory[activeBoxId] : undefined; const baseItems = useMemo(() => { if (activeMatched) return activeMatched; @@ -201,8 +197,6 @@ const OotdTagEditor = ({ boxesRef.current = boxes; }, [boxes]); - // 박스별 최신 분석 요청 번호. 같은 박스를 짧게 두 번 이상 조정하면 요청이 겹칠 수 있는데, - // 먼저 보낸 요청이 나중에 응답으로 와서 최신 결과를 덮어쓰지 않도록 "가장 최근에 보낸 요청"만 반영한다. const analysisSeqRef = useRef>({}); const runAnalysis = async (boxId: string, bbox: Bbox) => { @@ -220,8 +214,6 @@ const OotdTagEditor = ({ return next; }); if (res.matchedItemIds.length === 0) { - // 특정 아이템은 못 맞췄을 때: 카테고리만 기록해두고, 실제 목록은 최신 recommended를 - // 그 카테고리로 필터링해 반응형으로 보여준다(카테고리 아이템이 없으면 "유사한 아이템 없음"). setAnalyzedCategory((prev) => ({ ...prev, [boxId]: res.categorySmall })); return; } @@ -290,10 +282,6 @@ const OotdTagEditor = ({ }); setActiveBoxId(nextId); setShowBoxUi(false); // 말풍선을 통한 활성화라 박스 테두리는 안 띄운다 - // 상세 편집에서 복원된 기존 태그는 이번 세션에서 한 번도 분석된 적이 없어서(생성 시에만 - // 분석이 도는 흐름), 그대로 두면 baseItems가 전체 recommended로 폴백돼 "처음 박스 했을 때 - // 나온 아이템들"이 아니라 전체 목록이 보인다. 처음 활성화되는 시점에 그 위치를 한 번 분석해서 - // 원래 태그할 때와 같은 후보 목록(+선택된 아이템)이 뜨게 한다. if (nextId && !(nextId in analyzedItems) && !(nextId in analyzedCategory)) { scheduleAnalysis(nextId); } @@ -393,8 +381,6 @@ const OotdTagEditor = ({ )} - {/* 박스들은 항상 렌더링(꺼져 있어도 새로 탭한 박스는 위치를 잡을 수 있어야 함). - 토글은 말풍선(라벨)만 숨긴다 — 지금 배치 중인 박스의 테두리/스포트라이트는 영향 없음. */} {boxes.map((b) => { const isActive = activeBoxId === b.id; const showFrame = isActive && showBoxUi; diff --git a/src/pages/camera/component/TagBBox.tsx b/src/pages/camera/component/TagBBox.tsx index 6128ec7..af44594 100644 --- a/src/pages/camera/component/TagBBox.tsx +++ b/src/pages/camera/component/TagBBox.tsx @@ -16,7 +16,7 @@ type Props = { type Corner = 'nw' | 'ne' | 'sw' | 'se'; -const MIN_W = 0.08; // 박스 최소 가로(이미지 가로 대비 비율) — 안경/스카프 같은 작은 액세서리도 감쌀 수 있게 +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);