diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..71b67b1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yml] +indent_size = 2 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2c48dea --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + - directory: /.github/workflows + package-ecosystem: github-actions + schedule: + interval: monthly + - directory: / + package-ecosystem: npm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8db4485 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.x + + - uses: actions/cache@v4 + with: + path: node_modules + key: ${{ runner.os }}-node-${{ hashFiles('bun.lockb') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install Dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + deploy: + runs-on: ubuntu-latest + if: github.event_name == 'push' + needs: + - build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist/ + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28dc2b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,196 @@ +# Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node +# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,macos,node + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,macos,node diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7859abc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,15 @@ +{ + "editor.foldingImportsByDefault": true, + "editor.defaultFormatter": null, + "editor.formatOnSave": true, + "editor.linkedEditing": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..db55d61 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,11 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "dev", + "type": "bun", + "script": "dev", + "group": "none", + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..947e398 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Axel Rindle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b0cd87 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# mjml.app + +> ✉️ A playground with live-preview capabilities for MJML, the Mailjet Markup Language + +## License + +[MIT](LICENSE) diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..bc0e094 Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..24510b2 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..a02cb09 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,31 @@ +import { configActDefault, configActReact } from '@actcoding/eslint-config' +import pluginQuery from '@tanstack/eslint-plugin-query' + +/** @type import('eslint').Linter.Config[] */ +const config = [ + ...configActDefault, + ...configActReact, + { + name: 'app/ignores', + ignores: [ + '*.d.ts', + ], + }, + { + name: 'app/react', + rules: { + '@react/react-in-jsx-scope': 'off', + '@react/prop-types': 'off', + }, + }, + { + name: 'app/tailwind', + rules: { + '@tailwindcss/no-custom-classname': 'off', + }, + }, + + ...pluginQuery.configs['flat/recommended'], +] + +export default config diff --git a/index.html b/index.html new file mode 100644 index 0000000..ad4205b --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + MJML Playground + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..34f7fec --- /dev/null +++ b/package.json @@ -0,0 +1,72 @@ +{ + "private": true, + "name": "mjml.app", + "description": "✉️ A playground with live-preview capabilities for MJML, the Mailjet Markup Language", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.1.0", + "@fortawesome/fontawesome-svg-core": "^6.6.0", + "@fortawesome/free-brands-svg-icons": "^6.6.0", + "@fortawesome/free-solid-svg-icons": "^6.6.0", + "@fortawesome/react-fontawesome": "^0.2.2", + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-alert-dialog": "^1.1.2", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-menubar": "^1.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", + "@radix-ui/react-tooltip": "^1.1.3", + "@tanstack/react-query": "^5.61.5", + "@tanstack/react-router": "^1.70.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "dot-prop": "^9.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-minify-whitespace": "^1.0.1", + "hast-util-to-html": "^9.0.3", + "json-edit-react": "^1.17.0", + "lucide-react": "^0.453.0", + "mjml-browser": "^4.15.3", + "nanoid": "^5.0.7", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "usehooks-ts": "^3.1.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@actcoding/eslint-config": "^0.0.8", + "@tanstack/eslint-plugin-query": "^5.61.4", + "@tanstack/react-query-devtools": "^5.61.5", + "@tanstack/router-devtools": "^1.70.0", + "@tanstack/router-plugin": "^1.69.1", + "@types/hast": "^3.0.4", + "@types/mjml-browser": "^4.15.0", + "@types/mjml-core": "^4.15.0", + "@types/node": "^22.7.6", + "@types/react": "^18.3.10", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "autoprefixer": "^10.4.20", + "eslint": "^9.11.1", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "type-fest": "^4.26.1", + "typescript": "^5.5.3", + "vite": "^5.4.8" + } +} \ No newline at end of file diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..d41ad63 --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..aa18f99 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..8103ee6 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/icon-192-maskable.png b/public/icon-192-maskable.png new file mode 100644 index 0000000..585b672 Binary files /dev/null and b/public/icon-192-maskable.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..9723c86 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512-maskable.png b/public/icon-512-maskable.png new file mode 100644 index 0000000..c2bd209 Binary files /dev/null and b/public/icon-512-maskable.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..67c053e Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..0f908c3 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json.schemastore.org/web-manifest-combined.json", + "background_color": "#eb5f3f", + "theme_color": "#eb5f3f", + "lang": "en", + "dir": "ltr", + "orientation": "landscape", + "display": "standalone", + "categories": [ + "developer tools", + "utilities", + "design" + ], + "name": "MJML Playground", + "short_name": "MJML", + "description": "✉️ A playground with live-preview capabilities for MJML, the Mailjet Markup Language", + "icons": [ + { + "src": "/favicon.ico", + "type": "image/x-icon", + "sizes": "16x16 32x32" + }, + { + "src": "/icon-192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "/icon-512.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon-192-maskable.png", + "type": "image/png", + "sizes": "192x192", + "purpose": "maskable" + }, + { + "src": "/icon-512-maskable.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/src/assets/fileJson.svg b/src/assets/fileJson.svg new file mode 100644 index 0000000..b22a791 --- /dev/null +++ b/src/assets/fileJson.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + diff --git a/src/assets/fileXml.svg b/src/assets/fileXml.svg new file mode 100644 index 0000000..c97a17a --- /dev/null +++ b/src/assets/fileXml.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + diff --git a/src/assets/undraw_no_data_re_kwbl.svg b/src/assets/undraw_no_data_re_kwbl.svg new file mode 100644 index 0000000..36d0aca --- /dev/null +++ b/src/assets/undraw_no_data_re_kwbl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/ActionTooltip.tsx b/src/components/ActionTooltip.tsx new file mode 100644 index 0000000..e886e00 --- /dev/null +++ b/src/components/ActionTooltip.tsx @@ -0,0 +1,47 @@ +import { IconDefinition } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { ReactNode } from '@tanstack/react-router' +import { Simplify } from 'type-fest' +import { Button } from './ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip' + +type Props = Simplify<{ + label: string + icon: IconDefinition | string + action?: () => void | Promise + children?: (children: ReactNode) => ReactNode +}> + +function Icon({ icon }: Props) { + if (typeof icon === 'string') { + return + } + + return +} + +function IconButton(props: Props) { + return ( + + ) +} + +export default function ActionTooltip(props: Props) { + return ( + + + {props.children ? props.children(IconButton(props)) : IconButton(props)} + + +

+ {props.label} +

+
+
+ ) +} diff --git a/src/components/AppSettingsDialog.tsx b/src/components/AppSettingsDialog.tsx new file mode 100644 index 0000000..84ddeb2 --- /dev/null +++ b/src/components/AppSettingsDialog.tsx @@ -0,0 +1,73 @@ +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { useAppSettings } from '@/hooks/use-app-settings' +import { faCog } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { zodResolver } from '@hookform/resolvers/zod' +import { useCallback, useState } from 'react' +import { useForm } from 'react-hook-form' +import { AppSettings, appSettingsSchema } from './context/settings/context' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form' +import { Input } from './ui/input' + +export default function AppSettingsDialog() { + const { settings, overwriteSettings } = useAppSettings() + + const [open, setOpen] = useState(false) + + const form = useForm({ + resolver: zodResolver(appSettingsSchema), + defaultValues: settings, + }) + + const onSubmit = useCallback((values: AppSettings) => { + overwriteSettings(values) + setOpen(false) + }, [overwriteSettings, setOpen]) + + return ( + + + + + +
+ + + + Settings + + + Customize the application to your likings. + + + ( + + Editor Font Size + + + + + + )} + /> + + + Cancel + + +
+ +
+
+ ) +} diff --git a/src/components/AppTemplatesDialog.tsx b/src/components/AppTemplatesDialog.tsx new file mode 100644 index 0000000..f8745e3 --- /dev/null +++ b/src/components/AppTemplatesDialog.tsx @@ -0,0 +1,163 @@ +import { useAppEditor } from '@/hooks/use-app-editor' +import { useAppStorage } from '@/hooks/use-app-storage' +import { useToast } from '@/hooks/use-toast' +import { cn } from '@/lib/utils' +import { faFileCode, faTrash } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { zodResolver } from '@hookform/resolvers/zod' +import { useCallback, useMemo } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { Button } from './ui/button' +import { Card, CardHeader, CardTitle } from './ui/card' +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form' +import { Input } from './ui/input' +import { ScrollArea } from './ui/scroll-area' + +import imageEmpty from '@/assets/undraw_no_data_re_kwbl.svg' +import { MJMLJsonObject } from '@/mjml/types' + +const formSchema = z.strictObject({ + search: z.string(), +}) + +type FormSchema = z.infer + +export default function AppTemplatesDialog() { + const { toast } = useToast() + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + search: '', + }, + }) + + const onSubmit = useCallback((values: FormSchema) => { + console.log(values) + }, []) + + const { templates, removeTemplate } = useAppStorage() + const amount = useMemo(() => Object.keys(templates).length, [templates]) + + const { search } = form.watch() + const filteredTemplates = useMemo(() => { + const entries = Object.entries(templates) + if (search.length === 0) { + return entries + } + + return entries.filter(([e]) => e.toLocaleLowerCase().includes(search.toLocaleLowerCase())) + }, [search, templates]) + + const editor = useAppEditor() + const onSelect = useCallback((name: string, data: MJMLJsonObject) => { + editor.filename[1](name) + editor.data[1](data) + + toast({ + title: 'Template geladen', + }) + }, [editor.data, editor.filename, toast]) + + return ( + + + + + + + + Templates + + + Choose a template to load or create a blank template. + + + +
+ + ( + + Search + + + + + + )} + /> + + + + + +
+ {filteredTemplates.length === 0 ? + ( +
+ +

+ Noch nichts gespeichert. +

+
+ ) : + filteredTemplates.map(([name, data], i) => ( +
+ onSelect(name, data)} + > + + + {name} + + + + +
+ )) + } +
+ +
+ + +

