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
37 changes: 37 additions & 0 deletions src/components/copy-link-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
aria-label="글 링크 복사"
className={iconActionVariants()}
onClick={handleCopy}
type="button"
>
<Icons.link aria-hidden="true" className="size-4" />
</button>
)
}
22 changes: 7 additions & 15 deletions src/components/giscus-comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,16 @@

import { useEffect, useRef } from "react"
import type { GiscusConfig } from "@/config/integrations"
// 사이트 테마는 next-themes가 아니라 <html class="dark"> 토글로 직접 관리된다(features/theme).
// giscus의 data-theme="preferred_color_scheme"는 OS 설정만 보고, 이 클래스를 모른다.
import { getResolvedTheme, observeResolvedTheme } from "@/features/theme/theme-controller"

type GiscusCommentsProps = Readonly<{
config: GiscusConfig
}>

const GISCUS_ORIGIN = "https://giscus.app"

// 사이트 테마는 next-themes가 아니라 <html class="dark"> 토글로 직접 관리된다(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<HTMLDivElement>(null)

Expand All @@ -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<HTMLIFrameElement>("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])
Expand Down
20 changes: 20 additions & 0 deletions src/components/icon-action.ts
Original file line number Diff line number Diff line change
@@ -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<typeof iconActionVariants>
9 changes: 8 additions & 1 deletion src/components/kakao-share-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

import { toast } from "sonner"
import { iconActionVariants } from "@/components/icon-action"
import { Icons } from "@/components/icons"

type KakaoShareButtonProps = Readonly<{
Expand Down Expand Up @@ -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<KakaoSdk> | undefined

Expand All @@ -36,6 +41,8 @@ function loadKakaoSdk(): Promise<KakaoSdk> {
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) {
Expand Down Expand Up @@ -96,7 +103,7 @@ export function KakaoShareButton({
return (
<button
aria-label="카카오톡으로 공유"
className="inline-flex size-9 items-center justify-center rounded-lg bg-transparent text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
className={iconActionVariants()}
onClick={handleClick}
type="button"
>
Expand Down
99 changes: 12 additions & 87 deletions src/components/layout/profile-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<aside
aria-label="사이트 프로필"
className="rounded-lg border border-sidebar-border bg-sidebar p-5 text-sidebar-foreground shadow-sm"
>
<ProfileSummary />
<SearchForm />
<Button
aria-controls={contentId}
aria-expanded={isOpen}
className="group mt-5 w-full cursor-pointer justify-between rounded-md border-transparent bg-transparent text-sidebar-foreground hover:bg-transparent hover:text-sidebar-foreground aria-expanded:bg-transparent aria-expanded:text-sidebar-foreground xl:hidden"
onClick={() => setIsOpen((currentOpen) => !currentOpen)}
size="lg"
type="button"
variant="ghost"
>
탐색
<Icons.chevronDown
aria-hidden="true"
className="size-4 transition-transform duration-150 group-aria-expanded:rotate-180"
/>
</Button>
<div
aria-hidden={!isOpen}
className={cn(
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out xl:hidden",
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
id={contentId}
inert={!isOpen}
>
<div className="min-h-0 overflow-hidden">
<SidebarContent />
</div>
</div>
<SidebarSearchForm />
<SidebarMobileDisclosure>
<SidebarContent />
</SidebarMobileDisclosure>
<div className="hidden xl:block">
<SidebarContent />
</div>
Expand Down Expand Up @@ -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<HTMLFormElement>) {
event.preventDefault()
const trimmed = query.trim()

if (trimmed.length > 0) {
router.push(`/search/?q=${encodeURIComponent(trimmed)}`)
}
}

return (
<search className="mt-5">
<form action="/search/" onSubmit={handleSubmit}>
<label className="sr-only" htmlFor="sidebar-search">
사이트 검색
</label>
<div className="flex h-10 w-full items-center gap-2.5 rounded-md border border-input bg-background px-3 text-sm font-semibold leading-[1.55] text-muted-foreground shadow-xs transition-colors duration-150 focus-within:border-ring focus-within:text-foreground focus-within:ring-2 focus-within:ring-ring/20">
<SearchIcon aria-hidden="true" className="size-4 shrink-0" />
<input
aria-label="사이트 검색"
className="min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground"
id="sidebar-search"
name="q"
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="Search"
type="search"
value={query}
/>
</div>
</form>
</search>
)
}

function SidebarContent() {
return (
<>
Expand Down Expand Up @@ -168,10 +96,7 @@ type SidebarActionLinkProps = Readonly<{

function SidebarActionLink({ href, label, children }: SidebarActionLinkProps) {
return (
<a
className="inline-flex size-9 items-center justify-center rounded-lg bg-transparent text-sidebar-foreground transition-colors duration-150 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sidebar-ring"
href={href}
>
<a className={iconActionVariants({ tone: "sidebar" })} href={href}>
<span className="sr-only">{label}</span>
{children}
</a>
Expand Down
37 changes: 28 additions & 9 deletions src/components/layout/reading-progress-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div aria-hidden="true" className="fixed inset-x-0 top-0 z-50 h-0.5 bg-transparent">
<div
className="h-full bg-primary transition-[width] duration-150 ease-out"
style={{ width: `${progress}%` }}
className="h-full origin-left bg-primary transition-transform duration-150 ease-out"
style={{ transform: `scaleX(${progress})` }}
/>
</div>
)
Expand Down
56 changes: 56 additions & 0 deletions src/components/layout/sidebar-mobile-disclosure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client"

import { usePathname } from "next/navigation"
import { type ReactNode, useEffect, useId, useState } from "react"
import { Icons } from "@/components/icons"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

type SidebarMobileDisclosureProps = Readonly<{
children: ReactNode
}>

// 사이드바에서 상태가 필요한 건 이 모바일 접기 토글뿐이라, 서버에서 그린 콘텐츠를
// children 으로 받아 토글 껍데기만 클라이언트로 남긴다.
export function SidebarMobileDisclosure({ children }: SidebarMobileDisclosureProps) {
const [isOpen, setIsOpen] = useState(false)
const contentId = useId()
const pathname = usePathname()

useEffect(() => {
if (pathname !== null) {
setIsOpen(false)
}
}, [pathname])

return (
<>
<Button
aria-controls={contentId}
aria-expanded={isOpen}
className="group mt-5 w-full cursor-pointer justify-between rounded-md border-transparent bg-transparent text-sidebar-foreground hover:bg-transparent hover:text-sidebar-foreground aria-expanded:bg-transparent aria-expanded:text-sidebar-foreground xl:hidden"
onClick={() => setIsOpen((currentOpen) => !currentOpen)}
size="lg"
type="button"
variant="ghost"
>
탐색
<Icons.chevronDown
aria-hidden="true"
className="size-4 transition-transform duration-150 group-aria-expanded:rotate-180"
/>
</Button>
<div
aria-hidden={!isOpen}
className={cn(
"grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out xl:hidden",
isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
id={contentId}
inert={!isOpen}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
</>
)
}
Loading