Skip to content

Commit b6a1152

Browse files
committed
feat: LinkPreview type 추가
1 parent 0e7a9b3 commit b6a1152

File tree

4 files changed

+239
-4
lines changed

4 files changed

+239
-4
lines changed

Diff for: packages/notion-to-jsx/src/components/Renderer/components/Block/BlockRenderer.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
MemoizedRichText,
55
MemoizedImage,
66
MemoizedBookmark,
7+
MemoizedLinkPreview,
78
} from '../MemoizedComponents';
89
import { CodeBlock } from '../Code';
910
import { Heading1, Heading2, Heading3, Paragraph } from '../Typography';
@@ -24,6 +25,13 @@ const BlockRenderer: React.FC<Props> = ({ block, onFocus, index }) => {
2425
};
2526

2627
switch (block.type) {
28+
case 'link_preview':
29+
return (
30+
<MemoizedLinkPreview
31+
url={block.link_preview.url}
32+
{...blockProps}
33+
/>
34+
);
2735
case 'paragraph':
2836
return (
2937
<Paragraph {...blockProps}>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import React, { useState, useEffect } from 'react';
2+
import {
3+
link,
4+
card,
5+
content,
6+
iconContainer,
7+
icon,
8+
title,
9+
updatedText,
10+
} from './styles.css';
11+
12+
export interface LinkPreviewProps {
13+
url: string;
14+
}
15+
16+
interface RepoData {
17+
name: string;
18+
full_name: string;
19+
owner: {
20+
avatar_url: string;
21+
};
22+
updated_at: string;
23+
}
24+
25+
// GitHub 레포지토리 데이터를 가져오는 함수
26+
const fetchGitHubRepoData = async (
27+
repoPath: string
28+
): Promise<RepoData | null> => {
29+
try {
30+
const apiUrl = `https://api.github.com/repos/${repoPath}`;
31+
const response = await fetch(apiUrl);
32+
33+
if (!response.ok) {
34+
throw new Error('Failed to fetch GitHub repo data');
35+
}
36+
37+
const data = await response.json();
38+
return data;
39+
} catch (error) {
40+
console.error('Error fetching GitHub repo data:', error);
41+
return null;
42+
}
43+
};
44+
45+
// GitHub URL에서 레포지토리 경로 추출
46+
const extractRepoPathFromUrl = (url: string): string | null => {
47+
try {
48+
const parsedUrl = new URL(url);
49+
if (parsedUrl.hostname === 'github.com') {
50+
// URL 경로에서 첫 번째 '/'를 제거하고 나머지 경로 반환
51+
const path = parsedUrl.pathname.substring(1);
52+
// 레포지토리 경로는 일반적으로 'username/repo-name' 형식
53+
const pathParts = path.split('/');
54+
if (pathParts.length >= 2) {
55+
return `${pathParts[0]}/${pathParts[1]}`;
56+
}
57+
}
58+
return null;
59+
} catch (error) {
60+
console.error('Error parsing URL:', error);
61+
return null;
62+
}
63+
};
64+
65+
// 날짜 포맷팅 함수
66+
const formatUpdatedTime = (dateString: string): string => {
67+
const date = new Date(dateString);
68+
const now = new Date();
69+
const diffInHours = Math.floor(
70+
(now.getTime() - date.getTime()) / (1000 * 60 * 60)
71+
);
72+
73+
if (diffInHours < 24) {
74+
return `Updated ${diffInHours} hours ago`;
75+
} else {
76+
const diffInDays = Math.floor(diffInHours / 24);
77+
if (diffInDays === 1) {
78+
return 'Updated yesterday';
79+
} else if (diffInDays < 30) {
80+
return `Updated ${diffInDays} days ago`;
81+
} else {
82+
const months = [
83+
'Jan',
84+
'Feb',
85+
'Mar',
86+
'Apr',
87+
'May',
88+
'Jun',
89+
'Jul',
90+
'Aug',
91+
'Sep',
92+
'Oct',
93+
'Nov',
94+
'Dec',
95+
];
96+
return `Updated on ${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
97+
}
98+
}
99+
};
100+
101+
const LinkPreview: React.FC<LinkPreviewProps> = ({ url }) => {
102+
const [repoData, setRepoData] = useState<RepoData | null>(null);
103+
const [loading, setLoading] = useState(true);
104+
105+
useEffect(() => {
106+
const loadRepoData = async () => {
107+
setLoading(true);
108+
const repoPath = extractRepoPathFromUrl(url);
109+
110+
if (repoPath) {
111+
const data = await fetchGitHubRepoData(repoPath);
112+
setRepoData(data);
113+
}
114+
115+
setLoading(false);
116+
};
117+
118+
loadRepoData();
119+
}, [url]);
120+
121+
// 레포지토리 이름 추출 (full_name에서 organization/repo 형식)
122+
const repoName =
123+
repoData?.name ||
124+
extractRepoPathFromUrl(url)?.split('/')[1] ||
125+
'Repository';
126+
127+
// 업데이트 시간 포맷팅
128+
const updatedTimeText = repoData?.updated_at
129+
? formatUpdatedTime(repoData.updated_at)
130+
: '';
131+
132+
return (
133+
<a href={url} target="_blank" rel="noopener noreferrer" className={link}>
134+
<div className={card}>
135+
<div className={iconContainer}>
136+
<img
137+
src={
138+
repoData?.owner?.avatar_url ||
139+
'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png'
140+
}
141+
alt="Repository icon"
142+
className={icon}
143+
/>
144+
</div>
145+
<div className={content}>
146+
<div className={title}>{repoName}</div>
147+
<div className={updatedText}>
148+
{loading ? 'Loading...' : `NotionX • ${updatedTimeText}`}
149+
</div>
150+
</div>
151+
</div>
152+
</a>
153+
);
154+
};
155+
156+
export default LinkPreview;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { style } from '@vanilla-extract/css';
2+
import { vars } from '../../../../styles/theme.css';
3+
4+
export const link = style({
5+
textDecoration: 'none',
6+
display: 'block',
7+
paddingTop: vars.spacing.xxs,
8+
paddingBottom: vars.spacing.xxs,
9+
});
10+
11+
export const card = style({
12+
display: 'flex',
13+
border: `1px solid ${vars.colors.border}`,
14+
borderRadius: vars.borderRadius.md,
15+
overflow: 'hidden',
16+
transition: 'box-shadow 0.2s ease',
17+
alignItems: 'center',
18+
maxHeight: '4rem',
19+
padding: vars.spacing.base,
20+
paddingLeft: vars.spacing.md,
21+
gap: vars.spacing.md,
22+
':hover': {
23+
boxShadow: vars.shadows.md,
24+
},
25+
});
26+
27+
export const content = style({
28+
display: 'flex',
29+
flex: '1 1 auto',
30+
flexDirection: 'column',
31+
justifyContent: 'space-between',
32+
overflow: 'hidden',
33+
});
34+
35+
export const iconContainer = style({
36+
display: 'flex',
37+
alignItems: 'center',
38+
justifyContent: 'center',
39+
maxWidth: '2.5rem',
40+
height: '100%',
41+
flexShrink: 0,
42+
});
43+
44+
export const icon = style({
45+
width: '2.5rem',
46+
height: '2.5rem',
47+
objectFit: 'contain',
48+
borderRadius: vars.borderRadius.sm,
49+
});
50+
51+
export const title = style({
52+
fontSize: vars.typography.fontSize.base,
53+
fontWeight: vars.typography.fontWeight.semibold,
54+
color: vars.colors.text,
55+
overflow: 'hidden',
56+
textOverflow: 'ellipsis',
57+
whiteSpace: 'nowrap',
58+
});
59+
60+
export const updatedText = style({
61+
fontSize: vars.typography.fontSize.xs,
62+
color: vars.colors.secondary,
63+
overflow: 'hidden',
64+
textOverflow: 'ellipsis',
65+
whiteSpace: 'nowrap',
66+
});

Diff for: packages/notion-to-jsx/src/components/Renderer/components/MemoizedComponents.tsx

+9-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import RichText, { RichTextItem, RichTextProps } from './RichText/RichTexts';
33
import { Image, ImageProps } from './Image';
44
import Bookmark, { type BookmarkProps } from './Bookmark/Bookmark';
5+
import LinkPreview, { type LinkPreviewProps } from './LinkPreview/LinkPreview';
56

67
export const MemoizedRichText = React.memo<RichTextProps>(
78
RichText,
@@ -21,10 +22,14 @@ export const MemoizedImage = React.memo<ImageProps>(Image, (prev, next) => {
2122
export const MemoizedBookmark = React.memo<BookmarkProps>(
2223
Bookmark,
2324
(prev, next) => {
24-
return (
25-
prev.url === next.url &&
26-
JSON.stringify(prev.caption) === JSON.stringify(next.caption)
27-
);
25+
return prev.url === next.url;
26+
}
27+
);
28+
29+
export const MemoizedLinkPreview = React.memo<LinkPreviewProps>(
30+
LinkPreview,
31+
(prev, next) => {
32+
return prev.url === next.url;
2833
}
2934
);
3035

0 commit comments

Comments
 (0)