@djangocfg/widget-visual 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +106 -0
- package/src/design/ColorPalette/ColorPalette.tsx +129 -0
- package/src/design/ColorPalette/README.md +34 -0
- package/src/design/ColorPalette/color-palette.stories.tsx +95 -0
- package/src/design/ColorPalette/components/Swatch.tsx +102 -0
- package/src/design/ColorPalette/hooks/useCopyToClipboard.ts +41 -0
- package/src/design/ColorPalette/index.ts +12 -0
- package/src/design/ColorPalette/lazy.tsx +21 -0
- package/src/design/ColorPalette/types.ts +63 -0
- package/src/design/ColorPalette/utils.ts +114 -0
- package/src/design/ColorPicker/ColorPicker.tsx +257 -0
- package/src/design/ColorPicker/color-picker.stories.tsx +113 -0
- package/src/design/ColorPicker/context/ColorPickerContext.tsx +28 -0
- package/src/design/ColorPicker/context/ColorPickerStore.tsx +166 -0
- package/src/design/ColorPicker/context/index.ts +2 -0
- package/src/design/ColorPicker/index.ts +15 -0
- package/src/design/ColorPicker/lazy.tsx +24 -0
- package/src/design/ColorPicker/lib/color-utils.ts +323 -0
- package/src/design/ColorPicker/parts/ColorPickerAlphaSlider.tsx +72 -0
- package/src/design/ColorPicker/parts/ColorPickerArea.tsx +155 -0
- package/src/design/ColorPicker/parts/ColorPickerEyeDropper.tsx +73 -0
- package/src/design/ColorPicker/parts/ColorPickerFormatSelect.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerHueSlider.tsx +64 -0
- package/src/design/ColorPicker/parts/ColorPickerInput.tsx +512 -0
- package/src/design/ColorPicker/parts/ColorPickerSwatch.tsx +63 -0
- package/src/design/ColorPicker/parts/index.ts +7 -0
- package/src/design/ColorPicker/types.ts +77 -0
- package/src/gallery/components/Gallery.tsx +182 -0
- package/src/gallery/components/compact/GalleryCompact.tsx +396 -0
- package/src/gallery/components/compact/index.ts +1 -0
- package/src/gallery/components/index.ts +21 -0
- package/src/gallery/components/lightbox/GalleryLightbox.tsx +426 -0
- package/src/gallery/components/lightbox/index.ts +1 -0
- package/src/gallery/components/media/GalleryImage.tsx +105 -0
- package/src/gallery/components/media/GalleryMedia.tsx +70 -0
- package/src/gallery/components/media/GalleryVideo.tsx +241 -0
- package/src/gallery/components/media/index.ts +3 -0
- package/src/gallery/components/preview/GalleryCarousel.tsx +374 -0
- package/src/gallery/components/preview/GalleryGrid.tsx +519 -0
- package/src/gallery/components/preview/index.ts +2 -0
- package/src/gallery/components/shared/ImageSpinner.tsx +37 -0
- package/src/gallery/components/shared/index.ts +1 -0
- package/src/gallery/components/thumbnails/GalleryThumbnails.tsx +198 -0
- package/src/gallery/components/thumbnails/GalleryThumbnailsVirtual.tsx +164 -0
- package/src/gallery/components/thumbnails/index.ts +2 -0
- package/src/gallery/gallery.stories.tsx +182 -0
- package/src/gallery/hooks/index.ts +23 -0
- package/src/gallery/hooks/useGallery.ts +138 -0
- package/src/gallery/hooks/useImageDimensions.ts +224 -0
- package/src/gallery/hooks/usePinchZoom.ts +239 -0
- package/src/gallery/hooks/usePreloadImages.ts +119 -0
- package/src/gallery/hooks/useSwipe.ts +87 -0
- package/src/gallery/hooks/useVirtualList.ts +129 -0
- package/src/gallery/hooks/useZoom.ts +321 -0
- package/src/gallery/index.ts +66 -0
- package/src/gallery/thumbnails.stories.tsx +127 -0
- package/src/gallery/types.ts +185 -0
- package/src/gallery/utils/imageAnalysis.ts +52 -0
- package/src/gallery/utils/index.ts +16 -0
- package/src/gallery/utils/normalizeUrl.ts +31 -0
- package/src/index.ts +18 -0
- package/src/lottie/LottiePlayer.client.tsx +319 -0
- package/src/lottie/index.tsx +66 -0
- package/src/lottie/lazy.tsx +65 -0
- package/src/lottie/lottie-player.stories.tsx +173 -0
- package/src/lottie/types.ts +138 -0
- package/src/lottie/useLottie.ts +187 -0
- package/src/lottie/usePrefersReducedMotion.ts +46 -0
- package/src/marquee/Marquee.tsx +637 -0
- package/src/marquee/index.ts +7 -0
- package/src/marquee/lazy.tsx +14 -0
- package/src/marquee/marquee.stories.tsx +118 -0
- package/src/marquee/types.ts +40 -0
- package/src/qrcode/QRCode.tsx +468 -0
- package/src/qrcode/index.ts +10 -0
- package/src/qrcode/lazy.tsx +19 -0
- package/src/qrcode/qr-code.stories.tsx +102 -0
- package/src/qrcode/types.ts +57 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import {
|
|
3
|
+
Marquee,
|
|
4
|
+
MarqueeContent,
|
|
5
|
+
MarqueeItem,
|
|
6
|
+
MarqueeEdge,
|
|
7
|
+
} from '.';
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: 'Widgets/Visual/Marquee',
|
|
11
|
+
component: Marquee,
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
parameters: {
|
|
14
|
+
docs: {
|
|
15
|
+
description: {
|
|
16
|
+
component: 'A smooth scrolling marquee with pause-on-hover, auto-fill, and edge gradients.',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
argTypes: {
|
|
21
|
+
side: { control: 'select', options: ['left', 'right', 'top', 'bottom'] },
|
|
22
|
+
speed: { control: 'number' },
|
|
23
|
+
pauseOnHover: { control: 'boolean' },
|
|
24
|
+
reverse: { control: 'boolean' },
|
|
25
|
+
autoFill: { control: 'boolean' },
|
|
26
|
+
},
|
|
27
|
+
} satisfies Meta<typeof Marquee>;
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
type Story = StoryObj<typeof meta>;
|
|
31
|
+
|
|
32
|
+
const tags = ['React', 'TypeScript', 'Tailwind', 'Next.js', 'Vite', 'Node.js', 'PostgreSQL'];
|
|
33
|
+
|
|
34
|
+
export const Default: Story = {
|
|
35
|
+
render: () => (
|
|
36
|
+
<Marquee className="w-full">
|
|
37
|
+
<MarqueeContent>
|
|
38
|
+
{tags.map((tag) => (
|
|
39
|
+
<MarqueeItem key={tag} className="rounded-full border px-4 py-1 text-sm">
|
|
40
|
+
{tag}
|
|
41
|
+
</MarqueeItem>
|
|
42
|
+
))}
|
|
43
|
+
</MarqueeContent>
|
|
44
|
+
</Marquee>
|
|
45
|
+
),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const PauseOnHover: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<Marquee pauseOnHover className="w-full">
|
|
51
|
+
<MarqueeContent>
|
|
52
|
+
{tags.map((tag) => (
|
|
53
|
+
<MarqueeItem key={tag} className="rounded-full border px-4 py-1 text-sm">
|
|
54
|
+
{tag}
|
|
55
|
+
</MarqueeItem>
|
|
56
|
+
))}
|
|
57
|
+
</MarqueeContent>
|
|
58
|
+
</Marquee>
|
|
59
|
+
),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const Reverse: Story = {
|
|
63
|
+
render: () => (
|
|
64
|
+
<Marquee reverse className="w-full">
|
|
65
|
+
<MarqueeContent>
|
|
66
|
+
{tags.map((tag) => (
|
|
67
|
+
<MarqueeItem key={tag} className="rounded-full border px-4 py-1 text-sm">
|
|
68
|
+
{tag}
|
|
69
|
+
</MarqueeItem>
|
|
70
|
+
))}
|
|
71
|
+
</MarqueeContent>
|
|
72
|
+
</Marquee>
|
|
73
|
+
),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const Vertical: Story = {
|
|
77
|
+
render: () => (
|
|
78
|
+
<Marquee side="top" className="h-48">
|
|
79
|
+
<MarqueeContent>
|
|
80
|
+
{tags.map((tag) => (
|
|
81
|
+
<MarqueeItem key={tag} className="rounded-full border px-4 py-1 text-sm">
|
|
82
|
+
{tag}
|
|
83
|
+
</MarqueeItem>
|
|
84
|
+
))}
|
|
85
|
+
</MarqueeContent>
|
|
86
|
+
</Marquee>
|
|
87
|
+
),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const WithEdges: Story = {
|
|
91
|
+
render: () => (
|
|
92
|
+
<div className="relative w-full">
|
|
93
|
+
<Marquee className="w-full">
|
|
94
|
+
<MarqueeContent>
|
|
95
|
+
{tags.map((tag) => (
|
|
96
|
+
<MarqueeItem key={tag} className="rounded-full border px-4 py-1 text-sm">
|
|
97
|
+
{tag}
|
|
98
|
+
</MarqueeItem>
|
|
99
|
+
))}
|
|
100
|
+
</MarqueeContent>
|
|
101
|
+
</Marquee>
|
|
102
|
+
<MarqueeEdge side="left" />
|
|
103
|
+
<MarqueeEdge side="right" />
|
|
104
|
+
</div>
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const AutoFill: Story = {
|
|
109
|
+
render: () => (
|
|
110
|
+
<Marquee autoFill className="w-full">
|
|
111
|
+
<MarqueeContent>
|
|
112
|
+
<MarqueeItem className="rounded-full bg-primary px-4 py-1 text-sm text-primary-foreground">
|
|
113
|
+
Only one item — auto-filled
|
|
114
|
+
</MarqueeItem>
|
|
115
|
+
</MarqueeContent>
|
|
116
|
+
</Marquee>
|
|
117
|
+
),
|
|
118
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export type Side = 'left' | 'right' | 'top' | 'bottom';
|
|
4
|
+
export type Orientation = 'horizontal' | 'vertical';
|
|
5
|
+
export type Direction = 'ltr' | 'rtl';
|
|
6
|
+
|
|
7
|
+
export interface MarqueeProps extends React.ComponentProps<'div'> {
|
|
8
|
+
side?: Side;
|
|
9
|
+
dir?: Direction;
|
|
10
|
+
speed?: number;
|
|
11
|
+
delay?: number;
|
|
12
|
+
loopCount?: number;
|
|
13
|
+
gap?: string | number;
|
|
14
|
+
autoFill?: boolean;
|
|
15
|
+
pauseOnHover?: boolean;
|
|
16
|
+
pauseOnKeyboard?: boolean;
|
|
17
|
+
reverse?: boolean;
|
|
18
|
+
asChild?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MarqueeContextValue {
|
|
22
|
+
side: Side;
|
|
23
|
+
orientation: Orientation;
|
|
24
|
+
dir: Direction;
|
|
25
|
+
speed: number;
|
|
26
|
+
loopCount: number;
|
|
27
|
+
contentRef: React.RefObject<HTMLDivElement | null>;
|
|
28
|
+
rootRef: React.RefObject<HTMLDivElement | null>;
|
|
29
|
+
autoFill: boolean;
|
|
30
|
+
pauseOnHover: boolean;
|
|
31
|
+
pauseOnKeyboard: boolean;
|
|
32
|
+
reverse: boolean;
|
|
33
|
+
paused: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MarqueeEdgeProps extends React.ComponentProps<'div'> {
|
|
37
|
+
side: Side;
|
|
38
|
+
size?: 'default' | 'sm' | 'lg';
|
|
39
|
+
asChild?: boolean;
|
|
40
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Slot as SlotPrimitive } from '@radix-ui/react-slot';
|
|
4
|
+
import * as React from 'react';
|
|
5
|
+
import { useComposedRefs } from '@djangocfg/ui-core/lib';
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
import type { QRCodeProps, QRCodeContextValue, StoreState, Store, QRCodeCanvasOpts } from './types';
|
|
8
|
+
|
|
9
|
+
const ROOT_NAME = 'QRCode';
|
|
10
|
+
const CANVAS_NAME = 'QRCodeCanvas';
|
|
11
|
+
const SVG_NAME = 'QRCodeSvg';
|
|
12
|
+
const IMAGE_NAME = 'QRCodeImage';
|
|
13
|
+
const SKELETON_NAME = 'QRCodeSkeleton';
|
|
14
|
+
|
|
15
|
+
const StoreContext = React.createContext<Store | null>(null);
|
|
16
|
+
|
|
17
|
+
function useStore<T>(selector: (state: StoreState) => T): T {
|
|
18
|
+
const store = React.useContext(StoreContext);
|
|
19
|
+
if (!store) {
|
|
20
|
+
throw new Error(`\`useQRCode\` must be used within \`${ROOT_NAME}\``);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const getSnapshot = React.useCallback(
|
|
24
|
+
() => selector(store.getState()),
|
|
25
|
+
[store, selector],
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const QRCodeContext = React.createContext<QRCodeContextValue | null>(null);
|
|
32
|
+
|
|
33
|
+
function useQRCodeContext(consumerName: string) {
|
|
34
|
+
const context = React.useContext(QRCodeContext);
|
|
35
|
+
if (!context) {
|
|
36
|
+
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
|
|
37
|
+
}
|
|
38
|
+
return context;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function useLazyRef<T>(init: () => T): React.RefObject<T> {
|
|
42
|
+
const ref = React.useRef<T | null>(null);
|
|
43
|
+
if (ref.current === null) {
|
|
44
|
+
ref.current = init();
|
|
45
|
+
}
|
|
46
|
+
return ref as React.RefObject<T>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function QRCode(props: QRCodeProps) {
|
|
50
|
+
const {
|
|
51
|
+
value,
|
|
52
|
+
size = 200,
|
|
53
|
+
level = 'M',
|
|
54
|
+
margin = 1,
|
|
55
|
+
quality = 0.92,
|
|
56
|
+
backgroundColor = '#ffffff',
|
|
57
|
+
foregroundColor = '#000000',
|
|
58
|
+
onError,
|
|
59
|
+
onGenerated,
|
|
60
|
+
className,
|
|
61
|
+
style,
|
|
62
|
+
asChild,
|
|
63
|
+
...rootProps
|
|
64
|
+
} = props;
|
|
65
|
+
|
|
66
|
+
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
|
67
|
+
|
|
68
|
+
const listenersRef = useLazyRef(() => new Set<() => void>());
|
|
69
|
+
const stateRef = useLazyRef<StoreState>(() => ({
|
|
70
|
+
dataUrl: null,
|
|
71
|
+
svgString: null,
|
|
72
|
+
isGenerating: false,
|
|
73
|
+
error: null,
|
|
74
|
+
generationKey: '',
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
const store = React.useMemo<Store>(() => {
|
|
78
|
+
return {
|
|
79
|
+
subscribe: (cb) => {
|
|
80
|
+
listenersRef.current.add(cb);
|
|
81
|
+
return () => listenersRef.current.delete(cb);
|
|
82
|
+
},
|
|
83
|
+
getState: () => stateRef.current,
|
|
84
|
+
setState: (key, value) => {
|
|
85
|
+
if (Object.is(stateRef.current[key], value)) return;
|
|
86
|
+
stateRef.current[key] = value;
|
|
87
|
+
store.notify();
|
|
88
|
+
},
|
|
89
|
+
setStates: (updates) => {
|
|
90
|
+
let hasChanged = false;
|
|
91
|
+
|
|
92
|
+
for (const key of Object.keys(updates) as Array<keyof StoreState>) {
|
|
93
|
+
const value = updates[key];
|
|
94
|
+
if (value !== undefined && !Object.is(stateRef.current[key], value)) {
|
|
95
|
+
Object.assign(stateRef.current, { [key]: value });
|
|
96
|
+
hasChanged = true;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (hasChanged) {
|
|
101
|
+
store.notify();
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
notify: () => {
|
|
105
|
+
for (const cb of listenersRef.current) {
|
|
106
|
+
cb();
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}, [listenersRef, stateRef]);
|
|
111
|
+
|
|
112
|
+
const canvasOpts = React.useMemo<QRCodeCanvasOpts>(
|
|
113
|
+
() => ({
|
|
114
|
+
errorCorrectionLevel: level,
|
|
115
|
+
type: 'image/png',
|
|
116
|
+
quality,
|
|
117
|
+
margin,
|
|
118
|
+
color: {
|
|
119
|
+
dark: foregroundColor,
|
|
120
|
+
light: backgroundColor,
|
|
121
|
+
},
|
|
122
|
+
width: size,
|
|
123
|
+
}),
|
|
124
|
+
[level, margin, foregroundColor, backgroundColor, size, quality],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const generationKey = React.useMemo(() => {
|
|
128
|
+
if (!value) return '';
|
|
129
|
+
|
|
130
|
+
return JSON.stringify({
|
|
131
|
+
value,
|
|
132
|
+
size,
|
|
133
|
+
level,
|
|
134
|
+
margin,
|
|
135
|
+
quality,
|
|
136
|
+
foregroundColor,
|
|
137
|
+
backgroundColor,
|
|
138
|
+
});
|
|
139
|
+
}, [value, level, margin, foregroundColor, backgroundColor, size, quality]);
|
|
140
|
+
|
|
141
|
+
const onQRCodeGenerate = React.useCallback(
|
|
142
|
+
async (targetGenerationKey: string) => {
|
|
143
|
+
if (!value || !targetGenerationKey) return;
|
|
144
|
+
|
|
145
|
+
const currentState = store.getState();
|
|
146
|
+
if (
|
|
147
|
+
currentState.isGenerating ||
|
|
148
|
+
currentState.generationKey === targetGenerationKey
|
|
149
|
+
)
|
|
150
|
+
return;
|
|
151
|
+
|
|
152
|
+
store.setStates({
|
|
153
|
+
isGenerating: true,
|
|
154
|
+
error: null,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const QRCode = (await import('qrcode')).default;
|
|
159
|
+
|
|
160
|
+
let dataUrl: string | null = null;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
dataUrl = await QRCode.toDataURL(value, canvasOpts as Parameters<typeof QRCode.toDataURL>[1]);
|
|
164
|
+
} catch {
|
|
165
|
+
dataUrl = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (canvasRef.current) {
|
|
169
|
+
const { type: _t, quality: _q, rendererOpts: _r, ...renderersOpts } = canvasOpts;
|
|
170
|
+
void _t; void _q; void _r;
|
|
171
|
+
await QRCode.toCanvas(canvasRef.current, value, renderersOpts);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const svgString = await QRCode.toString(value, {
|
|
175
|
+
errorCorrectionLevel: canvasOpts.errorCorrectionLevel,
|
|
176
|
+
margin: canvasOpts.margin,
|
|
177
|
+
color: canvasOpts.color,
|
|
178
|
+
width: canvasOpts.width,
|
|
179
|
+
type: 'svg',
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
store.setStates({
|
|
183
|
+
dataUrl,
|
|
184
|
+
svgString,
|
|
185
|
+
isGenerating: false,
|
|
186
|
+
generationKey: targetGenerationKey,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
onGenerated?.();
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const parsedError =
|
|
192
|
+
error instanceof Error
|
|
193
|
+
? error
|
|
194
|
+
: new Error('Failed to generate QR code');
|
|
195
|
+
store.setStates({
|
|
196
|
+
error: parsedError,
|
|
197
|
+
isGenerating: false,
|
|
198
|
+
});
|
|
199
|
+
onError?.(parsedError);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
[value, canvasOpts, store, onError, onGenerated],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const contextValue = React.useMemo<QRCodeContextValue>(
|
|
206
|
+
() => ({
|
|
207
|
+
value,
|
|
208
|
+
size,
|
|
209
|
+
level,
|
|
210
|
+
margin,
|
|
211
|
+
backgroundColor,
|
|
212
|
+
foregroundColor,
|
|
213
|
+
canvasRef,
|
|
214
|
+
}),
|
|
215
|
+
[value, size, backgroundColor, foregroundColor, level, margin],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
React.useLayoutEffect(() => {
|
|
219
|
+
if (generationKey) {
|
|
220
|
+
const rafId = requestAnimationFrame(() => {
|
|
221
|
+
onQRCodeGenerate(generationKey);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return () => cancelAnimationFrame(rafId);
|
|
225
|
+
}
|
|
226
|
+
}, [generationKey, onQRCodeGenerate]);
|
|
227
|
+
|
|
228
|
+
const RootPrimitive = asChild ? SlotPrimitive : 'div';
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
<StoreContext.Provider value={store}>
|
|
232
|
+
<QRCodeContext.Provider value={contextValue}>
|
|
233
|
+
<RootPrimitive
|
|
234
|
+
data-slot="qr-code"
|
|
235
|
+
{...rootProps}
|
|
236
|
+
className={cn(className, 'relative flex flex-col items-center gap-2')}
|
|
237
|
+
style={
|
|
238
|
+
{
|
|
239
|
+
'--qr-code-size': `${size}px`,
|
|
240
|
+
...style,
|
|
241
|
+
} as React.CSSProperties
|
|
242
|
+
}
|
|
243
|
+
/>
|
|
244
|
+
</QRCodeContext.Provider>
|
|
245
|
+
</StoreContext.Provider>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
QRCode.displayName = ROOT_NAME;
|
|
250
|
+
|
|
251
|
+
interface QRCodeCanvasProps extends React.ComponentProps<'canvas'> {
|
|
252
|
+
asChild?: boolean;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function QRCodeCanvas(props: QRCodeCanvasProps) {
|
|
256
|
+
const { asChild, className, ref, ...canvasProps } = props;
|
|
257
|
+
|
|
258
|
+
const context = useQRCodeContext(CANVAS_NAME);
|
|
259
|
+
const generationKey = useStore((state) => state.generationKey);
|
|
260
|
+
|
|
261
|
+
const composedRef = useComposedRefs(ref, context.canvasRef);
|
|
262
|
+
|
|
263
|
+
const CanvasPrimitive = asChild ? SlotPrimitive : 'canvas';
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<CanvasPrimitive
|
|
267
|
+
data-slot="qr-code-canvas"
|
|
268
|
+
{...canvasProps}
|
|
269
|
+
ref={composedRef}
|
|
270
|
+
width={context.size}
|
|
271
|
+
height={context.size}
|
|
272
|
+
className={cn(
|
|
273
|
+
'relative max-h-(--qr-code-size) max-w-(--qr-code-size)',
|
|
274
|
+
!generationKey && 'invisible',
|
|
275
|
+
className,
|
|
276
|
+
)}
|
|
277
|
+
/>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
QRCodeCanvas.displayName = CANVAS_NAME;
|
|
282
|
+
|
|
283
|
+
interface QRCodeSvgProps extends React.ComponentProps<'div'> {
|
|
284
|
+
asChild?: boolean;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function QRCodeSvg(props: QRCodeSvgProps) {
|
|
288
|
+
const { asChild, className, style, ...svgProps } = props;
|
|
289
|
+
|
|
290
|
+
const context = useQRCodeContext(SVG_NAME);
|
|
291
|
+
const svgString = useStore((state) => state.svgString);
|
|
292
|
+
|
|
293
|
+
if (!svgString) return null;
|
|
294
|
+
|
|
295
|
+
const SvgPrimitive = asChild ? SlotPrimitive : 'div';
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<SvgPrimitive
|
|
299
|
+
data-slot="qr-code-svg"
|
|
300
|
+
{...svgProps}
|
|
301
|
+
className={cn(
|
|
302
|
+
'relative max-h-(--qr-code-size) max-w-(--qr-code-size)',
|
|
303
|
+
className,
|
|
304
|
+
)}
|
|
305
|
+
style={{ width: context.size, height: context.size, ...style }}
|
|
306
|
+
dangerouslySetInnerHTML={{ __html: svgString }}
|
|
307
|
+
/>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
QRCodeSvg.displayName = SVG_NAME;
|
|
312
|
+
|
|
313
|
+
interface QRCodeImageProps extends React.ComponentProps<'img'> {
|
|
314
|
+
asChild?: boolean;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function QRCodeImage(props: QRCodeImageProps) {
|
|
318
|
+
const { alt = 'QR Code', asChild, className, ...imageProps } = props;
|
|
319
|
+
|
|
320
|
+
const context = useQRCodeContext(IMAGE_NAME);
|
|
321
|
+
const dataUrl = useStore((state) => state.dataUrl);
|
|
322
|
+
|
|
323
|
+
if (!dataUrl) return null;
|
|
324
|
+
|
|
325
|
+
const ImagePrimitive = asChild ? SlotPrimitive : 'img';
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<ImagePrimitive
|
|
329
|
+
data-slot="qr-code-image"
|
|
330
|
+
{...imageProps}
|
|
331
|
+
src={dataUrl}
|
|
332
|
+
alt={alt}
|
|
333
|
+
width={context.size}
|
|
334
|
+
height={context.size}
|
|
335
|
+
className={cn(
|
|
336
|
+
'relative max-h-(--qr-code-size) max-w-(--qr-code-size)',
|
|
337
|
+
className,
|
|
338
|
+
)}
|
|
339
|
+
/>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
QRCodeImage.displayName = IMAGE_NAME;
|
|
344
|
+
|
|
345
|
+
interface QRCodeDownloadProps extends React.ComponentProps<'button'> {
|
|
346
|
+
filename?: string;
|
|
347
|
+
format?: 'png' | 'svg';
|
|
348
|
+
asChild?: boolean;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function QRCodeDownload(props: QRCodeDownloadProps) {
|
|
352
|
+
const {
|
|
353
|
+
filename = 'qrcode',
|
|
354
|
+
format = 'png',
|
|
355
|
+
asChild,
|
|
356
|
+
className,
|
|
357
|
+
children,
|
|
358
|
+
...buttonProps
|
|
359
|
+
} = props;
|
|
360
|
+
|
|
361
|
+
const dataUrl = useStore((state) => state.dataUrl);
|
|
362
|
+
const svgString = useStore((state) => state.svgString);
|
|
363
|
+
|
|
364
|
+
const onClick = React.useCallback(
|
|
365
|
+
(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
366
|
+
buttonProps.onClick?.(event);
|
|
367
|
+
if (event.defaultPrevented) return;
|
|
368
|
+
|
|
369
|
+
const link = document.createElement('a');
|
|
370
|
+
|
|
371
|
+
if (format === 'png' && dataUrl) {
|
|
372
|
+
link.href = dataUrl;
|
|
373
|
+
link.download = `${filename}.png`;
|
|
374
|
+
} else if (format === 'svg' && svgString) {
|
|
375
|
+
const blob = new Blob([svgString], { type: 'image/svg+xml' });
|
|
376
|
+
link.href = URL.createObjectURL(blob);
|
|
377
|
+
link.download = `${filename}.svg`;
|
|
378
|
+
} else {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
document.body.appendChild(link);
|
|
383
|
+
link.click();
|
|
384
|
+
document.body.removeChild(link);
|
|
385
|
+
|
|
386
|
+
if (format === 'svg' && svgString) {
|
|
387
|
+
URL.revokeObjectURL(link.href);
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
[dataUrl, svgString, filename, format, buttonProps.onClick],
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const ButtonPrimitive = asChild ? SlotPrimitive : 'button';
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<ButtonPrimitive
|
|
397
|
+
type="button"
|
|
398
|
+
data-slot="qr-code-download"
|
|
399
|
+
{...buttonProps}
|
|
400
|
+
className={cn('max-w-(--qr-code-size)', className)}
|
|
401
|
+
onClick={onClick}
|
|
402
|
+
>
|
|
403
|
+
{children ?? `Download ${format.toUpperCase()}`}
|
|
404
|
+
</ButtonPrimitive>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
QRCodeDownload.displayName = 'QRCodeDownload';
|
|
409
|
+
|
|
410
|
+
interface QRCodeOverlayProps extends React.ComponentProps<'div'> {
|
|
411
|
+
asChild?: boolean;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function QRCodeOverlay(props: QRCodeOverlayProps) {
|
|
415
|
+
const { asChild, className, ...overlayProps } = props;
|
|
416
|
+
|
|
417
|
+
const OverlayPrimitive = asChild ? SlotPrimitive : 'div';
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<OverlayPrimitive
|
|
421
|
+
data-slot="qr-code-overlay"
|
|
422
|
+
{...overlayProps}
|
|
423
|
+
className={cn(
|
|
424
|
+
'absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-sm bg-background',
|
|
425
|
+
className,
|
|
426
|
+
)}
|
|
427
|
+
/>
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
QRCodeOverlay.displayName = 'QRCodeOverlay';
|
|
432
|
+
|
|
433
|
+
interface QRCodeSkeletonProps extends React.ComponentProps<'div'> {
|
|
434
|
+
asChild?: boolean;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function QRCodeSkeleton(props: QRCodeSkeletonProps) {
|
|
438
|
+
const { asChild, className, style, ...skeletonProps } = props;
|
|
439
|
+
|
|
440
|
+
const context = useQRCodeContext(SKELETON_NAME);
|
|
441
|
+
const dataUrl = useStore((state) => state.dataUrl);
|
|
442
|
+
const svgString = useStore((state) => state.svgString);
|
|
443
|
+
const generationKey = useStore((state) => state.generationKey);
|
|
444
|
+
|
|
445
|
+
const isLoaded = dataUrl || svgString || generationKey;
|
|
446
|
+
|
|
447
|
+
if (isLoaded) return null;
|
|
448
|
+
|
|
449
|
+
const SkeletonPrimitive = asChild ? SlotPrimitive : 'div';
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<SkeletonPrimitive
|
|
453
|
+
data-slot="qr-code-skeleton"
|
|
454
|
+
{...skeletonProps}
|
|
455
|
+
className={cn(
|
|
456
|
+
'absolute max-h-(--qr-code-size) max-w-(--qr-code-size) animate-pulse bg-accent',
|
|
457
|
+
className,
|
|
458
|
+
)}
|
|
459
|
+
style={{
|
|
460
|
+
width: context.size,
|
|
461
|
+
height: context.size,
|
|
462
|
+
...style,
|
|
463
|
+
}}
|
|
464
|
+
/>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
QRCodeSkeleton.displayName = SKELETON_NAME;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createLazyComponent } from '@djangocfg/widget-kit/lazy';
|
|
4
|
+
import type { QRCodeProps } from './types';
|
|
5
|
+
|
|
6
|
+
export const LazyQRCode = createLazyComponent<QRCodeProps>(
|
|
7
|
+
() => import('./QRCode').then((mod) => ({ default: mod.QRCode })),
|
|
8
|
+
{
|
|
9
|
+
displayName: 'LazyQRCode',
|
|
10
|
+
fallback: (
|
|
11
|
+
<div
|
|
12
|
+
data-slot="qr-code-skeleton"
|
|
13
|
+
className="h-[200px] w-[200px] animate-pulse rounded-sm bg-muted"
|
|
14
|
+
/>
|
|
15
|
+
),
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export type { QRCodeProps } from './types';
|