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
4 changes: 3 additions & 1 deletion src/app/(site)/posts/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { GiscusComments } from "@/components/giscus-comments"
import { MainBreadcrumbs } from "@/components/layout"
import { MarkdownContent } from "@/components/markdown-content"
import { PostMeta } from "@/components/post-meta"
import { PostShare } from "@/components/post-share"
import { PostTags } from "@/components/post-tags"
import { Lead, PageTitle, typography } from "@/components/typography"
import { getPublicIntegrations } from "@/config/integrations"
import { siteConfig } from "@/config/site"
import { absoluteUrl, siteConfig } from "@/config/site"
import { shouldRenderTableOfContents } from "@/features/post-toc/toc-policy"
import { type RenderedMarkdown, renderMarkdown } from "@/lib/markdown"
import { findPostBySlug, getAdjacentPosts, getPostSlugs } from "@/lib/posts"
Expand Down Expand Up @@ -75,6 +76,7 @@ export default async function PostPage({ params }: PostPageProps) {
<PostDetailLayout
footer={
<>
<PostShare title={post.title} url={absoluteUrl(`/posts/${post.slug}/`)} />
<PostNavigation nextPost={nextPost} previousPost={previousPost} />
<GiscusComments config={integrations.giscus} />
</>
Expand Down
33 changes: 33 additions & 0 deletions src/components/icons.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
ChevronDownIcon,
ChevronRightIcon,
ChevronUpIcon,
FileTextIcon,
FolderIcon,
HomeIcon,
LinkIcon,
ListIcon,
MailIcon,
MonitorIcon,
Expand All @@ -15,6 +17,8 @@ import {
} from "lucide-react"
import type { SVGProps } from "react"

// lucide-react 는 브랜드 로고를 담지 않는다(라이선스 정책). 공유 버튼에 쓰는
// X·Facebook·Telegram 은 각 서비스가 공개한 로고 마크를 직접 그려 넣는다.
function GithubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24" {...props}>
Expand All @@ -23,9 +27,35 @@ function GithubIcon(props: SVGProps<SVGSVGElement>) {
)
}

function XIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24" {...props}>
<path d="M18.9 1.5h3.68l-8.04 9.19L24 22.5h-7.41l-5.8-7.58-6.64 7.58H.46l8.6-9.83L0 1.5h7.59l5.24 6.93ZM17.6 20.3h2.04L6.49 3.6H4.3Z" />
</svg>
)
}

function FacebookIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24" {...props}>
<path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.09 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.89v2.25h3.32l-.53 3.49h-2.79V24C19.61 23.09 24 18.1 24 12.07Z" />
</svg>
)
}

function TelegramIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24" {...props}>
<path d="M12 0C5.37 0 0 5.37 0 12s5.37 12 12 12 12-5.37 12-12S18.63 0 12 0Zm5.57 8.16-1.86 8.79c-.14.63-.51.78-1.03.49l-2.85-2.1-1.37 1.32c-.15.15-.28.28-.57.28l.2-2.9 5.29-4.78c.23-.2-.05-.32-.36-.11l-6.54 4.12-2.82-.88c-.61-.19-.62-.61.13-.91l11.02-4.25c.51-.18.96.12.76.93Z" />
</svg>
)
}

