Initial Commit
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_URI=http://localhost:8080
|
||||
NEXT_PUBLIC_AWS_REGION=eu-west-3
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
|
||||
config.module.rules.push({
|
||||
test: [/src\/(components|layouts)\/index.ts/i],
|
||||
sideEffects: false,
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@plunk/dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"clean": "rimraf .next node_modules .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@plunk/shared": "1.0.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@tiptap/core": "^2.5.4",
|
||||
"@tiptap/extension-color": "^2.5.4",
|
||||
"@tiptap/extension-dropcursor": "^2.5.4",
|
||||
"@tiptap/extension-font-family": "^2.5.4",
|
||||
"@tiptap/extension-image": "^2.5.4",
|
||||
"@tiptap/extension-link": "^2.5.4",
|
||||
"@tiptap/extension-placeholder": "^2.5.4",
|
||||
"@tiptap/extension-text-align": "^2.5.4",
|
||||
"@tiptap/extension-text-style": "^2.5.4",
|
||||
"@tiptap/extension-typography": "^2.5.4",
|
||||
"@tiptap/pm": "^2.5.4",
|
||||
"@tiptap/react": "^2.5.4",
|
||||
"@tiptap/starter-kit": "^2.5.4",
|
||||
"@tiptap/suggestion": "^2.5.4",
|
||||
"@uiball/loaders": "^1.3.1",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.12",
|
||||
"framer-motion": "^11.3.7",
|
||||
"jotai": "2.9.0",
|
||||
"lucide-react": "^0.408.0",
|
||||
"next": "14.2.5",
|
||||
"next-seo": "^6.5.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"prosemirror-commands": "^1.5.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"recharts": "^2.12.7",
|
||||
"sharp": "^0.33.4",
|
||||
"sonner": "^1.5.0",
|
||||
"swr": "2.2.5",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/favicon/mstile-150x150.png"/>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
After Width: | Height: | Size: 658 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="1080.000000pt" height="1080.000000pt" viewBox="0 0 1080.000000 1080.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,1080.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M6218 10755 c-1 -1 -52 -6 -113 -9 -109 -6 -147 -10 -235 -21 -25 -3
|
||||
-65 -8 -90 -11 -38 -5 -115 -17 -255 -40 -16 -3 -68 -14 -115 -24 -47 -10 -96
|
||||
-21 -110 -23 -23 -5 -66 -16 -239 -64 -136 -37 -411 -134 -546 -193 -252 -109
|
||||
-535 -257 -735 -384 -59 -38 -184 -121 -195 -129 -5 -4 -59 -44 -120 -88 -266
|
||||
-197 -581 -493 -785 -739 -19 -23 -43 -52 -54 -64 -10 -11 -39 -48 -64 -81
|
||||
-25 -33 -49 -62 -52 -65 -18 -14 -260 -357 -260 -367 0 -3 -10 -20 -23 -37
|
||||
-69 -93 -387 -656 -392 -696 -1 -3 -11 -24 -23 -47 -108 -206 -293 -687 -382
|
||||
-993 -12 -41 -26 -86 -30 -100 -5 -14 -19 -63 -30 -110 -12 -47 -28 -105 -35
|
||||
-130 -8 -25 -16 -58 -19 -75 -3 -16 -14 -68 -26 -115 -11 -47 -25 -107 -30
|
||||
-135 -6 -27 -12 -61 -15 -75 -3 -14 -7 -36 -10 -50 -2 -14 -9 -50 -15 -80 -6
|
||||
-30 -13 -66 -16 -80 -2 -14 -7 -41 -10 -60 -7 -36 -9 -47 -18 -115 -3 -22 -8
|
||||
-49 -10 -61 -2 -12 -7 -45 -11 -75 -3 -30 -8 -61 -10 -69 -2 -8 -6 -37 -10
|
||||
-65 -3 -27 -7 -63 -9 -80 -2 -16 -7 -59 -10 -95 -4 -36 -9 -76 -11 -90 -2 -14
|
||||
-7 -61 -11 -105 -4 -44 -8 -93 -10 -110 -3 -38 -14 -184 -19 -255 -27 -407
|
||||
-27 -1218 0 -1455 2 -16 6 -64 10 -105 4 -41 9 -84 11 -95 2 -11 6 -46 9 -77
|
||||
3 -32 10 -83 16 -115 5 -32 12 -76 15 -98 10 -68 73 -355 100 -450 237 -845
|
||||
667 -1476 1235 -1810 46 -26 238 -121 299 -147 69 -30 263 -91 323 -103 15 -3
|
||||
39 -7 55 -10 15 -3 32 -7 39 -10 6 -2 38 -7 70 -11 32 -3 62 -8 66 -11 15 -9
|
||||
391 -12 452 -3 144 20 160 22 166 26 3 2 19 5 36 8 148 27 377 140 510 253
|
||||
203 172 308 322 457 656 18 39 94 267 102 302 2 11 13 58 25 105 11 47 22 96
|
||||
24 110 2 14 17 97 34 185 17 88 33 180 36 204 3 23 7 46 9 50 2 3 7 28 10 55
|
||||
4 27 9 52 11 56 2 3 6 24 9 45 2 22 23 141 46 265 23 124 43 236 45 250 4 27
|
||||
4 28 40 230 25 134 33 182 41 230 3 14 7 36 9 50 3 14 8 37 10 53 3 15 7 39
|
||||
10 55 3 15 12 68 20 117 9 50 20 110 25 135 5 25 11 59 14 75 3 26 14 88 30
|
||||
170 2 11 25 142 51 290 26 149 50 273 54 276 3 4 50 10 104 13 93 6 137 11
|
||||
217 22 19 3 58 7 85 10 28 2 64 6 80 9 17 3 48 7 70 10 142 21 400 73 575 116
|
||||
560 139 1036 346 1478 640 111 74 294 208 312 228 3 3 25 22 50 42 82 67 183
|
||||
163 304 290 399 418 702 991 821 1549 11 55 36 196 44 255 40 283 33 767 -14
|
||||
995 -2 14 -12 60 -20 102 -115 560 -455 1118 -898 1470 -328 261 -755 473
|
||||
-1157 573 -81 20 -249 56 -305 66 -72 12 -78 12 -155 23 -25 4 -56 8 -70 11
|
||||
-14 2 -61 7 -105 11 -44 3 -93 8 -110 11 -36 5 -672 13 -677 8z m142 -1239
|
||||
c84 -6 217 -21 275 -31 11 -1 36 -6 55 -9 72 -12 87 -16 165 -35 367 -91 649
|
||||
-262 871 -529 77 -92 74 -88 126 -172 84 -135 144 -280 187 -455 19 -77 24
|
||||
-108 38 -225 14 -112 6 -513 -11 -608 -2 -9 -7 -42 -11 -72 -8 -63 -7 -54 -34
|
||||
-185 -12 -55 -35 -145 -53 -200 -17 -55 -32 -102 -32 -105 -3 -17 -59 -142
|
||||
-109 -240 -51 -100 -148 -252 -204 -321 -300 -366 -742 -625 -1309 -767 -142
|
||||
-36 -267 -62 -359 -75 -16 -3 -46 -7 -65 -11 -106 -18 -102 -19 -96 22 14 88
|
||||
18 112 32 192 9 47 17 97 19 112 4 27 21 122 30 168 3 14 8 43 11 65 2 22 18
|
||||
119 35 215 16 96 32 189 35 205 5 33 15 89 20 120 2 11 15 88 29 170 14 83 34
|
||||
200 45 260 11 61 22 126 24 145 3 19 9 60 15 90 11 59 17 92 27 155 3 22 12
|
||||
74 20 115 8 41 17 91 19 110 3 19 9 64 14 100 10 69 7 336 -3 383 -54 239
|
||||
-237 378 -531 404 -232 20 -416 -5 -580 -81 -223 -103 -343 -243 -414 -482
|
||||
-16 -54 -85 -415 -135 -699 -15 -88 -58 -325 -76 -425 -12 -63 -23 -126 -25
|
||||
-140 -2 -14 -13 -77 -25 -140 -11 -63 -23 -130 -26 -149 -3 -18 -7 -43 -10
|
||||
-55 -4 -19 -56 -320 -68 -391 -3 -16 -12 -70 -21 -120 -9 -49 -18 -101 -20
|
||||
-115 -2 -14 -22 -135 -44 -270 -22 -135 -52 -315 -65 -400 -14 -85 -28 -166
|
||||
-30 -180 -16 -87 -69 -389 -71 -407 -2 -12 -6 -34 -9 -50 -3 -15 -8 -44 -11
|
||||
-63 -4 -19 -10 -54 -16 -78 -5 -23 -12 -59 -15 -80 -5 -32 -37 -223 -50 -292
|
||||
-2 -14 -7 -40 -10 -59 -6 -38 -11 -64 -19 -101 -3 -14 -7 -41 -9 -60 -3 -19
|
||||
-19 -114 -36 -210 -17 -96 -33 -188 -36 -205 -3 -16 -7 -37 -9 -45 -2 -8 -6
|
||||
-33 -10 -55 -3 -22 -12 -76 -20 -120 -8 -44 -17 -97 -21 -119 -3 -21 -8 -48
|
||||
-10 -60 -2 -11 -29 -163 -59 -336 -68 -390 -62 -356 -90 -475 -147 -622 -384
|
||||
-760 -719 -420 -129 131 -256 357 -354 632 -42 117 -104 320 -117 380 -2 10
|
||||
-8 38 -14 63 -5 25 -12 59 -15 75 -3 17 -8 40 -10 52 -5 24 -34 226 -41 283
|
||||
-2 19 -6 53 -9 75 -5 40 -10 107 -22 280 -18 274 -2 931 32 1290 13 146 34
|
||||
338 39 375 2 14 9 61 15 105 6 44 13 95 16 114 2 19 6 43 9 55 2 12 7 38 10
|
||||
59 5 35 50 280 59 322 2 11 12 56 21 100 69 335 236 874 359 1160 116 268 243
|
||||
523 340 684 31 51 56 95 56 97 0 8 169 256 228 334 207 274 434 510 672 700
|
||||
221 176 482 331 735 435 154 64 412 142 560 170 147 27 261 41 405 51 46 3 85
|
||||
6 87 7 4 5 335 -1 423 -7z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface AlertProps {
|
||||
type: 'info' | 'danger' | 'warning' | 'success';
|
||||
title: string;
|
||||
children?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-300',
|
||||
danger: 'bg-red-50 text-red-800 border-red-300',
|
||||
warning: 'bg-yellow-50 text-yellow-800 border-yellow-300',
|
||||
success: 'bg-green-50 text-green-800 border-green-300',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.type
|
||||
* @param root0.title
|
||||
* @param root0.children
|
||||
*/
|
||||
export default function Alert({type = 'info', title, children}: AlertProps) {
|
||||
const classNames = ['w-full px-7 py-5 border rounded-lg'];
|
||||
classNames.push(styles[type]);
|
||||
|
||||
return (
|
||||
<div className={classNames.join(' ')}>
|
||||
<p className={'font-medium'}>{title}</p>
|
||||
<p className={'text-sm'}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Alert} from './Alert';
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface BadgeProps {
|
||||
type: 'info' | 'danger' | 'warning' | 'success' | 'purple';
|
||||
children: string;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.type
|
||||
* @param root0.children
|
||||
*/
|
||||
export default function Badge({type = 'info', children}: BadgeProps) {
|
||||
const classNames = ['inline-flex items-center px-2 py-0.5 rounded text-xs font-medium'];
|
||||
classNames.push(styles[type]);
|
||||
|
||||
return <span className={classNames.join(' ')}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Badge} from './Badge';
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
options?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.children
|
||||
* @param root0.className
|
||||
* @param root0.actions
|
||||
* @param root0.options
|
||||
*/
|
||||
export default function Card({title, description, children, className, actions, options}: CardProps) {
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && optionsOpen) {
|
||||
setOptionsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div className={`rounded border border-neutral-200 bg-white px-8 py-4 ${className}`}>
|
||||
<div className={'flex items-center'}>
|
||||
<div className={'flex w-full flex-col gap-3 md:flex-row md:items-center'}>
|
||||
<div>
|
||||
<h2 className={'text-xl font-semibold leading-tight text-neutral-800'}>{title}</h2>
|
||||
<p className={'text-sm text-neutral-500'}>{description}</p>
|
||||
</div>
|
||||
<div className={'flex flex-1 gap-x-2.5 md:justify-end'}>{actions}</div>
|
||||
</div>
|
||||
|
||||
{options && (
|
||||
<div className="relative ml-3 inline-block text-left" ref={ref}>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptionsOpen(!optionsOpen)}
|
||||
className="flex items-center rounded-full text-neutral-500 transition hover:text-neutral-800"
|
||||
id="menu-button"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span className="sr-only">Open options</span>
|
||||
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{optionsOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.9}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.9}}
|
||||
transition={{duration: 0.1}}
|
||||
className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="menu-button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{options}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'py-4'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Card} from './Card';
|
||||
@@ -0,0 +1,118 @@
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import React from 'react';
|
||||
|
||||
export interface CodeBlockProps {
|
||||
language: string;
|
||||
code: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.code
|
||||
* @param root0.language
|
||||
* @param root0.style
|
||||
*/
|
||||
export default function ({code, language, style}: CodeBlockProps) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
customStyle={style}
|
||||
showLineNumbers
|
||||
language={language}
|
||||
style={{
|
||||
'hljs': {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
padding: '0.5em',
|
||||
background: '#1e293b',
|
||||
color: '#f8f8f2',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#8be9fd',
|
||||
},
|
||||
'hljs-function .hljs-keyword': {
|
||||
color: '#ff79c6',
|
||||
},
|
||||
'hljs-subst': {
|
||||
color: '#f8f8f2',
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#d8b4fe',
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#c3e88d',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#c3e88d',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#f1fa8c',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-template-tag': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-comment': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-doctag': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as CodeBlock} from './CodeBlock';
|
||||
@@ -0,0 +1,191 @@
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
|
||||
export interface Dropdownprops {
|
||||
withSearch?: boolean;
|
||||
inModal?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
values: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
selectedValue: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.onChange
|
||||
* @param root0.values
|
||||
* @param root0.selectedValue
|
||||
* @param root0.className
|
||||
* @param root0.withSearch
|
||||
* @param root0.inModal
|
||||
* @param root0.disabled
|
||||
*/
|
||||
export default function Dropdown({
|
||||
onChange,
|
||||
values,
|
||||
selectedValue,
|
||||
className,
|
||||
withSearch = false,
|
||||
inModal = false,
|
||||
disabled = false,
|
||||
}: Dropdownprops) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className ?? ''}>
|
||||
<div className="relative mt-1 w-full">
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? 'cursor-default bg-neutral-100' : 'cursor-pointer bg-white'
|
||||
} relative w-full rounded border border-neutral-300 py-2 pl-3 pr-10 text-left focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500 sm:text-sm`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block flex items-center truncate">
|
||||
{values.find(v => v.value === selectedValue)
|
||||
? `${values
|
||||
.find(v => v.value === selectedValue)
|
||||
?.name.charAt(0)
|
||||
.toUpperCase()}${values
|
||||
.find(v => v.value === selectedValue)
|
||||
?.name.slice(1)
|
||||
.toLowerCase()}`
|
||||
: 'No value selected'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{rotate: '90deg'}}
|
||||
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{opacity: 0, height: 0}}
|
||||
animate={{opacity: 1, height: 'auto'}}
|
||||
exit={{opacity: 0, height: 0}}
|
||||
transition={{duration: 0.2, ease: 'easeInOut'}}
|
||||
className={`${
|
||||
inModal ? 'fixed w-64' : 'absolute w-full'
|
||||
} z-50 mt-1 max-h-72 rounded-md border border-black border-opacity-10 bg-white text-base shadow-lg focus:outline-none sm:text-sm`}
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="sticky top-0 z-50 bg-white">
|
||||
{withSearch ? (
|
||||
<>
|
||||
<li className="relative cursor-default select-none px-3 py-2 text-neutral-800">
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
autoComplete={'off'}
|
||||
className="block w-full rounded border-neutral-300 border-opacity-5 focus:border-neutral-800 sm:text-sm"
|
||||
placeholder={'Search'}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</li>
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 max-h-52 overflow-y-scroll p-1'
|
||||
}
|
||||
>
|
||||
{values.filter(value => value.name.toLowerCase().startsWith(query.toLowerCase())).length === 0 ? (
|
||||
<li className="relative cursor-default select-none py-2.5 pl-3 pr-9 text-neutral-800">
|
||||
No results found
|
||||
</li>
|
||||
) : (
|
||||
values
|
||||
.filter(value => value.name.toLowerCase().startsWith(query.toLowerCase()))
|
||||
.map((value, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`x-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
onChange(value.value);
|
||||
setQuery('');
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{value.name.charAt(0).toUpperCase() + value.name.slice(1).toLowerCase()}
|
||||
</span>
|
||||
{value.value === selectedValue ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-800">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Dropdown} from './Dropdown';
|
||||
@@ -0,0 +1,57 @@
|
||||
import {FieldError, UseFormRegisterReturn} from 'react-hook-form';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
export interface InputProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'number';
|
||||
register: UseFormRegisterReturn;
|
||||
error?: FieldError;
|
||||
className?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @param props.label
|
||||
* @param props.type
|
||||
* @param props.register
|
||||
* @param props.error
|
||||
* @param props.placeholder
|
||||
* @param props.className
|
||||
*/
|
||||
export default function Input(props: InputProps) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<label className="block text-sm font-medium text-neutral-700">{props.label}</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
autoComplete={'off'}
|
||||
type={props.type}
|
||||
min={props.type === 'number' ? props.min : undefined}
|
||||
max={props.type === 'number' ? props.max : undefined}
|
||||
className={
|
||||
'block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm'
|
||||
}
|
||||
placeholder={props.placeholder}
|
||||
{...props.register}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{props.error && (
|
||||
<motion.p
|
||||
initial={{height: 0}}
|
||||
animate={{height: 'auto'}}
|
||||
exit={{height: 0}}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{props.error.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Input} from './Input';
|
||||
@@ -0,0 +1,873 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { API_URI } from "../../../lib/constants";
|
||||
import { Modal } from "../../Overlay";
|
||||
import "tippy.js/animations/scale.css";
|
||||
import HTMLEditor from "@monaco-editor/react";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { Dropcursor } from "@tiptap/extension-dropcursor";
|
||||
import FontFamily from "@tiptap/extension-font-family";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
ImageIcon,
|
||||
Inspect,
|
||||
LinkIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Dropdown } from "../Dropdown";
|
||||
import { Button } from "./extensions/Button";
|
||||
import { EditorBubbleMenu } from "./extensions/EditorBubbleMenu";
|
||||
import { Mention } from "./extensions/MetadataSuggestion/MetadataSuggestion";
|
||||
import suggestion from "./extensions/MetadataSuggestion/Suggestions";
|
||||
import { Progress, type colors } from "./extensions/Progress";
|
||||
import Slash from "./extensions/Slash";
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
value: string;
|
||||
mode: "PLUNK" | "HTML";
|
||||
onChange: (value: string, type: "PLUNK" | "HTML") => void;
|
||||
modeSwitcher?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.value
|
||||
* @param root0.onChange
|
||||
*/
|
||||
export default function Editor({
|
||||
value,
|
||||
onChange,
|
||||
mode,
|
||||
modeSwitcher,
|
||||
}: MarkdownEditorProps) {
|
||||
const [imageModal, setImageModal] = useState(false);
|
||||
const [urlModal, setUrlModal] = useState(false);
|
||||
const [barModal, setBarModal] = useState(false);
|
||||
const [buttonModal, setButtonModal] = useState(false);
|
||||
const [confirmModal, setConfirmModal] = useState(false);
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Slash,
|
||||
StarterKit,
|
||||
Typography,
|
||||
TextStyle,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
// @ts-ignore
|
||||
suggestion,
|
||||
}),
|
||||
Dropcursor.configure({
|
||||
width: 3,
|
||||
color: "#e5e5e5",
|
||||
}),
|
||||
TextAlign.configure({
|
||||
alignments: ["left", "center", "right"],
|
||||
types: ["heading", "paragraph"],
|
||||
defaultAlignment: "left",
|
||||
}),
|
||||
FontFamily.configure({
|
||||
types: ["textStyle"],
|
||||
}),
|
||||
Image.configure({ allowBase64: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: "Start typing or press / to use a slash command",
|
||||
includeChildren: true,
|
||||
}),
|
||||
Progress,
|
||||
Button,
|
||||
Link.configure({
|
||||
autolink: true,
|
||||
protocols: ["http", "https", "mailto"],
|
||||
}).extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Space: ({ editor }) => {
|
||||
if (editor.isActive("link")) {
|
||||
// Toggle the link and add a space
|
||||
editor.commands.toggleMark("link");
|
||||
// Add a space
|
||||
return editor.chain().focus().insertContent(" ").run();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
Color,
|
||||
],
|
||||
content: value,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose font-sans my-5 focus:outline-none",
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
return event.key === "Enter" && !event.shiftKey;
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML(), "PLUNK");
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerUrl,
|
||||
handleSubmit: handleSubmitUrl,
|
||||
reset: resetUrl,
|
||||
setFocus: setFocusUrl,
|
||||
formState: { errors: errorsUrl },
|
||||
} = useForm<{
|
||||
url: string;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
url: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?(?:\?\S*)?$/,
|
||||
)
|
||||
.transform((u) => {
|
||||
if (u.startsWith("{{") && u.endsWith("}}")) {
|
||||
return u;
|
||||
}
|
||||
|
||||
return u.startsWith("http") ? u : `https://${u}`;
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerBar,
|
||||
handleSubmit: handleSubmitBar,
|
||||
reset: resetBar,
|
||||
setValue: setValueBar,
|
||||
watch: watchBar,
|
||||
formState: { errors: errorsBar },
|
||||
} = useForm<{
|
||||
percent: number;
|
||||
color: colors;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
percent: z.preprocess(
|
||||
(a) => Number.parseInt(z.string().parse(a), 10),
|
||||
z.number().positive().max(100),
|
||||
),
|
||||
color: z
|
||||
.enum([
|
||||
"red",
|
||||
"yellow",
|
||||
"green",
|
||||
"blue",
|
||||
"indigo",
|
||||
"purple",
|
||||
"pink",
|
||||
"orange",
|
||||
"black",
|
||||
])
|
||||
.default("blue"),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
color: "blue",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerButton,
|
||||
handleSubmit: handleSubmitButton,
|
||||
reset: resetButton,
|
||||
setValue: setValueButton,
|
||||
watch: watchButton,
|
||||
formState: { errors: errorsButton },
|
||||
} = useForm<{
|
||||
link: string;
|
||||
color: colors;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
link: z
|
||||
.string()
|
||||
.regex(
|
||||
/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?$/,
|
||||
)
|
||||
.transform((u) => {
|
||||
if (u.startsWith("{{") && u.endsWith("}}")) {
|
||||
return u;
|
||||
}
|
||||
|
||||
return u.startsWith("http") ? u : `https://${u}`;
|
||||
}),
|
||||
color: z
|
||||
.enum([
|
||||
"red",
|
||||
"yellow",
|
||||
"green",
|
||||
"blue",
|
||||
"indigo",
|
||||
"purple",
|
||||
"pink",
|
||||
"orange",
|
||||
"black",
|
||||
])
|
||||
.default("blue"),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
color: "blue",
|
||||
},
|
||||
});
|
||||
|
||||
const addImage = useCallback(
|
||||
(data: { url: string }) => {
|
||||
editor?.chain().focus().setImage({ src: data.url }).run();
|
||||
setImageModal(false);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const addBar = useCallback(
|
||||
(data: { percent: number; color: colors }) => {
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.setProgress({ percent: data.percent, color: data.color })
|
||||
.run();
|
||||
setBarModal(false);
|
||||
resetBar();
|
||||
},
|
||||
[editor, resetBar],
|
||||
);
|
||||
|
||||
const addButton = useCallback(
|
||||
(data: { link: string; color: colors }) => {
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.setButton({ href: data.link, color: data.color })
|
||||
.run();
|
||||
setButtonModal(false);
|
||||
resetButton();
|
||||
},
|
||||
[editor, resetButton],
|
||||
);
|
||||
|
||||
const addUrl = useCallback(
|
||||
(data: { url: string }) => {
|
||||
editor
|
||||
?.chain()
|
||||
.focus()
|
||||
.setLink({ href: data.url, target: "_blank" })
|
||||
.run();
|
||||
setUrlModal(false);
|
||||
resetUrl();
|
||||
},
|
||||
[editor, resetUrl],
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={"Watch out!"}
|
||||
isOpen={confirmModal}
|
||||
onToggle={() => setConfirmModal(!confirmModal)}
|
||||
onAction={() => {
|
||||
if (mode === "PLUNK") {
|
||||
void onChange("", "HTML");
|
||||
} else {
|
||||
void onChange("", "PLUNK");
|
||||
}
|
||||
|
||||
editor.chain().clearContent().run();
|
||||
setConfirmModal(false);
|
||||
}}
|
||||
type={"danger"}
|
||||
>
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
<p className={"text-sm text-neutral-700"}>
|
||||
Are you sure you want to switch to{" "}
|
||||
{mode === "PLUNK" ? "HTML" : "the Plunk Editor"}? <br /> This will
|
||||
clear your current content.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
{modeSwitcher && (
|
||||
<div className={"my-3 flex w-full gap-3 rounded-lg bg-neutral-100 p-2"}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setConfirmModal(true);
|
||||
}}
|
||||
className={`w-full flex-1 rounded p-2 text-sm font-medium ${
|
||||
mode === "PLUNK" ? "bg-white" : "hover:bg-neutral-50"
|
||||
} transition ease-in-out`}
|
||||
>
|
||||
Plunk Editor
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setConfirmModal(true);
|
||||
}}
|
||||
className={`w-full flex-1 rounded p-2 text-sm font-medium ${
|
||||
mode === "HTML" ? "bg-white" : "hover:bg-neutral-50"
|
||||
} transition ease-in-out`}
|
||||
>
|
||||
HTML
|
||||
</button>{" "}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={"Add image"}
|
||||
description={"Enter the URL of the image you want to add."}
|
||||
isOpen={imageModal}
|
||||
onToggle={() => setImageModal(!imageModal)}
|
||||
onAction={handleSubmitUrl(addImage)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M4.75 16L7.49619 12.5067C8.2749 11.5161 9.76453 11.4837 10.5856 12.4395L13 15.25M10.915 12.823C11.9522 11.5037 13.3973 9.63455 13.4914 9.51294C13.4947 9.50859 13.4979 9.50448 13.5013 9.50017C14.2815 8.51598 15.7663 8.48581 16.5856 9.43947L19 12.25M6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V6.75C19.25 5.64543 18.3546 4.75 17.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25Z" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitUrl(addImage)}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"url"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Image URL
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete={"off"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder="https://www.example.com/image.png"
|
||||
{...registerUrl("url")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsUrl.url?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsUrl.url.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add URL"}
|
||||
description={"Copy and paste the URL"}
|
||||
isOpen={urlModal}
|
||||
onToggle={() => setUrlModal(!urlModal)}
|
||||
onAction={handleSubmitUrl(addUrl)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M16.75 13.25L18 12C19.6569 10.3431 19.6569 7.65685 18 6V6C16.3431 4.34315 13.6569 4.34315 12 6L10.75 7.25" />
|
||||
<path d="M7.25 10.75L6 12C4.34315 13.6569 4.34315 16.3431 6 18V18C7.65685 19.6569 10.3431 19.6569 12 18L13.25 16.75" />
|
||||
<path d="M14.25 9.75L9.75 14.25" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitUrl(addUrl)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"url"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
URL
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center rounded-l border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
|
||||
https://
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete={"off"}
|
||||
className={
|
||||
"block w-full rounded-r border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder="www.example.com"
|
||||
{...registerUrl("url")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsUrl.url?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsUrl.url.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add a progress bar"}
|
||||
description={"Show the percentage of progress with ease"}
|
||||
isOpen={barModal}
|
||||
onToggle={() => setBarModal(!barModal)}
|
||||
onAction={handleSubmitBar(addBar)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M3 12m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M9 8m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M15 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M4 20l14 0" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitBar(addBar)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"percentage"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Percentage
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
autoComplete={"off"}
|
||||
type={"number"}
|
||||
min={0}
|
||||
max={100}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder={"20"}
|
||||
{...registerBar("percent")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsBar.percent?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsBar.percent.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"style"}
|
||||
className="flex items-center text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<Dropdown
|
||||
inModal={true}
|
||||
onChange={(t) => setValueBar("color", t as colors)}
|
||||
values={[
|
||||
{ value: "blue", name: "Blue" },
|
||||
{ value: "red", name: "Red" },
|
||||
{ value: "green", name: "Green" },
|
||||
{ value: "yellow", name: "Yellow" },
|
||||
{ value: "orange", name: "Orange" },
|
||||
{ value: "purple", name: "Purple" },
|
||||
{ value: "pink", name: "Pink" },
|
||||
{ value: "indigo", name: "Indigo" },
|
||||
]}
|
||||
selectedValue={watchBar("color")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errorsBar.color?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsBar.color.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add a button"}
|
||||
description={"Create a button with a link"}
|
||||
isOpen={buttonModal}
|
||||
onToggle={() => setButtonModal(!buttonModal)}
|
||||
onAction={handleSubmitButton(addButton)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M8 13v-8.5a1.5 1.5 0 0 1 3 0v7.5" />
|
||||
<path d="M11 11.5v-2a1.5 1.5 0 0 1 3 0v2.5" />
|
||||
<path d="M14 10.5a1.5 1.5 0 0 1 3 0v1.5" />
|
||||
<path d="M17 11.5a1.5 1.5 0 0 1 3 0v4.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7l-.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47" />
|
||||
<path d="M5 3l-1 -1" />
|
||||
<path d="M4 7h-1" />
|
||||
<path d="M14 3l1 -1" />
|
||||
<path d="M15 6h1" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitButton(addButton)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"percentage"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Link
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center rounded-l border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
|
||||
https://
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete={"off"}
|
||||
className={
|
||||
"block w-full rounded-r border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder="www.example.com"
|
||||
{...registerButton("link")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsButton.link?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsButton.link.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label
|
||||
htmlFor={"style"}
|
||||
className="flex items-center text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Color
|
||||
</label>
|
||||
<Dropdown
|
||||
inModal={true}
|
||||
onChange={(t) => setValueButton("color", t as colors)}
|
||||
values={[
|
||||
{ value: "blue", name: "Blue" },
|
||||
{ value: "red", name: "Red" },
|
||||
{ value: "green", name: "Green" },
|
||||
{ value: "yellow", name: "Yellow" },
|
||||
{ value: "orange", name: "Orange" },
|
||||
{ value: "purple", name: "Purple" },
|
||||
{ value: "pink", name: "Pink" },
|
||||
{ value: "indigo", name: "Indigo" },
|
||||
{ value: "black", name: "Black" },
|
||||
]}
|
||||
selectedValue={watchButton("color")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errorsButton.color?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsButton.color.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<div>
|
||||
<>
|
||||
{mode === "PLUNK" ? (
|
||||
<>
|
||||
<div
|
||||
onClick={() => {
|
||||
editor.chain().focus().run();
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Email Body
|
||||
</label>
|
||||
<div className="mt-1 h-full">
|
||||
<div
|
||||
className={
|
||||
"flex h-full max-h-[600px] flex-col items-center overflow-y-auto overflow-x-hidden rounded border border-neutral-300 px-3 py-1"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"sticky top-3 z-10 mt-3 flex flex-col justify-center gap-3 rounded-lg border border-neutral-300 bg-white p-4 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-3"}>
|
||||
<div className={"flex"}>
|
||||
<button
|
||||
title={"Align Left"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-l-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().setTextAlign("left").run();
|
||||
}}
|
||||
>
|
||||
<AlignLeft
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "left",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
title={"Align Center"}
|
||||
className={
|
||||
"flex items-center justify-center border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextAlign("center")
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
<AlignCenter
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "center",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
title={"Align Right"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-r-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextAlign("right")
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
<AlignRight
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "right",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={"flex"}>
|
||||
<button
|
||||
title={"Image"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-l-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setImageModal(true);
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={24} strokeWidth={"1.5"} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={"Button"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-r-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setButtonModal(true);
|
||||
}}
|
||||
>
|
||||
<Inspect size={24} strokeWidth={"1.5"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
"prose prose-sm prose-neutral space-y-4 break-words p-4"
|
||||
}
|
||||
>
|
||||
<div className={"w-full"} style={{ width: "600px" }}>
|
||||
<EditorContent editor={editor} />
|
||||
<EditorBubbleMenu
|
||||
editor={editor}
|
||||
items={[
|
||||
{
|
||||
name: "Link",
|
||||
icon: LinkIcon,
|
||||
command: () => {
|
||||
setUrlModal(true);
|
||||
setTimeout(() => {
|
||||
setFocusUrl("url", { shouldSelect: true });
|
||||
}, 100);
|
||||
},
|
||||
isActive: () => false,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"mb-3 grid gap-3 md:grid-cols-1"}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Email Body
|
||||
</label>
|
||||
<div className="mt-1 h-full">
|
||||
<HTMLEditor
|
||||
height={400}
|
||||
className={"rounded border border-neutral-300"}
|
||||
language="html"
|
||||
theme="vs-light"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e as string, "HTML")}
|
||||
options={{
|
||||
inlineSuggest: true,
|
||||
fontSize: "12px",
|
||||
formatOnType: true,
|
||||
autoClosingBrackets: true,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700">
|
||||
Preview
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={
|
||||
"mt-1 h-full rounded border border-neutral-300 p-3"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={"revert-tailwind"}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: value,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {mergeAttributes, Node, wrappingInputRule} from '@tiptap/core';
|
||||
|
||||
export type colors = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' | 'black';
|
||||
|
||||
// Map each color to a tailwind color hex code for 500
|
||||
const colorMap = {
|
||||
red: '#ef4444',
|
||||
orange: '#f97316',
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
blue: '#2563eb',
|
||||
indigo: '#6366f1',
|
||||
purple: '#8b5cf6',
|
||||
pink: '#ec4899',
|
||||
black: '#171717',
|
||||
} as const;
|
||||
|
||||
export interface ButtonOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
button: {
|
||||
/**
|
||||
* Set a blockquote node
|
||||
*/
|
||||
setButton: (attributes: {href: string; color: colors}) => ReturnType;
|
||||
/**
|
||||
* Toggle a blockquote node
|
||||
*/
|
||||
toggleButton: (attributes: {href: string; color: colors}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const inputRegex = /^\s*>\s$/;
|
||||
|
||||
export const Button = Node.create<ButtonOptions>({
|
||||
name: 'button',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'btn',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
href: {
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
default: 'blue' as colors,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a.btn',
|
||||
priority: 51,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `color: white; background-color: ${
|
||||
colorMap[node.attrs.color as colors]
|
||||
}; text-align: center; text-decoration: none; padding: 12px; border-radius: 8px; display: block; font-size: 15px; line-height: 20px; font-weight: 600; margin: 9px 0 9px 0;`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setButton:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.setNode(this.name, attributes);
|
||||
},
|
||||
toggleButton:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.toggleNode(this.name, 'paragraph', attributes);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
wrappingInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {Editor} from '@tiptap/core';
|
||||
import cx from 'classnames';
|
||||
import {Check, ChevronDown} from 'lucide-react';
|
||||
import {FC} from 'react';
|
||||
|
||||
export interface BubbleColorMenuItem {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ColorSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const ColorSelector: FC<ColorSelectorProps> = ({editor, isOpen, setIsOpen}) => {
|
||||
const items: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: 'Default',
|
||||
color: '#000000',
|
||||
},
|
||||
{
|
||||
name: 'Purple',
|
||||
color: '#9333EA',
|
||||
},
|
||||
{
|
||||
name: 'Red',
|
||||
color: '#E00000',
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
color: '#2563EB',
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
color: '#008A00',
|
||||
},
|
||||
{
|
||||
name: 'Orange',
|
||||
color: '#FFA500',
|
||||
},
|
||||
{
|
||||
name: 'Pink',
|
||||
color: '#BA4081',
|
||||
},
|
||||
{
|
||||
name: 'Gray',
|
||||
color: '#A8A29E',
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.find(({color}) => editor.isActive('textStyle', {color}));
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
className="flex h-full items-center gap-1 p-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<span style={{color: activeItem?.color ?? '#000000'}}>A</span>
|
||||
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="animate-in fade-in slide-in-from-top-1 fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-neutral-200 bg-white p-1 shadow-xl">
|
||||
{items.map(({name, color}, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cx(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1 text-sm text-neutral-600 hover:bg-neutral-100',
|
||||
{
|
||||
'text-blue-600': editor.isActive('textStyle', {color}),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border border-neutral-200 px-1 py-px font-medium" style={{color}}>
|
||||
A
|
||||
</div>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
{editor.isActive('textStyle', {color}) && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import {BubbleMenu, BubbleMenuProps} from '@tiptap/react';
|
||||
import cx from 'classnames';
|
||||
import {FC, useState} from 'react';
|
||||
import {BoldIcon, ItalicIcon, StrikethroughIcon} from 'lucide-react';
|
||||
|
||||
import {NodeSelector} from './NodeSelector';
|
||||
import {ColorSelector} from './ColorSelector';
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof BoldIcon;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'> & {
|
||||
items: BubbleMenuItem[];
|
||||
};
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = props => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'bold',
|
||||
isActive: () => props.editor?.isActive('bold') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleBold().run(),
|
||||
icon: BoldIcon,
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
isActive: () => props.editor?.isActive('italic') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
||||
icon: ItalicIcon,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'strike',
|
||||
isActive: () => props.editor?.isActive('strike') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleStrike().run(),
|
||||
icon: StrikethroughIcon,
|
||||
},
|
||||
...props.items,
|
||||
];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({editor}) => {
|
||||
// don't show if image is selected
|
||||
if (editor.isActive('image')) {
|
||||
return false;
|
||||
}
|
||||
return editor.view.state.selection.content().size > 0;
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: 'transform 0.15s ease-out',
|
||||
onHidden: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
className="flex overflow-hidden rounded border border-neutral-200 bg-white shadow-xl"
|
||||
>
|
||||
{props.editor && (
|
||||
<NodeSelector editor={props.editor} isOpen={isNodeSelectorOpen} setIsOpen={setIsNodeSelectorOpen} />
|
||||
)}
|
||||
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
item.command();
|
||||
}}
|
||||
className="p-2 text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
>
|
||||
<item.icon
|
||||
className={cx('h-4 w-4', {
|
||||
'text-blue-500': item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{props.editor && (
|
||||
<ColorSelector editor={props.editor} isOpen={isColorSelectorOpen} setIsOpen={setIsColorSelectorOpen} />
|
||||
)}
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import {mergeAttributes, Node} from '@tiptap/core';
|
||||
import {Node as ProseMirrorNode} from '@tiptap/pm/model';
|
||||
import {PluginKey} from '@tiptap/pm/state';
|
||||
import Suggestion, {SuggestionOptions} from '@tiptap/suggestion';
|
||||
|
||||
export interface MentionOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
renderLabel: (props: {options: MentionOptions; node: ProseMirrorNode}) => string;
|
||||
suggestion: Omit<SuggestionOptions, 'editor'>;
|
||||
}
|
||||
|
||||
export const MentionPluginKey = new PluginKey('mention');
|
||||
|
||||
export const Mention = Node.create<MentionOptions>({
|
||||
name: 'mention',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderLabel({options, node}) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
suggestion: {
|
||||
char: '{{',
|
||||
pluginKey: MentionPluginKey,
|
||||
command: ({editor, range, props}) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(' ');
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1;
|
||||
}
|
||||
|
||||
editor.chain().focus().insertContent(`${props.id}}}`).run();
|
||||
|
||||
window.getSelection()?.collapseToEnd();
|
||||
},
|
||||
allow: ({state, range}) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const type = state.schema.nodes[this.name];
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type);
|
||||
|
||||
return allow;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
group: 'inline',
|
||||
|
||||
inline: true,
|
||||
|
||||
selectable: true,
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.id) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
'data-id': attributes.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-label'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.label) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
'data-label': attributes.label,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes({'data-type': this.name}, this.options.HTMLAttributes, HTMLAttributes),
|
||||
this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
renderText({node}) {
|
||||
return this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
});
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () =>
|
||||
this.editor.commands.command(({tr, state}) => {
|
||||
let isMention = false;
|
||||
const {selection} = state;
|
||||
const {empty, anchor} = selection;
|
||||
|
||||
if (!empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||
if (node.type.name === this.name) {
|
||||
isMention = true;
|
||||
tr.insertText(this.options.suggestion.char ?? '', pos, pos + node.nodeSize);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return isMention;
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export default forwardRef((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = props.items[index];
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item });
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex(
|
||||
(selectedIndex + props.items.length - 1) % props.items.length,
|
||||
);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="z-50 mt-2 w-56 origin-top-right rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{props.items.length ? (
|
||||
props.items.map((item, index) => (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 ${
|
||||
index === selectedIndex ? "bg-neutral-50" : ""
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100">
|
||||
No result
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { network } from "dashboard/src/lib/network";
|
||||
import type { RefAttributes } from "react";
|
||||
import tippy from "tippy.js";
|
||||
import MentionList from "./SuggestionList";
|
||||
|
||||
export default {
|
||||
items: async ({ query }: { query: string }) => {
|
||||
const activeProject =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem("project")
|
||||
: null;
|
||||
|
||||
if (!activeProject) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys = await network.fetch<string[]>(
|
||||
"GET",
|
||||
`/projects/id/${activeProject}/contacts/metadata`,
|
||||
);
|
||||
|
||||
return keys.filter((key) =>
|
||||
key.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<unknown, RefAttributes<unknown>>;
|
||||
let popup: { destroy: () => void }[];
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: any; clientRect: any }) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props);
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (popup[0]) {
|
||||
popup[0].destroy();
|
||||
}
|
||||
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import {Editor} from '@tiptap/core';
|
||||
import cx from 'classnames';
|
||||
import {Check, ChevronDown, Heading1, Heading2, Heading3, ListOrdered, TextIcon} from 'lucide-react';
|
||||
import {FC} from 'react';
|
||||
|
||||
import {BubbleMenuItem} from './EditorBubbleMenu';
|
||||
|
||||
interface NodeSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeSelector: FC<NodeSelectorProps> = ({editor, isOpen, setIsOpen}) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'Text',
|
||||
icon: TextIcon,
|
||||
command: () => editor.chain().focus().toggleNode('paragraph', 'paragraph').run(),
|
||||
isActive: () => editor.isActive('paragraph') && !editor.isActive('bulletList') && !editor.isActive('orderedList'),
|
||||
},
|
||||
{
|
||||
name: 'Heading 1',
|
||||
icon: Heading1,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 1}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 1}),
|
||||
},
|
||||
{
|
||||
name: 'Heading 2',
|
||||
icon: Heading2,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 2}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 2}),
|
||||
},
|
||||
{
|
||||
name: 'Heading 3',
|
||||
icon: Heading3,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 3}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 3}),
|
||||
},
|
||||
{
|
||||
name: 'Bullet List',
|
||||
icon: ListOrdered,
|
||||
command: () => editor.chain().focus().toggleBulletList().run(),
|
||||
isActive: () => editor.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
name: 'Numbered List',
|
||||
icon: ListOrdered,
|
||||
command: () => editor.chain().focus().toggleOrderedList().run(),
|
||||
isActive: () => editor.isActive('orderedList'),
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.find(item => item.isActive());
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
className="flex h-full items-center gap-1 p-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<span>{activeItem?.name}</span>
|
||||
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="animate-in fade-in slide-in-from-top-1 fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-neutral-200 bg-white p-1 shadow-xl">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cx(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1 text-sm text-neutral-600 hover:bg-neutral-100',
|
||||
{
|
||||
'text-blue-600': item.isActive(),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border border-neutral-200 p-1">
|
||||
<item.icon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{item.isActive() && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import {Node} from '@tiptap/core';
|
||||
|
||||
export type colors = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' | 'black';
|
||||
|
||||
// Map each color to a tailwind color hex code for 500
|
||||
const colorMap = {
|
||||
red: '#ef4444',
|
||||
orange: '#f97316',
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
blue: '#2563eb',
|
||||
indigo: '#6366f1',
|
||||
purple: '#8b5cf6',
|
||||
pink: '#ec4899',
|
||||
black: '#171717',
|
||||
} as const;
|
||||
|
||||
export interface ProgressOptions {
|
||||
percent: number;
|
||||
color: colors;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
progress: {
|
||||
/**
|
||||
* Set a heading node
|
||||
*/
|
||||
setProgress: (attributes: {percent: number; color: colors}) => ReturnType;
|
||||
/**
|
||||
* Toggle a heading node
|
||||
*/
|
||||
toggleProgress: (attributes: {percent: number; color: colors}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Progress = Node.create<ProgressOptions>({
|
||||
name: 'progress',
|
||||
|
||||
content: 'inline*',
|
||||
|
||||
group: 'block',
|
||||
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
percent: {
|
||||
default: 100,
|
||||
rendered: false,
|
||||
},
|
||||
color: {
|
||||
default: 'blue' as colors,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'table',
|
||||
getAttrs: element => {
|
||||
// @ts-ignore
|
||||
const percent = element.querySelector('td')?.style.width;
|
||||
// @ts-ignore
|
||||
const color = element.querySelector('td')?.style.backgroundColor;
|
||||
|
||||
const rgb = color?.slice(4, color.length - 1).split(', ');
|
||||
const hex = rgb?.map((value: any) => {
|
||||
const hex = Number(value).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
});
|
||||
|
||||
return {
|
||||
percent: Number(percent?.slice(0, percent.length - 1)),
|
||||
color: Object.keys(colorMap).find(key => colorMap[key as colors] === `#${hex?.join('')}`) as colors,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node}) {
|
||||
// Render a progress bar using table elements
|
||||
return [
|
||||
'table',
|
||||
{
|
||||
class: 'progress',
|
||||
style: `width: 100%; border-radius: 10px;height: 28px;`,
|
||||
},
|
||||
[
|
||||
'tr',
|
||||
{
|
||||
style: `width: 100%; border-radius: 8px;`,
|
||||
},
|
||||
// Render two cells, one for the progress bar and one for the percentage
|
||||
[
|
||||
'td',
|
||||
{
|
||||
style: `width: ${node.attrs.percent}%; background-color: ${
|
||||
colorMap[node.attrs.color as colors]
|
||||
}; border-top-left-radius: 8px; border-bottom-left-radius: 8px;`,
|
||||
},
|
||||
],
|
||||
[
|
||||
'td',
|
||||
{
|
||||
style: `width: ${
|
||||
100 - node.attrs.percent
|
||||
}%; background-color: #f5f5f5; border-top-right-radius: 8px; border-bottom-right-radius: 8px;`,
|
||||
},
|
||||
],
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setProgress:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.setNode(this.name, attributes);
|
||||
},
|
||||
toggleProgress:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.toggleNode(this.name, 'paragraph', attributes);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { type Editor, Extension, type Range } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Strikethrough,
|
||||
} from "lucide-react";
|
||||
import React, {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
interface CommandItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface Command {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const Command = Extension.create({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const getSuggestionItems = ({ query }: { query: string }) => {
|
||||
return [
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
icon: <Heading1 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 1 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
icon: <Heading2 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 2 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
icon: <Heading3 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setNode("heading", { level: 3 })
|
||||
.run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bold",
|
||||
description: "Make text bold.",
|
||||
icon: <Bold size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("bold").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Italic",
|
||||
description: "Make text italic.",
|
||||
icon: <Italic size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("italic").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Strikethrough",
|
||||
description: "Make text strikethrough.",
|
||||
icon: <Strikethrough size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("strike").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a bullet list.",
|
||||
icon: <List size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a numbered list.",
|
||||
icon: <ListOrdered size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
description: "Create a code block.",
|
||||
icon: <Code size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Create a quote.",
|
||||
icon: <Quote size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Left",
|
||||
description: "Align text to the left.",
|
||||
icon: <AlignLeft size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("left").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Center",
|
||||
description: "Align text to the center.",
|
||||
icon: <AlignCenter size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("center").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Right",
|
||||
description: "Align text to the right.",
|
||||
icon: <AlignRight size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("right").run();
|
||||
},
|
||||
},
|
||||
].filter((item) => {
|
||||
if (query.length > 0) {
|
||||
return item.title.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// .slice(0, 10);
|
||||
};
|
||||
|
||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item.offsetHeight;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
command,
|
||||
editor,
|
||||
}: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
|
||||
command(item);
|
||||
},
|
||||
[command, editor, items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (container) {
|
||||
updateScrollView(container, item);
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
return items.length > 0 ? (
|
||||
<div
|
||||
ref={commandListContainer}
|
||||
className="z-50 h-auto max-h-[330px] w-72 overflow-y-auto scroll-smooth rounded-md border border-neutral-200 bg-white px-1 py-2 shadow-md transition-all"
|
||||
>
|
||||
{items.map((item: CommandItemProps, index: number) => {
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-neutral-800 hover:bg-neutral-100 ${
|
||||
index === selectedIndex ? "bg-neutral-100 text-neutral-800" : ""
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-neutral-200 bg-white">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-xs text-neutral-500">{item.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const Slash = Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems,
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
export default Slash;
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Editor} from './Editor';
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface MultiselectDropdownProps {
|
||||
onChange: (value: string[]) => void;
|
||||
values: readonly {
|
||||
name: string;
|
||||
value: string;
|
||||
tag?: string;
|
||||
}[];
|
||||
selectedValues?: readonly string[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.onChange
|
||||
* @param root0.values
|
||||
* @param root0.selectedValues
|
||||
* @param root0.className
|
||||
* @param root0.disabled
|
||||
*/
|
||||
export default function MultiselectDropdown({
|
||||
onChange,
|
||||
values,
|
||||
selectedValues: PropsselectedValues,
|
||||
className,
|
||||
disabled = false,
|
||||
}: MultiselectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedValues, setSelectedValues] = useState<readonly string[]>([]);
|
||||
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (PropsselectedValues) {
|
||||
setSelectedValues(PropsselectedValues);
|
||||
}
|
||||
}, [PropsselectedValues]);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className={className ?? ''}>
|
||||
<div className="relative mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? 'cursor-default bg-neutral-100' : 'cursor-pointer bg-white'
|
||||
} relative w-full rounded border border-neutral-300 py-2 pl-3 pr-10 text-left sm:text-sm`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">{selectedValues.length} selected</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{rotate: '90deg'}}
|
||||
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{opacity: 0, height: 0}}
|
||||
animate={{opacity: 1, height: 'auto'}}
|
||||
exit={{opacity: 0, height: 0}}
|
||||
transition={{duration: 0.2, ease: 'easeInOut'}}
|
||||
className="scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 absolute z-40 mt-1 max-h-72 w-full overflow-y-scroll rounded-md border border-black border-opacity-5 bg-white p-1 pr-1 text-base shadow focus:outline-none sm:text-sm"
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
>
|
||||
<li className="relative cursor-default select-none px-3 py-2 text-neutral-800">
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
autoComplete={'off'}
|
||||
className="block w-full rounded border-neutral-300 focus:border-black focus:ring-black sm:text-sm"
|
||||
placeholder={'Search'}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</li>
|
||||
|
||||
{values.filter(value => value.name.toLowerCase().includes(query.toLowerCase())).length === 0 ? (
|
||||
<li className="relative cursor-default select-none py-2 pl-3 pr-9 text-neutral-800">
|
||||
No results found
|
||||
</li>
|
||||
) : (
|
||||
values
|
||||
.filter(value => value.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((value, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`multiselect-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
const isAlreadySelected = selectedValues.find(selection => value.value === selection);
|
||||
|
||||
const updatedArray = isAlreadySelected
|
||||
? selectedValues.filter(selection => selection !== value.value)
|
||||
: [...selectedValues, value.value];
|
||||
|
||||
onChange(updatedArray);
|
||||
setSelectedValues(updatedArray);
|
||||
}}
|
||||
>
|
||||
{value.tag && (
|
||||
<span
|
||||
className={'mr-3 whitespace-nowrap rounded bg-blue-100 px-3 py-0.5 text-xs text-blue-900'}
|
||||
>
|
||||
{value.tag}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate font-normal">
|
||||
{value.name.charAt(0).toUpperCase() + value.name.slice(1).toLowerCase()}
|
||||
</span>
|
||||
{value.value === selectedValues.find(selection => value.value === selection) ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-black">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as MultiselectDropdown} from './MultiselectDropdown';
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface ToggleProps {
|
||||
title: string;
|
||||
description: string;
|
||||
toggled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.toggled
|
||||
* @param root0.onToggle
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.className
|
||||
* @param root0.disabled
|
||||
*/
|
||||
export default function Toggle({title, description, toggled, onToggle, disabled, className}: ToggleProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center justify-between ${className}`}>
|
||||
<span className="flex flex-grow flex-col">
|
||||
<span
|
||||
className={`${
|
||||
disabled ? 'text-neutral-400' : 'text-neutral-800'
|
||||
} text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className={`${disabled ? 'text-neutral-300' : 'text-neutral-500'} w-10/12 text-sm`}>{description}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? 'bg-neutral-100' : toggled ? 'bg-neutral-800' : 'bg-neutral-200'
|
||||
} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2`}
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
aria-labelledby="availability-label"
|
||||
aria-describedby="availability-description"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
disabled ? 'translate-x-0' : toggled ? 'translate-x-5' : 'translate-x-0'
|
||||
} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Toggle} from './Toggle';
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './Toggle';
|
||||
export * from './Dropdown';
|
||||
export * from './MultiselectDropdown';
|
||||
export * from './MarkdownEditor';
|
||||
export * from './Input';
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.onMethodChange
|
||||
*/
|
||||
export default function AnalyticsTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [
|
||||
{to: '/analytics', text: 'Overview', active: router.route === '/analytics'},
|
||||
{to: '/analytics/clicks', text: 'Clicks', active: router.route === '/analytics/clicks'},
|
||||
];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as AnalyticsTabs} from './AnalyticsTabs';
|
||||
@@ -0,0 +1,14 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function DeveloperTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [{to: '/developers/webhooks', text: 'Webhooks', active: router.route === '/developers/webhooks'}];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as DeveloperTabs} from './DeveloperTabs';
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, {MutableRefObject, useEffect} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useActiveProject, useProjects} from '../../../lib/hooks/projects';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useAtom} from 'jotai';
|
||||
import {atomActiveProject} from '../../../lib/atoms/project';
|
||||
|
||||
export interface ProjectSelectorProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const ProjectSelector = React.forwardRef<HTMLDivElement, ProjectSelectorProps>(
|
||||
({open, onToggle}: ProjectSelectorProps, ref) => {
|
||||
const router = useRouter();
|
||||
const {data: projects} = useProjects();
|
||||
const activeProject = useActiveProject();
|
||||
const [, setActiveProjectId] = useAtom(atomActiveProject);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
const onChange = (project: string) => {
|
||||
localStorage.setItem('project', project);
|
||||
setActiveProjectId(project);
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={'projects'} className="block select-none text-sm font-semibold text-neutral-600">
|
||||
Projects
|
||||
</label>
|
||||
<div ref={ref}>
|
||||
<div className="relative mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'relative w-full cursor-pointer rounded border border-neutral-300 bg-white py-2 pl-3 pr-10 text-left focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500 sm:text-sm'
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="block flex items-center gap-x-1.5 truncate font-medium">
|
||||
{activeProject?.name ?? 'No active project'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{rotate: '90deg'}}
|
||||
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{opacity: 0, scale: 0.6}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.6}}
|
||||
transition={{duration: 0.1}}
|
||||
className={`absolute z-50 mt-1 w-full rounded bg-white text-base shadow-md ring-1 ring-neutral-800 ring-opacity-5 focus:outline-none sm:text-sm`}
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
aria-labelledby="listbox-label"
|
||||
aria-activedescendant="listbox-option-3"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 max-h-72 overflow-y-scroll p-1'
|
||||
}
|
||||
>
|
||||
{projects?.map((project, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`projects-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
onChange(project.id);
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
project.id === activeProject?.id ? 'font-medium' : 'font-normal'
|
||||
} flex items-center truncate`}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
{project.id === activeProject?.id ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-4 text-neutral-800">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<hr className={'my-0.5'} />
|
||||
<li
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={async () => {
|
||||
await router.push('/new');
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center truncate font-normal">Create new project</span>
|
||||
</li>
|
||||
</div>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ProjectSelector;
|
||||
@@ -0,0 +1 @@
|
||||
export {default as ProjectSelector} from './ProjectSelector';
|
||||
@@ -0,0 +1,19 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function SettingTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [
|
||||
{to: '/settings/project', text: 'Project Settings', active: router.route === '/settings/project'},
|
||||
{to: '/settings/api', text: 'API Keys', active: router.route === '/settings/api'},
|
||||
{to: '/settings/identity', text: 'Verified Domain', active: router.route === '/settings/identity'},
|
||||
{to: '/settings/members', text: 'Members', active: router.route === '/settings/members'},
|
||||
];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as SettingTabs} from './SettingTabs';
|
||||
@@ -0,0 +1,366 @@
|
||||
import React, {ReactElement, useState} from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import {ProjectSelector} from '../../index';
|
||||
import Image from 'next/image';
|
||||
import logo from '../../../../public/assets/logo.png';
|
||||
import {Home, LayoutTemplate, LineChart, LogOut, Send, Settings, TerminalSquare, Users2, Workflow} from 'lucide-react';
|
||||
|
||||
interface SidebarLinkType {
|
||||
to: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
highlight?: boolean;
|
||||
position: 'top' | 'bottom';
|
||||
icon: ReactElement;
|
||||
}
|
||||
|
||||
interface SidebarLinkProps {
|
||||
active?: boolean;
|
||||
to: string;
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
highlight?: boolean;
|
||||
svgPath: React.ReactElement;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
mobileOpen: boolean;
|
||||
onSidebarVisibilityChange: () => void;
|
||||
}
|
||||
|
||||
const links: SidebarLinkType[] = [
|
||||
{
|
||||
to: '/',
|
||||
text: 'Dashboard',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Home />,
|
||||
},
|
||||
{
|
||||
to: '/contacts',
|
||||
text: 'Contacts',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Users2 />,
|
||||
},
|
||||
{
|
||||
to: '/analytics',
|
||||
text: 'Analytics',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <LineChart />,
|
||||
},
|
||||
// {
|
||||
// to: '/developers',
|
||||
// text: 'Developers',
|
||||
// disabled: false,
|
||||
// position: 'top',
|
||||
// icon: <TerminalSquare />,
|
||||
// },
|
||||
{
|
||||
to: '/settings/project',
|
||||
text: 'Project Settings',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Settings />,
|
||||
},
|
||||
{
|
||||
to: '/events',
|
||||
text: 'Events',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <TerminalSquare />,
|
||||
},
|
||||
{
|
||||
to: '/templates',
|
||||
text: 'Templates',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <LayoutTemplate />,
|
||||
},
|
||||
{
|
||||
to: '/actions',
|
||||
text: 'Actions',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Workflow />,
|
||||
},
|
||||
{
|
||||
to: '/campaigns',
|
||||
text: 'Campaigns',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Send />,
|
||||
},
|
||||
|
||||
// {
|
||||
// to: '/settings/account',
|
||||
// text: 'Account Settings',
|
||||
// disabled: false,
|
||||
// position: 'bottom',
|
||||
// icon: (
|
||||
// <>
|
||||
// <Settings />
|
||||
// </>
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.active
|
||||
* @param root0.to
|
||||
* @param root0.text
|
||||
* @param root0.disabled
|
||||
* @param root0.svgPath
|
||||
* @param root0.highlight
|
||||
*/
|
||||
function SidebarLink({active, to, text, disabled, highlight, svgPath}: SidebarLinkProps) {
|
||||
if (to.startsWith('http')) {
|
||||
return (
|
||||
<a
|
||||
onClick={() => window.open(to, '_blank')?.focus()}
|
||||
className={`${
|
||||
active
|
||||
? 'cursor-default bg-neutral-100 text-neutral-700'
|
||||
: disabled
|
||||
? 'text-neutral-200'
|
||||
: 'cursor-pointer text-neutral-400 hover:bg-neutral-50 hover:text-neutral-700'
|
||||
} flex items-center gap-x-3 rounded p-2 text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">{svgPath}</div>
|
||||
{text}
|
||||
{highlight && <div className="ml-auto rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-800">New</div>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={to}
|
||||
className={`${
|
||||
active
|
||||
? 'cursor-default bg-neutral-100 text-neutral-700'
|
||||
: disabled
|
||||
? 'text-neutral-200'
|
||||
: 'cursor-pointer text-neutral-400 hover:bg-neutral-50 hover:text-neutral-700'
|
||||
} flex items-center gap-x-3 rounded p-2 text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">{svgPath}</div>
|
||||
{text}
|
||||
{highlight && <div className="ml-auto rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-800">New</div>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.mobileOpen
|
||||
* @param root0.onSidebarVisibilityChange
|
||||
*/
|
||||
export default function Sidebar({mobileOpen, onSidebarVisibilityChange}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const projectSelectorRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
const [projectSelectorOpen, setProjectSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, x: -100}}
|
||||
animate={{opacity: 1, x: 0}}
|
||||
exit={{opacity: 0, x: -100}}
|
||||
transition={{ease: 'easeOut', duration: 0.15}}
|
||||
className="fixed inset-0 z-40 flex w-full md:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<motion.div
|
||||
animate={{opacity: [0, 1]}}
|
||||
transition={{ease: 'easeOut', duration: 0.15}}
|
||||
className="fixed inset-0 bg-neutral-600 bg-opacity-75"
|
||||
aria-hidden={!mobileOpen}
|
||||
/>
|
||||
|
||||
<div className="relative flex h-full w-full max-w-xs flex-1 flex-col bg-white">
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => onSidebarVisibilityChange()}
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-0 flex-1 overflow-y-auto pb-4 pt-5">
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<Link href={'/'} passHref>
|
||||
<Image className={'cursor-pointer'} width={40} height={40} quality={40} src={logo} alt="Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={'mt-5 px-2'}>
|
||||
<ProjectSelector
|
||||
open={projectSelectorOpen}
|
||||
onToggle={() => setProjectSelectorOpen(!projectSelectorOpen)}
|
||||
ref={projectSelectorRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="mt-5 space-y-1 px-2">
|
||||
{links
|
||||
.filter(l => l.position === 'top')
|
||||
.map((link, index) => {
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`mobile-top-${index}`}
|
||||
active={
|
||||
link.to === '/'
|
||||
? router.pathname === link.to
|
||||
: router.pathname.split('/')[1].includes(link.to.split('/')[1])
|
||||
}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex-0 mb-4 space-y-1 bg-white px-2">
|
||||
<nav>
|
||||
{links
|
||||
.filter(l => l.position === 'bottom')
|
||||
.map((link, index) => {
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`mobile-bottom-${index}`}
|
||||
active={router.pathname === link.to}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-14 flex-shrink-0" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex w-72 flex-col">
|
||||
<div className="flex h-0 flex-1 flex-col border-r border-neutral-100 bg-white px-6">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pb-4 pt-5">
|
||||
<div className="flex flex-shrink-0 items-center justify-center px-4">
|
||||
<Link href={'/'} passHref>
|
||||
<Image className={'cursor-pointer'} width={35} height={35} quality={80} src={logo} alt="Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={'px-2'}>
|
||||
<ProjectSelector
|
||||
open={projectSelectorOpen}
|
||||
onToggle={() => setProjectSelectorOpen(!projectSelectorOpen)}
|
||||
ref={projectSelectorRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="mt-5 flex-1 space-y-1 px-2">
|
||||
{links
|
||||
.filter(l => l.position === 'top')
|
||||
.map((link, index) => {
|
||||
if (link.to === '/events') {
|
||||
return (
|
||||
<div className={'pt-3'}>
|
||||
<p className={'pb-1 text-sm font-semibold text-neutral-500'}>Automations</p>
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={router.pathname.split('/')[1].includes(link.to.split('/')[1])}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (link.to === '/campaigns') {
|
||||
return (
|
||||
<div className={'py-3'}>
|
||||
<p className={'pb-1 text-sm font-semibold text-neutral-500'}>Campaigns</p>
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={router.pathname.split('/')[1].includes(link.to.split('/')[1])}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={
|
||||
link.to === '/'
|
||||
? router.pathname === link.to
|
||||
: router.pathname.split('/')[1].includes(link.to.split('/')[1])
|
||||
}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex-0 mb-4 w-full space-y-1 bg-white px-2">
|
||||
<Link
|
||||
href={'/auth/logout'}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-x-3 rounded p-2 text-sm font-medium text-neutral-400 transition ease-in-out hover:bg-neutral-50 hover:text-neutral-700'
|
||||
}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<LogOut />
|
||||
</div>
|
||||
Sign out
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Sidebar} from './Sidebar';
|
||||
@@ -0,0 +1,61 @@
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
export interface TabProps {
|
||||
links: {
|
||||
to: string;
|
||||
text: string;
|
||||
active: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.links
|
||||
*/
|
||||
export default function Tabs({links}: TabProps) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="tabs" className="sr-only">
|
||||
Select a tab
|
||||
</label>
|
||||
<select
|
||||
id="tabs"
|
||||
name="tabs"
|
||||
className="focus:ring-mirage-500 focus:border-mirage-500 block w-full rounded border-neutral-300 py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
|
||||
onChange={e => router.push(e.target.value)}
|
||||
>
|
||||
{links.map(link => {
|
||||
return (
|
||||
<option value={link.to} selected={link.active}>
|
||||
{link.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div className="border-b border-neutral-200">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
{links.map(link => {
|
||||
return (
|
||||
<Link
|
||||
href={link.to}
|
||||
className={`${
|
||||
link.active
|
||||
? 'border-mirage-500 text-mirage-600'
|
||||
: 'text-neutral-500 hover:border-neutral-300 hover:text-neutral-700'
|
||||
} whitespace-nowrap border-b-2 border-transparent px-1 py-4 text-sm font-medium transition`}
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Tabs} from './Tabs';
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './Sidebar';
|
||||
export * from './ProjectSelector';
|
||||
export * from './Tabs';
|
||||
export * from './SettingTabs';
|
||||
export * from './AnalyticsTabs';
|
||||
export * from './DeveloperTabs';
|
||||
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onAction: () => void;
|
||||
children?: React.ReactNode;
|
||||
action?: string;
|
||||
type: 'info' | 'danger';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.isOpen
|
||||
* @param root0.onToggle
|
||||
* @param root0.onAction
|
||||
* @param root0.children
|
||||
* @param root0.action
|
||||
* @param root0.type
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.icon
|
||||
*/
|
||||
export default function Modal({
|
||||
title,
|
||||
description,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onAction,
|
||||
children,
|
||||
action,
|
||||
type,
|
||||
icon,
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{ease: 'easeInOut', duration: 0.15}}
|
||||
className="fixed inset-0 z-20 bg-neutral-500 bg-opacity-75 transition ease-in-out"
|
||||
aria-hidden="true"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.7}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.7}}
|
||||
transition={{ease: 'easeInOut', duration: 0.15}}
|
||||
className="relative z-40 inline-block transform overflow-hidden rounded-lg border border-black border-opacity-5 bg-white px-8 py-10 text-left align-bottom shadow-2xl sm:my-8 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
>
|
||||
<div className="absolute right-0 top-0 hidden p-8 sm:block">
|
||||
<button
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-md bg-white text-neutral-400 transition hover:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="sm:flex sm:items-start">
|
||||
{type === 'info' ? (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-neutral-100 p-3 text-neutral-800 sm:mx-0 sm:h-12 sm:w-12">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-red-50 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-900"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex-1 sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<div className={'mb-3'}>
|
||||
<p className={'text-lg font-semibold text-neutral-800'}>{title}</p>
|
||||
<p className={'text-sm text-neutral-500'}>{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${children ? 'mt-5' : ''} sm:flex sm:flex-row-reverse`}>
|
||||
<motion.button
|
||||
whileHover={{scale: 1.05}}
|
||||
whileTap={{scale: 0.95}}
|
||||
type="button"
|
||||
className={`${
|
||||
type === 'info'
|
||||
? 'bg-neutral-800 focus:ring-neutral-800'
|
||||
: 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
} inline-flex w-full justify-center rounded border border-transparent px-6 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm`}
|
||||
onClick={onAction}
|
||||
>
|
||||
{action ? action : 'Confirm'}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{scale: 1.05}}
|
||||
whileTap={{scale: 0.95}}
|
||||
type="button"
|
||||
className="mt-3 inline-flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
onClick={onToggle}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Modal} from './Modal';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Modal/index';
|
||||
@@ -0,0 +1,81 @@
|
||||
export interface SkeletonProps {
|
||||
type: 'table' | 'card';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.type
|
||||
*/
|
||||
export default function Skeleton({type}: SkeletonProps) {
|
||||
if (type === 'table') {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className="w-full animate-pulse space-y-4 divide-y divide-neutral-200 rounded border border-neutral-200 p-4 shadow md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<div>
|
||||
<div className="mb-2.5 h-2.5 w-24 rounded-full bg-neutral-300"></div>
|
||||
<div className="h-2 w-32 rounded-full bg-neutral-200"></div>
|
||||
</div>
|
||||
<div className="h-2.5 w-12 rounded-full bg-neutral-300"></div>
|
||||
</div>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Skeleton} from './Skeleton';
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface TableProps {
|
||||
values: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
|
||||
[key: string]: string | number | boolean | Date | React.ReactNode | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.values
|
||||
*/
|
||||
export default function Table({values}: TableProps) {
|
||||
if (values.length === 0) {
|
||||
return <h1>No values provided</h1>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div className="overflow-hidden rounded border border-neutral-200">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-neutral-50">
|
||||
<tr>
|
||||
{Object.keys(values[0]).map(header => {
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={`${
|
||||
typeof values[0][header] === 'boolean' ? 'text-center' : 'text-left'
|
||||
} px-6 py-3 text-xs font-medium text-neutral-800`}
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{values.map(row => {
|
||||
return (
|
||||
<tr className={`border-t border-neutral-100 bg-white transition ease-in-out hover:bg-neutral-50`}>
|
||||
{Object.entries(row).map(value => {
|
||||
if (value[1] === null || value[1] === undefined) {
|
||||
return (
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-neutral-500">Not specified</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value[1] === 'boolean') {
|
||||
return (
|
||||
<td className="whitespace-nowrap px-6 py-4 text-sm text-neutral-500">
|
||||
{value[1] ? (
|
||||
<svg
|
||||
className={'mx-auto h-7 w-7 rounded-full bg-green-50 p-1 text-green-500'}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M5.75 12.8665L8.33995 16.4138C9.15171 17.5256 10.8179 17.504 11.6006 16.3715L18.25 6.75"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className={'mx-auto h-7 w-7 rounded-full bg-red-50 p-1 text-red-500'}
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M17.25 6.75L6.75 17.25"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M6.75 6.75L17.25 17.25"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return <td className="whitespace-nowrap px-6 py-4 text-sm text-neutral-500">{value[1]}</td>;
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Table} from './Table';
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import {Ghost} from 'lucide-react';
|
||||
|
||||
export interface EmptyProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.icon
|
||||
*/
|
||||
export default function Empty({title, description, icon}: EmptyProps) {
|
||||
return (
|
||||
<div className="relative block w-full p-12 text-center">
|
||||
<svg
|
||||
className="mx-auto mb-6 h-12 w-12 rounded bg-neutral-100 p-3 font-bold text-neutral-800"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? (
|
||||
<>
|
||||
<Ghost />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className="mt-2 block text-sm font-medium text-neutral-800">{title}</span>
|
||||
<span className="mt-1 block text-sm font-normal text-neutral-600">{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Empty} from './Empty';
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
*
|
||||
*/
|
||||
import { LineWobble } from "@uiball/loaders";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function FullscreenLoader() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="mt-8 flex flex-col items-center justify-center sm:mx-auto sm:w-full sm:max-w-lg">
|
||||
<h1 className="mt-3 text-center font-medium" suppressHydrationWarning>
|
||||
Loading...
|
||||
</h1>
|
||||
<p className={"text-center text-sm text-neutral-600"}>
|
||||
Does this take longer than expected? Try clearing your browser's cache
|
||||
or check if you have an ad blocker enabled!
|
||||
</p>
|
||||
<div className={"mt-6"}>
|
||||
<LineWobble size={200} color={"#262626"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as FullscreenLoader} from './FullscreenLoader';
|
||||
@@ -0,0 +1,24 @@
|
||||
import {motion} from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.percentage
|
||||
*/
|
||||
export default function ProgressBar({percentage}: ProgressBarProps) {
|
||||
const formattedPercentage = isNaN(percentage) ? 0 : percentage;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-full bg-neutral-200 transition ease-in-out">
|
||||
<motion.div
|
||||
transition={{duration: 0.5}}
|
||||
animate={{width: ['0%', `${formattedPercentage}%`]}}
|
||||
className="h-2 rounded-full bg-neutral-700"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as ProgressBar} from './ProgressBar';
|
||||
@@ -0,0 +1,20 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
export interface RedirectProps {
|
||||
to: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.to
|
||||
*/
|
||||
export default function Redirect({to}: RedirectProps) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
void router.push(to);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Redirect} from './Redirect';
|
||||
@@ -0,0 +1,37 @@
|
||||
import Tippy from '@tippyjs/react';
|
||||
import React, {ReactNode} from 'react';
|
||||
|
||||
export interface TooltipProps {
|
||||
content: ReactNode | string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.content
|
||||
* @param root0.icon
|
||||
*/
|
||||
export default function Tooltip({content, icon}: TooltipProps) {
|
||||
return (
|
||||
<>
|
||||
<Tippy
|
||||
maxWidth={450}
|
||||
className={'rounded-md border border-neutral-200 bg-white px-6 py-6 text-sm text-neutral-800 shadow-md'}
|
||||
content={<div>{content}</div>}
|
||||
>
|
||||
<svg
|
||||
className={'ml-1 h-4 w-4 cursor-pointer'}
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{icon}
|
||||
</svg>
|
||||
</Tippy>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Tooltip} from './Tooltip';
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './Redirect';
|
||||
export * from './FullscreenLoader';
|
||||
export * from './Empty';
|
||||
export * from './ProgressBar';
|
||||
export * from './Tooltip';
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './Input';
|
||||
export * from './Utility';
|
||||
export * from './Alert';
|
||||
export * from './Badge';
|
||||
export * from './Table';
|
||||
export * from './Overlay';
|
||||
export * from './Navigation';
|
||||
export * from './Card';
|
||||
export * from './Skeleton';
|
||||
export * from './CodeBlock';
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, {useState} from 'react';
|
||||
import {FullscreenLoader, Redirect, Sidebar} from '../components';
|
||||
import {useActiveProject, useProjects} from '../lib/hooks/projects';
|
||||
import {useUser} from '../lib/hooks/users';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
export const Dashboard = (props: {children: React.ReactNode}) => {
|
||||
const router = useRouter();
|
||||
const activeProject = useActiveProject();
|
||||
const {data: projects} = useProjects();
|
||||
const {data: user} = useUser();
|
||||
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
if (!projects || !user || !activeProject) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
return <Redirect to={'/new'} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-50">
|
||||
<Sidebar
|
||||
mobileOpen={mobileSidebarOpen}
|
||||
onSidebarVisibilityChange={() => setMobileSidebarOpen(!mobileSidebarOpen)}
|
||||
/>
|
||||
<div className="flex w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="pl-1 pt-1 sm:pl-3 sm:pt-3 md:hidden">
|
||||
<button
|
||||
className="focus:ring-azure-500 -ml-0.5 -mt-0.5 inline-flex h-12 w-12 items-center justify-center rounded text-neutral-500 hover:text-neutral-800 focus:outline-none focus:ring-2 focus:ring-inset"
|
||||
onClick={() => setMobileSidebarOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<main className="relative z-0 flex-1 overflow-y-scroll focus:outline-none">
|
||||
<div className="min-h-screen">
|
||||
<div className="relative mx-auto min-h-screen">
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={router.pathname}
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2, ease: 'easeInOut'}}
|
||||
className="mx-auto h-full max-w-7xl space-y-6 px-4 py-5 sm:px-6 md:px-8"
|
||||
>
|
||||
{props.children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Dashboard';
|
||||
@@ -0,0 +1,5 @@
|
||||
import {atom} from 'jotai';
|
||||
|
||||
export const atomActiveProject = atom<string | null>(
|
||||
typeof window !== 'undefined' ? window.localStorage.getItem('project') : null,
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
export const API_URI = process.env.NEXT_PUBLIC_API_URI ?? 'http://localhost:8080';
|
||||
export const AWS_REGION = process.env.NEXT_PUBLIC_AWS_REGION;
|
||||
|
||||
export const NO_AUTH_ROUTES = ['/auth/signup', '/auth/login', '/auth/reset', '/unsubscribe/[id]', '/subscribe/[id]'];
|
||||
@@ -0,0 +1,43 @@
|
||||
import useSWR from 'swr';
|
||||
import {Action, Email, Event, Task, Template, Trigger} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function useAction(id: string) {
|
||||
return useSWR(`/v1/actions/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function useRelatedActions(id: string) {
|
||||
return useSWR<
|
||||
(Action & {
|
||||
events: Event[];
|
||||
notevents: Event[];
|
||||
triggers: Trigger[];
|
||||
emails: Email[];
|
||||
template: Template;
|
||||
})[]
|
||||
>(`/v1/actions/${id}/related`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActions() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(Action & {
|
||||
triggers: Trigger[];
|
||||
template: Template;
|
||||
emails: Email[];
|
||||
tasks: Task[];
|
||||
})[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/actions` : null);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {useActiveProject} from './projects';
|
||||
import useSWR from 'swr';
|
||||
|
||||
/**
|
||||
|
||||
* @param method
|
||||
*/
|
||||
export function useAnalytics(method?: 'week' | 'month' | 'year') {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<{
|
||||
contacts: {
|
||||
timeseries: {
|
||||
day: Date;
|
||||
count: number;
|
||||
}[];
|
||||
subscribed: number;
|
||||
unsubscribed: number;
|
||||
};
|
||||
emails: {
|
||||
total: number;
|
||||
bounced: number;
|
||||
opened: number;
|
||||
complaint: number;
|
||||
totalPrev: number;
|
||||
bouncedPrev: number;
|
||||
openedPrev: number;
|
||||
complaintPrev: number;
|
||||
};
|
||||
clicks: {
|
||||
actions: {link: string; name: string; count: number}[];
|
||||
};
|
||||
}>(activeProject ? `/projects/id/${activeProject.id}/analytics?method=${method ?? 'week'}` : null);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import useSWR from 'swr';
|
||||
import {Campaign} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function useCampaign(id: string) {
|
||||
return useSWR(`/v1/campaigns/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useCampaigns() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(Campaign & {
|
||||
emails: {
|
||||
id: string;
|
||||
status: string;
|
||||
}[];
|
||||
tasks: {
|
||||
id: string;
|
||||
}[];
|
||||
recipients: {
|
||||
id: string;
|
||||
}[];
|
||||
})[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/campaigns` : null);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import useSWR from 'swr';
|
||||
import {Action, Contact, Email, Event, Project, Trigger} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
export interface WithProject {
|
||||
id: string;
|
||||
withProject: true;
|
||||
}
|
||||
|
||||
export interface WithoutProject {
|
||||
id: string;
|
||||
withProject?: false;
|
||||
}
|
||||
|
||||
export type WithOrWithoutProject<T extends WithProject | WithoutProject> = T extends WithProject
|
||||
?
|
||||
| (Contact & {
|
||||
emails: Email[];
|
||||
triggers: (Trigger & {
|
||||
event: Event | null;
|
||||
action: Action | null;
|
||||
})[];
|
||||
project: Project;
|
||||
})
|
||||
| null
|
||||
:
|
||||
| (Contact & {
|
||||
emails: Email[];
|
||||
triggers: (Trigger & {
|
||||
event: Event | null;
|
||||
action: Action | null;
|
||||
})[];
|
||||
})
|
||||
| null;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id.id
|
||||
* @param id
|
||||
* @param id.withProject
|
||||
*/
|
||||
export function useContact<T extends WithProject | WithoutProject>({id, withProject = false}: T) {
|
||||
return useSWR<WithOrWithoutProject<T>>(withProject ? `/v1/contacts/${id}?withProject=true` : `/v1/contacts/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param page
|
||||
*/
|
||||
export function useContacts(page: number) {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<{
|
||||
contacts: (Contact & {
|
||||
triggers: Trigger[];
|
||||
})[];
|
||||
count: number;
|
||||
}>(activeProject ? `/projects/id/${activeProject.id}/contacts?page=${page}` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useContactsCount() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<number>(activeProject ? `/projects/id/${activeProject.id}/contacts/count` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useContactMetadata() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<string[]>(activeProject ? `/projects/id/${activeProject.id}/contacts/metadata` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param query
|
||||
*/
|
||||
export function searchContacts(query: string | undefined) {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
if (!query) {
|
||||
return useSWR<{
|
||||
contacts: (Contact & {
|
||||
triggers: Trigger[];
|
||||
emails: Email[];
|
||||
})[];
|
||||
count: number;
|
||||
}>(activeProject ? `/projects/id/${activeProject.id}/contacts` : null);
|
||||
}
|
||||
|
||||
return useSWR<{
|
||||
contacts: (Contact & {
|
||||
triggers: Trigger[];
|
||||
emails: Email[];
|
||||
})[];
|
||||
count: number;
|
||||
}>(activeProject ? `/projects/id/${activeProject.id}/contacts/search?query=${query}` : null, {
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useActiveProject} from './projects';
|
||||
import useSWR from 'swr';
|
||||
import {Email} from '@prisma/client';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useEmails() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<Email[]>(activeProject ? `/projects/id/${activeProject.id}/emails` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useEmailsCount() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<number>(activeProject ? `/projects/id/${activeProject.id}/emails/count` : null);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import useSWR from 'swr';
|
||||
import {Event} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useEvents() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(Event & {
|
||||
triggers: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
contactId: string;
|
||||
}[];
|
||||
})[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/events` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useEventsWithoutTriggers() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<Event[]>(activeProject ? `/projects/id/${activeProject.id}/events?triggers=false` : null);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {Action, Contact, Email, Event, Project, Role} from '@prisma/client';
|
||||
import {useAtom} from 'jotai';
|
||||
import useSWR from 'swr';
|
||||
import {atomActiveProject} from '../atoms/project';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useProjects() {
|
||||
return useSWR<Project[]>('/users/@me/projects');
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActiveProject(): Project | null {
|
||||
const [activeProject, setActiveProject] = useAtom(atomActiveProject);
|
||||
const {data: projects} = useProjects();
|
||||
|
||||
if (!projects) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeProject && !projects.find(project => project.id === activeProject)) {
|
||||
setActiveProject(null);
|
||||
window.localStorage.removeItem('project');
|
||||
}
|
||||
|
||||
if (!activeProject && projects.length > 0) {
|
||||
setActiveProject(projects[0].id);
|
||||
window.localStorage.setItem('project', projects[0].id);
|
||||
}
|
||||
|
||||
return projects.find(project => project.id === activeProject) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActiveProjectMemberships() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
{
|
||||
userId: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
}[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/memberships` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActiveProjectFeed(page: number) {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(
|
||||
| {
|
||||
createdAt: Date;
|
||||
contact: Contact;
|
||||
event: Event | null;
|
||||
action: Action | null;
|
||||
}
|
||||
| ({
|
||||
contact: Contact;
|
||||
} & Email)
|
||||
)[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/feed?page=${page}` : null);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useActiveProjectVerifiedIdentity() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<{
|
||||
tokens: string[];
|
||||
}>(activeProject ? `/identities/id/${activeProject.id}` : null);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import useSWR from 'swr';
|
||||
import {Action, Template} from '@prisma/client';
|
||||
import {useActiveProject} from './projects';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param id
|
||||
*/
|
||||
export function useTemplate(id: string) {
|
||||
return useSWR(`/v1/templates/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useTemplates() {
|
||||
const activeProject = useActiveProject();
|
||||
|
||||
return useSWR<
|
||||
(Template & {
|
||||
actions: Action[];
|
||||
})[]
|
||||
>(activeProject ? `/projects/id/${activeProject.id}/templates` : null);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import useSWR from 'swr';
|
||||
|
||||
/**
|
||||
* Fetch the current user. undefined means loading, null means logged out
|
||||
*
|
||||
*/
|
||||
export function useUser() {
|
||||
return useSWR('/users/@me', {shouldRetryOnError: false});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import {API_URI} from './constants';
|
||||
import {infer as ZodInfer, ZodSchema} from 'zod';
|
||||
|
||||
interface Json {
|
||||
[x: string]: string | number | boolean | Date | Json | JsonArray;
|
||||
}
|
||||
|
||||
type JsonArray = (string | number | boolean | Date | Json | JsonArray)[];
|
||||
|
||||
interface TypedSchema extends ZodSchema {
|
||||
_type: any;
|
||||
}
|
||||
|
||||
export class network {
|
||||
/**
|
||||
* Fetcher function that includes toast support
|
||||
* @param method Request method
|
||||
* @param path Request endpoint or path
|
||||
* @param body Request body
|
||||
*/
|
||||
public static async fetch<T, Schema extends TypedSchema | void = void>(
|
||||
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
|
||||
path: string,
|
||||
body?: Schema extends TypedSchema ? ZodInfer<Schema> : never,
|
||||
): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : API_URI + path;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body: body && JSON.stringify(body),
|
||||
headers: body && {'Content-Type': 'application/json'},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (response.status >= 400) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
throw new Error(res?.message ?? 'Something went wrong!');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public static async mock<T, Schema extends TypedSchema | void = void>(
|
||||
key: string,
|
||||
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
|
||||
path: string,
|
||||
body?: Schema extends TypedSchema ? ZodInfer<Schema> : never,
|
||||
): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : API_URI + path;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
body: body && JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json', 'Authorization': `Bearer ${key}`},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
|
||||
if (response.status >= 400) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
throw new Error(res?.message ?? 'Something went wrong!');
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import "../../styles/index.css";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import utc from "dayjs/plugin/utc";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import Router, { useRouter } from "next/router";
|
||||
import NProgress from "nprogress";
|
||||
import React from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { SWRConfig } from "swr";
|
||||
import { network } from "../lib/network";
|
||||
import "nprogress/nprogress.css";
|
||||
import advancedFormat from "dayjs/plugin/advancedFormat";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { DefaultSeo } from "next-seo";
|
||||
import { FullscreenLoader, Redirect } from "../components";
|
||||
import { NO_AUTH_ROUTES } from "../lib/constants";
|
||||
import { useUser } from "../lib/hooks/users";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(advancedFormat);
|
||||
dayjs.extend(duration);
|
||||
|
||||
Router.events.on("routeChangeStart", () => NProgress.start());
|
||||
Router.events.on("routeChangeComplete", () => NProgress.done());
|
||||
Router.events.on("routeChangeError", () => NProgress.done());
|
||||
|
||||
/**
|
||||
* Main app component
|
||||
* @param props Props
|
||||
* @param props.Component App component
|
||||
* @param props.pageProps
|
||||
*/
|
||||
function App({ Component, pageProps }: AppProps) {
|
||||
const router = useRouter();
|
||||
const { data: user, error } = useUser();
|
||||
|
||||
if (error && !NO_AUTH_ROUTES.includes(router.route)) {
|
||||
return <Redirect to={"/auth/login"} />;
|
||||
}
|
||||
|
||||
if (!user && !NO_AUTH_ROUTES.includes(router.route)) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Plunk Dashboard | The Email Platform for SaaS</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
key={"viewport"}
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Toaster position={"bottom-right"} />
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main app root component that houses all components
|
||||
* @param props Default nextjs props
|
||||
*/
|
||||
export default function WithProviders(props: AppProps) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
fetcher: (url: string) => network.fetch("GET", url),
|
||||
revalidateOnFocus: true,
|
||||
}}
|
||||
>
|
||||
<JotaiProvider>
|
||||
<DefaultSeo
|
||||
defaultTitle={"Plunk Dashboard | The Email Platform for SaaS"}
|
||||
title={"Plunk Dashboard | The Email Platform for SaaS"}
|
||||
description={
|
||||
"Plunk is the affordable, developer-friendly email platform that brings together marketing, transactional and broadcast emails into one single, complete solution"
|
||||
}
|
||||
twitter={{
|
||||
cardType: "summary_large_image",
|
||||
handle: "@useplunk",
|
||||
site: "@useplunk",
|
||||
}}
|
||||
openGraph={{
|
||||
title: "Plunk Dashboard | The Email Platform for SaaS",
|
||||
description:
|
||||
"Plunk is the affordable, developer-friendly email platform that brings together marketing, transactional and broadcast emails into one single, complete solution",
|
||||
images: [
|
||||
{ url: "https://app.useplunk.com/assets/card.png", alt: "Plunk" },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
|
||||
<App {...props} />
|
||||
</JotaiProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Document, {Head, Html, Main, NextScript} from 'next/document';
|
||||
import React from 'react';
|
||||
|
||||
export default class MyDocument extends Document {
|
||||
public render() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
{/* Start fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
{/* End fonts */}
|
||||
|
||||
{/* Start favicon */}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#171717" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
|
||||
<meta name="theme-color" content="#171717" />
|
||||
{/* End favicon */}
|
||||
</Head>
|
||||
<body className={'cursor-default antialiased'}>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ActionSchemas, type UtilitySchemas } from "@plunk/shared";
|
||||
import type { Action } from "@prisma/client";
|
||||
import { useEvents } from "dashboard/src/lib/hooks/events";
|
||||
import { useTemplates } from "dashboard/src/lib/hooks/templates";
|
||||
import dayjs from "dayjs";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Save } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type FieldError, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Dropdown,
|
||||
Empty,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
MultiselectDropdown,
|
||||
Toggle,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import {
|
||||
useAction,
|
||||
useActions,
|
||||
useRelatedActions,
|
||||
} from "../../lib/hooks/actions";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface ActionValues {
|
||||
name: string;
|
||||
runOnce: boolean;
|
||||
delay: number;
|
||||
template: string;
|
||||
events: string[];
|
||||
notevents: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
if (!router.isReady) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const project = useActiveProject();
|
||||
const { mutate } = useActions();
|
||||
const { data: templates } = useTemplates();
|
||||
const { data: events } = useEvents();
|
||||
const { data: action } = useAction(router.query.id as string);
|
||||
const { data: related } = useRelatedActions(router.query.id as string);
|
||||
|
||||
const [delay, setDelay] = useState<{
|
||||
delay: number;
|
||||
unit: "MINUTES" | "HOURS" | "DAYS";
|
||||
}>({
|
||||
delay: 0,
|
||||
unit: "MINUTES",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
switch (delay.unit) {
|
||||
case "MINUTES":
|
||||
setValue("delay", delay.delay);
|
||||
break;
|
||||
case "HOURS":
|
||||
setValue("delay", delay.delay * 60);
|
||||
break;
|
||||
case "DAYS":
|
||||
setValue("delay", delay.delay * 24 * 60);
|
||||
break;
|
||||
}
|
||||
}, [delay]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
reset,
|
||||
setValue,
|
||||
} = useForm<ActionValues>({
|
||||
defaultValues: { events: [], notevents: [] },
|
||||
resolver: zodResolver(ActionSchemas.update),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.delay !== 0) {
|
||||
if (action.delay % 1440 === 0) {
|
||||
setDelay({ unit: "DAYS", delay: action.delay / 1440 });
|
||||
} else if (action.delay % 60 === 0) {
|
||||
setDelay({ unit: "HOURS", delay: action.delay / 60 });
|
||||
} else {
|
||||
setDelay({ unit: "MINUTES", delay: action.delay });
|
||||
}
|
||||
}
|
||||
|
||||
reset({
|
||||
...action,
|
||||
template: action.templateId,
|
||||
delay: 0,
|
||||
events: action.events.map((e: { id: string }) => e.id),
|
||||
notevents: action.notevents.map((e: { id: string }) => e.id),
|
||||
});
|
||||
}, [reset, action]);
|
||||
|
||||
if (!project || !action || !templates || !events || !related) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const updateAction = (data: ActionValues) => {
|
||||
toast.promise(
|
||||
network.mock<Action, typeof ActionSchemas.update>(
|
||||
project.secret,
|
||||
"PUT",
|
||||
"/v1/actions",
|
||||
{
|
||||
id: action.id,
|
||||
...data,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Saving your action",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Saved your action";
|
||||
},
|
||||
error: "Could not save your action!",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const remove = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
toast.promise(
|
||||
network.mock<Action, typeof UtilitySchemas.id>(
|
||||
project.secret,
|
||||
"DELETE",
|
||||
"/v1/actions",
|
||||
{
|
||||
id: action.id,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Deleting your action",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Deleted your action";
|
||||
},
|
||||
error: "Could not delete your action!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/actions");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card
|
||||
title={"Edit your action"}
|
||||
options={
|
||||
<>
|
||||
<button
|
||||
onClick={remove}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-800 transition hover:bg-neutral-100"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M6.75 7.75L7.59115 17.4233C7.68102 18.4568 8.54622 19.25 9.58363 19.25H14.4164C15.4538 19.25 16.319 18.4568 16.4088 17.4233L17.25 7.75"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M9.75 7.5V6.75C9.75 5.64543 10.6454 4.75 11.75 4.75H12.25C13.3546 4.75 14.25 5.64543 14.25 6.75V7.5"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M5 7.75H19"
|
||||
/>
|
||||
</svg>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit(updateAction)}
|
||||
className="mx-auto my-3 max-w-xl space-y-6"
|
||||
>
|
||||
<Input
|
||||
label={"Name"}
|
||||
placeholder={"Onboarding Flow"}
|
||||
register={register("name")}
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"events"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Run on triggers
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => setValue("events", e)}
|
||||
values={events
|
||||
.filter(
|
||||
(e) => !e.campaignId && !watch("notevents").includes(e.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.templateId && !b.templateId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!a.templateId && b.templateId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name === "unsubscribe" || a.name === "subscribe") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name === "unsubscribe" || b.name === "subscribe") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag: e.templateId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: e.name === "unsubscribe" || e.name === "subscribe"
|
||||
? "Automated"
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
selectedValues={watch("events")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.events as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.events as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"events"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Exclude contacts with triggers
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => setValue("notevents", e)}
|
||||
values={events
|
||||
.filter(
|
||||
(e) => !e.campaignId && !watch("events").includes(e.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.templateId && !b.templateId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!a.templateId && b.templateId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name === "unsubscribe" || a.name === "subscribe") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name === "unsubscribe" || b.name === "subscribe") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag: e.templateId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: e.name === "unsubscribe" || e.name === "subscribe"
|
||||
? "Automated"
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
selectedValues={watch("notevents")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.notevents as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.notevents as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"template"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Template
|
||||
</label>
|
||||
<div className={"grid gap-6 sm:grid-cols-6"}>
|
||||
<div className={"sm:col-span-4"}>
|
||||
<Dropdown
|
||||
onChange={(t) => setValue("template", t)}
|
||||
values={templates.map((t) => {
|
||||
return { name: t.subject, value: t.id };
|
||||
})}
|
||||
selectedValue={watch("template")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.template?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.template.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<Link
|
||||
href={`/templates/${action.templateId}`}
|
||||
passHref
|
||||
className={"sm:col-span-2"}
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex h-full w-full items-center justify-center gap-x-1 rounded border border-neutral-300 bg-white text-center text-sm font-medium text-neutral-800"
|
||||
}
|
||||
>
|
||||
<svg className={"h-5 w-5"} fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M4.75 19.25L9 18.25L18.2929 8.95711C18.6834 8.56658 18.6834 7.93342 18.2929 7.54289L16.4571 5.70711C16.0666 5.31658 15.4334 5.31658 15.0429 5.70711L5.75 15L4.75 19.25Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M19.25 19.25H13.75"
|
||||
/>
|
||||
</svg>
|
||||
Edit
|
||||
</motion.button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"template"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Delay before sending
|
||||
</label>
|
||||
<div className={"grid grid-cols-6 gap-4"}>
|
||||
<div className={"col-span-2 mt-1"}>
|
||||
<input
|
||||
type={"number"}
|
||||
autoComplete={"off"}
|
||||
min={0}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder={"0"}
|
||||
value={delay.delay}
|
||||
onChange={(e) =>
|
||||
setDelay({
|
||||
...delay,
|
||||
delay: Number.parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={"col-span-4"}>
|
||||
<Dropdown
|
||||
onChange={(t) =>
|
||||
setDelay({
|
||||
...delay,
|
||||
unit: t as "MINUTES" | "HOURS" | "DAYS",
|
||||
})
|
||||
}
|
||||
values={[
|
||||
{ name: "Minutes", value: "MINUTES" },
|
||||
{ name: "Hours", value: "HOURS" },
|
||||
{ name: "Days", value: "DAYS" },
|
||||
]}
|
||||
selectedValue={delay.unit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Toggle
|
||||
title={"Run once"}
|
||||
description={
|
||||
"Toggle this on if you want to run this action only once per contact."
|
||||
}
|
||||
toggled={watch("runOnce")}
|
||||
onToggle={() => setValue("runOnce", !watch("runOnce"))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex justify-end gap-3"}>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
return router.push("/actions");
|
||||
}}
|
||||
className={
|
||||
"flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-center gap-x-2 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<Save strokeWidth={1.5} size={18} />
|
||||
Save
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
<Card title={"Related Actions"}>
|
||||
<div className={"grid gap-3 sm:grid-cols-2"}>
|
||||
{related.length > 0 ? (
|
||||
related
|
||||
.sort((a, b) => {
|
||||
if (a.delay < b.delay) {
|
||||
return -1;
|
||||
}
|
||||
if (a.delay > b.delay) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
.map((r) => {
|
||||
return (
|
||||
<Link href={`/actions/${r.id}`} key={r.id}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-6 rounded border border-solid border-neutral-200 bg-white px-8 py-4"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className="inline-flex rounded bg-neutral-100 p-4 text-neutral-800 ring-4 ring-white">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeWidth={"1.5"}
|
||||
d="M16 21h3c.81 0 1.48 -.67 1.48 -1.48l.02 -.02c0 -.82 -.69 -1.5 -1.5 -1.5h-3v3z"
|
||||
/>
|
||||
<path
|
||||
strokeWidth={"1.5"}
|
||||
d="M16 15h2.5c.84 -.01 1.5 .66 1.5 1.5s-.66 1.5 -1.5 1.5h-2.5v-3z"
|
||||
/>
|
||||
<path
|
||||
strokeWidth={"1.5"}
|
||||
d="M4 9v-4c0 -1.036 .895 -2 2 -2s2 .964 2 2v4"
|
||||
/>
|
||||
<path
|
||||
strokeWidth={"1.5"}
|
||||
d="M2.99 11.98a9 9 0 0 0 9 9m9 -9a9 9 0 0 0 -9 -9"
|
||||
/>
|
||||
<path strokeWidth={"1.5"} d="M8 7h-4" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div className={"text-sm"}>
|
||||
<p
|
||||
className={
|
||||
"text-base font-semibold leading-tight text-neutral-800"
|
||||
}
|
||||
>
|
||||
{r.name}
|
||||
</p>
|
||||
<p className={"text-neutral-500"}>
|
||||
Runs after{" "}
|
||||
{r.events
|
||||
.filter(
|
||||
(e) =>
|
||||
action.events.filter(
|
||||
(a: { id: string }) => a.id === e.id,
|
||||
).length > 0,
|
||||
)
|
||||
.map((e) => e.name)}{" "}
|
||||
and{" "}
|
||||
{
|
||||
r.events.filter((e) => {
|
||||
return (
|
||||
action.events.filter(
|
||||
(a: { id: string }) => a.id === e.id,
|
||||
).length === 0
|
||||
);
|
||||
}).length
|
||||
}{" "}
|
||||
other events
|
||||
</p>
|
||||
<div className={"mt-1"}>
|
||||
{r.delay === action.delay ? (
|
||||
<Badge type={"info"}>Same delay</Badge>
|
||||
) : r.delay > action.delay ? (
|
||||
<Badge type={"info"}>
|
||||
{`${dayjs.duration(r.delay - action.delay, "minutes").humanize()} after this action`}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge type={"info"}>
|
||||
{`${dayjs.duration(action.delay - r.delay, "minutes").humanize()} before this action`}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className={"sm:col-span-3"}>
|
||||
<Empty
|
||||
title={"No related actions"}
|
||||
description={"Easy access to all actions that share events"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import dayjs from "dayjs";
|
||||
import { motion } from "framer-motion";
|
||||
import { Plus, Workflow } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Empty,
|
||||
FullscreenLoader,
|
||||
Skeleton,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActions } from "../../lib/hooks/actions";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const { data: actions } = useActions();
|
||||
|
||||
if (!project) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
{actions?.length === 0 && (
|
||||
<Alert type={"info"} title={"Need a hand?"}>
|
||||
<div className={"mt-3 grid items-center sm:grid-cols-4"}>
|
||||
<p className={"sm:col-span-3"}>
|
||||
Want us to help you get started? We can help you build your
|
||||
first action in less than 5 minutes.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href={"/onboarding/actions"}
|
||||
className={
|
||||
"inline-block rounded bg-neutral-800 px-6 py-2 text-center text-sm font-medium text-white sm:col-span-1"
|
||||
}
|
||||
>
|
||||
Build an action
|
||||
</Link>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={"Actions"}
|
||||
description={
|
||||
"Repeatable automations that can be triggered by your applications"
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Link href={"actions/new"} passHref>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-center gap-x-1 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<Plus strokeWidth={1.5} size={18} />
|
||||
New
|
||||
</motion.button>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{actions ? (
|
||||
actions.length > 0 ? (
|
||||
<>
|
||||
<div className={"grid grid-cols-1 gap-6 sm:grid-cols-2"}>
|
||||
{actions
|
||||
.sort((a, b) => {
|
||||
if (a.name < b.name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name > b.name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((a) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="col-span-1 divide-y divide-neutral-200 rounded border border-neutral-200 bg-white"
|
||||
key={a.id}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between space-x-6 p-6">
|
||||
<span className="inline-flex rounded bg-neutral-100 p-3 text-neutral-800 ring-4 ring-white">
|
||||
<Workflow size={20} />
|
||||
</span>
|
||||
<div className="flex-1 truncate">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="truncate text-lg font-bold text-neutral-800">
|
||||
{a.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={"mb-6"}>
|
||||
<h2
|
||||
className={
|
||||
"text col-span-2 truncate font-semibold text-neutral-700"
|
||||
}
|
||||
>
|
||||
Quick stats
|
||||
</h2>
|
||||
<div className={"grid grid-cols-2 gap-3"}>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Total triggers
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{a.triggers.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Last activity
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{a.triggers.length > 0
|
||||
? "Last triggered"
|
||||
: "Created"}{" "}
|
||||
{dayjs()
|
||||
.to(
|
||||
a.triggers.length > 0
|
||||
? a.triggers.sort((a, b) => {
|
||||
return a.createdAt >
|
||||
b.createdAt
|
||||
? -1
|
||||
: 1;
|
||||
})[0].createdAt
|
||||
: a.createdAt,
|
||||
)
|
||||
.toString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Open rate
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{a.emails.length > 0
|
||||
? Math.round(
|
||||
(a.emails.filter(
|
||||
(e) => e.status === "OPENED",
|
||||
).length /
|
||||
a.emails.length) *
|
||||
100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</p>
|
||||
</div>
|
||||
{a.delay > 0 && (
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Emails in queue
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
{a.tasks.length}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"my-4"}>
|
||||
<h2
|
||||
className={
|
||||
"col-span-2 truncate font-semibold text-neutral-700"
|
||||
}
|
||||
>
|
||||
Properties
|
||||
</h2>
|
||||
<div className={"grid grid-cols-2 gap-3"}>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Repeats
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
<Badge
|
||||
type={a.runOnce ? "success" : "info"}
|
||||
>
|
||||
{a.runOnce
|
||||
? "Runs once per user"
|
||||
: "Recurring"}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
className={
|
||||
"text-xs font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
Delay
|
||||
</label>
|
||||
<p className="mt-1 truncate text-sm text-neutral-500">
|
||||
<Badge
|
||||
type={
|
||||
a.delay === 0 ? "info" : "success"
|
||||
}
|
||||
>
|
||||
{a.delay === 0
|
||||
? "Instant"
|
||||
: a.delay % 1440 === 0
|
||||
? `${a.delay / 1440} day delay`
|
||||
: a.delay % 60 === 0
|
||||
? `${a.delay / 60} hour delay`
|
||||
: `${a.delay} minute delay`}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="-mt-px flex divide-x divide-neutral-200">
|
||||
<div className="flex w-0 flex-1">
|
||||
<Link
|
||||
href={`/actions/${a.id}`}
|
||||
passHref
|
||||
className="relative inline-flex w-0 flex-1 items-center justify-center rounded-bl rounded-br py-4 text-sm font-medium text-neutral-800 transition hover:bg-neutral-50 hover:text-neutral-700"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M4.75 19.25L9 18.25L18.2929 8.95711C18.6834 8.56658 18.6834 7.93342 18.2929 7.54289L16.4571 5.70711C16.0666 5.31658 15.4334 5.31658 15.0429 5.70711L5.75 15L4.75 19.25Z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M19.25 19.25H13.75"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span className="ml-3">Edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Empty
|
||||
title={"No actions here"}
|
||||
description={"Set up a new automation in a few clicks"}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<Skeleton type={"table"} />
|
||||
)}
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ActionSchemas } from "@plunk/shared";
|
||||
import type { Template } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { type FieldError, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
Dropdown,
|
||||
FullscreenLoader,
|
||||
Input,
|
||||
MultiselectDropdown,
|
||||
Toggle,
|
||||
} from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActions } from "../../lib/hooks/actions";
|
||||
import { useEvents } from "../../lib/hooks/events";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
import { useTemplates } from "../../lib/hooks/templates";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
interface ActionValues {
|
||||
name: string;
|
||||
runOnce: boolean;
|
||||
delay: number;
|
||||
template: string;
|
||||
events: string[];
|
||||
notevents: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const { mutate } = useActions();
|
||||
const { data: templates } = useTemplates();
|
||||
const { data: events } = useEvents();
|
||||
const router = useRouter();
|
||||
|
||||
const [delay, setDelay] = useState<{
|
||||
delay: number;
|
||||
unit: "MINUTES" | "HOURS" | "DAYS";
|
||||
}>({
|
||||
delay: 0,
|
||||
unit: "MINUTES",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
switch (delay.unit) {
|
||||
case "MINUTES":
|
||||
setValue("delay", delay.delay);
|
||||
break;
|
||||
case "HOURS":
|
||||
setValue("delay", delay.delay * 60);
|
||||
break;
|
||||
case "DAYS":
|
||||
setValue("delay", delay.delay * 24 * 60);
|
||||
break;
|
||||
}
|
||||
}, [delay]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<ActionValues>({
|
||||
resolver: zodResolver(ActionSchemas.create),
|
||||
defaultValues: {
|
||||
template: "No template selected",
|
||||
events: [],
|
||||
notevents: [],
|
||||
runOnce: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project || !templates || !events) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const create = async (data: ActionValues) => {
|
||||
toast.promise(
|
||||
network.mock<Template, typeof ActionSchemas.create>(
|
||||
project.secret,
|
||||
"POST",
|
||||
"/v1/actions",
|
||||
{
|
||||
...data,
|
||||
},
|
||||
),
|
||||
{
|
||||
loading: "Creating new action",
|
||||
success: () => {
|
||||
void mutate();
|
||||
return "Created new action";
|
||||
},
|
||||
error: "Could not create new action!",
|
||||
},
|
||||
);
|
||||
|
||||
await router.push("/actions");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<Card title={"Create a new action"}>
|
||||
<form
|
||||
onSubmit={handleSubmit(create)}
|
||||
className="mx-auto my-3 max-w-xl space-y-6"
|
||||
>
|
||||
<Input
|
||||
label={"Name"}
|
||||
placeholder={"Onboarding Flow"}
|
||||
register={register("name")}
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"events"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Run on triggers
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => setValue("events", e)}
|
||||
values={events
|
||||
.filter(
|
||||
(e) => !e.campaignId && !watch("notevents").includes(e.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.templateId && !b.templateId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!a.templateId && b.templateId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name === "unsubscribe" || a.name === "subscribe") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name === "unsubscribe" || b.name === "subscribe") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag: e.templateId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: e.name === "unsubscribe" || e.name === "subscribe"
|
||||
? "Automated"
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
selectedValues={watch("events")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.events as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.events as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"events"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Exclude contacts with triggers
|
||||
</label>
|
||||
<MultiselectDropdown
|
||||
onChange={(e) => setValue("notevents", e)}
|
||||
values={events
|
||||
.filter(
|
||||
(e) => !e.campaignId && !watch("events").includes(e.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.templateId && !b.templateId) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!a.templateId && b.templateId) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (a.name === "unsubscribe" || a.name === "subscribe") {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (b.name === "unsubscribe" || b.name === "subscribe") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (
|
||||
a.name.includes("delivered") &&
|
||||
!b.name.includes("delivered")
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
})
|
||||
.map((e) => {
|
||||
return {
|
||||
name: e.name,
|
||||
value: e.id,
|
||||
tag: e.templateId
|
||||
? e.name.includes("opened")
|
||||
? "On Open"
|
||||
: "On Delivery"
|
||||
: e.name === "unsubscribe" || e.name === "subscribe"
|
||||
? "Automated"
|
||||
: undefined,
|
||||
};
|
||||
})}
|
||||
selectedValues={watch("notevents")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{(errors.notevents as FieldError | undefined)?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{(errors.notevents as FieldError | undefined)?.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"template"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Template
|
||||
</label>
|
||||
<Dropdown
|
||||
onChange={(t) => setValue("template", t)}
|
||||
values={templates.map((t) => {
|
||||
return { name: t.subject, value: t.id };
|
||||
})}
|
||||
selectedValue={watch("template")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errors.template?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.template.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"template"}
|
||||
className="block text-sm font-medium text-neutral-800"
|
||||
>
|
||||
Delay before sending
|
||||
</label>
|
||||
<div className={"grid grid-cols-6 gap-4"}>
|
||||
<div className={"col-span-2 mt-1"}>
|
||||
<input
|
||||
type={"number"}
|
||||
autoComplete={"off"}
|
||||
min={0}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder={"0"}
|
||||
value={delay.delay}
|
||||
onChange={(e) =>
|
||||
setDelay({
|
||||
...delay,
|
||||
delay: Number.parseInt(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={"col-span-4"}>
|
||||
<Dropdown
|
||||
onChange={(t) =>
|
||||
setDelay({
|
||||
...delay,
|
||||
unit: t as "MINUTES" | "HOURS" | "DAYS",
|
||||
})
|
||||
}
|
||||
values={[
|
||||
{ name: "Minutes", value: "MINUTES" },
|
||||
{ name: "Hours", value: "HOURS" },
|
||||
{ name: "Days", value: "DAYS" },
|
||||
]}
|
||||
selectedValue={delay.unit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Toggle
|
||||
title={"Run once"}
|
||||
description={
|
||||
"Toggle this on if you want to run this action only once per contact."
|
||||
}
|
||||
toggled={watch("runOnce")}
|
||||
onToggle={() => setValue("runOnce", !watch("runOnce"))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={"flex justify-end gap-3"}>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
return router.push("/actions");
|
||||
}}
|
||||
className={
|
||||
"flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
className={
|
||||
"flex items-center gap-x-0.5 rounded bg-neutral-800 px-8 py-2 text-center text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M12 5.75V18.25"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M18.25 12L5.75 12"
|
||||
/>
|
||||
</svg>
|
||||
Create
|
||||
</motion.button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {useActiveProject} from '../../lib/hooks/projects';
|
||||
import {useAnalytics} from '../../lib/hooks/analytics';
|
||||
import {AnalyticsTabs, Card, FullscreenLoader} from '../../components';
|
||||
import React from 'react';
|
||||
import {Dashboard} from '../../layouts';
|
||||
import {Ring} from '@uiball/loaders';
|
||||
import {Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis} from 'recharts';
|
||||
import {valueFormatter} from './index';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const {data: analytics} = useAnalytics();
|
||||
|
||||
if (!project) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<AnalyticsTabs />
|
||||
|
||||
<div className={'grid grid-cols-2 gap-6'}>
|
||||
<Card title={'Clicks'} description={'Last 7 days'} className={'sm:col-span-2'}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
height={300}
|
||||
data={analytics.clicks.actions}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="4 5" />
|
||||
|
||||
<YAxis axisLine={false} fill={'#fff'} tick={<></>} tickSize={0} width={5} />
|
||||
|
||||
<XAxis
|
||||
tickSize={0}
|
||||
stroke={'#fff'}
|
||||
interval={0}
|
||||
dataKey="link"
|
||||
tick={({x, y, payload}) => {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text x={0} y={0} dy={16} fill={'#666'} textAnchor={'middle'} className="text-xs">
|
||||
{payload.value.length > 5 ? `${payload.value.substring(0, 20)}...` : payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
// Ease in out animation
|
||||
cursor={{fill: '#f5f5f5', opacity: '0.5'}}
|
||||
content={({active, payload, label}) => {
|
||||
if (active && payload?.length) {
|
||||
const dataPoint = payload[0];
|
||||
return (
|
||||
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
|
||||
<p className="font-medium text-neutral-800">{`${label}`}</p>
|
||||
<p className="text-neutral-600">{valueFormatter(dataPoint.value as number)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Bar dataKey="count" stackId="a" fill={'#3b82f6'} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={'flex h-[300px] items-center justify-center'}>
|
||||
<Ring size={32} color={'#a3a3a3'} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import { AnalyticsTabs, Card, FullscreenLoader } from "../../components";
|
||||
import { Dashboard } from "../../layouts";
|
||||
import { useActiveProject } from "../../lib/hooks/projects";
|
||||
|
||||
import { Ring } from "@uiball/loaders";
|
||||
import dayjs from "dayjs";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import React from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useAnalytics } from "../../lib/hooks/analytics";
|
||||
|
||||
export const valueFormatter = (number: number) => {
|
||||
if (number > 999999999) {
|
||||
return `${Intl.NumberFormat("us")
|
||||
.format(number / 1000000000)
|
||||
.toString()}B`;
|
||||
}
|
||||
|
||||
if (number > 999999) {
|
||||
return `${Intl.NumberFormat("us")
|
||||
.format(number / 1000000)
|
||||
.toString()}M`;
|
||||
}
|
||||
|
||||
if (number > 999) {
|
||||
return `${Intl.NumberFormat("us")
|
||||
.format(number / 1000)
|
||||
.toString()}K`;
|
||||
}
|
||||
|
||||
return Intl.NumberFormat("us").format(number).toString();
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const project = useActiveProject();
|
||||
const { data: analytics } = useAnalytics();
|
||||
|
||||
if (!project) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dashboard>
|
||||
<AnalyticsTabs />
|
||||
|
||||
<div className={"grid grid-cols-2 gap-6"}>
|
||||
<Card>
|
||||
{analytics ? (
|
||||
<div className={"flex items-center"}>
|
||||
<div>
|
||||
<p className={"font-medium text-neutral-600"}>Bounce Rate</p>
|
||||
<p className={"text-2xl font-semibold text-neutral-800"}>
|
||||
<>
|
||||
{(
|
||||
(analytics.emails.bounced / analytics.emails.total) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
<div className={"flex flex-1 justify-end"}>
|
||||
{analytics.emails.bounced / analytics.emails.total >
|
||||
analytics.emails.bouncedPrev / analytics.emails.totalPrev ? (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-1 text-sm font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
{Number.isNaN(
|
||||
(analytics.emails.bounced / analytics.emails.total -
|
||||
analytics.emails.bouncedPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100,
|
||||
)
|
||||
? 0
|
||||
: (
|
||||
(analytics.emails.bounced /
|
||||
analytics.emails.total -
|
||||
analytics.emails.bouncedPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
<ArrowUp className={"text-red-400"} size={24} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-1 text-sm font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
{Number.isNaN(
|
||||
(analytics.emails.bounced / analytics.emails.total -
|
||||
analytics.emails.bouncedPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100,
|
||||
)
|
||||
? 0
|
||||
: (
|
||||
(analytics.emails.bounced /
|
||||
analytics.emails.total -
|
||||
analytics.emails.bouncedPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
<ArrowDown className={"text-green-400"} size={24} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[55px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card>
|
||||
{analytics ? (
|
||||
<div className={"flex items-center"}>
|
||||
<div>
|
||||
<p className={"font-medium text-neutral-600"}>Spam Rate</p>
|
||||
<p className={"text-2xl font-semibold text-neutral-800"}>
|
||||
<>
|
||||
{(
|
||||
(analytics.emails.complaint / analytics.emails.total) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
</>
|
||||
</p>
|
||||
</div>
|
||||
<div className={"flex flex-1 justify-end"}>
|
||||
{analytics.emails.complaint / analytics.emails.total >
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev ? (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-1 text-sm font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
{Number.isNaN(
|
||||
(analytics.emails.complaint / analytics.emails.total -
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100,
|
||||
)
|
||||
? 0
|
||||
: (
|
||||
(analytics.emails.complaint /
|
||||
analytics.emails.total -
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
<ArrowUp className={"text-red-400"} size={24} />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={
|
||||
"flex items-center gap-1 text-sm font-medium text-neutral-500"
|
||||
}
|
||||
>
|
||||
{Number.isNaN(
|
||||
(analytics.emails.complaint / analytics.emails.total -
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100,
|
||||
)
|
||||
? 0
|
||||
: (
|
||||
(analytics.emails.complaint /
|
||||
analytics.emails.total -
|
||||
analytics.emails.complaintPrev /
|
||||
analytics.emails.totalPrev) *
|
||||
100
|
||||
).toFixed(2)}
|
||||
%
|
||||
<ArrowDown className={"text-green-400"} size={24} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[55px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card title={"Contacts"} className={"col-span-2"}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<ResponsiveContainer width={"100%"} height={300}>
|
||||
<AreaChart
|
||||
width={500}
|
||||
height={300}
|
||||
data={analytics.contacts.timeseries
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(a.day).getTime() - new Date(b.day).getTime()
|
||||
);
|
||||
})
|
||||
.map((i) => {
|
||||
return {
|
||||
day: dayjs(i.day).format("MMM DD"),
|
||||
count: i.count,
|
||||
};
|
||||
})}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 20,
|
||||
left: 20,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="4 5" />
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="gradientFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#2563eb"
|
||||
stopOpacity={0.4}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="#93c5fd"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
domain={[
|
||||
0,
|
||||
analytics.contacts.timeseries.length === 0
|
||||
? 10
|
||||
: analytics.contacts.timeseries[
|
||||
analytics.contacts.timeseries.length - 1
|
||||
].count * 1.1,
|
||||
]}
|
||||
fill={"#fff"}
|
||||
tickSize={0}
|
||||
width={5}
|
||||
interval={0}
|
||||
/>
|
||||
|
||||
<XAxis
|
||||
tickSize={0}
|
||||
stroke={"#fff"}
|
||||
dataKey="day"
|
||||
interval={3}
|
||||
tick={({ x, y, payload }) => {
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={16}
|
||||
fill={"#666"}
|
||||
textAnchor={"middle"}
|
||||
className="text-sm" // Add your custom class name here
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload?.length) {
|
||||
// Customize the tooltip content here
|
||||
const dataPoint = payload[0];
|
||||
return (
|
||||
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
|
||||
<p className="font-medium text-neutral-800">{`${label}`}</p>
|
||||
<p className="text-neutral-600">
|
||||
{valueFormatter(dataPoint.value as number)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Area
|
||||
type="basis"
|
||||
dataKey="count"
|
||||
stroke="#2563eb"
|
||||
fill="url(#gradientFill)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[300px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card title={"Retention Rate"}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart width={300} height={300}>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
// Customize the tooltip content here
|
||||
const dataPoint = payload[0];
|
||||
return (
|
||||
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
|
||||
<p className="font-medium text-neutral-800">{`${dataPoint.name}`}</p>
|
||||
<p className="text-neutral-600">
|
||||
{valueFormatter(dataPoint.value as number)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Pie
|
||||
data={[
|
||||
{
|
||||
name: "Subscribed",
|
||||
value: analytics.contacts.subscribed,
|
||||
},
|
||||
{
|
||||
name: "Unsubscribed",
|
||||
value: analytics.contacts.unsubscribed,
|
||||
},
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}) => {
|
||||
const radius =
|
||||
innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x =
|
||||
cx + radius * Math.cos((-midAngle * Math.PI) / 180);
|
||||
const y =
|
||||
cy + radius * Math.sin((-midAngle * Math.PI) / 180);
|
||||
|
||||
if (percent < 0.1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={percent > 0.5 ? "white" : "#666"}
|
||||
className={"text-sm font-semibold"}
|
||||
textAnchor={x > cx ? "start" : "middle"}
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
outerRadius={90}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{[
|
||||
{
|
||||
name: "Subscribed",
|
||||
value: analytics.contacts.subscribed,
|
||||
},
|
||||
{
|
||||
name: "Unsubscribed",
|
||||
value: analytics.contacts.unsubscribed,
|
||||
},
|
||||
].map((entry, index) => (
|
||||
<Cell
|
||||
style={{ outline: "none" }}
|
||||
key={`cell-${entry.name}`}
|
||||
fill={["#3b82f6", "#e5e5e5"][index % 2]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[200px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
<Card title={"Open rate"}>
|
||||
{analytics ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<PieChart width={300} height={300}>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload?.length) {
|
||||
// Customize the tooltip content here
|
||||
const dataPoint = payload[0];
|
||||
return (
|
||||
<div className="rounded border border-neutral-100 bg-white px-5 py-3 shadow-sm">
|
||||
<p className="font-medium text-neutral-800">{`${dataPoint.name}`}</p>
|
||||
<p className="text-neutral-600">
|
||||
{valueFormatter(dataPoint.value as number)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<Pie
|
||||
data={[
|
||||
{ name: "Opened", value: analytics.emails.opened },
|
||||
|
||||
{
|
||||
name: "Unopened",
|
||||
value:
|
||||
analytics.emails.total -
|
||||
analytics.emails.opened -
|
||||
analytics.emails.bounced -
|
||||
analytics.emails.complaint,
|
||||
},
|
||||
]}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}) => {
|
||||
const radius =
|
||||
innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x =
|
||||
cx + radius * Math.cos((-midAngle * Math.PI) / 180);
|
||||
const y =
|
||||
cy + radius * Math.sin((-midAngle * Math.PI) / 180);
|
||||
|
||||
if (percent < 0.1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={percent > 0.5 ? "white" : "#666"}
|
||||
className={"text-sm font-semibold"}
|
||||
textAnchor={x > cx ? "start" : "middle"}
|
||||
dominantBaseline="central"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
outerRadius={90}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{[
|
||||
{ name: "Opened", value: analytics.emails.opened },
|
||||
|
||||
{
|
||||
name: "Unopened",
|
||||
value:
|
||||
analytics.emails.total -
|
||||
analytics.emails.opened -
|
||||
analytics.emails.bounced -
|
||||
analytics.emails.complaint,
|
||||
},
|
||||
].map((entry, index) => (
|
||||
<Cell
|
||||
style={{ outline: "none" }}
|
||||
key={`cell-${entry.name}`}
|
||||
fill={["#3b82f6", "#e5e5e5"][index % 2]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"flex h-[200px] items-center justify-center"}>
|
||||
<Ring size={32} color={"#a3a3a3"} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</Dashboard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { UserSchemas } from "@plunk/shared";
|
||||
import type { User } from "@prisma/client";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import logo from "../../../public/assets/logo.png";
|
||||
import { FullscreenLoader, Redirect } from "../../components";
|
||||
import { useUser } from "../../lib/hooks/users";
|
||||
import { network } from "../../lib/network";
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
interface AuthValues {
|
||||
password: string;
|
||||
email: string;
|
||||
auth: string;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: user, error, mutate } = useUser();
|
||||
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [hidePassword, setHidePassword] = useState(true);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
} = useForm<AuthValues>({
|
||||
resolver: zodResolver(UserSchemas.credentials),
|
||||
});
|
||||
|
||||
if (user && !error) {
|
||||
return <Redirect to={"/"} />;
|
||||
}
|
||||
|
||||
if (!user && !error) {
|
||||
return <FullscreenLoader />;
|
||||
}
|
||||
|
||||
const login = async (data: AuthValues) => {
|
||||
setSubmitted(true);
|
||||
const result = await network.fetch<
|
||||
| {
|
||||
success: true;
|
||||
data: User;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
data: string;
|
||||
}
|
||||
| {
|
||||
success: "redirect";
|
||||
redirect: string;
|
||||
},
|
||||
typeof UserSchemas.credentials
|
||||
>("POST", "/auth/login", {
|
||||
...data,
|
||||
});
|
||||
|
||||
if (result.success === "redirect") {
|
||||
return router.push(result.redirect);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
await mutate(result.data);
|
||||
|
||||
return router.push("/");
|
||||
}
|
||||
setError("auth", { message: result.data });
|
||||
|
||||
setSubmitted(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-off-white flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col items-center sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<Image
|
||||
src={logo}
|
||||
placeholder={"blur"}
|
||||
width={35}
|
||||
height={35}
|
||||
alt={"Plunk Logo"}
|
||||
/>
|
||||
<h2 className="mt-4 text-center text-3xl font-bold text-neutral-800">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="rounded border border-neutral-200 bg-white px-4 py-8 sm:px-10">
|
||||
<form onSubmit={handleSubmit(login)} className="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"email"}
|
||||
className="block text-sm font-medium text-neutral-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type={"email"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
autoComplete={"email"}
|
||||
placeholder={"[email protected]"}
|
||||
{...register("email")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errors.email?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.email.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={"password"}
|
||||
className="block text-sm font-semibold text-neutral-600"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative mt-1">
|
||||
<input
|
||||
type={hidePassword ? "password" : "text"}
|
||||
placeholder={hidePassword ? "•••••••••••••" : "Password"}
|
||||
autoComplete={"current-password"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
{...register("password")}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3">
|
||||
<svg
|
||||
onClick={() => setHidePassword(!hidePassword)}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{hidePassword ? (
|
||||
<>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errors.password?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
Password must be at least 6 characters long
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="submit"
|
||||
className={
|
||||
"flex w-full items-center justify-center rounded-md bg-neutral-800 py-2.5 text-sm font-medium text-white"
|
||||
}
|
||||
>
|
||||
{submitted ? (
|
||||
<svg
|
||||
className="-ml-1 mr-3 h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</motion.button>
|
||||
<AnimatePresence>
|
||||
{errors.auth?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errors.auth.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className={"w-full text-center"}>
|
||||
<Link
|
||||
href={"/auth/signup"}
|
||||
passHref
|
||||
className={
|
||||
"text-sm text-neutral-500 underline transition ease-in-out hover:text-neutral-500"
|
||||
}
|
||||
>
|
||||
Want to create an account instead?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||