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/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/my-images/page.tsx b/apps/dashboard/src/app/my-images/page.tsx new file mode 100644 index 0000000..e2c264c --- /dev/null +++ b/apps/dashboard/src/app/my-images/page.tsx @@ -0,0 +1,5 @@ +export default function Templates() { + return ( +

Todo

+ ) +} diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index 6971fc1..16cd065 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -1,32 +1,12 @@ -'use client' -import { OgEditor } from "../components/OgEditor"; -import { createElementId } from "../lib/elements"; -import type { OGElement } from "../lib/types"; - -/** - * 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', - }, -] +import { Suspense } from "react"; +import { OgSplash } from "../components/OgSplash"; export default function Home() { return ( - + // OgSplash uses `useSearchParams()` so we need to wrap it in a Suspense + // to allow to statically render the page: https://nextjs.org/docs/app/building-your-application/rendering/server-components#dynamic-functions + + + ) } diff --git a/apps/dashboard/src/app/templates/page.tsx b/apps/dashboard/src/app/templates/page.tsx new file mode 100644 index 0000000..e2c264c --- /dev/null +++ b/apps/dashboard/src/app/templates/page.tsx @@ -0,0 +1,5 @@ +export default function Templates() { + return ( +

Todo

+ ) +} diff --git a/apps/dashboard/src/components/Button.tsx b/apps/dashboard/src/components/Button.tsx deleted file mode 100644 index 0e1c916..0000000 --- a/apps/dashboard/src/components/Button.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { ReactNode } from "react" - -interface ButtonProps { - icon?: ReactNode - variant?: 'danger' - onClick: () => void - isLoading?: boolean - className?: string - children: ReactNode -} - -export function Button({ icon, variant, onClick, isLoading, className, children }: ButtonProps) { - return ( - - ) -} diff --git a/apps/dashboard/src/components/CustomLink.tsx b/apps/dashboard/src/components/CustomLink.tsx new file mode 100644 index 0000000..d35d196 --- /dev/null +++ b/apps/dashboard/src/components/CustomLink.tsx @@ -0,0 +1,20 @@ +import Link from "next/link" +import type { ReactNode } from "react" + +interface CustomLinkProps { + icon?: ReactNode + iconPosition?: 'left' | 'right' + href: string + children: ReactNode +} + +export function CustomLink({ icon, iconPosition = 'left', href, children }: CustomLinkProps) { + return ( + + {icon && iconPosition === 'left' ? icon : null} + {children} + {icon && iconPosition === 'right' ? icon : null} + + ) +} + diff --git a/apps/dashboard/src/components/LeftPanel/ExportSection.tsx b/apps/dashboard/src/components/LeftPanel/ExportSection.tsx index cc783a8..bed0eab 100644 --- a/apps/dashboard/src/components/LeftPanel/ExportSection.tsx +++ b/apps/dashboard/src/components/LeftPanel/ExportSection.tsx @@ -1,35 +1,16 @@ import { useState } from "react"; import { flushSync } from "react-dom"; -import { Button } from "../Button"; +import { Button } from "../forms/Button"; 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/LeftPanel/ModificationsSection.tsx b/apps/dashboard/src/components/LeftPanel/ModificationsSection.tsx index 84b2388..785c869 100644 --- a/apps/dashboard/src/components/LeftPanel/ModificationsSection.tsx +++ b/apps/dashboard/src/components/LeftPanel/ModificationsSection.tsx @@ -1,4 +1,4 @@ -import { Button } from "../Button"; +import { Button } from "../forms/Button"; import { useOg } from "../OgEditor"; import { DeleteIcon } from "../icons/DeleteIcon"; import { RedoIcon } from "../icons/RedoIcon"; diff --git a/apps/dashboard/src/components/LeftPanel/SplashSection.tsx b/apps/dashboard/src/components/LeftPanel/SplashSection.tsx new file mode 100644 index 0000000..ac92b21 --- /dev/null +++ b/apps/dashboard/src/components/LeftPanel/SplashSection.tsx @@ -0,0 +1,12 @@ +import { CustomLink } from "../CustomLink"; +import { ArrowLeftIcon } from "../icons/ArrowLeftIcon"; + +export function SplashSection() { + return ( + }> + Back + + ) +} + + diff --git a/apps/dashboard/src/components/LeftPanel/index.tsx b/apps/dashboard/src/components/LeftPanel/index.tsx index 2ff403d..3d2a805 100644 --- a/apps/dashboard/src/components/LeftPanel/index.tsx +++ b/apps/dashboard/src/components/LeftPanel/index.tsx @@ -1,10 +1,13 @@ import { ExportSection } from "./ExportSection"; import { ModificationSection } from "./ModificationsSection"; import { ElementsSection } from "./ElementsSection"; +import { SplashSection } from "./SplashSection"; export function LeftPanel() { return (
+ +
diff --git a/apps/dashboard/src/components/OgEditor.tsx b/apps/dashboard/src/components/OgEditor.tsx index 49e7683..c50083d 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,17 +46,10 @@ 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 - - if (item) { - return JSON.parse(item) as OGElement[] - } - - return initialElements - }) + const [elements, setRealElements] = useState([]) const rootRef = useRef(null) const setSelectedElement = useCallback((id: string | null) => { @@ -84,8 +79,8 @@ export function OgEditor({ initialElements, width, height }: OgProviderProps) { return newElements }) - localStorage.setItem('elements', JSON.stringify(newElements)) - }, []) + localStorage.setItem(localStorageKey, JSON.stringify(newElements)) + }, [localStorageKey]) const updateElement = useCallback((element: OGElement) => { const index = elements.findIndex(item => item.id === element.id) @@ -141,15 +136,25 @@ export function OgEditor({ initialElements, width, height }: OgProviderProps) { }, [setElements]) /** - * Immediately load fonts for elements that are visible on the page. + * When the editor image is updated or loaded for the first time, reset every + * state, and load the elements and fonts. */ useEffect(() => { - elements.forEach(element => { + const item = localStorage.getItem(localStorageKey) + const ogElements = item ? JSON.parse(item) as OGElement[] : initialElements + + setRealElements(ogElements) + setSelectedElement(null) + edits.length = 0 + editIndex = -1 + + // Immediately load fonts for elements that will be visible on the page. + ogElements.forEach(element => { if (element.tag === 'p' || element.tag === 'span') { maybeLoadFont(element.fontFamily, element.fontWeight) } }) - }, []) + }, [localStorageKey, initialElements]) useEffect(() => { function onContextMenu(event: MouseEvent) { @@ -253,10 +258,10 @@ export function OgEditor({ initialElements, width, height }: OgProviderProps) { return (
-
+
-
+

{width}x{height}

@@ -268,7 +273,7 @@ export function OgEditor({ initialElements, width, height }: OgProviderProps) {
-
+
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/components/OgSplash.tsx b/apps/dashboard/src/components/OgSplash.tsx new file mode 100644 index 0000000..873bc45 --- /dev/null +++ b/apps/dashboard/src/components/OgSplash.tsx @@ -0,0 +1,118 @@ +'use client' +import type { MouseEvent, ReactNode } from "react"; +import { Suspense, useEffect, useState } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { INITIAL_ELEMENTS, createElementId } from "../lib/elements"; +import type { OGElement } from "../lib/types"; +import { OgEditor } from "./OgEditor"; +import { DeleteIcon } from "./icons/DeleteIcon"; +import { AddIcon } from "./icons/AddIcon"; +import { OgImage } from "./OgImage"; +import { ArrowRightIcon } from "./icons/ArrowRightIcon"; +import { CustomLink } from "./CustomLink"; + +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 function OgSplash() { + const searchParams = useSearchParams(); + const image = searchParams.get('i') + 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(({ id }) => id !== ogImage)) + } + } + + return ( + <> + + {image ? null : ( +
+
+
+
+

Templates

+ } iconPosition="right"> + See all + +
+
+ + + +
+
+
+
+
+

My OG images

+ } iconPosition="right"> + See all + +
+
+ + Start from scratch + + {ogImages.map(ogImage => { + return ( + + ) + })} +
+
+
+
+ )} + + ) +} diff --git a/apps/dashboard/src/components/RightPanel/ModificationsSection.tsx b/apps/dashboard/src/components/RightPanel/ModificationsSection.tsx index 977fef6..5d2f658 100644 --- a/apps/dashboard/src/components/RightPanel/ModificationsSection.tsx +++ b/apps/dashboard/src/components/RightPanel/ModificationsSection.tsx @@ -1,5 +1,5 @@ import type { OGElement } from "../../lib/types"; -import { Button } from "../Button"; +import { Button } from "../forms/Button"; import { useOg } from "../OgEditor"; import { DeleteIcon } from "../icons/DeleteIcon"; diff --git a/apps/dashboard/src/components/icons/ArrowLeftIcon.tsx b/apps/dashboard/src/components/icons/ArrowLeftIcon.tsx new file mode 100644 index 0000000..341fe0c --- /dev/null +++ b/apps/dashboard/src/components/icons/ArrowLeftIcon.tsx @@ -0,0 +1,7 @@ +import type { SVGProps } from "react"; + +export function ArrowLeftIcon(props: SVGProps) { + return ( + + ) +} diff --git a/apps/dashboard/src/components/icons/ArrowRightIcon.tsx b/apps/dashboard/src/components/icons/ArrowRightIcon.tsx new file mode 100644 index 0000000..a1ad0a0 --- /dev/null +++ b/apps/dashboard/src/components/icons/ArrowRightIcon.tsx @@ -0,0 +1,7 @@ +import type { SVGProps } from "react"; + +export function ArrowRightIcon(props: SVGProps) { + 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'}