From c82729696fc89804159af19c5b732828f1abb271 Mon Sep 17 00:00:00 2001 From: Tom Lienard Date: Thu, 28 Dec 2023 09:35:13 +0100 Subject: [PATCH] feat: templates --- apps/dashboard/next.config.js | 3 +- apps/dashboard/package.json | 9 +- apps/dashboard/src/app/[id]/page.tsx | 14 +++ apps/dashboard/src/app/globals.css | 16 +++ apps/dashboard/src/app/page.tsx | 115 ++++++++++++++---- .../components/LeftPanel/ExportSection.tsx | 23 +--- apps/dashboard/src/components/OgEditor.tsx | 9 +- apps/dashboard/src/components/OgImage.tsx | 39 ++++++ apps/dashboard/src/lib/elements.ts | 23 ++++ apps/dashboard/src/lib/fonts.ts | 27 ++++ pnpm-lock.yaml | 13 ++ 11 files changed, 239 insertions(+), 52 deletions(-) create mode 100644 apps/dashboard/src/app/[id]/page.tsx create mode 100644 apps/dashboard/src/components/OgImage.tsx diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js index 509ab00..8b1ea01 100644 --- a/apps/dashboard/next.config.js +++ b/apps/dashboard/next.config.js @@ -5,7 +5,8 @@ const nextConfig = { }, typescript: { ignoreBuildErrors: true, - } + }, + reactStrictMode: false, } module.exports = nextConfig diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index c832b33..87c8f15 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -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" } } diff --git a/apps/dashboard/src/app/[id]/page.tsx b/apps/dashboard/src/app/[id]/page.tsx new file mode 100644 index 0000000..e3bb118 --- /dev/null +++ b/apps/dashboard/src/app/[id]/page.tsx @@ -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 ( + + ) +} diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 56a5857..40b13bc 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -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; } diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index 6971fc1..fbe81e2 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -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) => void +} + +function OgImageWrapper({ href, elements, children, deletable }: OgImageWrapperProps) { + return ( + + {elements ? ( + }> + + + ) : null} + {children} + {deletable ? ( + + + + ) : null} + + ) +} + +interface OGImage { + id: string + content: OGElement[] +} export default function Home() { + const [ogImages, setOgImages] = useState([]) + + useEffect(() => { + const images = Object.keys(localStorage).reduce((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) { + event.preventDefault(); + event.stopPropagation(); + + localStorage.removeItem(ogImage) + setOgImages(ogImages.filter((image) => image.id !== ogImage)) + } + } + return ( - + <> + +
+
+
+

Templates

+
+ + + +
+
+
+
+

My OG images

+
+ + Start from scratch + + {ogImages.map(ogImage => { + return ( + + ) + })} +
+
+
+
+ ) } diff --git a/apps/dashboard/src/components/LeftPanel/ExportSection.tsx b/apps/dashboard/src/components/LeftPanel/ExportSection.tsx index cc783a8..bb31937 100644 --- a/apps/dashboard/src/components/LeftPanel/ExportSection.tsx +++ b/apps/dashboard/src/components/LeftPanel/ExportSection.tsx @@ -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 {} @@ -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) diff --git a/apps/dashboard/src/components/OgEditor.tsx b/apps/dashboard/src/components/OgEditor.tsx index 49e7683..e47d4e6 100644 --- a/apps/dashboard/src/components/OgEditor.tsx +++ b/apps/dashboard/src/components/OgEditor.tsx @@ -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"; @@ -35,6 +36,7 @@ export function useOg() { interface OgProviderProps { initialElements: OGElement[] + localStorageKey: string width: number height: number } @@ -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(null) const [elements, setRealElements] = useState(() => { - 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[] @@ -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) => { diff --git a/apps/dashboard/src/components/OgImage.tsx b/apps/dashboard/src/components/OgImage.tsx new file mode 100644 index 0000000..20d9dd7 --- /dev/null +++ b/apps/dashboard/src/components/OgImage.tsx @@ -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 +} diff --git a/apps/dashboard/src/lib/elements.ts b/apps/dashboard/src/lib/elements.ts index ac54b25..dff865e 100644 --- a/apps/dashboard/src/lib/elements.ts +++ b/apps/dashboard/src/lib/elements.ts @@ -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); } diff --git a/apps/dashboard/src/lib/fonts.ts b/apps/dashboard/src/lib/fonts.ts index cf9ee66..799414a 100644 --- a/apps/dashboard/src/lib/fonts.ts +++ b/apps/dashboard/src/lib/fonts.ts @@ -1,3 +1,5 @@ +import type { OGElement } from "./types" + export const FONTS = [ 'Roboto', 'Open Sans', @@ -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, + } + })) +} + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26b9543..74060bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: react-dom: specifier: ^18 version: 18.2.0(react@18.2.0) + react-promise-suspense: + specifier: ^0.3.4 + version: 0.3.4 satori: specifier: ^0.10.11 version: 0.10.11 @@ -2040,6 +2043,10 @@ packages: strip-final-newline: 3.0.0 dev: true + /fast-deep-equal@2.0.1: + resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -3345,6 +3352,12 @@ packages: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: true + /react-promise-suspense@0.3.4: + resolution: {integrity: sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==} + dependencies: + fast-deep-equal: 2.0.1 + dev: false + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'}