diff --git a/apps/website/next.config.mjs b/apps/website/next.config.mjs index 424144cc6..aa50ea0b0 100644 --- a/apps/website/next.config.mjs +++ b/apps/website/next.config.mjs @@ -58,6 +58,9 @@ const config = { }); return config; }, + experimental: { + optimizePackageImports: ['@vapor-ui/icons', '@vapor-ui/core'], + }, }; export default withMDX(config); diff --git a/apps/website/src/app/blocks/[...slug]/page.tsx b/apps/website/src/app/blocks/[...slug]/page.tsx index 2d5c2fa84..e321fa2c4 100644 --- a/apps/website/src/app/blocks/[...slug]/page.tsx +++ b/apps/website/src/app/blocks/[...slug]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'; import { BlockPageBody } from '~/components/block-page-body'; import { BlockPageHeader } from '~/components/block-page-header'; +import { SITE_URL } from '~/constants/domain'; import { blockSource } from '~/lib/source'; import { getMDXComponents } from '~/mdx-components'; @@ -23,6 +24,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string title={title} description={description} previewImageUrl={previewImageUrl} + markdownUrl={`${SITE_URL}${page.url}.mdx`} /> diff --git a/apps/website/src/app/docs/[[...slug]]/page.tsx b/apps/website/src/app/docs/[[...slug]]/page.tsx index 956f26adb..baa62eac8 100644 --- a/apps/website/src/app/docs/[[...slug]]/page.tsx +++ b/apps/website/src/app/docs/[[...slug]]/page.tsx @@ -2,6 +2,7 @@ import { DocsBody, DocsPage } from 'fumadocs-ui/page'; import { notFound } from 'next/navigation'; import { DocsPageHeader } from '~/components/docs-page-header'; +import { SITE_URL } from '~/constants/domain'; import { source } from '~/lib/source'; import { getMDXComponents } from '~/mdx-components'; import { generatePageMetadata } from '~/utils/metadata'; @@ -35,7 +36,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string diff --git a/apps/website/src/app/docs/components/[...slug]/page.tsx b/apps/website/src/app/docs/components/[...slug]/page.tsx index 07e99b3a2..030507b74 100644 --- a/apps/website/src/app/docs/components/[...slug]/page.tsx +++ b/apps/website/src/app/docs/components/[...slug]/page.tsx @@ -34,7 +34,7 @@ const page = async ({ params }: { params: Promise<{ slug?: string[] }> }) => { diff --git a/apps/website/src/app/theme/[[...tool]]/_components/tool-detail-sheet-client/tool-detail-sheet-client.tsx b/apps/website/src/app/theme/[[...tool]]/_components/tool-detail-sheet-client/tool-detail-sheet-client.tsx index 865324d28..bd381719a 100644 --- a/apps/website/src/app/theme/[[...tool]]/_components/tool-detail-sheet-client/tool-detail-sheet-client.tsx +++ b/apps/website/src/app/theme/[[...tool]]/_components/tool-detail-sheet-client/tool-detail-sheet-client.tsx @@ -59,7 +59,7 @@ export const ToolDetailSheetClient = ({ {description} - + diff --git a/apps/website/src/app/theme/[[...tool]]/page.tsx b/apps/website/src/app/theme/[[...tool]]/page.tsx index eab3f28ab..5ab25b0bc 100644 --- a/apps/website/src/app/theme/[[...tool]]/page.tsx +++ b/apps/website/src/app/theme/[[...tool]]/page.tsx @@ -1,3 +1,4 @@ +import { SITE_URL } from '~/constants/domain'; import { themeSource } from '~/lib/source'; import { getMDXComponents } from '~/mdx-components'; @@ -38,7 +39,7 @@ export default async function ThemePage({ params }: PageProps) { // MDX frontmatter에서 직접 가져옴 (Single Source of Truth) title: page.data.title, description: page.data.description ?? '', - markdownUrl: `${page.url}.mdx`, + markdownUrl: `${SITE_URL}${page.url}.mdx`, children: (
diff --git a/apps/website/src/components/block-page-header/block-page-header.tsx b/apps/website/src/components/block-page-header/block-page-header.tsx index 6dc6e25e5..a0a1d7377 100644 --- a/apps/website/src/components/block-page-header/block-page-header.tsx +++ b/apps/website/src/components/block-page-header/block-page-header.tsx @@ -7,9 +7,15 @@ interface BlockPageHeaderProps { title: string; description?: string; previewImageUrl?: string; + markdownUrl?: string; } -export const BlockPageHeader = ({ title, description, previewImageUrl }: BlockPageHeaderProps) => { +export const BlockPageHeader = ({ + title, + description, + previewImageUrl, + markdownUrl, +}: BlockPageHeaderProps) => { return (
{/* Header Section */} @@ -41,7 +47,7 @@ export const BlockPageHeader = ({ title, description, previewImageUrl }: BlockPa /> )}
- + {markdownUrl && }
{/* Preview Section */} diff --git a/apps/website/src/components/copy-button/copy-button.icons.tsx b/apps/website/src/components/copy-button/copy-button.icons.tsx new file mode 100644 index 000000000..760ea7587 --- /dev/null +++ b/apps/website/src/components/copy-button/copy-button.icons.tsx @@ -0,0 +1,25 @@ +export const AnthropicIcon = () => ( + + + +); + +export const OpenAIIcon = () => ( + + + +); diff --git a/apps/website/src/components/copy-button/copy-button.tsx b/apps/website/src/components/copy-button/copy-button.tsx index 7806b9ac2..35e46b4de 100644 --- a/apps/website/src/components/copy-button/copy-button.tsx +++ b/apps/website/src/components/copy-button/copy-button.tsx @@ -1,38 +1,177 @@ 'use client'; -import { Button } from '@vapor-ui/core'; -import { CopyAsMarkdownOutlineIcon, CopyIcon } from '@vapor-ui/icons'; +import { useMemo, useState } from 'react'; + +import { Button, HStack, IconButton, Menu } from '@vapor-ui/core'; +import { + ChevronDownOutlineIcon, + ConfirmOutlineIcon, + CopyAsMarkdownOutlineIcon, + OpenInNewOutlineIcon, +} from '@vapor-ui/icons'; +import { track } from '@vercel/analytics'; import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'; +import { + COPY_BUTTON_ACTIONS, + type CopyButtonAction, + createCopyButtonEventName, +} from '~/constants/analytics'; + +import { AnthropicIcon, OpenAIIcon } from './copy-button.icons'; + +const markdownIcon = ; +const anthropicIcon = ; +const openAIIcon = ; + const cache = new Map(); -type CopyButtonProps = Omit & { +const TRUSTED_DOMAINS = ['vapor-ui.goorm.io', 'localhost'] as const; + +const isValidMarkdownUrl = (url: string): boolean => { + try { + const parsed = new URL(url); + return TRUSTED_DOMAINS.some( + (domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`), + ); + } catch { + return false; + } +}; + +type CopyButtonProps = { markdownUrl: string; }; -export const CopyButton = ({ markdownUrl, ...props }: CopyButtonProps) => { - const handleCopyContent = async (url: string) => { - const cached = cache.get(url); - if (cached) return navigator.clipboard.writeText(cached); - - await navigator.clipboard.write([ - new ClipboardItem({ - 'text/plain': fetch(url).then(async (res) => { - const content = await res.text(); - cache.set(url, content); - - return content; - }), - }), - ]); +const LLM_URLS = { + claude: 'https://claude.ai/new', + chatgpt: 'https://chat.openai.com/', +} as const; + +const LLM_PROMPT_MESSAGE = ' 문서를 읽고 질문에 답해줘.'; + +const openLLMChat = (llmType: keyof typeof LLM_URLS, docUrl: string) => { + if (!isValidMarkdownUrl(docUrl)) { + console.error('Invalid doc URL:', docUrl); + return; + } + + const prompt = encodeURIComponent(`${docUrl}${LLM_PROMPT_MESSAGE}`); + const url = `${LLM_URLS[llmType]}?q=${prompt}`; + window.open(url, '_blank'); +}; + +const trackCopyButtonEvent = (action: CopyButtonAction, markdownUrl: string) => { + const eventName = createCopyButtonEventName(action, markdownUrl); + track(eventName); +}; + +export const CopyButton = ({ markdownUrl }: CopyButtonProps) => { + const [checked, onCopy] = useCopyButton(() => handleCopyContent()); + const [isLoading, setIsLoading] = useState(false); + + const handleCopyContent = async () => { + trackCopyButtonEvent(COPY_BUTTON_ACTIONS.COPY_MARKDOWN, markdownUrl); + + const cached = cache.get(markdownUrl); + if (cached) { + await navigator.clipboard.writeText(cached); + return; + } + + if (!isValidMarkdownUrl(markdownUrl)) { + console.error('Invalid markdown URL:', markdownUrl); + return; + } + + setIsLoading(true); + try { + const res = await fetch(markdownUrl); + const content = await res.text(); + cache.set(markdownUrl, content); + await navigator.clipboard.writeText(content); + } finally { + setIsLoading(false); + } }; - const [checked, onClick] = useCopyButton(() => handleCopyContent(markdownUrl)); + const menuItems = useMemo( + () => [ + { + label: '마크다운으로 보기', + icon: markdownIcon, + onClick: () => { + if (isValidMarkdownUrl(markdownUrl)) { + trackCopyButtonEvent(COPY_BUTTON_ACTIONS.VIEW_MARKDOWN, markdownUrl); + window.open(markdownUrl, '_blank'); + } + }, + isExternal: true, + }, + { + label: 'Claude에게 질문하기', + icon: anthropicIcon, + onClick: () => { + trackCopyButtonEvent(COPY_BUTTON_ACTIONS.ASK_CLAUDE, markdownUrl); + openLLMChat('claude', markdownUrl); + }, + isExternal: true, + }, + { + label: 'ChatGPT에게 질문하기', + icon: openAIIcon, + onClick: () => { + trackCopyButtonEvent(COPY_BUTTON_ACTIONS.ASK_CHATGPT, markdownUrl); + openLLMChat('chatgpt', markdownUrl); + }, + isExternal: true, + }, + ], + [markdownUrl], + ); return ( - + + + + + + } + > + + + + {menuItems.map((item) => ( + + {item.icon} + {item.label} + {item.isExternal && ( + + )} + + ))} + + + ); }; diff --git a/apps/website/src/constants/analytics.ts b/apps/website/src/constants/analytics.ts new file mode 100644 index 000000000..01d22c79c --- /dev/null +++ b/apps/website/src/constants/analytics.ts @@ -0,0 +1,43 @@ +/** + * Vercel Analytics event constants and type definitions + */ + +export const COPY_BUTTON_ACTIONS = { + COPY_MARKDOWN: 'copy_markdown', + VIEW_MARKDOWN: 'view_markdown', + ASK_CLAUDE: 'ask_claude', + ASK_CHATGPT: 'ask_chatgpt', +} as const; + +export type CopyButtonAction = (typeof COPY_BUTTON_ACTIONS)[keyof typeof COPY_BUTTON_ACTIONS]; + +/** + * Extracts docs path from document URL + * Example: /docs/components/navigation-menu.mdx → components/navigation-menu + */ +export const extractComponentName = (markdownUrl: string): string => { + try { + const url = new URL(markdownUrl); + const pathname = url.pathname + .split('/') + .slice(-2) + .join('/') + .replace(/\.mdx?$/, ''); + const segments = pathname.split('/').filter(Boolean); + return segments.join('/') || 'unknown'; + } catch { + return 'unknown'; + } +}; + +/** + * Creates CopyButton event name + * Example: copy_markdown:components/navigation-menu, ask_claude:components/button + */ +export const createCopyButtonEventName = ( + action: CopyButtonAction, + markdownUrl: string, +): string => { + const componentName = extractComponentName(markdownUrl); + return `${action}:${componentName}`; +}; diff --git a/apps/website/src/constants/domain.ts b/apps/website/src/constants/domain.ts index 42fcdd4cd..21ddc5967 100644 --- a/apps/website/src/constants/domain.ts +++ b/apps/website/src/constants/domain.ts @@ -1 +1,2 @@ export const STATICS_DOMAIN = 'https://statics.goorm.io'; +export const SITE_URL = 'https://vapor-ui.goorm.io';