Release 2025-05-20-0D8p
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { isInputError, type ActionAccept, type ActionClient } from 'astro:actions'
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
import { CAPTCHA_LENGTH, generateCaptcha } from '../lib/captcha'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
import { Picture } from 'astro:assets'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { formatDateShort } from '../lib/timeAgo'
|
||||
|
||||
import MyPicture from './MyPicture.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
@@ -73,13 +73,12 @@ const { messages, userId, class: className, ...htmlProps } = Astro.props
|
||||
{!isCurrentUser && !isNextFromSameUser && (
|
||||
<p class="text-day-500 mb-0.5 text-xs">
|
||||
{!!message.user.picture && (
|
||||
<Picture
|
||||
<MyPicture
|
||||
src={message.user.picture}
|
||||
height={16}
|
||||
width={16}
|
||||
class="inline-block rounded-full align-[-0.33em]"
|
||||
alt=""
|
||||
formats={['jxl', 'avif', 'webp']}
|
||||
/>
|
||||
)}
|
||||
{message.user.name}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
---
|
||||
import Image from 'astro/components/Image.astro'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
@@ -19,6 +18,7 @@ import { formatDateShort } from '../lib/timeAgo'
|
||||
import BadgeSmall from './BadgeSmall.astro'
|
||||
import CommentModeration from './CommentModeration.astro'
|
||||
import CommentReply from './CommentReply.astro'
|
||||
import MyPicture from './MyPicture.astro'
|
||||
import TimeFormatted from './TimeFormatted.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
@@ -158,11 +158,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
<span class="flex items-center gap-1">
|
||||
{
|
||||
comment.author.picture && (
|
||||
<Image
|
||||
<MyPicture
|
||||
src={comment.author.picture}
|
||||
alt={`Profile for ${comment.author.displayName ?? comment.author.name}`}
|
||||
class="size-6 rounded-full bg-zinc-700 object-cover"
|
||||
loading="lazy"
|
||||
height={24}
|
||||
width={24}
|
||||
/>
|
||||
|
||||
42
web/src/components/MyPicture.astro
Normal file
42
web/src/components/MyPicture.astro
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
import { Picture } from 'astro:assets'
|
||||
|
||||
import defaultServiceImage from '../assets/fallback-service-image.jpg'
|
||||
|
||||
const fallbackImages = {
|
||||
service: defaultServiceImage,
|
||||
} as const satisfies Record<string, typeof defaultServiceImage>
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Picture>, 'src'> & {
|
||||
src: ComponentProps<typeof Picture>['src'] | null | undefined
|
||||
fallback?: keyof typeof fallbackImages
|
||||
}
|
||||
|
||||
const {
|
||||
src,
|
||||
formats = ['avif', 'webp'],
|
||||
fallback = undefined as keyof typeof fallbackImages | undefined,
|
||||
height,
|
||||
width,
|
||||
...props
|
||||
} = Astro.props
|
||||
|
||||
const fallbackImage = fallback ? fallbackImages[fallback] : undefined
|
||||
---
|
||||
|
||||
{/* eslint-disable @typescript-eslint/no-explicit-any */}
|
||||
{
|
||||
!!(src ?? fallbackImage) && (
|
||||
<Picture
|
||||
src={
|
||||
typeof src === 'string' ? new URL(src, Astro.url).href : ((src ?? fallbackImage) as unknown as string)
|
||||
}
|
||||
formats={formats}
|
||||
height={height ? Number(height) * 2 : undefined}
|
||||
width={width ? Number(width) * 2 : undefined}
|
||||
{...(props as any)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,16 +2,16 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { ImageResponse } from '@vercel/og'
|
||||
import sharp from 'sharp'
|
||||
|
||||
import defaultOGImageBg from '../assets/ogimage-bg.png'
|
||||
import defaultOGImage from '../assets/ogimage.png'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { urlWithParams } from '../lib/urls'
|
||||
|
||||
import type { APIContext } from 'astro'
|
||||
import type { Prettify } from 'ts-essentials'
|
||||
|
||||
export type GenericOgImageProps = Partial<Record<string, string>>
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// NOTE //
|
||||
// Use this website to create and preview templates //
|
||||
@@ -52,15 +52,41 @@ const defaultOptions = {
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Space Grotesk',
|
||||
weight: 400,
|
||||
style: 'normal',
|
||||
data: fs.readFileSync(
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'@fontsource',
|
||||
'space-grotesk',
|
||||
'files',
|
||||
'space-grotesk-latin-400-normal.woff'
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Space Grotesk',
|
||||
weight: 700,
|
||||
style: 'normal',
|
||||
data: fs.readFileSync(
|
||||
path.resolve(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'@fontsource',
|
||||
'space-grotesk',
|
||||
'files',
|
||||
'space-grotesk-latin-700-normal.woff'
|
||||
)
|
||||
),
|
||||
},
|
||||
],
|
||||
} as const satisfies ConstructorParameters<typeof ImageResponse>[1]
|
||||
|
||||
function absoluteUrl(url: string, context: Pick<APIContext, 'url'>) {
|
||||
return new URL(url, context.url.origin).href
|
||||
}
|
||||
|
||||
export const ogImageTemplates = {
|
||||
default: (_props: Record<never, never> = {}, context: APIContext) => {
|
||||
default: (_props: Record<never, never> = {}, context) => {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<img
|
||||
@@ -74,37 +100,278 @@ export const ogImageTemplates = {
|
||||
defaultOptions
|
||||
)
|
||||
},
|
||||
generic: ({ title }: { title?: string }, context) => {
|
||||
service: async (
|
||||
{
|
||||
title,
|
||||
description,
|
||||
categories,
|
||||
score,
|
||||
imageUrl,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
categories: {
|
||||
name: string
|
||||
icon: string
|
||||
}[]
|
||||
score: number
|
||||
imageUrl: string | null
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const scoreInfo = makeOverallScoreInfo(score, 10)
|
||||
const scoreColors = {
|
||||
'bg-score-1': '#e26136',
|
||||
'bg-score-2': '#eba370',
|
||||
'bg-score-3': '#eddb82',
|
||||
'bg-score-4': '#8de2d7',
|
||||
'bg-score-5': '#3cdd71',
|
||||
} as const satisfies Record<string, string>
|
||||
const scoreColor =
|
||||
Object.entries(scoreColors).find(([className]) => scoreInfo.classNameBg?.includes(className))?.[1] ??
|
||||
'white'
|
||||
|
||||
const PADING = 80
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
fontSize: 100,
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundImage: `url(${absoluteUrl(defaultOGImageBg.src, context)})`,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '50px 200px',
|
||||
textAlign: 'center',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
padding: PADING,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 10,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{!!imageUrl && (
|
||||
<img
|
||||
src={absoluteUrl(imageUrl, context)}
|
||||
style={{
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: 20,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ display: 'flex', paddingTop: 20 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 100,
|
||||
fontWeight: 'bold',
|
||||
color: '#3bdb78',
|
||||
fontFamily: 'Space Grotesk',
|
||||
lineHeight: 1.2,
|
||||
height: 120,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: -20,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
display: 'flex',
|
||||
gap: 50,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
flex: 1,
|
||||
justifyContent: 'space-between',
|
||||
alignSelf: 'stretch',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 30,
|
||||
color: 'white',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxHeight: 115,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 40,
|
||||
flexWrap: 'wrap',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 50,
|
||||
marginTop: 10,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{await Promise.all(
|
||||
categories.map(async (category) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10, whiteSpace: 'nowrap' }}>
|
||||
<img
|
||||
src={await iconUrl(category.icon, 50)}
|
||||
width={50}
|
||||
height={50}
|
||||
style={{ width: 50, height: 50 }}
|
||||
/>
|
||||
{category.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 150,
|
||||
color: 'black',
|
||||
height: 200,
|
||||
width: 200,
|
||||
borderRadius: 30,
|
||||
backgroundColor: scoreColor,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{score}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="white"
|
||||
width={400}
|
||||
viewBox="0 0 204 28"
|
||||
style={{ position: 'absolute', top: PADING, right: PADING }}
|
||||
>
|
||||
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
</div>
|
||||
),
|
||||
defaultOptions
|
||||
)
|
||||
},
|
||||
} as const satisfies Record<string, (props: GenericOgImageProps, context: APIContext) => ImageResponse | null>
|
||||
generic: async (
|
||||
{
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
description?: string | null
|
||||
icon?: string | null
|
||||
},
|
||||
context
|
||||
) => {
|
||||
const PADING = 80
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
color: 'white',
|
||||
backgroundImage: `url(${absoluteUrl(defaultOGImageBg.src, context)})`,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: PADING,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="white"
|
||||
width={400}
|
||||
viewBox="0 0 204 28"
|
||||
style={{ marginBottom: 'auto' }}
|
||||
>
|
||||
<path d="M1 0a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V1a1 1 0 0 0-1-1Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3H9a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zm12.82 0h2.37a1 1 0 0 1 .85.46L38 12.27l4.97-7.8A1 1 0 0 1 43.8 4h2.37a1 1 0 0 1 .85 1.54l-6.87 10.8a1 1 0 0 0-.16.53V23a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.13a1 1 0 0 0-.15-.53l-6.87-10.8A1 1 0 0 1 29.82 4ZM57 4h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H56v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H57a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h3V5a1 1 0 0 1 1-1zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V7.6l9.18 15.9c.18.3.5.5.86.5H99a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4L86.83 4.5a1 1 0 0 0-.87-.5Zm29 0a1 1 0 0 0-1 1v3h12V5a1 1 0 0 0-1-1zm11 4v12h3a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1zm-12 0V8h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V8h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V11.4l5.53 12.02a1 1 0 0 0 .91.58h3.12a1 1 0 0 0 .91-.58L176 11.4V23a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1h-3.36a1 1 0 0 0-.9.58L168 19.21l-6.73-14.63a1 1 0 0 0-.9-.58Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
|
||||
</svg>
|
||||
|
||||
<div style={{ display: 'flex', paddingTop: 20 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 100,
|
||||
fontWeight: 'bold',
|
||||
color: '#3bdb78',
|
||||
fontFamily: 'Space Grotesk',
|
||||
lineHeight: 1.2,
|
||||
height: 120,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: -20,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
style={{
|
||||
fontSize: 40,
|
||||
color: 'white',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxHeight: 200,
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
|
||||
{!!icon && (
|
||||
<img
|
||||
src={await iconUrl(icon, 200)}
|
||||
width={200}
|
||||
height={200}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: PADING,
|
||||
right: PADING,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
defaultOptions
|
||||
)
|
||||
},
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(props: any, context: APIContext) => ImageResponse | Promise<ImageResponse | null> | null
|
||||
>
|
||||
|
||||
type OgImageTemplate = keyof typeof ogImageTemplates
|
||||
type OgImageProps<T extends OgImageTemplate> = Parameters<(typeof ogImageTemplates)[T]>[0]
|
||||
// eslint-disable-next-line @typescript-eslint/sort-type-constituents
|
||||
export type OgImageAllTemplatesWithGenericProps = { template: OgImageTemplate } & GenericOgImageProps
|
||||
|
||||
export type OgImageAllTemplatesWithProps = Prettify<
|
||||
{
|
||||
@@ -119,5 +386,44 @@ export function makeOgImageUrl(
|
||||
) {
|
||||
return typeof ogImage === 'string'
|
||||
? new URL(ogImage, baseUrl).href
|
||||
: urlWithParams(new URL('/ogimage.png', baseUrl), ogImage ?? {})
|
||||
: urlWithParams(new URL('/ogimage.png', baseUrl), { data: JSON.stringify(ogImage ?? {}) })
|
||||
}
|
||||
|
||||
// Utilities ------------------------------------------------------------
|
||||
|
||||
function absoluteUrl(url: string, context: Pick<APIContext, 'url'>) {
|
||||
return new URL(url, context.url.origin).href
|
||||
}
|
||||
|
||||
async function svgUrlToBase64Png(svgUrl: string, width?: number, height?: number): Promise<string> {
|
||||
// 1. Fetch the SVG file
|
||||
const response = await fetch(svgUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch SVG: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const svgBuffer = await response.arrayBuffer()
|
||||
|
||||
// 2. Convert SVG to PNG using sharp
|
||||
let image = sharp(svgBuffer).png().negate({ alpha: false })
|
||||
if (width || height) {
|
||||
image = image.resize(width, height, {
|
||||
fit: 'contain',
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
const pngBuffer = await image.toBuffer()
|
||||
|
||||
// 3. Convert to base64 string
|
||||
const base64 = pngBuffer.toString('base64')
|
||||
return `data:image/png;base64,${base64}`
|
||||
}
|
||||
|
||||
async function iconUrl(icon: string, size = 30) {
|
||||
const [, prefix, name] = /^([^:]+):(.*)$/.exec(icon) ?? []
|
||||
if (!prefix || !name) return undefined
|
||||
const url = `https://api.iconify.design/${prefix}/${name}.svg`
|
||||
const result = await svgUrlToBase64Png(url, size, size)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
@@ -16,33 +17,6 @@ export type Props = HTMLAttributes<'div'> & {
|
||||
|
||||
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
||||
|
||||
export function makeOverallScoreInfo(score: number, total = 10) {
|
||||
const classNamesByColor = {
|
||||
red: 'bg-score-1 text-black',
|
||||
orange: 'bg-score-2 text-black',
|
||||
yellow: 'bg-score-3 text-black',
|
||||
blue: 'bg-score-4 text-black',
|
||||
green: 'bg-score-5 text-black',
|
||||
} as const satisfies Record<string, string>
|
||||
|
||||
const formattedScore = Math.round(score).toLocaleString()
|
||||
const n = score / total
|
||||
|
||||
if (n > 1) return { text: '', classNameBg: classNamesByColor.green, formattedScore }
|
||||
if (n >= 0.9 && n <= 1) return { text: 'Excellent', classNameBg: classNamesByColor.green, formattedScore }
|
||||
if (n >= 0.8 && n < 0.9) return { text: 'Very Good', classNameBg: classNamesByColor.blue, formattedScore }
|
||||
if (n >= 0.7 && n < 0.8) return { text: 'Good', classNameBg: classNamesByColor.blue, formattedScore }
|
||||
if (n >= 0.6 && n < 0.7) return { text: 'Okay', classNameBg: classNamesByColor.yellow, formattedScore }
|
||||
if (n >= 0.5 && n < 0.6) {
|
||||
return { text: 'Acceptable', classNameBg: classNamesByColor.yellow, formattedScore }
|
||||
}
|
||||
if (n >= 0.4 && n < 0.5) return { text: 'Bad', classNameBg: classNamesByColor.orange, formattedScore }
|
||||
if (n >= 0.3 && n < 0.4) return { text: 'Very Bad', classNameBg: classNamesByColor.orange, formattedScore }
|
||||
if (n >= 0.2 && n < 0.3) return { text: 'Really Bad', classNameBg: classNamesByColor.red, formattedScore }
|
||||
if (n >= 0 && n < 0.2) return { text: 'Terrible', classNameBg: classNamesByColor.red, formattedScore }
|
||||
return { text: '', classNameBg: undefined, formattedScore }
|
||||
}
|
||||
|
||||
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||
---
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Image } from 'astro:assets'
|
||||
|
||||
import defaultImage from '../assets/fallback-service-image.jpg'
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import { makeOverallScoreInfo } from './ScoreSquare.astro'
|
||||
import MyPicture from './MyPicture.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
@@ -76,9 +75,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
>
|
||||
<!-- Header with Icon and Title -->
|
||||
<div class="flex items-center gap-(--gap)">
|
||||
<Image
|
||||
src={// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
imageUrl || (defaultImage as unknown as string)}
|
||||
<MyPicture
|
||||
src={imageUrl}
|
||||
fallback="service"
|
||||
alt={name || 'Service logo'}
|
||||
class="size-12 shrink-0 rounded-sm object-contain text-white"
|
||||
width={48}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { kycLevels } from '../constants/kycLevels'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
import { type ServicesFiltersObject, type ServicesFiltersOptions } from '../pages/index.astro'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import PillsRadioGroup from './PillsRadioGroup.astro'
|
||||
import { makeOverallScoreInfo } from './ScoreSquare.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
Reference in New Issue
Block a user