Skip to content

Commit

Permalink
fix: batch of small fixes (#18)
Browse files Browse the repository at this point in the history
* fix: batch of small fixes

* fix: add metadata

* fix: avoid crashing if Intl.Segmenter is not supported
  • Loading branch information
QuiiBz authored Dec 29, 2023
1 parent b6cef03 commit 5e03e8a
Show file tree
Hide file tree
Showing 10 changed files with 134 additions and 34 deletions.
3 changes: 2 additions & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"react": "^18",
"react-dom": "^18",
"react-promise-suspense": "^0.3.4",
"satori": "^0.10.11"
"satori": "^0.10.11",
"sonner": "^1.3.1"
},
"devDependencies": {
"@next/eslint-plugin-next": "^14.0.3",
Expand Down
6 changes: 5 additions & 1 deletion apps/dashboard/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Toaster } from 'sonner'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })
Expand All @@ -16,7 +17,10 @@ export default function RootLayout({
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
<body className={inter.className}>
{children}
<Toaster />
</body>
</html>
)
}
5 changes: 5 additions & 0 deletions apps/dashboard/src/app/my-images/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Suspense } from "react";
import { OgSplash } from "../../components/OgSplash";

export const metadata = {
title: 'My images - OG Studio',
description: 'My Open Graph images.',
}