+ {search.length === 0 + ? ( + {amount} templates available + ) + : ( + {filteredTemplates.length} / {amount} templates available + ) + } +

+
+ + Close + +
+
+
+ ) +} diff --git a/src/components/ComponentChooseDialog.tsx b/src/components/ComponentChooseDialog.tsx new file mode 100644 index 0000000..3b28c8a --- /dev/null +++ b/src/components/ComponentChooseDialog.tsx @@ -0,0 +1,126 @@ +import { MjmlComponent, mjmlComponents } from '@/mjml/components' +import { faExternalLink } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { zodResolver } from '@hookform/resolvers/zod' +import { useCallback } from 'react' +import { useForm } from 'react-hook-form' +import { UnionToTuple } from 'type-fest' +import { z } from 'zod' +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog' +import { Button } from './ui/button' +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from './ui/form' +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from './ui/select' + +const components = Object.values(mjmlComponents).reduce((a, b) => [...a, ...b], []) as UnionToTuple + +const formSchema = z.strictObject({ + component: z.enum(components), +}) + +type FormSchema = z.infer + +type Props = { + open: boolean + setOpen: (open: boolean) => void + onSubmit: (values: FormSchema) => void +} + +export default function ComponentChooseDialog({ + onSubmit, + open, setOpen, +}: Props) { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + component: 'mj-text', + }, + }) + + const handleSubmit = useCallback((values: FormSchema) => { + onSubmit(values) + setOpen(false) + }, [onSubmit, setOpen]) + + return ( + + + + + Add Component + + + Choose an MJML component to insert into the tree. + + + +
+ + ( + + + MJML Component + + + + An extensive documentation on all the components can be + found in the MJML documentation + + + + )} + /> + + + + + + + + + +
+
+ ) +} diff --git a/src/components/DarkModeToggle.tsx b/src/components/DarkModeToggle.tsx new file mode 100644 index 0000000..1f441b5 --- /dev/null +++ b/src/components/DarkModeToggle.tsx @@ -0,0 +1,29 @@ +import { useDarkMode } from 'usehooks-ts' +import { Button } from './ui/button' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons' +import { useEffect } from 'react' + +const element = document.querySelector('html') as HTMLHtmlElement + +export default function DarkModeToggle() { + const { isDarkMode, toggle } = useDarkMode() + + useEffect(() => { + if (isDarkMode) { + element.classList.add('dark') + } else { + element.classList.remove('dark') + } + }, [isDarkMode]) + + return ( + + ) +} diff --git a/src/components/Devtools.tsx b/src/components/Devtools.tsx new file mode 100644 index 0000000..959c10b --- /dev/null +++ b/src/components/Devtools.tsx @@ -0,0 +1,28 @@ +import { lazy, Suspense } from 'react' + +const TanStackRouterDevtools = + process.env.NODE_ENV === 'production' + ? () => null + : lazy(() => + import('@tanstack/router-devtools').then((res) => ({ + default: res.TanStackRouterDevtools, + })), + ) + +const TanStackQueryDevtools = + process.env.NODE_ENV === 'production' + ? () => null + : lazy(() => + import('@tanstack/react-query-devtools').then((res) => ({ + default: res.ReactQueryDevtools, + })), + ) + +export default function Devtools() { + return (<> + + + + + ) +} diff --git a/src/components/TemplateSaveDialog.tsx b/src/components/TemplateSaveDialog.tsx new file mode 100644 index 0000000..a6e94f9 --- /dev/null +++ b/src/components/TemplateSaveDialog.tsx @@ -0,0 +1,92 @@ +import { useAppEditor } from '@/hooks/use-app-editor' +import { useAppStorage } from '@/hooks/use-app-storage' +import { zodResolver } from '@hookform/resolvers/zod' +import { PropsWithChildren, useCallback, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog' +import { Button } from './ui/button' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from './ui/form' +import { Input } from './ui/input' + +export default function TemplateSaveDialog({ children }: PropsWithChildren) { + const { + data: [data], + filename: [filename, setFilename], + } = useAppEditor() + + const { hasTemplate, saveTemplate } = useAppStorage() + + const [open, setOpen] = useState(false) + + const formSchema = useMemo(() => z.strictObject({ + filename: z.string().min(1), + }), []) + + type FormSchema = z.infer + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + filename, + }, + }) + + const onSubmit = useCallback((values: FormSchema) => { + saveTemplate(values.filename, data) + + setFilename(values.filename) + form.reset(values) + + setOpen(false) + }, [data, form, saveTemplate, setFilename]) + + const values = form.watch() + const exists = useMemo(() => hasTemplate(values.filename), [hasTemplate, values.filename]) + + return ( + + {children} + + + + Speichern + + + +
+ + ( + + Filename + + + + + + )} + /> + + + + Abbrechen + + {exists ? ( + + ) : ( + + )} + + + +
+
+ ) +} diff --git a/src/components/context/editor/context.ts b/src/components/context/editor/context.ts new file mode 100644 index 0000000..62f29a8 --- /dev/null +++ b/src/components/context/editor/context.ts @@ -0,0 +1,22 @@ +import { MJMLJsonObject, ZMJMLJsonObject } from '@/mjml/types' +import { createContext, Dispatch, SetStateAction } from 'react' +import { z } from 'zod' + +export const appEditorSchema = z.strictObject({ + filename: z.string().optional(), + data: ZMJMLJsonObject, +}) + +export type AppEditorSchema = z.infer + +type State = [S, Dispatch>] +type LocalStorageState = [...State, () => void] + +export type Context = { + filename: LocalStorageState + data: State + persist: () => void + reset: () => void +} + +export const AppEditorContext = createContext({} as Context) diff --git a/src/components/context/editor/provider.tsx b/src/components/context/editor/provider.tsx new file mode 100644 index 0000000..3efa12e --- /dev/null +++ b/src/components/context/editor/provider.tsx @@ -0,0 +1,83 @@ +import { MJMLJsonObject } from '@/mjml/types' +import { PropsWithChildren, useState } from 'react' +import { AppEditorContext, Context } from './context' +import { useLocalStorage } from 'usehooks-ts' + +const storageKey = 'template' + +const defaultData: MJMLJsonObject = { + tagName: 'mjml', + children: [ + { + tagName: 'mj-head', + children: [ + { + tagName: 'mj-attributes', + children: [ + { + tagName: 'mj-all', + attributes: { + 'font-family': 'sans-serif', + }, + }, + { + tagName: 'mj-text', + attributes: { + 'font-size': '14px', + }, + }, + ], + }, + ], + }, + { + tagName: 'mj-body', + attributes: { + 'background-color': '#f1f5f9', + }, + children: [ + { + tagName: 'mj-section', + children: [ + { + tagName: 'mj-column', + children: [ + { + tagName: 'mj-text', + content: 'Hello World', + }, + ], + }, + ], + }, + ], + }, + ], +} +const initialData = localStorage.getItem(storageKey) + +export function AppEditorProvider({ children }: PropsWithChildren) { + const filename = useLocalStorage('lastFilename', 'template.mjml') + const data = useState( + initialData !== null + ? JSON.parse(initialData) + : defaultData, + ) + + const context: Context = { + filename, + data, + persist() { + localStorage.setItem(storageKey, JSON.stringify(data[0])) + }, + reset() { + data[1](defaultData) + }, + } + + return ( + + {children} + + ) +} diff --git a/src/components/context/settings/context.ts b/src/components/context/settings/context.ts new file mode 100644 index 0000000..b38edd8 --- /dev/null +++ b/src/components/context/settings/context.ts @@ -0,0 +1,25 @@ +import { createContext } from 'react' +import type { Get, Paths } from 'type-fest' +import { z } from 'zod' + +export const appSettingsSchema = z.strictObject({ + editor: z.strictObject({ + fontSize: z.coerce.number().int().min(8).max(20), + }), +}) + +export type AppSettings = z.infer + +export type Context = { + settings: AppSettings + updateSettings: >(key: Path, value: Get) => void + overwriteSettings: (newSettings: AppSettings) => void +} + +export const AppSettingsContext = createContext({ + settings: { + editor: { + fontSize: 14, + }, + }, +} as Context) diff --git a/src/components/context/settings/provider.tsx b/src/components/context/settings/provider.tsx new file mode 100644 index 0000000..dfc0ab2 --- /dev/null +++ b/src/components/context/settings/provider.tsx @@ -0,0 +1,67 @@ +import { PropsWithChildren, useEffect, useMemo, useState } from 'react' +import { AppSettings, AppSettingsContext, appSettingsSchema, Context } from './context' +import { setProperty } from 'dot-prop' + +const defaultSettings: AppSettings = { + editor: { + fontSize: 14, + }, +} + +const storageKey = 'app-settings' + +export function AppSettingsProvider({ children }: PropsWithChildren) { + const [ready, setReady] = useState(false) + const [settings, setSettings] = useState(defaultSettings) + + const context = useMemo(() => ({ + settings, + updateSettings(key, value) { + const copy = Object.assign({}, settings) + + setProperty(copy, key, value) + setSettings(copy) + }, + overwriteSettings(newSettings) { + setSettings(newSettings) + }, + }), [settings]) + + // persistence + useEffect(() => { + if (!ready) { + return + } + + localStorage.setItem(storageKey, JSON.stringify(settings)) + }, [ready, settings]) + + // initial load + useEffect(() => { + const fromStorage = localStorage.getItem(storageKey) + if (fromStorage === null) { + setReady(true) + return + } + + try { + const parsed = JSON.parse(fromStorage) + const validated = appSettingsSchema.parse(parsed) + setSettings(validated) + } catch (error) { + // ignore + } finally { + setReady(true) + } + }, []) + + if (!ready) { + return null + } + + return ( + + {children} + + ) +} diff --git a/src/components/context/storage/context.ts b/src/components/context/storage/context.ts new file mode 100644 index 0000000..7b87cdc --- /dev/null +++ b/src/components/context/storage/context.ts @@ -0,0 +1,18 @@ +import { MJMLJsonObject, ZMJMLJsonObject } from '@/mjml/types' +import { createContext } from 'react' +import { z } from 'zod' + +export const ZTemplates = z.record(z.string().min(1), ZMJMLJsonObject) + +export type Templates = z.infer + +export type Context = { + templates: Templates + + getTemplate: (name: string) => MJMLJsonObject | undefined + hasTemplate: (name: string) => boolean + saveTemplate: (name: string, data: MJMLJsonObject) => void + removeTemplate: (name: string) => void +} + +export const AppStorageContext = createContext({} as Context) diff --git a/src/components/context/storage/provider.tsx b/src/components/context/storage/provider.tsx new file mode 100644 index 0000000..fe02710 --- /dev/null +++ b/src/components/context/storage/provider.tsx @@ -0,0 +1,87 @@ +import { useToast } from '@/hooks/use-toast' +import { MJMLJsonObject } from '@/mjml/types' +import { useMutation, UseMutationResult, useQuery, UseQueryResult } from '@tanstack/react-query' +import { PropsWithChildren, useCallback, useMemo } from 'react' +import { AppStorageContext, Context, Templates, ZTemplates } from './context' + +const storageKey = 'templates' + +// TODO: Use IndexedDB or something else more db-like + +function useTemplates(): [UseQueryResult, UseMutationResult] { + const { toast } = useToast() + + const query = useQuery({ + queryKey: ['templates'], + initialData: {}, + queryFn: () => { + const data = localStorage.getItem(storageKey) + if (data === null) { + localStorage.setItem(storageKey, '{}') + return {} + } + + const { success, data: parsed, error } = ZTemplates.safeParse(JSON.parse(data)) + if (!success) { + toast({ + title: 'Failed to load templates!', + description: error.message, + variant: 'destructive', + }) + console.error(error) + + localStorage.setItem(`${storageKey}-backup`, data) + localStorage.setItem(storageKey, '{}') + return {} + } + + return parsed + }, + }) + + const mutation = useMutation({ + mutationFn: async (templates: Templates) => { + localStorage.setItem(storageKey, JSON.stringify(templates)) + await query.refetch() + }, + }) + + return [query, mutation] +} + +export function AppStorageProvider({ children }: PropsWithChildren) { + const [ + { data }, + { mutate }, + ] = useTemplates() + + const templates = useMemo(() => data ?? {}, [data]) + + const getTemplate = useCallback((name: string) => templates[name], [templates]) + const hasTemplate = useCallback((name: string) => getTemplate(name) !== undefined, [getTemplate]) + const removeTemplate = useCallback((name: string) => { + const filtered = Object.fromEntries(Object.entries(templates).filter(([key]) => key !== name)) + mutate(filtered) + }, [mutate, templates]) + const saveTemplate = useCallback((name: string, data: MJMLJsonObject) => { + mutate({ + ...templates, + [name]: data, + }) + }, [mutate, templates]) + + const context: Context = { + templates, + + getTemplate, + hasTemplate, + removeTemplate, + saveTemplate, + } + + return ( + + {children} + + ) +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..a77e9b8 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..13a8259 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + }, +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..77e9fb7 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..1cc6c16 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { Cross2Icon } from '@radix-ui/react-icons' + +import { cn } from '@/lib/utils' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..089bca5 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form' + +import { cn } from '@/lib/utils' +import { Label } from '@/components/ui/label' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = 'FormItem' + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +