react-email 3.0.7 → 4.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/index.js +10 -4
  3. package/dist/cli/index.mjs +9 -3
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +14 -12
  6. package/dist/preview/.next/build-manifest.json +3 -3
  7. package/dist/preview/.next/cache/.rscinfo +1 -1
  8. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  9. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  13. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  14. package/dist/preview/.next/next-server.js.nft.json +1 -1
  15. package/dist/preview/.next/prerender-manifest.json +1 -1
  16. package/dist/preview/.next/required-server-files.json +1 -1
  17. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  18. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  19. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  20. package/dist/preview/.next/server/app/page.js +1 -1
  21. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  22. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  23. package/dist/preview/.next/server/app/preview/[...slug]/page.js +5 -5
  24. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  25. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  26. package/dist/preview/.next/server/chunks/196.js +1 -1
  27. package/dist/preview/.next/server/chunks/282.js +15 -0
  28. package/dist/preview/.next/server/chunks/667.js +1 -0
  29. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  30. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  31. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  32. package/dist/preview/.next/server/pages/500.html +1 -1
  33. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  34. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  35. package/dist/preview/.next/server/webpack-runtime.js +1 -1
  36. package/dist/preview/.next/static/chunks/207-7ab46c2d84f60fed.js +1 -0
  37. package/dist/preview/.next/static/chunks/490-9a10c001ec2dffb2.js +1 -0
  38. package/dist/preview/.next/static/chunks/afa401a5-9ebf2515b1397993.js +6 -0
  39. package/dist/preview/.next/static/chunks/app/layout-f1bad3fcfbc7eb6b.js +1 -0
  40. package/dist/preview/.next/static/chunks/app/page-800163ba6c6d943d.js +1 -0
  41. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-5b5c4557fc89db64.js +1 -0
  42. package/dist/preview/.next/static/chunks/{main-app-771a0fc4ad5aa154.js → main-app-d1b0aa870bcfb13e.js} +1 -1
  43. package/dist/preview/.next/static/css/d6c4def4cc3fb858.css +3 -0
  44. package/dist/preview/.next/trace +22 -21
  45. package/dist/preview/.next/types/app/layout.ts +1 -1
  46. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  47. package/package.json +8 -2
  48. package/src/actions/email-validation/check-images.spec.tsx +90 -0
  49. package/src/actions/email-validation/check-images.ts +142 -0
  50. package/src/actions/email-validation/check-links.spec.tsx +92 -0
  51. package/src/actions/email-validation/check-links.ts +91 -0
  52. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +22 -0
  53. package/src/actions/email-validation/get-line-and-column-from-index.ts +43 -0
  54. package/src/actions/email-validation/quick-fetch.ts +12 -0
  55. package/src/animated-icons-data/help.json +1082 -0
  56. package/src/animated-icons-data/link.json +1309 -0
  57. package/src/animated-icons-data/load.json +443 -0
  58. package/src/animated-icons-data/mail.json +1320 -0
  59. package/src/app/globals.css +0 -24
  60. package/src/app/layout.tsx +6 -2
  61. package/src/app/page.tsx +8 -9
  62. package/src/app/preview/[...slug]/page.tsx +1 -0
  63. package/src/app/preview/[...slug]/preview.tsx +3 -3
  64. package/src/app/preview/[...slug]/rendering-error.tsx +6 -6
  65. package/src/components/button.tsx +53 -42
  66. package/src/components/code-container.tsx +6 -6
  67. package/src/components/code-snippet.tsx +11 -0
  68. package/src/components/code.tsx +4 -4
  69. package/src/components/icons/icon-button.tsx +1 -1
  70. package/src/components/icons/icon-circle-check.tsx +21 -0
  71. package/src/components/icons/icon-circle-close.tsx +17 -0
  72. package/src/components/icons/icon-circle-warning.tsx +17 -0
  73. package/src/components/icons/icon-email.tsx +18 -0
  74. package/src/components/icons/icon-image.tsx +19 -0
  75. package/src/components/icons/icon-link.tsx +14 -0
  76. package/src/components/icons/icon-stamp.tsx +14 -0
  77. package/src/components/send.tsx +9 -9
  78. package/src/components/shell.tsx +32 -34
  79. package/src/components/sidebar/checking-results.tsx +150 -0
  80. package/src/components/sidebar/{sidebar-directory-children.tsx → file-tree-directory-children.tsx} +19 -15
  81. package/src/components/sidebar/{sidebar-directory.tsx → file-tree-directory.tsx} +9 -10
  82. package/src/components/sidebar/file-tree.tsx +31 -0
  83. package/src/components/sidebar/image-checker.tsx +161 -0
  84. package/src/components/sidebar/link-checker.tsx +151 -0
  85. package/src/components/sidebar/sidebar.tsx +344 -22
  86. package/src/components/tooltip-content.tsx +2 -2
  87. package/src/components/topbar.tsx +13 -16
  88. package/src/hooks/use-icon-animation.ts +41 -0
  89. package/tsconfig.json +1 -0
  90. package/dist/preview/.next/server/chunks/693.js +0 -1
  91. package/dist/preview/.next/server/chunks/720.js +0 -10
  92. package/dist/preview/.next/static/chunks/12-b9450aa0845e7574.js +0 -1
  93. package/dist/preview/.next/static/chunks/154-f7f86c8589140c56.js +0 -1
  94. package/dist/preview/.next/static/chunks/app/layout-6d33e2ffcffd58d4.js +0 -1
  95. package/dist/preview/.next/static/chunks/app/page-43a07e4b8c5c0840.js +0 -1
  96. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-71202390d5f9a34b.js +0 -1
  97. package/dist/preview/.next/static/css/a34876a6c565fff8.css +0 -3
  98. /package/dist/preview/.next/static/{RZga3-2qKYa2RLg-hxunV → Mn2FuRztLqr32yO8CKHi9}/_buildManifest.js +0 -0
  99. /package/dist/preview/.next/static/{RZga3-2qKYa2RLg-hxunV → Mn2FuRztLqr32yO8CKHi9}/_ssgManifest.js +0 -0
