Merge branch 'MainPage'
This commit is contained in:
17
.cursor/.prompt/search.md
Normal file
17
.cursor/.prompt/search.md
Normal file
@@ -0,0 +1,17 @@
|
||||
검색어 입력 폼은
|
||||
|
||||
|
||||
width: 384px;
|
||||
height: 48px;
|
||||
padding: 0 8px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--gray-20, #D5D5D5);
|
||||
background: var(--gray-00, #FFF);
|
||||
|
||||
이 모양이며 좌측에
|
||||
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 21L17.682 17.682M17.682 17.682C18.4963 16.8676 19 15.7426 19 14.5C19 12.0147 16.9853 10 14.5 10C12.0147 10 10 12.0147 10 14.5C10 16.9853 12.0147 19 14.5 19C15.7426 19 16.8676 18.4963 17.682 17.682ZM28 16C28 22.6274 22.6274 28 16 28C9.37258 28 4 22.6274 4 16C4 9.37258 9.37258 4 16 4C22.6274 4 28 9.37258 28 16Z" stroke="#707070" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
||||
이 아이콘이 들어감 placeholder는 검색어를 입력해 주세요.
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 829 KiB After Width: | Height: | Size: 829 KiB |
@@ -21,6 +21,7 @@ export function AppHeader() {
|
||||
const [panelHeight, setPanelHeight] = React.useState<number>(0);
|
||||
const [blockWidths, setBlockWidths] = React.useState<Record<string, number>>({});
|
||||
const closeTimer = React.useRef<number | null>(null);
|
||||
const [navMinWidths, setNavMinWidths] = React.useState<Record<string, number>>({});
|
||||
|
||||
// 현재 경로 기반 활성 보드/카테고리 계산
|
||||
const pathname = usePathname();
|
||||
@@ -97,6 +98,97 @@ export function AppHeader() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// (요구) 상위 카테고리 폭을 키우지 않음: 측정은 하되 상위 폭 업데이트는 하지 않음
|
||||
React.useEffect(() => {
|
||||
if (!categories.length) return;
|
||||
const measure = () => {
|
||||
// 상위 폭 미변경: 측정만 수행하고 상태는 건드리지 않음
|
||||
};
|
||||
// 레이아웃 안정화 이후(다음 두 프레임)에 1회 측정
|
||||
const raf1 = window.requestAnimationFrame(() => {
|
||||
const raf2 = window.requestAnimationFrame(() => measure());
|
||||
(measure as any)._raf2 = raf2;
|
||||
});
|
||||
const onResize = () => measure();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf1);
|
||||
if ((measure as any)._raf2) window.cancelAnimationFrame((measure as any)._raf2);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [categories]);
|
||||
|
||||
// 초기 로드/리사이즈 시, 드롭다운 최종 폭을 선계산(상위 폭은 키우지 않음)
|
||||
React.useEffect(() => {
|
||||
if (!categories.length) return;
|
||||
const recomputeOnce = () => {
|
||||
const container = panelRef.current;
|
||||
if (!container) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const nextPositions: Record<string, number> = {};
|
||||
const desiredWidths: Record<string, number> = {};
|
||||
const minWidthsBySlug: Record<string, number> = { community: 200 };
|
||||
categories.forEach((cat) => {
|
||||
const itemEl = navItemRefs.current[cat.slug];
|
||||
if (!itemEl) return;
|
||||
const r = itemEl.getBoundingClientRect();
|
||||
nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left);
|
||||
const baseWidth = r.width; // 상위 아이템 실제 폭
|
||||
const minWidth = minWidthsBySlug[cat.slug] ?? baseWidth;
|
||||
const blockEl = blockRefs.current[cat.slug];
|
||||
let maxChildWidth = 0;
|
||||
if (blockEl) {
|
||||
const links = blockEl.querySelectorAll("a");
|
||||
links.forEach((a) => {
|
||||
const el = a as HTMLElement;
|
||||
const rectW = el.getBoundingClientRect().width;
|
||||
const cs = window.getComputedStyle(el);
|
||||
const ml = parseFloat(cs.marginLeft || "0");
|
||||
const mr = parseFloat(cs.marginRight || "0");
|
||||
const w = Math.ceil(rectW + ml + mr);
|
||||
if (w > maxChildWidth) maxChildWidth = w;
|
||||
});
|
||||
if (maxChildWidth === 0) {
|
||||
const contentEl = blockEl.firstElementChild as HTMLElement | null;
|
||||
maxChildWidth = Math.ceil(contentEl?.getBoundingClientRect().width ?? blockEl.getBoundingClientRect().width);
|
||||
}
|
||||
}
|
||||
// 하위 컨테이너 폭을 상위 폭으로 상한(clamp)
|
||||
const measuredSub = Math.max(maxChildWidth, 0);
|
||||
desiredWidths[cat.slug] = Math.min(baseWidth, Math.max(minWidth, measuredSub));
|
||||
});
|
||||
const finalWidths: Record<string, number> = {};
|
||||
const ordered = categories
|
||||
.filter((c) => typeof nextPositions[c.slug] === "number")
|
||||
.sort((a, b) => (nextPositions[a.slug]! - nextPositions[b.slug]!));
|
||||
ordered.forEach((cat, idx) => {
|
||||
const currentLeft = nextPositions[cat.slug]!;
|
||||
const desired = desiredWidths[cat.slug] ?? 0;
|
||||
const maxAvail = idx < ordered.length - 1
|
||||
? Math.max(0, nextPositions[ordered[idx + 1].slug]! - currentLeft)
|
||||
: Math.max(0, containerRect.width - currentLeft);
|
||||
finalWidths[cat.slug] = Math.min(desired, maxAvail);
|
||||
});
|
||||
|
||||
// 상태 업데이트는 변경된 경우에만 수행
|
||||
const eq = (a: Record<string, number>, b: Record<string, number>) => {
|
||||
const ka = Object.keys(a);
|
||||
const kb = Object.keys(b);
|
||||
if (ka.length !== kb.length) return false;
|
||||
for (const k of ka) { if (a[k] !== b[k]) return false; }
|
||||
return true;
|
||||
};
|
||||
setBlockWidths((prev) => (eq(prev, finalWidths) ? prev : finalWidths));
|
||||
};
|
||||
const raf = window.requestAnimationFrame(() => recomputeOnce());
|
||||
const onResize = () => recomputeOnce();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [categories]);
|
||||
|
||||
// 헤더 항목과 드롭다운 블록 정렬: 좌표 측정 및 패널 높이 계산
|
||||
React.useEffect(() => {
|
||||
if (!megaOpen) return;
|
||||
@@ -112,12 +204,31 @@ export function AppHeader() {
|
||||
if (!itemEl) return;
|
||||
const r = itemEl.getBoundingClientRect();
|
||||
nextPositions[cat.slug] = Math.max(0, r.left - containerRect.left);
|
||||
const baseWidth = r.width; // 각 네비 항목의 실제 폭을 기본값으로 사용
|
||||
const baseWidth = r.width; // 상위 항목 실제 폭
|
||||
const minWidth = minWidthsBySlug[cat.slug] ?? baseWidth;
|
||||
desiredWidths[cat.slug] = Math.max(baseWidth, minWidth);
|
||||
// 하위 카테고리(드롭다운) 버튼들의 최대 실제 폭(패딩+마진 포함) 측정
|
||||
const blockEl = blockRefs.current[cat.slug];
|
||||
let maxChildWidth = 0;
|
||||
if (blockEl) {
|
||||
const links = blockEl.querySelectorAll("a");
|
||||
links.forEach((a) => {
|
||||
const el = a as HTMLElement;
|
||||
const rectW = el.getBoundingClientRect().width;
|
||||
const cs = window.getComputedStyle(el);
|
||||
const ml = parseFloat(cs.marginLeft || "0");
|
||||
const mr = parseFloat(cs.marginRight || "0");
|
||||
const w = Math.ceil(rectW + ml + mr);
|
||||
if (w > maxChildWidth) maxChildWidth = w;
|
||||
});
|
||||
if (maxChildWidth === 0) {
|
||||
const contentEl = blockEl.firstElementChild as HTMLElement | null;
|
||||
maxChildWidth = Math.ceil(contentEl?.getBoundingClientRect().width ?? blockEl.getBoundingClientRect().width);
|
||||
}
|
||||
}
|
||||
// 하위 컨테이너 폭을 상위 폭으로 상한(clamp)
|
||||
const measuredSub = Math.max(maxChildWidth, 0);
|
||||
desiredWidths[cat.slug] = Math.min(baseWidth, Math.max(minWidth, measuredSub));
|
||||
});
|
||||
setLeftPositions(nextPositions);
|
||||
|
||||
// 원하는 최소 폭을 기준으로 하되, 다음 항목의 시작점까지를 넘지 않도록 클램프
|
||||
const finalWidths: Record<string, number> = {};
|
||||
const ordered = categories
|
||||
@@ -133,6 +244,18 @@ export function AppHeader() {
|
||||
});
|
||||
setBlockWidths(finalWidths);
|
||||
|
||||
// 우측 경계 초과 시 좌측으로 이동하여 뷰포트 안에 맞춤
|
||||
const finalLefts: Record<string, number> = {};
|
||||
ordered.forEach((cat) => {
|
||||
const currentLeft = nextPositions[cat.slug]!;
|
||||
const width = finalWidths[cat.slug] ?? 0;
|
||||
const maxLeft = Math.max(0, containerRect.width - width);
|
||||
finalLefts[cat.slug] = Math.max(0, Math.min(currentLeft, maxLeft));
|
||||
});
|
||||
setLeftPositions(finalLefts);
|
||||
|
||||
// 상위 카테고리 최소 폭은 초기 측정(useEffect)에서만 설정
|
||||
|
||||
// 패널 높이 = 블록들 중 최대 높이
|
||||
let maxH = 0;
|
||||
categories.forEach((cat) => {
|
||||
@@ -146,6 +269,29 @@ export function AppHeader() {
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [megaOpen, categories]);
|
||||
|
||||
// xl 이상으로 뷰포트가 커지면 모바일 사이드바를 자동으로 닫음
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia("(min-width: 1280px)");
|
||||
const onChange = (e: any) => {
|
||||
if (e.matches) setMobileOpen(false);
|
||||
};
|
||||
// 초기 상태 체크 (이미 xl 이상인 경우 닫기)
|
||||
if (mql.matches) setMobileOpen(false);
|
||||
if (typeof mql.addEventListener === "function") {
|
||||
mql.addEventListener("change", onChange);
|
||||
} else {
|
||||
// Safari 등 구형 브라우저 호환
|
||||
mql.addListener(onChange);
|
||||
}
|
||||
return () => {
|
||||
if (typeof mql.removeEventListener === "function") {
|
||||
mql.removeEventListener("change", onChange);
|
||||
} else {
|
||||
mql.removeListener(onChange);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<header ref={headerRef} className="relative flex items-center justify-between px-4 py-3 bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
||||
<div className="flex items-center gap-3 z-[100]">
|
||||
@@ -187,11 +333,11 @@ export function AppHeader() {
|
||||
ref={(el) => {
|
||||
navItemRefs.current[cat.slug] = el;
|
||||
}}
|
||||
style={{ minWidth: idx === categories.length - 1 ? 120 : undefined }}
|
||||
style={idx === categories.length - 1 ? { minWidth: 120 } : undefined}
|
||||
>
|
||||
<Link
|
||||
href={cat.boards?.[0]?.id ? `/boards/${cat.boards[0].id}` : `/boards?category=${cat.slug}`}
|
||||
className={`px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
|
||||
className={`block w-full px-2 py-2 text-sm font-medium transition-colors duration-200 hover:text-neutral-900 whitespace-nowrap ${
|
||||
activeCategorySlug === cat.slug ? "text-neutral-900" : "text-neutral-700"
|
||||
}`}
|
||||
ref={(el) => {
|
||||
@@ -230,7 +376,7 @@ export function AppHeader() {
|
||||
style={{ left: (leftPositions[cat.slug] ?? 0), width: blockWidths[cat.slug] ?? undefined }}
|
||||
>
|
||||
{/* <div className="mb-2 font-semibold text-neutral-800">{cat.name}</div> */}
|
||||
<div className="mx-auto w-max flex flex-col items-center gap-3">
|
||||
<div className="mx-auto flex flex-col items-center gap-3 w-full">
|
||||
{cat.boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
@@ -252,7 +398,7 @@ export function AppHeader() {
|
||||
</div>
|
||||
</div>
|
||||
<div id="dummy" className="block"></div>
|
||||
<div className="hidden md:block">
|
||||
<div className="hidden xl:flex xl:flex-1 justify-end">
|
||||
<SearchBar/>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -101,15 +101,47 @@ export default function HorizontalCardScroller({ items }: HorizontalCardScroller
|
||||
<div ref={scrollRef} className="scrollbar-hidden h-full overflow-x-auto overflow-y-hidden">
|
||||
<div className="flex h-full items-center gap-4">
|
||||
{items.map((card) => (
|
||||
<article key={card.id} className="flex-shrink-0 w-[384px] h-[324px] rounded-xl bg-white overflow-hidden shadow-sm p-2">
|
||||
<article
|
||||
key={card.id}
|
||||
className="flex-shrink-0 w-[384px] h-[308px] rounded-[16px] bg-white overflow-hidden"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 1px 2px 0 var(--color-alpha-shadow1, rgba(0, 0, 0, 0.05)), 0 0 2px 0 var(--color-alpha-shadow1, rgba(0, 0, 0, 0.05))",
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-rows-[192px_116px] h-full">
|
||||
<div className="w-full h-[192px] overflow-hidden rounded-lg">
|
||||
{/* 상단: 사진 384x192, 상단 라운드 16, 하단 0 */}
|
||||
<div className="w-full h-[192px] overflow-hidden rounded-t-[16px]">
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="h-[116px] flex flex-col justify-center px-2">
|
||||
<p className="text-sm text-neutral-600">{card.region}</p>
|
||||
<p className="text-base font-semibold">{card.name}</p>
|
||||
<p className="text-sm text-neutral-600">{card.address}</p>
|
||||
{/* 하단: 384x116, x-패딩 32, y-패딩 16, 3행 */}
|
||||
<div className="h-[116px] px-8 py-4 grid grid-rows-[26px_auto_16px]">
|
||||
{/* 1행: 배지 68x26, r-20, p:6px 16px, 좌측정렬 */}
|
||||
<div className="w-[68px] h-[26px] rounded-[20px] px-4 py-[6px] bg-neutral-100 text-neutral-700 text-[12px] font-medium leading-[14px] flex items-center">
|
||||
진행중
|
||||
</div>
|
||||
{/* 2행: 업체이름 24px, 400 */}
|
||||
<div className="self-center">
|
||||
<p className="text-[24px] font-normal text-neutral-900 truncate">{card.name}</p>
|
||||
</div>
|
||||
{/* 3행: 주소, 하트, 숫자, 별, 숫자 (12px, w-300, lh-16) */}
|
||||
<div className="flex items-center gap-3 text-[12px] font-light leading-4 text-neutral-600">
|
||||
<span className="flex-1 truncate">{card.address}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{/* 하트 */}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M12 21s-7.364-4.632-9.428-8.571C.841 9.698 2.09 6.5 5.143 6.5 7.018 6.5 8.4 7.64 9 8.75 9.6 7.64 10.982 6.5 12.857 6.5c3.053 0 4.302 3.198 2.571 5.929C19.364 16.368 12 21 12 21z" />
|
||||
</svg>
|
||||
<span>12</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{/* 별 */}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M12 17.27l6.18 3.73-1.64-7.03L21.5 9.24l-7.19-.61L12 2 9.69 8.63 2.5 9.24l4.96 4.73L5.82 21z" />
|
||||
</svg>
|
||||
<span>4.5</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -14,25 +14,40 @@ export function SearchBar() {
|
||||
}}
|
||||
role="search"
|
||||
aria-label="사이트 검색"
|
||||
style={{ display: "flex", gap: 8, alignItems: "center" }}
|
||||
className="relative w-full max-w-[384px]"
|
||||
>
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
className="absolute right-2 top-2 w-8 h-8"
|
||||
>
|
||||
<path
|
||||
d="M21 21L17.682 17.682M17.682 17.682C18.4963 16.8676 19 15.7426 19 14.5C19 12.0147 16.9853 10 14.5 10C12.0147 10 10 12.0147 10 14.5C10 16.9853 12.0147 19 14.5 19C15.7426 19 16.8676 18.4963 17.682 17.682ZM28 16C28 22.6274 22.6274 28 16 28C9.37258 28 4 22.6274 4 16C4 9.37258 9.37258 4 16 4C22.6274 4 28 9.37258 28 16Z"
|
||||
stroke="#707070"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
placeholder="검색어 입력"
|
||||
placeholder="검색어를 입력해 주세요."
|
||||
enterKeyHint="search"
|
||||
aria-label="검색어"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setTerm("");
|
||||
}}
|
||||
style={{ padding: "6px 8px", border: "1px solid #ddd", borderRadius: 6, minWidth: 160 }}
|
||||
className="w-full h-12 pr-12 pl-2 rounded-2xl border border-neutral-300 bg-white"
|
||||
/>
|
||||
{term && (
|
||||
<button type="button" aria-label="검색어 지우기" onClick={() => setTerm("")}>×</button>
|
||||
)}
|
||||
<button type="submit">검색</button>
|
||||
{/* 접근성용 제출 버튼 (시각적으로는 숨김) */}
|
||||
<button type="submit" aria-label="검색" className="sr-only" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user