Initial work for postgen
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
import {atoms as a, style as s} from '../theme/index.js'
|
||||
|
||||
export function Box({
|
||||
children,
|
||||
cx,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
cx?: Record<string, any>[]
|
||||
}) {
|
||||
return <div style={s([a.flex, a.flex_col, ...(cx ?? [])])}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
|
||||
import {atoms as a} from '../theme/index.js'
|
||||
import {Box} from './Box.js'
|
||||
|
||||
export function Row({
|
||||
children,
|
||||
gutter = 0,
|
||||
cx,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
gutter?: number
|
||||
cx?: Record<string, any>[]
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.flex_row,
|
||||
{
|
||||
marginLeft: -gutter,
|
||||
marginRight: -gutter,
|
||||
},
|
||||
...(cx || []),
|
||||
]}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function Column({
|
||||
children,
|
||||
gutter = 0,
|
||||
cx,
|
||||
width = 1,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
gutter?: number
|
||||
cx?: Record<string, any>[]
|
||||
width?: number
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.flex_col,
|
||||
{
|
||||
paddingLeft: gutter,
|
||||
paddingRight: gutter,
|
||||
width: `${width * 100}%`,
|
||||
},
|
||||
...(cx || []),
|
||||
]}>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
|
||||
import {style as s} from '../theme/index.js'
|
||||
import {Image as ImageSource} from '../util/resolvePostData.js'
|
||||
|
||||
export type ImageProps = Omit<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
'src' | 'style'
|
||||
> & {
|
||||
image: ImageSource
|
||||
cx?: Record<string, any>[]
|
||||
}
|
||||
|
||||
export function Image({image, cx, ...rest}: ImageProps) {
|
||||
const {image: src, mime = 'image/jpeg'} = image
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
src={`data:${mime};base64,${src.toString('base64')}`}
|
||||
style={cx ? s(cx) : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
|
||||
import React from 'react'
|
||||
|
||||
import {atoms as a, theme as t} from '../theme/index.js'
|
||||
import {Image as ImageSource} from '../util/resolvePostData.js'
|
||||
import {toShortUrl} from '../util/toShortUrl.js'
|
||||
import {Box} from './Box.js'
|
||||
import {Image} from './Image.js'
|
||||
import {Text} from './Text.js'
|
||||
|
||||
export function LinkCard({
|
||||
image,
|
||||
uri,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
image?: ImageSource
|
||||
uri?: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
{image && (
|
||||
<Box
|
||||
cx={[
|
||||
a.relative,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
{paddingTop: (630 / 1200) * 100 + '%'},
|
||||
]}>
|
||||
<Image
|
||||
image={image}
|
||||
cx={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box cx={[a.p_md, t.atoms.bg]}>
|
||||
{uri && (
|
||||
<Text cx={[a.text_xs, a.pb_sm, t.atoms.text_contrast_medium]}>
|
||||
{toShortUrl(uri)}
|
||||
</Text>
|
||||
)}
|
||||
<Text cx={[a.text_md, a.font_bold, a.pb_xs]}>{title}</Text>
|
||||
<Text cx={[a.text_sm, a.leading_snug]}>{description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
/* eslint-disable bsky-internal/avoid-unwrapped-text, react-native-a11y/has-valid-accessibility-ignores-invert-colors */
|
||||
import React from 'react'
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
AppBskyGraphStarterpack,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {atoms as a, gradient, theme as t} from '../theme/index.js'
|
||||
import {formatCount} from '../util/formatCount.js'
|
||||
import {formatDate} from '../util/formatDate.js'
|
||||
import {getStarterPackImageUri} from '../util/getStarterPackImageUri.js'
|
||||
import {Image as ImageSource, PostData} from '../util/resolvePostData.js'
|
||||
import {Box} from './Box.js'
|
||||
import * as Grid from './Grid.js'
|
||||
import {CircleInfo} from './icons/CircleInfo.js'
|
||||
import {Heart} from './icons/Heart.js'
|
||||
import {Logomark} from './icons/Logomark.js'
|
||||
import {Logotype} from './icons/Logotype.js'
|
||||
import {Repost} from './icons/Repost.js'
|
||||
import {Image} from './Image.js'
|
||||
import {LinkCard} from './LinkCard.js'
|
||||
import {RichText} from './RichText.js'
|
||||
import {Text} from './Text.js'
|
||||
|
||||
export function Post({
|
||||
post,
|
||||
data,
|
||||
}: {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
data: PostData
|
||||
}) {
|
||||
if (AppBskyFeedPost.isRecord(post.record)) {
|
||||
const avatar = data.images.get(post.author.avatar)
|
||||
const text = post.record.text
|
||||
const rt = data.texts.get(text)
|
||||
const hasInteractions = post.likeCount > 0 || post.repostCount > 0
|
||||
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.flex,
|
||||
a.align_center,
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.p_3xl,
|
||||
{
|
||||
backgroundImage: `linear-gradient(to bottom, ${gradient('sky')})`,
|
||||
},
|
||||
]}>
|
||||
<Box
|
||||
cx={[a.flex, a.flex_col, a.w_full, a.p_xl, a.rounded_md, t.atoms.bg]}>
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_sm, a.pb_sm]}>
|
||||
<Box
|
||||
cx={[
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
},
|
||||
]}>
|
||||
{avatar && <Image height="100%" width="100%" image={avatar} />}
|
||||
</Box>
|
||||
<Box cx={[a.flex_col]}>
|
||||
<Text cx={[a.text_md, a.font_bold, a.pb_2xs]}>
|
||||
{post.author.displayName || post.author.handle}
|
||||
</Text>
|
||||
<Text cx={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
@{post.author.handle}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{rt && <RichText value={rt} />}
|
||||
|
||||
{post.embed && (
|
||||
<Box cx={[a.pt_md]}>
|
||||
<Embeds embed={post.embed} data={data} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box cx={[a.flex_row, a.align_center, a.justify_between, a.pt_lg]}>
|
||||
<Text cx={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||
{formatDate(post.record.createdAt)}
|
||||
</Text>
|
||||
|
||||
{!hasInteractions && <Logo />}
|
||||
</Box>
|
||||
|
||||
{hasInteractions && (
|
||||
<Box cx={[a.pt_md]}>
|
||||
<Box
|
||||
cx={[
|
||||
a.w_full,
|
||||
a.pb_md,
|
||||
a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
]}
|
||||
/>
|
||||
|
||||
<Box cx={[a.flex_row, a.align_center, a.justify_between]}>
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_2xl]}>
|
||||
{post.likeCount > 0 && (
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Heart size={22} fill={t.palette.red_400} />
|
||||
<Box
|
||||
cx={[
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{formatCount(post.likeCount)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{post.repostCount > 0 && (
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Repost size={22} fill={t.palette.green_600} />
|
||||
<Box
|
||||
cx={[
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{formatCount(post.repostCount)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Logo />
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<Box cx={[a.flex_row, a.align_center]}>
|
||||
<Logomark size={20} fill={t.palette.primary_500} />
|
||||
<Box
|
||||
cx={[
|
||||
{
|
||||
paddingTop: '3px',
|
||||
paddingLeft: '6px',
|
||||
},
|
||||
]}>
|
||||
<Logotype size={64} fill={t.palette.contrast_800} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function Embeds({
|
||||
embed,
|
||||
data,
|
||||
hideNestedEmbeds,
|
||||
}: {
|
||||
embed: AppBskyFeedDefs.PostView['embed']
|
||||
data: PostData
|
||||
hideNestedEmbeds?: boolean
|
||||
}) {
|
||||
if (AppBskyEmbedExternal.isView(embed)) {
|
||||
const {title, description, uri, thumb} = embed.external
|
||||
const image = data.images.get(thumb)
|
||||
return (
|
||||
<LinkCard
|
||||
image={image}
|
||||
title={title}
|
||||
description={description}
|
||||
uri={uri}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (AppBskyEmbedImages.isView(embed)) {
|
||||
const {images} = embed
|
||||
|
||||
if (images.length > 0) {
|
||||
const imgs = images.map(({fullsize}) => data.images.get(fullsize))
|
||||
if (imgs.length === 1) {
|
||||
return (
|
||||
<Box cx={[a.rounded_sm, a.w_full, a.overflow_hidden]}>
|
||||
<Image image={imgs[0]} />
|
||||
</Box>
|
||||
)
|
||||
} else if (imgs.length === 2) {
|
||||
return (
|
||||
<Grid.Row gutter={a.p_xs.padding}>
|
||||
<Grid.Column gutter={a.p_xs.padding} width={1 / 2}>
|
||||
<SquareImage image={imgs[0]} />
|
||||
</Grid.Column>
|
||||
<Grid.Column gutter={a.p_xs.padding} width={1 / 2}>
|
||||
<SquareImage image={imgs[1]} />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
)
|
||||
} else if (imgs.length === 3) {
|
||||
return (
|
||||
<Grid.Row gutter={a.p_xs.padding}>
|
||||
<Grid.Column gutter={a.p_xs.padding} width={2 / 3}>
|
||||
<SquareImage image={imgs[0]} />
|
||||
</Grid.Column>
|
||||
<Grid.Column gutter={a.p_xs.padding} width={1 / 3} cx={[a.gap_sm]}>
|
||||
<SquareImage image={imgs[1]} />
|
||||
<SquareImage image={imgs[2]} />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Grid.Row gutter={a.p_xs.padding}>
|
||||
<Grid.Column gutter={a.p_xs.padding} width={1 / 2} cx={[a.gap_sm]}>
|
||||
<SquareImage image={imgs[0]} />
|
||||
<SquareImage image={imgs[1]} />
|
||||
</Grid.Column>
|
||||
<Grid.Column gutter={a.p_xs.padding} width={1 / 2} cx={[a.gap_sm]}>
|
||||
<SquareImage image={imgs[2]} />
|
||||
<SquareImage image={imgs[3]} />
|
||||
</Grid.Column>
|
||||
</Grid.Row>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecord.isView(embed)) {
|
||||
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
|
||||
return <FeedCard embed={embed.record} data={data} />
|
||||
}
|
||||
if (AppBskyGraphDefs.isListView(embed.record)) {
|
||||
return <ListCard embed={embed.record} data={data} />
|
||||
}
|
||||
if (
|
||||
AppBskyGraphDefs.isStarterPackViewBasic(embed.record) &&
|
||||
AppBskyGraphStarterpack.isRecord(embed.record.record)
|
||||
) {
|
||||
const uri = getStarterPackImageUri(embed.record)
|
||||
const {name, description} = embed.record.record
|
||||
const image = data.images.get(uri)
|
||||
return <LinkCard image={image} title={name} description={description} />
|
||||
}
|
||||
return <QuoteEmbed embed={embed} data={data} />
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||
return (
|
||||
<Box cx={[a.gap_md]}>
|
||||
<Embeds embed={embed.media} data={data} />
|
||||
{!hideNestedEmbeds && <QuoteEmbed embed={embed.record} data={data} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function FeedCard({
|
||||
embed,
|
||||
data,
|
||||
}: {
|
||||
embed: AppBskyFeedDefs.GeneratorView
|
||||
data: PostData
|
||||
}) {
|
||||
const {avatar, displayName, likeCount, creator} = embed
|
||||
const image = data.images.get(avatar)
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.w_full,
|
||||
a.gap_sm,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{image && (
|
||||
<Image
|
||||
image={image}
|
||||
cx={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
width: '40px',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Box cx={[a.pt_2xs]}>
|
||||
<Text cx={[a.text_md, a.font_bold, a.pb_2xs]}>{displayName}</Text>
|
||||
<Text cx={[a.text_sm, a.leading_snug]}>By @{creator.handle}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{likeCount > 1 && (
|
||||
<Text cx={[a.text_sm, a.leading_snug]}>
|
||||
Liked by {formatCount(likeCount)} users
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListCard({
|
||||
embed,
|
||||
data,
|
||||
}: {
|
||||
embed: AppBskyGraphDefs.ListView
|
||||
data: PostData
|
||||
}) {
|
||||
const {avatar, name, creator} = embed
|
||||
const image = data.images.get(avatar)
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.w_full,
|
||||
a.gap_sm,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{image && (
|
||||
<Image
|
||||
image={image}
|
||||
cx={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
width: '40px',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Box cx={[a.pt_2xs]}>
|
||||
<Text cx={[a.text_md, a.font_bold, a.pb_2xs]}>{name}</Text>
|
||||
<Text cx={[a.text_sm, a.leading_snug]}>By @{creator.handle}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function SquareImage({image}: {image: ImageSource}) {
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.relative,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_25,
|
||||
{paddingTop: '100%'},
|
||||
]}>
|
||||
<Image
|
||||
image={image}
|
||||
cx={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuoteEmbed({
|
||||
embed,
|
||||
data,
|
||||
}: {
|
||||
embed: AppBskyEmbedRecord.View
|
||||
data: PostData
|
||||
}) {
|
||||
if (
|
||||
AppBskyEmbedRecord.isViewRecord(embed.record) &&
|
||||
AppBskyFeedPost.isRecord(embed.record.value) &&
|
||||
AppBskyFeedPost.validateRecord(embed.record.value).success
|
||||
) {
|
||||
const {author, value: post, embeds} = embed.record
|
||||
const avatar = data.images.get(author.avatar)
|
||||
const rt = data.texts.get(post.text)
|
||||
const notPublic = author.labels.some(l => l.val === `!no-unauthenticated`)
|
||||
|
||||
if (notPublic) {
|
||||
return (
|
||||
<NotQuoteEmbed>
|
||||
The author of the quoted post has requested their posts not be
|
||||
displayed on external sites
|
||||
</NotQuoteEmbed>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.w_full,
|
||||
a.p_md,
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
]}>
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_xs, a.pb_sm]}>
|
||||
<Box
|
||||
cx={[
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
},
|
||||
]}>
|
||||
{avatar && <Image height="100%" width="100%" image={avatar} />}
|
||||
</Box>
|
||||
<Box cx={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<Text cx={[a.text_sm, a.font_bold]}>
|
||||
{author.displayName || author.handle}
|
||||
</Text>
|
||||
<Text cx={[a.text_xs, t.atoms.text_contrast_medium]}>
|
||||
@{author.handle}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{rt && (
|
||||
<Box>
|
||||
<RichText value={rt} cx={[a.text_sm]} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{Boolean(embeds && embeds.length) && (
|
||||
<Box cx={[a.pt_sm]}>
|
||||
<Embeds embed={embeds[0]} data={data} hideNestedEmbeds />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
|
||||
return <NotQuoteEmbed>Quoted post is blocked</NotQuoteEmbed>
|
||||
} else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
|
||||
return (
|
||||
<NotQuoteEmbed>
|
||||
Quoted post not found, it may have been deleted.
|
||||
</NotQuoteEmbed>
|
||||
)
|
||||
} else if (AppBskyEmbedRecord.isViewDetached(embed.record)) {
|
||||
return <NotQuoteEmbed>Quoted post detached by author</NotQuoteEmbed>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function NotQuoteEmbed({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<Box
|
||||
cx={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<CircleInfo size={20} fill={t.atoms.text_contrast_low.color} />
|
||||
<Text cx={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
import {AppBskyRichtextFacet, RichText as RichTextApi} from '@atproto/api'
|
||||
|
||||
import {atoms as a, style as s, theme as t} from '../theme/index.js'
|
||||
import {toShortUrl} from '../util/toShortUrl.js'
|
||||
import {Text} from './Text.js'
|
||||
|
||||
export function RichText({
|
||||
value,
|
||||
disableLinks,
|
||||
cx,
|
||||
}: {
|
||||
value: RichTextApi
|
||||
disableLinks?: boolean
|
||||
cx?: Record<string, any>[]
|
||||
}) {
|
||||
const {facets} = value
|
||||
const baseStyles = [
|
||||
a.leading_snug,
|
||||
{
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
cx ? s(cx) : {},
|
||||
]
|
||||
const linkStyles = [
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
]
|
||||
|
||||
if (!facets?.length) {
|
||||
// if (isOnlyEmoji(text)) {
|
||||
// const fontSize =
|
||||
// (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
|
||||
// return (
|
||||
// <Text
|
||||
// selectable={selectable}
|
||||
// testID={testID}
|
||||
// style={[baseStyles, {fontSize}]}
|
||||
// // @ts-ignore web only -prf
|
||||
// dataSet={WORD_WRAP}>
|
||||
// {text}
|
||||
// </Text>
|
||||
// )
|
||||
// }
|
||||
}
|
||||
|
||||
const els = []
|
||||
let key = 0
|
||||
// N.B. must access segments via `richText.segments`, not via destructuring
|
||||
for (const segment of value.segments()) {
|
||||
const {text, link, mention, tag} = segment
|
||||
if (
|
||||
mention &&
|
||||
AppBskyRichtextFacet.validateMention(mention).success &&
|
||||
!disableLinks
|
||||
) {
|
||||
els.push(
|
||||
<span key={key} style={s(linkStyles)}>
|
||||
{text}
|
||||
</span>,
|
||||
)
|
||||
} else if (link && AppBskyRichtextFacet.validateLink(link).success) {
|
||||
const url = toShortUrl(text)
|
||||
if (disableLinks) {
|
||||
els.push(url)
|
||||
} else {
|
||||
els.push(
|
||||
<span key={key} style={s(linkStyles)}>
|
||||
{url}
|
||||
</span>,
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
!disableLinks &&
|
||||
tag &&
|
||||
AppBskyRichtextFacet.validateTag(tag).success
|
||||
) {
|
||||
els.push(
|
||||
<span key={key} style={s(linkStyles)}>
|
||||
{segment.text}
|
||||
</span>,
|
||||
)
|
||||
} else {
|
||||
els.push(segment.text)
|
||||
}
|
||||
key++
|
||||
}
|
||||
|
||||
return <Text cx={baseStyles}>{els}</Text>
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export function StarterPack(props: {
|
||||
color: 'white',
|
||||
padding: 60,
|
||||
fontSize: 40,
|
||||
fontWeight: '700',
|
||||
}}>
|
||||
JOIN THE CONVERSATION
|
||||
</div>
|
||||
@@ -134,6 +135,7 @@ export function StarterPack(props: {
|
||||
fontSize: isLongTitle ? 55 : 65,
|
||||
display: 'flex',
|
||||
textAlign: 'center',
|
||||
fontWeight: '700',
|
||||
}}>
|
||||
{record?.name || 'Starter Pack'}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
|
||||
import {atoms as a, style as s, theme as t} from '../theme/index.js'
|
||||
|
||||
export function Text({
|
||||
children,
|
||||
cx,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
cx?: Record<string, any>[]
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={s([
|
||||
a.flex,
|
||||
a.flex_col,
|
||||
a.font_normal,
|
||||
a.leading_tight,
|
||||
a.tracking_wide,
|
||||
t.atoms.text,
|
||||
...(cx ?? []),
|
||||
])}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export function CircleInfo({size, fill}: {size: number; fill?: string}) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-1a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-4a1 1 0 0 1-1-1Zm1-3a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Heart({size, fill}: {size: number; fill?: string}) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Logomark({size, fill}: {size: number; fill?: string}) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 500 441" width={size}>
|
||||
<path
|
||||
fill={fill}
|
||||
d="M108.382 29.634C165.705 72.668 227.36 159.93 250 206.754c22.64-46.824 84.295-134.084 141.618-177.12C432.98-1.42 500-25.447 500 51.008c0 15.269-8.755 128.269-13.889 146.615-17.848 63.779-82.883 80.047-140.735 70.2 101.122 17.211 126.846 74.218 71.291 131.225-105.511 108.268-151.649-27.165-163.471-61.867-2.167-6.361-3.181-9.338-3.196-6.807-.015-2.531-1.029.446-3.196 6.807-11.822 34.702-57.96 170.135-163.47 61.867-55.556-57.007-29.832-114.014 71.29-131.225-57.852 9.847-122.887-6.421-140.735-70.2C8.755 179.278 0 66.278 0 51.009 0-25.446 67.02-1.419 108.382 29.634Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Logotype({size, fill}: {size: number; fill?: string}) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 398 108" width={size}>
|
||||
<path
|
||||
fill={fill}
|
||||
d="M53.139 40.276c9.306 3.396 14.247 11.24 14.247 20.022 0 14.87-9.766 23.886-28.494 23.886H.632V.818h36.996c17.809 0 26.426 9.25 26.426 21.544 0 8.196-3.677 14.167-10.915 17.914Zm-16.66-26.462H16.143v21.779h20.336c7.928 0 12.179-4.215 12.179-11.24 0-6.44-4.366-10.539-12.179-10.539ZM16.143 71.07h21.945c8.732 0 13.442-4.098 13.442-11.474 0-7.728-4.48-11.592-13.442-11.592H16.143V71.07ZM88.892 84.184H74.415V.818h14.477v83.366ZM136.892 57.605V23.767h14.477v60.417h-14.017v-8.782c-4.481 6.791-10.686 10.187-18.613 10.187-12.524 0-20.681-7.728-20.681-21.778V23.767h14.476v37.585c0 7.61 3.677 11.474 11.145 11.474 7.009 0 13.213-5.268 13.213-15.22ZM217.264 55.03v3.512h-44.35c1.034 10.42 6.664 15.572 15.396 15.572 6.664 0 11.145-2.927 13.558-8.664h13.902c-3.102 12.294-13.443 20.139-27.575 20.139-8.847 0-15.97-2.927-21.37-8.665-5.4-5.737-8.158-13.347-8.158-22.949 0-9.484 2.643-17.094 8.043-22.949 5.4-5.737 12.409-8.664 21.256-8.664 8.961 0 16.085 3.044 21.37 9.016 5.285 5.971 7.928 13.933 7.928 23.651Zm-29.413-21.194c-7.928 0-13.443 4.684-14.822 14.402h29.758c-1.264-8.781-6.549-14.402-14.936-14.402ZM249.35 85.823c-17.234 0-26.311-6.908-27.115-20.841h14.132c.804 7.493 4.481 10.303 13.213 10.303 7.813 0 11.719-2.459 11.719-7.26 0-4.331-2.757-6.439-11.604-7.961l-6.779-1.17c-12.983-2.226-19.417-8.314-19.417-18.267 0-11.357 8.847-18.265 24.587-18.265 16.89 0 25.622 6.79 26.196 20.49H260.61c-.345-7.376-4.596-9.952-12.524-9.952-6.894 0-10.34 2.342-10.34 7.025 0 4.215 2.987 6.089 9.881 7.376l7.468 1.171c14.362 2.693 20.566 8.08 20.566 18.383 0 12.177-9.651 18.968-26.311 18.968ZM339.201 84.184h-16.545l-17.235-28.101-8.961 9.133v18.968h-14.247V.818h14.247v48.006l24.128-25.057h17.234l-22.405 22.832 23.784 37.585ZM378.008 38.988l4.826-15.221H398L375.136 89.1c-2.413 6.674-5.4 11.475-9.192 14.168-3.791 2.693-9.191 3.981-16.315 3.981-2.413 0-4.481-.117-6.319-.351V95.307h5.515c6.549 0 9.766-4.098 9.766-9.718 0-2.81-.919-6.908-2.758-12.177l-17.234-49.645h15.626l4.825 15.104c3.562 11.358 6.664 22.598 9.422 33.721 2.528-9.6 5.745-20.841 9.536-33.604Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export function Repost({size, fill}: {size: number; fill?: string}) {
|
||||
return (
|
||||
<svg fill="none" viewBox="0 0 24 24" width={size} height={size}>
|
||||
<path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M17.957 2.293a1 1 0 1 0-1.414 1.414L17.836 5H6a3 3 0 0 0-3 3v3a1 1 0 1 0 2 0V8a1 1 0 0 1 1-1h11.836l-1.293 1.293a1 1 0 0 0 1.414 1.414l2.47-2.47a1.75 1.75 0 0 0 0-2.474l-2.47-2.47ZM20 12a1 1 0 0 1 1 1v3a3 3 0 0 1-3 3H6.164l1.293 1.293a1 1 0 1 1-1.414 1.414l-2.47-2.47a1.75 1.75 0 0 1 0-2.474l2.47-2.47a1 1 0 0 1 1.414 1.414L6.164 17H18a1 1 0 0 0 1-1v-3a1 1 0 0 1 1-1Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -31,9 +31,12 @@ export class AppContext {
|
||||
const fontDirectory = path.join(__DIRNAME, 'assets', 'fonts')
|
||||
const fontFiles = readdirSync(fontDirectory)
|
||||
const fonts = fontFiles.map(file => {
|
||||
const basename = path.basename(file, path.extname(file))
|
||||
const [weight, name] = basename.split('-')
|
||||
return {
|
||||
name: path.basename(file, path.extname(file)),
|
||||
name,
|
||||
data: readFileSync(path.join(fontDirectory, file)),
|
||||
weight: parseInt(weight),
|
||||
}
|
||||
})
|
||||
return new AppContext({
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Express} from 'express'
|
||||
|
||||
import {AppContext} from '../context.js'
|
||||
import {default as health} from './health.js'
|
||||
import {default as post} from './post.js'
|
||||
import {default as starterPack} from './starter-pack.js'
|
||||
|
||||
export * from './util.js'
|
||||
@@ -9,5 +10,6 @@ export * from './util.js'
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
app = health(ctx, app) // GET /_health
|
||||
app = starterPack(ctx, app) // GET /start/:actor/:rkey
|
||||
app = post(ctx, app) // POST /post
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import {AppBskyFeedDefs, AppBskyFeedPost, AtUri} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {Express} from 'express'
|
||||
import satori from 'satori'
|
||||
|
||||
import {Post} from '../components/Post.js'
|
||||
import {AppContext} from '../context.js'
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {loadEmojiAsSvg} from '../util.js'
|
||||
import {resolvePostData} from '../util/resolvePostData.js'
|
||||
import {handler, originVerifyMiddleware} from './util.js'
|
||||
|
||||
const WIDTH = 600
|
||||
|
||||
export default function (ctx: AppContext, app: Express) {
|
||||
return app.get(
|
||||
'/:actor/post/:rkey',
|
||||
originVerifyMiddleware(ctx),
|
||||
handler(async (req, res) => {
|
||||
let {actor, rkey} = req.params
|
||||
let uri = AtUri.make(actor, 'app.bsky.feed.post', rkey)
|
||||
|
||||
let post: AppBskyFeedDefs.PostView
|
||||
|
||||
try {
|
||||
if (!actor.startsWith('did:')) {
|
||||
const res = await ctx.appviewAgent.resolveHandle({
|
||||
handle: actor,
|
||||
})
|
||||
actor = res.data.did
|
||||
}
|
||||
uri = AtUri.make(actor, 'app.bsky.feed.post', rkey)
|
||||
const {data} = await ctx.appviewAgent.getPosts({
|
||||
uris: [uri.toString()],
|
||||
})
|
||||
post = data.posts.at(0)
|
||||
|
||||
const notPublic = post.author.labels.some(
|
||||
l => l.val === `!no-unauthenticated`,
|
||||
)
|
||||
if (notPublic) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
} catch (err) {
|
||||
httpLogger.warn({err, uri: uri.toString()}, 'could not fetch post')
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
|
||||
if (!AppBskyFeedPost.isRecord(post.record)) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
|
||||
const data = await resolvePostData(post, ctx.appviewAgent)
|
||||
const svg = await satori(<Post post={post} data={data} />, {
|
||||
fonts: ctx.fonts,
|
||||
width: WIDTH,
|
||||
loadAdditionalAsset: async (code, text) => {
|
||||
if (code === 'emoji') {
|
||||
return await loadEmojiAsSvg(text)
|
||||
}
|
||||
},
|
||||
})
|
||||
const output = await resvg.renderAsync(svg, {
|
||||
fitTo: {
|
||||
mode: 'width',
|
||||
value: WIDTH * 2,
|
||||
},
|
||||
logLevel: 'trace',
|
||||
})
|
||||
res.statusCode = 200
|
||||
res.setHeader('content-type', 'image/png')
|
||||
res.setHeader('cdn-tag', [...data.images.keys()].join(','))
|
||||
return res.end(output.asPng())
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import * as tokens from './tokens.js'
|
||||
|
||||
export const atoms = {
|
||||
block: {
|
||||
display: 'block',
|
||||
},
|
||||
inline_block: {
|
||||
display: 'inline-block',
|
||||
},
|
||||
inline: {
|
||||
display: 'inline',
|
||||
},
|
||||
/*
|
||||
* Positioning
|
||||
*/
|
||||
fixed: {
|
||||
position: 'absolute',
|
||||
},
|
||||
absolute: {
|
||||
position: 'absolute',
|
||||
},
|
||||
relative: {
|
||||
position: 'relative',
|
||||
},
|
||||
inset_0: {
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
|
||||
overflow_hidden: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
/*
|
||||
* Width
|
||||
*/
|
||||
w_full: {
|
||||
width: '100%',
|
||||
},
|
||||
h_full: {
|
||||
height: '100%',
|
||||
},
|
||||
h_full_vh: {
|
||||
height: '100vh',
|
||||
},
|
||||
|
||||
/*
|
||||
* Theme-independent bg colors
|
||||
*/
|
||||
bg_transparent: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
|
||||
/*
|
||||
* Border radius
|
||||
*/
|
||||
rounded_2xs: {
|
||||
borderRadius: tokens.borderRadius._2xs,
|
||||
},
|
||||
rounded_xs: {
|
||||
borderRadius: tokens.borderRadius.xs,
|
||||
},
|
||||
rounded_sm: {
|
||||
borderRadius: tokens.borderRadius.sm,
|
||||
},
|
||||
rounded_md: {
|
||||
borderRadius: tokens.borderRadius.md,
|
||||
},
|
||||
rounded_full: {
|
||||
borderRadius: tokens.borderRadius.full,
|
||||
},
|
||||
|
||||
/*
|
||||
* Flex
|
||||
*/
|
||||
gap_2xs: {
|
||||
gap: tokens.space._2xs,
|
||||
},
|
||||
gap_xs: {
|
||||
gap: tokens.space.xs,
|
||||
},
|
||||
gap_sm: {
|
||||
gap: tokens.space.sm,
|
||||
},
|
||||
gap_md: {
|
||||
gap: tokens.space.md,
|
||||
},
|
||||
gap_lg: {
|
||||
gap: tokens.space.lg,
|
||||
},
|
||||
gap_xl: {
|
||||
gap: tokens.space.xl,
|
||||
},
|
||||
gap_2xl: {
|
||||
gap: tokens.space._2xl,
|
||||
},
|
||||
gap_3xl: {
|
||||
gap: tokens.space._3xl,
|
||||
},
|
||||
gap_4xl: {
|
||||
gap: tokens.space._4xl,
|
||||
},
|
||||
gap_5xl: {
|
||||
gap: tokens.space._5xl,
|
||||
},
|
||||
flex: {
|
||||
display: 'flex',
|
||||
},
|
||||
flex_col: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
flex_row: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
flex_col_reverse: {
|
||||
flexDirection: 'column-reverse',
|
||||
},
|
||||
flex_row_reverse: {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
flex_wrap: {
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
flex_0: {
|
||||
flex: '0 0 auto',
|
||||
},
|
||||
flex_1: {
|
||||
flex: 1,
|
||||
},
|
||||
flex_grow: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
flex_shrink: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
flex_shrink_0: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
justify_start: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
justify_center: {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
justify_between: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
justify_end: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
align_center: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
align_start: {
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
align_end: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
align_baseline: {
|
||||
alignItems: 'baseline',
|
||||
},
|
||||
align_stretch: {
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
self_auto: {
|
||||
alignSelf: 'auto',
|
||||
},
|
||||
self_start: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
self_end: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
self_center: {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
self_stretch: {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
self_baseline: {
|
||||
alignSelf: 'baseline',
|
||||
},
|
||||
|
||||
/*
|
||||
* Text
|
||||
*/
|
||||
text_left: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
text_center: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
text_right: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
text_2xs: {
|
||||
fontSize: tokens.fontSize._2xs,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_xs: {
|
||||
fontSize: tokens.fontSize.xs,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_sm: {
|
||||
fontSize: tokens.fontSize.sm,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_md: {
|
||||
fontSize: tokens.fontSize.md,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_lg: {
|
||||
fontSize: tokens.fontSize.lg,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_xl: {
|
||||
fontSize: tokens.fontSize.xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_2xl: {
|
||||
fontSize: tokens.fontSize._2xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_3xl: {
|
||||
fontSize: tokens.fontSize._3xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_4xl: {
|
||||
fontSize: tokens.fontSize._4xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_5xl: {
|
||||
fontSize: tokens.fontSize._5xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
leading_tight: {
|
||||
lineHeight: '1.15',
|
||||
},
|
||||
leading_snug: {
|
||||
lineHeight: '1.3',
|
||||
},
|
||||
leading_normal: {
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
tracking_normal: {
|
||||
letterSpacing: 0,
|
||||
},
|
||||
tracking_wide: {
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
font_normal: {
|
||||
fontWeight: tokens.fontWeight.normal,
|
||||
},
|
||||
font_semibold: {
|
||||
fontWeight: tokens.fontWeight.semibold,
|
||||
},
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.bold,
|
||||
},
|
||||
font_heavy: {
|
||||
fontWeight: tokens.fontWeight.heavy,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
mono: {
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
|
||||
/*
|
||||
* Border
|
||||
*/
|
||||
border_0: {
|
||||
borderWidth: '0px',
|
||||
},
|
||||
border: {
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
},
|
||||
border_t: {
|
||||
borderTopWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
},
|
||||
border_b: {
|
||||
borderBottomWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
},
|
||||
border_l: {
|
||||
borderLeftWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
},
|
||||
border_r: {
|
||||
borderRightWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
},
|
||||
|
||||
/*
|
||||
* Padding
|
||||
*/
|
||||
p_0: {
|
||||
padding: 0,
|
||||
},
|
||||
p_2xs: {
|
||||
padding: tokens.space._2xs,
|
||||
},
|
||||
p_xs: {
|
||||
padding: tokens.space.xs,
|
||||
},
|
||||
p_sm: {
|
||||
padding: tokens.space.sm,
|
||||
},
|
||||
p_md: {
|
||||
padding: tokens.space.md,
|
||||
},
|
||||
p_lg: {
|
||||
padding: tokens.space.lg,
|
||||
},
|
||||
p_xl: {
|
||||
padding: tokens.space.xl,
|
||||
},
|
||||
p_2xl: {
|
||||
padding: tokens.space._2xl,
|
||||
},
|
||||
p_3xl: {
|
||||
padding: tokens.space._3xl,
|
||||
},
|
||||
p_4xl: {
|
||||
padding: tokens.space._4xl,
|
||||
},
|
||||
p_5xl: {
|
||||
padding: tokens.space._5xl,
|
||||
},
|
||||
px_0: {
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
},
|
||||
px_2xs: {
|
||||
paddingLeft: tokens.space._2xs,
|
||||
paddingRight: tokens.space._2xs,
|
||||
},
|
||||
px_xs: {
|
||||
paddingLeft: tokens.space.xs,
|
||||
paddingRight: tokens.space.xs,
|
||||
},
|
||||
px_sm: {
|
||||
paddingLeft: tokens.space.sm,
|
||||
paddingRight: tokens.space.sm,
|
||||
},
|
||||
px_md: {
|
||||
paddingLeft: tokens.space.md,
|
||||
paddingRight: tokens.space.md,
|
||||
},
|
||||
px_lg: {
|
||||
paddingLeft: tokens.space.lg,
|
||||
paddingRight: tokens.space.lg,
|
||||
},
|
||||
px_xl: {
|
||||
paddingLeft: tokens.space.xl,
|
||||
paddingRight: tokens.space.xl,
|
||||
},
|
||||
px_2xl: {
|
||||
paddingLeft: tokens.space._2xl,
|
||||
paddingRight: tokens.space._2xl,
|
||||
},
|
||||
px_3xl: {
|
||||
paddingLeft: tokens.space._3xl,
|
||||
paddingRight: tokens.space._3xl,
|
||||
},
|
||||
px_4xl: {
|
||||
paddingLeft: tokens.space._4xl,
|
||||
paddingRight: tokens.space._4xl,
|
||||
},
|
||||
px_5xl: {
|
||||
paddingLeft: tokens.space._5xl,
|
||||
paddingRight: tokens.space._5xl,
|
||||
},
|
||||
py_0: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
py_2xs: {
|
||||
paddingTop: tokens.space._2xs,
|
||||
paddingBottom: tokens.space._2xs,
|
||||
},
|
||||
py_xs: {
|
||||
paddingTop: tokens.space.xs,
|
||||
paddingBottom: tokens.space.xs,
|
||||
},
|
||||
py_sm: {
|
||||
paddingTop: tokens.space.sm,
|
||||
paddingBottom: tokens.space.sm,
|
||||
},
|
||||
py_md: {
|
||||
paddingTop: tokens.space.md,
|
||||
paddingBottom: tokens.space.md,
|
||||
},
|
||||
py_lg: {
|
||||
paddingTop: tokens.space.lg,
|
||||
paddingBottom: tokens.space.lg,
|
||||
},
|
||||
py_xl: {
|
||||
paddingTop: tokens.space.xl,
|
||||
paddingBottom: tokens.space.xl,
|
||||
},
|
||||
py_2xl: {
|
||||
paddingTop: tokens.space._2xl,
|
||||
paddingBottom: tokens.space._2xl,
|
||||
},
|
||||
py_3xl: {
|
||||
paddingTop: tokens.space._3xl,
|
||||
paddingBottom: tokens.space._3xl,
|
||||
},
|
||||
py_4xl: {
|
||||
paddingTop: tokens.space._4xl,
|
||||
paddingBottom: tokens.space._4xl,
|
||||
},
|
||||
py_5xl: {
|
||||
paddingTop: tokens.space._5xl,
|
||||
paddingBottom: tokens.space._5xl,
|
||||
},
|
||||
pt_0: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
pt_2xs: {
|
||||
paddingTop: tokens.space._2xs,
|
||||
},
|
||||
pt_xs: {
|
||||
paddingTop: tokens.space.xs,
|
||||
},
|
||||
pt_sm: {
|
||||
paddingTop: tokens.space.sm,
|
||||
},
|
||||
pt_md: {
|
||||
paddingTop: tokens.space.md,
|
||||
},
|
||||
pt_lg: {
|
||||
paddingTop: tokens.space.lg,
|
||||
},
|
||||
pt_xl: {
|
||||
paddingTop: tokens.space.xl,
|
||||
},
|
||||
pt_2xl: {
|
||||
paddingTop: tokens.space._2xl,
|
||||
},
|
||||
pt_3xl: {
|
||||
paddingTop: tokens.space._3xl,
|
||||
},
|
||||
pt_4xl: {
|
||||
paddingTop: tokens.space._4xl,
|
||||
},
|
||||
pt_5xl: {
|
||||
paddingTop: tokens.space._5xl,
|
||||
},
|
||||
pb_0: {
|
||||
paddingBottom: 0,
|
||||
},
|
||||
pb_2xs: {
|
||||
paddingBottom: tokens.space._2xs,
|
||||
},
|
||||
pb_xs: {
|
||||
paddingBottom: tokens.space.xs,
|
||||
},
|
||||
pb_sm: {
|
||||
paddingBottom: tokens.space.sm,
|
||||
},
|
||||
pb_md: {
|
||||
paddingBottom: tokens.space.md,
|
||||
},
|
||||
pb_lg: {
|
||||
paddingBottom: tokens.space.lg,
|
||||
},
|
||||
pb_xl: {
|
||||
paddingBottom: tokens.space.xl,
|
||||
},
|
||||
pb_2xl: {
|
||||
paddingBottom: tokens.space._2xl,
|
||||
},
|
||||
pb_3xl: {
|
||||
paddingBottom: tokens.space._3xl,
|
||||
},
|
||||
pb_4xl: {
|
||||
paddingBottom: tokens.space._4xl,
|
||||
},
|
||||
pb_5xl: {
|
||||
paddingBottom: tokens.space._5xl,
|
||||
},
|
||||
pl_0: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
pl_2xs: {
|
||||
paddingLeft: tokens.space._2xs,
|
||||
},
|
||||
pl_xs: {
|
||||
paddingLeft: tokens.space.xs,
|
||||
},
|
||||
pl_sm: {
|
||||
paddingLeft: tokens.space.sm,
|
||||
},
|
||||
pl_md: {
|
||||
paddingLeft: tokens.space.md,
|
||||
},
|
||||
pl_lg: {
|
||||
paddingLeft: tokens.space.lg,
|
||||
},
|
||||
pl_xl: {
|
||||
paddingLeft: tokens.space.xl,
|
||||
},
|
||||
pl_2xl: {
|
||||
paddingLeft: tokens.space._2xl,
|
||||
},
|
||||
pl_3xl: {
|
||||
paddingLeft: tokens.space._3xl,
|
||||
},
|
||||
pl_4xl: {
|
||||
paddingLeft: tokens.space._4xl,
|
||||
},
|
||||
pl_5xl: {
|
||||
paddingLeft: tokens.space._5xl,
|
||||
},
|
||||
pr_0: {
|
||||
paddingRight: 0,
|
||||
},
|
||||
pr_2xs: {
|
||||
paddingRight: tokens.space._2xs,
|
||||
},
|
||||
pr_xs: {
|
||||
paddingRight: tokens.space.xs,
|
||||
},
|
||||
pr_sm: {
|
||||
paddingRight: tokens.space.sm,
|
||||
},
|
||||
pr_md: {
|
||||
paddingRight: tokens.space.md,
|
||||
},
|
||||
pr_lg: {
|
||||
paddingRight: tokens.space.lg,
|
||||
},
|
||||
pr_xl: {
|
||||
paddingRight: tokens.space.xl,
|
||||
},
|
||||
pr_2xl: {
|
||||
paddingRight: tokens.space._2xl,
|
||||
},
|
||||
pr_3xl: {
|
||||
paddingRight: tokens.space._3xl,
|
||||
},
|
||||
pr_4xl: {
|
||||
paddingRight: tokens.space._4xl,
|
||||
},
|
||||
pr_5xl: {
|
||||
paddingRight: tokens.space._5xl,
|
||||
},
|
||||
|
||||
/*
|
||||
* Text decoration
|
||||
*/
|
||||
underline: {
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
strike_through: {
|
||||
textDecorationLine: 'line-through',
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,84 @@
|
||||
export const BLUE_HUE = 211
|
||||
export const RED_HUE = 346
|
||||
export const GREEN_HUE = 152
|
||||
|
||||
/**
|
||||
* Smooth progression of lightness "stops" for generating HSL colors.
|
||||
*/
|
||||
export const COLOR_STOPS = [
|
||||
0, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 1,
|
||||
]
|
||||
|
||||
export function generateScale(start: number, end: number) {
|
||||
const range = end - start
|
||||
return COLOR_STOPS.map(stop => {
|
||||
return start + range * stop
|
||||
})
|
||||
}
|
||||
|
||||
export const defaultScale = generateScale(6, 100)
|
||||
// dim shifted 6% lighter
|
||||
export const dimScale = generateScale(12, 100)
|
||||
|
||||
export const colors = {
|
||||
white: '#FFFFFF',
|
||||
black: '#000000',
|
||||
|
||||
contrast_0: `hsl(${BLUE_HUE}, 20%, ${defaultScale[14]}%)`,
|
||||
contrast_25: `hsl(${BLUE_HUE}, 20%, ${defaultScale[13]}%)`,
|
||||
contrast_50: `hsl(${BLUE_HUE}, 20%, ${defaultScale[12]}%)`,
|
||||
contrast_100: `hsl(${BLUE_HUE}, 20%, ${defaultScale[11]}%)`,
|
||||
contrast_200: `hsl(${BLUE_HUE}, 20%, ${defaultScale[10]}%)`,
|
||||
contrast_300: `hsl(${BLUE_HUE}, 20%, ${defaultScale[9]}%)`,
|
||||
contrast_400: `hsl(${BLUE_HUE}, 20%, ${defaultScale[8]}%)`,
|
||||
contrast_500: `hsl(${BLUE_HUE}, 20%, ${defaultScale[7]}%)`,
|
||||
contrast_600: `hsl(${BLUE_HUE}, 24%, ${defaultScale[6]}%)`,
|
||||
contrast_700: `hsl(${BLUE_HUE}, 24%, ${defaultScale[5]}%)`,
|
||||
contrast_800: `hsl(${BLUE_HUE}, 28%, ${defaultScale[4]}%)`,
|
||||
contrast_900: `hsl(${BLUE_HUE}, 28%, ${defaultScale[3]}%)`,
|
||||
contrast_950: `hsl(${BLUE_HUE}, 28%, ${defaultScale[2]}%)`,
|
||||
contrast_975: `hsl(${BLUE_HUE}, 28%, ${defaultScale[1]}%)`,
|
||||
contrast_1000: `hsl(${BLUE_HUE}, 28%, ${defaultScale[0]}%)`,
|
||||
|
||||
primary_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
|
||||
primary_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
|
||||
primary_100: `hsl(${BLUE_HUE}, 99%, 90%)`,
|
||||
primary_200: `hsl(${BLUE_HUE}, 99%, 80%)`,
|
||||
primary_300: `hsl(${BLUE_HUE}, 99%, 70%)`,
|
||||
primary_400: `hsl(${BLUE_HUE}, 99%, 60%)`,
|
||||
primary_500: `hsl(${BLUE_HUE}, 99%, 53%)`,
|
||||
primary_600: `hsl(${BLUE_HUE}, 99%, 42%)`,
|
||||
primary_700: `hsl(${BLUE_HUE}, 99%, 34%)`,
|
||||
primary_800: `hsl(${BLUE_HUE}, 99%, 26%)`,
|
||||
primary_900: `hsl(${BLUE_HUE}, 99%, 18%)`,
|
||||
primary_950: `hsl(${BLUE_HUE}, 99%, 10%)`,
|
||||
primary_975: `hsl(${BLUE_HUE}, 99%, 7%)`,
|
||||
|
||||
green_25: `hsl(${GREEN_HUE}, 82%, 97%)`,
|
||||
green_50: `hsl(${GREEN_HUE}, 82%, 95%)`,
|
||||
green_100: `hsl(${GREEN_HUE}, 82%, 90%)`,
|
||||
green_200: `hsl(${GREEN_HUE}, 82%, 80%)`,
|
||||
green_300: `hsl(${GREEN_HUE}, 82%, 70%)`,
|
||||
green_400: `hsl(${GREEN_HUE}, 82%, 60%)`,
|
||||
green_500: `hsl(${GREEN_HUE}, 82%, 50%)`,
|
||||
green_600: `hsl(${GREEN_HUE}, 82%, 42%)`,
|
||||
green_700: `hsl(${GREEN_HUE}, 82%, 34%)`,
|
||||
green_800: `hsl(${GREEN_HUE}, 82%, 26%)`,
|
||||
green_900: `hsl(${GREEN_HUE}, 82%, 18%)`,
|
||||
green_950: `hsl(${GREEN_HUE}, 82%, 10%)`,
|
||||
green_975: `hsl(${GREEN_HUE}, 82%, 7%)`,
|
||||
|
||||
red_25: `hsl(${RED_HUE}, 91%, 97%)`,
|
||||
red_50: `hsl(${RED_HUE}, 91%, 95%)`,
|
||||
red_100: `hsl(${RED_HUE}, 91%, 90%)`,
|
||||
red_200: `hsl(${RED_HUE}, 91%, 80%)`,
|
||||
red_300: `hsl(${RED_HUE}, 91%, 70%)`,
|
||||
red_400: `hsl(${RED_HUE}, 91%, 60%)`,
|
||||
red_500: `hsl(${RED_HUE}, 91%, 50%)`,
|
||||
red_600: `hsl(${RED_HUE}, 91%, 42%)`,
|
||||
red_700: `hsl(${RED_HUE}, 91%, 34%)`,
|
||||
red_800: `hsl(${RED_HUE}, 91%, 26%)`,
|
||||
red_900: `hsl(${RED_HUE}, 91%, 18%)`,
|
||||
red_950: `hsl(${RED_HUE}, 91%, 10%)`,
|
||||
red_975: `hsl(${RED_HUE}, 91%, 7%)`,
|
||||
} as const
|
||||
@@ -0,0 +1,100 @@
|
||||
import {colors} from './colors.js'
|
||||
import * as tokens from './tokens.js'
|
||||
|
||||
export {atoms} from './atoms.js'
|
||||
export * as tokens from './tokens.js'
|
||||
|
||||
const fontFamily = 'Inter'
|
||||
|
||||
export const theme = {
|
||||
palette: colors,
|
||||
atoms: {
|
||||
text: {
|
||||
color: colors.black,
|
||||
fontFamily,
|
||||
},
|
||||
text_contrast_low: {
|
||||
color: colors.contrast_500,
|
||||
fontFamily,
|
||||
},
|
||||
text_contrast_medium: {
|
||||
color: colors.contrast_700,
|
||||
fontFamily,
|
||||
},
|
||||
text_contrast_high: {
|
||||
color: colors.contrast_900,
|
||||
fontFamily,
|
||||
},
|
||||
text_inverted: {
|
||||
color: colors.white,
|
||||
fontFamily,
|
||||
},
|
||||
bg: {
|
||||
backgroundColor: colors.white,
|
||||
},
|
||||
bg_contrast_25: {
|
||||
backgroundColor: colors.contrast_25,
|
||||
},
|
||||
bg_contrast_50: {
|
||||
backgroundColor: colors.contrast_50,
|
||||
},
|
||||
bg_contrast_100: {
|
||||
backgroundColor: colors.contrast_100,
|
||||
},
|
||||
bg_contrast_200: {
|
||||
backgroundColor: colors.contrast_200,
|
||||
},
|
||||
bg_contrast_300: {
|
||||
backgroundColor: colors.contrast_300,
|
||||
},
|
||||
bg_contrast_400: {
|
||||
backgroundColor: colors.contrast_400,
|
||||
},
|
||||
bg_contrast_500: {
|
||||
backgroundColor: colors.contrast_500,
|
||||
},
|
||||
bg_contrast_600: {
|
||||
backgroundColor: colors.contrast_600,
|
||||
},
|
||||
bg_contrast_700: {
|
||||
backgroundColor: colors.contrast_700,
|
||||
},
|
||||
bg_contrast_800: {
|
||||
backgroundColor: colors.contrast_800,
|
||||
},
|
||||
bg_contrast_900: {
|
||||
backgroundColor: colors.contrast_900,
|
||||
},
|
||||
bg_contrast_950: {
|
||||
backgroundColor: colors.contrast_950,
|
||||
},
|
||||
bg_contrast_975: {
|
||||
backgroundColor: colors.contrast_975,
|
||||
},
|
||||
bg_primary_500: {
|
||||
backgroundColor: colors.primary_500,
|
||||
},
|
||||
border_contrast_low: {
|
||||
borderColor: colors.contrast_100,
|
||||
},
|
||||
border_contrast_medium: {
|
||||
borderColor: colors.contrast_200,
|
||||
},
|
||||
border_contrast_high: {
|
||||
borderColor: colors.contrast_300,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function style(styleObjects: Record<string, any>[]) {
|
||||
return Object.assign({}, ...styleObjects)
|
||||
}
|
||||
|
||||
export function gradient(color: keyof typeof tokens.gradients) {
|
||||
const {values} = tokens.gradients[color]
|
||||
return values
|
||||
.map(([pos, color]) => {
|
||||
return `${color} ${pos * 100}%`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export const space = {
|
||||
_2xs: 2,
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
_2xl: 24,
|
||||
_3xl: 28,
|
||||
_4xl: 32,
|
||||
_5xl: 40,
|
||||
} as const
|
||||
|
||||
export const fontSize = {
|
||||
_2xs: 10,
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 18,
|
||||
xl: 20,
|
||||
_2xl: 22,
|
||||
_3xl: 26,
|
||||
_4xl: 32,
|
||||
_5xl: 40,
|
||||
} as const
|
||||
|
||||
export const lineHeight = {
|
||||
none: 1,
|
||||
normal: 1.5,
|
||||
relaxed: 1.625,
|
||||
} as const
|
||||
|
||||
export const borderRadius = {
|
||||
_2xs: 2,
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
full: 999,
|
||||
} as const
|
||||
|
||||
export const fontWeight = {
|
||||
normal: '400',
|
||||
semibold: '500',
|
||||
bold: '600',
|
||||
heavy: '700',
|
||||
} as const
|
||||
|
||||
export const gradients = {
|
||||
sky: {
|
||||
values: [
|
||||
[0, '#0A7AFF'],
|
||||
[1, '#59B9FF'],
|
||||
],
|
||||
hover_value: '#0A7AFF',
|
||||
},
|
||||
midnight: {
|
||||
values: [
|
||||
[0, '#022C5E'],
|
||||
[1, '#4079BC'],
|
||||
],
|
||||
hover_value: '#022C5E',
|
||||
},
|
||||
sunrise: {
|
||||
values: [
|
||||
[0, '#4E90AE'],
|
||||
[0.4, '#AEA3AB'],
|
||||
[0.8, '#E6A98F'],
|
||||
[1, '#F3A84C'],
|
||||
],
|
||||
hover_value: '#AEA3AB',
|
||||
},
|
||||
sunset: {
|
||||
values: [
|
||||
[0, '#6772AF'],
|
||||
[0.6, '#B88BB6'],
|
||||
[1, '#FFA6AC'],
|
||||
],
|
||||
hover_value: '#B88BB6',
|
||||
},
|
||||
summer: {
|
||||
values: [
|
||||
[0, '#FF6A56'],
|
||||
[0.3, '#FF9156'],
|
||||
[1, '#FFDD87'],
|
||||
],
|
||||
hover_value: '#FF9156',
|
||||
},
|
||||
nordic: {
|
||||
values: [
|
||||
[0, '#083367'],
|
||||
[1, '#9EE8C1'],
|
||||
],
|
||||
hover_value: '#3A7085',
|
||||
},
|
||||
bonfire: {
|
||||
values: [
|
||||
[0, '#203E4E'],
|
||||
[0.4, '#755B62'],
|
||||
[0.8, '#CD7765'],
|
||||
[1, '#EF956E'],
|
||||
],
|
||||
hover_value: '#755B62',
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,10 @@
|
||||
export function formatCount(count: number) {
|
||||
return Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
// `1,953` shouldn't be rounded up to 2k, it should be truncated.
|
||||
// @ts-ignore
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
|
||||
roundingMode: 'trunc',
|
||||
}).format(count)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function formatDate(date: number | string | Date) {
|
||||
const d = new Date(date)
|
||||
return `${d.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})} at ${d.toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import MIMEType from 'whatwg-mimetype'
|
||||
|
||||
export async function getImage(url: string) {
|
||||
const response = await fetch(url)
|
||||
const mimeType = new MIMEType(response.headers.get('content-type') ?? '')
|
||||
const arrayBuf = await response.arrayBuffer() // must drain body even if it will be discarded
|
||||
if (response.status !== 200) return null
|
||||
return {
|
||||
mime: mimeType.essence as string | undefined,
|
||||
image: Buffer.from(arrayBuf),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
|
||||
const DEV = process.env.NODE_ENV === 'development'
|
||||
const HOST = DEV
|
||||
? `http://localhost:3000/start`
|
||||
: `https://ogcard.cdn.bsky.app/start`
|
||||
|
||||
export function getStarterPackImageUri(
|
||||
record: AppBskyGraphDefs.StarterPackViewBasic,
|
||||
) {
|
||||
const urip = new AtUri(record.uri)
|
||||
return `${HOST}/${record.creator.did}/${urip.rkey}`
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyGraphDefs,
|
||||
AtpAgent,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {httpLogger} from '../logger.js'
|
||||
import {getImage} from './getImage.js'
|
||||
import {getStarterPackImageUri} from './getStarterPackImageUri.js'
|
||||
|
||||
export type Metadata = {
|
||||
aspectRatio: {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
}
|
||||
|
||||
export type Image = Metadata & {
|
||||
mime: 'image/jpeg' | 'image/png' | string | undefined
|
||||
image: Buffer
|
||||
}
|
||||
|
||||
export type PostData = {
|
||||
images: Map<string, Image>
|
||||
texts: Map<string, RichText>
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(aspectRatio?: {
|
||||
width: number
|
||||
height: number
|
||||
}): Metadata['aspectRatio'] {
|
||||
if (!aspectRatio)
|
||||
return {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
}
|
||||
return aspectRatio
|
||||
}
|
||||
|
||||
export async function resolvePostData(
|
||||
post: AppBskyFeedDefs.PostView,
|
||||
agent: AtpAgent,
|
||||
): Promise<PostData> {
|
||||
const images: Map<string, Metadata> = new Map()
|
||||
const texts: Map<string, RichText> = new Map()
|
||||
|
||||
// console.log(JSON.stringify(post, null, 2))
|
||||
|
||||
if (post.author.avatar) {
|
||||
images.set(post.author.avatar, {
|
||||
aspectRatio: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyFeedPost.isRecord(post.record) && post.record.text) {
|
||||
texts.set(post.record.text, new RichText({text: post.record.text}))
|
||||
}
|
||||
|
||||
if (post.embed) {
|
||||
if (AppBskyEmbedImages.isView(post.embed)) {
|
||||
// get OPs media
|
||||
for (const image of post.embed.images) {
|
||||
images.set(image.fullsize, {
|
||||
aspectRatio: normalizeAspectRatio(image.aspectRatio),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedExternal.isView(post.embed)) {
|
||||
if (post.embed.external.thumb) {
|
||||
images.set(post.embed.external.thumb, {
|
||||
aspectRatio: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecord.isView(post.embed)) {
|
||||
if (AppBskyEmbedRecord.isViewRecord(post.embed.record)) {
|
||||
if (post.embed.record.author.avatar) {
|
||||
images.set(post.embed.record.author.avatar, {
|
||||
aspectRatio: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for (const embed of post.embed.record.embeds) {
|
||||
if (AppBskyEmbedImages.isView(embed)) {
|
||||
for (const image of embed.images) {
|
||||
images.set(image.fullsize, {
|
||||
aspectRatio: normalizeAspectRatio(image.aspectRatio),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (AppBskyEmbedExternal.isView(embed)) {
|
||||
if (embed.external.thumb) {
|
||||
images.set(embed.external.thumb, {
|
||||
aspectRatio: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||
if (AppBskyEmbedImages.isView(embed.media)) {
|
||||
for (const image of embed.media.images) {
|
||||
images.set(image.fullsize, {
|
||||
aspectRatio: normalizeAspectRatio(image.aspectRatio),
|
||||
})
|
||||
}
|
||||
} else if (AppBskyEmbedExternal.isView(embed.media)) {
|
||||
if (embed.media.external.thumb) {
|
||||
images.set(embed.media.external.thumb, {
|
||||
aspectRatio: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyGraphDefs.isListView(embed.record)) {
|
||||
if (embed.record.avatar) {
|
||||
images.set(embed.record.avatar, {
|
||||
aspectRatio: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
|
||||
if (embed.record.avatar) {
|
||||
images.set(embed.record.avatar, {
|
||||
aspectRatio: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
|
||||
const uri = getStarterPackImageUri(embed.record)
|
||||
images.set(uri, {
|
||||
aspectRatio: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(post.embed.record.value) &&
|
||||
post.embed.record.value.text
|
||||
) {
|
||||
texts.set(
|
||||
post.embed.record.value.text,
|
||||
new RichText({text: post.embed.record.value.text}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyGraphDefs.isListView(post.embed.record)) {
|
||||
if (post.embed.record.avatar) {
|
||||
images.set(post.embed.record.avatar, {
|
||||
aspectRatio: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyFeedDefs.isGeneratorView(post.embed.record)) {
|
||||
if (post.embed.record.avatar) {
|
||||
images.set(post.embed.record.avatar, {
|
||||
aspectRatio: {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyGraphDefs.isStarterPackViewBasic(post.embed.record)) {
|
||||
const uri = getStarterPackImageUri(post.embed.record)
|
||||
images.set(uri, {
|
||||
aspectRatio: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
|
||||
// get OPs media
|
||||
if (AppBskyEmbedImages.isView(post.embed.media)) {
|
||||
for (const image of post.embed.media.images) {
|
||||
images.set(image.fullsize, {
|
||||
aspectRatio: normalizeAspectRatio(image.aspectRatio),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedExternal.isView(post.embed.media)) {
|
||||
if (post.embed.media.external.thumb) {
|
||||
images.set(post.embed.media.external.thumb, {
|
||||
aspectRatio: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// get media from embedded post
|
||||
if (AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) {
|
||||
for (const embed of post.embed.record.record.embeds) {
|
||||
if (AppBskyEmbedImages.isView(embed)) {
|
||||
for (const image of embed.images) {
|
||||
images.set(image.fullsize, {
|
||||
aspectRatio: normalizeAspectRatio(image.aspectRatio),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (AppBskyEmbedExternal.isView(embed)) {
|
||||
if (embed.external.thumb) {
|
||||
images.set(embed.external.thumb, {
|
||||
aspectRatio: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
|
||||
if (AppBskyEmbedImages.isView(embed.media)) {
|
||||
for (const image of embed.media.images) {
|
||||
images.set(image.fullsize, {
|
||||
aspectRatio: normalizeAspectRatio(image.aspectRatio),
|
||||
})
|
||||
}
|
||||
} else if (AppBskyEmbedExternal.isView(embed.media)) {
|
||||
if (embed.media.external.thumb) {
|
||||
images.set(embed.media.external.thumb, {
|
||||
aspectRatio: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
AppBskyFeedPost.isRecord(post.embed.record.record.value) &&
|
||||
post.embed.record.record.value.text
|
||||
) {
|
||||
texts.set(
|
||||
post.embed.record.record.value.text,
|
||||
new RichText({text: post.embed.record.record.value.text}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = Array.from(images.entries())
|
||||
const resolved = await Promise.all(
|
||||
deduped.map(async ([url, meta]) => {
|
||||
try {
|
||||
const image = await getImage(url)
|
||||
return [
|
||||
url,
|
||||
{
|
||||
...meta,
|
||||
...image,
|
||||
},
|
||||
] as const
|
||||
} catch (err) {
|
||||
httpLogger.warn({err, uri: url}, 'could not fetch image')
|
||||
return [
|
||||
url,
|
||||
{
|
||||
...meta,
|
||||
image: null,
|
||||
},
|
||||
] as const
|
||||
}
|
||||
}),
|
||||
)
|
||||
await Promise.all(Array.from(texts.values()).map(r => r.detectFacets(agent)))
|
||||
const extracted = resolved.filter(([, i]) => i.image !== null) as [
|
||||
string,
|
||||
Image,
|
||||
][]
|
||||
|
||||
return {
|
||||
images: new Map(extracted),
|
||||
texts,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function toShortUrl(url: string): string {
|
||||
try {
|
||||
const urlp = new URL(url)
|
||||
if (urlp.protocol !== 'http:' && urlp.protocol !== 'https:') {
|
||||
return url
|
||||
}
|
||||
const path =
|
||||
(urlp.pathname === '/' ? '' : urlp.pathname) + urlp.search + urlp.hash
|
||||
if (path.length > 15) {
|
||||
return urlp.host + path.slice(0, 13) + '...'
|
||||
}
|
||||
return urlp.host + path
|
||||
} catch (e) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user