Skip to content

Commit

Permalink
Merge pull request #60
Browse files Browse the repository at this point in the history
* feat(articles): open graph image template + editor with preview

* Merge branch 'main' into vh/feat/articles/social-image-editor-with-pr…
  • Loading branch information
vojtaholik authored Feb 1, 2024
1 parent 1efc391 commit a6f19e2
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 12 deletions.
1 change: 1 addition & 0 deletions apps/course-builder-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@codemirror/state": "^6.3.3",
"@codemirror/view": "^6.22.3",
"@coursebuilder/ui": "^1.0.1",
"@fontsource/inter": "^5.0.16",
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.3.2",
"@mdx-js/loader": "^3.0.0",
Expand Down
6 changes: 6 additions & 0 deletions apps/course-builder-web/sanity/schemas/documents/article.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ export default {
},
],
},
defineField({
name: 'socialImage',
title: 'Social Image',
description: 'Used as a preview image on Twitter cards etc.',
type: 'string',
}),
defineField({
name: 'description',
title: 'Short Description',
Expand Down
84 changes: 84 additions & 0 deletions apps/course-builder-web/src/app/[article]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { ImageResponse } from 'next/og'
import { getArticle } from '@/lib/articles'

export const runtime = 'edge'
export const revalidate = 60

export default async function ArticleOG({ params }: { params: { article: string } }) {
// const authorPhoto = fetch(
// new URL(`../../public/images/author.jpg`, import.meta.url)
// ).then(res => res.arrayBuffer());

// fonts
const inter500 = fetch(
new URL(`../../../node_modules/@fontsource/inter/files/inter-latin-500-normal.woff`, import.meta.url),
).then((res) => res.arrayBuffer())

const resource = await getArticle(params.article)

return new ImageResponse(
(
<div tw="flex p-10 h-full w-full bg-white flex-col" style={font('Inter 500')}>
<main tw="flex flex-col gap-5 h-full flex-grow items-center justify-center">
<div tw="absolute text-[32px] top-5">Course Builder</div>
<div tw="text-[48px]">{resource?.title}</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
{/* <img
tw="rounded-full h-74"
alt={author.name}
@ts-ignore
src={await authorPhoto}
/> */}
</main>
<Background />
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter 500',
data: await inter500,
},
],
},
)
}

// lil helper for more succinct styles
function font(fontFamily: string) {
return { fontFamily }
}

const Background = () => {
return (
<svg
style={{ position: 'absolute', opacity: 0.05 }}
width="1200"
height="630"
viewBox="0 0 1200 630"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_50_18106)">
<line y1="125.5" x2="1200" y2="125.5" stroke="black" />
<line y1="251.5" x2="1200" y2="251.5" stroke="black" />
<line y1="377.5" x2="1200" y2="377.5" stroke="black" />
<line y1="503.5" x2="1200" y2="503.5" stroke="black" />
<line x1="150.5" y1="-2.18557e-08" x2="150.5" y2="630" stroke="black" />
<line x1="300.5" y1="-2.18557e-08" x2="300.5" y2="630" stroke="black" />
<line x1="450.5" y1="-2.18557e-08" x2="450.5" y2="630" stroke="black" />
<line x1="600.5" y1="-2.18557e-08" x2="600.5" y2="630" stroke="black" />
<line x1="750.5" y1="-2.18557e-08" x2="750.5" y2="630" stroke="black" />
<line x1="900.5" y1="-2.18557e-08" x2="900.5" y2="630" stroke="black" />
<line x1="1050.5" y1="-2.18557e-08" x2="1050.5" y2="630" stroke="black" />
</g>
<defs>
<clipPath id="clip0_50_18106">
<rect width="1200" height="630" fill="white" />
</clipPath>
</defs>
</svg>
)
}
8 changes: 2 additions & 6 deletions apps/course-builder-web/src/app/[article]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,11 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
return parent as Metadata
}

const previousImages = (await parent).openGraph?.images || []

const ogImage = getOGImageUrlForTitle(article.title)
const customOgImage = article.socialImage

