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'}