Skip to content

Commit d731674

Browse files
authored
Merge pull request #15 from 01-binary/storybook-renderer-test
Storybook renderer test
2 parents 3e22eb0 + e3f9b2c commit d731674

File tree

15 files changed

+6046
-38
lines changed

15 files changed

+6046
-38
lines changed

apps/storybook/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
"build-storybook": "storybook build"
1111
},
1212
"dependencies": {
13+
"@types/prismjs": "^1.26.5",
14+
"@types/styled-components": "^5.1.34",
1315
"notion-to-utils": "workspace:*",
16+
"prismjs": "^1.29.0",
1417
"react": "^18.3.1",
15-
"react-dom": "^18.3.1"
18+
"react-dom": "^18.3.1",
19+
"styled-components": "^6.1.14"
1620
},
1721
"devDependencies": {
1822
"@chromatic-com/storybook": "^3.2.4",
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React, { useState, useEffect } from 'react';
2+
import styled from 'styled-components';
3+
import { RichTextItem } from '../types';
4+
import { RichText } from './RichText';
5+
6+
interface OpenGraphData {
7+
title: string;
8+
description: string;
9+
image: string;
10+
siteName: string;
11+
}
12+
13+
interface BookmarkProps {
14+
url: string;
15+
caption?: RichTextItem[];
16+
}
17+
18+
const Card = styled.div`
19+
margin: ${({ theme }) => theme.spacing.md} 0;
20+
border: 1px solid ${({ theme }) => theme.colors.border};
21+
border-radius: ${({ theme }) => theme.borderRadius.md};
22+
overflow: hidden;
23+
transition: box-shadow 0.2s ease;
24+
25+
&:hover {
26+
box-shadow: ${({ theme }) => theme.shadows.md};
27+
}
28+
`;
29+
30+
const Content = styled.div`
31+
padding: ${({ theme }) => theme.spacing.md};
32+
`;
33+
34+
const PreviewImage = styled.img`
35+
width: 100%;
36+
height: 200px;
37+
object-fit: cover;
38+
background: ${({ theme }) => theme.colors.code.background};
39+
`;
40+
41+
const Title = styled.h4`
42+
margin: 0 0 ${({ theme }) => theme.spacing.xs};
43+
font-size: ${({ theme }) => theme.typography.fontSize.base};
44+
color: ${({ theme }) => theme.colors.text};
45+
`;
46+
47+
const Description = styled.p`
48+
margin: 0;
49+
font-size: ${({ theme }) => theme.typography.fontSize.small};
50+
color: ${({ theme }) => theme.colors.secondary};
51+
display: -webkit-box;
52+
-webkit-line-clamp: 2;
53+
-webkit-box-orient: vertical;
54+
overflow: hidden;
55+
`;
56+
57+
const SiteName = styled.div`
58+
margin-top: ${({ theme }) => theme.spacing.sm};
59+
font-size: ${({ theme }) => theme.typography.fontSize.small};
60+
color: ${({ theme }) => theme.colors.primary};
61+
`;
62+
63+
const Caption = styled.div`
64+
margin-top: ${({ theme }) => theme.spacing.sm};
65+
padding-top: ${({ theme }) => theme.spacing.sm};
66+
border-top: 1px solid ${({ theme }) => theme.colors.border};
67+
font-size: ${({ theme }) => theme.typography.fontSize.small};
68+
color: ${({ theme }) => theme.colors.secondary};
69+
`;
70+
71+
// 실제 프로덕션에서는 서버 사이드에서 처리하거나 전용 API를 사용해야 합니다
72+
const fetchOpenGraphData = async (url: string): Promise<OpenGraphData> => {
73+
// 임시로 더미 데이터를 반환
74+
return {
75+
title: new URL(url).hostname,
76+
description: 'No description available',
77+
image: '',
78+
siteName: new URL(url).hostname.split('.')[1]
79+
};
80+
};
81+
82+
export const Bookmark: React.FC<BookmarkProps> = ({ url, caption }) => {
83+
const [ogData, setOgData] = useState<OpenGraphData | null>(null);
84+
const [error, setError] = useState(false);
85+
86+
useEffect(() => {
87+
const loadOgData = async () => {
88+
try {
89+
const data = await fetchOpenGraphData(url);
90+
setOgData(data);
91+
} catch (err) {
92+
setError(true);
93+
}
94+
};
95+
96+
loadOgData();
97+
}, [url]);
98+
99+
return (
100+
<a href={url} target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }}>
101+
<Card>
102+
{ogData?.image && <PreviewImage src={ogData.image} alt={ogData.title} loading="lazy" />}
103+
<Content>
104+
<Title>{ogData?.title || url}</Title>
105+
{ogData?.description && <Description>{ogData.description}</Description>}
106+
{ogData?.siteName && <SiteName>{ogData.siteName}</SiteName>}
107+
{caption && caption.length > 0 && (
108+
<Caption>
109+
<RichText richText={caption} />
110+
</Caption>
111+
)}
112+
</Content>
113+
</Card>
114+
</a>
115+
);
116+
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import React, { useState, useEffect } from 'react';
2+
import styled from 'styled-components';
3+
import { RichTextItem } from '../types';
4+
import { RichText } from './RichText';
5+
6+
interface ImageProps {
7+
src: string;
8+
alt: string;
9+
caption?: RichTextItem[];
10+
priority?: boolean;
11+
}
12+
13+
const ImageContainer = styled.div`
14+
position: relative;
15+
width: 100%;
16+
background: ${({ theme }) => theme.colors.code.background};
17+
border-radius: ${({ theme }) => theme.borderRadius.md};
18+
overflow: hidden;
19+
`;
20+
21+
const StyledImage = styled.img<{ $isLoaded: boolean }>`
22+
width: 100%;
23+
height: auto;
24+
display: block;
25+
opacity: ${({ $isLoaded }) => ($isLoaded ? 1 : 0)};
26+
transition: opacity 0.3s ease;
27+
`;
28+
29+
const Placeholder = styled.div`
30+
position: absolute;
31+
top: 0;
32+
left: 0;
33+
right: 0;
34+
bottom: 0;
35+
display: flex;
36+
align-items: center;
37+
justify-content: center;
38+
background: ${({ theme }) => theme.colors.code.background};
39+
color: ${({ theme }) => theme.colors.secondary};
40+
`;
41+
42+
const Caption = styled.figcaption`
43+
text-align: center;
44+
color: ${({ theme }) => theme.colors.secondary};
45+
margin-top: ${({ theme }) => theme.spacing.sm};
46+
font-size: ${({ theme }) => theme.typography.fontSize.small};
47+
`;
48+
49+
export const Image: React.FC<ImageProps> = ({ src, alt, caption, priority = false }) => {
50+
const [isLoaded, setIsLoaded] = useState(false);
51+
const [error, setError] = useState(false);
52+
53+
useEffect(() => {
54+
setIsLoaded(false);
55+
setError(false);
56+
}, [src]);
57+
58+
return (
59+
<figure>
60+
<ImageContainer>
61+
{!isLoaded && !error && (
62+
<Placeholder>
63+
Loading...
64+
</Placeholder>
65+
)}
66+
{error && (
67+
<Placeholder>
68+
Failed to load image
69+
</Placeholder>
70+
)}
71+
<StyledImage
72+
src={src}
73+
alt={alt}
74+
$isLoaded={isLoaded}
75+
loading={priority ? 'eager' : 'lazy'}
76+
onLoad={() => setIsLoaded(true)}
77+
onError={() => setError(true)}
78+
/>
79+
</ImageContainer>
80+
{caption && caption.length > 0 && (
81+
<Caption>
82+
<RichText richText={caption} />
83+
</Caption>
84+
)}
85+
</figure>
86+
);
87+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React from 'react';
2+
import { RichText } from './RichText';
3+
import { Image } from './Image';
4+
import { Bookmark } from './Bookmark';
5+
import { RichTextItem } from '../types';
6+
7+
export const MemoizedRichText = React.memo(RichText, (prev, next) => {
8+
return JSON.stringify(prev.richText) === JSON.stringify(next.richText);
9+
});
10+
11+
export const MemoizedImage = React.memo(Image, (prev, next) => {
12+
return (
13+
prev.src === next.src &&
14+
prev.alt === next.alt &&
15+
JSON.stringify(prev.caption) === JSON.stringify(next.caption)
16+
);
17+
});
18+
19+
export const MemoizedBookmark = React.memo(Bookmark, (prev, next) => {
20+
return (
21+
prev.url === next.url &&
22+
JSON.stringify(prev.caption) === JSON.stringify(next.caption)
23+
);
24+
});
25+
26+
// 타입 가드 유틸리티
27+
export const isRichTextArray = (value: unknown): value is RichTextItem[] => {
28+
if (!Array.isArray(value)) return false;
29+
return value.every((item) =>
30+
typeof item === 'object' &&
31+
item !== null &&
32+
'type' in item &&
33+
item.type === 'text'
34+
);
35+
};

0 commit comments

Comments
 (0)