커맨드 팔레트 (Cmd+K)
글이 쌓이면서 상단 내비게이션만으로는 답답해져서, VSCode·Linear처럼 ⌘K로 어디든 가는 팔레트를 붙였다.
CommandPalette.tsx는 전역 keydown 리스너로 열린다. 데이터(글 목록, 카테고리)는 마운트 시 미리 받지 않고, 팔레트를 처음 열 때 한 번만 fetch해서 캐시한다.
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen( !v);
}
}
.(, handleKeyDown);
...
}, []);
검색은 라벨/서브라벨 부분 일치, 항목은 "이동 / 관리 / 카테고리 / 글" 그룹으로 나눴다. 관리 메뉴는 isAdmin()일 때만 노출하고, 화살표+Enter로 이동한다.
TopNav 트리거는 팔레트를 직접 참조하지 않고 open-command-palette 커스텀 이벤트만 던진다. 카테고리 변경 알림에 쓰던 패턴을 그대로 재사용했다.
읽기 진행 표시
목차, 읽기 진행률 바, 예상 읽기 시간을 붙였다.
목차는 ##/###만 추출하고, 코드 블록 안 #은 여닫는 ``` 개수를 세서 건너뛴다. 슬러그는 본문 렌더링에 쓰는 rehype-slug와 규칙을 맞추려고 목차 쪽도 github-slugger를 직접 썼다 — 안 그러면 링크가 미묘하게 어긋난다.
읽는 위치는 IntersectionObserver로 잡는다. rootMargin: "-96px 0px -70% 0px"로 위는 헤더 높이만큼, 아래는 화면 70%를 잘라내서 헤딩이 화면 위쪽에 왔을 때만 활성화한다.
읽기 시간은 띄어쓰기 기준 단어 수 대신, 마크다운 기호와 공백을 뺀 순수 글자 수를 분당 500자로 나눠서 계산했다. 한국어는 조사 때문에 단어 수 기준이 잘 안 맞는다.
const CHARS_PER_MINUTE = 500;
export function estimateReadingMinutes(content: string): number {
const charCount = (content).(, ).;
.(, .(charCount / ));
}
좋아요
좋아요 여부는 로그인과 무관하게 localStorage에만 기록한다(방문자 식별자 비저장 정책과 동일). 클릭하면 응답을 기다리지 않고 먼저 카운트·하트를 바꾸고, 실패하면 되돌리는 낙관적 업데이트를 붙였다.
배운 점
전부 "지금 어디에 있고 뭘 할 수 있는지"를 알려주는 방향이었다. 각 컴포넌트를 독립적으로 짜둬서(팔레트는 이벤트, 나머지는 content만 받음) 다른 페이지에도 재사용하기 쉬울 것 같다.