diff --git a/.gitignore b/.gitignore index bc1e64b..219d983 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ dist dist-ssr *.local +# Python test cache / bytecode +__pycache__/ +*.py[cod] + # Env .env .env.*.local diff --git a/src/features/home/components/SearchResultGallery.test.tsx b/src/features/home/components/SearchResultGallery.test.tsx index 5d01421..c17d557 100644 --- a/src/features/home/components/SearchResultGallery.test.tsx +++ b/src/features/home/components/SearchResultGallery.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SearchResultItem } from '@/features/search/api/searchRecords'; import { SearchResultGallery } from './SearchResultGallery'; -const ITEMS: SearchResultItem[] = Array.from({ length: 4 }, (_, index) => ({ +const ITEMS: SearchResultItem[] = Array.from({ length: 7 }, (_, index) => ({ recordId: index + 1, similarity: 1 - index * 0.1, place: { @@ -40,6 +40,18 @@ afterEach(() => { }); describe('SearchResultGallery', () => { + it('검색 결과 6개를 한 페이지의 2행 3열용 그룹에 담는다', () => { + act(() => { + root.render(); + }); + + const pages = container.querySelectorAll('.pl-results__page'); + + expect(pages).toHaveLength(1); + expect(pages[0]?.querySelectorAll('.pl-results__card')).toHaveLength(6); + expect(container.querySelector('.pl-results__controls')).toBeNull(); + }); + it('가로 탐색 가능한 결과 영역과 응답 순서의 카드를 렌더한다', () => { const onSelectRecord = vi.fn(); @@ -51,16 +63,44 @@ describe('SearchResultGallery', () => { const cards = Array.from(container.querySelectorAll('.pl-results__card')); expect(gallery?.getAttribute('role')).toBe('region'); - expect(gallery?.getAttribute('aria-label')).toBe('검색 결과 4곳'); + expect(gallery?.getAttribute('aria-label')).toBe('검색 결과 7곳'); expect(gallery?.tabIndex).toBe(0); - expect(cards.map((card) => card.textContent)).toEqual([ - expect.stringContaining('장소 1/4'), - expect.stringContaining('장소 2/4'), - expect.stringContaining('장소 3/4'), - expect.stringContaining('장소 4/4'), - ]); + expect(container.querySelectorAll('.pl-results__page')).toHaveLength(2); + expect(cards.map((card) => card.textContent)).toEqual( + Array.from({ length: 7 }, (_, index) => expect.stringContaining(`장소 ${index + 1}/7`)), + ); act(() => cards[2].click()); expect(onSelectRecord).toHaveBeenCalledWith(3); }); + + it('운영체제 스크롤바 설정과 무관한 6개 단위 탐색 컨트롤을 제공한다', () => { + act(() => { + root.render(); + }); + + const gallery = container.querySelector('.pl-results__gallery'); + const previousButton = container.querySelector( + 'button[aria-label="이전 검색 결과 6개"]', + ); + const nextButton = container.querySelector( + 'button[aria-label="다음 검색 결과 6개"]', + ); + const pages = Array.from(container.querySelectorAll('.pl-results__page')); + const scrollTo = vi.fn(); + + Object.defineProperty(gallery, 'scrollTo', { configurable: true, value: scrollTo }); + Object.defineProperty(pages[1], 'offsetLeft', { configurable: true, value: 960 }); + + expect(previousButton?.disabled).toBe(true); + expect(nextButton?.disabled).toBe(false); + expect(container.querySelector('.pl-results__page-status')?.textContent).toBe('1/2'); + + act(() => nextButton?.click()); + + expect(scrollTo).toHaveBeenCalledWith({ left: 960, behavior: 'smooth' }); + expect(previousButton?.disabled).toBe(false); + expect(nextButton?.disabled).toBe(true); + expect(container.querySelector('.pl-results__page-status')?.textContent).toBe('2/2'); + }); }); diff --git a/src/features/home/components/SearchResultGallery.tsx b/src/features/home/components/SearchResultGallery.tsx index 86fa9e1..6a07549 100644 --- a/src/features/home/components/SearchResultGallery.tsx +++ b/src/features/home/components/SearchResultGallery.tsx @@ -1,9 +1,9 @@ -import { useEffect, useId, useRef } from 'react'; +import { useEffect, useId, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { SearchResultItem } from '@/features/search/api/searchRecords'; import { ContextStickyNote } from '@/shared/ui/ContextStickyNote'; import { PaperNoteParts } from '@/shared/ui/PaperNoteParts'; -import { PinStanding } from '@/shared/ui/PinSymbols'; +import { PinPushMount, PinStanding } from '@/shared/ui/PinSymbols'; import { FOCUSABLE_SELECTOR } from '@/shared/lib/focusableSelector'; interface SearchResultGalleryProps { @@ -11,8 +11,19 @@ interface SearchResultGalleryProps { onSelectRecord: (recordId: number) => void; } +const SEARCH_RESULT_PAGE_SIZE = 6; +const SEARCH_NOTE_METRICS = { + bodyFontPx: 15, + bodyLineHeightPx: 18, + metaFontPx: 9, + padXPx: 14, + padTopPx: 18, + padBottomPx: 12, + dividerGapPx: 6, +} as const; + /** - * 스마트 검색 결과 갤러리: 가로 스크롤 카드 목록. + * 스마트 검색 결과 갤러리: PC 한 쪽에 2행 × 3열로 놓이는 종이 메모 목록. * 근거: Jira S15P11A705-165, mockup(PinLog.responsive.dc.html) home-coverflow(1063~1104행). * 목업은 3D coverflow(카드 겹침) 효과를 쓰지만, 이번 범위는 가로 스크롤 + 카드 형태로 충분하다고 * 명시돼 있어 완전한 3D 구현은 하지 않았다. @@ -27,63 +38,162 @@ interface SearchResultGalleryProps { * 표) matchedContext.body는 항상 본인 Context 원문이라 공개 범위 규칙(타인 Context 원문 비공개) * 위반이 아니다. attachment='flat'을 쓴 이유는 lifted보다 회전·그림자가 절제돼 있어 좁은 * 카드 안에서 다른 항목(이름·위치·키워드)과 부딪히지 않기 때문 — 디자인 시안이 도착하면 - * 이 선택은 다시 확인한다. S15P11A705-437부터 카드 폭은 고정값이 아니라 갤러리 가용 폭의 - * 3등분이다. 바깥 결과 영역이 좁아져도 세 번째 카드만 잘리는 대신 세 카드가 함께 줄어든다. + * 이 선택은 다시 확인한다. S15P11A705-438부터 한 페이지는 6장(2행 × 3열)이다. 일곱 번째부터 + * 다음 종이 쪽에 놓고 페이지 단위로 넘긴다. 카드 외곽은 반듯한 노트 한 장과 뒤에 겹친 종이만 + * 남기고, 바깥 노트의 부착 장식은 앱 공용 `PinPushMount`로 통일한다. 안쪽 Context는 다른 + * 화면과 같은 공용 컴포넌트 판형(파스텔 종이·테이프)을 그대로 유지한다. * editable을 넘기지 않는다(기본 false) — 검색 결과 카드는 Context를 고치는 자리가 아니다. */ export function SearchResultGallery({ items, onSelectRecord }: SearchResultGalleryProps) { const total = items.length; + const pageCount = Math.ceil(total / SEARCH_RESULT_PAGE_SIZE); + const pages = Array.from({ length: pageCount }, (_, pageIndex) => + items.slice(pageIndex * SEARCH_RESULT_PAGE_SIZE, (pageIndex + 1) * SEARCH_RESULT_PAGE_SIZE), + ); + const galleryId = useId(); + const galleryRef = useRef(null); + const [pageIndex, setPageIndex] = useState(0); + + const scrollToPage = (nextPageIndex: number) => { + const gallery = galleryRef.current; + if (gallery === null) { + return; + } + + const boundedPageIndex = Math.max(0, Math.min(nextPageIndex, pageCount - 1)); + const targetPage = gallery.querySelectorAll('.pl-results__page')[boundedPageIndex]; + if (targetPage === undefined) { + return; + } + + setPageIndex(boundedPageIndex); + gallery.scrollTo({ left: targetPage.offsetLeft, behavior: 'smooth' }); + }; + + const handleGalleryScroll = () => { + const gallery = galleryRef.current; + if (gallery === null) { + return; + } + + const pageStarts = Array.from(gallery.querySelectorAll('.pl-results__page')).map( + (page) => page.offsetLeft, + ); + const nearestPageIndex = pageStarts.reduce( + (nearest, start, index) => + Math.abs(gallery.scrollLeft - start) < Math.abs(gallery.scrollLeft - pageStarts[nearest]) + ? index + : nearest, + 0, + ); + setPageIndex(nearestPageIndex); + }; return ( -
- {items.map((item, index) => ( - + ); + })}
+ ))} + - {item.keywords.length > 0 ? ( -
- {item.keywords.map((keyword) => ( - - {keyword} - - ))} -
- ) : item.keywordStatus === 'PROCESSING' ? ( -

- AI가 키워드를 분석 중이에요. 잠시 후 자동으로 채워집니다 -

- ) : ( -

이 기록엔 키워드가 없어요

- )} - - - - ))} + {pageCount > 1 && ( + + )} ); } diff --git a/src/features/home/paperAperture.css b/src/features/home/paperAperture.css index d1e79e8..f87a853 100644 --- a/src/features/home/paperAperture.css +++ b/src/features/home/paperAperture.css @@ -1097,23 +1097,26 @@ position: absolute; z-index: 15; left: 50%; - top: 50%; - width: min(960px, calc(100% - 320px)); + top: calc(50% + 38px); + width: min(1020px, calc(100% - 280px)); transform: translate(-50%, -50%); } -/* 437: 세 카드가 한 화면의 한 "쪽"이다. 네 번째부터는 같은 폭으로 오른쪽에 이어지고, 각 쪽의 - 첫 카드(1·4·7…)에만 snap을 걸어 휠·트랙패드 탐색이 세 장 단위로 안정된다. 스크롤 영역 자체에 - 포커스를 줄 수 있어 키보드 화살표/페이지 이동도 브라우저 기본 동작으로 사용할 수 있다. */ +/* 438: 한 쪽은 2행 × 3열, 총 6장이다. gallery는 쪽을 가로로 넘기는 레일이고 page가 실제 6칸 + 종이판이다. 한 카드씩 auto-column으로 흘리면 2행에서 순서가 1·3·5 / 2·4·6으로 섞이므로, + 반드시 6장씩 DOM을 묶은 page 안에서 row-major grid를 쓴다. */ +.pl-results__viewport { + min-width: 0; +} + .pl-results__gallery { - display: grid; - grid-auto-flow: column; - grid-auto-columns: calc((100% - 2rem) / 3); - gap: 1rem; + display: flex; + height: min(610px, calc(100cqh - 230px)); + min-height: 440px; overflow-x: auto; overflow-y: hidden; - padding: 4px 2px 12px; - scroll-padding-inline: 2px; + padding: 10px 12px 8px; + scroll-padding-inline: 12px; scroll-snap-type: x mandatory; scrollbar-color: theme('colors.log-mint') rgba(250, 247, 246, 0.82); outline: none; @@ -1125,13 +1128,146 @@ outline-offset: 4px; } -.pl-results__card:nth-child(3n + 1) { +.pl-results__page { + box-sizing: border-box; + display: grid; + flex: 0 0 100%; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 16px 18px; + min-width: 0; + padding: 4px 8px 8px; scroll-snap-align: start; scroll-snap-stop: always; } +/* 438 사용자 피드백: 찢긴 가장자리와 장식 종류를 여러 개 섞으면 종이보다 실루엣이 먼저 보여 + 글을 읽는 데 방해가 된다. 테두리 없는 흰 노트 한 페이지와 은은한 괘선만 남기고, + 고정 장치는 앱 공용 PinPushMount 하나만 쓴다. */ +.pl-results__card { + position: relative; + isolation: isolate; + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + gap: 7px; + padding: 19px 18px 15px; + border: 0; + background: + repeating-linear-gradient(0deg, transparent 0 30px, rgba(59, 183, 162, 0.04) 30px 31px), #fff; + color: theme('colors.pin-navy'); + text-align: left; + transition: + box-shadow 180ms ease, + transform 180ms ease; + box-shadow: 0 8px 16px rgba(4, 33, 66, 0.11); +} + +.pl-results__card:hover { + z-index: 2; + box-shadow: 0 12px 20px rgba(4, 33, 66, 0.15); + transform: translateY(-3px); +} + +.pl-results__card:focus-visible { + outline: 2px solid theme('colors.log-mint'); + outline-offset: 2px; +} + +.pl-results__index { + flex: none; + font-family: theme('fontFamily.hand'); + font-size: 14px; + font-weight: 700; + letter-spacing: 0.08em; + color: theme('colors.log-mint'); +} + +.pl-results__place { + min-width: 0; + flex: none; + padding-bottom: 5px; + border-bottom: 1px dashed rgba(4, 33, 66, 0.16); +} + +.pl-results__place-name { + overflow: hidden; + font-size: 16px; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pl-results__address { + overflow: hidden; + margin-top: 1px; + color: theme('colors.log-mint'); + font-size: 11px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pl-results__keywords { + display: flex; + min-height: 19px; + flex: none; + gap: 10px; + overflow: hidden; +} + +/* 칩 대신 메모지에 형광펜으로 밑줄 친 낱말처럼 보이게 한다. */ +.pl-results__keyword { + position: relative; + z-index: 0; + flex: none; + padding-inline: 2px; + font-size: 11px; + font-weight: 700; + color: theme('colors.pin-navy'); +} + +.pl-results__keyword::before { + content: ''; + position: absolute; + z-index: -1; + right: 0; + bottom: 1px; + left: 0; + height: 7px; + background: rgba(59, 183, 162, 0.24); + transform: rotate(-1.5deg); +} + +.pl-results__keyword-status { + min-height: 19px; + flex: none; + font-size: 10px; + color: theme('colors.ink-gray-light'); +} + +.pl-results__context { + min-height: 0; + flex: 1; + overflow: visible; + padding: 13px 7px 7px; +} + +.pl-results__context .context-sticky-note { + min-height: calc(100% - 4px); +} + +/* 회전된 Context의 모서리는 자르지 않되, 긴 본문이 다음 카드까지 침범하지 않게 본문만 3줄로 묶는다. */ +.pl-results__context .context-sticky-note > p:first-of-type { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + .pl-results__gallery::-webkit-scrollbar { - height: 12px; + height: 10px; } .pl-results__gallery::-webkit-scrollbar-track { @@ -1151,6 +1287,75 @@ background-color: theme('colors.pin-navy'); } +.pl-results__controls { + display: flex; + align-items: center; + gap: 10px; + width: min(520px, calc(100% - 24px)); + margin: 8px auto 0; + color: theme('colors.pin-navy'); +} + +.pl-results__page-button { + display: grid; + flex: none; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid theme('colors.line-card'); + border-radius: 9999px; + background: rgba(250, 247, 246, 0.94); + box-shadow: 0 5px 14px rgba(4, 33, 66, 0.12); + font-size: 22px; + line-height: 1; + transition: + color 160ms ease, + background-color 160ms ease, + transform 160ms ease; +} + +.pl-results__page-button:not(:disabled):hover { + color: white; + background: theme('colors.log-mint'); + transform: translateY(-1px); +} + +.pl-results__page-button:focus-visible { + outline: 2px solid theme('colors.log-mint'); + outline-offset: 2px; +} + +.pl-results__page-button:disabled { + cursor: default; + opacity: 0.35; +} + +.pl-results__page-track { + position: relative; + flex: 1; + height: 8px; + overflow: hidden; + border: 1px solid theme('colors.line-card'); + border-radius: 9999px; + background: rgba(250, 247, 246, 0.88); +} + +.pl-results__page-thumb { + position: absolute; + inset-block: 0; + left: 0; + border-radius: inherit; + background: theme('colors.log-mint'); + transition: transform 220ms ease; +} + +.pl-results__page-status { + min-width: 32px; + font-size: 11px; + font-weight: 700; + text-align: center; +} + /* ── 반응형: 축소가 아니라 제본 방식이 바뀐다 ───────────────────────── 넓으면 4판(세로쓰기 곁텍스트 포함), 중간이면 상하판만, 좁으면 상판 위주. 창의 비율 자체가 판형을 따라 바뀐다. @@ -1202,11 +1407,14 @@ font-size: 12px; } .pl-results__gallery { - grid-auto-columns: min(86%, 288px); - scroll-snap-type: x mandatory; + height: min(610px, calc(100cqh - 210px)); + } + .pl-results__page { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); } - .pl-results__card:nth-child(n) { - scroll-snap-align: start; + .pl-results__controls { + width: calc(100% - 24px); } } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 3300207..4c3249e 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -178,6 +178,7 @@ export function HomePage() { {hasResults && (
item.recordId).join('-')} items={searchMutation.data.items} onSelectRecord={setOpenRecordId} /> diff --git a/tests/__pycache__/test_frontend_ci_contract.cpython-312.pyc b/tests/__pycache__/test_frontend_ci_contract.cpython-312.pyc deleted file mode 100644 index 3b3723c..0000000 Binary files a/tests/__pycache__/test_frontend_ci_contract.cpython-312.pyc and /dev/null differ