Skip to content

Commit

Permalink
refactor: LinkCard
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <[email protected]>
  • Loading branch information
Innei committed Nov 2, 2023
1 parent 50ddd75 commit daa7682
Show file tree
Hide file tree
Showing 2 changed files with 359 additions and 1 deletion.
335 changes: 335 additions & 0 deletions src/components/ui/link-card/LinkCard2.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
import React, { useCallback, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import clsx from 'clsx'
import Link from 'next/link'
import RemoveMarkdown from 'remove-markdown'
import type { FC, ReactNode, SyntheticEvent } from 'react'

import { simpleCamelcaseKeys as camelcaseKeys } from '@mx-space/api-client'

import { LazyLoad } from '~/components/common/Lazyload'
import { usePeek } from '~/components/widgets/peek/usePeek'
import { useIsClientTransition } from '~/hooks/common/use-is-client'
import { preventDefault } from '~/lib/dom'
import { fetchGitHubApi } from '~/lib/github'
import { apiClient } from '~/lib/request'

import styles from './LinkCard.module.css'

export type LinkCardSource = 'gh' | 'self' | 'mx-space' | 'gh-commit' | 'gh-pr'

export interface LinkCardProps {
id: string
source?: LinkCardSource
className?: string
}

export const LinkCard = (props: LinkCardProps) => {
const isClient = useIsClientTransition()

if (!isClient) return null

return (
<LazyLoad placeholder={<LinkCardSkeleton />}>
<LinkCardImpl {...props} />
</LazyLoad>
)
}

const LinkCardImpl: FC<LinkCardProps> = (props) => {
const { id, source = 'self', className } = props

const [loading, setLoading] = useState(true)
const [isError, setIsError] = useState(false)
const [fullUrl, setFullUrl] = useState('about:blank')
const [cardInfo, setCardInfo] = useState<{
title: ReactNode
desc?: ReactNode
image?: string
}>()

const peek = usePeek()
const handleCanPeek = useCallback(
async (e: SyntheticEvent<any>) => {
const success = peek(fullUrl)
if (success) preventDefault(e)
},
[fullUrl],
)

const { isValid, fetchFn } = useMemo(
() => validTypeAndFetchFunction(source, id),
[source, id],
)

const fetchInfo = useCallback(async () => {
if (!fetchFn) {
return
}
setLoading(true)

await fetchFn(id, setCardInfo, setFullUrl).catch((err) => {
console.log('fetch card info error: ', err)
setIsError(true)
})
setLoading(false)
}, [fetchFn, id])

const { ref } = useInView({
triggerOnce: true,
onChange(inView) {
if (!inView) {
return
}

fetchInfo()
},
})

if (!isValid) {
return null
}

const LinkComponent = source === 'self' ? Link : 'a'

return (
<LinkComponent
href={fullUrl}
target={source !== 'self' ? '_blank' : '_self'}
ref={ref}
className={clsx(
styles['card-grid'],
(loading || isError) && styles['skeleton'],
isError && styles['error'],
className,
)}
onClick={handleCanPeek}
>
<span className={styles['contents']}>
<span className={styles['title']}>{cardInfo?.title}</span>
<span className={styles['desc']}>{cardInfo?.desc}</span>
</span>
{(loading || cardInfo?.image) && (
<span
className={styles['image']}
data-image={cardInfo?.image || ''}
style={{
backgroundImage: cardInfo?.image
? `url(${cardInfo.image})`
: undefined,
}}
/>
)}
</LinkComponent>
)
}

const LinkCardSkeleton = () => {
return (
<span className={clsx(styles['card-grid'], styles['skeleton'])}>
<span className={styles['contents']}>
<span className={styles['title']} />
<span className={styles['desc']} />
</span>
<span className={styles['image']} />
</span>
)
}

type FetchFunction = (
id: string,
setCardInfo: React.Dispatch<
React.SetStateAction<
| {
title: ReactNode
desc?: ReactNode
image?: string | undefined
}
| undefined
>
>,
setFullUrl: (url: string) => void,
) => Promise<void>

type FetchObject = {
isValid: (id: string) => boolean
fetch: FetchFunction
}

function validTypeAndFetchFunction(source: LinkCardSource, id: string) {
const fetchDataFunctions = {
'mx-space': fetchMxSpaceData,
gh: fetchGitHubData,
'gh-commit': fetchGitHubCommitData,
'gh-pr': fetchGitHubPRData,
self: fetchMxSpaceData,
} as Record<LinkCardSource, FetchObject>

const fetchFunction = fetchDataFunctions[source]
if (!fetchFunction) {
return { isValid: false, fetchFn: null }
}

const isValid = fetchFunction.isValid(id)
return { isValid, fetchFn: isValid ? fetchFunction.fetch : null }
}

const fetchGitHubData: FetchObject = {
isValid: (id) => {
// owner/repo
const parts = id.split('/')
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0
},
fetch: async (id, setCardInfo, setFullUrl) => {
const [owner, repo] = id.split('/')
try {
const response = await fetchGitHubApi(
`https://api.github.com/repos/${owner}/${repo}`,
)
const data = camelcaseKeys(response)

setCardInfo({
title: data.name,
desc: data.description,
image: data.owner.avatarUrl,
})

setFullUrl(data.htmlUrl)
} catch (err) {
console.error('Error fetching GitHub data:', err)
throw err
}
},
}

const fetchGitHubCommitData: FetchObject = {
isValid: (id) => {
// 假设 'gh-commit' 类型的 id 应该是 'username/repo/commit/commitId' 的形式
const parts = id.split('/')
return (
parts.length === 4 &&
parts.every((part) => part.length > 0) &&
parts[2] === 'commit'
)
},
fetch: async (id, setCardInfo, setFullUrl) => {
const [owner, repo, , commitId] = id.split('/')
try {
const response = await fetchGitHubApi(
`https://api.github.com/repos/${owner}/${repo}/commits/${commitId}`,
)
const data = camelcaseKeys(response)

setCardInfo({
title: (
<span className="font-normal">
{data.commit.message.replace(/Signed-off-by:.+/, '')}
</span>
),
desc: (
<span className="flex items-center space-x-5 font-mono">
<span className="text-uk-green-light">+{data.stats.additions}</span>
<span className="text-uk-red-light">-{data.stats.deletions}</span>

<span className="text-sm">{data.sha.slice(0, 7)}</span>

<span className="text-sm opacity-80">
{owner}/{repo}
</span>
</span>
),
image: data.author?.avatarUrl,
})

setFullUrl(`https://github.com/${owner}/${repo}/commit/${commitId}`)
} catch (err) {
console.error('Error fetching GitHub commit data:', err)
throw err
}
},
}

const fetchGitHubPRData: FetchObject = {
isValid: (id) => {
// ${owner}/${repo}/${pr}
const parts = id.split('/')
return parts.length === 3 && parts.every((part) => part.length > 0)
},
fetch: async (id, setCardInfo, setFullUrl) => {
const [owner, repo, , prNumber] = id.split('/')
try {
const response = await fetchGitHubApi(
`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
)
const data = camelcaseKeys(response)

setCardInfo({
title: `PR: ${data.title}`,
desc: (
<span className="flex items-center space-x-5 font-mono">
<span className="text-uk-green-light">+{data.additions}</span>
<span className="text-uk-red-light">-{data.deletions}</span>
<span className="text-sm opacity-80">
{owner}/{repo}
</span>
</span>
),
image: data.user.avatarUrl,
})

setFullUrl(data.htmlUrl)
} catch (err) {
console.error('Error fetching GitHub PR data:', err)
throw err
}
},
}

const fetchMxSpaceData: FetchObject = {
isValid: (id) => {
// 'posts/cate/slug' 或 'notes/nid'
const [type, ...rest] = id.split('/')
if (type !== 'posts' && type !== 'notes') {
return false
}

if (type === 'posts') {
return rest.length === 2
}
return rest.length === 1
},
fetch: async (id, setCardInfo, setFullUrl) => {
const [type, ...rest] = id.split('/')
try {
let data: {
title: string
text: string
images?: { src: string }[]
meta?: Record<string, any>
cover?: string
summary?: string
} = { title: '', text: '' }

if (type === 'posts') {
const [cate, slug] = rest
const response = await apiClient.post.getPost(cate, slug)
data = response
setFullUrl(`/posts/${cate}/${slug}`)
} else if (type === 'notes') {
const [nid] = rest
const response = await apiClient.note.getNoteById(nid)
data = response
setFullUrl(`/notes/${nid}`)
}

setCardInfo({
title: data.title,
desc: data.summary || `${RemoveMarkdown(data.text).slice(0, 50)}...`,
image: data.cover || data.meta?.cover || data.images?.[0]?.src,
})
} catch (err) {
console.error('Error fetching self data:', err)
throw err
}
},
}
25 changes: 24 additions & 1 deletion src/components/ui/link-card/index.demo.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { DocumentComponent } from 'storybook/typings'

import { LinkCard } from './LinkCard'
import { LinkCard } from './LinkCard2'

export const LinkCardDemo1: DocumentComponent = () => {
return <LinkCard id="notes/145" source="mx-space" />
Expand All @@ -27,3 +27,26 @@ LinkCardDemo3.meta = {
description: 'Error LinkCard',
title: 'LinkCard - Error',
}

export const LinkCardDemo4: DocumentComponent = () => {
return <LinkCard id="innei/shiro/132" source="gh-pr" />
}

LinkCardDemo4.meta = {
description: 'Show Github PR LinkCard',
title: 'LinkCard - GitHub PR',
}

export const LinkCardDemo5: DocumentComponent = () => {
return (
<LinkCard
id="innei/shiro/commit/0bf51cd8cf8457a2666906dea4a333081fc2a2b8"
source="gh-commit"
/>
)
}

LinkCardDemo5.meta = {
description: 'Show Github Commit LinkCard',
title: 'LinkCard - GitHub Commit',
}

1 comment on commit daa7682

@vercel
Copy link

@vercel vercel bot commented on daa7682 Nov 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

shiro – ./

springtide.vercel.app
shiro-innei.vercel.app
innei.in
shiro-git-main-innei.vercel.app

Please sign in to comment.