diff --git a/.changeset/happy-glasses-dance.md b/.changeset/happy-glasses-dance.md new file mode 100644 index 0000000..cf768bf --- /dev/null +++ b/.changeset/happy-glasses-dance.md @@ -0,0 +1,5 @@ +--- +"notion-to-jsx": patch +--- + +Update link property type and access nested url property for text links, add loading skeleton to image and cover components diff --git a/apps/renderer-storybook/scripts/fetchNotionProperties.ts b/apps/renderer-storybook/scripts/fetchNotionProperties.ts index b0b26d0..13a049d 100644 --- a/apps/renderer-storybook/scripts/fetchNotionProperties.ts +++ b/apps/renderer-storybook/scripts/fetchNotionProperties.ts @@ -12,7 +12,7 @@ const __dirname = dirname(__filename); dotenv.config({ path: resolve(__dirname, '../.env.local') }); // 페이지 ID -const PAGE_ID = '1239c6bf-2b17-8076-a838-d17ca1c89783'; +const PAGE_ID = '1399c6bf-2b17-80f4-bfcf-e81ca24d2c5d'; // ? using this script : pnpx tsx scripts/fetchNotionProperties.ts async function fetchAndSaveProperties() { diff --git a/apps/renderer-storybook/src/sample-data/notionProperties.json b/apps/renderer-storybook/src/sample-data/notionProperties.json index d2518af..9aa8c72 100644 --- a/apps/renderer-storybook/src/sample-data/notionProperties.json +++ b/apps/renderer-storybook/src/sample-data/notionProperties.json @@ -1,6 +1,6 @@ { "Date": { - "start": "2025-01-18", + "start": "2024-11-09", "end": null, "time_zone": null }, @@ -9,10 +9,10 @@ "name": "WEB", "color": "default" }, - "Slug": "notion-lib-1", + "Slug": "changeset-github-action", "isPublished": true, - "Desc": "노션 페이지에 글을 쓰면 별도 배포 없이 포스팅되는 라이브러리를 직접 만들기로 했다!", + "Desc": "changeset과 github action을 도입해 버전 관리하는 방법을 공유한다!", "Tags": null, - "Name": "Notion API로 블로그 만들기 (with npm) - 1", - "coverUrl": "https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Fcd7314a5-d906-43b0-81e7-42eff82c02a3%2F8423f797-5e58-45ea-a201-25587375c59d%2F1692217660016.png?table=block&id=1239c6bf-2b17-8076-a838-d17ca1c89783&cache=v2" + "Name": "changeset과 github action으로 버전 관리하기", + "coverUrl": "https://www.notion.so/image/https%3A%2F%2Fprod-files-secure.s3.us-west-2.amazonaws.com%2Fcd7314a5-d906-43b0-81e7-42eff82c02a3%2Fce42e11d-7b8b-462c-b1df-9015be63135b%2Fchangesets-banner-light.png?table=block&id=1399c6bf-2b17-80f4-bfcf-e81ca24d2c5d&cache=v2" } \ No newline at end of file diff --git a/packages/notion-to-jsx/src/components/Cover/index.tsx b/packages/notion-to-jsx/src/components/Cover/index.tsx index abfd332..01dcc17 100644 --- a/packages/notion-to-jsx/src/components/Cover/index.tsx +++ b/packages/notion-to-jsx/src/components/Cover/index.tsx @@ -1,12 +1,33 @@ -import { cover } from './styles.css'; +import { useState } from 'react'; +import { coverContainer, skeletonWrapper, imageStyle } from './styles.css'; +import Skeleton from '../Skeleton'; interface CoverProps { src: string; alt: string; } +/** + * 노션 페이지 상단에 표시되는 커버 이미지 컴포넌트 + * 이미지 로딩 중에는 스켈레톤 UI를 표시하고, 로딩 완료 시 자연스럽게 이미지로 전환됩니다. + */ const Cover = ({ src, alt }: CoverProps) => { - return {alt}; + const [isLoaded, setIsLoaded] = useState(false); + + return ( +
+
+ +
+ {alt} setIsLoaded(true)} + loading="lazy" + /> +
+ ); }; export default Cover; diff --git a/packages/notion-to-jsx/src/components/Cover/styles.css.ts b/packages/notion-to-jsx/src/components/Cover/styles.css.ts index b005af5..ab138ab 100644 --- a/packages/notion-to-jsx/src/components/Cover/styles.css.ts +++ b/packages/notion-to-jsx/src/components/Cover/styles.css.ts @@ -1,14 +1,15 @@ import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; -export const cover = style({ +export const coverContainer = style({ + position: 'relative', width: '100%', maxWidth: '56.25rem', height: '30vh', - display: 'block', - objectFit: 'cover', - objectPosition: 'center 50%', - borderRadius: '1.5rem', margin: '0 auto', + borderRadius: '1.5rem', + overflow: 'hidden', + boxShadow: '2px 2px 8px 4px hsla(0,0%,6%,.1)', '@media': { '(max-width: 900px)': { borderRadius: '0.5rem', @@ -16,3 +17,53 @@ export const cover = style({ }, }, }); + +export const skeletonWrapper = recipe({ + base: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: 1, + transition: 'opacity 0.3s ease', + }, + variants: { + isLoaded: { + true: { + opacity: 0, + }, + false: { + opacity: 1, + }, + }, + }, + defaultVariants: { + isLoaded: false, + }, +}); + +export const imageStyle = recipe({ + base: { + width: '100%', + height: '100%', + objectFit: 'cover', + objectPosition: 'center 50%', + display: 'block', + zIndex: 2, + transition: 'opacity 0.3s ease', + }, + variants: { + isLoaded: { + true: { + opacity: 1, + }, + false: { + opacity: 0, + }, + }, + }, + defaultVariants: { + isLoaded: false, + }, +}); diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Image/Image.tsx b/packages/notion-to-jsx/src/components/Renderer/components/Image/Image.tsx index 895f6cd..44727db 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Image/Image.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/Image/Image.tsx @@ -3,11 +3,12 @@ import { MemoizedRichText } from '../MemoizedComponents'; import { imageContainer, imageWrapper, - styledImage, - placeholder, + imageStyle, caption, + skeletonWrapper, } from './styles.css'; import { RichTextItem } from '../RichText/RichTexts'; +import Skeleton from '../../../Skeleton'; export interface ImageFormat { block_width?: number; @@ -26,38 +27,6 @@ export interface ImageProps { const MAX_WIDTH = 720; -// 이미지 스타일 유틸리티 함수 -const getImageStyles = (format?: ImageFormat, isColumn: boolean = false) => { - // width 계산 로직 - const getWidthStyle = () => { - if ( - !isColumn && - format?.block_aspect_ratio && - format.block_aspect_ratio < 1 - ) { - return `${format.block_aspect_ratio * 100}%`; - } - - if (format?.block_width) { - return format.block_width > MAX_WIDTH - ? '100%' - : `${format.block_width}px`; - } - - return '100%'; - }; - - // aspectRatio 계산 로직 - const getAspectRatioStyle = () => { - return format?.block_aspect_ratio ? `${format.block_aspect_ratio}` : 'auto'; - }; - - return { - width: getWidthStyle(), - aspectRatio: getAspectRatioStyle(), - }; -}; - // 이미지 태그에 사용되는 aspectRatio 스타일 const getImageTagStyle = (format?: ImageFormat) => { return format?.block_aspect_ratio @@ -76,41 +45,12 @@ const Image: React.FC = ({ return (
-
- {!isLoaded && ( -
- - - - - - - - - - -
- )} +
+
+ +
= ({ }; export default Image; + +// 이미지 스타일 유틸리티 함수 +const getImageStyles = (format?: ImageFormat, isColumn: boolean = false) => { + // width 계산 로직 + const getWidthStyle = () => { + if ( + !isColumn && + format?.block_aspect_ratio && + format.block_aspect_ratio < 1 + ) { + return `${format.block_aspect_ratio * 100}%`; + } + + if (format?.block_width) { + return format.block_width > MAX_WIDTH + ? '100%' + : `${format.block_width}px`; + } + + return '100%'; + }; + + // aspectRatio 계산 로직 + const getAspectRatioStyle = () => { + return format?.block_aspect_ratio ? `${format.block_aspect_ratio}` : 'auto'; + }; + + return { + width: getWidthStyle(), + aspectRatio: getAspectRatioStyle(), + }; +}; diff --git a/packages/notion-to-jsx/src/components/Renderer/components/Image/styles.css.ts b/packages/notion-to-jsx/src/components/Renderer/components/Image/styles.css.ts index 35ef958..969f0ff 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/Image/styles.css.ts +++ b/packages/notion-to-jsx/src/components/Renderer/components/Image/styles.css.ts @@ -1,6 +1,5 @@ import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; -import { createVar, fallbackVar } from '@vanilla-extract/css'; import { vars } from '../../../../styles/theme.css'; export const imageContainer = style({ @@ -15,36 +14,18 @@ export const imageContainer = style({ alignItems: 'center', }); -export const imageWidthVar = createVar(); -export const imageAspectRatioVar = createVar(); - -export const imageWrapper = recipe({ - base: { - position: 'relative', - maxWidth: '100%', - width: fallbackVar(imageWidthVar, '100%'), - }, - variants: { - hasWidth: { - true: {}, - false: { - width: '100%', - }, - }, - }, - defaultVariants: { - hasWidth: false, - }, +export const imageWrapper = style({ + position: 'relative', + maxWidth: '100%', }); -export const styledImage = recipe({ +export const imageStyle = recipe({ base: { width: '100%', height: 'auto', display: 'block', transition: 'opacity 0.3s ease', objectFit: 'contain', - aspectRatio: fallbackVar(imageAspectRatioVar, 'auto'), }, variants: { loaded: { @@ -53,12 +34,16 @@ export const styledImage = recipe({ }, false: { opacity: 0, - height: 0, }, }, hasAspectRatio: { - true: {}, - false: {}, + true: { + // aspectRatio는 recipe 단계에서는 빈 객체로 두고 + // 컴포넌트에서 동적으로 계산된 값으로 채워집니다 + }, + false: { + aspectRatio: 'auto', + }, }, }, defaultVariants: { @@ -67,15 +52,34 @@ export const styledImage = recipe({ }, }); -export const placeholder = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', -}); - export const caption = style({ textAlign: 'center', color: vars.colors.secondary, marginTop: vars.spacing.sm, fontSize: vars.typography.fontSize.small, }); + +export const skeletonWrapper = recipe({ + base: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: 1, + transition: 'opacity 0.3s ease', + }, + variants: { + isLoaded: { + true: { + opacity: 0, + }, + false: { + opacity: 1, + }, + }, + }, + defaultVariants: { + isLoaded: false, + }, +}); diff --git a/packages/notion-to-jsx/src/components/Renderer/components/RichText/RichTexts.tsx b/packages/notion-to-jsx/src/components/Renderer/components/RichText/RichTexts.tsx index ea562c7..6c5005f 100644 --- a/packages/notion-to-jsx/src/components/Renderer/components/RichText/RichTexts.tsx +++ b/packages/notion-to-jsx/src/components/Renderer/components/RichText/RichTexts.tsx @@ -44,7 +44,9 @@ export interface RichTextItem { text?: { content: string; - link: string | null; + link: { + url: string | null; + } | null; }; } @@ -76,8 +78,9 @@ const RichTexts: React.FC = ({ richTexts }) => { case 'text': { if (text.text) { const { text: textData } = text; - content = textData.link - ? renderLink(textData.link, textData.content) + + content = textData.link?.url + ? renderLink(textData.link.url, textData.content) : textData.content; } else { content = text.plain_text; diff --git a/packages/notion-to-jsx/src/components/Skeleton/index.tsx b/packages/notion-to-jsx/src/components/Skeleton/index.tsx new file mode 100644 index 0000000..d5b0132 --- /dev/null +++ b/packages/notion-to-jsx/src/components/Skeleton/index.tsx @@ -0,0 +1,57 @@ +import { FC } from 'react'; +import * as styles from './styles.css'; + +type SkeletonProps = { + /** + * 스켈레톤 형태 - 직사각형, 원형, 이미지 크기 + * @default 'rect' + */ + variant?: 'rect' | 'circle' | 'image'; + /** + * 커스텀 너비 (px 또는 %) + */ + width?: string; + /** + * 커스텀 높이 (px 또는 %) + */ + height?: string; + /** + * 추가 CSS 클래스명 + */ + className?: string; +}; + +/** + * 콘텐츠 로딩 중에 표시되는 물결 효과가 있는 스켈레톤 컴포넌트입니다. + * 이미지, 텍스트 등의 로딩 상태를 표시하는 데 사용합니다. + */ +const Skeleton: FC = ({ + variant = 'rect', + width, + height, + className, +}) => { + const getVariantClass = () => { + switch (variant) { + case 'circle': + return styles.circle; + case 'image': + return styles.image; + case 'rect': + default: + return styles.rect; + } + }; + + return ( +
+ ); +}; + +export default Skeleton; diff --git a/packages/notion-to-jsx/src/components/Skeleton/styles.css.ts b/packages/notion-to-jsx/src/components/Skeleton/styles.css.ts new file mode 100644 index 0000000..01b907c --- /dev/null +++ b/packages/notion-to-jsx/src/components/Skeleton/styles.css.ts @@ -0,0 +1,50 @@ +import { style, keyframes } from '@vanilla-extract/css'; + +const shimmer = keyframes({ + '0%': { + transform: 'translateX(-100%)', + }, + '100%': { + transform: 'translateX(100%)', + }, +}); + +export const skeleton = style({ + display: 'inline-block', + height: '100%', + width: '100%', + backgroundColor: '#f0f0f0', + borderRadius: '4px', + position: 'relative', + overflow: 'hidden', + '::after': { + content: '""', + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundImage: + 'linear-gradient(90deg, rgba(255, 255, 255, 0) 0, rgba(255, 255, 255, 0.2) 20%, rgba(255, 255, 255, 0.5) 60%, rgba(255, 255, 255, 0))', + animation: `${shimmer} .8s infinite linear`, + backgroundSize: '100% 100%', + backgroundRepeat: 'no-repeat', + }, +}); + +export const rect = style({ + width: '100%', + height: '20px', + marginBottom: '8px', +}); + +export const circle = style({ + width: '50px', + height: '50px', + borderRadius: '50%', +}); + +export const image = style({ + width: '100%', + borderRadius: '8px', +});