react-email 4.0.0-alpha.5 → 4.0.0-alpha.7
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.
- package/CHANGELOG.md +12 -0
- package/dist/cli/index.js +1179 -2659
- package/dist/cli/index.mjs +17 -11
- package/dist/preview/.next/BUILD_ID +1 -1
- package/dist/preview/.next/app-build-manifest.json +32 -31
- package/dist/preview/.next/app-path-routes-manifest.json +6 -1
- package/dist/preview/.next/build-manifest.json +14 -14
- package/dist/preview/.next/cache/.rscinfo +1 -1
- package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
- package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
- package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
- package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
- package/dist/preview/.next/diagnostics/framework.json +1 -1
- package/dist/preview/.next/export-marker.json +6 -1
- package/dist/preview/.next/images-manifest.json +57 -1
- package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
- package/dist/preview/.next/next-server.js.nft.json +1 -1
- package/dist/preview/.next/prerender-manifest.json +41 -1
- package/dist/preview/.next/required-server-files.json +310 -1
- package/dist/preview/.next/routes-manifest.json +64 -1
- package/dist/preview/.next/server/app/_not-found/page.js +1 -1
- package/dist/preview/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/dist/preview/.next/server/app/favicon.ico/route.js +1 -1
- package/dist/preview/.next/server/app/favicon.ico/route.js.nft.json +1 -1
- package/dist/preview/.next/server/app/page.js +1 -1
- package/dist/preview/.next/server/app/page.js.nft.json +1 -1
- package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
- package/dist/preview/.next/server/app/preview/[...slug]/page.js +51 -11
- package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
- package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
- package/dist/preview/.next/server/app-paths-manifest.json +1 -1
- package/dist/preview/.next/server/chunks/446.js +6 -0
- package/dist/preview/.next/server/chunks/600.js +8 -0
- package/dist/preview/.next/server/chunks/811.js +13 -0
- package/dist/preview/.next/server/chunks/816.js +14 -0
- package/dist/preview/.next/server/chunks/943.js +1 -0
- package/dist/preview/.next/server/functions-config-manifest.json +4 -1
- package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
- package/dist/preview/.next/server/next-font-manifest.js +1 -1
- package/dist/preview/.next/server/next-font-manifest.json +1 -1
- package/dist/preview/.next/server/pages/500.html +1 -1
- package/dist/preview/.next/server/pages/_app.js +1 -1
- package/dist/preview/.next/server/pages/_app.js.nft.json +1 -1
- package/dist/preview/.next/server/pages/_document.js +1 -1
- package/dist/preview/.next/server/pages/_document.js.nft.json +1 -1
- package/dist/preview/.next/server/pages/_error.js +1 -1
- package/dist/preview/.next/server/pages/_error.js.nft.json +1 -1
- package/dist/preview/.next/server/pages-manifest.json +5 -1
- package/dist/preview/.next/server/server-reference-manifest.js +1 -1
- package/dist/preview/.next/server/server-reference-manifest.json +1 -1
- package/dist/preview/.next/server/webpack-runtime.js +1 -1
- package/dist/preview/.next/static/Pms2orsQgT5xpttCfZfH5/_buildManifest.js +1 -0
- package/dist/preview/.next/static/chunks/287-7864b805e6bdc854.js +1 -0
- package/dist/preview/.next/static/chunks/412-31817e53b50a3e73.js +1 -0
- package/dist/preview/.next/static/chunks/683-1fb40795502f6e63.js +1 -0
- package/dist/preview/.next/static/chunks/744-79730358b37b2212.js +1 -0
- package/dist/preview/.next/static/chunks/781-5f16c6bc9d9d4cc1.js +1 -0
- package/dist/preview/.next/static/chunks/832ad4be-cb988facfb8f955f.js +1 -0
- package/dist/preview/.next/static/chunks/880-9c0b721328117b8b.js +1 -0
- package/dist/preview/.next/static/chunks/{afa401a5-a600c227dacf3ab4.js → afa401a5-3e949a1cfd317dd3.js} +3 -3
- package/dist/preview/.next/static/chunks/app/_not-found/page-09d694081cc9d4dc.js +1 -0
- package/dist/preview/.next/static/chunks/app/layout-ffdee5cc1be30e7b.js +1 -0
- package/dist/preview/.next/static/chunks/app/page-9ea0bd45cd6294b0.js +1 -0
- package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-9e22979a25c836c0.js +1 -0
- package/dist/preview/.next/static/chunks/framework-c2bd6d936e3077bc.js +1 -0
- package/dist/preview/.next/static/chunks/main-44463a8301435b64.js +1 -0
- package/dist/preview/.next/static/chunks/main-app-256b213b179a95cc.js +1 -0
- package/dist/preview/.next/static/chunks/pages/_app-f3011d3f00bb8dba.js +1 -0
- package/dist/preview/.next/static/chunks/pages/_error-39a87dee2e97a2a3.js +1 -0
- package/dist/preview/.next/static/chunks/{webpack-2eb145a20ee6cb77.js → webpack-41e2667c9f086a4f.js} +1 -1
- package/dist/preview/.next/static/css/eaae8ce545b295f9.css +3 -0
- package/dist/preview/.next/trace +26 -22
- package/dist/preview/.next/types/app/layout.ts +1 -1
- package/dist/preview/.next/types/app/page.ts +84 -0
- package/dist/preview/.next/types/app/preview/[...slug]/page.ts +1 -1
- package/dist/preview/.next/types/cache-life.d.ts +3 -3
- package/package.json +14 -9
- package/scripts/build-preview-server.mjs +32 -0
- package/scripts/fill-caniemail-data.mjs +36 -0
- package/src/actions/email-validation/caniemail-data.ts +85993 -0
- package/src/actions/email-validation/check-compatibility.ts +321 -0
- package/src/actions/email-validation/check-images.spec.tsx +15 -13
- package/src/actions/email-validation/check-images.ts +8 -2
- package/src/actions/email-validation/check-links.spec.tsx +27 -15
- package/src/actions/email-validation/check-links.ts +8 -2
- package/src/actions/email-validation/get-code-location-from-ast-element.ts +18 -0
- package/src/actions/get-email-path-from-slug.ts +1 -1
- package/src/actions/render-email-by-path.tsx +2 -1
- package/src/{utils/emails-directory-absolute-path.ts → app/env.ts} +5 -0
- package/src/app/layout.tsx +1 -1
- package/src/app/page.tsx +1 -1
- package/src/app/preview/[...slug]/page.tsx +89 -19
- package/src/app/preview/[...slug]/preview.tsx +25 -68
- package/src/components/code-container.tsx +90 -71
- package/src/components/code.tsx +106 -43
- package/src/components/icons/icon-info.tsx +18 -0
- package/src/components/icons/icon-reload.tsx +13 -14
- package/src/components/logo.tsx +3 -2
- package/src/components/resizable-wrapper.tsx +1 -4
- package/src/components/sidebar/file-tree-directory-children.tsx +1 -0
- package/src/components/sidebar/sidebar.tsx +2 -3
- package/src/components/toolbar/code-preview-line-link.tsx +40 -0
- package/src/components/toolbar/compatibility.tsx +113 -0
- package/src/components/toolbar/linter.tsx +226 -125
- package/src/components/toolbar/results.tsx +5 -2
- package/src/components/toolbar/spam-assassin.tsx +40 -43
- package/src/components/toolbar/toolbar-button.tsx +52 -0
- package/src/components/toolbar/use-cached-state.ts +33 -0
- package/src/components/toolbar.tsx +196 -110
- package/src/components/tooltip-content.tsx +1 -1
- package/src/components/topbar/view-size-controls.tsx +1 -1
- package/src/components/topbar.tsx +4 -29
- package/src/contexts/emails.tsx +2 -1
- package/src/contexts/fragment-identifier.tsx +46 -0
- package/src/contexts/preview.tsx +81 -0
- package/src/hooks/use-email-rendering-result.ts +2 -1
- package/src/hooks/use-fragment-identifier.ts +14 -0
- package/src/utils/__snapshots__/get-email-component.spec.ts.snap +1 -1
- package/src/utils/caniemail/all-css-properties.ts +358 -0
- package/src/utils/caniemail/ast/get-object-variables.ts +61 -0
- package/src/utils/caniemail/ast/get-used-style-properties.ts +91 -0
- package/src/utils/caniemail/get-compatibility-stats-for-entry.ts +118 -0
- package/src/utils/caniemail/get-css-functions.ts +25 -0
- package/src/utils/caniemail/get-css-property-names.ts +32 -0
- package/src/utils/caniemail/get-css-property-with-value.ts +14 -0
- package/src/utils/caniemail/get-css-unit.ts +3 -0
- package/src/utils/caniemail/get-element-attributes.ts +7 -0
- package/src/utils/caniemail/get-element-names.ts +20 -0
- package/src/utils/caniemail/tailwind/generate-tailwind-rules.ts +30 -0
- package/src/utils/caniemail/tailwind/get-tailwind-config.ts +203 -0
- package/src/utils/caniemail/tailwind/get-tailwind-metadata.spec.ts +25 -0
- package/src/utils/caniemail/tailwind/get-tailwind-metadata.ts +45 -0
- package/src/utils/caniemail/tailwind/setup-tailwind-context.ts +15 -0
- package/src/utils/get-email-component.ts +34 -67
- package/src/utils/get-line-and-column-from-offset.spec.ts +11 -0
- package/src/utils/get-line-and-column-from-offset.ts +11 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/linting.ts +60 -0
- package/src/utils/load-stream.ts +15 -0
- package/src/utils/result.ts +49 -0
- package/src/utils/run-bundled-code.ts +64 -0
- package/src/utils/sanitize.ts +6 -0
- package/tailwind-internals.d.ts +133 -0
- package/tsconfig.json +9 -3
- package/build-preview-server.mjs +0 -25
- package/dist/preview/.next/cache/images/TcyzHbFXGFjrOu3wEMvDoSmqCh3qP3iiNqJf0QbED9Y/60.1741728556140.cQ5qicbpvoXZ7leVmWqG2ElLwXB1ynYeSv8MBSA-QeM.Vy8iMWM3MGUtMTk1ODcxYmIyNzMi.webp +0 -0
- package/dist/preview/.next/cache/webpack/client-development/0.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/1.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/10.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/11.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/12.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/13.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/2.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/3.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/4.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/5.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/6.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/7.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/8.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/9.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/index.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
- package/dist/preview/.next/cache/webpack/server-development/0.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/1.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/2.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/3.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/4.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/5.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/6.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/7.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/8.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/9.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/index.pack.gz +0 -0
- package/dist/preview/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
- package/dist/preview/.next/server/chunks/143.js +0 -6
- package/dist/preview/.next/server/chunks/409.js +0 -5
- package/dist/preview/.next/server/chunks/46.js +0 -1
- package/dist/preview/.next/server/chunks/478.js +0 -14
- package/dist/preview/.next/server/chunks/707.js +0 -13
- package/dist/preview/.next/static/B4EYZiVzdylEG9lAIl-aO/_buildManifest.js +0 -1
- package/dist/preview/.next/static/chunks/575-bc52750855c25df4.js +0 -2
- package/dist/preview/.next/static/chunks/684-0f1ef7361c499798.js +0 -1
- package/dist/preview/.next/static/chunks/684c6b30-0c65da32762fc4ee.js +0 -1
- package/dist/preview/.next/static/chunks/81-e7539b08d9d3fb4d.js +0 -1
- package/dist/preview/.next/static/chunks/883-70c8267c50bc4133.js +0 -1
- package/dist/preview/.next/static/chunks/921-d1dc8c63f49e85d6.js +0 -1
- package/dist/preview/.next/static/chunks/app/_not-found/page-03ce767859c36d4e.js +0 -1
- package/dist/preview/.next/static/chunks/app/layout-7cf14e28880544f1.js +0 -1
- package/dist/preview/.next/static/chunks/app/page-065cb49b0a078541.js +0 -1
- package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-656510fd180c803c.js +0 -1
- package/dist/preview/.next/static/chunks/framework-2a724981073c3a29.js +0 -1
- package/dist/preview/.next/static/chunks/main-552b9719bbc3a274.js +0 -1
- package/dist/preview/.next/static/chunks/main-app-914a73336fd45af5.js +0 -1
- package/dist/preview/.next/static/chunks/pages/_app-77ca34bce25ac75c.js +0 -1
- package/dist/preview/.next/static/chunks/pages/_error-73f611c46abbb495.js +0 -1
- package/dist/preview/.next/static/css/2df96d9ee014e8de.css +0 -3
- package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +0 -22
- package/src/actions/email-validation/get-line-and-column-from-index.ts +0 -43
- package/src/components/icons/icon-scanner.tsx +0 -19
- package/src/components/icons/icon-scissors.tsx +0 -19
- /package/dist/preview/.next/static/{B4EYZiVzdylEG9lAIl-aO → Pms2orsQgT5xpttCfZfH5}/_ssgManifest.js +0 -0
|
@@ -8,7 +8,7 @@ export const Results = ({
|
|
|
8
8
|
return (
|
|
9
9
|
<table
|
|
10
10
|
className={cn(
|
|
11
|
-
'group relative w-full border-collapse text-left text-slate-10 text-
|
|
11
|
+
'group relative w-full border-collapse text-left text-slate-10 text-sm',
|
|
12
12
|
className,
|
|
13
13
|
)}
|
|
14
14
|
>
|
|
@@ -41,7 +41,10 @@ Results.Column = ({
|
|
|
41
41
|
...props
|
|
42
42
|
}: React.ComponentProps<'td'>) => {
|
|
43
43
|
return (
|
|
44
|
-
<td
|
|
44
|
+
<td
|
|
45
|
+
className={cn('py-1.5 align-bottom font-regular', className)}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
45
48
|
{children}
|
|
46
49
|
</td>
|
|
47
50
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
2
|
import { toast } from 'sonner';
|
|
3
|
-
import { cn } from '../../utils';
|
|
3
|
+
import { cn, sanitize } from '../../utils';
|
|
4
4
|
import { IconWarning } from '../icons/icon-warning';
|
|
5
5
|
import { Results } from './results';
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ interface SpamAssassinProps {
|
|
|
8
8
|
result: SpamCheckingResult | undefined;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
interface SpamCheckingResult {
|
|
11
|
+
export interface SpamCheckingResult {
|
|
12
12
|
checks: {
|
|
13
13
|
name: string;
|
|
14
14
|
description: string;
|
|
@@ -25,33 +25,26 @@ function toSorted<T>(array: T[], sorter: (a: T, b: T) => number): T[] {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export const useSpamAssassin = ({
|
|
28
|
-
slug,
|
|
29
28
|
markup,
|
|
30
29
|
plainText,
|
|
30
|
+
|
|
31
|
+
initialResult,
|
|
31
32
|
}: {
|
|
32
|
-
slug: string;
|
|
33
33
|
markup: string;
|
|
34
34
|
plainText: string;
|
|
35
|
-
}) => {
|
|
36
|
-
const cacheKey = `spam-assassin-${slug.replaceAll('/', '-')}`;
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (cachedValue) {
|
|
44
|
-
try {
|
|
45
|
-
setResult(JSON.parse(cachedValue));
|
|
46
|
-
} catch (exception) {
|
|
47
|
-
setResult(undefined);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
}, [cacheKey]);
|
|
36
|
+
initialResult?: SpamCheckingResult;
|
|
37
|
+
}) => {
|
|
38
|
+
const [result, setResult] = useState<SpamCheckingResult | undefined>(
|
|
39
|
+
initialResult,
|
|
40
|
+
);
|
|
51
41
|
|
|
52
42
|
const [loading, setLoading] = useState(false);
|
|
43
|
+
const isLoadingRef = useRef(false);
|
|
53
44
|
|
|
54
45
|
const load = async () => {
|
|
46
|
+
if (isLoadingRef.current) return;
|
|
47
|
+
isLoadingRef.current = true;
|
|
55
48
|
setLoading(true);
|
|
56
49
|
|
|
57
50
|
try {
|
|
@@ -64,25 +57,21 @@ export const useSpamAssassin = ({
|
|
|
64
57
|
}),
|
|
65
58
|
});
|
|
66
59
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
toast.error(responseBody.error);
|
|
73
|
-
} else {
|
|
74
|
-
setResult(responseBody);
|
|
75
|
-
localStorage.setItem(cacheKey, JSON.stringify(responseBody));
|
|
76
|
-
}
|
|
60
|
+
const responseBody = (await response.json()) as
|
|
61
|
+
| { error: string }
|
|
62
|
+
| SpamCheckingResult;
|
|
63
|
+
if ('error' in responseBody) {
|
|
64
|
+
toast.error(responseBody.error);
|
|
77
65
|
} else {
|
|
78
|
-
|
|
79
|
-
|
|
66
|
+
setResult(responseBody);
|
|
67
|
+
return responseBody;
|
|
80
68
|
}
|
|
81
69
|
} catch (exception) {
|
|
82
70
|
console.error(exception);
|
|
83
71
|
toast.error(JSON.stringify(exception));
|
|
84
72
|
} finally {
|
|
85
73
|
setLoading(false);
|
|
74
|
+
isLoadingRef.current = false;
|
|
86
75
|
}
|
|
87
76
|
};
|
|
88
77
|
|
|
@@ -94,25 +83,33 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
|
|
|
94
83
|
<>
|
|
95
84
|
{result ? (
|
|
96
85
|
<Results>
|
|
97
|
-
<Results.Row className="sticky border-b
|
|
86
|
+
<Results.Row className="sticky border-b top-0 bg-black">
|
|
98
87
|
<Results.Column className="uppercase">
|
|
99
|
-
<span className="flex gap-
|
|
88
|
+
<span className="flex gap-2 items-center">
|
|
100
89
|
<IconWarning
|
|
101
90
|
className={cn(
|
|
102
|
-
result.points
|
|
103
|
-
result.points >
|
|
91
|
+
result.points === 0 ? 'text-green-400' : null,
|
|
92
|
+
result.points > 0 && result.points <= 1.5 ? null : null,
|
|
93
|
+
result.points > 1.5 ? 'text-yellow-100' : null,
|
|
94
|
+
result.points > 3 ? 'text-orange-400' : null,
|
|
104
95
|
result.points >= 5 ? 'text-red-400' : null,
|
|
105
96
|
)}
|
|
106
97
|
/>
|
|
107
98
|
Score
|
|
108
99
|
</span>
|
|
109
100
|
</Results.Column>
|
|
110
|
-
<Results.Column>
|
|
101
|
+
<Results.Column>
|
|
102
|
+
{result.points === 0
|
|
103
|
+
? 'Congratulations! Your email is clean of abuse indicators.'
|
|
104
|
+
: 'Lower scores are better'}
|
|
105
|
+
</Results.Column>
|
|
111
106
|
<Results.Column
|
|
112
107
|
className={cn(
|
|
113
|
-
'text-right text-
|
|
108
|
+
'text-right text-3xl tracking-tighter font-bold',
|
|
109
|
+
result.points === 0 ? 'text-green-400' : null,
|
|
110
|
+
result.points > 0 && result.points <= 1.5 ? null : null,
|
|
114
111
|
result.points > 1.5 ? 'text-yellow-200' : null,
|
|
115
|
-
result.points > 3 ? 'text-orange-
|
|
112
|
+
result.points > 3 ? 'text-orange-400' : null,
|
|
116
113
|
result.points >= 5 ? 'text-red-400' : null,
|
|
117
114
|
)}
|
|
118
115
|
>
|
|
@@ -123,15 +120,15 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
|
|
|
123
120
|
(check) => (
|
|
124
121
|
<Results.Row key={check.name}>
|
|
125
122
|
<Results.Column className="uppercase">
|
|
126
|
-
<span className="flex gap-
|
|
123
|
+
<span className="flex gap-2 items-center">
|
|
127
124
|
<IconWarning
|
|
128
125
|
className={cn(
|
|
129
126
|
check.points > 1 ? 'text-yellow-200' : null,
|
|
130
|
-
check.points > 2 ? 'text-orange-
|
|
127
|
+
check.points > 2 ? 'text-orange-400' : null,
|
|
131
128
|
check.points > 3 ? 'text-red-400' : null,
|
|
132
129
|
)}
|
|
133
130
|
/>
|
|
134
|
-
{check.name}
|
|
131
|
+
{sanitize(check.name)}
|
|
135
132
|
</span>
|
|
136
133
|
</Results.Column>
|
|
137
134
|
<Results.Column>{check.description}</Results.Column>
|
|
@@ -139,7 +136,7 @@ export const SpamAssassin = ({ result }: SpamAssassinProps) => {
|
|
|
139
136
|
className={cn(
|
|
140
137
|
'text-right font-mono tracking-tighter',
|
|
141
138
|
check.points > 1 ? 'text-yellow-200' : null,
|
|
142
|
-
check.points > 2 ? 'text-orange-
|
|
139
|
+
check.points > 2 ? 'text-orange-400' : null,
|
|
143
140
|
check.points > 3 ? 'text-red-400' : null,
|
|
144
141
|
)}
|
|
145
142
|
>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { motion } from 'framer-motion';
|
|
2
|
+
import { cn } from '../../utils';
|
|
3
|
+
import { Tooltip } from '../tooltip';
|
|
4
|
+
|
|
5
|
+
interface ToolbarButtonProps extends React.ComponentProps<'button'> {
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
active?: boolean;
|
|
8
|
+
tooltip?: React.ReactNode;
|
|
9
|
+
delayDuration?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const ToolbarButton = ({
|
|
13
|
+
children,
|
|
14
|
+
className,
|
|
15
|
+
active,
|
|
16
|
+
tooltip,
|
|
17
|
+
delayDuration = 500,
|
|
18
|
+
...props
|
|
19
|
+
}: ToolbarButtonProps) => {
|
|
20
|
+
return (
|
|
21
|
+
<Tooltip.Provider>
|
|
22
|
+
<Tooltip delayDuration={delayDuration}>
|
|
23
|
+
<Tooltip.Trigger asChild>
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
{...props}
|
|
27
|
+
className={cn(
|
|
28
|
+
'h-full w-fit font-regular flex text-sm text-slate-10 items-center align-middle justify-center px-1 gap-2 relative',
|
|
29
|
+
'hover:text-slate-12 transition-colors',
|
|
30
|
+
active && 'data-[state=active]:text-cyan-11',
|
|
31
|
+
className,
|
|
32
|
+
)}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
{active ? (
|
|
36
|
+
<motion.span
|
|
37
|
+
className="-bottom-px absolute rounded-sm left-0 w-full bg-cyan-11 h-px"
|
|
38
|
+
layoutId="active-toolbar-button"
|
|
39
|
+
transition={{
|
|
40
|
+
type: 'spring',
|
|
41
|
+
bounce: 0.2,
|
|
42
|
+
duration: 0.6,
|
|
43
|
+
}}
|
|
44
|
+
/>
|
|
45
|
+
) : null}
|
|
46
|
+
</button>
|
|
47
|
+
</Tooltip.Trigger>
|
|
48
|
+
{tooltip ? <Tooltip.Content>{tooltip}</Tooltip.Content> : null}
|
|
49
|
+
</Tooltip>
|
|
50
|
+
</Tooltip.Provider>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
export const useCachedState = <T>(key: string) => {
|
|
4
|
+
let value: T | undefined = undefined;
|
|
5
|
+
if ('localStorage' in global) {
|
|
6
|
+
const storedValue = global.localStorage.getItem(key);
|
|
7
|
+
if (storedValue !== null) {
|
|
8
|
+
try {
|
|
9
|
+
value = JSON.parse(storedValue) as T;
|
|
10
|
+
} catch (exception) {
|
|
11
|
+
console.warn(
|
|
12
|
+
'Failed to load stored value for',
|
|
13
|
+
key,
|
|
14
|
+
'with value',
|
|
15
|
+
value,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return [
|
|
22
|
+
useSyncExternalStore(
|
|
23
|
+
() => () => {},
|
|
24
|
+
() => value,
|
|
25
|
+
() => undefined,
|
|
26
|
+
),
|
|
27
|
+
function setValue(newValue: T | undefined) {
|
|
28
|
+
if ('localStorage' in global) {
|
|
29
|
+
global.localStorage.setItem(key, JSON.stringify(newValue));
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
] as const;
|
|
33
|
+
};
|
|
@@ -1,185 +1,241 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
import * as Tabs from '@radix-ui/react-tabs';
|
|
2
|
-
import { LayoutGroup
|
|
3
|
+
import { LayoutGroup } from 'framer-motion';
|
|
3
4
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
4
|
-
import { useEffect } from 'react';
|
|
5
|
+
import { use, useEffect } from 'react';
|
|
6
|
+
import type { CompatibilityCheckingResult } from '../actions/email-validation/check-compatibility';
|
|
7
|
+
import { isBuilding } from '../app/env';
|
|
8
|
+
import { PreviewContext } from '../contexts/preview';
|
|
5
9
|
import { cn } from '../utils';
|
|
6
10
|
import { IconArrowDown } from './icons/icon-arrow-down';
|
|
11
|
+
import { IconInfo } from './icons/icon-info';
|
|
7
12
|
import { IconReload } from './icons/icon-reload';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
import { Compatibility, useCompatibility } from './toolbar/compatibility';
|
|
14
|
+
import { Linter, type LintingRow, useLinter } from './toolbar/linter';
|
|
15
|
+
import {
|
|
16
|
+
SpamAssassin,
|
|
17
|
+
type SpamCheckingResult,
|
|
18
|
+
useSpamAssassin,
|
|
19
|
+
} from './toolbar/spam-assassin';
|
|
20
|
+
import { ToolbarButton } from './toolbar/toolbar-button';
|
|
21
|
+
import { useCachedState } from './toolbar/use-cached-state';
|
|
13
22
|
|
|
14
|
-
type
|
|
15
|
-
emailSlug: string;
|
|
16
|
-
markup: string;
|
|
17
|
-
plainText: string;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
type ActivePanelValue = 'linter' | 'spam-assassin';
|
|
21
|
-
|
|
22
|
-
interface ToolbarButton extends React.ComponentProps<'button'> {
|
|
23
|
-
children: React.ReactNode;
|
|
24
|
-
active?: boolean;
|
|
25
|
-
tooltip?: React.ReactNode;
|
|
26
|
-
}
|
|
23
|
+
export type ToolbarTabValue = 'linter' | 'compatibility' | 'spam-assassin';
|
|
27
24
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
tooltip,
|
|
33
|
-
...props
|
|
34
|
-
}: ToolbarButton) => {
|
|
35
|
-
return (
|
|
36
|
-
<Tooltip.Provider>
|
|
37
|
-
<Tooltip>
|
|
38
|
-
<Tooltip.Trigger asChild>
|
|
39
|
-
<button
|
|
40
|
-
type="button"
|
|
41
|
-
{...props}
|
|
42
|
-
className={cn(
|
|
43
|
-
'h-full w-fit font-medium flex text-sm text-slate-10 items-center align-middle justify-center px-1 py-2 gap-1 relative',
|
|
44
|
-
'hover:text-slate-12 transition-colors',
|
|
45
|
-
active && 'data-[state=active]:text-cyan-11',
|
|
46
|
-
className,
|
|
47
|
-
)}
|
|
48
|
-
>
|
|
49
|
-
{children}
|
|
50
|
-
{active ? (
|
|
51
|
-
<motion.span
|
|
52
|
-
className="-bottom-px absolute rounded-sm left-0 w-full bg-cyan-11 h-px"
|
|
53
|
-
layoutId="active-toolbar-button"
|
|
54
|
-
transition={{
|
|
55
|
-
type: 'spring',
|
|
56
|
-
bounce: 0.2,
|
|
57
|
-
duration: 0.6,
|
|
58
|
-
}}
|
|
59
|
-
/>
|
|
60
|
-
) : null}
|
|
61
|
-
</button>
|
|
62
|
-
</Tooltip.Trigger>
|
|
63
|
-
{tooltip ? <Tooltip.Content>{tooltip}</Tooltip.Content> : null}
|
|
64
|
-
</Tooltip>
|
|
65
|
-
</Tooltip.Provider>
|
|
66
|
-
);
|
|
67
|
-
};
|
|
25
|
+
const ToolbarInner = ({
|
|
26
|
+
serverLintingRows,
|
|
27
|
+
serverSpamCheckingResult,
|
|
28
|
+
serverCompatibilityResults,
|
|
68
29
|
|
|
69
|
-
export const Toolbar = ({
|
|
70
|
-
emailSlug,
|
|
71
30
|
markup,
|
|
31
|
+
reactMarkup,
|
|
72
32
|
plainText,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}: ToolbarProps
|
|
33
|
+
emailPath,
|
|
34
|
+
emailSlug,
|
|
35
|
+
}: ToolbarProps & {
|
|
36
|
+
markup: string;
|
|
37
|
+
reactMarkup: string;
|
|
38
|
+
plainText: string;
|
|
39
|
+
emailSlug: string;
|
|
40
|
+
emailPath: string;
|
|
41
|
+
}) => {
|
|
76
42
|
const pathname = usePathname();
|
|
77
43
|
const searchParams = useSearchParams();
|
|
78
44
|
const router = useRouter();
|
|
79
45
|
|
|
80
|
-
const
|
|
81
|
-
|
|
|
46
|
+
const activeTab = (searchParams.get('toolbar-panel') ?? undefined) as
|
|
47
|
+
| ToolbarTabValue
|
|
82
48
|
| undefined;
|
|
83
49
|
|
|
84
|
-
const toggled =
|
|
50
|
+
const toggled = activeTab !== undefined;
|
|
85
51
|
|
|
86
|
-
const setActivePanelValue = (newValue:
|
|
87
|
-
console.log(newValue);
|
|
52
|
+
const setActivePanelValue = (newValue: ToolbarTabValue | undefined) => {
|
|
88
53
|
const params = new URLSearchParams(searchParams);
|
|
89
54
|
if (newValue === undefined) {
|
|
90
55
|
params.delete('toolbar-panel');
|
|
91
56
|
} else {
|
|
92
57
|
params.set('toolbar-panel', newValue);
|
|
93
58
|
}
|
|
94
|
-
router.push(`${pathname}?${params.toString()}`);
|
|
59
|
+
router.push(`${pathname}?${params.toString()}${location.hash}`);
|
|
95
60
|
};
|
|
96
61
|
|
|
97
|
-
const [
|
|
98
|
-
|
|
62
|
+
const [cachedSpamCheckingResult, setCachedSpamCheckingResult] =
|
|
63
|
+
useCachedState<SpamCheckingResult>(
|
|
64
|
+
`spam-assassin-${emailSlug.replaceAll('/', '-')}`,
|
|
65
|
+
);
|
|
66
|
+
const [spamCheckingResult, { load: loadSpamChecking, loading: spamLoading }] =
|
|
67
|
+
useSpamAssassin({
|
|
68
|
+
markup,
|
|
69
|
+
plainText,
|
|
70
|
+
|
|
71
|
+
initialResult: serverSpamCheckingResult ?? cachedSpamCheckingResult,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const [cachedLintingRows, setCachedLintingRows] = useCachedState<
|
|
75
|
+
LintingRow[]
|
|
76
|
+
>(`linter-${emailSlug.replaceAll('/', '-')}`);
|
|
77
|
+
const [lintingRows, { load: loadLinting, loading: lintLoading }] = useLinter({
|
|
99
78
|
markup,
|
|
100
|
-
|
|
79
|
+
|
|
80
|
+
initialRows: serverLintingRows ?? cachedLintingRows,
|
|
101
81
|
});
|
|
82
|
+
const [cachedCompatibilityResults, setCachedCompatibilityResults] =
|
|
83
|
+
useCachedState<CompatibilityCheckingResult[]>(
|
|
84
|
+
`compatibility-${emailSlug.replaceAll('/', '-')}`,
|
|
85
|
+
);
|
|
86
|
+
const [
|
|
87
|
+
compatibilityCheckingResults,
|
|
88
|
+
{ load: loadCompatibility, loading: compatibilityLoading },
|
|
89
|
+
] = useCompatibility({
|
|
90
|
+
emailPath,
|
|
91
|
+
reactMarkup,
|
|
102
92
|
|
|
103
|
-
|
|
104
|
-
slug: emailSlug,
|
|
105
|
-
markup,
|
|
93
|
+
initialResults: serverCompatibilityResults ?? cachedCompatibilityResults,
|
|
106
94
|
});
|
|
107
95
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
96
|
+
if (!isBuilding) {
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
(async () => {
|
|
99
|
+
const lintingRows = await loadLinting();
|
|
100
|
+
setCachedLintingRows(lintingRows);
|
|
101
|
+
|
|
102
|
+
const spamCheckingResult = await loadSpamChecking();
|
|
103
|
+
setCachedSpamCheckingResult(spamCheckingResult);
|
|
104
|
+
|
|
105
|
+
const compatibilityCheckingResults = await loadCompatibility();
|
|
106
|
+
setCachedCompatibilityResults(compatibilityCheckingResults);
|
|
107
|
+
})();
|
|
108
|
+
}, []);
|
|
109
|
+
}
|
|
112
110
|
|
|
113
111
|
return (
|
|
114
112
|
<div
|
|
115
|
-
{...rest}
|
|
116
113
|
data-toggled={toggled}
|
|
117
114
|
className={cn(
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
|
|
115
|
+
'absolute bottom-0 left-0 right-0',
|
|
116
|
+
'bg-black group/toolbar text-xs text-slate-11 h-52 transition-transform',
|
|
117
|
+
'data-[toggled=false]:translate-y-[170px]',
|
|
121
118
|
)}
|
|
122
119
|
>
|
|
123
120
|
<Tabs.Root
|
|
124
|
-
value={
|
|
121
|
+
value={activeTab ?? ''}
|
|
125
122
|
onValueChange={(newValue) => {
|
|
126
|
-
setActivePanelValue(newValue as
|
|
123
|
+
setActivePanelValue(newValue as ToolbarTabValue);
|
|
127
124
|
}}
|
|
128
125
|
asChild
|
|
129
126
|
>
|
|
130
127
|
<div className="flex flex-col h-full">
|
|
131
|
-
<Tabs.List className="flex gap-4 px-
|
|
128
|
+
<Tabs.List className="flex gap-4 px-4 border-b border-solid border-slate-6 h-10 w-full flex-shrink-0">
|
|
132
129
|
<LayoutGroup id="toolbar">
|
|
133
|
-
<Tabs.Trigger asChild value="spam-assassin">
|
|
134
|
-
<ToolbarButton active={activePanelValue === 'spam-assassin'}>
|
|
135
|
-
<IconScissors />
|
|
136
|
-
Spam Assassin
|
|
137
|
-
</ToolbarButton>
|
|
138
|
-
</Tabs.Trigger>
|
|
139
130
|
<Tabs.Trigger asChild value="linter">
|
|
140
|
-
<ToolbarButton active={
|
|
141
|
-
<IconScanner />
|
|
131
|
+
<ToolbarButton active={activeTab === 'linter'}>
|
|
142
132
|
Linter
|
|
143
133
|
</ToolbarButton>
|
|
144
134
|
</Tabs.Trigger>
|
|
135
|
+
<Tabs.Trigger asChild value="compatibility">
|
|
136
|
+
<ToolbarButton active={activeTab === 'compatibility'}>
|
|
137
|
+
Compatibility
|
|
138
|
+
</ToolbarButton>
|
|
139
|
+
</Tabs.Trigger>
|
|
140
|
+
<Tabs.Trigger asChild value="spam-assassin">
|
|
141
|
+
<ToolbarButton active={activeTab === 'spam-assassin'}>
|
|
142
|
+
Spam
|
|
143
|
+
</ToolbarButton>
|
|
144
|
+
</Tabs.Trigger>
|
|
145
145
|
</LayoutGroup>
|
|
146
|
-
<div className="flex gap-
|
|
146
|
+
<div className="flex gap-0.5 ml-auto">
|
|
147
147
|
<ToolbarButton
|
|
148
|
-
|
|
148
|
+
delayDuration={0}
|
|
149
|
+
tooltip={
|
|
150
|
+
(activeTab === 'linter' &&
|
|
151
|
+
'The Linter tab checks all the images and links for common issues like missing alt text, broken URLs, insecure HTTP methods, and more.') ||
|
|
152
|
+
(activeTab === 'spam-assassin' &&
|
|
153
|
+
'The Spam tab will look at the content and use a robust scoring framework to determine if the email is likely to be spam. Powered by SpamAssassin.') ||
|
|
154
|
+
(activeTab === 'compatibility' &&
|
|
155
|
+
'The Compatibility tab shows how well the HTML/CSS is supported across mail clients like Outlook, Gmail, etc. Powered by Can I Email.') ||
|
|
156
|
+
'Info'
|
|
157
|
+
}
|
|
149
158
|
onClick={() => {
|
|
150
|
-
if (
|
|
151
|
-
void loadSpamChecking();
|
|
152
|
-
} else if (activePanelValue === 'linter') {
|
|
153
|
-
void loadLinting();
|
|
154
|
-
} else {
|
|
159
|
+
if (activeTab === undefined) {
|
|
155
160
|
setActivePanelValue('linter');
|
|
156
|
-
|
|
161
|
+
} else {
|
|
162
|
+
setActivePanelValue(undefined);
|
|
157
163
|
}
|
|
158
164
|
}}
|
|
159
165
|
>
|
|
160
|
-
<
|
|
166
|
+
<IconInfo size={24} />
|
|
161
167
|
</ToolbarButton>
|
|
168
|
+
{isBuilding ? null : (
|
|
169
|
+
<ToolbarButton
|
|
170
|
+
tooltip="Reload"
|
|
171
|
+
disabled={lintLoading || spamLoading}
|
|
172
|
+
onClick={async () => {
|
|
173
|
+
if (activeTab === undefined) {
|
|
174
|
+
setActivePanelValue('linter');
|
|
175
|
+
}
|
|
176
|
+
if (activeTab === 'spam-assassin') {
|
|
177
|
+
await loadSpamChecking();
|
|
178
|
+
} else if (activeTab === 'linter') {
|
|
179
|
+
await loadLinting();
|
|
180
|
+
} else if (activeTab === 'compatibility') {
|
|
181
|
+
await loadCompatibility();
|
|
182
|
+
}
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
<IconReload
|
|
186
|
+
size={24}
|
|
187
|
+
className={cn({
|
|
188
|
+
'animate-spin opacity-60 animate-spin-fast':
|
|
189
|
+
lintLoading || spamLoading,
|
|
190
|
+
})}
|
|
191
|
+
/>
|
|
192
|
+
</ToolbarButton>
|
|
193
|
+
)}
|
|
162
194
|
<ToolbarButton
|
|
163
195
|
tooltip="Toggle toolbar"
|
|
164
196
|
onClick={() => {
|
|
165
|
-
if (
|
|
197
|
+
if (activeTab === undefined) {
|
|
166
198
|
setActivePanelValue('linter');
|
|
167
199
|
} else {
|
|
168
200
|
setActivePanelValue(undefined);
|
|
169
201
|
}
|
|
170
202
|
}}
|
|
171
203
|
>
|
|
172
|
-
<IconArrowDown
|
|
204
|
+
<IconArrowDown
|
|
205
|
+
size={24}
|
|
206
|
+
className="transition-transform group-data-[toggled=false]/toolbar:rotate-180"
|
|
207
|
+
/>
|
|
173
208
|
</ToolbarButton>
|
|
174
209
|
</div>
|
|
175
210
|
</Tabs.List>
|
|
176
211
|
|
|
177
|
-
<div className="flex-grow transition-opacity opacity-100 group-data-[toggled=false]/toolbar:opacity-0 overflow-y-auto px-2">
|
|
212
|
+
<div className="flex-grow transition-opacity opacity-100 group-data-[toggled=false]/toolbar:opacity-0 overflow-y-auto px-2 pt-3">
|
|
178
213
|
<Tabs.Content value="linter">
|
|
179
|
-
|
|
214
|
+
{lintLoading ? (
|
|
215
|
+
<div className="animate-pulse text-slate-11 text-sm pt-1">
|
|
216
|
+
Running linting...
|
|
217
|
+
</div>
|
|
218
|
+
) : (
|
|
219
|
+
<Linter rows={lintingRows} />
|
|
220
|
+
)}
|
|
180
221
|
</Tabs.Content>
|
|
181
222
|
<Tabs.Content value="spam-assassin">
|
|
182
|
-
|
|
223
|
+
{spamLoading ? (
|
|
224
|
+
<div className="animate-pulse text-slate-11 text-sm pt-1">
|
|
225
|
+
Running spam check...
|
|
226
|
+
</div>
|
|
227
|
+
) : (
|
|
228
|
+
<SpamAssassin result={spamCheckingResult} />
|
|
229
|
+
)}
|
|
230
|
+
</Tabs.Content>
|
|
231
|
+
<Tabs.Content value="compatibility">
|
|
232
|
+
{compatibilityLoading ? (
|
|
233
|
+
<div className="animate-pulse text-slate-11 text-sm pt-1">
|
|
234
|
+
Running compatibility check...
|
|
235
|
+
</div>
|
|
236
|
+
) : (
|
|
237
|
+
<Compatibility results={compatibilityCheckingResults} />
|
|
238
|
+
)}
|
|
183
239
|
</Tabs.Content>
|
|
184
240
|
</div>
|
|
185
241
|
</div>
|
|
@@ -187,3 +243,33 @@ export const Toolbar = ({
|
|
|
187
243
|
</div>
|
|
188
244
|
);
|
|
189
245
|
};
|
|
246
|
+
|
|
247
|
+
interface ToolbarProps {
|
|
248
|
+
serverSpamCheckingResult: SpamCheckingResult | undefined;
|
|
249
|
+
serverLintingRows: LintingRow[] | undefined;
|
|
250
|
+
serverCompatibilityResults: CompatibilityCheckingResult[] | undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const Toolbar = ({
|
|
254
|
+
serverLintingRows,
|
|
255
|
+
serverSpamCheckingResult,
|
|
256
|
+
serverCompatibilityResults,
|
|
257
|
+
}: ToolbarProps) => {
|
|
258
|
+
const { emailPath, emailSlug, renderedEmailMetadata } = use(PreviewContext)!;
|
|
259
|
+
|
|
260
|
+
if (renderedEmailMetadata === undefined) return null;
|
|
261
|
+
const { markup, plainText, reactMarkup } = renderedEmailMetadata;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<ToolbarInner
|
|
265
|
+
emailPath={emailPath}
|
|
266
|
+
emailSlug={emailSlug}
|
|
267
|
+
markup={markup}
|
|
268
|
+
reactMarkup={reactMarkup}
|
|
269
|
+
plainText={plainText}
|
|
270
|
+
serverLintingRows={serverLintingRows}
|
|
271
|
+
serverSpamCheckingResult={serverSpamCheckingResult}
|
|
272
|
+
serverCompatibilityResults={serverCompatibilityResults}
|
|
273
|
+
/>
|
|
274
|
+
);
|
|
275
|
+
};
|
|
@@ -123,7 +123,7 @@ const PresetMenuItem = ({
|
|
|
123
123
|
onClick={() => onSelect(dimensions)}
|
|
124
124
|
>
|
|
125
125
|
{name}
|
|
126
|
-
<span className="flex h-fit items-center rounded-full bg-slate-6 px-
|
|
126
|
+
<span className="flex h-fit items-center rounded-full bg-slate-6 px-2 py-1 font-medium text-slate-11 text-xs">
|
|
127
127
|
{dimensions.width}x{dimensions.height}
|
|
128
128
|
</span>
|
|
129
129
|
</DropdownMenu.Item>
|