notionsoft-ui 1.0.34 → 1.0.35
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/.storybook/main.ts +19 -13
- package/.storybook/preview.css +0 -16
- package/package.json +7 -2
- package/src/notion-ui/animated-item/animated-item.tsx +1 -1
- package/src/notion-ui/animated-item/index.ts +1 -1
- package/src/notion-ui/button/Button.stories.tsx +31 -8
- package/src/notion-ui/button/button.tsx +10 -2
- package/src/notion-ui/button-spinner/ButtonSpinner.stories.tsx +42 -34
- package/src/notion-ui/button-spinner/button-spinner.tsx +4 -5
- package/src/notion-ui/cache-svg/CachedSvg.stories.tsx +74 -0
- package/src/notion-ui/cache-svg/cached-svg.tsx +150 -0
- package/src/notion-ui/cache-svg/index.ts +3 -0
- package/src/notion-ui/cache-svg/utils.ts +7 -0
- package/src/notion-ui/cached-image/cached-image.stories.tsx +109 -0
- package/src/notion-ui/cached-image/cached-image.tsx +213 -0
- package/src/notion-ui/cached-image/index.ts +3 -0
- package/src/notion-ui/cached-image/utils.ts +7 -0
- package/src/notion-ui/date-picker/DatePicker.stories.tsx +0 -2
- package/src/notion-ui/date-picker/date-picker.tsx +5 -5
- package/src/notion-ui/input/Input.stories.tsx +1 -1
- package/src/notion-ui/input/input.tsx +5 -4
- package/src/notion-ui/multi-date-picker/multi-date-picker.tsx +5 -5
- package/src/notion-ui/page-size-select/page-size-select.tsx +29 -12
- package/src/notion-ui/password-input/password-input.tsx +3 -3
- package/src/notion-ui/phone-input/phone-input.tsx +38 -8
- package/src/notion-ui/shimmer/shimmer.tsx +9 -3
- package/src/notion-ui/shining-text/shining-text.tsx +2 -6
- package/src/notion-ui/sidebar/index.ts +3 -0
- package/src/notion-ui/sidebar/sidebar-item.tsx +198 -0
- package/src/notion-ui/sidebar/sidebar.stories.tsx +181 -0
- package/src/notion-ui/sidebar/sidebar.tsx +284 -0
- package/src/notion-ui/textarea/Textarea.stories.tsx +1 -1
- package/src/notion-ui/textarea/textarea.tsx +3 -3
package/.storybook/main.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
|
-
import type { StorybookConfig } from
|
|
2
|
-
|
|
1
|
+
import type { StorybookConfig } from "@storybook/react-vite";
|
|
2
|
+
import { mergeConfig } from "vite";
|
|
3
|
+
import tsconfigPaths from "vite-tsconfig-paths";
|
|
3
4
|
const config: StorybookConfig = {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
|
|
7
|
-
],
|
|
8
|
-
"addons": [
|
|
5
|
+
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
|
6
|
+
addons: [
|
|
9
7
|
"@chromatic-com/storybook",
|
|
10
8
|
"@storybook/addon-docs",
|
|
11
9
|
"@storybook/addon-a11y",
|
|
12
|
-
"@storybook/addon-vitest"
|
|
10
|
+
"@storybook/addon-vitest",
|
|
13
11
|
],
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
12
|
+
framework: {
|
|
13
|
+
name: "@storybook/react-vite",
|
|
14
|
+
options: {},
|
|
15
|
+
},
|
|
16
|
+
async viteFinal(config) {
|
|
17
|
+
return mergeConfig(config, {
|
|
18
|
+
plugins: [tsconfigPaths()],
|
|
19
|
+
define: {
|
|
20
|
+
"import.meta.env.VITE_API_BASE_URL": JSON.stringify(""),
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
},
|
|
18
24
|
};
|
|
19
|
-
export default config;
|
|
25
|
+
export default config;
|
package/.storybook/preview.css
CHANGED
|
@@ -47,22 +47,6 @@
|
|
|
47
47
|
--color-primary-background: var(--primary-background);
|
|
48
48
|
--primary-background: oklch(20.435% 0.06485 262.22);
|
|
49
49
|
|
|
50
|
-
--text-sm-ltr: 11px;
|
|
51
|
-
--text-md-ltr: 12px;
|
|
52
|
-
--text-lg-ltr: 13px;
|
|
53
|
-
--text-xl-ltr: 14px;
|
|
54
|
-
--text-2xl-ltr: 15px;
|
|
55
|
-
--text-3xl-ltr: 17px;
|
|
56
|
-
--text-4xl-ltr: 20px;
|
|
57
|
-
|
|
58
|
-
--text-sm-rtl: 15px;
|
|
59
|
-
--text-md-rtl: 16px;
|
|
60
|
-
--text-lg-rtl: 17px;
|
|
61
|
-
--text-xl-rtl: 18px;
|
|
62
|
-
--text-2xl-rtl: 19px;
|
|
63
|
-
--text-3xl-rtl: 20px;
|
|
64
|
-
--text-4xl-rtl: 22px;
|
|
65
|
-
|
|
66
50
|
--breakpoint-xxl: 500px;
|
|
67
51
|
--animate-shimmer: shimmer 2.2s infinite linear;
|
|
68
52
|
@keyframes shimmer {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "notionsoft-ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.35",
|
|
4
4
|
"description": "A React UI component installer (shadcn-style). Installs components directly into your project.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"notionsoft-ui": "./cli/index.cjs"
|
|
@@ -22,12 +22,17 @@
|
|
|
22
22
|
"chalk": "^4.1.2",
|
|
23
23
|
"clsx": "^2.1.1",
|
|
24
24
|
"commander": "^12.0.0",
|
|
25
|
+
"dompurify": "^3.3.1",
|
|
25
26
|
"fs-extra": "^11.2.0",
|
|
27
|
+
"i18next": "^25.7.3",
|
|
26
28
|
"lucide-react": "^0.554.0",
|
|
27
29
|
"react": "^19.2.0",
|
|
28
30
|
"react-dom": "^19.2.0",
|
|
31
|
+
"react-i18next": "^16.5.0",
|
|
29
32
|
"react-multi-date-picker": "^4.5.2",
|
|
30
|
-
"
|
|
33
|
+
"react-router": "^7.11.0",
|
|
34
|
+
"tailwind-merge": "^3.4.0",
|
|
35
|
+
"vite-tsconfig-paths": "^6.0.3"
|
|
31
36
|
},
|
|
32
37
|
"devDependencies": {
|
|
33
38
|
"@chromatic-com/storybook": "^4.1.3",
|
|
@@ -32,7 +32,7 @@ export interface AnimatedItemProps {
|
|
|
32
32
|
children: React.ReactNode | ((inView: boolean) => React.ReactNode);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export
|
|
35
|
+
export function AnimatedItem(props: AnimatedItemProps) {
|
|
36
36
|
const [inView, setInView] = useState(false);
|
|
37
37
|
const { springProps, intersectionArgs, children } = props;
|
|
38
38
|
const defaultOnStart = useCallback(
|
|
@@ -2,12 +2,13 @@ import type { Meta, StoryObj } from "@storybook/react";
|
|
|
2
2
|
import Button from "./button";
|
|
3
3
|
|
|
4
4
|
const meta: Meta<typeof Button> = {
|
|
5
|
-
title: "
|
|
5
|
+
title: "Components/Button",
|
|
6
6
|
component: Button,
|
|
7
|
+
tags: ["autodocs"],
|
|
7
8
|
argTypes: {
|
|
8
9
|
variant: {
|
|
9
10
|
control: "select",
|
|
10
|
-
options: ["
|
|
11
|
+
options: ["primary", "secondary", "warning", "success", "outline"],
|
|
11
12
|
},
|
|
12
13
|
disabled: {
|
|
13
14
|
control: "boolean",
|
|
@@ -15,7 +16,11 @@ const meta: Meta<typeof Button> = {
|
|
|
15
16
|
children: {
|
|
16
17
|
control: "text",
|
|
17
18
|
},
|
|
18
|
-
|
|
19
|
+
},
|
|
20
|
+
args: {
|
|
21
|
+
children: "Button",
|
|
22
|
+
variant: "primary",
|
|
23
|
+
disabled: false,
|
|
19
24
|
},
|
|
20
25
|
};
|
|
21
26
|
|
|
@@ -25,33 +30,51 @@ type Story = StoryObj<typeof Button>;
|
|
|
25
30
|
|
|
26
31
|
export const Primary: Story = {
|
|
27
32
|
args: {
|
|
28
|
-
children: "Primary Button",
|
|
29
33
|
variant: "primary",
|
|
30
34
|
},
|
|
31
35
|
};
|
|
32
36
|
|
|
33
37
|
export const Secondary: Story = {
|
|
34
38
|
args: {
|
|
35
|
-
children: "Secondary Button",
|
|
36
39
|
variant: "secondary",
|
|
37
40
|
},
|
|
38
41
|
};
|
|
39
42
|
|
|
40
43
|
export const Warning: Story = {
|
|
41
44
|
args: {
|
|
42
|
-
children: "Warning Button",
|
|
43
45
|
variant: "warning",
|
|
46
|
+
children: "Delete",
|
|
44
47
|
},
|
|
45
48
|
};
|
|
49
|
+
|
|
46
50
|
export const Success: Story = {
|
|
47
51
|
args: {
|
|
48
|
-
children: "Success Button",
|
|
49
52
|
variant: "success",
|
|
53
|
+
children: "Saved",
|
|
50
54
|
},
|
|
51
55
|
};
|
|
56
|
+
|
|
52
57
|
export const Outline: Story = {
|
|
53
58
|
args: {
|
|
54
|
-
children: "Outline Button",
|
|
55
59
|
variant: "outline",
|
|
56
60
|
},
|
|
57
61
|
};
|
|
62
|
+
|
|
63
|
+
export const Disabled: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
disabled: true,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const AllVariants: Story = {
|
|
70
|
+
render: () => (
|
|
71
|
+
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
|
72
|
+
<Button variant="primary">Primary</Button>
|
|
73
|
+
<Button variant="secondary">Secondary</Button>
|
|
74
|
+
<Button variant="success">Success</Button>
|
|
75
|
+
<Button variant="warning">Warning</Button>
|
|
76
|
+
<Button variant="outline">Outline</Button>
|
|
77
|
+
<Button disabled>Disabled</Button>
|
|
78
|
+
</div>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
@@ -3,7 +3,13 @@ import { cn } from "../../utils/cn";
|
|
|
3
3
|
// import { cn } from "@/utils/cn";
|
|
4
4
|
|
|
5
5
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
6
|
-
variant?:
|
|
6
|
+
variant?:
|
|
7
|
+
| "primary"
|
|
8
|
+
| "secondary"
|
|
9
|
+
| "warning"
|
|
10
|
+
| "success"
|
|
11
|
+
| "outline"
|
|
12
|
+
| "icon";
|
|
7
13
|
}
|
|
8
14
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
9
15
|
(props, ref: any) => {
|
|
@@ -13,6 +19,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
13
19
|
? "border hover:bg-primary hover:text-primary-foreground"
|
|
14
20
|
: variant == "warning"
|
|
15
21
|
? "bg-red-500 text-primary-foreground"
|
|
22
|
+
: variant == "icon"
|
|
23
|
+
? "rtl:px-3 rtl:py-1 ltr:py-2 border gap-x-3 text-primary border border-primary/10 hover:bg-primary/5 hover:opacity-90 transition-opacity px-5"
|
|
16
24
|
: variant == "success"
|
|
17
25
|
? "bg-green-500 text-primary-foreground"
|
|
18
26
|
: variant == "outline"
|
|
@@ -24,7 +32,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
24
32
|
disabled={disabled}
|
|
25
33
|
ref={ref}
|
|
26
34
|
className={cn(
|
|
27
|
-
`rounded-sm
|
|
35
|
+
`rounded-sm items-center justify-center flex gap-x-1 leading-snug cursor-pointer ltr:text-xs rtl:text-[13px] sm:rtl:text-sm rtl:font-semibold
|
|
28
36
|
transition w-fit px-3 py-1.5 duration-200 ease-linear`,
|
|
29
37
|
style,
|
|
30
38
|
disabled &&
|
|
@@ -1,58 +1,66 @@
|
|
|
1
|
-
import Button from "../button/button";
|
|
2
|
-
import ButtonSpinner from "./button-spinner";
|
|
3
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import ButtonSpinner from "./button-spinner";
|
|
4
|
+
import Button from "../button/button";
|
|
4
5
|
|
|
5
6
|
const meta: Meta<typeof ButtonSpinner> = {
|
|
6
|
-
title: "
|
|
7
|
+
title: "Components/ButtonSpinner",
|
|
7
8
|
component: ButtonSpinner,
|
|
8
|
-
|
|
9
|
-
layout: "centered",
|
|
10
|
-
},
|
|
9
|
+
tags: ["autodocs"],
|
|
11
10
|
argTypes: {
|
|
12
|
-
loading: {
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
loading: {
|
|
12
|
+
control: "boolean",
|
|
13
|
+
},
|
|
14
|
+
children: {
|
|
15
|
+
control: false,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
loading: false,
|
|
15
20
|
},
|
|
16
21
|
};
|
|
17
22
|
|
|
18
23
|
export default meta;
|
|
19
|
-
type Story = StoryObj<typeof ButtonSpinner>;
|
|
20
|
-
|
|
21
|
-
/* -----------------------------
|
|
22
|
-
Template: how ButtonSpinner
|
|
23
|
-
is intended to be used
|
|
24
|
-
------------------------------ */
|
|
25
|
-
const Template = (args) => (
|
|
26
|
-
<Button disabled={args.loading} className="flex items-center gap-3">
|
|
27
|
-
<ButtonSpinner {...args} />
|
|
28
|
-
</Button>
|
|
29
|
-
);
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
Stories
|
|
33
|
-
------------------------------ */
|
|
25
|
+
type Story = StoryObj<typeof ButtonSpinner>;
|
|
34
26
|
|
|
35
27
|
export const Default: Story = {
|
|
36
|
-
render:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
28
|
+
render: (args) => (
|
|
29
|
+
<Button>
|
|
30
|
+
<ButtonSpinner {...args}>Submit</ButtonSpinner>
|
|
31
|
+
</Button>
|
|
32
|
+
),
|
|
41
33
|
};
|
|
42
34
|
|
|
43
35
|
export const Loading: Story = {
|
|
44
|
-
render: Template,
|
|
45
36
|
args: {
|
|
46
37
|
loading: true,
|
|
47
|
-
children: "Saving...",
|
|
48
38
|
},
|
|
39
|
+
render: (args) => (
|
|
40
|
+
<Button>
|
|
41
|
+
<ButtonSpinner {...args}>Submitting</ButtonSpinner>
|
|
42
|
+
</Button>
|
|
43
|
+
),
|
|
49
44
|
};
|
|
50
45
|
|
|
51
|
-
export const
|
|
52
|
-
render: Template,
|
|
46
|
+
export const WithDifferentText: Story = {
|
|
53
47
|
args: {
|
|
54
48
|
loading: true,
|
|
55
|
-
className: "text-blue-600 font-semibold",
|
|
56
|
-
children: "Processing...",
|
|
57
49
|
},
|
|
50
|
+
render: (args) => (
|
|
51
|
+
<Button variant="success">
|
|
52
|
+
<ButtonSpinner {...args}>Saving</ButtonSpinner>
|
|
53
|
+
</Button>
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const InlineUsage: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<div className="flex items-center gap-2">
|
|
60
|
+
<span>Loading</span>
|
|
61
|
+
<ButtonSpinner loading={true}>
|
|
62
|
+
<span className="sr-only">spinner</span>
|
|
63
|
+
</ButtonSpinner>
|
|
64
|
+
</div>
|
|
65
|
+
),
|
|
58
66
|
};
|
|
@@ -4,23 +4,22 @@ import { cn } from "../../utils/cn";
|
|
|
4
4
|
export interface IButtonSpinnerProps {
|
|
5
5
|
children: any;
|
|
6
6
|
loading: boolean;
|
|
7
|
-
className?: string;
|
|
8
7
|
}
|
|
9
8
|
|
|
10
9
|
export default function ButtonSpinner(props: IButtonSpinnerProps) {
|
|
11
|
-
const { loading, children
|
|
10
|
+
const { loading, children } = props;
|
|
12
11
|
return (
|
|
13
12
|
<>
|
|
13
|
+
{children}
|
|
14
14
|
{loading && (
|
|
15
|
-
<div className="relative
|
|
15
|
+
<div className="relative size-3">
|
|
16
16
|
{/* <!-- Ring --> */}
|
|
17
17
|
<div
|
|
18
|
-
className="
|
|
18
|
+
className="size-3 rounded-full animate-spin absolute
|
|
19
19
|
border border-solid border-secondary border-t-transparent"
|
|
20
20
|
/>
|
|
21
21
|
</div>
|
|
22
22
|
)}
|
|
23
|
-
<h1 className={cn("", className)}>{children}</h1>
|
|
24
23
|
</>
|
|
25
24
|
);
|
|
26
25
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
|
|
3
|
+
import CachedSvg from "./cached-svg";
|
|
4
|
+
|
|
5
|
+
/* ---------------------------------- */
|
|
6
|
+
/* Meta */
|
|
7
|
+
/* ---------------------------------- */
|
|
8
|
+
const meta: Meta<typeof CachedSvg> = {
|
|
9
|
+
title: "Media/CachedSvg",
|
|
10
|
+
component: CachedSvg,
|
|
11
|
+
tags: ["autodocs"],
|
|
12
|
+
argTypes: {
|
|
13
|
+
className: { control: "text" },
|
|
14
|
+
classNames: { control: false },
|
|
15
|
+
src: { control: "text" },
|
|
16
|
+
fetch: { control: false },
|
|
17
|
+
apiConfig: { control: false },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default meta;
|
|
22
|
+
|
|
23
|
+
type Story = StoryObj<typeof CachedSvg>;
|
|
24
|
+
|
|
25
|
+
/* ---------------------------------- */
|
|
26
|
+
/* Sample SVG string */
|
|
27
|
+
/* ---------------------------------- */
|
|
28
|
+
const sampleSvg = `<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
29
|
+
<circle cx="12" cy="12" r="10" fill="currentColor"/>
|
|
30
|
+
</svg>`;
|
|
31
|
+
|
|
32
|
+
/* ---------------------------------- */
|
|
33
|
+
/* Stories */
|
|
34
|
+
/* ---------------------------------- */
|
|
35
|
+
|
|
36
|
+
/* -------- Fetch-based example -------- */
|
|
37
|
+
export const FetchExample: Story = {
|
|
38
|
+
render: () => (
|
|
39
|
+
<div style={{ width: 40, height: 40 }}>
|
|
40
|
+
<CachedSvg
|
|
41
|
+
src="https://dummy-svg-url.com/sample.svg"
|
|
42
|
+
fetch={async () => {
|
|
43
|
+
return new Response(sampleSvg, { status: 200 });
|
|
44
|
+
}}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/* -------- API-config-based example -------- */
|
|
51
|
+
export const ApiConfigExample: Story = {
|
|
52
|
+
render: () => (
|
|
53
|
+
<div style={{ width: 40, height: 40 }}>
|
|
54
|
+
<CachedSvg
|
|
55
|
+
apiConfig={{
|
|
56
|
+
src: "https://dummy-api.com/svg",
|
|
57
|
+
headers: { Authorization: "Bearer token" },
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/* -------- Loading state (Shimmer) -------- */
|
|
65
|
+
export const LoadingState: Story = {
|
|
66
|
+
render: () => (
|
|
67
|
+
<div style={{ width: 40, height: 40 }}>
|
|
68
|
+
<CachedSvg
|
|
69
|
+
src="https://dummy-loading.com/svg"
|
|
70
|
+
fetch={() => new Promise(() => {})} // never resolves to simulate loading
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React, { forwardRef, useEffect, useRef, useState } from "react";
|
|
2
|
+
import Shimmer from "../shimmer";
|
|
3
|
+
import { cn } from "../../utils/cn";
|
|
4
|
+
import DOMPurify from "dompurify";
|
|
5
|
+
|
|
6
|
+
/* ---------------------------------- */
|
|
7
|
+
/* Types */
|
|
8
|
+
/* ---------------------------------- */
|
|
9
|
+
|
|
10
|
+
interface BaseSvgProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
11
|
+
classNames?: {
|
|
12
|
+
shimmerClassName?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FetchSvgProps extends BaseSvgProps {
|
|
17
|
+
src: string;
|
|
18
|
+
fetch: (src: string) => Promise<Response>;
|
|
19
|
+
apiConfig?: never;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ApiConfigSvgProps extends BaseSvgProps {
|
|
23
|
+
apiConfig: {
|
|
24
|
+
src: string;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
src?: never;
|
|
28
|
+
fetch?: never;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CachedSvgProps = FetchSvgProps | ApiConfigSvgProps;
|
|
32
|
+
|
|
33
|
+
/* ---------------------------------- */
|
|
34
|
+
/* Cache */
|
|
35
|
+
/* ---------------------------------- */
|
|
36
|
+
|
|
37
|
+
const SVG_CACHE = "svg-cache-v1";
|
|
38
|
+
|
|
39
|
+
/* ---------------------------------- */
|
|
40
|
+
/* Sanitizer */
|
|
41
|
+
/* ---------------------------------- */
|
|
42
|
+
|
|
43
|
+
function sanitizeSvg(svg: string): string {
|
|
44
|
+
return DOMPurify.sanitize(svg, {
|
|
45
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
46
|
+
FORBID_TAGS: ["script", "foreignObject", "iframe", "object", "embed"],
|
|
47
|
+
FORBID_ATTR: [
|
|
48
|
+
"onload",
|
|
49
|
+
"onclick",
|
|
50
|
+
"onmouseover",
|
|
51
|
+
"onerror",
|
|
52
|
+
"href",
|
|
53
|
+
"xlink:href",
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ---------------------------------- */
|
|
59
|
+
/* Component */
|
|
60
|
+
/* ---------------------------------- */
|
|
61
|
+
|
|
62
|
+
const CachedSvg = forwardRef<HTMLDivElement, CachedSvgProps>(
|
|
63
|
+
({ className, classNames, fetch, apiConfig, ...rest }, ref) => {
|
|
64
|
+
const [svg, setSvg] = useState<string | null>(null);
|
|
65
|
+
const [loading, setLoading] = useState(true);
|
|
66
|
+
|
|
67
|
+
const fetchRef = useRef(fetch);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
fetchRef.current = fetch;
|
|
70
|
+
}, [fetch]);
|
|
71
|
+
|
|
72
|
+
async function loadSvg() {
|
|
73
|
+
try {
|
|
74
|
+
const src = apiConfig?.src ?? (rest as any).src;
|
|
75
|
+
if (!src) return;
|
|
76
|
+
|
|
77
|
+
/* ---------------- Cache ---------------- */
|
|
78
|
+
|
|
79
|
+
if ("caches" in window) {
|
|
80
|
+
const cache = await caches.open(SVG_CACHE);
|
|
81
|
+
const cached = await cache.match(src);
|
|
82
|
+
|
|
83
|
+
if (cached) {
|
|
84
|
+
const text = await cached.text();
|
|
85
|
+
setSvg(sanitizeSvg(text));
|
|
86
|
+
setLoading(false);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------------- Fetch ---------------- */
|
|
92
|
+
|
|
93
|
+
const response = fetchRef.current
|
|
94
|
+
? await fetchRef.current(src)
|
|
95
|
+
: await window.fetch(src, { headers: apiConfig?.headers });
|
|
96
|
+
|
|
97
|
+
if (!response.ok) throw new Error("SVG fetch failed");
|
|
98
|
+
|
|
99
|
+
const clone = response.clone();
|
|
100
|
+
const text = await response.text();
|
|
101
|
+
|
|
102
|
+
setSvg(sanitizeSvg(text));
|
|
103
|
+
|
|
104
|
+
if ("caches" in window) {
|
|
105
|
+
const cache = await caches.open(SVG_CACHE);
|
|
106
|
+
await cache.put(src, clone);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error(err);
|
|
110
|
+
} finally {
|
|
111
|
+
setLoading(false);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
loadSvg();
|
|
117
|
+
}, [apiConfig?.src, (rest as any).src]);
|
|
118
|
+
|
|
119
|
+
/* ---------------- UI ---------------- */
|
|
120
|
+
const iconStyle = "opacity-90 rounded-full w-[20px] h-[18px]";
|
|
121
|
+
if (loading || !svg) {
|
|
122
|
+
return (
|
|
123
|
+
<Shimmer
|
|
124
|
+
className={cn(
|
|
125
|
+
"bg-primary/10",
|
|
126
|
+
iconStyle,
|
|
127
|
+
classNames?.shimmerClassName
|
|
128
|
+
)}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<div
|
|
135
|
+
ref={ref}
|
|
136
|
+
className={cn(
|
|
137
|
+
"[&>svg>path]:fill-current [&>svg>g>*]:fill-current items-center justify-center flex",
|
|
138
|
+
iconStyle,
|
|
139
|
+
className
|
|
140
|
+
)}
|
|
141
|
+
dangerouslySetInnerHTML={{ __html: svg }}
|
|
142
|
+
{...rest}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
CachedSvg.displayName = "CachedSvg";
|
|
149
|
+
|
|
150
|
+
export default CachedSvg;
|