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 ( diff --git a/src/components/layout/profile-sidebar.tsx b/src/components/layout/profile-sidebar.tsx index 5b30e42..e3c0575 100644 --- a/src/components/layout/profile-sidebar.tsx +++ b/src/components/layout/profile-sidebar.tsx @@ -1,65 +1,30 @@ -"use client" - -import { SearchIcon } from "lucide-react" import Link from "next/link" -import { usePathname, useRouter } from "next/navigation" -import { type FormEvent, type ReactNode, useEffect, useId, useState } from "react" +import type { ReactNode } from "react" +import { iconActionVariants } from "@/components/icon-action" import { Icons } from "@/components/icons" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" -import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { siteNavigationItems } from "@/config/navigation" import { siteConfig } from "@/config/site" -import { cn } from "@/lib/utils" +import { SidebarMobileDisclosure } from "./sidebar-mobile-disclosure" import { SidebarNavigation } from "./sidebar-navigation" +import { SidebarSearchForm } from "./sidebar-search-form" import { ThemeModeDropdown } from "./theme-mode-dropdown" +// 이 컴포넌트는 모든 페이지의 루트 셸에서 렌더된다. 프로필·링크처럼 정적인 부분까지 +// 클라이언트 번들로 보내지 않도록 서버 컴포넌트로 두고, 상태가 필요한 조각 +// (모바일 접기, 검색 폼, 테마 드롭다운, 활성 내비게이션)만 클라이언트 섬으로 남긴다. export function ProfileSidebar() { - const [isOpen, setIsOpen] = useState(false) - const contentId = useId() - const pathname = usePathname() - - useEffect(() => { - if (pathname !== null) { - setIsOpen(false) - } - }, [pathname]) - return (