@@ -1,4 +1,5 @@
1
1
  'use client';
2
+
2
3
  import * as React from 'react';
3
4
  import { cn } from '../utils';
4
5
  import { Logo } from './logo';
@@ -27,14 +28,13 @@ export const Shell = ({
27
28
  const [triggerTransition, setTriggerTransition] = React.useState(false);
28
29
 
29
30
  return (
30
- <div className="flex bg-black text-white flex-col h-screen overflow-x-hidden">
31
- <div className="flex lg:hidden items-center px-6 justify-between h-[70px] border-b border-slate-6">
32
- <div className="h-[70px] flex items-center">
31
+ <>
32
+ <div className="flex h-[4.375rem] items-center justify-between border-slate-6 border-b px-6 lg:hidden">
33
+ <div className="flex h-[4.375rem] items-center">
33
34
  <Logo />
34
35
  </div>
35
-
36
36
  <button
37
- className="h-6 w-6 rounded flex items-center justify-center text-white"
37
+ className="flex h-6 w-6 items-center justify-center rounded text-white"
38
38
  onClick={() => {
39
39
  setSidebarToggled((v) => !v);
40
40
  }}
@@ -48,6 +48,7 @@ export const Shell = ({
48
48
  width="16"
49
49
  xmlns="http://www.w3.org/2000/svg"
50
50
  >
51
+ <title>Menu</title>
51
52
  <path
52
53
  clipRule="evenodd"
53
54
  d="M1.5 3C1.22386 3 1 3.22386 1 3.5C1 3.77614 1.22386 4 1.5 4H13.5C13.7761 4 14 3.77614 14 3.5C14 3.22386 13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5 8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761 13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z"
@@ -57,36 +58,34 @@ export const Shell = ({
57
58
  </svg>
58
59
  </button>
59
60
  </div>
60
-
61
- <div className="flex bg-slate-2">
61
+ <React.Suspense>
62
62
  <Sidebar
63
- className={cn(
64
- 'w-screen max-w-full bg-black h-screen lg:h-auto z-50 lg:z-auto lg:max-w-[275px] fixed top-[70px] lg:top-0 left-0',
65
- {
66
- 'translate-x-0 lg:-translate-x-full': sidebarToggled,
67
- '-translate-x-full lg:translate-x-0': !sidebarToggled,
68
- },
69
- )}
63
+ className={cn({
64
+ 'lg:-translate-x-full translate-x-0': sidebarToggled,
65
+ '-translate-x-full lg:translate-x-0': !sidebarToggled,
66
+ })}
70
67
  currentEmailOpenSlug={currentEmailOpenSlug}
68
+ markup={markup}
71
69
  style={{
72
70
  transition: triggerTransition ? 'transform 0.2s ease-in-out' : '',
73
71
  }}
74
72
  />
75
-
76
- <main
77
- className={cn(
78
- 'absolute will-change-width h-screen w-[100vw] right-0',
79
- {
80
- 'lg:translate-x-0 lg:w-[calc(100vw)]': sidebarToggled,
81
- 'lg:translate-x-0 lg:w-[calc(100vw-275px)]': !sidebarToggled,
82
- },
83
- )}
84
- style={{
85
- transition: triggerTransition
86
- ? 'width 0.2s ease-in-out, transform 0.2s ease-in-out'
87
- : '',
88
- }}
89
- >
73
+ </React.Suspense>
74
+ <main
75
+ className={cn(
76
+ 'relative h-full max-h-full min-h-screen w-[100vw] overflow-hidden will-change-width sm:mt-[4.375rem] md:absolute md:right-0 lg:mt-0',
77
+ {
78
+ 'lg:w-[calc(100vw)] lg:translate-x-0': sidebarToggled,
79
+ 'lg:w-[calc(100vw-20rem)] lg:translate-x-0': !sidebarToggled,
80
+ },
81
+ )}
82
+ style={{
83
+ transition: triggerTransition
84
+ ? 'width 0.2s ease-in-out, transform 0.2s ease-in-out'
85
+ : '',
86
+ }}
87
+ >
88
+ <div className="relative h-full w-full">
90
89
  {currentEmailOpenSlug && pathSeparator ? (
91
90
  <Topbar
92
91
  activeView={activeView}
@@ -107,12 +106,11 @@ export const Shell = ({
107
106
  setActiveView={setActiveView}
108
107
  />
109
108
  ) : null}
110
-
111
- <div className="h-[calc(100vh_-_70px)] overflow-auto mx-auto">
109
+ <div className="relative mx-auto h-[calc(100vh-3.3125rem)] grow md:h-full">
112
110
  {children}
113
111
  </div>
114
- </main>
115
- </div>
116
- </div>
112
+ </div>
113
+ </main>
114
+ </>
117
115
  );
118
116
  };
@@ -0,0 +1,150 @@
1
+ import * as Collapsible from '@radix-ui/react-collapsible';
2
+ import { AnimatePresence, motion } from 'framer-motion';
3
+ import type { ComponentProps } from 'react';
4
+ import { cn } from '../../utils';
5
+
6
+ export type ResultStatus = 'error' | 'warning' | 'success';
7
+
8
+ const statusStyles = {
9
+ error: 'text-red-600 hover:bg-red-600/10',
10
+ warning: 'text-yellow-300 hover:bg-yellow-400/10',
11
+ success: 'text-green-600 hover:bg-green-600/10',
12
+ };
13
+
14
+ interface ResultListProps {
15
+ status: ResultStatus;
16
+ label: React.ReactNode;
17
+
18
+ disabled?: boolean;
19
+ defaultOpen?: boolean;
20
+
21
+ children: React.ReactNode;
22
+ }
23
+
24
+ export const ResultList = ({
25
+ status,
26
+ label,
27
+
28
+ disabled,
29
+ defaultOpen,
30
+
31
+ children,
32
+ }: ResultListProps) => {
33
+ return (
34
+ <Collapsible.Root className="group" defaultOpen={defaultOpen && !disabled}>
35
+ <Collapsible.Trigger
36
+ className={cn(
37
+ 'group flex w-full items-center gap-1 rounded p-2 transition-colors duration-200 ease-[cubic-bezier(.36,.66,.6,1)]',
38
+ statusStyles[status],
39
+ disabled && 'cursor-not-allowed opacity-70',
40
+ )}
41
+ disabled={disabled}
42
+ >
43
+ <span
44
+ className={cn(
45
+ '-mt-[.125rem] transition-transform duration-200 ease-[cubic-bezier(.36,.66,.6,1)]',
46
+ 'rotate-0 group-data-[state=open]:rotate-90',
47
+ )}
48
+ >
49
+ <svg
50
+ fill="none"
51
+ height="15"
52
+ viewBox="0 0 15 15"
53
+ width="15"
54
+ xmlns="http://www.w3.org/2000/svg"
55
+ >
56
+ <path
57
+ clipRule="evenodd"
58
+ d="M6.1584 3.13508C6.35985 2.94621 6.67627 2.95642 6.86514 3.15788L10.6151 7.15788C10.7954 7.3502 10.7954 7.64949 10.6151 7.84182L6.86514 11.8418C6.67627 12.0433 6.35985 12.0535 6.1584 11.8646C5.95694 11.6757 5.94673 11.3593 6.1356 11.1579L9.565 7.49985L6.1356 3.84182C5.94673 3.64036 5.95694 3.32394 6.1584 3.13508Z"
59
+ fill="currentColor"
60
+ fillRule="evenodd"
61
+ />
62
+ </svg>
63
+ </span>
64
+ <div className="flex flex-1 items-center gap-1 font-bold text-[.625rem] uppercase tracking-wide">
65
+ {label}
66
+ </div>
67
+ </Collapsible.Trigger>
68
+ {children ? (
69
+ <Collapsible.Content>
70
+ <ol className="mt-2 mb-1 flex list-none flex-col gap-4">
71
+ {children}
72
+ </ol>
73
+ </Collapsible.Content>
74
+ ) : null}
75
+ </Collapsible.Root>
76
+ );
77
+ };
78
+
79
+ type ResultProps = {
80
+ status: ResultStatus;
81
+ } & ComponentProps<typeof motion.li>;
82
+
83
+ const resultAnimation = {
84
+ hidden: { opacity: 0, y: 10 },
85
+ visible: {
86
+ opacity: 1,
87
+ y: 0,
88
+ transition: { duration: 0.6, ease: 'easeOut', staggerChildren: 0.1 },
89
+ },
90
+ };
91
+
92
+ export const Result = ({ children, status, ...rest }: ResultProps) => {
93
+ return (
94
+ <AnimatePresence mode="wait">
95
+ <motion.li
96
+ data-status={status}
97
+ initial="hidden"
98
+ layout
99
+ variants={resultAnimation}
100
+ animate="visible"
101
+ {...rest}
102
+ className={cn(
103
+ 'group/item relative w-full rounded-md p-2 pl-4 transition-colors duration-300 ease-out hover:bg-slate-5',
104
+ rest.className,
105
+ )}
106
+ >
107
+ {children}
108
+ </motion.li>
109
+ </AnimatePresence>
110
+ );
111
+ };
112
+
113
+ const titleStatusAnimation = {
114
+ hidden: { opacity: 0, y: 5 },
115
+ visible: {
116
+ opacity: 1,
117
+ y: 0,
118
+ transition: { duration: 0.4, ease: 'easeOut' },
119
+ },
120
+ };
121
+
122
+ interface ResultStatusDescriptionProps {
123
+ children: React.ReactNode;
124
+ }
125
+
126
+ Result.StatusDescription = ({ children }: ResultStatusDescriptionProps) => {
127
+ return (
128
+ <motion.div
129
+ className="mt-1 font-semibold text-[.625rem] uppercase"
130
+ variants={titleStatusAnimation}
131
+ >
132
+ {children}
133
+ </motion.div>
134
+ );
135
+ };
136
+
137
+ interface ResultTitleProps {
138
+ children: React.ReactNode;
139
+ }
140
+
141
+ Result.Title = ({ children }: ResultTitleProps) => {
142
+ return (
143
+ <motion.div
144
+ className="flex w-full items-center gap-2 text-xs group-data-[status=error]/item:text-red-400 group-data-[status=success]/item:text-green-400 group-data-[status=warning]/item:text-yellow-300 "
145
+ variants={titleStatusAnimation}
146
+ >
147
+ {children}
148
+ </motion.div>
149
+ );
150
+ };
@@ -5,9 +5,9 @@ import { useSearchParams } from 'next/navigation';
5
5
  import { cn } from '../../utils';
6
6
  import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata';
7
7
  import { IconFile } from '../icons/icon-file';
8
- import { SidebarDirectory } from './sidebar-directory';
8
+ import { FileTreeDirectory } from './file-tree-directory';
9
9
 
10
- export const SidebarDirectoryChildren = (props: {
10
+ export const FileTreeDirectoryChildren = (props: {
11
11
  emailsDirectoryMetadata: EmailsDirectory;
12
12
  currentEmailOpenSlug?: string;
13
13
  open: boolean;
@@ -29,22 +29,20 @@ export const SidebarDirectoryChildren = (props: {
29
29
  initial={{ opacity: 0, height: 0 }}
30
30
  >
31
31
  {props.isRoot ? null : (
32
- <div className="line absolute left-2.5 w-px h-full bg-slate-6" />
32
+ <div className="line absolute left-2.5 h-full w-px bg-slate-6" />
33
33
  )}
34
-
35
34
  <div className="flex flex-col truncate">
36
35
  <LayoutGroup id="sidebar">
37
36
  {props.emailsDirectoryMetadata.subDirectories.map(
38
37
  (subDirectory) => (
39
- <SidebarDirectory
40
- className="pl-4 py-0"
38
+ <FileTreeDirectory
39
+ className="p-0 data-[state=open]:mb-2"
41
40
  currentEmailOpenSlug={props.currentEmailOpenSlug}
42
41
  emailsDirectoryMetadata={subDirectory}
43
42
  key={subDirectory.absolutePath}
44
43
  />
45
44
  ),
46
45
  )}
47
-
48
46
  {props.emailsDirectoryMetadata.emailFilenames.map(
49
47
  (emailFilename, index) => {
50
48
  const emailSlug = props.isRoot
@@ -78,7 +76,7 @@ export const SidebarDirectoryChildren = (props: {
78
76
  <motion.span
79
77
  animate={{ x: 0, opacity: 1 }}
80
78
  className={cn(
81
- 'text-[14px] flex items-center align-middle pl-3 h-8 max-w-full rounded-md text-slate-11 relative transition-colors',
79
+ 'relative flex h-8 max-w-full items-center rounded-md pl-3 align-middle text-slate-11 text-sm transition-colors duration-100 ease-[cubic-bezier(.6,.12,.34,.96)]',
82
80
  {
83
81
  'text-cyan-11': isCurrentPage,
84
82
  'hover:text-slate-12':
@@ -94,19 +92,25 @@ export const SidebarDirectoryChildren = (props: {
94
92
  {isCurrentPage ? (
95
93
  <motion.span
96
94
  animate={{ opacity: 1 }}
97
- className="absolute left-0 right-0 top-0 bottom-0 rounded-md bg-cyan-5 opacity-0"
95
+ className="absolute inset-0 rounded-md bg-cyan-5 opacity-0 transition-all duration-200 ease-[cubic-bezier(.6,.12,.34,.96)]"
98
96
  exit={{ opacity: 0 }}
99
97
  initial={{ opacity: 0 }}
100
98
  >
101
- {!props.isRoot && (
102
- <div className="bg-cyan-11 w-px absolute top-1 left-1.5 h-6" />
103
- )}
99
+ <motion.div
100
+ className="absolute top-1 left-[.625rem] h-6 w-px rounded-sm bg-cyan-11"
101
+ layoutId="active-file"
102
+ transition={{
103
+ type: 'spring',
104
+ bounce: 0.2,
105
+ duration: 0.6,
106
+ }}
107
+ />
104
108
  </motion.span>
105
109
  ) : null}
106
110
  <IconFile
107
- className="absolute left-4 w-[24px] h-[24px]"
108
- height="24"
109
- width="24"
111
+ className="absolute left-4 h-5 w-5"
112
+ height="20"
113
+ width="20"
110
114
  />
111
115
  <span className="truncate pl-8">{emailFilename}</span>
112
116
  </motion.span>
@@ -7,7 +7,7 @@ import { Heading } from '../heading';
7
7
  import { IconArrowDown } from '../icons/icon-arrow-down';
8
8
  import { IconFolder } from '../icons/icon-folder';
9
9
  import { IconFolderOpen } from '../icons/icon-folder-open';
10
- import { SidebarDirectoryChildren } from './sidebar-directory-children';
10
+ import { FileTreeDirectoryChildren } from './file-tree-directory-children';
11
11
 
12
12
  interface SidebarDirectoryProps {
13
13
  emailsDirectoryMetadata: EmailsDirectory;
@@ -17,7 +17,7 @@ interface SidebarDirectoryProps {
17
17
 
18
18
  const persistedOpenDirectories = new Set<string>();
19
19
 
20
- export const SidebarDirectory = ({
20
+ export const FileTreeDirectory = ({
21
21
  emailsDirectoryMetadata: directoryMetadata,
22
22
  className,
23
23
  currentEmailOpenSlug,
@@ -51,21 +51,21 @@ export const SidebarDirectory = ({
51
51
  >
52
52
  <Collapsible.Trigger
53
53
  className={cn(
54
- 'text-[14px] flex items-center font-medium gap-2 justify-between w-full my-1',
54
+ 'mt-1 mb-1.5 flex w-full items-center justify-between gap-2 font-medium text-[14px]',
55
55
  {
56
56
  'cursor-pointer': !isEmpty,
57
57
  },
58
58
  )}
59
59
  >
60
- <div className="flex items-center text-slate-11 transition ease-in-out duration-200 hover:text-slate-12 gap-1">
60
+ <div className="flex items-center gap-2 text-slate-11 transition duration-200 ease-in-out hover:text-slate-12">
61
61
  {open ? (
62
- <IconFolderOpen height="24" width="24" />
62
+ <IconFolderOpen height="20" width="20" />
63
63
  ) : (
64
- <IconFolder height="24" width="24" />
64
+ <IconFolder height="20" width="20" />
65
65
  )}
66
66
  <Heading
67
67
  as="h3"
68
- className="transition ease-in-out duration-200 hover:text-slate-12"
68
+ className="transition duration-200 ease-in-out hover:text-slate-12"
69
69
  color="gray"
70
70
  size="2"
71
71
  weight="medium"
@@ -75,14 +75,13 @@ export const SidebarDirectory = ({
75
75
  </div>
76
76
  {!isEmpty ? (
77
77
  <IconArrowDown
78
- className="data-[open=true]:rotate-180 transition-transform opacity-60 justify-self-end"
78
+ className="justify-self-end opacity-60 transition-transform data-[open=true]:rotate-180"
79
79
  data-open={open}
80
80
  />
81
81
  ) : null}
82
82
  </Collapsible.Trigger>
83
-
84
83
  {!isEmpty ? (
85
- <SidebarDirectoryChildren
84
+ <FileTreeDirectoryChildren
86
85
  currentEmailOpenSlug={currentEmailOpenSlug}
87
86
  emailsDirectoryMetadata={directoryMetadata}
88
87
  open={open}
@@ -0,0 +1,31 @@
1
+ import * as Collapsible from '@radix-ui/react-collapsible';
2
+ import * as React from 'react';
3
+ import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata';
4
+ import { FileTreeDirectoryChildren } from './file-tree-directory-children';
5
+
6
+ interface FileTreeProps {
7
+ currentEmailOpenSlug: string | undefined;
8
+ emailsDirectoryMetadata: EmailsDirectory;
9
+ }
10
+
11
+ export const FileTree = ({
12
+ currentEmailOpenSlug,
13
+ emailsDirectoryMetadata,
14
+ }: FileTreeProps) => {
15
+ return (
16
+ <div className="flex h-full w-full flex-col overflow-hidden lg:w-full lg:min-w-[14.5rem] lg:max-w-[14.5rem]">
17
+ <nav className="flex w-full flex-grow flex-col overflow-y-auto p-4 pr-0 pl-0">
18
+ <Collapsible.Root open>
19
+ <React.Suspense>
20
+ <FileTreeDirectoryChildren
21
+ currentEmailOpenSlug={currentEmailOpenSlug}
22
+ emailsDirectoryMetadata={emailsDirectoryMetadata}
23
+ isRoot
24
+ open
25
+ />
26
+ </React.Suspense>
27
+ </Collapsible.Root>
28
+ </nav>
29
+ </div>
30
+ );
31
+ };
@@ -0,0 +1,161 @@
1
+ import prettyBytes from 'pretty-bytes';
2
+ import * as React from 'react';
3
+ import {
4
+ type ImageCheckingResult,
5
+ checkImages,
6
+ } from '../../actions/email-validation/check-images';
7
+ import { Button } from '../button';
8
+ import { Result, ResultList, type ResultStatus } from './checking-results';
9
+
10
+ interface ImageCheckerResultsProps {
11
+ label: string;
12
+ status: ResultStatus;
13
+ results: ImageCheckingResult[];
14
+
15
+ justLoadedIn: boolean;
16
+ }
17
+
18
+ const ImageCheckerResults = ({
19
+ label,
20
+ status,
21
+ results,
22
+
23
+ justLoadedIn,
24
+ }: ImageCheckerResultsProps) => {
25
+ return (
26
+ <ResultList
27
+ label={
28
+ <>
29
+ <span>{label}</span>
30
+ <span>({results.length})</span>
31
+ </>
32
+ }
33
+ defaultOpen={justLoadedIn}
34
+ status={status}
35
+ disabled={results.length === 0}
36
+ >
37
+ {results.map(({ source, status, checks }) => (
38
+ <Result className="flex gap-2" key={source} status={status}>
39
+ <img
40
+ width="24px"
41
+ className="my-auto rounded-sm"
42
+ src={source}
43
+ // biome-ignore lint/a11y/noRedundantAlt: The word image does fit in with the context and thus is not redundant
44
+ alt="image checked"
45
+ />
46
+ <div className="flex w-[calc(100%-.5rem-24px)] flex-col">
47
+ <Result.Title>
48
+ <span className="block overflow-hidden truncate text-ellipsis whitespace-nowrap">
49
+ {source}
50
+ </span>
51
+ </Result.Title>
52
+ <Result.StatusDescription>
53
+ {checks
54
+ .map((check) => {
55
+ if (check.type === 'syntax' && !check.passed)
56
+ return 'Invalid URL';
57
+ if (check.type === 'accessibility' && !check.passed)
58
+ return 'Missing alt';
59
+ if (check.type === 'security')
60
+ return check.passed ? 'Secure' : 'Insecure';
61
+ if (
62
+ check.type === 'fetch_attempt' &&
63
+ check.metadata.fetchStatusCode
64
+ )
65
+ return `${check.metadata.fetchStatusCode}`;
66
+ if (check.type === 'image_size' && check.metadata.byteCount)
67
+ return `${prettyBytes(check.metadata.byteCount)}`;
68
+ return null;
69
+ })
70
+ .filter(Boolean)
71
+ .join(' - ')}
72
+ </Result.StatusDescription>
73
+ </div>
74
+ </Result>
75
+ ))}
76
+ </ResultList>
77
+ );
78
+ };
79
+
80
+ interface ImageCheckerProps {
81
+ emailSlug: string;
82
+ emailMarkup: string;
83
+ }
84
+
85
+ export const ImageChecker = ({ emailSlug, emailMarkup }: ImageCheckerProps) => {
86
+ const cacheKey = `image-checking-results-${emailSlug.replaceAll('/', '-')}`;
87
+
88
+ const [results, setResults] = React.useState<
89
+ ImageCheckingResult[] | undefined
90
+ >();
91
+
92
+ React.useEffect(() => {
93
+ const cachedValue =
94
+ 'localStorage' in global ? global.localStorage.getItem(cacheKey) : null;
95
+ if (cachedValue) {
96
+ setResults(JSON.parse(cachedValue));
97
+ }
98
+ }, [cacheKey]);
99
+
100
+ const [justLoadedIn, setJustLoadedIn] = React.useState(false);
101
+ const [loading, setLoading] = React.useState(false);
102
+
103
+ const handleRun = () => {
104
+ setLoading(true);
105
+ checkImages(emailMarkup, `${location.protocol}//${location.host}`)
106
+ .then((newResults) => {
107
+ setResults(newResults);
108
+ setJustLoadedIn(true);
109
+ localStorage.setItem(cacheKey, JSON.stringify(newResults));
110
+ })
111
+ .catch(console.error)
112
+ .finally(() => setLoading(false));
113
+ };
114
+
115
+ const errorResults = React.useMemo(
116
+ () => results?.filter((r) => r.status === 'error') || [],
117
+ [results],
118
+ );
119
+ const warningResults = React.useMemo(
120
+ () => results?.filter((r) => r.status === 'warning') || [],
121
+ [results],
122
+ );
123
+ const successResults = React.useMemo(
124
+ () => results?.filter((r) => r.status === 'success') || [],
125
+ [results],
126
+ );
127
+
128
+ return (
129
+ <div className="mt-4 flex w-full flex-col gap-2 text-pretty">
130
+ {results ? (
131
+ <>
132
+ <ImageCheckerResults
133
+ label="Errors"
134
+ results={errorResults}
135
+ justLoadedIn={justLoadedIn}
136
+ status="error"
137
+ />
138
+ <ImageCheckerResults
139
+ label="Warnings"
140
+ results={warningResults}
141
+ justLoadedIn={justLoadedIn}
142
+ status="warning"
143
+ />
144
+ <ImageCheckerResults
145
+ label="Success"
146
+ results={successResults}
147
+ justLoadedIn={justLoadedIn}
148
+ status="success"
149
+ />
150
+ </>
151
+ ) : (
152
+ <span className="text-xs leading-relaxed">
153
+ Check if all links are valid and redirect to the correct pages.
154
+ </span>
155
+ )}
156
+ <Button loading={loading} onClick={handleRun}>
157
+ {results ? 'Re-run' : 'Run'}
158
+ </Button>
159
+ </div>
160
+ );
161
+ };