├── .gitignore ├── README.md ├── dashboard ├── .env.production ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── components.json ├── index.html ├── package.json ├── postcss.config.js ├── proxyOptions.js ├── public │ └── vite.svg ├── src │ ├── App.tsx │ ├── MainPage.tsx │ ├── components │ │ ├── features │ │ │ ├── editor │ │ │ │ ├── bubble-menu │ │ │ │ │ ├── color-selector.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── link-selector.tsx │ │ │ │ │ └── node-selector.tsx │ │ │ │ ├── extensions │ │ │ │ │ ├── custom-keymap.ts │ │ │ │ │ ├── drag-and-drop.tsx │ │ │ │ │ ├── image-resizer.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── slash-command.tsx │ │ │ │ │ └── updated-image.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── plugins │ │ │ │ │ └── upload-images.tsx │ │ │ │ ├── props.ts │ │ │ │ └── prosemirror.css │ │ │ └── newsletter │ │ │ │ ├── DesignSheet.tsx │ │ │ │ └── NewsletterHeader.tsx │ │ ├── layout │ │ │ ├── ErrorBanner.tsx │ │ │ ├── PageHeader.tsx │ │ │ ├── Sidebar.tsx │ │ │ └── TableLoader.tsx │ │ ├── theme-provider.tsx │ │ └── ui │ │ │ ├── alert-dialog.tsx │ │ │ ├── alert.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── command.tsx │ │ │ ├── context-menu.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── full-page-loader.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── loader.tsx │ │ │ ├── popover.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── table.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ └── tooltip.tsx │ ├── index.css │ ├── lib │ │ ├── UserProvider.tsx │ │ ├── dates.ts │ │ ├── hooks │ │ │ └── use-local-storage.ts │ │ └── utils.ts │ ├── main.tsx │ ├── pages │ │ ├── Audience.tsx │ │ ├── Authors.tsx │ │ ├── Categories.tsx │ │ ├── NewsletterEditor.tsx │ │ ├── Newsletters.tsx │ │ └── Posts.tsx │ ├── types │ │ └── Newsletter.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock ├── license.txt ├── mailrun ├── __init__.py ├── config │ └── __init__.py ├── hooks.py ├── mailrun │ └── __init__.py ├── modules.txt ├── patches.txt ├── public │ └── .gitkeep ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py └── www │ ├── __init__.py │ └── dashboard.html ├── package.json ├── pyproject.toml └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | node_modules 7 | __pycache__ 8 | 9 | dist/ 10 | mailrun/public/mailrun 11 | mailrun/public/node_modules 12 | mailrun/www/mailrun.html -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## mailrun 2 | 3 | Frappe app to create, send, and manage newsletters + write and manage blog posts. 4 | 5 | ### OSSHack 6 | 7 | [Loom link](https://www.loom.com/share/ad97a9b6669744088c70aab565e5f1b6?sid=fd3f387c-19bd-488e-ae01-fac1b76e9d4d) 8 | 9 | This project was started during [OSSHack](https://osshack.com) to simplify marketing features which are available in [Frappe framework](https://frappeframework.com). 10 | 11 | Things that work: list views with skeleton loaders and errpr states, Tiptap editor with in-line formatting (bubble menu) and Slash commands. 12 | 13 | Need to figure out how to apply the same styles to the email when sending it. 14 | 15 | Longer term vision for this project would be to make it the go-to marketing management app for all Frappe and [ERPNext](https://erpnext.com) users. 16 | 17 | 18 | #### This is still a work in progress 19 | 20 | #### Features 21 | 22 | 1. Create audiences (Email Groups) and import contacts directly from CSV files 23 | 2. Use the in-built text editor to build stunning emails and blog posts for your marketing needs. 24 | 25 | #### Screenshots 26 | 27 | image 28 | 29 | image 30 | 31 | image 32 | 33 | image 34 | 35 | 36 | #### License 37 | 38 | agpl-3.0 39 | -------------------------------------------------------------------------------- /dashboard/.env.production: -------------------------------------------------------------------------------- 1 | VITE_BASE_NAME='dashboard' -------------------------------------------------------------------------------- /dashboard/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | // 'react-refresh/only-export-components': [ 14 | // 'warn', 15 | // { allowConstantExport: true }, 16 | // ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /dashboard/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /dashboard/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | project: ['./tsconfig.json', './tsconfig.node.json'], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | } 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /dashboard/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /dashboard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Mailrun 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /dashboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mailrun-web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build --base=/assets/mailrun/mailrun/ && yarn copy-html-entry", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "copy-html-entry": "cp ../mailrun/public/mailrun/index.html ../mailrun/www/mailrun.html" 12 | }, 13 | "dependencies": { 14 | "@hookform/resolvers": "^3.3.2", 15 | "@radix-ui/react-alert-dialog": "^1.0.5", 16 | "@radix-ui/react-avatar": "^1.0.4", 17 | "@radix-ui/react-checkbox": "^1.0.4", 18 | "@radix-ui/react-context-menu": "^2.1.5", 19 | "@radix-ui/react-dialog": "^1.0.5", 20 | "@radix-ui/react-dropdown-menu": "^2.0.6", 21 | "@radix-ui/react-icons": "^1.3.0", 22 | "@radix-ui/react-label": "^2.0.2", 23 | "@radix-ui/react-popover": "^1.0.7", 24 | "@radix-ui/react-select": "^2.0.0", 25 | "@radix-ui/react-separator": "^1.0.3", 26 | "@radix-ui/react-slot": "^1.0.2", 27 | "@radix-ui/react-toggle": "^1.0.3", 28 | "@radix-ui/react-toggle-group": "^1.0.4", 29 | "@radix-ui/react-tooltip": "^1.0.7", 30 | "@tiptap/extension-bubble-menu": "^2.1.13", 31 | "@tiptap/extension-color": "^2.1.13", 32 | "@tiptap/extension-highlight": "^2.1.13", 33 | "@tiptap/extension-image": "^2.1.13", 34 | "@tiptap/extension-link": "^2.1.13", 35 | "@tiptap/extension-placeholder": "^2.1.13", 36 | "@tiptap/extension-table": "^2.1.13", 37 | "@tiptap/extension-table-cell": "^2.1.13", 38 | "@tiptap/extension-table-header": "^2.1.13", 39 | "@tiptap/extension-table-row": "^2.1.13", 40 | "@tiptap/extension-text-align": "^2.1.13", 41 | "@tiptap/extension-text-style": "^2.1.13", 42 | "@tiptap/extension-typography": "^2.1.13", 43 | "@tiptap/extension-underline": "^2.1.13", 44 | "@tiptap/extension-youtube": "^2.1.13", 45 | "@tiptap/pm": "^2.1.13", 46 | "@tiptap/react": "^2.1.13", 47 | "@tiptap/starter-kit": "^2.1.13", 48 | "@tiptap/suggestion": "^2.1.13", 49 | "cal-sans": "^1.0.1", 50 | "class-variance-authority": "^0.7.0", 51 | "clsx": "^2.0.0", 52 | "cmdk": "^0.2.0", 53 | "frappe-react-sdk": "^1.3.8", 54 | "lucide-react": "^0.294.0", 55 | "moment": "^2.29.4", 56 | "react": "^18.2.0", 57 | "react-dom": "^18.2.0", 58 | "react-hook-form": "^7.48.2", 59 | "react-moveable": "^0.55.0", 60 | "react-router-dom": "^6.20.1", 61 | "socket.io-client": "^4.5.1", 62 | "sonner": "^1.2.4", 63 | "tailwind-merge": "^2.1.0", 64 | "tailwindcss-animate": "^1.0.7", 65 | "use-debounce": "^10.0.0", 66 | "zod": "^3.22.4" 67 | }, 68 | "devDependencies": { 69 | "@types/node": "^20.10.2", 70 | "@types/react": "^18.2.37", 71 | "@types/react-dom": "^18.2.15", 72 | "@typescript-eslint/eslint-plugin": "^6.10.0", 73 | "@typescript-eslint/parser": "^6.10.0", 74 | "@vitejs/plugin-react": "^4.2.0", 75 | "autoprefixer": "^10.4.16", 76 | "eslint": "^8.53.0", 77 | "eslint-plugin-react-hooks": "^4.6.0", 78 | "eslint-plugin-react-refresh": "^0.4.4", 79 | "postcss": "^8.4.32", 80 | "tailwindcss": "^3.3.5", 81 | "typescript": "^5.2.2", 82 | "vite": "^5.0.0" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /dashboard/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /dashboard/proxyOptions.js: -------------------------------------------------------------------------------- 1 | const common_site_config = require('../../../sites/common_site_config.json'); 2 | const { webserver_port } = common_site_config; 3 | 4 | export default { 5 | '^/(app|api|assets|files)': { 6 | target: `http://127.0.0.1:${webserver_port}`, 7 | ws: true, 8 | router: function (req) { 9 | const site_name = req.headers.host.split(':')[0]; 10 | return `http://${site_name}:${webserver_port}`; 11 | } 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /dashboard/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /dashboard/src/App.tsx: -------------------------------------------------------------------------------- 1 | import 'cal-sans' 2 | import { ThemeProvider } from "@/components/theme-provider" 3 | import { FrappeProvider } from 'frappe-react-sdk' 4 | import { 5 | createBrowserRouter, 6 | createRoutesFromElements, 7 | Navigate, 8 | Route, 9 | RouterProvider, 10 | } from "react-router-dom"; 11 | import { FullPageLoader } from './components/ui/full-page-loader'; 12 | import { UserProvider } from './lib/UserProvider'; 13 | import { Newsletters } from './pages/Newsletters'; 14 | import { MainPage } from './MainPage'; 15 | import { NewsletterEditor } from './pages/NewsletterEditor'; 16 | import { Toaster } from 'sonner'; 17 | import { Categories } from './pages/Categories'; 18 | import { Authors } from './pages/Authors'; 19 | import { Audience } from './pages/Audience'; 20 | import { Posts } from './pages/Posts'; 21 | 22 | const router = createBrowserRouter( 23 | createRoutesFromElements( 24 | <> 25 | {/* }> */} 26 | } > 27 | } /> 28 | } /> 29 | } /> 30 | } /> 31 | } /> 32 | } /> 33 | 34 | } /> 35 | {/* */} 36 | 37 | ), { 38 | basename: `/${import.meta.env.VITE_BASE_NAME}` ?? '', 39 | } 40 | ) 41 | 42 | function App() { 43 | 44 | return ( 45 | 46 | 49 | 50 | } /> 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | 58 | export default App 59 | -------------------------------------------------------------------------------- /dashboard/src/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom" 2 | import { Sidebar } from "./components/layout/Sidebar" 3 | 4 | 5 | export const MainPage = () => { 6 | return ( 7 |
8 | 9 |
10 | 11 |
12 |
13 | ) 14 | } -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/bubble-menu/color-selector.tsx: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/core"; 2 | import { Check, ChevronDown } from "lucide-react"; 3 | import { Dispatch, FC, SetStateAction } from "react"; 4 | import * as Popover from "@radix-ui/react-popover"; 5 | 6 | export interface BubbleColorMenuItem { 7 | name: string; 8 | color: string; 9 | } 10 | 11 | interface ColorSelectorProps { 12 | editor: Editor; 13 | isOpen: boolean; 14 | setIsOpen: Dispatch>; 15 | } 16 | 17 | const TEXT_COLORS: BubbleColorMenuItem[] = [ 18 | { 19 | name: "Default", 20 | color: "black", 21 | }, 22 | { 23 | name: "Purple", 24 | color: "#9333EA", 25 | }, 26 | { 27 | name: "Red", 28 | color: "#E00000", 29 | }, 30 | { 31 | name: "Yellow", 32 | color: "#EAB308", 33 | }, 34 | { 35 | name: "Blue", 36 | color: "#2563EB", 37 | }, 38 | { 39 | name: "Green", 40 | color: "#008A00", 41 | }, 42 | { 43 | name: "Orange", 44 | color: "#FFA500", 45 | }, 46 | { 47 | name: "Pink", 48 | color: "#BA4081", 49 | }, 50 | { 51 | name: "Gray", 52 | color: "#A8A29E", 53 | }, 54 | ]; 55 | 56 | const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [ 57 | { 58 | name: "Default", 59 | color: "var(--highlight-default)", 60 | }, 61 | { 62 | name: "Purple", 63 | color: "var(--highlight-purple)", 64 | }, 65 | { 66 | name: "Red", 67 | color: "var(--highlight-red)", 68 | }, 69 | { 70 | name: "Yellow", 71 | color: "var(--highlight-yellow)", 72 | }, 73 | { 74 | name: "Blue", 75 | color: "var(--highlight-blue)", 76 | }, 77 | { 78 | name: "Green", 79 | color: "var(--highlight-green)", 80 | }, 81 | { 82 | name: "Orange", 83 | color: "var(--highlight-orange)", 84 | }, 85 | { 86 | name: "Pink", 87 | color: "var(--highlight-pink)", 88 | }, 89 | { 90 | name: "Gray", 91 | color: "var(--highlight-gray)", 92 | }, 93 | ]; 94 | 95 | export const ColorSelector: FC = ({ 96 | editor, 97 | isOpen, 98 | setIsOpen, 99 | }) => { 100 | const activeColorItem = TEXT_COLORS.find(({ color }) => 101 | editor.isActive("textStyle", { color }) 102 | ); 103 | 104 | const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) => 105 | editor.isActive("highlight", { color }) 106 | ); 107 | 108 | return ( 109 | 110 |
111 | setIsOpen(!isOpen)} 114 | > 115 | 122 | A 123 | 124 | 125 | 126 | 127 | 128 | 132 |
133 | Color 134 |
135 | {TEXT_COLORS.map(({ name, color }, index) => ( 136 | 164 | ))} 165 | 166 |
167 | Background 168 |
169 | 170 | {HIGHLIGHT_COLORS.map(({ name, color }, index) => ( 171 | 194 | ))} 195 |
196 |
197 |
198 | ); 199 | }; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/bubble-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react"; 2 | import { FC, useState } from "react"; 3 | import { 4 | BoldIcon, 5 | ItalicIcon, 6 | UnderlineIcon, 7 | StrikethroughIcon, 8 | CodeIcon, 9 | } from "lucide-react"; 10 | import { NodeSelector } from "./node-selector"; 11 | import { ColorSelector } from "./color-selector"; 12 | import { LinkSelector } from "./link-selector"; 13 | import { cn } from "@/lib/utils"; 14 | 15 | export interface BubbleMenuItem { 16 | name: string; 17 | isActive: () => boolean; 18 | command: () => void; 19 | icon: typeof BoldIcon; 20 | } 21 | 22 | type EditorBubbleMenuProps = Omit; 23 | 24 | export const EditorBubbleMenu: FC = (props) => { 25 | const items: BubbleMenuItem[] = [ 26 | { 27 | name: "bold", 28 | isActive: () => props.editor?.isActive("bold") || false, 29 | command: () => props.editor?.chain().focus().toggleBold().run(), 30 | icon: BoldIcon, 31 | }, 32 | { 33 | name: "italic", 34 | isActive: () => props.editor?.isActive("italic") || false, 35 | command: () => props.editor?.chain().focus().toggleItalic().run(), 36 | icon: ItalicIcon, 37 | }, 38 | { 39 | name: "underline", 40 | isActive: () => props.editor?.isActive("underline") || false, 41 | command: () => props.editor?.chain().focus().toggleUnderline().run(), 42 | icon: UnderlineIcon, 43 | }, 44 | { 45 | name: "strike", 46 | isActive: () => props.editor?.isActive("strike") || false, 47 | command: () => props.editor?.chain().focus().toggleStrike().run(), 48 | icon: StrikethroughIcon, 49 | }, 50 | { 51 | name: "code", 52 | isActive: () => props.editor?.isActive("code") || false, 53 | command: () => props.editor?.chain().focus().toggleCode().run(), 54 | icon: CodeIcon, 55 | }, 56 | ]; 57 | 58 | const bubbleMenuProps: EditorBubbleMenuProps = { 59 | ...props, 60 | shouldShow: ({ state, editor }) => { 61 | const { selection } = state; 62 | const { empty } = selection; 63 | 64 | // don't show bubble menu if: 65 | // - the selected node is an image 66 | // - the selection is empty 67 | // - the selection is a node selection (for drag handles) 68 | if (editor.isActive("image") || empty || isNodeSelection(selection)) { 69 | return false; 70 | } 71 | return true; 72 | }, 73 | tippyOptions: { 74 | moveTransition: "transform 0.15s ease-out", 75 | onHidden: () => { 76 | setIsNodeSelectorOpen(false); 77 | setIsColorSelectorOpen(false); 78 | setIsLinkSelectorOpen(false); 79 | }, 80 | }, 81 | }; 82 | 83 | const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false); 84 | const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); 85 | const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); 86 | 87 | return ( 88 | 92 | { 96 | setIsNodeSelectorOpen(!isNodeSelectorOpen); 97 | setIsColorSelectorOpen(false); 98 | setIsLinkSelectorOpen(false); 99 | }} 100 | /> 101 | { 105 | setIsLinkSelectorOpen(!isLinkSelectorOpen); 106 | setIsColorSelectorOpen(false); 107 | setIsNodeSelectorOpen(false); 108 | }} 109 | /> 110 |
111 | {items.map((item, index) => ( 112 | 124 | ))} 125 |
126 | { 130 | setIsColorSelectorOpen(!isColorSelectorOpen); 131 | setIsNodeSelectorOpen(false); 132 | setIsLinkSelectorOpen(false); 133 | }} 134 | /> 135 |
136 | ); 137 | }; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/bubble-menu/link-selector.tsx: -------------------------------------------------------------------------------- 1 | import { cn, getUrlFromString } from "@/lib/utils"; 2 | import { Editor } from "@tiptap/core"; 3 | import { Check, Trash } from "lucide-react"; 4 | import { Dispatch, FC, SetStateAction, useEffect, useRef } from "react"; 5 | 6 | interface LinkSelectorProps { 7 | editor: Editor; 8 | isOpen: boolean; 9 | setIsOpen: Dispatch>; 10 | } 11 | 12 | export const LinkSelector: FC = ({ 13 | editor, 14 | isOpen, 15 | setIsOpen, 16 | }) => { 17 | const inputRef = useRef(null); 18 | 19 | // Autofocus on input by default 20 | useEffect(() => { 21 | inputRef.current && inputRef.current?.focus(); 22 | }); 23 | 24 | return ( 25 |
26 | 45 | {isOpen && ( 46 |
{ 48 | e.preventDefault(); 49 | const input = e.currentTarget[0] as HTMLInputElement; 50 | const url = getUrlFromString(input.value); 51 | url && editor.chain().focus().setLink({ href: url }).run(); 52 | setIsOpen(false); 53 | }} 54 | className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-stone-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-top-1" 55 | > 56 | 63 | {editor.getAttributes("link").href ? ( 64 | 74 | ) : ( 75 | 78 | )} 79 |
80 | )} 81 |
82 | ); 83 | }; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/bubble-menu/node-selector.tsx: -------------------------------------------------------------------------------- 1 | import { Editor } from "@tiptap/core"; 2 | import { 3 | Check, 4 | ChevronDown, 5 | Heading1, 6 | Heading2, 7 | Heading3, 8 | TextQuote, 9 | ListOrdered, 10 | TextIcon, 11 | Code, 12 | } from "lucide-react"; 13 | import { Dispatch, FC, SetStateAction } from "react"; 14 | import { BubbleMenuItem } from "."; 15 | import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 16 | 17 | interface NodeSelectorProps { 18 | editor: Editor; 19 | isOpen: boolean; 20 | setIsOpen: Dispatch>; 21 | } 22 | 23 | export const NodeSelector: FC = ({ 24 | editor, 25 | isOpen, 26 | setIsOpen, 27 | }) => { 28 | const items: BubbleMenuItem[] = [ 29 | { 30 | name: "Text", 31 | icon: TextIcon, 32 | command: () => 33 | editor.chain().focus().toggleNode("paragraph", "paragraph").run(), 34 | // I feel like there has to be a more efficient way to do this – feel free to PR if you know how! 35 | isActive: () => 36 | editor.isActive("paragraph") && 37 | !editor.isActive("bulletList") && 38 | !editor.isActive("orderedList"), 39 | }, 40 | { 41 | name: "Heading 1", 42 | icon: Heading1, 43 | command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(), 44 | isActive: () => editor.isActive("heading", { level: 1 }), 45 | }, 46 | { 47 | name: "Heading 2", 48 | icon: Heading2, 49 | command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(), 50 | isActive: () => editor.isActive("heading", { level: 2 }), 51 | }, 52 | { 53 | name: "Heading 3", 54 | icon: Heading3, 55 | command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(), 56 | isActive: () => editor.isActive("heading", { level: 3 }), 57 | }, 58 | { 59 | name: "Bullet List", 60 | icon: ListOrdered, 61 | command: () => editor.chain().focus().toggleBulletList().run(), 62 | isActive: () => editor.isActive("bulletList"), 63 | }, 64 | { 65 | name: "Numbered List", 66 | icon: ListOrdered, 67 | command: () => editor.chain().focus().toggleOrderedList().run(), 68 | isActive: () => editor.isActive("orderedList"), 69 | }, 70 | { 71 | name: "Quote", 72 | icon: TextQuote, 73 | command: () => 74 | editor 75 | .chain() 76 | .focus() 77 | .toggleNode("paragraph", "paragraph") 78 | .toggleBlockquote() 79 | .run(), 80 | isActive: () => editor.isActive("blockquote"), 81 | }, 82 | { 83 | name: "Code", 84 | icon: Code, 85 | command: () => editor.chain().focus().toggleCodeBlock().run(), 86 | isActive: () => editor.isActive("codeBlock"), 87 | }, 88 | ]; 89 | 90 | const activeItem = items.filter((item) => item.isActive()).pop() ?? { 91 | name: "Multiple", 92 | }; 93 | 94 | return ( 95 | 96 |
97 | setIsOpen(!isOpen)} 100 | > 101 | {activeItem?.name} 102 | 103 | 104 | 105 | 109 | {items.map((item, index) => ( 110 | 130 | ))} 131 | 132 |
133 |
134 | ); 135 | }; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/extensions/custom-keymap.ts: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | 3 | declare module "@tiptap/core" { 4 | // eslint-disable-next-line no-unused-vars 5 | interface Commands { 6 | customkeymap: { 7 | /** 8 | * Select text between node boundaries 9 | */ 10 | selectTextWithinNodeBoundaries: () => ReturnType; 11 | }; 12 | } 13 | } 14 | 15 | const CustomKeymap = Extension.create({ 16 | name: "CustomKeymap", 17 | 18 | addCommands() { 19 | return { 20 | selectTextWithinNodeBoundaries: 21 | () => 22 | ({ editor, commands }) => { 23 | const { state } = editor; 24 | const { tr } = state; 25 | const startNodePos = tr.selection.$from.start(); 26 | const endNodePos = tr.selection.$to.end(); 27 | return commands.setTextSelection({ 28 | from: startNodePos, 29 | to: endNodePos, 30 | }); 31 | }, 32 | }; 33 | }, 34 | 35 | addKeyboardShortcuts() { 36 | return { 37 | "Mod-a": ({ editor }) => { 38 | const { state } = editor; 39 | const { tr } = state; 40 | const startSelectionPos = tr.selection.from; 41 | const endSelectionPos = tr.selection.to; 42 | const startNodePos = tr.selection.$from.start(); 43 | const endNodePos = tr.selection.$to.end(); 44 | const isCurrentTextSelectionNotExtendedToNodeBoundaries = 45 | startSelectionPos > startNodePos || endSelectionPos < endNodePos; 46 | if (isCurrentTextSelectionNotExtendedToNodeBoundaries) { 47 | editor.chain().selectTextWithinNodeBoundaries().run(); 48 | return true; 49 | } 50 | return false; 51 | }, 52 | }; 53 | }, 54 | }); 55 | 56 | export default CustomKeymap; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/extensions/drag-and-drop.tsx: -------------------------------------------------------------------------------- 1 | import { Extension } from "@tiptap/core"; 2 | 3 | import { NodeSelection, Plugin } from "@tiptap/pm/state"; 4 | // @ts-ignore 5 | import { __serializeForClipboard, EditorView } from "@tiptap/pm/view"; 6 | 7 | export interface DragHandleOptions { 8 | /** 9 | * The width of the drag handle 10 | */ 11 | dragHandleWidth: number; 12 | } 13 | function absoluteRect(node: Element) { 14 | const data = node.getBoundingClientRect(); 15 | 16 | return { 17 | top: data.top, 18 | left: data.left, 19 | width: data.width, 20 | }; 21 | } 22 | 23 | function nodeDOMAtCoords(coords: { x: number; y: number }) { 24 | return document 25 | .elementsFromPoint(coords.x, coords.y) 26 | .find( 27 | (elem: Element) => 28 | elem.parentElement?.matches?.(".ProseMirror") || 29 | elem.matches( 30 | [ 31 | "li", 32 | "p:not(:first-child)", 33 | "pre", 34 | "blockquote", 35 | "h1, h2, h3, h4, h5, h6", 36 | ].join(", ") 37 | ) 38 | ); 39 | } 40 | 41 | function nodePosAtDOM(node: Element, view: EditorView) { 42 | const boundingRect = node.getBoundingClientRect(); 43 | 44 | return view.posAtCoords({ 45 | left: boundingRect.left + 1, 46 | top: boundingRect.top + 1, 47 | })?.inside; 48 | } 49 | 50 | function DragHandle(options: DragHandleOptions) { 51 | function handleDragStart(event: DragEvent, view: EditorView) { 52 | view.focus(); 53 | 54 | if (!event.dataTransfer) return; 55 | 56 | const node = nodeDOMAtCoords({ 57 | x: event.clientX + 50 + options.dragHandleWidth, 58 | y: event.clientY, 59 | }); 60 | 61 | if (!(node instanceof Element)) return; 62 | 63 | const nodePos = nodePosAtDOM(node, view); 64 | if (nodePos == null || nodePos < 0) return; 65 | 66 | view.dispatch( 67 | view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)) 68 | ); 69 | 70 | const slice = view.state.selection.content(); 71 | const { dom, text } = __serializeForClipboard(view, slice); 72 | 73 | event.dataTransfer.clearData(); 74 | event.dataTransfer.setData("text/html", dom.innerHTML); 75 | event.dataTransfer.setData("text/plain", text); 76 | event.dataTransfer.effectAllowed = "copyMove"; 77 | 78 | event.dataTransfer.setDragImage(node, 0, 0); 79 | 80 | view.dragging = { slice, move: event.ctrlKey }; 81 | } 82 | 83 | function handleClick(event: MouseEvent, view: EditorView) { 84 | view.focus(); 85 | 86 | view.dom.classList.remove("dragging"); 87 | 88 | const node = nodeDOMAtCoords({ 89 | x: event.clientX + 50 + options.dragHandleWidth, 90 | y: event.clientY, 91 | }); 92 | 93 | if (!(node instanceof Element)) return; 94 | 95 | const nodePos = nodePosAtDOM(node, view); 96 | if (!nodePos) return; 97 | 98 | view.dispatch( 99 | view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)) 100 | ); 101 | } 102 | 103 | let dragHandleElement: HTMLElement | null = null; 104 | 105 | function hideDragHandle() { 106 | if (dragHandleElement) { 107 | dragHandleElement.classList.add("hidden"); 108 | } 109 | } 110 | 111 | function showDragHandle() { 112 | if (dragHandleElement) { 113 | dragHandleElement.classList.remove("hidden"); 114 | } 115 | } 116 | 117 | return new Plugin({ 118 | view: (view) => { 119 | dragHandleElement = document.createElement("div"); 120 | dragHandleElement.draggable = true; 121 | dragHandleElement.dataset.dragHandle = ""; 122 | dragHandleElement.classList.add("drag-handle"); 123 | dragHandleElement.addEventListener("dragstart", (e) => { 124 | handleDragStart(e, view); 125 | }); 126 | dragHandleElement.addEventListener("click", (e) => { 127 | handleClick(e, view); 128 | }); 129 | 130 | hideDragHandle(); 131 | 132 | view?.dom?.parentElement?.appendChild(dragHandleElement); 133 | 134 | return { 135 | destroy: () => { 136 | dragHandleElement?.remove?.(); 137 | dragHandleElement = null; 138 | }, 139 | }; 140 | }, 141 | props: { 142 | handleDOMEvents: { 143 | mousemove: (view, event) => { 144 | if (!view.editable) { 145 | return; 146 | } 147 | 148 | const node = nodeDOMAtCoords({ 149 | x: event.clientX + 50 + options.dragHandleWidth, 150 | y: event.clientY, 151 | }); 152 | 153 | if (!(node instanceof Element) || node.matches("ul, ol")) { 154 | hideDragHandle(); 155 | return; 156 | } 157 | 158 | const compStyle = window.getComputedStyle(node); 159 | const lineHeight = parseInt(compStyle.lineHeight, 10); 160 | const paddingTop = parseInt(compStyle.paddingTop, 10); 161 | 162 | const rect = absoluteRect(node); 163 | 164 | rect.top += (lineHeight - 24) / 2; 165 | rect.top += paddingTop; 166 | // Li markers 167 | if (node.matches("ul:not([data-type=taskList]) li, ol li")) { 168 | rect.left -= options.dragHandleWidth; 169 | } 170 | rect.width = options.dragHandleWidth; 171 | 172 | if (!dragHandleElement) return; 173 | 174 | dragHandleElement.style.left = `${rect.left - rect.width}px`; 175 | dragHandleElement.style.top = `${rect.top}px`; 176 | showDragHandle(); 177 | }, 178 | keydown: () => { 179 | hideDragHandle(); 180 | }, 181 | mousewheel: () => { 182 | hideDragHandle(); 183 | }, 184 | // dragging class is used for CSS 185 | dragstart: (view) => { 186 | view.dom.classList.add("dragging"); 187 | }, 188 | drop: (view) => { 189 | view.dom.classList.remove("dragging"); 190 | }, 191 | dragend: (view) => { 192 | view.dom.classList.remove("dragging"); 193 | }, 194 | }, 195 | }, 196 | }); 197 | } 198 | 199 | interface DragAndDropOptions { } 200 | 201 | const DragAndDrop = Extension.create({ 202 | name: "dragAndDrop", 203 | 204 | addProseMirrorPlugins() { 205 | return [ 206 | DragHandle({ 207 | dragHandleWidth: 24, 208 | }), 209 | ]; 210 | }, 211 | }); 212 | 213 | export default DragAndDrop; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/extensions/image-resizer.tsx: -------------------------------------------------------------------------------- 1 | import Moveable from "react-moveable"; 2 | 3 | export const ImageResizer = ({ editor }) => { 4 | const updateMediaSize = () => { 5 | const imageInfo = document.querySelector( 6 | ".ProseMirror-selectednode" 7 | ) as HTMLImageElement; 8 | if (imageInfo) { 9 | const selection = editor.state.selection; 10 | editor.commands.setImage({ 11 | src: imageInfo.src, 12 | width: Number(imageInfo.style.width.replace("px", "")), 13 | height: Number(imageInfo.style.height.replace("px", "")), 14 | }); 15 | editor.commands.setNodeSelection(selection.from); 16 | } 17 | }; 18 | 19 | return ( 20 | <> 21 | { 44 | delta[0] && (target!.style.width = `${width}px`); 45 | delta[1] && (target!.style.height = `${height}px`); 46 | }} 47 | // { target, isDrag, clientX, clientY }: any 48 | onResizeEnd={() => { 49 | updateMediaSize(); 50 | }} 51 | /* scalable */ 52 | /* Only one of resizable, scalable, warpable can be used. */ 53 | scalable={true} 54 | throttleScale={0} 55 | /* Set the direction of resizable */ 56 | renderDirections={["w", "e"]} 57 | onScale={({ 58 | target, 59 | // scale, 60 | // dist, 61 | // delta, 62 | transform, 63 | }: // clientX, 64 | // clientY, 65 | any) => { 66 | target!.style.transform = transform; 67 | }} 68 | /> 69 | 70 | ); 71 | }; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/extensions/index.ts: -------------------------------------------------------------------------------- 1 | import StarterKit from "@tiptap/starter-kit"; 2 | import HorizontalRule from "@tiptap/extension-horizontal-rule"; 3 | import TiptapLink from "@tiptap/extension-link"; 4 | import TiptapImage from "@tiptap/extension-image"; 5 | import Placeholder from "@tiptap/extension-placeholder"; 6 | import TiptapUnderline from "@tiptap/extension-underline"; 7 | import TextStyle from "@tiptap/extension-text-style"; 8 | import { Color } from "@tiptap/extension-color"; 9 | import Highlight from "@tiptap/extension-highlight"; 10 | import SlashCommand from "./slash-command"; 11 | import { InputRule } from "@tiptap/core"; 12 | import UploadImagesPlugin from "../plugins/upload-images"; 13 | import UpdatedImage from "./updated-image"; 14 | import CustomKeymap from "./custom-keymap"; 15 | import DragAndDrop from "./drag-and-drop"; 16 | 17 | export const defaultExtensions = [ 18 | StarterKit.configure({ 19 | bulletList: { 20 | HTMLAttributes: { 21 | style: "list-style-type: disc; list-style-position: outside; line-height: 12px; ", 22 | class: "mailrun", 23 | }, 24 | }, 25 | orderedList: { 26 | HTMLAttributes: { 27 | style: "list-style-type: deciaml; list-style-position: outside; line-height: 12px;", 28 | class: "mailrun", 29 | }, 30 | }, 31 | listItem: { 32 | HTMLAttributes: { 33 | style: "line-height: 1.5;", 34 | class: "mailrun", 35 | }, 36 | }, 37 | blockquote: { 38 | HTMLAttributes: { 39 | style: "border-left: 4px solid rgb(214 211 209); padding-left: 8px;", 40 | class: "mailrun" 41 | }, 42 | }, 43 | codeBlock: { 44 | HTMLAttributes: { 45 | class: 46 | "mailrun rounded-sm bg-stone-100 p-5 font-mono font-medium text-stone-800", 47 | }, 48 | }, 49 | code: { 50 | HTMLAttributes: { 51 | class: 52 | "mailrun rounded-md bg-stone-200 px-1.5 py-1 font-mono font-medium text-stone-900", 53 | spellcheck: "false", 54 | }, 55 | }, 56 | dropcursor: { 57 | color: "#DBEAFE", 58 | width: 4, 59 | }, 60 | gapcursor: false, 61 | horizontalRule: false, 62 | paragraph: { 63 | HTMLAttributes: { 64 | class: "mailrun", 65 | }, 66 | }, 67 | heading: { 68 | levels: [1, 2, 3], 69 | HTMLAttributes: { 70 | // style: "", 71 | class: "mailrun text-xl font-bold", 72 | } 73 | } 74 | }), 75 | // patch to fix horizontal rule bug: https://github.com/ueberdosis/tiptap/pull/3859#issuecomment-1536799740 76 | HorizontalRule.extend({ 77 | addInputRules() { 78 | return [ 79 | new InputRule({ 80 | find: /^(?:---|—-|___\s|\*\*\*\s)$/, 81 | handler: ({ state, range }) => { 82 | const attributes = {}; 83 | 84 | const { tr } = state; 85 | const start = range.from; 86 | let end = range.to; 87 | 88 | tr.insert(start - 1, this.type.create(attributes)).delete( 89 | tr.mapping.map(start), 90 | tr.mapping.map(end) 91 | ); 92 | }, 93 | }), 94 | ]; 95 | }, 96 | }).configure({ 97 | HTMLAttributes: { 98 | class: "mt-4 mb-6 border-t border-stone-300", 99 | }, 100 | }), 101 | TiptapLink.configure({ 102 | HTMLAttributes: { 103 | class: 104 | "text-stone-400 underline underline-offset-[3px] hover:text-stone-600 transition-colors cursor-pointer", 105 | }, 106 | }), 107 | TiptapImage.extend({ 108 | addProseMirrorPlugins() { 109 | return [UploadImagesPlugin()]; 110 | }, 111 | }).configure({ 112 | allowBase64: true, 113 | HTMLAttributes: { 114 | class: "rounded-lg border border-stone-200", 115 | }, 116 | }), 117 | UpdatedImage.configure({ 118 | HTMLAttributes: { 119 | class: "rounded-lg border border-stone-200", 120 | }, 121 | }), 122 | Placeholder.configure({ 123 | placeholder: ({ node }) => { 124 | if (node.type.name === "heading") { 125 | return `Heading ${node.attrs.level}`; 126 | } 127 | return "Press '/' for commands"; 128 | }, 129 | includeChildren: true, 130 | }), 131 | SlashCommand, 132 | TiptapUnderline, 133 | TextStyle, 134 | Color, 135 | Highlight.configure({ 136 | multicolor: true, 137 | }), 138 | CustomKeymap, 139 | DragAndDrop, 140 | ]; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/extensions/slash-command.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useState, 3 | useEffect, 4 | useCallback, 5 | ReactNode, 6 | useRef, 7 | useLayoutEffect, 8 | } from "react"; 9 | import { Editor, Range, Extension } from "@tiptap/core"; 10 | import Suggestion from "@tiptap/suggestion"; 11 | import { ReactRenderer } from "@tiptap/react"; 12 | import tippy from "tippy.js"; 13 | import { 14 | Heading1, 15 | Heading2, 16 | Heading3, 17 | List, 18 | ListOrdered, 19 | Text, 20 | TextQuote, 21 | Image as ImageIcon, 22 | Code, 23 | } from "lucide-react"; 24 | import { startImageUpload } from "../plugins/upload-images" 25 | 26 | interface CommandItemProps { 27 | title: string; 28 | description: string; 29 | icon: ReactNode; 30 | } 31 | 32 | interface CommandProps { 33 | editor: Editor; 34 | range: Range; 35 | } 36 | 37 | const Command = Extension.create({ 38 | name: "slash-command", 39 | addOptions() { 40 | return { 41 | suggestion: { 42 | char: "/", 43 | command: ({ 44 | editor, 45 | range, 46 | props, 47 | }: { 48 | editor: Editor; 49 | range: Range; 50 | props: any; 51 | }) => { 52 | props.command({ editor, range }); 53 | }, 54 | }, 55 | }; 56 | }, 57 | addProseMirrorPlugins() { 58 | return [ 59 | Suggestion({ 60 | editor: this.editor, 61 | ...this.options.suggestion, 62 | }), 63 | ]; 64 | }, 65 | }); 66 | 67 | const getSuggestionItems = ({ query }: { query: string }) => { 68 | return [ 69 | { 70 | title: "Text", 71 | description: "Just start typing with plain text.", 72 | searchTerms: ["p", "paragraph"], 73 | icon: , 74 | command: ({ editor, range }: CommandProps) => { 75 | editor 76 | .chain() 77 | .focus() 78 | .deleteRange(range) 79 | .toggleNode("paragraph", "paragraph") 80 | .run(); 81 | }, 82 | }, 83 | { 84 | title: "Heading 1", 85 | description: "Big section heading.", 86 | searchTerms: ["title", "big", "large"], 87 | icon: , 88 | command: ({ editor, range }: CommandProps) => { 89 | editor 90 | .chain() 91 | .focus() 92 | .deleteRange(range) 93 | .setNode("heading", { level: 1 }) 94 | .run(); 95 | }, 96 | }, 97 | { 98 | title: "Heading 2", 99 | description: "Medium section heading.", 100 | searchTerms: ["subtitle", "medium"], 101 | icon: , 102 | command: ({ editor, range }: CommandProps) => { 103 | editor 104 | .chain() 105 | .focus() 106 | .deleteRange(range) 107 | .setNode("heading", { level: 2 }) 108 | .run(); 109 | }, 110 | }, 111 | { 112 | title: "Heading 3", 113 | description: "Small section heading.", 114 | searchTerms: ["subtitle", "small"], 115 | icon: , 116 | command: ({ editor, range }: CommandProps) => { 117 | editor 118 | .chain() 119 | .focus() 120 | .deleteRange(range) 121 | .setNode("heading", { level: 3 }) 122 | .run(); 123 | }, 124 | }, 125 | { 126 | title: "Bullet List", 127 | description: "Create a simple bullet list.", 128 | searchTerms: ["unordered", "point"], 129 | icon: , 130 | command: ({ editor, range }: CommandProps) => { 131 | editor.chain().focus().deleteRange(range).toggleBulletList().run(); 132 | }, 133 | }, 134 | { 135 | title: "Numbered List", 136 | description: "Create a list with numbering.", 137 | searchTerms: ["ordered"], 138 | icon: , 139 | command: ({ editor, range }: CommandProps) => { 140 | editor.chain().focus().deleteRange(range).toggleOrderedList().run(); 141 | }, 142 | }, 143 | { 144 | title: "Quote", 145 | description: "Capture a quote.", 146 | searchTerms: ["blockquote"], 147 | icon: , 148 | command: ({ editor, range }: CommandProps) => 149 | editor 150 | .chain() 151 | .focus() 152 | .deleteRange(range) 153 | .toggleNode("paragraph", "paragraph") 154 | .toggleBlockquote() 155 | .run(), 156 | }, 157 | { 158 | title: "Code", 159 | description: "Capture a code snippet.", 160 | searchTerms: ["codeblock"], 161 | icon: , 162 | command: ({ editor, range }: CommandProps) => 163 | editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), 164 | }, 165 | { 166 | title: "Image", 167 | description: "Upload an image from your computer.", 168 | searchTerms: ["photo", "picture", "media"], 169 | icon: , 170 | command: ({ editor, range }: CommandProps) => { 171 | editor.chain().focus().deleteRange(range).run(); 172 | // upload image 173 | const input = document.createElement("input"); 174 | input.type = "file"; 175 | input.accept = "image/*"; 176 | input.onchange = async () => { 177 | if (input.files?.length) { 178 | const file = input.files[0]; 179 | const pos = editor.view.state.selection.from; 180 | startImageUpload(file, editor.view, pos); 181 | } 182 | }; 183 | input.click(); 184 | }, 185 | }, 186 | ].filter((item) => { 187 | if (typeof query === "string" && query.length > 0) { 188 | const search = query.toLowerCase(); 189 | return ( 190 | item.title.toLowerCase().includes(search) || 191 | item.description.toLowerCase().includes(search) || 192 | (item.searchTerms && 193 | item.searchTerms.some((term: string) => term.includes(search))) 194 | ); 195 | } 196 | return true; 197 | }); 198 | }; 199 | 200 | export const updateScrollView = (container: HTMLElement, item: HTMLElement) => { 201 | const containerHeight = container.offsetHeight; 202 | const itemHeight = item ? item.offsetHeight : 0; 203 | 204 | const top = item.offsetTop; 205 | const bottom = top + itemHeight; 206 | 207 | if (top < container.scrollTop) { 208 | container.scrollTop -= container.scrollTop - top + 5; 209 | } else if (bottom > containerHeight + container.scrollTop) { 210 | container.scrollTop += bottom - containerHeight - container.scrollTop + 5; 211 | } 212 | }; 213 | 214 | const CommandList = ({ 215 | items, 216 | command, 217 | // editor, 218 | // range, 219 | }: { 220 | items: CommandItemProps[]; 221 | command: any; 222 | editor: any; 223 | range: any; 224 | }) => { 225 | const [selectedIndex, setSelectedIndex] = useState(0); 226 | 227 | 228 | 229 | const selectItem = useCallback( 230 | (index: number) => { 231 | const item = items[index]; 232 | if (item) { 233 | 234 | command(item); 235 | } 236 | }, 237 | [command, items] 238 | ); 239 | 240 | useEffect(() => { 241 | const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; 242 | const onKeyDown = (e: KeyboardEvent) => { 243 | if (navigationKeys.includes(e.key)) { 244 | e.preventDefault(); 245 | if (e.key === "ArrowUp") { 246 | setSelectedIndex((selectedIndex + items.length - 1) % items.length); 247 | return true; 248 | } 249 | if (e.key === "ArrowDown") { 250 | setSelectedIndex((selectedIndex + 1) % items.length); 251 | return true; 252 | } 253 | if (e.key === "Enter") { 254 | selectItem(selectedIndex); 255 | return true; 256 | } 257 | return false; 258 | } 259 | }; 260 | document.addEventListener("keydown", onKeyDown); 261 | return () => { 262 | document.removeEventListener("keydown", onKeyDown); 263 | }; 264 | }, [items, selectedIndex, setSelectedIndex, selectItem]); 265 | 266 | useEffect(() => { 267 | setSelectedIndex(0); 268 | }, [items]); 269 | 270 | const commandListContainer = useRef(null); 271 | 272 | useLayoutEffect(() => { 273 | const container = commandListContainer?.current; 274 | 275 | const item = container?.children[selectedIndex] as HTMLElement; 276 | 277 | if (item && container) updateScrollView(container, item); 278 | }, [selectedIndex]); 279 | 280 | return items.length > 0 ? ( 281 |
286 | {items.map((item: CommandItemProps, index: number) => { 287 | return ( 288 | 308 | ); 309 | })} 310 |
311 | ) : null; 312 | }; 313 | 314 | const renderItems = () => { 315 | let component: ReactRenderer | null = null; 316 | let popup: any | null = null; 317 | 318 | return { 319 | onStart: (props: { editor: Editor; clientRect: DOMRect }) => { 320 | component = new ReactRenderer(CommandList, { 321 | props, 322 | editor: props.editor, 323 | }); 324 | 325 | // @ts-ignore 326 | popup = tippy("body", { 327 | getReferenceClientRect: props.clientRect, 328 | appendTo: () => document.body, 329 | content: component.element, 330 | showOnCreate: true, 331 | interactive: true, 332 | trigger: "manual", 333 | placement: "bottom-start", 334 | }); 335 | }, 336 | onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { 337 | component?.updateProps(props); 338 | 339 | popup && 340 | popup[0].setProps({ 341 | getReferenceClientRect: props.clientRect, 342 | }); 343 | }, 344 | onKeyDown: (props: { event: KeyboardEvent }) => { 345 | if (props.event.key === "Escape") { 346 | popup?.[0].hide(); 347 | 348 | return true; 349 | } 350 | 351 | // @ts-ignore 352 | return component?.ref?.onKeyDown(props); 353 | }, 354 | onExit: () => { 355 | popup?.[0].destroy(); 356 | component?.destroy(); 357 | }, 358 | }; 359 | }; 360 | 361 | const SlashCommand = Command.configure({ 362 | suggestion: { 363 | items: getSuggestionItems, 364 | render: renderItems, 365 | }, 366 | }); 367 | 368 | export default SlashCommand; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/extensions/updated-image.ts: -------------------------------------------------------------------------------- 1 | import Image from "@tiptap/extension-image"; 2 | 3 | const UpdatedImage = Image.extend({ 4 | addAttributes() { 5 | return { 6 | ...this.parent?.(), 7 | width: { 8 | default: null, 9 | }, 10 | height: { 11 | default: null, 12 | }, 13 | }; 14 | }, 15 | }); 16 | 17 | export default UpdatedImage; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { useEditor, EditorContent, JSONContent } from "@tiptap/react"; 3 | import { defaultEditorProps } from "./props"; 4 | import { defaultExtensions } from "./extensions"; 5 | import useLocalStorage from "@/lib/hooks/use-local-storage"; 6 | import { useDebouncedCallback } from "use-debounce"; 7 | import { EditorBubbleMenu } from "./bubble-menu"; 8 | import { ImageResizer } from "./extensions/image-resizer"; 9 | import { EditorProps } from "@tiptap/pm/view"; 10 | import "./prosemirror.css"; 11 | import { Editor as EditorClass, Extensions } from "@tiptap/core"; 12 | 13 | export default function Editor({ 14 | className = "relative min-h-[800px] w-[700px] max-w-[700px] border-stone-200 bg-white sm:rounded-lg sm:border sm:shadow-md", 15 | defaultValue = '', 16 | extensions = [], 17 | editorProps = {}, 18 | onUpdate = () => { }, 19 | onDebouncedUpdate = () => { }, 20 | debounceDuration = 750, 21 | storageKey = "mailrun__content", 22 | disableLocalStorage = false, 23 | }: { 24 | /** 25 | * Additional classes to add to the editor container. 26 | */ 27 | className?: string; 28 | /** 29 | * The default value to use for the editor. 30 | * Defaults to defaultEditorContent. 31 | */ 32 | defaultValue?: JSONContent | string; 33 | /** 34 | * A list of extensions to use for the editor, in addition to the default extensions. 35 | * Defaults to []. 36 | */ 37 | extensions?: Extensions; 38 | /** 39 | * Props to pass to the underlying Tiptap editor, in addition to the default editor props. 40 | * Defaults to {}. 41 | */ 42 | editorProps?: EditorProps; 43 | /** 44 | * A callback function that is called whenever the editor is updated. 45 | * Defaults to () => {}. 46 | */ 47 | // eslint-disable-next-line no-unused-vars 48 | onUpdate?: (editor?: EditorClass) => void | Promise; 49 | /** 50 | * A callback function that is called whenever the editor is updated, but only after the defined debounce duration. 51 | * Defaults to () => {}. 52 | */ 53 | // eslint-disable-next-line no-unused-vars 54 | onDebouncedUpdate?: (editor?: EditorClass) => void | Promise; 55 | /** 56 | * The duration (in milliseconds) to debounce the onDebouncedUpdate callback. 57 | * Defaults to 750. 58 | */ 59 | debounceDuration?: number; 60 | /** 61 | * The key to use for storing the editor's value in local storage. 62 | * Defaults to "mailrun__content". 63 | */ 64 | storageKey?: string; 65 | /** 66 | * Disable local storage read/save. 67 | * Defaults to false. 68 | */ 69 | disableLocalStorage?: boolean; 70 | }) { 71 | const [content, setContent] = useLocalStorage(storageKey, defaultValue); 72 | 73 | const [hydrated, setHydrated] = useState(false); 74 | 75 | const debouncedUpdates = useDebouncedCallback(async ({ editor }) => { 76 | const json = editor.getJSON(); 77 | onDebouncedUpdate(editor); 78 | 79 | if (!disableLocalStorage) { 80 | setContent(json); 81 | } 82 | }, debounceDuration); 83 | 84 | const editor = useEditor({ 85 | extensions: [...defaultExtensions, ...extensions], 86 | editorProps: { 87 | ...defaultEditorProps, 88 | ...editorProps, 89 | }, 90 | onUpdate: (e) => { 91 | onUpdate(e.editor); 92 | debouncedUpdates(e); 93 | }, 94 | autofocus: "end", 95 | }); 96 | 97 | 98 | // Default: Hydrate the editor with the content from localStorage. 99 | // If disableLocalStorage is true, hydrate the editor with the defaultValue. 100 | useEffect(() => { 101 | if (!editor || hydrated) return; 102 | 103 | const value = disableLocalStorage ? defaultValue : content; 104 | 105 | if (value) { 106 | editor.commands.setContent(value); 107 | setHydrated(true); 108 | } 109 | }, [editor, defaultValue, content, hydrated, disableLocalStorage]); 110 | 111 | return ( 112 |
{ 114 | editor?.chain().focus().run(); 115 | }} 116 | className={className} 117 | > 118 | {editor && } 119 | {editor?.isActive("image") && } 120 | 121 |
122 | ); 123 | } -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/plugins/upload-images.tsx: -------------------------------------------------------------------------------- 1 | import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; 2 | import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; 3 | import { toast } from "sonner"; 4 | const uploadKey = new PluginKey("upload-image"); 5 | 6 | const UploadImagesPlugin = () => 7 | new Plugin({ 8 | key: uploadKey, 9 | state: { 10 | init() { 11 | return DecorationSet.empty; 12 | }, 13 | apply(tr, set) { 14 | set = set.map(tr.mapping, tr.doc); 15 | // See if the transaction adds or removes any placeholders 16 | const action = tr.getMeta(this); 17 | if (action && action.add) { 18 | const { id, pos, src } = action.add; 19 | 20 | const placeholder = document.createElement("div"); 21 | placeholder.setAttribute("class", "img-placeholder"); 22 | const image = document.createElement("img"); 23 | image.setAttribute( 24 | "class", 25 | "opacity-40 rounded-lg border border-stone-200" 26 | ); 27 | image.src = src; 28 | placeholder.appendChild(image); 29 | const deco = Decoration.widget(pos + 1, placeholder, { 30 | id, 31 | }); 32 | set = set.add(tr.doc, [deco]); 33 | } else if (action && action.remove) { 34 | set = set.remove( 35 | set.find(null, null, (spec) => spec.id == action.remove.id) 36 | ); 37 | } 38 | return set; 39 | }, 40 | }, 41 | props: { 42 | decorations(state) { 43 | return this.getState(state); 44 | }, 45 | }, 46 | }); 47 | 48 | export default UploadImagesPlugin; 49 | 50 | function findPlaceholder(state: EditorState, id: {}) { 51 | const decos = uploadKey.getState(state); 52 | const found = decos.find(null, null, (spec) => spec.id == id); 53 | return found.length ? found[0].from : null; 54 | } 55 | 56 | export function startImageUpload(file: File, view: EditorView, pos: number) { 57 | // check if the file is an image 58 | if (!file.type.includes("image/")) { 59 | toast.error("File type not supported."); 60 | return; 61 | 62 | // check if the file size is less than 20MB 63 | } else if (file.size / 1024 / 1024 > 20) { 64 | toast.error("File size too big (max 20MB)."); 65 | return; 66 | } 67 | 68 | // A fresh object to act as the ID for this upload 69 | const id = {}; 70 | 71 | // Replace the selection with a placeholder 72 | const tr = view.state.tr; 73 | if (!tr.selection.empty) tr.deleteSelection(); 74 | 75 | const reader = new FileReader(); 76 | reader.readAsDataURL(file); 77 | reader.onload = () => { 78 | tr.setMeta(uploadKey, { 79 | add: { 80 | id, 81 | pos, 82 | src: reader.result, 83 | }, 84 | }); 85 | view.dispatch(tr); 86 | }; 87 | 88 | handleImageUpload(file).then((src) => { 89 | const { schema } = view.state; 90 | 91 | let pos = findPlaceholder(view.state, id); 92 | // If the content around the placeholder has been deleted, drop 93 | // the image 94 | if (pos == null) return; 95 | 96 | // Otherwise, insert it at the placeholder's position, and remove 97 | // the placeholder 98 | 99 | // When BLOB_READ_WRITE_TOKEN is not valid or unavailable, read 100 | // the image locally 101 | const imageSrc = typeof src === "object" ? reader.result : src; 102 | 103 | const node = schema.nodes.image.create({ src: imageSrc }); 104 | const transaction = view.state.tr 105 | .replaceWith(pos, pos, node) 106 | .setMeta(uploadKey, { remove: { id } }); 107 | view.dispatch(transaction); 108 | }); 109 | } 110 | 111 | export const handleImageUpload = (file: File) => { 112 | // TODO: upload to Frappe server 113 | return new Promise((resolve) => { 114 | toast.promise( 115 | fetch("/api/upload", { 116 | method: "POST", 117 | headers: { 118 | "content-type": file?.type || "application/octet-stream", 119 | "x-vercel-filename": file?.name || "image.png", 120 | }, 121 | body: file, 122 | }).then(async (res) => { 123 | // Successfully uploaded image 124 | if (res.status === 200) { 125 | const { url } = (await res.json()) as BlobResult; 126 | // preload the image 127 | let image = new Image(); 128 | image.src = url; 129 | image.onload = () => { 130 | resolve(url); 131 | }; 132 | // No blob store configured 133 | } else if (res.status === 401) { 134 | resolve(file); 135 | 136 | throw new Error( 137 | "`BLOB_READ_WRITE_TOKEN` environment variable not found, reading image locally instead." 138 | ); 139 | // Unknown error 140 | } else { 141 | throw new Error(`Error uploading image. Please try again.`); 142 | } 143 | }), 144 | { 145 | loading: "Uploading image...", 146 | success: "Image uploaded successfully.", 147 | error: (e) => e.message, 148 | } 149 | ); 150 | }); 151 | }; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/props.ts: -------------------------------------------------------------------------------- 1 | import { EditorProps } from "@tiptap/pm/view"; 2 | import { startImageUpload } from "./plugins/upload-images"; 3 | 4 | export const defaultEditorProps: EditorProps = { 5 | attributes: { 6 | class: `prose-lg prose-stone dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`, 7 | }, 8 | handleDOMEvents: { 9 | keydown: (_view, event) => { 10 | // prevent default event listeners from firing when slash command is active 11 | if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { 12 | const slashCommand = document.querySelector("#slash-command"); 13 | if (slashCommand) { 14 | return true; 15 | } 16 | } 17 | }, 18 | }, 19 | handlePaste: (view, event) => { 20 | if ( 21 | event.clipboardData && 22 | event.clipboardData.files && 23 | event.clipboardData.files[0] 24 | ) { 25 | event.preventDefault(); 26 | const file = event.clipboardData.files[0]; 27 | const pos = view.state.selection.from; 28 | 29 | startImageUpload(file, view, pos); 30 | return true; 31 | } 32 | return false; 33 | }, 34 | handleDrop: (view, event, _slice, moved) => { 35 | if ( 36 | !moved && 37 | event.dataTransfer && 38 | event.dataTransfer.files && 39 | event.dataTransfer.files[0] 40 | ) { 41 | event.preventDefault(); 42 | const file = event.dataTransfer.files[0]; 43 | const coordinates = view.posAtCoords({ 44 | left: event.clientX, 45 | top: event.clientY, 46 | }); 47 | // here we deduct 1 from the pos or else the image will create an extra node 48 | startImageUpload(file, view, coordinates?.pos || 0 - 1); 49 | return true; 50 | } 51 | return false; 52 | }, 53 | }; -------------------------------------------------------------------------------- /dashboard/src/components/features/editor/prosemirror.css: -------------------------------------------------------------------------------- 1 | .ProseMirror { 2 | @apply p-12 px-8 sm:px-12; 3 | } 4 | 5 | .ProseMirror .is-editor-empty:first-child::before { 6 | content: attr(data-placeholder); 7 | float: left; 8 | color: #aaa; 9 | pointer-events: none; 10 | height: 0; 11 | } 12 | 13 | .ProseMirror .is-empty::before { 14 | content: attr(data-placeholder); 15 | float: left; 16 | color: #aaa; 17 | pointer-events: none; 18 | height: 0; 19 | } 20 | 21 | /* Custom image styles */ 22 | 23 | .ProseMirror img { 24 | transition: filter 0.1s ease-in-out; 25 | 26 | &:hover { 27 | cursor: pointer; 28 | filter: brightness(90%); 29 | } 30 | 31 | &.ProseMirror-selectednode { 32 | outline: 3px solid #5abbf7; 33 | filter: brightness(90%); 34 | } 35 | } 36 | 37 | .img-placeholder { 38 | position: relative; 39 | 40 | &:before { 41 | content: ""; 42 | box-sizing: border-box; 43 | position: absolute; 44 | top: 50%; 45 | left: 50%; 46 | width: 36px; 47 | height: 36px; 48 | border-radius: 50%; 49 | border: 3px solid var(--primary); 50 | border-top-color: var(--primary); 51 | animation: spinning 0.6s linear infinite; 52 | } 53 | } 54 | 55 | @keyframes spinning { 56 | to { 57 | transform: rotate(360deg); 58 | } 59 | } 60 | 61 | /* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */ 62 | 63 | ul[data-type="taskList"] li>label { 64 | margin-right: 0.2rem; 65 | user-select: none; 66 | } 67 | 68 | @media screen and (max-width: 768px) { 69 | ul[data-type="taskList"] li>label { 70 | margin-right: 0.5rem; 71 | } 72 | } 73 | 74 | ul[data-type="taskList"] li>label input[type="checkbox"] { 75 | -webkit-appearance: none; 76 | appearance: none; 77 | background-color: white; 78 | margin: 0; 79 | cursor: pointer; 80 | width: 1.2em; 81 | height: 1.2em; 82 | position: relative; 83 | top: 5px; 84 | border: 2px solid var(--stone-900); 85 | margin-right: 0.3rem; 86 | display: grid; 87 | place-content: center; 88 | 89 | &:hover { 90 | background-color: var(--stone-50); 91 | } 92 | 93 | &:active { 94 | background-color: var(--stone-200); 95 | } 96 | 97 | &::before { 98 | content: ""; 99 | width: 0.65em; 100 | height: 0.65em; 101 | transform: scale(0); 102 | transition: 120ms transform ease-in-out; 103 | box-shadow: inset 1em 1em; 104 | transform-origin: center; 105 | clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); 106 | } 107 | 108 | &:checked::before { 109 | transform: scale(1); 110 | } 111 | } 112 | 113 | ul[data-type="taskList"] li[data-checked="true"]>div>p { 114 | color: var(--stone-400); 115 | text-decoration: line-through; 116 | text-decoration-thickness: 2px; 117 | } 118 | 119 | /* Overwrite tippy-box original max-width */ 120 | 121 | .tippy-box { 122 | max-width: 400px !important; 123 | } 124 | 125 | .ProseMirror:not(.dragging) .ProseMirror-selectednode { 126 | outline: none !important; 127 | border-radius: 0.2rem; 128 | background-color: var(--highlight-blue); 129 | transition: background-color 0.2s; 130 | box-shadow: none; 131 | } 132 | 133 | .drag-handle { 134 | position: fixed; 135 | opacity: 1; 136 | transition: opacity ease-in 0.2s; 137 | border-radius: 0.25rem; 138 | 139 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); 140 | background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem); 141 | background-repeat: no-repeat; 142 | background-position: center; 143 | width: 1.2rem; 144 | height: 1.5rem; 145 | z-index: 50; 146 | cursor: grab; 147 | 148 | &:hover { 149 | background-color: var(--stone-100); 150 | transition: background-color 0.2s; 151 | } 152 | 153 | &:active { 154 | background-color: var(--stone-200); 155 | transition: background-color 0.2s; 156 | } 157 | 158 | &.hide { 159 | opacity: 0; 160 | pointer-events: none; 161 | } 162 | 163 | @media screen and (max-width: 600px) { 164 | display: none; 165 | pointer-events: none; 166 | } 167 | } 168 | 169 | .dark-theme .drag-handle { 170 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E"); 171 | } -------------------------------------------------------------------------------- /dashboard/src/components/features/newsletter/DesignSheet.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | import { 3 | Sheet, 4 | SheetContent, 5 | SheetFooter, 6 | SheetHeader, 7 | SheetTitle, 8 | SheetTrigger, 9 | } from "@/components/ui/sheet" 10 | import { PaintBucket } from "lucide-react" 11 | import { Input } from "@/components/ui/input" 12 | import { Label } from "@/components/ui/label" 13 | import { 14 | Select, 15 | SelectContent, 16 | SelectItem, 17 | SelectTrigger, 18 | SelectValue, 19 | } from "@/components/ui/select" 20 | import { Separator } from "@/components/ui/separator" 21 | 22 | 23 | type Props = {} 24 | 25 | export const DesignSheet = (props: Props) => { 26 | return ( 27 | 28 | 29 | 33 | 34 | 35 | 36 | Design your email 37 | 38 |
39 |

Body

40 |
41 | 42 | 43 |
44 |
45 | 46 | 56 |
57 |
58 | 59 | 60 |
61 | 62 |

Typography

63 |
64 | 65 | 66 |
67 |
68 | 69 | 70 |
71 |
72 | 73 | 74 |
75 |
76 | 77 | 78 | 79 |
80 |
81 | ) 82 | } -------------------------------------------------------------------------------- /dashboard/src/components/features/newsletter/NewsletterHeader.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button' 2 | import { Newsletter } from '@/types/Newsletter' 3 | import { ArrowLeft } from 'lucide-react' 4 | import { Link } from 'react-router-dom' 5 | import { DesignSheet } from './DesignSheet' 6 | 7 | interface NewsletterHeaderProps { 8 | newsletter?: Newsletter 9 | } 10 | 11 | export const NewsletterHeader = ({ newsletter }: NewsletterHeaderProps) => { 12 | return ( 13 |
14 |
15 | 20 | 21 |
22 |
23 |

{newsletter?.subject ?? "Untitled"}

24 |
25 |
26 | 27 | 28 | 29 |
30 |
31 | ) 32 | } -------------------------------------------------------------------------------- /dashboard/src/components/layout/ErrorBanner.tsx: -------------------------------------------------------------------------------- 1 | import { FrappeError } from 'frappe-react-sdk' 2 | import { PropsWithChildren, useMemo } from 'react' 3 | import React from 'react' 4 | import { Alert, AlertTitle } from "@/components/ui/alert" 5 | import { AlertTriangle } from 'lucide-react' 6 | 7 | interface ErrorBannerProps { 8 | error?: FrappeError | null, 9 | children?: React.ReactNode 10 | } 11 | 12 | interface ParsedErrorMessage { 13 | message: string, 14 | title?: string, 15 | indicator?: string, 16 | } 17 | export const ErrorBanner = ({ error, children }: ErrorBannerProps) => { 18 | 19 | 20 | //exc_type: "ValidationError" or "PermissionError" etc 21 | // exc: With entire traceback - useful for reporting maybe 22 | // httpStatus and httpStatusText - not needed 23 | // _server_messages: Array of messages - useful for showing to user 24 | // console.log(JSON.parse(error?._server_messages!)) 25 | 26 | const messages = useMemo(() => { 27 | if (!error) return [] 28 | let eMessages: ParsedErrorMessage[] = error?._server_messages ? JSON.parse(error?._server_messages) : [] 29 | eMessages = eMessages.map((m: any) => { 30 | try { 31 | return JSON.parse(m) 32 | } catch (e) { 33 | return m 34 | } 35 | }) 36 | 37 | if (eMessages.length === 0) { 38 | // Get the message from the exception by removing the exc_type 39 | const indexOfFirstColon = error?.exception?.indexOf(':') 40 | if (indexOfFirstColon) { 41 | const exception = error?.exception?.slice(indexOfFirstColon + 1) 42 | if (exception) { 43 | eMessages = [{ 44 | message: exception, 45 | title: "Error" 46 | }] 47 | } 48 | } 49 | 50 | if (eMessages.length === 0) { 51 | eMessages = [{ 52 | message: error?.message, 53 | title: "Error", 54 | indicator: "red" 55 | }] 56 | } 57 | } 58 | return eMessages 59 | }, [error]) 60 | 61 | if (messages.length === 0 || !error) return null 62 | return ( 63 | {/* Can do this since the error will be coming from the server */} 64 | {messages.map((m, i) =>
)} 67 | {children} 68 | ) 69 | } 70 | 71 | 72 | export const ErrorCallout = ({ children }: PropsWithChildren) => { 73 | return ( 74 | 75 | 76 | {children} 77 | 78 | ) 79 | } -------------------------------------------------------------------------------- /dashboard/src/components/layout/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from "react" 2 | 3 | 4 | export const PageHeader = ({ children }: PropsWithChildren) => { 5 | return ( 6 |
7 | {children} 8 |
9 | ) 10 | } 11 | 12 | 13 | export const PageHeading = ({ children }: PropsWithChildren) => { 14 | 15 | return

{children}

16 | } -------------------------------------------------------------------------------- /dashboard/src/components/layout/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | import { Users, Mails, Newspaper, User, Tags } from 'lucide-react' 3 | import { NavLink } from "react-router-dom" 4 | 5 | const ICON_SIZE = '16' 6 | export const Sidebar = () => { 7 | return ( 8 | 33 | ) 34 | } 35 | 36 | 37 | const SidebarItem = ({ path, icon, label }: { icon: ReactNode, label: string, path: string }) => { 38 | return `flex items-center space-x-2 p-2 rounded-md hover:bg-slate-100 hover:text-gray-800 dark:hover:text-gray-50 dark:hover:bg-slate-800 ${isActive ? 'bg-slate-200 text-gray-800 dark:text-gray-50' : 'text-gray-600 dark:text-gray-100'}`}> 40 | {icon} 41 | {label} 42 | 43 | } 44 | 45 | const SidebarHeading = ({ label }: { label: string }) => { 46 | return {label} 47 | } -------------------------------------------------------------------------------- /dashboard/src/components/layout/TableLoader.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TableBody, 3 | TableCell, 4 | TableRow, 5 | } from "@/components/ui/table" 6 | import { Skeleton } from "../ui/skeleton" 7 | 8 | interface TableLoaderProps { 9 | rows?: number, 10 | columns?: number 11 | } 12 | 13 | export const TableLoader = ({ rows = 10, columns = 5 }: TableLoaderProps) => { 14 | return ( 15 | 16 | { 17 | [...Array(rows)].map((e, index) => 18 | {[...Array(columns)].map((e, i) => 19 | 20 | )} 21 | ) 22 | } 23 | 24 | ) 25 | } -------------------------------------------------------------------------------- /dashboard/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react" 2 | 3 | type Theme = "dark" | "light" | "system" 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode 7 | defaultTheme?: Theme 8 | storageKey?: string 9 | } 10 | 11 | type ThemeProviderState = { 12 | theme: Theme 13 | setTheme: (theme: Theme) => void 14 | } 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | } 20 | 21 | const ThemeProviderContext = createContext(initialState) 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = "system", 26 | storageKey = "vite-ui-theme", 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 | ) 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement 35 | 36 | root.classList.remove("light", "dark") 37 | 38 | if (theme === "system") { 39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 | .matches 41 | ? "dark" 42 | : "light" 43 | 44 | root.classList.add("light") 45 | return 46 | } 47 | 48 | root.classList.add(theme) 49 | }, [theme]) 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme) 55 | setTheme(theme) 56 | }, 57 | } 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ) 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext) 68 | 69 | if (context === undefined) 70 | throw new Error("useTheme must be used within a ThemeProvider") 71 | 72 | return context 73 | } 74 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { buttonVariants } from "@/components/ui/button" 6 | 7 | const AlertDialog = AlertDialogPrimitive.Root 8 | 9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 10 | 11 | const AlertDialogPortal = AlertDialogPrimitive.Portal 12 | 13 | const AlertDialogOverlay = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, ...props }, ref) => ( 17 | 25 | )) 26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 27 | 28 | const AlertDialogContent = React.forwardRef< 29 | React.ElementRef, 30 | React.ComponentPropsWithoutRef 31 | >(({ className, ...props }, ref) => ( 32 | 33 | 34 | 42 | 43 | )) 44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 45 | 46 | const AlertDialogHeader = ({ 47 | className, 48 | ...props 49 | }: React.HTMLAttributes) => ( 50 |
57 | ) 58 | AlertDialogHeader.displayName = "AlertDialogHeader" 59 | 60 | const AlertDialogFooter = ({ 61 | className, 62 | ...props 63 | }: React.HTMLAttributes) => ( 64 |
71 | ) 72 | AlertDialogFooter.displayName = "AlertDialogFooter" 73 | 74 | const AlertDialogTitle = React.forwardRef< 75 | React.ElementRef, 76 | React.ComponentPropsWithoutRef 77 | >(({ className, ...props }, ref) => ( 78 | 83 | )) 84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 85 | 86 | const AlertDialogDescription = React.forwardRef< 87 | React.ElementRef, 88 | React.ComponentPropsWithoutRef 89 | >(({ className, ...props }, ref) => ( 90 | 95 | )) 96 | AlertDialogDescription.displayName = 97 | AlertDialogPrimitive.Description.displayName 98 | 99 | const AlertDialogAction = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 110 | 111 | const AlertDialogCancel = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 124 | )) 125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 126 | 127 | export { 128 | AlertDialog, 129 | AlertDialogPortal, 130 | AlertDialogOverlay, 131 | AlertDialogTrigger, 132 | AlertDialogContent, 133 | AlertDialogHeader, 134 | AlertDialogFooter, 135 | AlertDialogTitle, 136 | AlertDialogDescription, 137 | AlertDialogAction, 138 | AlertDialogCancel, 139 | } 140 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "ring-gray-500/10 bg-gray-50 text-gray-600 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20", 17 | outline: "text-foreground", 18 | success: 19 | "bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ) 27 | 28 | export interface BadgeProps 29 | extends React.HTMLAttributes, 30 | VariantProps { } 31 | 32 | function Badge({ className, variant, ...props }: BadgeProps) { 33 | return ( 34 |
35 | ) 36 | } 37 | 38 | export { Badge, badgeVariants } 39 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { CheckIcon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | 22 | 23 | 24 | 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { type DialogProps } from "@radix-ui/react-dialog" 3 | import { MagnifyingGlassIcon } from "@radix-ui/react-icons" 4 | import { Command as CommandPrimitive } from "cmdk" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Dialog, DialogContent } from "@/components/ui/dialog" 8 | 9 | const Command = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )) 22 | Command.displayName = CommandPrimitive.displayName 23 | 24 | interface CommandDialogProps extends DialogProps {} 25 | 26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/context-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" 3 | import { 4 | CheckIcon, 5 | ChevronRightIcon, 6 | DotFilledIcon, 7 | } from "@radix-ui/react-icons" 8 | 9 | import { cn } from "@/lib/utils" 10 | 11 | const ContextMenu = ContextMenuPrimitive.Root 12 | 13 | const ContextMenuTrigger = ContextMenuPrimitive.Trigger 14 | 15 | const ContextMenuGroup = ContextMenuPrimitive.Group 16 | 17 | const ContextMenuPortal = ContextMenuPrimitive.Portal 18 | 19 | const ContextMenuSub = ContextMenuPrimitive.Sub 20 | 21 | const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup 22 | 23 | const ContextMenuSubTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef & { 26 | inset?: boolean 27 | } 28 | >(({ className, inset, children, ...props }, ref) => ( 29 | 38 | {children} 39 | 40 | 41 | )) 42 | ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName 43 | 44 | const ContextMenuSubContent = React.forwardRef< 45 | React.ElementRef, 46 | React.ComponentPropsWithoutRef 47 | >(({ className, ...props }, ref) => ( 48 | 56 | )) 57 | ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName 58 | 59 | const ContextMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, ...props }, ref) => ( 63 | 64 | 72 | 73 | )) 74 | ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName 75 | 76 | const ContextMenuItem = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef & { 79 | inset?: boolean 80 | } 81 | >(({ className, inset, ...props }, ref) => ( 82 | 91 | )) 92 | ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName 93 | 94 | const ContextMenuCheckboxItem = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, children, checked, ...props }, ref) => ( 98 | 107 | 108 | 109 | 110 | 111 | 112 | {children} 113 | 114 | )) 115 | ContextMenuCheckboxItem.displayName = 116 | ContextMenuPrimitive.CheckboxItem.displayName 117 | 118 | const ContextMenuRadioItem = React.forwardRef< 119 | React.ElementRef, 120 | React.ComponentPropsWithoutRef 121 | >(({ className, children, ...props }, ref) => ( 122 | 130 | 131 | 132 | 133 | 134 | 135 | {children} 136 | 137 | )) 138 | ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName 139 | 140 | const ContextMenuLabel = React.forwardRef< 141 | React.ElementRef, 142 | React.ComponentPropsWithoutRef & { 143 | inset?: boolean 144 | } 145 | >(({ className, inset, ...props }, ref) => ( 146 | 155 | )) 156 | ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName 157 | 158 | const ContextMenuSeparator = React.forwardRef< 159 | React.ElementRef, 160 | React.ComponentPropsWithoutRef 161 | >(({ className, ...props }, ref) => ( 162 | 167 | )) 168 | ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName 169 | 170 | const ContextMenuShortcut = ({ 171 | className, 172 | ...props 173 | }: React.HTMLAttributes) => { 174 | return ( 175 | 182 | ) 183 | } 184 | ContextMenuShortcut.displayName = "ContextMenuShortcut" 185 | 186 | export { 187 | ContextMenu, 188 | ContextMenuTrigger, 189 | ContextMenuContent, 190 | ContextMenuItem, 191 | ContextMenuCheckboxItem, 192 | ContextMenuRadioItem, 193 | ContextMenuLabel, 194 | ContextMenuSeparator, 195 | ContextMenuShortcut, 196 | ContextMenuGroup, 197 | ContextMenuPortal, 198 | ContextMenuSub, 199 | ContextMenuSubContent, 200 | ContextMenuSubTrigger, 201 | ContextMenuRadioGroup, 202 | } 203 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { Cross2Icon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { 4 | CheckIcon, 5 | ChevronRightIcon, 6 | DotFilledIcon, 7 | } from "@radix-ui/react-icons" 8 | 9 | import { cn } from "@/lib/utils" 10 | 11 | const DropdownMenu = DropdownMenuPrimitive.Root 12 | 13 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 14 | 15 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 16 | 17 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 18 | 19 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 20 | 21 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 22 | 23 | const DropdownMenuSubTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef & { 26 | inset?: boolean 27 | } 28 | >(({ className, inset, children, ...props }, ref) => ( 29 | 38 | {children} 39 | 40 | 41 | )) 42 | DropdownMenuSubTrigger.displayName = 43 | DropdownMenuPrimitive.SubTrigger.displayName 44 | 45 | const DropdownMenuSubContent = React.forwardRef< 46 | React.ElementRef, 47 | React.ComponentPropsWithoutRef 48 | >(({ className, ...props }, ref) => ( 49 | 57 | )) 58 | DropdownMenuSubContent.displayName = 59 | DropdownMenuPrimitive.SubContent.displayName 60 | 61 | const DropdownMenuContent = React.forwardRef< 62 | React.ElementRef, 63 | React.ComponentPropsWithoutRef 64 | >(({ className, sideOffset = 4, ...props }, ref) => ( 65 | 66 | 76 | 77 | )) 78 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 79 | 80 | const DropdownMenuItem = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef & { 83 | inset?: boolean 84 | } 85 | >(({ className, inset, ...props }, ref) => ( 86 | 95 | )) 96 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 97 | 98 | const DropdownMenuCheckboxItem = React.forwardRef< 99 | React.ElementRef, 100 | React.ComponentPropsWithoutRef 101 | >(({ className, children, checked, ...props }, ref) => ( 102 | 111 | 112 | 113 | 114 | 115 | 116 | {children} 117 | 118 | )) 119 | DropdownMenuCheckboxItem.displayName = 120 | DropdownMenuPrimitive.CheckboxItem.displayName 121 | 122 | const DropdownMenuRadioItem = React.forwardRef< 123 | React.ElementRef, 124 | React.ComponentPropsWithoutRef 125 | >(({ className, children, ...props }, ref) => ( 126 | 134 | 135 | 136 | 137 | 138 | 139 | {children} 140 | 141 | )) 142 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 143 | 144 | const DropdownMenuLabel = React.forwardRef< 145 | React.ElementRef, 146 | React.ComponentPropsWithoutRef & { 147 | inset?: boolean 148 | } 149 | >(({ className, inset, ...props }, ref) => ( 150 | 159 | )) 160 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 161 | 162 | const DropdownMenuSeparator = React.forwardRef< 163 | React.ElementRef, 164 | React.ComponentPropsWithoutRef 165 | >(({ className, ...props }, ref) => ( 166 | 171 | )) 172 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 173 | 174 | const DropdownMenuShortcut = ({ 175 | className, 176 | ...props 177 | }: React.HTMLAttributes) => { 178 | return ( 179 | 183 | ) 184 | } 185 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 186 | 187 | export { 188 | DropdownMenu, 189 | DropdownMenuTrigger, 190 | DropdownMenuContent, 191 | DropdownMenuItem, 192 | DropdownMenuCheckboxItem, 193 | DropdownMenuRadioItem, 194 | DropdownMenuLabel, 195 | DropdownMenuSeparator, 196 | DropdownMenuShortcut, 197 | DropdownMenuGroup, 198 | DropdownMenuPortal, 199 | DropdownMenuSub, 200 | DropdownMenuSubContent, 201 | DropdownMenuSubTrigger, 202 | DropdownMenuRadioGroup, 203 | } 204 | -------------------------------------------------------------------------------- /dashboard/src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |