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/features/search/ui/OotdResultGrid.tsx b/src/features/search/ui/OotdResultGrid.tsx index e3ba782..c6e3cf8 100644 --- a/src/features/search/ui/OotdResultGrid.tsx +++ b/src/features/search/ui/OotdResultGrid.tsx @@ -1,4 +1,5 @@ import { useNavigate } from 'react-router-dom'; +import useIsWideLayout from '@/shared/hooks/useIsWideLayout'; import { resolveImageUrl } from '@/shared/lib/resolveImageUrl'; import { saveRecentOotd } from '../api/searchApi'; import type { SearchOotdResponse } from '../api/types'; @@ -11,8 +12,6 @@ export interface OotdResultGridProps { onLoadMore: () => void; } -const COLUMN_COUNT = 2; - const splitIntoColumns = (items: SearchOotdResponse[], columnCount: number) => { const columns: SearchOotdResponse[][] = Array.from({ length: columnCount }, () => []); items.forEach((item, index) => columns[index % columnCount].push(item)); @@ -24,7 +23,8 @@ const splitIntoColumns = (items: SearchOotdResponse[], columnCount: number) => { const OotdResultGrid = ({ items, hasNext, loading, onLoadMore }: OotdResultGridProps) => { const navigate = useNavigate(); const sentinelRef = useInfiniteScrollSentinel(hasNext, onLoadMore); - const columns = splitIntoColumns(items, COLUMN_COUNT); + const isWideLayout = useIsWideLayout(); + const columns = splitIntoColumns(items, isWideLayout ? 3 : 2); const handleClick = (id: number) => { saveRecentOotd(id); @@ -33,7 +33,7 @@ const OotdResultGrid = ({ items, hasNext, loading, onLoadMore }: OotdResultGridP return ( <> -
+
{columns.map((column, columnIndex) => (
{column.map((item) => ( diff --git a/src/features/search/ui/SearchOotdResultGrid.tsx b/src/features/search/ui/SearchOotdResultGrid.tsx index d9ba40c..6d20b5f 100644 --- a/src/features/search/ui/SearchOotdResultGrid.tsx +++ b/src/features/search/ui/SearchOotdResultGrid.tsx @@ -1,11 +1,10 @@ import { useNavigate } from 'react-router-dom'; +import useIsWideLayout from '@/shared/hooks/useIsWideLayout'; import { saveRecentOotd } from '../api/searchApi'; import type { SearchOotdResponse } from '../api/types'; import { useInfiniteScrollSentinel } from '../lib/useInfiniteScrollSentinel'; import SearchOotdCard from './SearchOotdCard'; -const COLUMN_COUNT = 2; - const splitIntoColumns = (items: T[], columnCount: number) => { const columns: T[][] = Array.from({ length: columnCount }, () => []); items.forEach((item, index) => columns[index % columnCount].push(item)); @@ -37,7 +36,8 @@ const SearchOotdResultGrid = ({ }: SearchOotdResultGridProps) => { const navigate = useNavigate(); const sentinelRef = useInfiniteScrollSentinel(hasNext, onLoadMore); - const columns = splitIntoColumns(items, COLUMN_COUNT); + const isWideLayout = useIsWideLayout(); + const columns = splitIntoColumns(items, isWideLayout ? 3 : 2); const handleClick = (id: number) => { saveRecentOotd(id); @@ -46,7 +46,7 @@ const SearchOotdResultGrid = ({ return ( <> -
+
{columns.map((column, columnIndex) => (
{column.map((item) => ( 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 && ( +
+ +
+ )}
diff --git a/src/pages/ootd/components/ViewHeader.tsx b/src/pages/ootd/components/ViewHeader.tsx index 0450666..0397f7d 100644 --- a/src/pages/ootd/components/ViewHeader.tsx +++ b/src/pages/ootd/components/ViewHeader.tsx @@ -1,4 +1,5 @@ -import { ChevronLeft, Menu } from 'lucide-react'; +import { Menu } from 'lucide-react'; +import { leftArrow } from '@/shared/assets'; export interface ViewHeaderProps { onBack: () => void; @@ -6,9 +7,9 @@ export interface ViewHeaderProps { } const ViewHeader = ({ onBack, onMoreClick }: ViewHeaderProps) => ( -
+