diff --git a/apps/preview/app/index.html b/apps/preview/app/index.html index 076024db..400511d1 100644 --- a/apps/preview/app/index.html +++ b/apps/preview/app/index.html @@ -3,13 +3,21 @@ + + + + + - jsx-email + Jsx-email Preview - +
diff --git a/apps/preview/app/postcss.config.cjs b/apps/preview/app/postcss.config.cjs index 5b5e3b1f..b9b33762 100644 --- a/apps/preview/app/postcss.config.cjs +++ b/apps/preview/app/postcss.config.cjs @@ -1,7 +1,7 @@ /* eslint-disable */ module.exports = { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, autoprefixer: {} } }; diff --git a/apps/preview/app/public/favicon.ico b/apps/preview/app/public/favicon.ico new file mode 100644 index 00000000..c14bb261 Binary files /dev/null and b/apps/preview/app/public/favicon.ico differ diff --git a/apps/preview/app/src/App.tsx b/apps/preview/app/src/App.tsx new file mode 100644 index 00000000..cb9e8741 --- /dev/null +++ b/apps/preview/app/src/App.tsx @@ -0,0 +1,48 @@ +import { Provider as MobxProvider, observer } from 'mobx-react'; +import { StrictMode, useState } from 'react'; +import { HashRouter, Route, Routes } from 'react-router-dom'; + +import { useAppStore } from './composables/useAppStore'; +import { Shell } from './layouts/Shell'; +import { AppStore } from './stores/AppStore'; +import { Index } from './views/Index'; +import { Preview } from './views/Preview/index'; + +const Router = observer(() => { + const appStore = useAppStore(); + + return ( + <> + {appStore.templates.isReady && ( + + + }> + } /> + + {Object.values(appStore.templates.records).map((template, index) => ( + } + /> + ))} + + + + + )} + + ); +}); + +export const App = () => { + const [appStore] = useState(() => new AppStore()); + + return ( + + + + + + ); +}; diff --git a/apps/preview/app/src/app.ts b/apps/preview/app/src/app.ts deleted file mode 100644 index 9d4f2a84..00000000 --- a/apps/preview/app/src/app.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const setup = () => { - // Note: Disables annoying key errors. We're static so we don't need to worry about this. - /* eslint-disable no-console */ - const og = console.error; - const re = - /^Warning: Each child in an array or iterator should have a unique "key" prop|^Warning: Each child in a list should have a unique "key" prop/; - console.error = (...args) => { - const [line] = args; - if (!re.test(line)) og(...args); - }; -}; diff --git a/apps/preview/app/src/components/DefaultPopupFooter.tsx b/apps/preview/app/src/components/DefaultPopupFooter.tsx new file mode 100644 index 00000000..eeb20ef7 --- /dev/null +++ b/apps/preview/app/src/components/DefaultPopupFooter.tsx @@ -0,0 +1,29 @@ +import { Icon } from '@iconify/react'; +import { Link as ReactRouterDomLink } from 'react-router-dom'; + +import { Button } from './ui/Button'; +import { Link } from './ui/Link'; +import { Separator } from './ui/Separator'; + +export const DefaultPopupFooter = () => ( +
+
+ + + + + Go to JSX Email's Github + + + + Go to JSX Email's Discord + +
+
+ Email Samples + Pro Templates +
+
+); diff --git a/apps/preview/app/src/components/code-container.tsx b/apps/preview/app/src/components/code-container.tsx deleted file mode 100644 index 93849202..00000000 --- a/apps/preview/app/src/components/code-container.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import classNames from 'classnames'; -import * as React from 'react'; - -import { Views } from '../types.js'; - -import { Code, type PreviewLanguage } from './code'; -import { IconButton, IconCheck, IconClipboard, IconDownload } from './icons'; - -interface RawProps { - content: string; - language: PreviewLanguage; -} - -interface CodeContainerProps { - activeView: Views; - raws: RawProps[]; - setActiveView: (lang: string) => void; -} - -const copyText = async (text: string) => { - try { - await navigator.clipboard.writeText(text); - } catch (error) { - throw new Error(`jsx-email Preview: Unable to copy text: ${error}`); - } -}; - -export const CodeContainer: React.FC> = ({ activeView, raws }) => { - const [isCopied, setIsCopied] = React.useState(false); - - const handleClipboard = async () => { - const activeContent = raws.filter(({ language }) => activeView === language); - setIsCopied(true); - await copyText(activeContent[0].content); - setTimeout(() => setIsCopied(false), 3000); - }; - - const value = raws.find((raw) => raw.language === activeView); - const file = new File([value!.content], `email.${value!.language}`); - const downloadUrl = URL.createObjectURL(file); - const copy = - 'rounded focus:text-dark-bg-text ease-in-out transition duration-200 focus:outline-none focus:ring-2 focus:ring-gray-8 hover:text-dark-bg-text absolute top-[20px] right-[20px] hidden md:block'; - const download = - 'text-gray-11 absolute top-[20px] right-[50px] hidden md:block transition ease-in-out duration-200 hover:text-dark-bg-text'; - - React.useEffect(() => { - setIsCopied(false); - }, [activeView]); - - return ( - <> - - {isCopied ? : } - - - - - - - {raws.map(({ language, content }) => ( -
- {content.trim()} -
- ))} - - ); -}; diff --git a/apps/preview/app/src/components/code.tsx b/apps/preview/app/src/components/code.tsx deleted file mode 100644 index fb553470..00000000 --- a/apps/preview/app/src/components/code.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import classnames from 'classnames'; -import { createHighlighterCoreSync } from 'shiki/core'; -import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; -import js from 'shiki/langs/javascript.mjs'; -import html from 'shiki/langs/html.mjs'; -import tsx from 'shiki/langs/tsx.mjs'; -import darkPlus from 'shiki/themes/dark-plus.mjs'; - -export type PreviewLanguage = 'html' | 'jsx' | 'plain'; - -interface CodeProps { - children: string; - className?: string; - language?: PreviewLanguage; -} - -const theme = 'dark-plus'; -const shiki = createHighlighterCoreSync({ - engine: createJavaScriptRegexEngine(), - langs: [html, js, tsx], - themes: [darkPlus] -}); - -export const Code = ({ children: value, language = 'html' }: CodeProps) => { - const lang = language === 'jsx' ? 'tsx' : language; - const code = language === 'plain' ? value : shiki.codeToHtml(value, { lang, theme }); - const lines = value.split('\n').length; - const css = ` - .${language} .shiki .line:before { - width: calc(${lines.toString().length} * 12px + 12px); - }`; - - return ( - <> - -
- - ); -}; diff --git a/apps/preview/app/src/components/icons.tsx b/apps/preview/app/src/components/icons.tsx deleted file mode 100644 index 863cceff..00000000 --- a/apps/preview/app/src/components/icons.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import classnames from 'classnames'; -import * as React from 'react'; - -export type IconElement = React.ElementRef<'svg'>; -export type RootProps = React.ComponentPropsWithoutRef<'svg'>; - -export interface IconProps extends RootProps { - size?: number; -} - -export const IconBase = React.forwardRef>( - ({ size = 20, ...props }, forwardedRef) => ( - - ) -); - -IconBase.displayName = 'IconBase'; - -export interface IconButtonProps extends React.ComponentPropsWithoutRef<'button'> {} - -export const IconButton = React.forwardRef>( - ({ children, className, ...props }, forwardedRef) => ( - - ) -); - -IconButton.displayName = 'IconButton'; - -export const IconCheck = React.forwardRef>( - ({ ...props }, forwardedRef) => ( - - - - ) -); - -IconCheck.displayName = 'IconCheck'; - -export const IconClipboard = React.forwardRef>( - ({ ...props }, forwardedRef) => ( - - - - - - - ) -); - -IconClipboard.displayName = 'IconClipboard'; - -export const IconDownload = React.forwardRef>( - ({ ...props }, forwardedRef) => ( - - - - ) -); - -IconDownload.displayName = 'IconDownload'; - -export const IconPaperAirplane = React.forwardRef>( - ({ ...props }, forwardedRef) => ( - - - - ) -); - -IconPaperAirplane.displayName = 'IconPaperAirplane'; - -export const IconLoader = React.forwardRef>( - ({ ...props }, forwardedRef) => ( - - - - - ) -); - -IconLoader.displayName = 'IconLoader'; diff --git a/apps/preview/app/src/components/logo.tsx b/apps/preview/app/src/components/logo.tsx index 59cb1eee..3e35ce86 100644 --- a/apps/preview/app/src/components/logo.tsx +++ b/apps/preview/app/src/components/logo.tsx @@ -1,38 +1,24 @@ -import * as React from 'react'; - -type LogoElement = React.ElementRef<'svg'>; -type RootProps = React.ComponentPropsWithoutRef<'svg'>; - -export const Logo = React.forwardRef>((_, __) => ( - - - - - - - - +export const Logo = ({ className }: { className?: string }) => ( + + + + + + + + + -)); - -Logo.displayName = 'Logo'; +); diff --git a/apps/preview/app/src/components/mobile.tsx b/apps/preview/app/src/components/mobile.tsx deleted file mode 100644 index 82e18b22..00000000 --- a/apps/preview/app/src/components/mobile.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; -import * as Select from '@radix-ui/react-select'; -import classnames from 'classnames'; -import * as React from 'react'; - -import devices from '../devices.json'; - -interface MobileProps extends React.ComponentPropsWithoutRef<'header'> { - setViewSize: (size: string) => void; -} - -const Group = (key: keyof typeof devices) => { - const item = classnames( - 'relative flex items-center px-4 py-2 rounded-md text-xs text-light-bg-text', - '!outline-none !select-none data-[highlighted]:bg-select-item-hover' - ); - const items = devices[key].map((device) => ( - - {device.name} - - - - - )); - - return {items}; -}; - -const button = 'flex items-center justify-center'; -const chevron = `${button} h-[25px] bg-light-bg text-light-bg-text`; -const list = 'bg-light-bg text-light-bg-text p-2 rounded-md shadow-md'; -const trigger = classnames( - 'inline-flex select-none items-center justify-center rounded-md px-4 py-2 text-xs font-medium', - 'bg-light-bg text-light-bg-text !outline-none m-auto', - 'hover:bg-select-item-hover' -); - -export const Mobile = React.forwardRef, Readonly>( - ({ className, title, setViewSize, ...props }, forwardedRef) => ( -
- - - - - - - - - - - - - - {Group('phones')} - - {Group('tablets')} - - - - - - - -
- ) -); - -Mobile.displayName = 'Mobile'; diff --git a/apps/preview/app/src/components/nav-button.tsx b/apps/preview/app/src/components/nav-button.tsx deleted file mode 100644 index a270ab8e..00000000 --- a/apps/preview/app/src/components/nav-button.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as ToggleGroup from '@radix-ui/react-toggle-group'; -import classnames from 'classnames'; -import { motion } from 'framer-motion'; - -import type { Views } from '../types.js'; - -const button = 'text-sm font-medium px-3 py-2 transition ease-in-out duration-200 relative'; -const motionSpan = 'absolute left-0 right-0 top-0 bottom-0 bg-cta-bg'; -const span = 'capitalize relative'; - -interface NavButtonProps extends React.ComponentPropsWithoutRef<'header'> { - activeView?: Views; - addClassNames?: string; - label: string; -} - -const variants = { - active: { opacity: 1 }, - inactive: { opacity: 0 } -}; - -export const NavButton = ({ activeView, addClassNames, label }: NavButtonProps) => ( - - - -
{label} - - -); - -NavButton.displayName = 'NavButton'; diff --git a/apps/preview/app/src/components/nav.tsx b/apps/preview/app/src/components/nav.tsx deleted file mode 100644 index 85f26668..00000000 --- a/apps/preview/app/src/components/nav.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Cross2Icon, QuestionMarkCircledIcon, HamburgerMenuIcon } from '@radix-ui/react-icons'; -import * as Popover from '@radix-ui/react-popover'; -import * as ToggleGroup from '@radix-ui/react-toggle-group'; -import classnames from 'classnames'; -import { LayoutGroup } from 'framer-motion'; -import * as React from 'react'; - -import { Views } from '../types.js'; - -import { NavButton } from './nav-button'; - -interface NavProps extends React.ComponentPropsWithoutRef<'header'> { - activeView?: Views; - markup?: string; - openNav: () => void; - setActiveView?: (view: Views) => void; - title: string; -} - -export const Nav = React.forwardRef, Readonly>( - ({ className, title, markup, activeView, setActiveView, openNav, ...props }, forwardedRef) => ( - <> -
-
- - {!!title && ( - - )} -
- {/*
activeView: {activeView}
*/} - {!!title && ( -
- - - - - - -
- The Desktop and Mobile views are an approximation of what your email - template will looke like on various devices. It should not be considered a - source of truth, but rather a guide for styling and layout. Always send a test - email to your target email clients for Quality Control, before sending emails in - production. -
- - - - -
-
-
- - - {setActiveView && ( - { - if (value) setActiveView(value as Views); - }} - > - - - - - - - )} - -
- )} -
-
- - {setActiveView && ( - { - if (value) setActiveView(value as Views); - }} - > - - - - - - - )} - -
- - ) -); - -Nav.displayName = 'Nav'; diff --git a/apps/preview/app/src/components/send.tsx b/apps/preview/app/src/components/send.tsx deleted file mode 100644 index eb106b77..00000000 --- a/apps/preview/app/src/components/send.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import * as Popover from '@radix-ui/react-popover'; -import * as React from 'react'; - -import { IconButton, IconPaperAirplane, IconLoader } from './icons'; - -const PlunkLogo: React.FC> = (props) => ( - - - - - -); - -export interface SendProps { - markup: string; -} - -export const Send: React.FC = ({ markup }) => { - const [open, setOpen] = React.useState(false); - const [to, setTo] = React.useState(''); - const [isSending, setIsSending] = React.useState(false); - const [submitError, setSubmitError] = React.useState(null); - const [showEmailSent, setShowEmailSent] = React.useState(false); - - const onFormSubmit = async (e: React.FormEvent) => { - try { - e.preventDefault(); - setIsSending(true); - setSubmitError(null); - - const response = await fetch('https://api.useplunk.com/v1/send', { - body: JSON.stringify({ - body: markup, - subject: 'Test jsx-email template', - to - }), - headers: { - Authorization: 'Bearer sk_dbed12137c418c4b2d278d84a634394b2209931a4a239b6f', - 'Content-Type': 'application/json' - }, - method: 'POST' - }); - - if (response.status !== 200) { - const error = await response.text(); - setSubmitError(error); - return; - } - } catch (error: unknown) { - setSubmitError('Something went wrong. Please try again.'); - } finally { - setIsSending(false); - } - - setShowEmailSent(true); - setTimeout(() => { - setOpen(false); - setShowEmailSent(false); - }, 3000); - }; - - return ( - -
- - - - - - - - - ✕ - - {showEmailSent ? ( -

Your template was sent!

- ) : ( -
- - setTo(e.target.value)} - defaultValue={to} - placeholder="you@example.com" - type="email" - id="to" - required - /> - -

{submitError}

-
-

- Powered by{' '} - - Plunk - - -

- -
-
- )} -
-
- - ); -}; diff --git a/apps/preview/app/src/components/shell.tsx b/apps/preview/app/src/components/shell.tsx deleted file mode 100644 index a5f1df31..00000000 --- a/apps/preview/app/src/components/shell.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import classNames from 'classnames'; -import * as React from 'react'; - -import type { TemplatePart, Views } from '../types.js'; - -import { Sidebar } from './sidebar'; -import { Nav } from './nav'; - -type ShellElement = React.ElementRef<'div'>; -type RootProps = React.ComponentPropsWithoutRef<'div'>; - -interface ShellProps extends RootProps { - activeView?: Views; - html?: string; - setActiveView?: (view: string) => void; - templateParts: TemplatePart[]; -} - -export const Shell = React.forwardRef>( - ({ title, templateParts, children, html, activeView, setActiveView }, forwardedRef) => { - const [showNav, setShowNav] = React.useState(false); - return ( -
-
- setShowNav(false)} - className={classNames('w-screen max-w-full lg:max-w-[275px]', { - 'translate-x-[-100%] lg:translate-x-0 absolute lg:relative': !showNav, - 'translate-x-0': showNav - })} - templateParts={templateParts} - title={title} - /> -
-
-
-
- ); - } -); - -Shell.displayName = 'Shell'; diff --git a/apps/preview/app/src/components/sidebar.tsx b/apps/preview/app/src/components/sidebar.tsx index 67b7f1e1..ca2a38b9 100644 --- a/apps/preview/app/src/components/sidebar.tsx +++ b/apps/preview/app/src/components/sidebar.tsx @@ -1,221 +1,243 @@ +import { Icon } from '@iconify/react'; import * as Collapsible from '@radix-ui/react-collapsible'; -import classnames from 'classnames'; -import * as React from 'react'; +import * as RadixToggleGroup from '@radix-ui/react-toggle-group'; +import clsx from 'clsx'; +import { observer } from 'mobx-react'; +import { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { Cross1Icon } from '@radix-ui/react-icons'; +import { useAppStore } from '../composables/useAppStore'; +import type { TemplatePart } from '../lib/types'; -import type { TemplatePart } from '../types.js'; +import { Logo } from './Logo'; +import { Separator } from './ui/Separator'; -import { Logo } from './logo'; - -type SidebarElement = React.ElementRef<'aside'>; -type RootProps = React.ComponentPropsWithoutRef<'aside'>; - -interface SidebarProps extends RootProps { - closeNav: () => void; - templateParts: TemplatePart[]; - title?: string; -} - -interface SidebarSectionProps { - closeNav: () => void; - currentPageTitle: string; +interface DirectoryTreeProps { isSubSection?: boolean; templateParts: TemplatePart[]; title: string; } -const FolderPlus = () => ( - - - -); +const version = '2.5.4'; -const FolderMinus = () => ( - - - +export const DirectoryTree = observer( + ({ templateParts, isSubSection, title = 'Email Templates' }: DirectoryTreeProps) => { + const { pathname: basePathName, search } = useLocation(); + const pathname = decodeURIComponent( + basePathName.startsWith('/') ? basePathName.slice(1) : basePathName + ); + + const [isOpen, setIsOpen] = useState( + !isSubSection || pathname.indexOf(title.toLowerCase()) > -1 + ); + + const appStore = useAppStore(); + + return ( + + + +
+

{title}

+
+
+ + {templateParts && templateParts.length > 0 && ( + +
+ +
+
+ {templateParts && + templateParts.map((item) => { + const isCurrentPage = pathname === `emails/${item.path}`; + const isParent = item.children && item.children.length > 0; + + return isParent ? ( +
+ +
+ ) : ( + appStore.sidebar.setIsOpen(false)} + > + + {isCurrentPage && ( + + + + )} + + + {item.template.templateName} + + + + ); + })} +
+
+ + )} + + ); + } ); -const FileName = () => ( - - - - +const SidebarTemplatesTree = observer( + (props: Omit) => { + const appStore = useAppStore(); + + return ; + } ); -export const SidebarSection = ({ - closeNav, - templateParts, - currentPageTitle, - isSubSection, - title = 'Email Templates' -}: SidebarSectionProps) => { - const { pathname: basePathName } = useLocation(); - const pathname = decodeURIComponent( - basePathName.startsWith('/') ? basePathName.slice(1) : basePathName - ); +const ColorSchemePicker = observer(() => { + const appStore = useAppStore(); + + const colorSchemes = [ + { + icon: 'ic:outline-computer', + name: 'system' + }, + { + icon: 'tabler:sun-filled', + name: 'light' + }, + { + icon: 'tabler:moon-filled', + name: 'dark' + } + ] as const satisfies { icon: string; name: string }[]; - const [isOpen, setIsOpen] = React.useState( - !isSubSection || pathname.indexOf(title.toLowerCase()) > -1 + return ( + + {colorSchemes.map((colorScheme, index) => ( + appStore.colorScheme.setPreference(colorScheme.name)} + > + + + ))} + ); +}); + +export const Sidebar = observer(() => { + const appStore = useAppStore(); + + useEffect(() => { + function handleResize() { + appStore.sidebar.setIsOpen(window.innerWidth > appStore.sidebar.breakpoint); + } + + addEventListener('resize', handleResize); + + return () => { + removeEventListener('resize', handleResize); + }; + }, []); return ( - - - {isOpen ? : } -
-

- {title} -

-
-
- - {templateParts && templateParts.length > 0 && ( - -
- -
-
- {templateParts && - templateParts.map((item) => { - const isCurrentPage = pathname === item.path; - const isParent = item.children && item.children.length > 0; - return isParent ? ( -
- -
- ) : ( - - - {isCurrentPage && ( - -
- - )} - - {item.template.templateName} - - - ); - })} +
+
+ {/* header */} +
+
+ + + jsx.email + + + v{version}
- - )} - - ); -}; - -export const Sidebar = React.forwardRef>( - ({ className, templateParts, closeNav, title, ...props }, forwardedRef) => ( - - ) -); + {/* color scheme */} +
+ +

+ Looking for inspiration? Check out{' '} + + pro.jsx.email + +

+
+
+
+ ); +}); + +export const Header = observer(() => { + const appStore = useAppStore(); -Sidebar.displayName = 'Sidebar'; + return ( +
+
+ + + + + jsx.email + +
+
+ ); +}); diff --git a/apps/preview/app/src/components/ui/Button.tsx b/apps/preview/app/src/components/ui/Button.tsx new file mode 100644 index 00000000..ee1f675d --- /dev/null +++ b/apps/preview/app/src/components/ui/Button.tsx @@ -0,0 +1,49 @@ +import { Slot } from '@radix-ui/react-slot'; +import { type VariantProps, cva } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full 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]:shrink-0', + { + defaultVariants: { + size: 'default', + variant: 'default' + }, + variants: { + size: { + default: 'h-12 px-4 py-2 [&_svg]:size-5', + icon: 'h-11 w-11 [&_svg]:size-5', + lg: 'h-11 rounded-full px-8 [&_svg]:size-5', + sm: 'h-9 rounded-full px-3 [&_svg]:size-5' + }, + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + outline: 'border-2 border-input bg-background hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80' + } + } + } +); + +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/apps/preview/app/src/components/ui/Input.tsx b/apps/preview/app/src/components/ui/Input.tsx new file mode 100644 index 00000000..900362a5 --- /dev/null +++ b/apps/preview/app/src/components/ui/Input.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => ( + + ) +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/apps/preview/app/src/components/ui/Link.tsx b/apps/preview/app/src/components/ui/Link.tsx new file mode 100644 index 00000000..fcb3d579 --- /dev/null +++ b/apps/preview/app/src/components/ui/Link.tsx @@ -0,0 +1,16 @@ +import { Icon } from '@iconify/react'; +import { Link as ReactRouterDomLink } from 'react-router-dom'; + +export const Link = ({ + children, + className, + ...props +}: Parameters[0]) => ( + + {children} + + +); diff --git a/apps/preview/app/src/components/ui/Popover.tsx b/apps/preview/app/src/components/ui/Popover.tsx new file mode 100644 index 00000000..beff8c41 --- /dev/null +++ b/apps/preview/app/src/components/ui/Popover.tsx @@ -0,0 +1,29 @@ +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger = PopoverPrimitive.Trigger; + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/apps/preview/app/src/components/ui/Popup.tsx b/apps/preview/app/src/components/ui/Popup.tsx new file mode 100644 index 00000000..78018365 --- /dev/null +++ b/apps/preview/app/src/components/ui/Popup.tsx @@ -0,0 +1,48 @@ +import * as RadixDialog from '@radix-ui/react-dialog'; + +const Root = ({ children, ...props }: RadixDialog.DialogProps) => ( + {children} +); + +const Trigger = ({ children, ...props }: RadixDialog.DialogTriggerProps) => ( + {children} +); + +const Body = ({ children }) => ( +
+
+ {children} +
+
+); + +const Modal = ({ children, ...props }: RadixDialog.DialogPortalProps) => ( + + + +
+ + {children} + + +); + +const Close = ({ children, ...props }: RadixDialog.DialogCloseProps) => ( + {children} +); + +const { Title } = RadixDialog; +const { Description } = RadixDialog; + +export const Popup = { + Body, + Close, + Description, + Modal, + Root, + Title, + Trigger +}; diff --git a/apps/preview/app/src/components/ui/Separator.tsx b/apps/preview/app/src/components/ui/Separator.tsx new file mode 100644 index 00000000..c8379679 --- /dev/null +++ b/apps/preview/app/src/components/ui/Separator.tsx @@ -0,0 +1,26 @@ +'use client'; + +import * as SeparatorPrimitive from '@radix-ui/react-separator'; +import * as React from 'react'; + +import { cn } from '../../lib/utils'; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/apps/preview/app/src/components/ui/ToggleGroup.tsx b/apps/preview/app/src/components/ui/ToggleGroup.tsx new file mode 100644 index 00000000..1363b1d9 --- /dev/null +++ b/apps/preview/app/src/components/ui/ToggleGroup.tsx @@ -0,0 +1,26 @@ +import * as RadixToggleGroup from '@radix-ui/react-toggle-group'; + +// import classNames from "classnames"; + +const Root = ({ children, ...props }: RadixToggleGroup.ToggleGroupSingleProps | undefined) => ( + + {children} + +); + +const Item = ({ children, ...props }: RadixToggleGroup.ToggleGroupItemProps | undefined) => ( + + {children} + +); + +export const ToggleGroup = { + Item, + Root +}; diff --git a/apps/preview/app/src/composables/useAppStore.ts b/apps/preview/app/src/composables/useAppStore.ts new file mode 100644 index 00000000..309b9301 --- /dev/null +++ b/apps/preview/app/src/composables/useAppStore.ts @@ -0,0 +1,14 @@ +import { MobXProviderContext } from 'mobx-react'; +import { useContext } from 'react'; + +import type { AppStore } from '../stores/AppStore'; + +export function useAppStore() { + const { store } = useContext(MobXProviderContext) as { + store: AppStore; + }; + + if (!store) throw new Error("Store doesn't exist."); + + return store; +} diff --git a/apps/preview/app/src/composables/useScroll.ts b/apps/preview/app/src/composables/useScroll.ts new file mode 100644 index 00000000..4bf2fc5e --- /dev/null +++ b/apps/preview/app/src/composables/useScroll.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useScroll() { + interface ScrollInfo { + edges: { + horizontal: 'right' | 'left' | false; + vertical: 'top' | 'bottom' | false; + }; + } + + const targetElRef = useRef(null); + const [scrollInfo, setScrollInfo] = useState({ + edges: { horizontal: 'left', vertical: 'top' } + }); + + useEffect(() => { + updateScrollInfo(); + + if (!targetElRef || !targetElRef.current) return undefined; + + const targetEl = targetElRef.current as unknown as HTMLElement; + + targetEl.addEventListener('scroll', updateScrollInfo); + + return () => { + targetEl.removeEventListener('scroll', updateScrollInfo); + }; + }, []); + + async function updateScrollInfo() { + const targetEl = targetElRef.current as unknown as HTMLElement; + if (!targetEl) return; + + const boundingRect = targetEl.getBoundingClientRect(); + + const modScrollInfo: ScrollInfo = JSON.parse(JSON.stringify(scrollInfo)); + + if (Math.floor(targetEl.scrollLeft) <= 0) modScrollInfo.edges.horizontal = 'left'; + else if ( + Math.ceil(targetEl.scrollLeft) + Math.ceil(boundingRect.width) >= + Math.ceil(targetEl.scrollWidth) + ) + modScrollInfo.edges.horizontal = 'right'; + else modScrollInfo.edges.horizontal = false; + + if (Math.floor(targetEl.scrollTop) <= 0) modScrollInfo.edges.vertical = 'top'; + else if ( + Math.ceil(targetEl.scrollTop) + Math.ceil(boundingRect.height) >= + Math.ceil(targetEl.scrollHeight) + ) + modScrollInfo.edges.vertical = 'bottom'; + else modScrollInfo.edges.vertical = false; + + setScrollInfo(modScrollInfo); + } + + return { + ref: targetElRef, + scroll: scrollInfo + }; +} diff --git a/apps/preview/app/src/config.ts b/apps/preview/app/src/config.ts deleted file mode 100644 index c25163e7..00000000 --- a/apps/preview/app/src/config.ts +++ /dev/null @@ -1,9 +0,0 @@ -interface AppConfig { - buildPath: string; - relativePath: string; -} - -export const config: AppConfig = { - buildPath: import.meta.env.VITE_JSXEMAIL_BUILD_PATH, - relativePath: import.meta.env.VITE_JSXEMAIL_RELATIVE_PATH -}; diff --git a/apps/preview/app/src/home.tsx b/apps/preview/app/src/home.tsx deleted file mode 100644 index b6c0c073..00000000 --- a/apps/preview/app/src/home.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client'; - -import * as SlotPrimitive from '@radix-ui/react-slot'; -import React from 'react'; - -import { Shell } from './components/shell'; - -export const Home = ({ templateParts }: { templateParts: any }) => { - React.useEffect(() => { - document.title = 'jsx-email Preview'; - }, []); - - return ( - -
-

