Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/features/home/components/SearchResultGallery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
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) => ({
recordId: index + 1,
similarity: 1 - index * 0.1,
place: {
placeId: index + 10,
name: `장소 ${index + 1}`,
address: `주소 ${index + 1}`,
lat: 37.5,
lng: 127,
},
matchedContext: {
contextId: index + 20,
body: `맥락 ${index + 1}`,
createdAt: '2026-08-12T00:00:00+09:00',
},
keywords: [],
keywordStatus: 'COMPLETED',
createdAt: '2026-08-12T00:00:00+09:00',
}));

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
});

describe('SearchResultGallery', () => {
it('가로 탐색 가능한 결과 영역과 응답 순서의 카드를 렌더한다', () => {
const onSelectRecord = vi.fn();

act(() => {
root.render(<SearchResultGallery items={ITEMS} onSelectRecord={onSelectRecord} />);
});

const gallery = container.querySelector<HTMLElement>('.pl-results__gallery');
const cards = Array.from(container.querySelectorAll<HTMLButtonElement>('.pl-results__card'));

expect(gallery?.getAttribute('role')).toBe('region');
expect(gallery?.getAttribute('aria-label')).toBe('검색 결과 4곳');
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'),
]);

act(() => cards[2].click());
expect(onSelectRecord).toHaveBeenCalledWith(3);
});
});
14 changes: 10 additions & 4 deletions src/features/home/components/SearchResultGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,27 @@ interface SearchResultGalleryProps {
* `POST /search/records`는 "내 기록의 맥락"만 검색하므로(api-contract.md "Place · 지도 · 검색"
* 표) matchedContext.body는 항상 본인 Context 원문이라 공개 범위 규칙(타인 Context 원문 비공개)
* 위반이 아니다. attachment='flat'을 쓴 이유는 lifted보다 회전·그림자가 절제돼 있어 좁은
* 카드(w-72) 안에서 다른 항목(이름·위치·키워드)과 부딪히지 않기 때문 — 디자인 시안이 도착하면
* 이 선택은 다시 확인한다.
* 카드 안에서 다른 항목(이름·위치·키워드)과 부딪히지 않기 때문 — 디자인 시안이 도착하면
* 이 선택은 다시 확인한다. S15P11A705-437부터 카드 폭은 고정값이 아니라 갤러리 가용 폭의
* 3등분이다. 바깥 결과 영역이 좁아져도 세 번째 카드만 잘리는 대신 세 카드가 함께 줄어든다.
* editable을 넘기지 않는다(기본 false) — 검색 결과 카드는 Context를 고치는 자리가 아니다.
*/
export function SearchResultGallery({ items, onSelectRecord }: SearchResultGalleryProps) {
const total = items.length;

return (
<div className="flex gap-4 overflow-x-auto pb-2">
<div
className="pl-results__gallery"
role="region"
aria-label={`검색 결과 ${total}곳`}
tabIndex={0}
>
{items.map((item, index) => (
<button
key={item.recordId}
type="button"
onClick={() => onSelectRecord(item.recordId)}
className="flex w-72 flex-none flex-col gap-3 rounded-2xl border border-line-card bg-white p-5 text-left shadow-sm transition-transform hover:-translate-y-1"
className="pl-results__card flex min-w-0 flex-col gap-3 rounded-2xl border border-line-card bg-white p-5 text-left shadow-sm transition-transform hover:-translate-y-1"
>
<p className="text-[11px] font-bold tracking-[0.12em] text-log-mint">
장소 {index + 1}/{total}
Expand Down
70 changes: 63 additions & 7 deletions src/features/home/paperAperture.css
Original file line number Diff line number Diff line change
Expand Up @@ -1088,18 +1088,67 @@
}
/* ── 검색 결과 ───────────────────────────────────────────────────────
결과는 창이 완전히 열린 뒤(--open이 1일 때)에만 존재하므로 종이 판과 겹칠 일이 없다.
좌우 레일의 포스트잇·책은 지도 쪽으로 삐져나오므로 넓은 판형에서는 360px 이상의 안전 여백을
예약한다. 1080px 이하에서는 레일 자체가 사라져 원래의 유동 여백으로 되돌린다.
437: 예전처럼 좌우에 각각 360px 이상을 예약하면 1280px PC에서 실제 결과 폭이 약 560px밖에
남지 않아 288px 카드 세 장 중 마지막 장이 잘렸다. 레일 소품이 실제로 침범하는 160px만
양쪽에 남기고 중앙 갤러리를 최대 960px로 제한한다. 내부 카드는 이 폭을 정확히 3등분한다.
z-index는 판(6·7)보다 위, 도크(20)보다 아래에 둔다 — 지우다 만 상태에서 판이 아직 돌아오는 중일
때 결과가 종이 밑으로 들어가 보이지 않게 하려는 것이다. */
.pl-results {
position: absolute;
z-index: 15;
left: 0;
right: 0;
left: 50%;
top: 50%;
transform: translateY(-50%);
padding: 0 clamp(360px, 25%, 440px);
width: min(960px, calc(100% - 320px));
transform: translate(-50%, -50%);
}

/* 437: 세 카드가 한 화면의 한 "쪽"이다. 네 번째부터는 같은 폭으로 오른쪽에 이어지고, 각 쪽의
첫 카드(1·4·7…)에만 snap을 걸어 휠·트랙패드 탐색이 세 장 단위로 안정된다. 스크롤 영역 자체에
포커스를 줄 수 있어 키보드 화살표/페이지 이동도 브라우저 기본 동작으로 사용할 수 있다. */
.pl-results__gallery {
display: grid;
grid-auto-flow: column;
grid-auto-columns: calc((100% - 2rem) / 3);
gap: 1rem;
overflow-x: auto;
overflow-y: hidden;
padding: 4px 2px 12px;
scroll-padding-inline: 2px;
scroll-snap-type: x mandatory;
scrollbar-color: theme('colors.log-mint') rgba(250, 247, 246, 0.82);
outline: none;
}

.pl-results__gallery:focus-visible {
border-radius: 18px;
outline: 2px solid theme('colors.log-mint');
outline-offset: 4px;
}

.pl-results__card:nth-child(3n + 1) {
scroll-snap-align: start;
scroll-snap-stop: always;
}

.pl-results__gallery::-webkit-scrollbar {
height: 12px;
}

.pl-results__gallery::-webkit-scrollbar-track {
background-color: rgba(250, 247, 246, 0.82);
border: 1px solid theme('colors.line-card');
border-radius: 9999px;
}

.pl-results__gallery::-webkit-scrollbar-thumb {
border: 3px solid transparent;
background-clip: content-box;
background-color: theme('colors.log-mint');
border-radius: 9999px;
}

.pl-results__gallery::-webkit-scrollbar-thumb:hover {
background-color: theme('colors.pin-navy');
}

/* ── 반응형: 축소가 아니라 제본 방식이 바뀐다 ─────────────────────────
Expand All @@ -1119,7 +1168,7 @@
--pl-sh-x: 0%;
}
.pl-results {
padding: 0 clamp(22px, 7%, 92px);
width: min(960px, calc(100% - 44px));
}
.pl-dock {
/* 곁열이 없으니 밀 대상도 없다 — 무대의 수학적 중앙이 곧 광학적 중앙이다. */
Expand Down Expand Up @@ -1152,6 +1201,13 @@
padding: 10px 14px;
font-size: 12px;
}
.pl-results__gallery {
grid-auto-columns: min(86%, 288px);
scroll-snap-type: x mandatory;
}
.pl-results__card:nth-child(n) {
scroll-snap-align: start;
}
}

@media (prefers-reduced-motion: reduce) {
Expand Down
4 changes: 3 additions & 1 deletion src/features/records/components/PlaceRecordSheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ describe('PlaceRecordSheet 760px 노트 밀도', () => {
(button) => button.textContent?.trim() === '이 이미지로 분석 시작',
);

expect(dropzone?.className).toContain('min-h-[120px]');
expect(dropzone?.className).toContain('flex-1');
expect(dropzone?.closest('div')?.className).toContain('min-h-[156px]');
expect(dropzone?.closest('div')?.className).toContain('my-3');
expect(analyzeButton?.className).toContain('h-12');
expect(analyzeButton?.className).not.toContain('h-[60px]');
});
Expand Down
41 changes: 22 additions & 19 deletions src/features/records/components/PlaceRecordSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,7 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe
* 분석된 이미지는 저장되지 않으며, 장소 후보 확인에만 사용됩니다.
</p>

<div className="mx-auto mb-3 w-full max-w-[320px]">
<div className="my-3 flex min-h-[156px] w-full flex-1">
<label
htmlFor="place-capture-input"
onDragOver={(event) => {
Expand All @@ -693,10 +693,9 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe
onDragLeave={() => setIsDraggingImage(false)}
onDrop={handleDropImage}
className={
'block cursor-pointer overflow-hidden rounded-xl border-4 shadow-[0_8px_20px_-10px_rgba(60,54,48,0.4)] transition-colors ' +
'flex min-h-0 w-full flex-1 cursor-pointer overflow-hidden rounded-xl border-4 shadow-[0_8px_20px_-10px_rgba(60,54,48,0.4)] transition-colors ' +
(isDraggingImage ? 'border-[#e9f4ee]' : 'border-white')
}
style={{ transform: 'rotate(-0.8deg)' }}
>
<input
id="place-capture-input"
Expand All @@ -706,13 +705,15 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe
className="sr-only"
/>
{imagePreviewUrl && imageFile ? (
<span className="grid grid-cols-[72px_1fr] items-center gap-3 bg-[#faf9f7] p-2 text-left">
<img
src={imagePreviewUrl}
alt="업로드한 캡처 미리보기"
className="h-[72px] w-[72px] rounded-lg object-cover"
/>
<span>
<span className="flex min-h-0 w-full flex-1 flex-col gap-2 bg-[#faf9f7] p-3 text-left">
<span className="relative min-h-0 w-full flex-1 overflow-hidden rounded-lg">
<img
src={imagePreviewUrl}
alt="업로드한 캡처 미리보기"
className="absolute inset-0 h-full w-full object-cover"
/>
</span>
<span className="flex-none">
<strong className="block text-sm font-extrabold text-[#2c2a28]">
{imageFile.name}
</strong>
Expand All @@ -724,17 +725,19 @@ export function PlaceRecordSheet({ previewMode = false, onRecordSaved }: PlaceRe
) : (
<span
data-testid="place-capture-dropzone"
className="grid min-h-[120px] place-items-center bg-[#faf9f7] p-4 text-center"
className="grid w-full flex-1 place-items-center bg-[#faf9f7] p-4 text-center"
>
<span className="mx-auto mb-2 grid h-10 w-10 place-items-center rounded-xl border-2 border-[#4f9b78]/60 text-2xl font-bold text-[#4f9b78]">
+
<span>
<span className="mx-auto mb-2 grid h-10 w-10 place-items-center rounded-xl border-2 border-[#4f9b78]/60 text-2xl font-bold text-[#4f9b78]">
+
</span>
<strong className="block text-sm font-extrabold text-[#2c2a28]">
이미지를 드래그하거나 클릭하여 업로드
</strong>
<small className="mt-1 block text-xs text-[#8a857e]">
JPG, PNG · 최대 1장(10MB 이하)
</small>
</span>
<strong className="block text-sm font-extrabold text-[#2c2a28]">
이미지를 드래그하거나 클릭하여 업로드
</strong>
<small className="mt-1 block text-xs text-[#8a857e]">
JPG, PNG · 최대 1장(10MB 이하)
</small>
</span>
)}
</label>
Expand Down
Loading