Skip to content

Commit

Permalink
feat: add link card spotlight
Browse files Browse the repository at this point in the history
  • Loading branch information
Innei committed Dec 23, 2023
1 parent 3c5425c commit fefdbee
Show file tree
Hide file tree
Showing 9 changed files with 160 additions and 47 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
language.ts
6 changes: 3 additions & 3 deletions src/components/layout/header/internal/HeaderContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ const ForDesktop: Component<{
shouldHideNavBg?: boolean
animatedIcon?: boolean
}> = ({ className, shouldHideNavBg, animatedIcon = true }) => {
const { config: headerMenuConfig } = useHeaderConfig()
const pathname = usePathname()

const mouseX = useMotionValue(0)
const mouseY = useMotionValue(0)
const radius = useMotionValue(0)
Expand All @@ -95,9 +98,6 @@ const ForDesktop: Component<{
[mouseX, mouseY, radius],
)

const { config: headerMenuConfig } = useHeaderConfig()
const pathname = usePathname()

const background = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, var(--spotlight-color) 0%, transparent 65%)`

return (
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/image/ZoomedImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import type { FC, ReactNode } from 'react'

import { LazyLoad } from '~/components/common/Lazyload'
import { useIsUnMounted } from '~/hooks/common/use-is-unmounted'
import { calculateDimensions } from '~/lib/calc-image'
import { isDev } from '~/lib/env'
import { clsxm } from '~/lib/helper'
import { calculateDimensions } from '~/lib/image'
import { useMarkdownImageRecord } from '~/providers/article/MarkdownImageRecordProvider'

import { Divider } from '../divider'
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/image/use-calculate-size.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useReducer } from 'react'

import { calculateDimensions } from '~/lib/calc-image'
import { calculateDimensions } from '~/lib/image'

const initialState = { height: 0, width: 0 }
type Action = { type: 'set'; height: number; width: number } | { type: 'reset' }
Expand Down
3 changes: 2 additions & 1 deletion src/components/ui/link-card/LinkCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
display: block;
min-width: 0;
margin-right: 0.55rem;
z-index: 1;
}

.title {
Expand Down Expand Up @@ -53,7 +54,7 @@

.image {
@apply aspect-square flex-shrink-0 bg-cover bg-center bg-no-repeat;
@apply bg-gray-50 dark:bg-neutral-700;
@apply z-[1] bg-gray-50 dark:bg-neutral-700;

height: 3rem;
width: 3rem;
Expand Down
93 changes: 75 additions & 18 deletions src/components/ui/link-card/LinkCard.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import React, { useCallback, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import clsx from 'clsx'
import { m, useMotionTemplate, useMotionValue } from 'framer-motion'
import Link from 'next/link'
import RemoveMarkdown from 'remove-markdown'
import uniqolor from 'uniqolor'
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 { LanguageToColorMap } from '~/constants/language'
import { useIsClientTransition } from '~/hooks/common/use-is-client'
import { preventDefault } from '~/lib/dom'
import { fetchGitHubApi } from '~/lib/github'
import { getDominantColor } from '~/lib/image'
import { apiClient } from '~/lib/request'

import { LinkCardSource } from './enums'
Expand All @@ -35,17 +39,21 @@ export const LinkCard = (props: LinkCardProps) => {
)
}

type CardState = {
title: ReactNode
desc?: ReactNode
image?: string
color?: string
}

const LinkCardImpl: FC<LinkCardProps> = (props) => {
const { id, source = LinkCardSource.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 [cardInfo, setCardInfo] = useState<CardState>()

const peek = usePeek()
const handleCanPeek = useCallback(
Expand Down Expand Up @@ -85,6 +93,21 @@ const LinkCardImpl: FC<LinkCardProps> = (props) => {
},
})

const mouseX = useMotionValue(0)
const mouseY = useMotionValue(0)
const radius = useMotionValue(0)
const handleMouseMove = useCallback(
({ clientX, clientY, currentTarget }: React.MouseEvent) => {
const bounds = currentTarget.getBoundingClientRect()
mouseX.set(clientX - bounds.left)
mouseY.set(clientY - bounds.top)
radius.set(Math.sqrt(bounds.width ** 2 + bounds.height ** 2) * 1.3)
},
[mouseX, mouseY, radius],
)

const background = useMotionTemplate`radial-gradient(${radius}px circle at ${mouseX}px ${mouseY}px, var(--spotlight-color) 0%, transparent 65%)`

if (!isValid) {
return null
}
Expand All @@ -100,10 +123,33 @@ const LinkCardImpl: FC<LinkCardProps> = (props) => {
styles['card-grid'],
(loading || isError) && styles['skeleton'],
isError && styles['error'],
'group',
className,
)}
onClick={handleCanPeek}
onMouseMove={handleMouseMove}
>
{cardInfo?.color && (
<>
<div
className="absolute inset-0 z-0"
style={{
backgroundColor: cardInfo?.color,
opacity: 0.06,
}}
/>
<m.div
layout
className="absolute inset-0 z-0 opacity-0 duration-500 group-hover:opacity-100"
style={
{
'--spotlight-color': `${cardInfo?.color}50`,
background,
} as any
}
/>
</>
)}
<span className={styles['contents']}>
<span className={styles['title']}>{cardInfo?.title}</span>
<span className={styles['desc']}>{cardInfo?.desc}</span>
Expand Down Expand Up @@ -137,16 +183,7 @@ const LinkCardSkeleton = () => {

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

Expand All @@ -158,7 +195,7 @@ type FetchObject = {
function validTypeAndFetchFunction(source: LinkCardSource, id: string) {
const fetchDataFunctions = {
[LinkCardSource.MixSpace]: fetchMxSpaceData,
[LinkCardSource.GHRepo]: fetchGitHubData,
[LinkCardSource.GHRepo]: fetchGitHubRepoData,
[LinkCardSource.GHCommit]: fetchGitHubCommitData,
[LinkCardSource.GHPr]: fetchGitHubPRData,
[LinkCardSource.Self]: fetchMxSpaceData,
Expand All @@ -173,7 +210,7 @@ function validTypeAndFetchFunction(source: LinkCardSource, id: string) {
return { isValid, fetchFn: isValid ? fetchFunction.fetch : null }
}

const fetchGitHubData: FetchObject = {
const fetchGitHubRepoData: FetchObject = {
isValid: (id) => {
// owner/repo
const parts = id.split('/')
Expand All @@ -191,6 +228,7 @@ const fetchGitHubData: FetchObject = {
title: data.name,
desc: data.description,
image: data.owner.avatarUrl,
color: (LanguageToColorMap as any)[data.language?.toLowerCase()],
})

setFullUrl(data.htmlUrl)
Expand Down Expand Up @@ -321,10 +359,29 @@ const fetchMxSpaceData: FetchObject = {
setFullUrl(`/notes/${nid}`)
}

const coverImage = data.cover || data.meta?.cover
let spotlightColor = ''
if (coverImage) {
const $image = new Image()
$image.src = coverImage
$image.crossOrigin = 'Anonymous'
$image.onload = () => {
setCardInfo((info) => {
if (info?.title !== data.title) return info
return { ...info, color: getDominantColor($image) }
})
}
} else {
spotlightColor = uniqolor(data.title, {
saturation: [30, 35],
lightness: [60, 70],
}).color
}
setCardInfo({
title: data.title,
desc: data.summary || `${RemoveMarkdown(data.text).slice(0, 50)}...`,
image: data.cover || data.meta?.cover || data.images?.[0]?.src,
image: coverImage || data.images?.[0]?.src,
color: spotlightColor,
})
} catch (err) {
console.error('Error fetching self data:', err)
Expand Down
36 changes: 36 additions & 0 deletions src/constants/language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export const LanguageToColorMap = {
typescript: '#2b7489',
javascript: '#f1e05a',
html: '#e34c26',
java: '#b07219',
go: '#00add8',
vue: '#2c3e50',
css: '#563d7c',
yaml: '#cb171e',
json: '#292929',
markdown: '#083fa1',
csharp: '#178600',
'c#': '#178600',
c: '#555555',
cpp: '#f34b7d',
'c++': '#f34b7d',
python: '#3572a5',
lua: '#000080',
vimscript: '#199f4b',
shell: '#89e051',
dockerfile: '#384d54',
ruby: '#701516',
php: '#4f5d95',
lisp: '#3fb68b',
kotlin: '#F18E33',
rust: '#dea584',
dart: '#00B4AB',
swift: '#ffac45',
'objective-c': '#438eff',
'objective-c++': '#6866fb',
r: '#198ce7',
matlab: '#e16737',
scala: '#c22d40',
sql: '#e38c00',
perl: '#0298c3',
}
23 changes: 0 additions & 23 deletions src/lib/calc-image.tsx

This file was deleted.

41 changes: 41 additions & 0 deletions src/lib/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export const calculateDimensions = ({
width,
height,
max,
}: {
width: number
height: number
max: { width: number; height: number }
}) => {
if (width === 0 || height === 0) throw new Error('Invalid image size')

const { width: maxW, height: maxH } = max

const wRatio = maxW / width || 1
const hRatio = maxH / height || 1

const ratio = Math.min(wRatio, hRatio, 1)

return {
width: width * ratio,
height: height * ratio,
}
}

export function getDominantColor(imageObject: HTMLImageElement) {
const canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d')!

canvas.width = 1
canvas.height = 1

// draw the image to one pixel and let the browser find the dominant color
ctx.drawImage(imageObject, 0, 0, 1, 1)

// get pixel color
const i = ctx.getImageData(0, 0, 1, 1).data

return `#${((1 << 24) + (i[0] << 16) + (i[1] << 8) + i[2])
.toString(16)
.slice(1)}`
}

1 comment on commit fefdbee

@vercel
Copy link

@vercel vercel bot commented on fefdbee Dec 23, 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
shiro-git-main-innei.vercel.app
innei.in

Please sign in to comment.