notionsoft-ui 1.0.34 → 1.0.36
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/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/cached-svg/CachedSvg.stories.tsx +74 -0
- package/src/notion-ui/cached-svg/cached-svg.tsx +150 -0
- package/src/notion-ui/cached-svg/index.ts +3 -0
- package/src/notion-ui/cached-svg/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.36",
|
|
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,109 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import CachedImage, { ImageProps } from "./cached-image";
|
|
3
|
+
|
|
4
|
+
/* ---------------------------------- */
|
|
5
|
+
/* Helpers */
|
|
6
|
+
/* ---------------------------------- */
|
|
7
|
+
|
|
8
|
+
// Basic fetch wrapper
|
|
9
|
+
const fetchImage = async (src: string): Promise<Response> => {
|
|
10
|
+
return fetch(src);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Delayed fetch to show shimmer/loading
|
|
14
|
+
const delayedFetch = async (src: string): Promise<Response> => {
|
|
15
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
16
|
+
return fetch(src);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/* ---------------------------------- */
|
|
20
|
+
/* Meta */
|
|
21
|
+
/* ---------------------------------- */
|
|
22
|
+
|
|
23
|
+
const meta: Meta<ImageProps> = {
|
|
24
|
+
title: "Media/CachedImage",
|
|
25
|
+
component: CachedImage,
|
|
26
|
+
parameters: {
|
|
27
|
+
layout: "centered",
|
|
28
|
+
},
|
|
29
|
+
argTypes: {
|
|
30
|
+
className: {
|
|
31
|
+
control: "text",
|
|
32
|
+
},
|
|
33
|
+
classNames: {
|
|
34
|
+
control: "object",
|
|
35
|
+
},
|
|
36
|
+
fetch: {
|
|
37
|
+
table: { disable: true },
|
|
38
|
+
},
|
|
39
|
+
apiConfig: {
|
|
40
|
+
table: { disable: true },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export default meta;
|
|
46
|
+
type Story = StoryObj<ImageProps>;
|
|
47
|
+
|
|
48
|
+
/* ---------------------------------- */
|
|
49
|
+
/* Stories */
|
|
50
|
+
/* ---------------------------------- */
|
|
51
|
+
|
|
52
|
+
// Default (fetch + cache)
|
|
53
|
+
export const Default: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
src: "/images/sample.jpg",
|
|
56
|
+
fetch: fetchImage,
|
|
57
|
+
className: "w-40 h-40 rounded-md",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// API config variant
|
|
62
|
+
export const WithApiConfig: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
apiConfig: {
|
|
65
|
+
src: "/images/sample.jpg",
|
|
66
|
+
},
|
|
67
|
+
className: "w-40 h-40 rounded-lg",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Cross-origin image (bypasses cache)
|
|
72
|
+
export const CrossOrigin: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
src: "https://picsum.photos/200",
|
|
75
|
+
fetch: fetchImage,
|
|
76
|
+
className: "w-40 h-40 rounded-full",
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Loading / shimmer state
|
|
81
|
+
export const LoadingState: Story = {
|
|
82
|
+
args: {
|
|
83
|
+
src: "/images/sample.jpg",
|
|
84
|
+
fetch: delayedFetch,
|
|
85
|
+
className: "w-32 h-32",
|
|
86
|
+
classNames: {
|
|
87
|
+
shimmerClassName: "bg-gray-200",
|
|
88
|
+
shimmerIconClassName: "stroke-gray-400",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Small size
|
|
94
|
+
export const Small: Story = {
|
|
95
|
+
args: {
|
|
96
|
+
src: "/images/sample.jpg",
|
|
97
|
+
fetch: fetchImage,
|
|
98
|
+
className: "w-16 h-16 rounded",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Large size
|
|
103
|
+
export const Large: Story = {
|
|
104
|
+
args: {
|
|
105
|
+
src: "/images/sample.jpg",
|
|
106
|
+
fetch: fetchImage,
|
|
107
|
+
className: "w-64 h-64 rounded-xl",
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import Shimmer from "@/components/notion-ui/shimmer";
|
|
2
|
+
import { cn } from "@/utils/cn";
|
|
3
|
+
import React, { useEffect, useState, forwardRef, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
/* ---------------------------------- */
|
|
6
|
+
/* Types */
|
|
7
|
+
/* ---------------------------------- */
|
|
8
|
+
|
|
9
|
+
interface FetchConfig {
|
|
10
|
+
src: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
params?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface BaseImageProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
16
|
+
classNames?: {
|
|
17
|
+
shimmerClassName?: string;
|
|
18
|
+
shimmerIconClassName?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FetchImageProps extends BaseImageProps {
|
|
23
|
+
src: string;
|
|
24
|
+
fetch: (src: string) => Promise<Response>;
|
|
25
|
+
apiConfig?: never;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ApiConfigImageProps extends BaseImageProps {
|
|
29
|
+
apiConfig: FetchConfig;
|
|
30
|
+
src?: never;
|
|
31
|
+
fetch?: never;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ImageProps = FetchImageProps | ApiConfigImageProps;
|
|
35
|
+
|
|
36
|
+
/* ---------------------------------- */
|
|
37
|
+
/* Cache helpers */
|
|
38
|
+
/* ---------------------------------- */
|
|
39
|
+
|
|
40
|
+
const IMAGE_CACHE = "image-cache-v1";
|
|
41
|
+
|
|
42
|
+
async function getCachedImage(url: string): Promise<string | null> {
|
|
43
|
+
const cache = await caches.open(IMAGE_CACHE);
|
|
44
|
+
const cached = await cache.match(url);
|
|
45
|
+
|
|
46
|
+
if (!cached) return null;
|
|
47
|
+
|
|
48
|
+
const blob = await cached.blob();
|
|
49
|
+
return URL.createObjectURL(blob);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function cacheImage(url: string, response: Response) {
|
|
53
|
+
const cache = await caches.open(IMAGE_CACHE);
|
|
54
|
+
await cache.put(url, response.clone());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ---------------------------------- */
|
|
58
|
+
/* Utils */
|
|
59
|
+
/* ---------------------------------- */
|
|
60
|
+
|
|
61
|
+
function isCrossOrigin(url: string): boolean {
|
|
62
|
+
try {
|
|
63
|
+
return new URL(url, window.location.href).origin !== window.location.origin;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------------------------------- */
|
|
70
|
+
/* Component */
|
|
71
|
+
/* ---------------------------------- */
|
|
72
|
+
|
|
73
|
+
const CachedImage = forwardRef<HTMLDivElement, ImageProps>((props, ref) => {
|
|
74
|
+
const { className, classNames, fetch, apiConfig, src, ...imgProps } = props;
|
|
75
|
+
|
|
76
|
+
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
|
77
|
+
const [loading, setLoading] = useState(true);
|
|
78
|
+
|
|
79
|
+
const { shimmerClassName, shimmerIconClassName } = classNames || {};
|
|
80
|
+
|
|
81
|
+
const fetchRef = useRef(fetch);
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
fetchRef.current = fetch;
|
|
84
|
+
}, [fetch]);
|
|
85
|
+
|
|
86
|
+
async function loadImage() {
|
|
87
|
+
try {
|
|
88
|
+
const resolvedSrc = apiConfig?.src ?? src;
|
|
89
|
+
|
|
90
|
+
if (!resolvedSrc) {
|
|
91
|
+
setImageUrl(null);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* ---------------------------------- */
|
|
96
|
+
/* Cross-origin → use <img src> */
|
|
97
|
+
/* ---------------------------------- */
|
|
98
|
+
if (isCrossOrigin(resolvedSrc)) {
|
|
99
|
+
setImageUrl(resolvedSrc);
|
|
100
|
+
setLoading(false);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* ---------------------------------- */
|
|
105
|
+
/* Cache Storage */
|
|
106
|
+
/* ---------------------------------- */
|
|
107
|
+
const cached = await getCachedImage(resolvedSrc);
|
|
108
|
+
if (cached) {
|
|
109
|
+
setImageUrl(cached);
|
|
110
|
+
setLoading(false);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ---------------------------------- */
|
|
115
|
+
/* Fetch */
|
|
116
|
+
/* ---------------------------------- */
|
|
117
|
+
const response = fetchRef.current
|
|
118
|
+
? await fetchRef.current(resolvedSrc)
|
|
119
|
+
: await window.fetch(resolvedSrc, {
|
|
120
|
+
headers: apiConfig?.headers,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
!response.ok ||
|
|
127
|
+
(!contentType.startsWith("image/") &&
|
|
128
|
+
contentType !== "application/octet-stream")
|
|
129
|
+
) {
|
|
130
|
+
throw new Error(`Invalid image response: ${contentType}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await cacheImage(resolvedSrc, response.clone());
|
|
134
|
+
|
|
135
|
+
const blob = await response.blob();
|
|
136
|
+
setImageUrl(URL.createObjectURL(blob));
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(err);
|
|
139
|
+
} finally {
|
|
140
|
+
setLoading(false);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
loadImage();
|
|
146
|
+
}, [src, apiConfig?.src]);
|
|
147
|
+
|
|
148
|
+
/* ---------------------------------- */
|
|
149
|
+
/* Cleanup */
|
|
150
|
+
/* ---------------------------------- */
|
|
151
|
+
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
return () => {
|
|
154
|
+
if (imageUrl?.startsWith("blob:")) {
|
|
155
|
+
URL.revokeObjectURL(imageUrl);
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}, [imageUrl]);
|
|
159
|
+
|
|
160
|
+
/* ---------------------------------- */
|
|
161
|
+
/* UI */
|
|
162
|
+
/* ---------------------------------- */
|
|
163
|
+
|
|
164
|
+
if (loading || !imageUrl) {
|
|
165
|
+
const stop = loading ? false : !imageUrl && true;
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Shimmer
|
|
169
|
+
className={cn(
|
|
170
|
+
"bg-primary/10 mx-auto flex p-2 items-center size-8 rounded border border-tertiary/10",
|
|
171
|
+
shimmerClassName
|
|
172
|
+
)}
|
|
173
|
+
stop={stop}
|
|
174
|
+
>
|
|
175
|
+
<svg
|
|
176
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
177
|
+
width="24"
|
|
178
|
+
height="24"
|
|
179
|
+
viewBox="0 0 24 24"
|
|
180
|
+
fill="none"
|
|
181
|
+
strokeLinecap="round"
|
|
182
|
+
strokeLinejoin="round"
|
|
183
|
+
className={cn(
|
|
184
|
+
"stroke-primary/40 mx-auto stroke-2",
|
|
185
|
+
shimmerIconClassName
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
<rect x="1" y="1" width="22" height="22" rx="2" ry="2" />
|
|
189
|
+
<polyline points="3,20 8,13 13,17 17,12 21,16" />
|
|
190
|
+
<circle cx="16" cy="6" r="2" />
|
|
191
|
+
</svg>
|
|
192
|
+
</Shimmer>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
ref={ref}
|
|
199
|
+
// src={imageUrl}
|
|
200
|
+
style={{ backgroundImage: `url(${imageUrl})` }}
|
|
201
|
+
// alt={alt}
|
|
202
|
+
className={cn(
|
|
203
|
+
"cursor-pointer shadow-lg bg-cover bg-center mx-auto",
|
|
204
|
+
className
|
|
205
|
+
)}
|
|
206
|
+
{...imgProps}
|
|
207
|
+
/>
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
CachedImage.displayName = "CachedImage";
|
|
212
|
+
|
|
213
|
+
export default CachedImage;
|