From c60f8aee3e9428e7680ba2d7ba40ae9b095d1af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=B0=EC=83=81=EB=B9=88?= Date: Sun, 16 Aug 2026 19:18:36 +0900 Subject: [PATCH] =?UTF-8?q?refactor(architecture):=20RSC=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EC=B6=95=EC=86=8C,=20=ED=85=8C=EB=A7=88=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=86=B5=ED=95=A9,=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=C2=B7=EA=B2=80=EC=83=89=C2=B7=EC=A7=84=ED=96=89=EB=B0=94=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileSidebar를 서버 컴포넌트로 전환하고 모바일 토글·검색 폼만 클라이언트 섬으로 분리 - 테마 판별/적용 4중 구현을 features/theme/theme-controller로 통합 - PostShare 서버화, 클립보드 실패 처리 추가, Kakao SDK에 SRI 적용, 아이콘 버튼 cva 통합 - ReadingProgressBar를 rAF 스로틀 + scaleX 트랜스폼으로 전환 - 검색 발췌 numeric entity 디코딩 보강 - RSS pubDate를 KST 기준으로 정합, 미사용 export 정리 Co-Authored-By: Claude Fable 5 --- src/components/copy-link-button.tsx | 37 +++++++ src/components/giscus-comments.tsx | 22 ++--- src/components/icon-action.ts | 20 ++++ src/components/kakao-share-button.tsx | 9 +- src/components/layout/profile-sidebar.tsx | 99 +++---------------- .../layout/reading-progress-bar.tsx | 37 +++++-- .../layout/sidebar-mobile-disclosure.tsx | 56 +++++++++++ src/components/layout/sidebar-search-form.tsx | 42 ++++++++ src/components/layout/theme-mode-dropdown.tsx | 23 +---- src/components/post-share.tsx | 23 ++--- .../post-diagram/post-diagram-renderer.tsx | 21 ++-- src/features/search/search-results.tsx | 30 +++++- src/features/theme/theme-controller.ts | 45 +++++++++ src/features/theme/theme-script.tsx | 6 +- src/features/theme/theme.ts | 4 - src/lib/posts.ts | 11 --- src/lib/rss.ts | 7 +- tests/posts-publication.test.ts | 46 +-------- tests/search.test.tsx | 21 ++++ tests/theme-controller.test.ts | 80 +++++++++++++++ tests/theme-foundation.test.ts | 5 +- 21 files changed, 413 insertions(+), 231 deletions(-) create mode 100644 src/components/copy-link-button.tsx create mode 100644 src/components/icon-action.ts create mode 100644 src/components/layout/sidebar-mobile-disclosure.tsx create mode 100644 src/components/layout/sidebar-search-form.tsx create mode 100644 src/features/theme/theme-controller.ts create mode 100644 tests/theme-controller.test.ts diff --git a/src/components/copy-link-button.tsx b/src/components/copy-link-button.tsx new file mode 100644 index 0000000..d3034df --- /dev/null +++ b/src/components/copy-link-button.tsx @@ -0,0 +1,37 @@ +"use client" + +import { toast } from "sonner" +import { iconActionVariants } from "@/components/icon-action" +import { Icons } from "@/components/icons" + +type CopyLinkButtonProps = Readonly<{ + url: string +}> + +export function CopyLinkButton({ url }: CopyLinkButtonProps) { + async function handleCopy() { + try { + // http 등 non-secure context 에서는 clipboard 가 아예 없다. 그대로 호출하면 + // 동기 TypeError, 권한 거부면 unhandled rejection 이라 둘 다 여기서 잡는다. + if (navigator.clipboard === undefined) { + throw new Error("Clipboard API unavailable") + } + + await navigator.clipboard.writeText(url) + toast.success("링크가 복사됐습니다") + } catch { + toast.error("링크를 복사하지 못했습니다. 주소창에서 직접 복사해 주세요.") + } + } + + return ( + + ) +} diff --git a/src/components/giscus-comments.tsx b/src/components/giscus-comments.tsx index a2b2d31..a68b25f 100644 --- a/src/components/giscus-comments.tsx +++ b/src/components/giscus-comments.tsx @@ -2,6 +2,9 @@ import { useEffect, useRef } from "react" import type { GiscusConfig } from "@/config/integrations" +// 사이트 테마는 next-themes가 아니라 토글로 직접 관리된다(features/theme). +// giscus의 data-theme="preferred_color_scheme"는 OS 설정만 보고, 이 클래스를 모른다. +import { getResolvedTheme, observeResolvedTheme } from "@/features/theme/theme-controller" type GiscusCommentsProps = Readonly<{ config: GiscusConfig @@ -9,12 +12,6 @@ type GiscusCommentsProps = Readonly<{ const GISCUS_ORIGIN = "https://giscus.app" -// 사이트 테마는 next-themes가 아니라 토글로 직접 관리된다(features/theme). -// giscus의 data-theme="preferred_color_scheme"는 OS 설정만 보고, 이 클래스를 모른다. -function resolveGiscusTheme(): "light" | "dark" { - return document.documentElement.classList.contains("dark") ? "dark" : "light" -} - export function GiscusComments({ config }: GiscusCommentsProps) { const containerRef = useRef(null) @@ -39,25 +36,20 @@ export function GiscusComments({ config }: GiscusCommentsProps) { script.setAttribute("data-reactions-enabled", "1") script.setAttribute("data-emit-metadata", "0") script.setAttribute("data-input-position", "bottom") - script.setAttribute("data-theme", resolveGiscusTheme()) + script.setAttribute("data-theme", getResolvedTheme()) script.setAttribute("data-lang", "ko") container.appendChild(script) // 테마가 바뀌어도 iframe은 다시 만들지 않고, giscus 자체 프로토콜로 실시간 전환한다. // https://github.com/giscus/giscus/blob/main/ADVANCED-USAGE.md#isetconfigmessage - const observer = new MutationObserver(() => { + const stopObservingTheme = observeResolvedTheme((theme) => { const iframe = container.querySelector("iframe.giscus-frame") - iframe?.contentWindow?.postMessage( - { giscus: { setConfig: { theme: resolveGiscusTheme() } } }, - GISCUS_ORIGIN, - ) + iframe?.contentWindow?.postMessage({ giscus: { setConfig: { theme } } }, GISCUS_ORIGIN) }) - observer.observe(document.documentElement, { attributeFilter: ["class"], attributes: true }) - return () => { - observer.disconnect() + stopObservingTheme() container.replaceChildren() } }, [config]) diff --git a/src/components/icon-action.ts b/src/components/icon-action.ts new file mode 100644 index 0000000..2bf827c --- /dev/null +++ b/src/components/icon-action.ts @@ -0,0 +1,20 @@ +import { cva, type VariantProps } from "class-variance-authority" + +// size-9 원형 아이콘 액션(공유·복사·사이드바 링크)의 공통 스타일. +// 문자열이 6곳에 복제되어 있던 것을 한 곳으로 모은다. 색·포커스 링만 tone 으로 갈린다. +export const iconActionVariants = cva( + "inline-flex size-9 items-center justify-center rounded-lg bg-transparent transition-colors duration-150 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2", + { + variants: { + tone: { + default: "text-muted-foreground focus-visible:outline-ring", + sidebar: "text-sidebar-foreground focus-visible:outline-sidebar-ring", + }, + }, + defaultVariants: { + tone: "default", + }, + }, +) + +export type IconActionVariantProps = VariantProps diff --git a/src/components/kakao-share-button.tsx b/src/components/kakao-share-button.tsx index a9028f4..ac620ff 100644 --- a/src/components/kakao-share-button.tsx +++ b/src/components/kakao-share-button.tsx @@ -1,6 +1,7 @@ "use client" import { toast } from "sonner" +import { iconActionVariants } from "@/components/icon-action" import { Icons } from "@/components/icons" type KakaoShareButtonProps = Readonly<{ @@ -28,6 +29,10 @@ declare global { } const KAKAO_SDK_SRC = "https://t1.kakaocdn.net/kakao_js_sdk/2.7.4/kakao.min.js" +// 카카오 devtalk 공지의 2.7.4 무결성 해시. CDN 이 변조돼도 브라우저가 실행을 거부한다. +// SDK 버전을 올리면 이 값도 반드시 함께 갱신해야 한다(불일치 시 로드 실패). +const KAKAO_SDK_INTEGRITY = + "sha384-DKYJZ8NLiK8MN4/C5P2dtSmLQ4KwPaoqAfyA/DfmEc1VDxu4yyC7wy6K1Hs90nka" let kakaoSdkPromise: Promise | undefined @@ -36,6 +41,8 @@ function loadKakaoSdk(): Promise { const script = document.createElement("script") script.src = KAKAO_SDK_SRC + script.integrity = KAKAO_SDK_INTEGRITY + script.crossOrigin = "anonymous" script.async = true script.onload = () => { if (window.Kakao === undefined) { @@ -96,7 +103,7 @@ export function KakaoShareButton({ return ( -
-
- -
-
+ + + +
@@ -101,43 +66,6 @@ function getAvatarFallback(name: string): string { return name.trim().charAt(0).toLocaleUpperCase("ko-KR") || "?" } -function SearchForm() { - const [query, setQuery] = useState("") - const router = useRouter() - - function handleSubmit(event: FormEvent) { - event.preventDefault() - const trimmed = query.trim() - - if (trimmed.length > 0) { - router.push(`/search/?q=${encodeURIComponent(trimmed)}`) - } - } - - return ( - -
- -
-
-
-
- ) -} - function SidebarContent() { return ( <> @@ -168,10 +96,7 @@ type SidebarActionLinkProps = Readonly<{ function SidebarActionLink({ href, label, children }: SidebarActionLinkProps) { return ( - + {label} {children} diff --git a/src/components/layout/reading-progress-bar.tsx b/src/components/layout/reading-progress-bar.tsx index 985e78c..425da7a 100644 --- a/src/components/layout/reading-progress-bar.tsx +++ b/src/components/layout/reading-progress-bar.tsx @@ -3,30 +3,49 @@ import { useEffect, useState } from "react" export function ReadingProgressBar() { + // 0~1 비율. width 대신 scaleX 로 그려 매 프레임 layout/paint 없이 컴포지터만 태운다. const [progress, setProgress] = useState(0) useEffect(() => { - const handleScroll = () => { + let animationFrameId = 0 + + const updateProgress = () => { const scrollable = document.documentElement.scrollHeight - window.innerHeight - setProgress(scrollable > 0 ? Math.min(100, (window.scrollY / scrollable) * 100) : 0) + setProgress(scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0) } - handleScroll() - window.addEventListener("scroll", handleScroll, { passive: true }) - window.addEventListener("resize", handleScroll) + // 스크롤 이벤트는 프레임당 여러 번 올 수 있어 rAF 로 한 번으로 접는다. + const scheduleUpdate = () => { + if (animationFrameId !== 0) { + return + } + + animationFrameId = window.requestAnimationFrame(() => { + animationFrameId = 0 + updateProgress() + }) + } + + updateProgress() + window.addEventListener("scroll", scheduleUpdate, { passive: true }) + window.addEventListener("resize", scheduleUpdate) return () => { - window.removeEventListener("scroll", handleScroll) - window.removeEventListener("resize", handleScroll) + window.removeEventListener("scroll", scheduleUpdate) + window.removeEventListener("resize", scheduleUpdate) + + if (animationFrameId !== 0) { + window.cancelAnimationFrame(animationFrameId) + } } }, []) return (