export default function MyImages() {
return (
// OgSplash uses `useSearchParams()` so we need to wrap it in a Suspense
Expand Down
5 changes: 5 additions & 0 deletions apps/dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Suspense } from "react";
import { OgSplash } from "../components/OgSplash";

export const metadata = {
title: 'OG Studio',
description: 'Figma-like OG (Open Graph) Image builder.',
}

export default function Home() {
return (
// OgSplash uses `useSearchParams()` so we need to wrap it in a Suspense
Expand Down
5 changes: 5 additions & 0 deletions apps/dashboard/src/app/templates/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Suspense } from "react";
import { OgSplash } from "../../components/OgSplash";

export const metadata = {
title: 'Templates - OG Studio',
description: 'Pre-made Open Graph image templates.',
}

export default function Templates() {
return (
// OgSplash uses `useSearchParams()` so we need to wrap it in a Suspense
Expand Down
7 changes: 5 additions & 2 deletions apps/dashboard/src/components/OgEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,15 @@ export function OgEditor({ initialElements, localStorageKey: key, width, height
event.preventDefault()

if (elementToCopy) {
addElement({
const newElement: OGElement = {
...elementToCopy,
x: elementToCopy.x + 10,
y: elementToCopy.y + 10,
id: createElementId(),
})
}

addElement(newElement)
elementToCopy = newElement
}
}
}
Expand Down
77 changes: 60 additions & 17 deletions apps/dashboard/src/components/OgSplash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ import { OgImage } from "./OgImage";
import { ArrowRightIcon } from "./icons/ArrowRightIcon";
import { CustomLink } from "./CustomLink";
import { ArrowLeftIcon } from "./icons/ArrowLeftIcon";
import { CopyIcon } from "./icons/CopyIcon";

interface OgImageWrapperProps {
href?: string
onClick?: (event: MouseEvent<HTMLElement>) => void
elements?: OGElement[]
children?: ReactNode
copiable?: (event: MouseEvent<HTMLSpanElement>) => void
deletable?: (event: MouseEvent<HTMLSpanElement>) => void
}

function OgImageWrapper({ href, onClick, elements, children, deletable }: OgImageWrapperProps) {
function OgImageWrapper({ href, onClick, elements, children, copiable, deletable }: OgImageWrapperProps) {
const Tag = href ? Link : 'button'

return (
Expand All @@ -34,8 +36,13 @@ function OgImageWrapper({ href, onClick, elements, children, deletable }: OgImag
</Suspense>
) : null}
{children}
{copiable ? (
<button className="absolute right-8 top-1 p-1 bg-black/60 rounded text-gray-300 hover:text-gray-200 hidden group-hover:block" onClick={copiable} type="button">
<CopyIcon />
</button>
) : null}
{deletable ? (
<button className="absolute right-0 top-0 p-2 text-gray-600 hover:text-gray-900 hidden group-hover:block" onClick={deletable} type="button">
<button className="absolute right-1 top-1 p-1 bg-black/60 rounded text-gray-300 hover:text-gray-200 hidden group-hover:block" onClick={deletable} type="button">
<DeleteIcon />
</button>
) : null}
Expand All @@ -58,6 +65,7 @@ export function OgSplash({ route }: OgSplashProps) {
const [ogImages, setOgImages] = useState<OGImage[]>([])
const router = useRouter()

// Load images from localStorage
useEffect(() => {
const images = Object.keys(localStorage).reduce<OGImage[]>((acc, current) => {
if (current.startsWith('og-')) {
Expand All @@ -73,36 +81,59 @@ export function OgSplash({ route }: OgSplashProps) {
setOgImages(images)
}, [image])

function copyTemplate(template: Template) {
function copyTemplate(template: Template, push = true) {
return function onClick() {
const id = createElementId()
const key = `og-${id}`

localStorage.setItem(key, JSON.stringify(template.elements))
router.push(`/?i=${id}`)

if (push) {
router.push(`/?i=${id}`)
} else {
setOgImages([{ id: key, content: template.elements }, ...ogImages])
}
}
}

function copyOgImage(ogImage: OGImage) {
const copy = copyTemplate({ name: ogImage.id, elements: ogImage.content }, false)

return function onClick(event: MouseEvent<HTMLSpanElement>) {
event.preventDefault();
event.stopPropagation();

copy()
}
}

function deleteOgImage(ogImage: string) {
function deleteOgImage(ogImage: OGImage) {
return function onClick(event: MouseEvent<HTMLSpanElement>) {
event.preventDefault();
event.stopPropagation();

localStorage.removeItem(ogImage)
setOgImages(ogImages.filter(({ id }) => id !== ogImage))
localStorage.removeItem(ogImage.id)
setOgImages(ogImages.filter(({ id }) => id !== ogImage.id))
}
}

return (
<>
<OgEditor height={630} initialElements={INITIAL_ELEMENTS} localStorageKey={image ?? 'splash'} width={1200} />
{image ? null : (
<div className="w-screen h-screen bg-black/20 flex justify-center items-center absolute top-0 left-0 z-10 backdrop-blur-[1px]">
<div className="p-8 rounded-md bg-white shadow-lg shadow-gray-300 w-[calc((300px*3)+(2rem*2)+(0.5rem*2))]">
<div className="w-screen h-screen bg-black/10 flex justify-center items-center absolute top-0 left-0 z-10 backdrop-blur-[1px]">
<div className="p-8 rounded-md bg-white shadow-lg shadow-gray-200 w-[980px] h-[636px]">
<div className="flex items-center justify-between">
<h1 className="text-gray-900 text-2xl">OG Studio</h1>
<CustomLink href="/" icon={<div className="w-6 h-6 rounded-full bg-gray-200 animate-pulse" />} iconPosition="right">
Guest
</CustomLink>
</div>
<div className="h-[1px] w-full bg-gray-100 my-8" />
{route === 'splash' ? (
<>
<div className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center">
<div className="flex justify-between items-center">
<h2 className="text-gray-800 text-xl">Templates</h2>
<CustomLink href="/templates" icon={<ArrowRightIcon />} iconPosition="right">
See all
Expand All @@ -116,8 +147,8 @@ export function OgSplash({ route }: OgSplashProps) {
</div>
<div className="h-[1px] w-full bg-gray-100 my-8" />
<div className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center">
<h2 className="text-gray-800 text-xl">My OG images</h2>
<div className="flex justify-between items-center">
<h2 className="text-gray-800 text-xl">My images</h2>
<CustomLink href="/my-images" icon={<ArrowRightIcon />} iconPosition="right">
See all
</CustomLink>
Expand All @@ -127,14 +158,20 @@ export function OgSplash({ route }: OgSplashProps) {
<AddIcon height="1.4em" width="1.4em" /> Start from scratch
</OgImageWrapper>
{ogImages.slice(0, 2).map(ogImage => (
<OgImageWrapper deletable={deleteOgImage(ogImage.id)} elements={ogImage.content} href={`/?i=${ogImage.id.replace('og-', '')}`} key={ogImage.id} />
<OgImageWrapper
copiable={copyOgImage(ogImage)}
deletable={deleteOgImage(ogImage)}
elements={ogImage.content}
href={`/?i=${ogImage.id.replace('og-', '')}`}
key={ogImage.id}
/>
))}
</div>
</div>
</>
) : route === 'templates' ? (
<div className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center">
<div className="flex justify-between items-center">
<h2 className="text-gray-800 text-xl">All templates</h2>
<CustomLink href="/" icon={<ArrowLeftIcon />}>
Back
Expand All @@ -148,8 +185,8 @@ export function OgSplash({ route }: OgSplashProps) {
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center">
<h2 className="text-gray-800 text-xl">My OG images</h2>
<div className="flex justify-between items-center">
<h2 className="text-gray-800 text-xl">My images</h2>
<CustomLink href="/" icon={<ArrowLeftIcon />}>
Back
</CustomLink>
Expand All @@ -159,7 +196,13 @@ export function OgSplash({ route }: OgSplashProps) {
<AddIcon height="1.4em" width="1.4em" /> Start from scratch
</OgImageWrapper>
{ogImages.map(ogImage => (
<OgImageWrapper deletable={deleteOgImage(ogImage.id)} elements={ogImage.content} href={`/?i=${ogImage.id.replace('og-', '')}`} key={ogImage.id} />
<OgImageWrapper
copiable={copyOgImage(ogImage)}
deletable={deleteOgImage(ogImage)}
elements={ogImage.content}
href={`/?i=${ogImage.id.replace('og-', '')}`}
key={ogImage.id}
/>
))}
</div>
</div>
Expand Down
7 changes: 7 additions & 0 deletions apps/dashboard/src/components/icons/CopyIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { SVGProps } from "react";

export function CopyIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg height="1em" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg" {...props}><path d="M9.115 17q-.69 0-1.152-.462q-.463-.463-.463-1.153V4.615q0-.69.463-1.152Q8.425 3 9.115 3h7.77q.69 0 1.152.463q.463.462.463 1.152v10.77q0 .69-.462 1.153q-.463.462-1.153.462zm0-1h7.77q.23 0 .423-.192q.192-.193.192-.423V4.615q0-.23-.192-.423Q17.115 4 16.885 4h-7.77q-.23 0-.423.192q-.192.193-.192.423v10.77q0 .23.192.423q.193.192.423.192m-3 4q-.69 0-1.152-.462q-.463-.463-.463-1.153V6.615h1v11.77q0 .23.192.423q.193.192.423.192h8.77v1zM8.5 16V4z" fill="currentColor" /></svg>
)
}
40 changes: 27 additions & 13 deletions apps/dashboard/src/lib/export.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Resvg, initWasm } from "@resvg/resvg-wasm"
import satori from "satori"
import { toast } from "sonner"

let wasmInitialized = false

Expand Down Expand Up @@ -60,20 +61,33 @@ export function domToReactLike(element: Element, dynamicTextReplace: string): Re


export async function exportToSvg(reactLike: Record<string, unknown>, fonts: { name: string, data: ArrayBuffer, weight: number }[]): Promise<string> {
const now = Date.now()

const svg = await satori(
// @ts-expect-error wtf?
reactLike,
{
width: 1200,
height: 630,
fonts,
},
)
try {
const now = Date.now()

const svg = await satori(
// @ts-expect-error wtf?
reactLike,
{
width: 1200,
height: 630,
fonts,
},
)

console.log('satori', Date.now() - now)
return svg
} catch (error) {
console.error(error)

// Firefox only recently added support for the Intl.Segmenter API
// See https://caniuse.com/mdn-javascript_builtins_intl_segmenter
// See https://github.com/QuiiBz/ogstudio/issues/19
if (error instanceof Error && error.message.includes('Intl.Segmenter')) {
toast.error('Your browser does not support a required feature (Intl.Segmenter). Please update to the latest version.')
}

console.log('satori', Date.now() - now)
return svg
return ''
}
}

export async function exportToPng(svg: string, fonts: Uint8Array[]): Promise<Uint8Array> {
Expand Down
13 changes: 13 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 5e03e8a

Please sign in to comment.