Skip to content

Commit

Permalink
feat: templates
Browse files Browse the repository at this point in the history
  • Loading branch information
QuiiBz committed Dec 28, 2023
1 parent 0a616aa commit c827296
Show file tree
Hide file tree
Showing 11 changed files with 239 additions and 52 deletions.
3 changes: 2 additions & 1 deletion apps/dashboard/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ const nextConfig = {
},
typescript: {
ignoreBuildErrors: true,
}
},
reactStrictMode: false,
}

module.exports = nextConfig
9 changes: 5 additions & 4 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@
"next": "14.0.4",
"react": "^18",
"react-dom": "^18",
"react-promise-suspense": "^0.3.4",
"satori": "^0.10.11"
},
"devDependencies": {
"@next/eslint-plugin-next": "^14.0.3",
"@ogstudio/eslint": "workspace:*",
"@ogstudio/typescript": "workspace:*",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"@next/eslint-plugin-next": "^14.0.3",
"postcss": "^8",
"tailwindcss": "^3.3.0",
"@ogstudio/eslint": "workspace:*",
"@ogstudio/typescript": "workspace:*"
"tailwindcss": "^3.3.0"
}
}
14 changes: 14 additions & 0 deletions apps/dashboard/src/app/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { OgEditor } from "../../components/OgEditor"
import { INITIAL_ELEMENTS } from "../../lib/elements"

interface EditorProps {
params: {
id: string
}
}

export default function Editor({ params: { id } }: EditorProps) {
return (
<OgEditor height={630} initialElements={INITIAL_ELEMENTS} localStorageKey={id} width={1200} />
)
}
16 changes: 16 additions & 0 deletions apps/dashboard/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@
@tailwind components;
@tailwind utilities;

