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
58 changes: 58 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ src/
2. **CSS Modules**: 모든 컴포넌트는 `.module.scss` 사용
3. **타입 안정성**: Props와 상태에 대한 엄격한 타입 정의
4. **재사용성**: 공용 컴포넌트는 `_components/`에 위치
5. **내부 링크는 Link 컴포넌트 사용**: `<a>` 태그 대신 `next/link`의 `Link` 컴포넌트 사용
- 클라이언트 사이드 네비게이션으로 페이지 전환 성능 향상
- prefetch 기능으로 링크 호버 시 미리 로딩
- 외부 링크만 `<a target="_blank" rel="noopener noreferrer">` 사용

### TypeScript 타입 컨벤션
1. **타입 정의 위치**: 모든 공용 타입은 `src/types/` 디렉토리에 정의
Expand Down Expand Up @@ -201,6 +205,60 @@ const [postsResult, categoriesResult] = await Promise.all([
- 독립적인 데이터 소스는 반드시 병렬로 fetch
- 순차 실행이 필요한 경우에만 개별 await 사용

## TOC (Table of Contents) 아키텍처

### 헤딩 추출 및 타입 정의
- **위치**: `src/utils/extractHeadings.ts`
- **지원 레벨**: H2~H5 (정규식: `/^(#{2,5})\s+(.*)$/gm`)
- **데이터 구조**: `HeadingType` (text, level, id)

```typescript
// src/types/headingType.ts
export interface HeadingType {
text: string; // 헤딩 텍스트
level: number; // 2, 3, 4, 5
id: string; // 스크롤 타겟 ID (제목 텍스트 사용)
}
```

### 통합 Heading 컴포넌트
- **위치**: `src/app/post/[id]/Markdown/_components/Heading.tsx`
- **동적 태그 패턴**: `const Tag = \`h${level}\` as const`
- **기능**: ID 속성 자동 생성, 호버 시 링크 복사 버튼

### TOC 하이라이팅 (Intersection Observer)
```typescript
// src/app/_components/HeadingIndexNav/useTOCHighlight.ts
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{
rootMargin: "-86px 0px -80% 0px", // 고정 헤더(86px) 오프셋
threshold: 0,
}
);
```

### 스크롤 오프셋 처리
- **CSS 방식**: `scroll-margin-top: $HEADER_HEIGHT` (SCSS 변수, Heading 앵커에 적용)
- **JS 방식**: `scrollTo(top: elementPosition + scrollY - 86)`
- **Observer 방식**: `rootMargin: "-86px 0px -80% 0px"`으로 뷰포트 경계 조정

### 컴포넌트 구조
```
src/app/_components/HeadingIndexNav/
├── index.tsx # 메인 TOC 컴포넌트 (Server)
├── index.module.scss # 컨테이너 스타일
├── TOCList.tsx # 리스트 렌더링 (Client)
├── TOCList.module.scss # 리스트 스타일
└── useTOCHighlight.ts # Intersection Observer 훅
```

## 성능 모니터링

### Core Web Vitals 최적화
Expand Down
69 changes: 69 additions & 0 deletions src/app/_components/HeadingIndexNav/TOCList.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
@use "src/styles/libs" as *;

.tocContainer {
display: flex;
flex-direction: column;
}

.tocList {
display: flex;
flex-direction: column;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
}

.tocItem {
font-size: 14px;
line-height: 1.4;
transition: all 0.2s ease;

a {
display: block;
color: $BLUISH-GRAY-500;
text-decoration: none;
padding: 4px 0;
padding-left: 12px;
border-left: 2px solid transparent;
transition: all 0.2s ease;

&:hover {
color: $BLUISH-GRAY-800;
}
}

&.active {
a {
color: $BLUE-600;
border-left-color: $BLUE-600;
font-weight: 500;
}
}
}

.level2 {
a {
padding-left: 12px;
}
}

.level3 {
a {
padding-left: 24px;
}
}

.level4 {
a {
padding-left: 36px;
font-size: 13px;
}
}

.level5 {
a {
padding-left: 48px;
font-size: 13px;
}
}
35 changes: 35 additions & 0 deletions src/app/_components/HeadingIndexNav/TOCList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"use client";
import React from "react";
import { HeadingType } from "@/types";
import { useTOCHighlight } from "./useTOCHighlight";
import styles from "./TOCList.module.scss";
import Link from "next/link";

interface TOCListProps {
headings: HeadingType[];
}

export const TOCList = ({ headings }: TOCListProps) => {
const activeId = useTOCHighlight(headings);

if (headings.length === 0) return null;

return (
<div className={styles.tocContainer}>
<ul className={styles.tocList}>
{headings.map(({ id, level, text }) => (
<li
key={id}
className={`${styles.tocItem} ${styles[`level${level}`]} ${
activeId === id ? styles.active : ""
}`}
>
<Link href={`#${id}`} scroll>
{text}
</Link>
</li>
))}
</ul>
</div>
);
};
18 changes: 3 additions & 15 deletions src/app/_components/HeadingIndexNav/index.module.scss
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
@use "src/styles/libs" as *;

.heading_index_nav {
.headingIndexNav {
display: none;

@include desktop_screen {
position: sticky;
top: 100px;
left: 10px;
display: flex;
flex-direction: column;
gap: 10px;
display: block;
width: 300px;
height: fit-content;
margin-inline: 30px;
.heading_index {
& a {
color: $BLUISH-GRAY-500;

&:hover {
color: $BLUISH-GRAY-800;
text-decoration: none;
}
}
}
}
}
13 changes: 6 additions & 7 deletions src/app/_components/HeadingIndexNav/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { extractHeadings } from "@/utils/extractHeadings";
import React from "react";
import styles from "./index.module.scss";
import { TOCList } from "./TOCList";

interface HeadingIndexProps {
markdown: string;
}

export const HeadingIndexNav = ({ markdown }: HeadingIndexProps) => {
const headingIndexes = extractHeadings(markdown);
const headings = extractHeadings(markdown);

return (
<nav className={styles.heading_index_nav}>
{headingIndexes.map((heading, i) => (
<li className={styles.heading_index} key={i}>
<a href={`#${heading}`}>{`${heading}`}</a>
</li>
))}
<nav className={styles.headingIndexNav}>
<TOCList headings={headings} />
</nav>
);
};
37 changes: 37 additions & 0 deletions src/app/_components/HeadingIndexNav/useTOCHighlight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";
import { useState, useEffect } from "react";
import { HeadingType } from "@/types";

export const useTOCHighlight = (headings: HeadingType[]) => {
const [activeId, setActiveId] = useState<string | null>(null);

useEffect(() => {
const headingElements = headings
.map((heading) => document.getElementById(heading.id))
.filter((el): el is HTMLElement => el !== null);

if (headingElements.length === 0) return;

const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{
rootMargin: "-86px 0px -80% 0px",
threshold: 0,
}
);

headingElements.forEach((el) => observer.observe(el));

return () => {
headingElements.forEach((el) => observer.unobserve(el));
};
}, [headings]);

return activeId;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,39 @@
position: relative;
width: fit-content;
cursor: pointer;
.link_copy_btn {

.linkCopyBtn {
position: absolute;
right: -35px;
bottom: 5px;
padding: 0 10px;
margin: 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;

@include desktop_screen {
right: -50px;
bottom: 10px;
}
&_wrapper {

&Wrapper {
width: 20px;
height: 20px;

@include desktop_screen {
width: 30px;
height: 30px;
}
}
}

&:hover .linkCopyBtn {
opacity: 1;
visibility: visible;
}

.anchor {
&:target::before {
display: block;
height: 86px; /* 헤더의 높이와 동일 */
margin-top: -86px; /* 음수 마진으로 헤더 높이만큼 오프셋 조정 */
visibility: hidden;
content: "";
}
scroll-margin-top: $HEADER_HEIGHT;
}
}
55 changes: 55 additions & 0 deletions src/app/post/[id]/Markdown/_components/Heading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";
import React, { PropsWithChildren } from "react";
import styles from "./Heading.module.scss";
import Image from "next/image";
import LinkCopySvg from "@/assets/link_copy.svg";
import { usePathname } from "next/navigation";
import { useToast } from "junyeol-components";

interface HeadingProps extends PropsWithChildren {
level: 2 | 3 | 4 | 5;
}

export default function Heading({ children, level }: HeadingProps) {
const pathname = usePathname();
const toast = useToast();

const Tag = `h${level}` as const;

return (
<div className={styles.heading}>
<button
className={styles.linkCopyBtn}
onClick={() => {
navigator.clipboard
.writeText(window.location.host + pathname + "#" + children)
.then(() => {
toast({
children: "링크가 복사되었습니다.",
type: "success",
});
})
.catch(() => {
toast({
children: (
<>
링크 복사에 실패했습니다.
<br />
홈페이지 하단의 연락처로 제보주세요
</>
),
type: "fail",
});
});
}}
>
<div className={styles.linkCopyBtnWrapper}>
<Image src={LinkCopySvg} width={30} height={30} alt="링크복사svg" />
</div>
</button>
<Tag className={styles.anchor} id={children as string}>
{children}
</Tag>
</div>
);
}
Loading