react-email 4.0.0-alpha.0 → 4.0.0-alpha.2

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 (92) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/cli/index.js +10 -10
  3. package/dist/cli/index.mjs +10 -13
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +19 -19
  6. package/dist/preview/.next/app-path-routes-manifest.json +1 -1
  7. package/dist/preview/.next/build-manifest.json +6 -6
  8. package/dist/preview/.next/cache/.rscinfo +1 -1
  9. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  13. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  14. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  15. package/dist/preview/.next/next-server.js.nft.json +1 -1
  16. package/dist/preview/.next/prerender-manifest.json +1 -1
  17. package/dist/preview/.next/required-server-files.json +1 -1
  18. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  19. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
  21. package/dist/preview/.next/server/app/page.js +1 -1
  22. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  23. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  24. package/dist/preview/.next/server/app/preview/[...slug]/page.js +6 -6
  25. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  26. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  27. package/dist/preview/.next/server/app-paths-manifest.json +1 -1
  28. package/dist/preview/.next/server/chunks/196.js +2 -2
  29. package/dist/preview/.next/server/chunks/590.js +1 -0
  30. package/dist/preview/.next/server/chunks/631.js +2 -2
  31. package/dist/preview/.next/server/chunks/734.js +15 -0
  32. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  33. package/dist/preview/.next/server/next-font-manifest.js +1 -1
  34. package/dist/preview/.next/server/next-font-manifest.json +1 -1
  35. package/dist/preview/.next/server/pages/500.html +1 -1
  36. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  37. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  38. package/dist/preview/.next/static/chunks/285-dbf6306a0d45c33d.js +1 -0
  39. package/dist/preview/.next/static/chunks/490-d26ba2019ccd4d2f.js +1 -0
  40. package/dist/preview/.next/static/chunks/603-36207c8905355e23.js +1 -0
  41. package/dist/preview/.next/static/chunks/afa401a5-9ebf2515b1397993.js +6 -0
  42. package/dist/preview/.next/static/chunks/app/layout-b13c19549e2d3e57.js +1 -0
  43. package/dist/preview/.next/static/chunks/app/page-8f366f3c14282f33.js +1 -0
  44. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9906dc842681db05.js +1 -0
  45. package/dist/preview/.next/static/chunks/main-app-d1b0aa870bcfb13e.js +1 -0
  46. package/dist/preview/.next/static/chunks/webpack-9255716c9496e606.js +1 -0
  47. package/dist/preview/.next/static/css/b60917edfd15a496.css +3 -0
  48. package/dist/preview/.next/trace +22 -21
  49. package/dist/preview/.next/types/app/layout.ts +1 -1
  50. package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
  51. package/module-punycode.d.ts +3 -0
  52. package/package.json +9 -9
  53. package/src/actions/email-validation/check-images.spec.tsx +89 -0
  54. package/src/actions/email-validation/check-images.ts +141 -0
  55. package/src/actions/email-validation/check-links.spec.tsx +91 -0
  56. package/src/actions/email-validation/check-links.ts +18 -15
  57. package/src/app/preview/[...slug]/preview.tsx +105 -19
  58. package/src/components/button.tsx +47 -36
  59. package/src/components/code-snippet.tsx +0 -2
  60. package/src/components/icons/icon-image.tsx +19 -0
  61. package/src/components/logo.tsx +0 -2
  62. package/src/components/resizable-wrapper.tsx +176 -0
  63. package/src/components/shell.tsx +17 -3
  64. package/src/components/sidebar/checking-results.tsx +150 -0
  65. package/src/components/sidebar/file-tree-directory-children.tsx +3 -6
  66. package/src/components/sidebar/image-checker.tsx +161 -0
  67. package/src/components/sidebar/link-checker.tsx +83 -223
  68. package/src/components/sidebar/sidebar.tsx +75 -27
  69. package/src/components/topbar/active-view-toggle-group.tsx +86 -0
  70. package/src/components/topbar/view-size-controls.tsx +247 -0
  71. package/src/components/topbar.tsx +50 -125
  72. package/src/hooks/use-clamped-state.ts +24 -0
  73. package/src/hooks/use-icon-animation.ts +4 -7
  74. package/src/utils/static-node-modules-for-vm.ts +2 -1
  75. package/tailwind.config.ts +12 -17
  76. package/tsconfig.json +6 -2
  77. package/tsconfig.test.json +8 -0
  78. package/vitest.config.ts +13 -0
  79. package/dist/preview/.next/server/chunks/273.js +0 -1
  80. package/dist/preview/.next/server/chunks/594.js +0 -10
  81. package/dist/preview/.next/static/chunks/18b16e15-6ad9b58e10ff8891.js +0 -1
  82. package/dist/preview/.next/static/chunks/490-48951f2e19ae3aef.js +0 -1
  83. package/dist/preview/.next/static/chunks/600-2e2ca4c8bbd97b61.js +0 -1
  84. package/dist/preview/.next/static/chunks/860-38d96c8819ba6f19.js +0 -1
  85. package/dist/preview/.next/static/chunks/app/layout-490964e2c3604d33.js +0 -1
  86. package/dist/preview/.next/static/chunks/app/page-d2432acd08db8fc0.js +0 -1
  87. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-f4e211e00c026401.js +0 -1
  88. package/dist/preview/.next/static/chunks/main-app-cd104297c6bcc87e.js +0 -1
  89. package/dist/preview/.next/static/chunks/webpack-7bf1ffb05f5540be.js +0 -1
  90. package/dist/preview/.next/static/css/5e0736cafbb392a9.css +0 -3
  91. /package/dist/preview/.next/static/{fZaiKz58wDr55pxLu9uHa → ll_lhpCErxdDFU8uF5Ujy}/_buildManifest.js +0 -0
  92. /package/dist/preview/.next/static/{fZaiKz58wDr55pxLu9uHa → ll_lhpCErxdDFU8uF5Ujy}/_ssgManifest.js +0 -0
