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 (