jsx-email Preview

- - -

- Start creating an email template by running{' '} - email create <template-name> -
-
- Run email help create for a list of options -
-
- Happy coding! -

-
-
- - - - Read our Documentation - - -
-
- ); -}; diff --git a/apps/preview/app/src/index.css b/apps/preview/app/src/index.css index f657449d..a3ec1950 100644 --- a/apps/preview/app/src/index.css +++ b/apps/preview/app/src/index.css @@ -1,209 +1,63 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import 'tailwindcss'; -@layer base { - html, - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; - font-weight: 300; - } - - .shiki { - background: inherit !important; - counter-reset: line 0; - display: block; - gap: 0.3em; - grid-auto-rows: 1em; - grid-template-columns: min-content 1fr; - } - - .shiki .line { - display: inline-block; - line-height: 20px; - min-height: auto; - vertical-align: middle; - white-space: pre; - } - - .shiki .line:last-child:before, - .shiki .line:last-child span { - padding-bottom: 30px; - } - - .shiki .line:first-child:before, - .shiki .line:first-child span { - padding-top: 10px; - } - - .shiki .line:before { - background: #3d3a3a; - color: #777; - content: counter(line); - counter-increment: line; - display: inline-block; - margin-right: 10px; - padding-right: 10px; - text-align: right; - } - - .plainText .shiki .line:before { - display: none; - } - - .inline-code { - background: #65758529; - border-radius: 4px; - color: #9cdcfe; - display: inline-block; - font-weight: 300; - padding: 3px 6px; - } - - .note { - border-radius: 4px; - padding: 20px; - margin-top: 1em; - width: 260px; - background-color: white; - box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px; - animation-duration: 400ms; - animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); - will-change: transform, opacity; - } - - .note:focus { - box-shadow: hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px, - 0 0 0 2px #333; - } - - .note[data-state='open'][data-side='top'] { - animation-name: slideDownAndFade; - } - - .note[data-state='open'][data-side='right'] { - animation-name: slideLeftAndFade; - } - - .note[data-state='open'][data-side='bottom'] { - animation-name: slideUpAndFade; - } - - .note[data-state='open'][data-side='left'] { - animation-name: slideRightAndFade; - } - - .note-arrow { - fill: white; - } - - .note-close { - font-family: inherit; - border-radius: 100%; - height: 25px; - width: 25px; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--violet-11); - position: absolute; - top: 5px; - right: 5px; - } - - .note-close:hover { - background-color: var(--violet-4); - } - - .note-close:focus { - box-shadow: 0 0 0 2px var(--violet-7); - } - - @keyframes slideUpAndFade { - from { - opacity: 0; - transform: translateY(2px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - - @keyframes slideRightAndFade { - from { - opacity: 0; - transform: translateX(-2px); - } - to { - opacity: 1; - transform: translateX(0); - } - } - - @keyframes slideDownAndFade { - from { - opacity: 0; - transform: translateY(-2px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - - @keyframes slideLeftAndFade { - from { - opacity: 0; - transform: translateX(2px); - } - to { - opacity: 1; - transform: translateX(0); - } - } -} - -.collapsible-content { - overflow: hidden; -} -.collapsible-content[data-state='open'] { - animation: slideDown 300ms ease-out; -} -.collapsible-content[data-state='closed'] { - animation: slideUp 300ms ease-out; -} +@config '../tailwind.config.cjs'; -@keyframes slideDown { - from { - height: 0; - } - to { - height: var(--radix-collapsible-content-height); - } -} - -@keyframes slideUp { - from { - height: var(--radix-collapsible-content-height); - } - to { - height: 0; +@layer base { + .text-block-inline-code { + @apply inline-block select-all whitespace-nowrap bg-neutral-100 dark:bg-neutral-900 py-1 px-2 rounded-lg font-medium; + } + + :root { + --background: 0 0% 100%; + --foreground: 0 0% 10%; + --muted: 0 0% 95%; + --muted-foreground: 0 0% 45%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 10%; + --border: 0 0% 90%; + --input: 0 0% 90%; + --card: 0 0% 100%; + --card-foreground: 0 0% 10%; + --primary: 0 0% 0%; + --primary-foreground: 0 0% 100%; + --secondary: 0 0% 95%; + --secondary-foreground: 0 0% 10%; + --accent: 0 0% 95%; + --accent-foreground: 0 0% 10%; + --destructive: 0 0% 50%; + --destructive-foreground: 0 0% 100%; + --ring: 0 0% 65%; + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 0%; + --foreground: 0 0% 90%; + --muted: 0 0% 5%; + --muted-foreground: 0 0% 55%; + --popover: 0 0% 0%; + --popover-foreground: 0 0% 90%; + --border: 0 0% 10%; + --input: 0 0% 10%; + --card: 0 0% 0%; + --card-foreground: 0 0% 90%; + --primary: 0 0% 100%; + --primary-foreground: 0 0% 0%; + --secondary: 0 0% 5%; + --secondary-foreground: 0 0% 90%; + --accent: 0 0% 5%; + --accent-foreground: 0 0% 90%; + --destructive: 0 0% 50%; + --destructive-foreground: 0 0% 0%; + --ring: 0 0% 35%; } } -@keyframes nav-fade-in { - 0% { - opacity: 0; - margin-left: -30px; +@layer base { + * { + @apply border-border; } - 100% { - opacity: 1; - margin-left: 0; + body { + @apply antialiased bg-background text-foreground; } } - -.animate-nav-fade-in { - animation: nav-fade-in 500ms ease-in-out forwards; -} diff --git a/apps/preview/app/src/layouts/Shell.tsx b/apps/preview/app/src/layouts/Shell.tsx new file mode 100644 index 00000000..95f91406 --- /dev/null +++ b/apps/preview/app/src/layouts/Shell.tsx @@ -0,0 +1,19 @@ +import { Outlet } from 'react-router-dom'; + +import { Header, Sidebar } from '../components/Sidebar'; + +export const Shell = () => ( + <> +
+
+
+ {/* sidebar */} + + {/* content */} +
+ +
+
+
+ +); diff --git a/apps/preview/app/src/lib/consts.ts b/apps/preview/app/src/lib/consts.ts new file mode 100644 index 00000000..e4fe1700 --- /dev/null +++ b/apps/preview/app/src/lib/consts.ts @@ -0,0 +1,5 @@ +// Warning: This API key was provided by plunk strictly for sending test emails from the jsx.email user interface, +// do not use for commercial purposes, do not send any personal details or sensitive data, +// you are not permitted to copy paste the API key, or to use it outside of the UI provided by jsx.email. +export const plunkApiBearerTokenProvidedOnlyForJsxEmailUiDoNotUseElsewhere = + 'sk_dbed12137c418c4b2d278d84a634394b2209931a4a239b6f'; diff --git a/apps/preview/app/src/devices.json b/apps/preview/app/src/lib/devices.json similarity index 95% rename from apps/preview/app/src/devices.json rename to apps/preview/app/src/lib/devices.json index 8e5c829f..7034513e 100644 --- a/apps/preview/app/src/devices.json +++ b/apps/preview/app/src/lib/devices.json @@ -13,12 +13,12 @@ { "name": "iPhone 15 Pro Max", "width": "430", - "height": "+932" + "height": "932" }, { "name": "iPhone 15 Pro", "width": "393", - "height": "+852" + "height": "852" }, { "name": "iPhone 14 Pro Max", @@ -85,7 +85,7 @@ { "name": "iPad Mini 4", "width": "768", - "height": "+1024" + "height": "1024" }, { "name": "iPad Pro 10.5\"", diff --git a/apps/preview/app/src/helpers.ts b/apps/preview/app/src/lib/helpers.ts similarity index 100% rename from apps/preview/app/src/helpers.ts rename to apps/preview/app/src/lib/helpers.ts diff --git a/apps/preview/app/src/templates.ts b/apps/preview/app/src/lib/templates.ts similarity index 84% rename from apps/preview/app/src/templates.ts rename to apps/preview/app/src/lib/templates.ts index 9f12caa1..ab349ed0 100644 --- a/apps/preview/app/src/templates.ts +++ b/apps/preview/app/src/lib/templates.ts @@ -10,10 +10,18 @@ export const gather = async () => { const templateFiles: Record = builtFiles.reduce((acc, file) => { const templateName = file.templateName || file.sourceFile.split('/').at(-1); + const fileExtensionRegex = /\.[^/.]+$/; + + const fileExtension = file.sourceFile.match(fileExtensionRegex)[0]; + const fileNameWithExtensionStripped = file.sourceFile.replace(fileExtensionRegex, ''); + return { ...acc, - [file.sourceFile]: { + [fileNameWithExtensionStripped]: { + fileExtension, + fileName: fileNameWithExtensionStripped, html: file.html, + // @ts-ignore // TODO: @andrew ? path: file.sourcePath.replace(`${targetPath}/`, ''), plain: file.plain, source: file.source, diff --git a/apps/preview/app/src/types.ts b/apps/preview/app/src/lib/types.ts similarity index 89% rename from apps/preview/app/src/types.ts rename to apps/preview/app/src/lib/types.ts index 3a3de4da..2760d64a 100644 --- a/apps/preview/app/src/types.ts +++ b/apps/preview/app/src/lib/types.ts @@ -9,13 +9,15 @@ export interface PreviewImportContent { export enum Views { Desktop = 'desktop', + Device = 'device', Html = 'html', Jsx = 'jsx', - Mobile = 'mobile', Plain = 'plain' } export interface TemplateData { + fileExtension: string; + fileName: string; html: string; path?: string; plain: string; diff --git a/apps/preview/app/src/lib/utils.ts b/apps/preview/app/src/lib/utils.ts new file mode 100644 index 00000000..9ad0df42 --- /dev/null +++ b/apps/preview/app/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/preview/app/src/main.tsx b/apps/preview/app/src/main.tsx index 3c58a7ff..5796514c 100644 --- a/apps/preview/app/src/main.tsx +++ b/apps/preview/app/src/main.tsx @@ -1,26 +1,13 @@ import { StrictMode } from 'react'; import ReactDOM from 'react-dom/client'; -import { RouterProvider } from 'react-router-dom'; +import { App } from './App'; import './index.css'; -import { setup } from './app'; -import { getRouter } from './routes'; -import { gather } from './templates'; -setup(); - -// if (import.meta.hot) { -// import.meta.hot.accept((mod) => { -// console.log(mod); -// }); -// } - -const router = getRouter(await gather()); -const rootElement = document.getElementById('root'); -const root = ReactDOM.createRoot(rootElement); +const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - + ); diff --git a/apps/preview/app/src/preview.tsx b/apps/preview/app/src/preview.tsx deleted file mode 100644 index 8a11025a..00000000 --- a/apps/preview/app/src/preview.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client'; - -import classnames from 'classnames'; -import React from 'react'; -import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; - -import { CodeContainer } from './components/code-container'; -import { Mobile } from './components/mobile'; -import { Shell } from './components/shell'; -import type { TemplatePart } from './types.js'; -import { Views } from './types.js'; -import { Send } from './components/send'; - -interface PreviewProps { - html: string; - jsx: string; - plainText: string; - templateParts: TemplatePart[]; - title: string; -} - -const validViews = Object.keys(Views).filter((key) => isNaN(+key)); - -const patchIframe = (frame: HTMLIFrameElement) => { - const doc = frame.contentDocument || frame.contentWindow.document; - const styleElement = doc.createElement('style'); - - styleElement.innerHTML = ` -/* Added by jsx-email for mobile view */ -table { overflow-wrap: anywhere; width: 100% !important; } -`; - doc.head.appendChild(styleElement); -}; - -export const Preview = ({ html, jsx, plainText, templateParts, title }: PreviewProps) => { - const { pathname } = useLocation(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const view = (searchParams.get('view') as Views) || Views.Desktop; - const [viewSize, setViewSize] = React.useState(null); - const [activeView, setActiveView] = React.useState(view); - const iframe = classnames('w-full h-[calc(100vh_-_70px)]', { - 'mt-2 mx-auto shadow-md': activeView === Views.Mobile - }); - let iframeStyle = {}; - - React.useEffect(() => { - document.title = `jsx-email • ${title}`; - - if (view && validViews.includes(view)) setActiveView(view); - }, [searchParams]); - - const iframeRef = React.useRef(null); - - React.useEffect(() => { - const handleLoad = () => { - if (activeView === Views.Mobile) patchIframe(iframeRef.current); - }; - - if (iframeRef.current) iframeRef.current.addEventListener('load', handleLoad); - - return () => { - if (iframeRef.current) iframeRef.current.removeEventListener('load', handleLoad); - }; - }, [html, activeView]); - - const handleViewChange = (view: Views) => { - setActiveView(view); - navigate(`${pathname}?view=${view}`); - }; - - if (activeView === Views.Mobile) { - const [width, height] = (viewSize || '430,932').split(','); - iframeStyle = { height: `${height}px`, width: `${width}px` }; - } else { - iframeStyle = void 0; - } - - return ( - - {activeView === Views.Mobile && } - {activeView === Views.Desktop || activeView === Views.Mobile ? ( - <> - -