@@ -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
+ };
@@ -1,230 +1,107 @@
1
- import * as Collapsible from '@radix-ui/react-collapsible';
2
- import { clsx } from 'clsx';
3
- import { AnimatePresence, motion } from 'framer-motion';
4
- import Lottie from 'lottie-react';
5
1
  import * as React from 'react';
6
2
  import {
7
3
  type LinkCheckingResult,
8
4
  checkLinks,
9
5
  } from '../../actions/email-validation/check-links';
10
- import animatedLoadIcon from '../../animated-icons-data/load.json';
11
- import { cn } from '../../utils';
12
6
  import { Button } from '../button';
7
+ import { Result, ResultList, type ResultStatus } from './checking-results';
13
8
 
14
- const containerAnimation = {
15
- hidden: { opacity: 0, y: 10 },
16
- visible: {
17
- opacity: 1,
18
- y: 0,
19
- transition: { duration: 0.6, ease: 'easeOut', staggerChildren: 0.1 },
20
- },
21
- };
22
-
23
- const childAnimation = {
24
- hidden: { opacity: 0, y: 5 },
25
- visible: {
26
- opacity: 1,
27
- y: 0,
28
- transition: { duration: 0.4, ease: 'easeOut' },
29
- },
30
- };
31
-
32
- const statusStyles = {
33
- error: 'text-red-600 hover:bg-red-600/10',
34
- warning: 'text-yellow-300 hover:bg-yellow-400/10',
35
- success: 'text-green-600 hover:bg-green-600/10',
36
- };
37
-
38
- interface LinkCheckerProps {
39
- emailSlug: string;
40
- emailMarkup: string;
41
- }
42
-
43
- type ResultStatus = 'error' | 'warning' | 'success';
44
-
45
- interface CollapsibleTriggerProps {
46
- count: number;
9
+ interface LinkCheckerResultsProps {
47
10
  label: string;
48
- variant: ResultStatus;
49
- disabled?: boolean;
50
- }
51
-
52
- interface ResultSectionProps {
53
11
  status: ResultStatus;
54
- label: string;
55
12
  results: LinkCheckingResult[];
56
- open: boolean;
13
+
14
+ justLoadedIn: boolean;
57
15
  }
58
16
 
59
- const CollapsibleTrigger = ({
60
- count,
17
+ const LinkCheckerResults = ({
61
18
  label,
62
- variant,
63
- disabled,
64
- }: CollapsibleTriggerProps) => (
65
- <Collapsible.Trigger
66
- className={clsx(
67
- 'group flex w-full items-center gap-1 rounded p-2 transition-colors duration-200 ease-[cubic-bezier(.36,.66,.6,1)]',
68
- statusStyles[variant],
69
- disabled && 'cursor-not-allowed opacity-70',
70
- )}
71
- disabled={disabled}
72
- >
73
- <span
74
- className={clsx(
75
- '-mt-[.125rem] transition-transform duration-200 ease-[cubic-bezier(.36,.66,.6,1)]',
76
- 'rotate-0 group-data-[state=open]:rotate-90',
77
- )}
78
- >
79
- <svg
80
- fill="none"
81
- height="15"
82
- viewBox="0 0 15 15"
83
- width="15"
84
- xmlns="http://www.w3.org/2000/svg"
85
- >
86
- <path
87
- clipRule="evenodd"
88
- 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"
89
- fill="currentColor"
90
- fillRule="evenodd"
91
- />
92
- </svg>
93
- </span>
94
- <div className="flex flex-1 items-center gap-1 font-bold text-[.625rem] uppercase tracking-wide">
95
- <span>{label}</span>
96
- <span>({count})</span>
97
- </div>
98
- </Collapsible.Trigger>
99
- );
100
-
101
- const ResultSection = ({
102
19
  status,
103
- label,
104
20
  results,
105
- open,
106
- }: ResultSectionProps) => {
107
- const isEmpty = results.length === 0;
108
-
109
- return (
110
- <Collapsible.Root className="group" defaultOpen={open && !isEmpty}>
111
- <CollapsibleTrigger
112
- count={results.length}
113
- label={label}
114
- variant={status}
115
- disabled={isEmpty}
116
- />
117
- {!isEmpty && (
118
- <Collapsible.Content>
119
- <ol className="mt-2 mb-1 flex list-none flex-col gap-4">
120
- {results.map((result, index) => (
121
- <LinkResultView key={index} {...result} />
122
- ))}
123
- </ol>
124
- </Collapsible.Content>
125
- )}
126
- </Collapsible.Root>
127
- );
128
- };
129
-
130
- const LoadingButton = ({
131
- loading,
132
- onClick,
133
- loadAnimation,
134
- animatedLoadIcon,
135
- children,
136
- }: any) => {
137
- React.useEffect(() => {
138
- if (loading) {
139
- loadAnimation.current?.play();
140
- }
141
- }, [loading, loadAnimation]);
142
21
 
22
+ justLoadedIn,
23
+ }: LinkCheckerResultsProps) => {
143
24
  return (
144
- <Button
145
- className="mt-2 mb-4 min-w-[5rem] transition-all disabled:border-transparent disabled:bg-slate-11"
146
- disabled={loading}
147
- onClick={onClick}
25
+ <ResultList
26
+ label={
27
+ <>
28
+ <span>{label}</span>
29
+ <span>({results.length})</span>
30
+ </>
31
+ }
32
+ defaultOpen={justLoadedIn}
33
+ status={status}
34
+ disabled={results.length === 0}
148
35
  >
149
- <div className="flex items-center justify-center gap-2">
150
- <span
151
- className={cn(
152
- '-ml-7 opacity-0 transition-opacity duration-200',
153
- loading && 'opacity-100',
154
- )}
155
- >
156
- <Lottie
157
- animationData={animatedLoadIcon}
158
- autoPlay={false}
159
- className="h-5 w-5"
160
- loop={true}
161
- lottieRef={loadAnimation}
162
- />
163
- </span>
164
- <span>{children}</span>
165
- </div>
166
- </Button>
36
+ {results.map(({ link, status, checks }) => (
37
+ <Result key={link} status={status}>
38
+ <a
39
+ href={link}
40
+ target="_blank"
41
+ rel="noopener noreferrer"
42
+ className="w-full"
43
+ >
44
+ <Result.Title>
45
+ <span className="block overflow-hidden truncate text-ellipsis whitespace-nowrap">
46
+ {link}
47
+ </span>
48
+ </Result.Title>
49
+ <Result.StatusDescription>
50
+ {checks
51
+ .map((check) => {
52
+ if (check.type === 'syntax' && !check.passed)
53
+ return 'Invalid URL';
54
+ if (check.type === 'fetch_attempt')
55
+ return `${check.metadata.fetchStatusCode}`;
56
+ if (check.type === 'security')
57
+ return check.passed ? 'Secure' : 'Insecure';
58
+ return null;
59
+ })
60
+ .filter(Boolean)
61
+ .join(' - ')}
62
+ </Result.StatusDescription>
63
+ </a>
64
+ </Result>
65
+ ))}
66
+ </ResultList>
167
67
  );
168
68
  };
169
69
 
170
- const LinkResultView = (props: LinkCheckingResult) => (
171
- <AnimatePresence mode="wait">
172
- <motion.li
173
- animate="visible"
174
- className="group/item relative w-full rounded-md p-2 pl-4 transition-colors duration-300 ease-out hover:bg-slate-5"
175
- data-status={props.status}
176
- initial="hidden"
177
- layout
178
- variants={containerAnimation}
179
- >
180
- <a
181
- href={props.link}
182
- target="_blank"
183
- rel="noopener noreferrer"
184
- className="w-full"
185
- >
186
- <motion.div
187
- 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"
188
- variants={childAnimation}
189
- >
190
- <span className="block overflow-hidden truncate text-ellipsis whitespace-nowrap">
191
- {props.link}
192
- </span>
193
- </motion.div>
194
- <motion.div
195
- className="mt-1 font-semibold text-[.625rem] uppercase"
196
- variants={childAnimation}
197
- >
198
- {props.checks
199
- .map((check) => {
200
- if (check.type === 'syntax' && !check.passed)
201
- return 'Invalid URL';
202
- if (check.type === 'fetch_attempt')
203
- return `${check.metadata.fetchStatusCode}`;
204
- if (check.type === 'security')
205
- return check.passed ? 'Secure' : 'Insecure';
206
- return null;
207
- })
208
- .filter(Boolean)
209
- .join(' - ')}
210
- </motion.div>
211
- </a>
212
- </motion.li>
213
- </AnimatePresence>
214
- );
70
+ interface LinkCheckerProps {
71
+ emailSlug: string;
72
+ emailMarkup: string;
73
+ }
215
74
 
216
75
  export const LinkChecker = ({ emailSlug, emailMarkup }: LinkCheckerProps) => {
217
76
  const cacheKey = `link-checking-results-${emailSlug.replaceAll('/', '-')}`;
218
- const cachedResults =
219
- 'localStorage' in window ? window.localStorage.getItem(cacheKey) : null;
220
77
 
221
78
  const [results, setResults] = React.useState<
222
79
  LinkCheckingResult[] | undefined
223
- >(cachedResults ? JSON.parse(cachedResults) : undefined);
224
- const [sectionsOpen, setSectionsOpen] = React.useState(false);
225
- const loadAnimation = React.useRef(null);
80
+ >();
81
+
82
+ React.useEffect(() => {
83
+ const cachedValue =
84
+ 'localStorage' in global ? global.localStorage.getItem(cacheKey) : null;
85
+ if (cachedValue) {
86
+ setResults(JSON.parse(cachedValue));
87
+ }
88
+ }, [cacheKey]);
89
+
90
+ const [justLoadedIn, setJustLoadedIn] = React.useState(false);
226
91
  const [loading, setLoading] = React.useState(false);
227
92
 
93
+ const handleRun = () => {
94
+ setLoading(true);
95
+ checkLinks(emailMarkup)
96
+ .then((newResults) => {
97
+ setResults(newResults);
98
+ setJustLoadedIn(true);
99
+ localStorage.setItem(cacheKey, JSON.stringify(newResults));
100
+ })
101
+ .catch(console.error)
102
+ .finally(() => setLoading(false));
103
+ };
104
+
228
105
  const errorResults = React.useMemo(
229
106
  () => results?.filter((r) => r.status === 'error') || [],
230
107
  [results],
@@ -238,39 +115,27 @@ export const LinkChecker = ({ emailSlug, emailMarkup }: LinkCheckerProps) => {
238
115
  [results],
239
116
  );
240
117
 
241
- const handleRun = () => {
242
- setLoading(true);
243
- checkLinks(emailMarkup)
244
- .then((newResults) => {
245
- setResults(newResults);
246
- setSectionsOpen(true);
247
- localStorage.setItem(cacheKey, JSON.stringify(newResults));
248
- })
249
- .catch(console.error)
250
- .finally(() => setLoading(false));
251
- };
252
-
253
118
  return (
254
119
  <div className="mt-4 flex w-full flex-col gap-2 text-pretty">
255
120
  {results ? (
256
121
  <>
257
- <ResultSection
122
+ <LinkCheckerResults
258
123
  label="Errors"
259
124
  results={errorResults}
125
+ justLoadedIn={justLoadedIn}
260
126
  status="error"
261
- open={sectionsOpen}
262
127
  />
263
- <ResultSection
128
+ <LinkCheckerResults
264
129
  label="Warnings"
265
130
  results={warningResults}
131
+ justLoadedIn={justLoadedIn}
266
132
  status="warning"
267
- open={sectionsOpen}
268
133
  />
269
- <ResultSection
134
+ <LinkCheckerResults
270
135
  label="Success"
271
136
  results={successResults}
137
+ justLoadedIn={justLoadedIn}
272
138
  status="success"
273
- open={sectionsOpen}
274
139
  />
275
140
  </>
276
141
  ) : (
@@ -278,14 +143,9 @@ export const LinkChecker = ({ emailSlug, emailMarkup }: LinkCheckerProps) => {
278
143
  Check if all links are valid and redirect to the correct pages.
279
144
  </span>
280
145
  )}
281
- <LoadingButton
282
- loading={loading}
283
- onClick={handleRun}
284
- loadAnimation={loadAnimation}
285
- animatedLoadIcon={animatedLoadIcon}
286
- >
146
+ <Button loading={loading} onClick={handleRun}>
287
147
  {results ? 'Re-run' : 'Run'}
288
- </LoadingButton>
148
+ </Button>
289
149
  </div>
290
150
  );
291
151
  };
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import { DotLottieReact } from '@lottiefiles/dotlottie-react';
3
4
  import * as Tabs from '@radix-ui/react-tabs';
4
5
  import { clsx } from 'clsx';
5
6
  import { motion } from 'framer-motion';
6
- import Lottie from 'lottie-react';
7
7
  import Link from 'next/link';
8
8
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
9
9
  import type * as React from 'react';
@@ -15,8 +15,10 @@ import { useIconAnimation } from '../../hooks/use-icon-animation';
15
15
  import { cn } from '../../utils';
16
16
  import { Button } from '../button';
17
17
  import { Heading } from '../heading';
18
+ import { IconImage } from '../icons/icon-image';
18
19
  import { Tooltip } from '../tooltip';
19
20
  import { FileTree } from './file-tree';
21
+ import { ImageChecker } from './image-checker';
20
22
  import { LinkChecker } from './link-checker';
21
23
 
22
24
  type SidebarPanelValue = 'file-tree' | 'link-checker' | 'image-checker';
@@ -145,7 +147,7 @@ const Panel = ({ title, active, children }: PanelProps) => (
145
147
  {title}
146
148
  </Heading>
147
149
  </div>
148
- <div className="-mt-[.5px] relative h-[calc(100vh-4.375rem)] w-full border-slate-4 border-t px-4 pb-3">
150
+ <div className="-mt-[.5px] relative h-[calc(100dvh-4.375rem)] w-full border-slate-4 border-t px-4 pb-3">
149
151
  {children}
150
152
  </div>
151
153
  </>
@@ -218,12 +220,14 @@ export const Sidebar = ({
218
220
  tabValue="file-tree"
219
221
  tooltipText="File Explorer"
220
222
  >
221
- <Lottie
222
- animationData={animatedMailIcon as object}
223
- autoPlay={false}
223
+ <DotLottieReact
224
+ data={animatedMailIcon}
225
+ autoplay={false}
224
226
  className="h-5 w-5"
225
227
  loop={false}
226
- lottieRef={mailAnimation.ref}
228
+ dotLottieRefCallback={(instance) => {
229
+ mailAnimation.ref.current = instance;
230
+ }}
227
231
  />
228
232
  </TabTrigger>
229
233
  <TabTrigger
@@ -234,14 +238,24 @@ export const Sidebar = ({
234
238
  tabValue="link-checker"
235
239
  tooltipText="Link Checker"
236
240
  >
237
- <Lottie
238
- animationData={animatedLinkIcon as object}
239
- autoPlay={false}
241
+ <DotLottieReact
242
+ data={animatedLinkIcon}
243
+ autoplay={false}
240
244
  className="h-6 w-6"
241
245
  loop={false}
242
- lottieRef={linkAnimation.ref}
246
+ dotLottieRefCallback={(instance) => {
247
+ linkAnimation.ref.current = instance;
248
+ }}
243
249
  />
244
250
  </TabTrigger>
251
+ <TabTrigger
252
+ activeTabValue={activePanelValue}
253
+ className="relative"
254
+ tabValue="image-checker"
255
+ tooltipText="Image Checker"
256
+ >
257
+ <IconImage className="h-6 w-6" />
258
+ </TabTrigger>
245
259
  <div className="mt-auto flex flex-col">
246
260
  <NavigationButton
247
261
  className="flex items-center justify-center"
@@ -251,12 +265,14 @@ export const Sidebar = ({
251
265
  side="right"
252
266
  tooltip="Documentation"
253
267
  >
254
- <Lottie
255
- animationData={animatedHelpIcon as object}
256
- autoPlay={false}
268
+ <DotLottieReact
269
+ data={animatedHelpIcon}
270
+ autoplay={false}
257
271
  className="h-5 w-5"
258
272
  loop={false}
259
- lottieRef={helpAnimation.ref}
273
+ dotLottieRefCallback={(instance) => {
274
+ helpAnimation.ref.current = instance;
275
+ }}
260
276
  />
261
277
  </NavigationButton>
262
278
  <NavigationButton
@@ -284,19 +300,28 @@ export const Sidebar = ({
284
300
  emailSlug={currentEmailOpenSlug}
285
301
  />
286
302
  ) : (
287
- <div className="mt-4 flex w-full flex-col gap-2 text-pretty text-xs leading-relaxed">
288
- <div className="flex flex-col gap-1 rounded-lg border border-[#0BB9CD]/50 bg-[#0BB9CD]/20 text-white">
289
- <span className="mx-2.5 mt-2">
290
- To use the Link Checker, you need to select a template.
291
- </span>
292
- <Button
293
- className="mx-2 my-2.5 transition-all disabled:border-transparent disabled:bg-slate-11"
294
- onClick={() => setActivePanelValue('file-tree')}
295
- >
296
- Select a template
297
- </Button>
298
- </div>
299
- </div>
303
+ <EmptyState
304
+ title="Link Checker"
305
+ onSelectTemplate={() => setActivePanelValue('file-tree')}
306
+ />
307
+ )}
308
+ </Panel>
309
+ )}
310
+ {activePanelValue === 'image-checker' && (
311
+ <Panel
312
+ title="Image Checker"
313
+ active={activePanelValue === 'image-checker'}
314
+ >
315
+ {currentEmailOpenSlug && emailMarkup ? (
316
+ <ImageChecker
317
+ emailMarkup={emailMarkup}
318
+ emailSlug={currentEmailOpenSlug}
319
+ />
320
+ ) : (
321
+ <EmptyState
322
+ title="Image Checker"
323
+ onSelectTemplate={() => setActivePanelValue('file-tree')}
324
+ />
300
325
  )}
301
326
  </Panel>
302
327
  )}
@@ -317,3 +342,26 @@ export const Sidebar = ({
317
342
  </Tabs.Root>
318
343
  );
319
344
  };
345
+
346
+ interface EmptyStateProps {
347
+ onSelectTemplate: () => void;
348
+ title: string;
349
+ }
350
+
351
+ const EmptyState = ({ onSelectTemplate, title }: EmptyStateProps) => {
352
+ return (
353
+ <div className="mt-4 flex w-full flex-col gap-2 text-pretty text-xs leading-relaxed">
354
+ <div className="flex flex-col gap-1 rounded-lg border border-[#0BB9CD]/50 bg-[#0BB9CD]/20 text-white">
355
+ <span className="mx-2.5 mt-2">
356
+ To use the {title}, you need to select a template.
357
+ </span>
358
+ <Button
359
+ className="mx-2 my-2.5 transition-all disabled:border-transparent disabled:bg-slate-11"
360
+ onClick={() => onSelectTemplate()}
361
+ >
362
+ Select a template
363
+ </Button>
364
+ </div>
365
+ </div>
366
+ );
367
+ };