/**
* https://github.com/tailwindlabs/tailwindcss/discussions/2394
* https://github.com/tailwindlabs/tailwindcss/pull/5732
*/
@layer utilities {
/* Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}

.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
}

.element:focus {
outline: none;
}
Expand Down
115 changes: 92 additions & 23 deletions apps/dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,101 @@
'use client'
import type { MouseEvent, ReactNode } from "react";
import { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { OgEditor } from "../components/OgEditor";
import { createElementId } from "../lib/elements";
import { INITIAL_ELEMENTS, createElementId } from "../lib/elements";
import { DeleteIcon } from "../components/icons/DeleteIcon";
import { AddIcon } from "../components/icons/AddIcon";
import type { OGElement } from "../lib/types";
import { OgImage } from "../components/OgImage";

/**
* The initial elements to render in the editor.
*
* It only contains a single element, a white background that
* takes the entire width and height of the editor.
*/
const initialElements: OGElement[] = [
{
id: createElementId(),
tag: 'div',
name: 'Box',
x: 0,
y: 0,
width: 1200,
height: 630,
visible: true,
rotate: 0,
opacity: 100,
backgroundColor: '#ffffff',
},
]
interface OgImageWrapperProps {
href: string
elements?: OGElement[]
children?: ReactNode
deletable?: (event: MouseEvent<HTMLSpanElement>) => void
}

function OgImageWrapper({ href, elements, children, deletable }: OgImageWrapperProps) {
return (
<Link className="h-32 min-w-60 flex items-center justify-center text-gray-600 border rounded border-gray-200 hover:border-gray-300 relative group" href={href}>
{elements ? (
<Suspense fallback={<div className="animate-pulse w-3/4 h-1/6 bg-gray-100 rounded-full" />}>
<OgImage elements={elements} />
</Suspense>
) : null}
{children}
{deletable ? (
<span className="absolute right-0 top-0 p-2 text-gray-600 hover:text-gray-900 hidden group-hover:block" onClick={deletable} role="button">
<DeleteIcon />
</span>
) : null}
</Link>
)
}

interface OGImage {
id: string
content: OGElement[]
}

export default function Home() {
const [ogImages, setOgImages] = useState<OGImage[]>([])

useEffect(() => {
const images = Object.keys(localStorage).reduce<OGImage[]>((acc, current) => {
if (current.startsWith('og-')) {
acc.push({
id: current,
content: JSON.parse(localStorage.getItem(current) || '[]') as OGElement[]
})
}

return acc
}, [])

setOgImages(images)
}, [])

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

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

return (
<OgEditor height={630} initialElements={initialElements} width={1200} />
<>
<OgEditor height={630} initialElements={INITIAL_ELEMENTS} localStorageKey="splash" width={1200} />
<div className="w-screen h-screen bg-black/20 flex justify-center items-center absolute top-0 left-0 z-10">
<div className="p-8 rounded-md bg-white shadow-lg shadow-gray-300 max-w-3xl">
<div className="flex flex-col gap-2">
<h2 className="text-gray-800 text-xl">Templates</h2>
<div className="flex gap-2 overflow-x-scroll no-scrollbar">
<OgImageWrapper elements={[]} href="/" />
<OgImageWrapper elements={[]} href="/" />
<OgImageWrapper elements={[]} href="/" />
</div>
</div>
<div className="h-[1px] w-full bg-gray-100 my-6" />
<div className="flex flex-col gap-2">
<h2 className="text-gray-800 text-xl">My OG images</h2>
<div className="flex gap-2 overflow-x-scroll no-scrollbar">
<OgImageWrapper href={`/${createElementId()}`}>
<AddIcon height="1.4em" width="1.4em" /> Start from scratch
</OgImageWrapper>
{ogImages.map(ogImage => {
return (
<OgImageWrapper deletable={deleteOgImage(ogImage.id)} elements={ogImage.content} href={`/${ogImage.id.replace('og-', '')}`} key={ogImage.id} />
)
})}
</div>
</div>
</div>
</div>
</>
)
}
23 changes: 2 additions & 21 deletions apps/dashboard/src/components/LeftPanel/ExportSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,12 @@ import { PngIcon } from "../icons/PngIcon";
import { SvgIcon } from "../icons/SvgIcon";
import { useOg } from "../OgEditor";
import { domToReactLike, exportToPng, exportToSvg } from "../../lib/export";
import { loadFonts } from "../../lib/fonts";

export function ExportSection() {
const { rootRef, elements, setSelectedElement } = useOg()
const [isLoading, setIsLoading] = useState(false)

async function getFonts() {
return Promise.all(elements.filter(element => element.tag === 'p' || element.tag === 'span').map(async element => {
// @ts-expect-error -- wrong inference
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- wrong inference
const fontName = element.fontFamily.toLowerCase().replace(' ', '-')
// @ts-expect-error -- wrong inference
const data = await fetch(`https://fonts.bunny.net/${fontName}/files/${fontName}-latin-${element.fontWeight}-normal.woff`).then(response => response.arrayBuffer())

return {
// @ts-expect-error -- wrong inference
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- wrong inference
name: element.fontFamily,
data,
// @ts-expect-error -- wrong inference
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- wrong inference
weight: element.fontWeight,
}
}))
}

async function exportSvg(openInNewTab = true) {
if (!rootRef.current) {
return {}
Expand All @@ -42,7 +23,7 @@ export function ExportSection() {
setIsLoading(true)

const reactLike = domToReactLike(rootRef.current, 'This is a dynamic text')
const fonts = await getFonts()
const fonts = await loadFonts(elements)
const svg = await exportToSvg(reactLike, fonts)

setIsLoading(false)
Expand Down
9 changes: 6 additions & 3 deletions apps/dashboard/src/components/OgEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client'
import type { RefObject } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type { OGElement } from "../lib/types";
Expand Down Expand Up @@ -35,6 +36,7 @@ export function useOg() {

interface OgProviderProps {
initialElements: OGElement[]
localStorageKey: string
width: number
height: number
}
Expand All @@ -44,10 +46,11 @@ let editIndex = -1

let elementToCopy: OGElement | undefined

export function OgEditor({ initialElements, width, height }: OgProviderProps) {
export function OgEditor({ initialElements, localStorageKey: key, width, height }: OgProviderProps) {
const localStorageKey = `og-${key}`
const [selectedElement, setRealSelectedElement] = useState<string | null>(null)
const [elements, setRealElements] = useState<OGElement[]>(() => {
const item = typeof localStorage !== 'undefined' ? localStorage.getItem('elements') : undefined
const item = typeof localStorage !== 'undefined' ? localStorage.getItem(localStorageKey) : undefined

if (item) {
return JSON.parse(item) as OGElement[]
Expand Down Expand Up @@ -84,7 +87,7 @@ export function OgEditor({ initialElements, width, height }: OgProviderProps) {
return newElements
})

localStorage.setItem('elements', JSON.stringify(newElements))
localStorage.setItem(localStorageKey, JSON.stringify(newElements))
}, [])

const updateElement = useCallback((element: OGElement) => {
Expand Down
39 changes: 39 additions & 0 deletions apps/dashboard/src/components/OgImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import usePromise from "react-promise-suspense"
import { createElementStyle } from "../lib/elements"
import { exportToSvg } from "../lib/export"
import { loadFonts } from "../lib/fonts"
import type { OGElement } from "../lib/types"

async function loadOgImage(elements: OGElement[]) {
const fonts = await loadFonts(elements)
const reactLike = {
type: 'div',
props: {
style: {
display: 'flex',
width: '100%',
height: '100%',
},
children: elements.map(element => ({
type: element.tag,
props: {
style: createElementStyle(element),
...(element.tag === 'p' ? { children: element.content } : {}),
},
}))
}
}

const svg = await exportToSvg(reactLike, fonts)
return `data:image/svg+xml;base64,${btoa(svg)}`
}

interface OgImageProps {
elements: OGElement[]
}

export function OgImage({ elements }: OgImageProps) {
const src = usePromise(loadOgImage, [elements])

return <img alt="" src={src} />
}
23 changes: 23 additions & 0 deletions apps/dashboard/src/lib/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@ import type { CSSProperties } from "react";
import type { OGElement } from "./types";
import { hexToRgba } from "./colors";

/**
* The initial elements to render in the editor.
*
* It only contains a single element, a white background that
* takes the entire width and height of the editor.
*/
export const INITIAL_ELEMENTS: OGElement[] = [
{
id: createElementId(),
tag: 'div',
name: 'Box',
x: 0,
y: 0,
width: 1200,
height: 630,
visible: true,
rotate: 0,
opacity: 100,
backgroundColor: '#ffffff',
},
]


export function createElementId() {
return Math.random().toString(36).substr(2, 9);
}
Expand Down
27 changes: 27 additions & 0 deletions apps/dashboard/src/lib/fonts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { OGElement } from "./types"

export const FONTS = [
'Roboto',
'Open Sans',
Expand Down Expand Up @@ -46,3 +48,28 @@ export function maybeLoadFont(font: string, weight: number) {
document.head.appendChild(link)
}

/**
* Load all fonts used in the given elements from Bunny Fonts. The fonts are
* returned as an `ArrayBuffer`, along with the font name and weight.
*/
export async function loadFonts(elements: OGElement[]) {
// TODO: dedupe fonts
return Promise.all(elements.filter(element => element.tag === 'p' || element.tag === 'span').map(async element => {
// @ts-expect-error -- wrong inference
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- wrong inference
const fontName = element.fontFamily.toLowerCase().replace(' ', '-')
// @ts-expect-error -- wrong inference
const data = await fetch(`https://fonts.bunny.net/${fontName}/files/${fontName}-latin-${element.fontWeight}-normal.woff`).then(response => response.arrayBuffer())

return {
// @ts-expect-error -- wrong inference
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- wrong inference
name: element.fontFamily,
data,
// @ts-expect-error -- wrong inference
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- wrong inference
weight: element.fontWeight,
}
}))
}

Loading

0 comments on commit c827296

Please sign in to comment.