export const Icons = {
chevronDown: ChevronDownIcon,
chevronRight: ChevronRightIcon,
chevronUp: ChevronUpIcon,
link: LinkIcon,
list: ListIcon,
navigation: {
home: HomeIcon,
Expand All @@ -38,6 +68,9 @@ export const Icons = {
github: GithubIcon,
mail: MailIcon,
rss: RssIcon,
x: XIcon,
facebook: FacebookIcon,
telegram: TelegramIcon,
},
theme: {
system: MonitorIcon,
Expand Down
6 changes: 6 additions & 0 deletions src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ReactNode } from "react"
import { BackToTopButton } from "./back-to-top-button"
import { ProfileSidebar } from "./profile-sidebar"
import { ReadingProgressBar } from "./reading-progress-bar"
import { SiteFooter } from "./site-footer"

type AppShellProps = Readonly<{
children: ReactNode
Expand All @@ -8,6 +11,7 @@ type AppShellProps = Readonly<{
export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-[100dvh] bg-background px-3 py-3 text-foreground sm:px-4 sm:py-4 lg:px-5 lg:py-5">
<ReadingProgressBar />
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-4 xl:flex-row xl:items-start xl:gap-6">
<div className="xl:sticky xl:top-5 xl:w-[240px] xl:shrink-0 xl:self-start">
<ProfileSidebar />
Expand All @@ -16,6 +20,8 @@ export function AppShell({ children }: AppShellProps) {
<div className="w-full">{children}</div>
</main>
</div>
<SiteFooter />
<BackToTopButton />
</div>
)
}
38 changes: 38 additions & 0 deletions src/components/layout/back-to-top-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client"

import { useEffect, useState } from "react"
import { Icons } from "@/components/icons"
import { cn } from "@/lib/utils"

const VISIBILITY_THRESHOLD_PX = 480

export function BackToTopButton() {
const [isVisible, setIsVisible] = useState(false)

useEffect(() => {
const handleScroll = () => {
setIsVisible(window.scrollY > VISIBILITY_THRESHOLD_PX)
}

handleScroll()
window.addEventListener("scroll", handleScroll, { passive: true })

return () => window.removeEventListener("scroll", handleScroll)
}, [])

return (
<button
aria-hidden={!isVisible}
aria-label="맨 위로"
className={cn(
"fixed right-4 bottom-4 z-40 inline-flex size-10 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-md transition-[opacity,transform] duration-150 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring sm:right-6 sm:bottom-6",
isVisible ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-2 opacity-0",
)}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
tabIndex={isVisible ? 0 : -1}
type="button"
>
<Icons.chevronUp aria-hidden="true" className="size-5" />
</button>
)
}
1 change: 1 addition & 0 deletions src/components/layout/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { AppShell } from "./app-shell"
export { MainBreadcrumbs } from "./main-breadcrumbs"
export { MainFrame } from "./main-frame"
export { SiteFooter } from "./site-footer"
33 changes: 33 additions & 0 deletions src/components/layout/reading-progress-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client"

import { useEffect, useState } from "react"

export function ReadingProgressBar() {
const [progress, setProgress] = useState(0)

useEffect(() => {
const handleScroll = () => {
const scrollable = document.documentElement.scrollHeight - window.innerHeight

setProgress(scrollable > 0 ? Math.min(100, (window.scrollY / scrollable) * 100) : 0)
}

handleScroll()
window.addEventListener("scroll", handleScroll, { passive: true })
window.addEventListener("resize", handleScroll)

return () => {
window.removeEventListener("scroll", handleScroll)
window.removeEventListener("resize", handleScroll)
}
}, [])

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}%` }}
/>
</div>
)
}
22 changes: 22 additions & 0 deletions src/components/layout/site-footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { siteConfig } from "@/config/site"

export function SiteFooter() {
const year = new Date().getFullYear()

return (
<footer className="mx-auto mt-4 w-full max-w-[1440px] px-2 pb-2 text-center">
<p className="text-sm leading-[1.55] text-muted-foreground">
&copy; {year} {siteConfig.author.name}. 별도 표기가 없는 한 글은{" "}
<a
className="underline-offset-4 hover:text-foreground hover:underline focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
href="https://creativecommons.org/licenses/by/4.0/"
rel="license noopener noreferrer"
target="_blank"
>
CC BY 4.0
</a>
를 따릅니다.
</p>
</footer>
)
}
77 changes: 77 additions & 0 deletions src/components/post-share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client"

import { type ReactNode, useState } from "react"
import { Icons } from "@/components/icons"

type PostShareProps = Readonly<{
title: string
url: string
}>

export function PostShare({ title, url }: PostShareProps) {
const [copied, setCopied] = useState(false)
const encodedUrl = encodeURIComponent(url)
const encodedTitle = encodeURIComponent(title)

async function handleCopy() {
await navigator.clipboard.writeText(url)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}

return (
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-8">
<span className="mr-1 text-xs font-semibold leading-[1.4] text-muted-foreground">공유</span>
<ShareLink
href={`https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`}
label="X(트위터)로 공유"
>
<Icons.social.x className="size-4" />
</ShareLink>
<ShareLink
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
label="Facebook으로 공유"
>
<Icons.social.facebook className="size-4" />
</ShareLink>
<ShareLink
href={`https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`}
label="Telegram으로 공유"
>
<Icons.social.telegram className="size-4" />
</ShareLink>
<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"
onClick={handleCopy}
type="button"
>
<Icons.link aria-hidden="true" className="size-4" />
</button>
<span
aria-live="polite"
className="text-xs font-semibold leading-[1.4] text-muted-foreground"
>
{copied ? "링크가 복사됐습니다" : null}
</span>
</div>
)
}

function ShareLink({
href,
label,
children,
}: Readonly<{ href: string; label: string; children: ReactNode }>) {
return (
<a
aria-label={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"
href={href}
rel="noopener noreferrer"
target="_blank"
>
{children}
</a>
)
}