return {
title: article.title,
openGraph: {
images: [ogImage, ...previousImages],
},
openGraph: customOgImage ? { images: [customOgImage] } : {},
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FeedbackMarker } from '@/lib/feedback-marker'
import { cn } from '@/lib/utils'
import { api } from '@/trpc/react'
import { zodResolver } from '@hookform/resolvers/zod'
import { ResetIcon } from '@radix-ui/react-icons'
import { ImagePlusIcon, ZapIcon } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
Expand Down Expand Up @@ -43,12 +44,14 @@ import { CloudinaryUploadWidget } from './cloudinary-upload-widget'
export function EditArticleForm({ article }: { article: Article }) {
const [feedbackMarkers, setFeedbackMarkers] = React.useState<FeedbackMarker[]>([])
const router = useRouter()
const defaultSocialImage = `/${article.slug}/opengraph-image`

const form = useForm<z.infer<typeof ArticleSchema>>({
resolver: zodResolver(ArticleSchema),
defaultValues: {
...article,
description: article.description ?? '',
socialImage: article.socialImage || defaultSocialImage,
},
})

Expand Down Expand Up @@ -187,7 +190,48 @@ export function EditArticleForm({ article }: { article: Article }) {
<FormItem className="px-5">
<FormLabel>Short Description</FormLabel>
<FormDescription>Used as a short &quot;SEO&quot; summary on Twitter cards etc.</FormDescription>
<Textarea {...field} />
<Textarea {...field} value={field.value?.toString()} />
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="socialImage"
render={({ field }) => (
<FormItem className="px-5">
<FormLabel>Social Image</FormLabel>
<FormDescription>Used as a preview image on Twitter cards etc.</FormDescription>
<img
alt={`social image preview for ${article.title}`}
width={1200 / 2}
height={630 / 2}
className="aspect-[1200/630] rounded-md border"
src={form.watch('socialImage')?.toString()}
/>
<div className="flex items-center gap-1">
<Input
{...field}
value={field.value?.toString()}
onDrop={(event) => {
// remove markdown image syntax
const url = event.dataTransfer.getData('text/plain').replace(/!\[(.*?)\]\((.*?)\)/, '$2')
return form.setValue('socialImage', url)
}}
/>
<Button
title="Reset to default"
disabled={form.watch('socialImage') === defaultSocialImage}
type="button"
variant="outline"
size="icon"
onClick={() => {
form.setValue('socialImage', defaultSocialImage)
}}
>
<ResetIcon />
</Button>
</div>
<FormMessage />
</FormItem>
)}
Expand Down
9 changes: 7 additions & 2 deletions apps/course-builder-web/src/lib/articles.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { env } from '@/env.mjs'
import { sanityQuery } from '@/server/sanity.server'
import { z } from 'zod'

Expand All @@ -16,16 +17,17 @@ export const ArticleSchema = z.object({
_updatedAt: z.string(),
title: z.string().min(2).max(90),
body: z.string().optional().nullable(),
description: z.string(),
description: z.string().optional().nullable(),
slug: z.string(),
state: ArticleStateSchema.default('draft'),
visibility: ArticleVisibilitySchema.default('unlisted'),
socialImage: z.string().optional().nullable(),
})

export type Article = z.infer<typeof ArticleSchema>

export async function getArticle(slugOrId: string) {
return sanityQuery<Article | null>(
const article = await sanityQuery<Article | null>(
`*[_type == "article" && (_id == "${slugOrId}" || slug.current == "${slugOrId}")][0]{
_id,
_type,
Expand All @@ -36,7 +38,10 @@ export async function getArticle(slugOrId: string) {
visibility,
"slug": slug.current,
state,
socialImage,
}`,
{ tags: ['articles', slugOrId] },
)

return ArticleSchema.parse(article)
}
6 changes: 3 additions & 3 deletions packages/ui/primitives/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivEl

return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn('space-y-2', className)} {...props} />
<div ref={ref} className={cn('space-y-1', className)} {...props} />
</FormItemContext.Provider>
)
},
Expand All @@ -80,7 +80,7 @@ const FormLabel = React.forwardRef<
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()

return <Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />
return <Label ref={ref} className={cn(error && 'text-destructive ', className)} htmlFor={formItemId} {...props} />
})
FormLabel.displayName = 'FormLabel'

Expand All @@ -105,7 +105,7 @@ const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttribu
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()

return <p ref={ref} id={formDescriptionId} className={cn('text-muted-foreground text-sm', className)} {...props} />
return <p ref={ref} id={formDescriptionId} className={cn('text-muted-foreground text-sm ', className)} {...props} />
},
)
FormDescription.displayName = 'FormDescription'
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit a6f19e2

Please sign in to comment.