@connectycube/react-ui-kit 0.0.19 → 0.0.22
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/configs/dependencies.json +21 -0
- package/configs/imports.json +7 -0
- package/dist/index.cjs +1 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -35
- package/dist/index.js.map +1 -1
- package/dist/types/components/attachment.d.ts +7 -8
- package/dist/types/components/attachment.d.ts.map +1 -1
- package/dist/types/components/avatar.d.ts +1 -0
- package/dist/types/components/avatar.d.ts.map +1 -1
- package/dist/types/components/badge.d.ts +1 -1
- package/dist/types/components/button.d.ts +2 -2
- package/dist/types/components/chat-bubble.d.ts +32 -0
- package/dist/types/components/chat-bubble.d.ts.map +1 -0
- package/dist/types/components/chat-input.d.ts +27 -0
- package/dist/types/components/chat-input.d.ts.map +1 -0
- package/dist/types/components/chat-list.d.ts +30 -0
- package/dist/types/components/chat-list.d.ts.map +1 -0
- package/dist/types/components/checkbox.d.ts +11 -0
- package/dist/types/components/checkbox.d.ts.map +1 -0
- package/dist/types/components/dialog-item.d.ts.map +1 -1
- package/dist/types/components/dialogs-list.d.ts +14 -0
- package/dist/types/components/dialogs-list.d.ts.map +1 -0
- package/dist/types/components/file-picker.d.ts +1 -1
- package/dist/types/components/file-picker.d.ts.map +1 -1
- package/dist/types/components/linkify-text.d.ts +6 -1
- package/dist/types/components/linkify-text.d.ts.map +1 -1
- package/dist/types/components/placeholder-text.d.ts.map +1 -1
- package/dist/types/components/quick-actions.d.ts +14 -0
- package/dist/types/components/quick-actions.d.ts.map +1 -0
- package/dist/types/components/status-call.d.ts +8 -0
- package/dist/types/components/status-call.d.ts.map +1 -0
- package/dist/types/components/switch.d.ts.map +1 -1
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -1
- package/gen/components/attachment.jsx +27 -25
- package/gen/components/avatar.jsx +14 -2
- package/gen/components/button.jsx +1 -1
- package/gen/components/chat-bubble.jsx +141 -0
- package/gen/components/chat-input.jsx +152 -0
- package/gen/components/chat-list.jsx +151 -0
- package/gen/components/checkbox.jsx +30 -0
- package/gen/components/dialog-item.jsx +5 -2
- package/gen/components/dialogs-list.jsx +73 -0
- package/gen/components/dismiss-layer.jsx +1 -1
- package/gen/components/file-picker.jsx +2 -2
- package/gen/components/linkify-text.jsx +41 -2
- package/gen/components/placeholder-text.jsx +5 -1
- package/gen/components/quick-actions.jsx +62 -0
- package/gen/components/search.jsx +1 -1
- package/gen/components/status-call.jsx +18 -0
- package/gen/components/stream-view.jsx +8 -8
- package/gen/components/switch.jsx +0 -2
- package/gen/index.js +16 -0
- package/package.json +17 -13
- package/src/components/attachment.tsx +38 -37
- package/src/components/avatar.tsx +3 -1
- package/src/components/button.tsx +1 -1
- package/src/components/chat-bubble.tsx +176 -0
- package/src/components/chat-input.tsx +172 -0
- package/src/components/chat-list.tsx +164 -0
- package/src/components/checkbox.tsx +40 -0
- package/src/components/connectycube-ui/attachment.tsx +269 -0
- package/src/components/connectycube-ui/chat-input.tsx +174 -0
- package/src/components/connectycube-ui/chat-message.tsx +138 -0
- package/src/components/connectycube-ui/link-preview.tsx +149 -0
- package/src/components/dialog-item.tsx +5 -2
- package/src/components/dialogs-list.tsx +84 -0
- package/src/components/dismiss-layer.tsx +1 -1
- package/src/components/file-picker.tsx +3 -3
- package/src/components/linkify-text.tsx +44 -3
- package/src/components/placeholder-text.tsx +5 -1
- package/src/components/quick-actions.tsx +74 -0
- package/src/components/search.tsx +1 -1
- package/src/components/status-call.tsx +23 -0
- package/src/components/stream-view.tsx +8 -8
- package/src/components/switch.tsx +0 -2
- package/src/index.ts +21 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { ChangeEvent, forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
3
|
+
import TextareaAutosize, { type TextareaAutosizeProps, type TextareaHeightChangeMeta } from 'react-textarea-autosize';
|
|
4
|
+
import { SendHorizontal, type LucideProps } from 'lucide-react';
|
|
5
|
+
import { Label, type LabelProps } from './label';
|
|
6
|
+
import { cn } from './utils';
|
|
7
|
+
|
|
8
|
+
interface ChatInputSendProps extends LabelProps {
|
|
9
|
+
onSend: () => void;
|
|
10
|
+
iconElement?: React.ReactNode;
|
|
11
|
+
iconProps?: LucideProps;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ChatInputProps extends TextareaAutosizeProps, Omit<React.ComponentProps<'textarea'>, 'style'> {
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
pending?: boolean;
|
|
17
|
+
draft?: string;
|
|
18
|
+
onSend?: (value?: string) => void;
|
|
19
|
+
onDraft?: (value?: string) => void;
|
|
20
|
+
onTyping?: (typing?: boolean) => void;
|
|
21
|
+
onHeightGrow?: (data: { height: number; shift: number }) => void;
|
|
22
|
+
hideChatInputSend?: boolean;
|
|
23
|
+
chatInputSendProps?: ChatInputSendProps;
|
|
24
|
+
containerProps?: React.ComponentProps<'div'>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ChatInputSendBase(
|
|
28
|
+
{ onSend = () => {}, iconElement, iconProps, ...props }: ChatInputSendProps,
|
|
29
|
+
ref: React.ForwardedRef<HTMLLabelElement>
|
|
30
|
+
) {
|
|
31
|
+
return (
|
|
32
|
+
<Label
|
|
33
|
+
ref={ref}
|
|
34
|
+
{...props}
|
|
35
|
+
onClick={onSend}
|
|
36
|
+
className={cn('group rounded-full py-2 pl-2.5 pr-1.5 cursor-pointer bg-ring/90 hover:bg-ring', props?.className)}
|
|
37
|
+
>
|
|
38
|
+
{iconElement || (
|
|
39
|
+
<SendHorizontal
|
|
40
|
+
{...iconProps}
|
|
41
|
+
className={cn(
|
|
42
|
+
'text-background group-hover:scale-110 transition-all duration-200 ease-out',
|
|
43
|
+
iconProps?.className
|
|
44
|
+
)}
|
|
45
|
+
/>
|
|
46
|
+
)}
|
|
47
|
+
</Label>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ChatInputSend = forwardRef<HTMLLabelElement, ChatInputSendProps>(ChatInputSendBase);
|
|
52
|
+
|
|
53
|
+
ChatInputSend.displayName = 'ChatInputSend';
|
|
54
|
+
|
|
55
|
+
function ChatInputBase(
|
|
56
|
+
{
|
|
57
|
+
pending = false,
|
|
58
|
+
draft,
|
|
59
|
+
onSend = () => {},
|
|
60
|
+
onDraft = () => {},
|
|
61
|
+
onTyping = () => {},
|
|
62
|
+
onHeightGrow = () => {},
|
|
63
|
+
hideChatInputSend = false,
|
|
64
|
+
chatInputSendProps,
|
|
65
|
+
containerProps,
|
|
66
|
+
children,
|
|
67
|
+
...props
|
|
68
|
+
}: ChatInputProps,
|
|
69
|
+
ref: React.ForwardedRef<HTMLTextAreaElement>
|
|
70
|
+
) {
|
|
71
|
+
const [value, setValue] = useState<string>();
|
|
72
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
73
|
+
const textareaHeightRef = useRef<number>(0);
|
|
74
|
+
const typingRef = useRef<boolean>(false);
|
|
75
|
+
const typingTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
|
76
|
+
const handleStopTyping = () => {
|
|
77
|
+
typingRef.current = false;
|
|
78
|
+
onTyping(false);
|
|
79
|
+
|
|
80
|
+
if (typingTimeoutRef.current) {
|
|
81
|
+
clearTimeout(typingTimeoutRef.current);
|
|
82
|
+
typingTimeoutRef.current = undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const handleOnChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
|
86
|
+
const { data } = event.nativeEvent as InputEvent;
|
|
87
|
+
|
|
88
|
+
setValue(event.target.value);
|
|
89
|
+
|
|
90
|
+
if (!typingRef.current && typeof data === 'string' && data.length > 0) {
|
|
91
|
+
typingRef.current = true;
|
|
92
|
+
onTyping(true);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
clearTimeout(typingTimeoutRef.current);
|
|
96
|
+
typingTimeoutRef.current = setTimeout(handleStopTyping, 5000);
|
|
97
|
+
};
|
|
98
|
+
const handleOnSend = async () => {
|
|
99
|
+
handleStopTyping();
|
|
100
|
+
|
|
101
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
102
|
+
onSend(value.trim());
|
|
103
|
+
setValue('');
|
|
104
|
+
textareaRef.current?.focus();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const handleOnHeightChange = (height: number, meta: TextareaHeightChangeMeta) => {
|
|
108
|
+
if (!height && !meta) return;
|
|
109
|
+
|
|
110
|
+
if (height !== textareaHeightRef.current) {
|
|
111
|
+
const shift = height > textareaHeightRef.current ? meta.rowHeight : -meta.rowHeight;
|
|
112
|
+
|
|
113
|
+
onHeightGrow({ height, shift });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
textareaHeightRef.current = height;
|
|
117
|
+
};
|
|
118
|
+
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
119
|
+
if (event.key === 'Enter' && event.shiftKey === false) {
|
|
120
|
+
event.preventDefault();
|
|
121
|
+
props.onKeyDown?.(event);
|
|
122
|
+
handleOnSend();
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const textarea = textareaRef.current;
|
|
128
|
+
|
|
129
|
+
handleStopTyping();
|
|
130
|
+
queueMicrotask(() => setValue(draft));
|
|
131
|
+
textarea?.focus();
|
|
132
|
+
|
|
133
|
+
return () => {
|
|
134
|
+
onDraft(textarea?.value);
|
|
135
|
+
};
|
|
136
|
+
}, [props.key]);
|
|
137
|
+
|
|
138
|
+
useImperativeHandle(ref, () => textareaRef.current || ({} as HTMLTextAreaElement), []);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div {...containerProps} className={cn('flex items-end gap-2', containerProps?.className)}>
|
|
142
|
+
<TextareaAutosize
|
|
143
|
+
name="textarea-autosize"
|
|
144
|
+
ref={textareaRef}
|
|
145
|
+
autoFocus
|
|
146
|
+
minRows={1}
|
|
147
|
+
maxRows={8}
|
|
148
|
+
{...props}
|
|
149
|
+
value={value}
|
|
150
|
+
onChange={handleOnChange}
|
|
151
|
+
onKeyDown={handleOnKeyDown}
|
|
152
|
+
onHeightChange={handleOnHeightChange}
|
|
153
|
+
className={cn(
|
|
154
|
+
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-11 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
|
155
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring',
|
|
156
|
+
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
|
157
|
+
'resize-none py-2.5',
|
|
158
|
+
pending && 'pointer-events-none bg-muted-foreground border-ring ring-ring/50 animate-pulse',
|
|
159
|
+
props?.className
|
|
160
|
+
)}
|
|
161
|
+
/>
|
|
162
|
+
{!hideChatInputSend && <ChatInputSend onSend={handleOnSend} {...chatInputSendProps} />}
|
|
163
|
+
{children}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ChatInput = forwardRef<HTMLTextAreaElement, ChatInputProps>(ChatInputBase);
|
|
169
|
+
|
|
170
|
+
ChatInput.displayName = 'ChatInput';
|
|
171
|
+
|
|
172
|
+
export { ChatInput, type ChatInputProps };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useState, useImperativeHandle, forwardRef, useCallback } from 'react';
|
|
2
|
+
import { Virtualizer, VirtualizerProps, type VirtualizerHandle } from 'virtua';
|
|
3
|
+
import { FilePickerDropzone, type FilePickerDropzoneProps } from './file-picker';
|
|
4
|
+
import { QuickActions, type QuickActionsProps } from './quick-actions';
|
|
5
|
+
import { Spinner } from './spinner';
|
|
6
|
+
import { cn } from './utils';
|
|
7
|
+
|
|
8
|
+
interface ChatListProps extends VirtualizerProps {
|
|
9
|
+
key: string;
|
|
10
|
+
loading?: boolean;
|
|
11
|
+
textareaMeasurement: { height: number; shift: number };
|
|
12
|
+
offsetToReach?: number;
|
|
13
|
+
onScrollStartReached?: () => void;
|
|
14
|
+
onScrollEndReached?: () => void;
|
|
15
|
+
onListReset?: (prev: string | undefined, next: string | undefined) => void;
|
|
16
|
+
onListCreate?: () => void;
|
|
17
|
+
onListGrow?: () => void;
|
|
18
|
+
enableFilePickerDropzone?: boolean;
|
|
19
|
+
containerProps?: React.ComponentProps<'div'>;
|
|
20
|
+
filePickerDropzoneProps?: FilePickerDropzoneProps;
|
|
21
|
+
quickActionsProps?: QuickActionsProps;
|
|
22
|
+
quickActionsVisible?: boolean;
|
|
23
|
+
minItemsCount?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ChatListHandle extends VirtualizerHandle {
|
|
27
|
+
scrollToBottom: (force?: boolean) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function DefaultChatListWrapper({ children, ...props }: React.ComponentProps<'div'>) {
|
|
31
|
+
return (
|
|
32
|
+
<div {...props} className={cn('relative size-full min-h-0', props?.className)}>
|
|
33
|
+
{children}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ChatListBase(
|
|
39
|
+
{
|
|
40
|
+
key,
|
|
41
|
+
loading = false,
|
|
42
|
+
textareaMeasurement,
|
|
43
|
+
offsetToReach = 200,
|
|
44
|
+
onScrollStartReached,
|
|
45
|
+
onScrollEndReached,
|
|
46
|
+
onListReset,
|
|
47
|
+
onListCreate,
|
|
48
|
+
onListGrow,
|
|
49
|
+
enableFilePickerDropzone = true,
|
|
50
|
+
containerProps,
|
|
51
|
+
filePickerDropzoneProps,
|
|
52
|
+
quickActionsProps,
|
|
53
|
+
children,
|
|
54
|
+
quickActionsVisible,
|
|
55
|
+
minItemsCount = 1,
|
|
56
|
+
...props
|
|
57
|
+
}: ChatListProps,
|
|
58
|
+
ref: React.ForwardedRef<ChatListHandle>
|
|
59
|
+
) {
|
|
60
|
+
const ChatListWrapper = enableFilePickerDropzone ? FilePickerDropzone : DefaultChatListWrapper;
|
|
61
|
+
const chatListWrapperProps = enableFilePickerDropzone ? filePickerDropzoneProps : containerProps;
|
|
62
|
+
const itemsCount = Array.isArray(children) ? children.length : Array.isArray(props.data) ? props.data.length : 0;
|
|
63
|
+
const itemsCountRef = useRef<number>(0);
|
|
64
|
+
const keyRef = useRef<string | undefined>(undefined);
|
|
65
|
+
const [shouldPrependMessages, setShouldPrependMessages] = useState<boolean>(false);
|
|
66
|
+
const [isPreparing, setIsPreparing] = useState<boolean>(false);
|
|
67
|
+
const virtuaRef = useRef<VirtualizerHandle>(null);
|
|
68
|
+
const prepareNextVirtualizer = () => {
|
|
69
|
+
queueMicrotask(() => {
|
|
70
|
+
setIsPreparing(true);
|
|
71
|
+
queueMicrotask(() => setIsPreparing(false));
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
const handleOnScroll = async (offset: number) => {
|
|
75
|
+
props.onScroll?.(offset);
|
|
76
|
+
|
|
77
|
+
if (!virtuaRef.current) return;
|
|
78
|
+
|
|
79
|
+
if (typeof onScrollStartReached === 'function' && offset < offsetToReach) {
|
|
80
|
+
setShouldPrependMessages(true);
|
|
81
|
+
onScrollStartReached();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (
|
|
85
|
+
typeof onScrollEndReached === 'function' &&
|
|
86
|
+
virtuaRef.current.viewportSize + offset + offsetToReach > virtuaRef.current.scrollSize
|
|
87
|
+
) {
|
|
88
|
+
onScrollEndReached();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const scrollToBottom = useCallback(
|
|
92
|
+
(force: boolean = false) => {
|
|
93
|
+
if (!virtuaRef.current) return;
|
|
94
|
+
|
|
95
|
+
if (force || virtuaRef.current.scrollSize - virtuaRef.current.scrollOffset < virtuaRef.current.viewportSize * 2) {
|
|
96
|
+
queueMicrotask(() => virtuaRef.current?.scrollToIndex(itemsCount - 1, { align: 'start' }));
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
[itemsCount]
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (key !== keyRef.current) {
|
|
104
|
+
prepareNextVirtualizer();
|
|
105
|
+
onListReset?.(keyRef.current, key);
|
|
106
|
+
keyRef.current = key;
|
|
107
|
+
itemsCountRef.current = 0;
|
|
108
|
+
}
|
|
109
|
+
}, [key, onListReset]);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (itemsCountRef.current === 0 && itemsCount > 0) {
|
|
113
|
+
prepareNextVirtualizer();
|
|
114
|
+
onListCreate?.();
|
|
115
|
+
itemsCountRef.current = itemsCount;
|
|
116
|
+
scrollToBottom(true);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (itemsCount > itemsCountRef.current) {
|
|
120
|
+
onListGrow?.();
|
|
121
|
+
itemsCountRef.current = itemsCount;
|
|
122
|
+
|
|
123
|
+
if (shouldPrependMessages) {
|
|
124
|
+
queueMicrotask(() => setShouldPrependMessages(false));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
scrollToBottom();
|
|
128
|
+
}
|
|
129
|
+
}, [itemsCount, shouldPrependMessages, onListCreate, onListGrow, scrollToBottom]);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
virtuaRef.current?.scrollBy(textareaMeasurement.shift);
|
|
133
|
+
}, [textareaMeasurement]);
|
|
134
|
+
|
|
135
|
+
useImperativeHandle(ref, () => ({ ...(virtuaRef.current || ({} as VirtualizerHandle)), scrollToBottom }), [
|
|
136
|
+
scrollToBottom,
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
if (isPreparing) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (minItemsCount >= itemsCount) {
|
|
144
|
+
return quickActionsVisible ? <QuickActions {...quickActionsProps} /> : <Spinner loading layout="centered" />;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<ChatListWrapper {...chatListWrapperProps}>
|
|
149
|
+
<Spinner loading={loading} className="my-5" />
|
|
150
|
+
<div className="flex flex-col h-full overflow-y-auto px-2">
|
|
151
|
+
<div className="grow" />
|
|
152
|
+
<Virtualizer ref={virtuaRef} onScroll={handleOnScroll} shift={shouldPrependMessages} {...props}>
|
|
153
|
+
{children}
|
|
154
|
+
</Virtualizer>
|
|
155
|
+
</div>
|
|
156
|
+
</ChatListWrapper>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const ChatList = forwardRef<ChatListHandle, ChatListProps>(ChatListBase);
|
|
161
|
+
|
|
162
|
+
ChatList.displayName = 'ChatList';
|
|
163
|
+
|
|
164
|
+
export { ChatList, type ChatListProps, type ChatListHandle };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { forwardRef } from 'react';
|
|
3
|
+
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
|
4
|
+
import { CheckIcon, type LucideProps } from 'lucide-react';
|
|
5
|
+
import { cn } from './utils';
|
|
6
|
+
|
|
7
|
+
interface CheckboxProps extends CheckboxPrimitive.CheckboxProps {
|
|
8
|
+
iconElement?: React.ReactNode;
|
|
9
|
+
iconProps?: LucideProps;
|
|
10
|
+
indicatorProps?: CheckboxPrimitive.CheckboxIndicatorProps;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function CheckboxBase(
|
|
14
|
+
{ iconElement, iconProps, indicatorProps, ...props }: CheckboxProps,
|
|
15
|
+
ref: React.ForwardedRef<HTMLButtonElement>
|
|
16
|
+
) {
|
|
17
|
+
return (
|
|
18
|
+
<CheckboxPrimitive.Root
|
|
19
|
+
ref={ref}
|
|
20
|
+
{...props}
|
|
21
|
+
className={cn(
|
|
22
|
+
'peer border-input dark:bg-input/30 data-[state=checked]:bg-ring data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-ring data-[state=checked]:border-ring focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
|
23
|
+
props?.className
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
<CheckboxPrimitive.Indicator
|
|
27
|
+
{...indicatorProps}
|
|
28
|
+
className={cn('flex items-center justify-center text-current transition-none', indicatorProps?.className)}
|
|
29
|
+
>
|
|
30
|
+
{iconElement || <CheckIcon {...iconProps} className={cn('size-3.5', iconProps?.className)} />}
|
|
31
|
+
</CheckboxPrimitive.Indicator>
|
|
32
|
+
</CheckboxPrimitive.Root>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(CheckboxBase);
|
|
37
|
+
|
|
38
|
+
Checkbox.displayName = 'Checkbox';
|
|
39
|
+
|
|
40
|
+
export { Checkbox, type CheckboxProps };
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { forwardRef, memo, useImperativeHandle, useRef, useState } from 'react';
|
|
3
|
+
import { File, FileXCorner, type LucideProps } from 'lucide-react';
|
|
4
|
+
import { Spinner } from './spinner';
|
|
5
|
+
import { cn, getRandomString } from './utils';
|
|
6
|
+
|
|
7
|
+
interface AttachmentProps {
|
|
8
|
+
uid?: string;
|
|
9
|
+
url?: string;
|
|
10
|
+
mimeType?: string;
|
|
11
|
+
pending?: boolean;
|
|
12
|
+
onReady?: (skipOnce?: boolean) => void;
|
|
13
|
+
linkProps?: AttachmentLinkProps;
|
|
14
|
+
containerProps?: React.ComponentProps<'div'>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AttachmentLinkProps
|
|
18
|
+
extends React.ComponentProps<'a'>, Omit<AttachmentProps, 'containerProps' & 'mimeType' & 'onReady'> {
|
|
19
|
+
children?: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AttachmentImageProps
|
|
23
|
+
extends React.ComponentProps<'img'>, Omit<AttachmentProps, 'containerProps' & 'mimeType'> {}
|
|
24
|
+
|
|
25
|
+
interface AttachmentAudioProps
|
|
26
|
+
extends React.ComponentProps<'audio'>, Omit<AttachmentProps, 'linkProps' & 'mimeType' & 'onReady'> {}
|
|
27
|
+
|
|
28
|
+
interface AttachmentVideoProps extends React.ComponentProps<'video'>, Omit<AttachmentProps, 'linkProps' & 'mimeType'> {
|
|
29
|
+
maxSize?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface AttachmentFileProps extends LucideProps, Omit<AttachmentProps, 'containerProps' & 'mimeType'> {
|
|
33
|
+
name?: string | undefined;
|
|
34
|
+
iconElement?: React.ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface AttachmentFailedProps extends LucideProps, Omit<AttachmentProps, 'linkProps' & 'mimeType'> {
|
|
38
|
+
name?: string | undefined;
|
|
39
|
+
iconElement?: React.ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function AttachmentLinkBase(
|
|
43
|
+
{ url, pending = false, children, ...props }: AttachmentLinkProps,
|
|
44
|
+
ref: React.ForwardedRef<HTMLAnchorElement>
|
|
45
|
+
) {
|
|
46
|
+
return (
|
|
47
|
+
<a
|
|
48
|
+
ref={ref}
|
|
49
|
+
target="_blank"
|
|
50
|
+
rel="noopener noreferrer"
|
|
51
|
+
{...props}
|
|
52
|
+
href={url}
|
|
53
|
+
className={cn(
|
|
54
|
+
'group relative min-h-8 min-w-8 w-full flex items-center justify-center rounded-md overflow-hidden bg-ring/10 hover:bg-ring/20 transition-color duration-300 ease-out cursor-pointer',
|
|
55
|
+
props?.className
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
<Spinner loading={pending} layout="overlay" />
|
|
60
|
+
</a>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const AttachmentLink = forwardRef<HTMLAnchorElement, AttachmentLinkProps>(AttachmentLinkBase);
|
|
65
|
+
|
|
66
|
+
AttachmentLink.displayName = 'AttachmentLink';
|
|
67
|
+
|
|
68
|
+
function AttachmentAudioBase(
|
|
69
|
+
{ uid, url, pending = false, containerProps, ...props }: AttachmentAudioProps,
|
|
70
|
+
ref: React.ForwardedRef<HTMLAudioElement>
|
|
71
|
+
) {
|
|
72
|
+
const audioId = `attachment_audio_${uid || getRandomString()}`;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
{...containerProps}
|
|
77
|
+
className={cn('relative min-h-8 min-w-8 w-full rounded-md overflow-hidden', containerProps?.className)}
|
|
78
|
+
>
|
|
79
|
+
<audio ref={ref} src={url} id={audioId} controls {...props} />
|
|
80
|
+
<Spinner loading={pending} layout="overlay" />
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const AttachmentAudio = forwardRef<HTMLAudioElement, AttachmentAudioProps>(AttachmentAudioBase);
|
|
86
|
+
|
|
87
|
+
function AttachmentVideoBase(
|
|
88
|
+
{ uid, url, maxSize = 360, pending = false, onReady = () => {}, containerProps, ...props }: AttachmentVideoProps,
|
|
89
|
+
ref: React.ForwardedRef<HTMLVideoElement>
|
|
90
|
+
) {
|
|
91
|
+
const videoId = `attachment_video_${uid || getRandomString()}`;
|
|
92
|
+
const videoMaxSize = `${maxSize}px`;
|
|
93
|
+
const playerRef = useRef<HTMLVideoElement>(null);
|
|
94
|
+
const [style, setStyle] = useState<React.CSSProperties>({
|
|
95
|
+
maxHeight: videoMaxSize,
|
|
96
|
+
maxWidth: videoMaxSize,
|
|
97
|
+
});
|
|
98
|
+
const handleCanPlay = (event: React.SyntheticEvent<HTMLVideoElement, Event>) => {
|
|
99
|
+
const player = playerRef.current;
|
|
100
|
+
|
|
101
|
+
if (player) {
|
|
102
|
+
const { videoWidth, videoHeight } = player;
|
|
103
|
+
const ratio = videoWidth / videoHeight || 1;
|
|
104
|
+
const height = ratio < 1 ? videoMaxSize : `${Math.round(maxSize / ratio)}px`;
|
|
105
|
+
const width = ratio < 1 ? `${Math.round(maxSize * ratio)}px` : videoMaxSize;
|
|
106
|
+
|
|
107
|
+
setStyle({ height, width, maxHeight: height, maxWidth: width });
|
|
108
|
+
props.onCanPlay?.(event);
|
|
109
|
+
onReady();
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
useImperativeHandle(ref, () => playerRef.current!, []);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
{...containerProps}
|
|
118
|
+
className={cn('relative min-h-20 w-full rounded-md overflow-hidden', containerProps?.className)}
|
|
119
|
+
>
|
|
120
|
+
<video
|
|
121
|
+
id={videoId}
|
|
122
|
+
ref={playerRef}
|
|
123
|
+
src={url}
|
|
124
|
+
controls
|
|
125
|
+
preload="metadata"
|
|
126
|
+
style={style}
|
|
127
|
+
{...props}
|
|
128
|
+
onCanPlay={handleCanPlay}
|
|
129
|
+
className={cn('size-full', props?.className)}
|
|
130
|
+
/>
|
|
131
|
+
<Spinner loading={pending} layout="overlay" />
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const AttachmentVideo = forwardRef<HTMLVideoElement, AttachmentVideoProps>(AttachmentVideoBase);
|
|
137
|
+
|
|
138
|
+
AttachmentVideo.displayName = 'AttachmentVideo';
|
|
139
|
+
|
|
140
|
+
function AttachmentImageBase(
|
|
141
|
+
{ uid, url, pending = false, onReady = () => {}, linkProps, ...props }: AttachmentImageProps,
|
|
142
|
+
ref: React.ForwardedRef<HTMLImageElement>
|
|
143
|
+
) {
|
|
144
|
+
const imageId = `attachment_image_${uid || getRandomString()}`;
|
|
145
|
+
const handleLoad = (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
146
|
+
props.onLoad?.(event);
|
|
147
|
+
onReady();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<AttachmentLink href={url} pending={pending} {...linkProps}>
|
|
152
|
+
<img
|
|
153
|
+
ref={ref}
|
|
154
|
+
src={url}
|
|
155
|
+
id={imageId}
|
|
156
|
+
alt="attachment"
|
|
157
|
+
{...props}
|
|
158
|
+
className={cn(
|
|
159
|
+
'rounded-md object-cover min-h-8 min-w-8 max-h-[360px] group-hover:scale-102 transition-transform duration-300 ease-out',
|
|
160
|
+
props?.className
|
|
161
|
+
)}
|
|
162
|
+
onLoad={handleLoad}
|
|
163
|
+
/>
|
|
164
|
+
</AttachmentLink>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const AttachmentImage = forwardRef<HTMLImageElement, AttachmentImageProps>(AttachmentImageBase);
|
|
169
|
+
|
|
170
|
+
AttachmentImage.displayName = 'AttachmentImage';
|
|
171
|
+
|
|
172
|
+
function AttachmentFile({ url, name, pending = false, iconElement, linkProps, ...props }: AttachmentFileProps) {
|
|
173
|
+
const fileId = `attachment_file_${props.id || getRandomString()}`;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<AttachmentLink
|
|
177
|
+
href={url}
|
|
178
|
+
pending={pending}
|
|
179
|
+
{...linkProps}
|
|
180
|
+
className={cn('flex-row gap-1.5 p-2 hover:shadow', linkProps?.className)}
|
|
181
|
+
>
|
|
182
|
+
{iconElement || (
|
|
183
|
+
<File
|
|
184
|
+
id={fileId}
|
|
185
|
+
{...props}
|
|
186
|
+
className={cn(
|
|
187
|
+
'size-5 shrink-0 text-foreground/80 group-hover:text-foreground duration-300 ease-out',
|
|
188
|
+
props?.className
|
|
189
|
+
)}
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
{name && (
|
|
193
|
+
<span className="font-medium line-clamp-1 break-all text-foreground/80 group-hover:text-foreground duration-300 ease-out">
|
|
194
|
+
{name}
|
|
195
|
+
</span>
|
|
196
|
+
)}
|
|
197
|
+
</AttachmentLink>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
AttachmentFile.displayName = 'AttachmentFile';
|
|
202
|
+
|
|
203
|
+
function AttachmentFailed({
|
|
204
|
+
name = 'Unknown file',
|
|
205
|
+
pending = false,
|
|
206
|
+
iconElement,
|
|
207
|
+
containerProps,
|
|
208
|
+
...props
|
|
209
|
+
}: AttachmentFailedProps) {
|
|
210
|
+
const failedId = `attachment_failed_${props.id || getRandomString()}`;
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
{...containerProps}
|
|
215
|
+
className={cn(
|
|
216
|
+
'relative min-h-8 min-w-8 w-full flex flex-row items-center justify-center gap-2 px-2 bg-red-600/10 rounded-md overflow-hidden',
|
|
217
|
+
containerProps?.className
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
{iconElement || (
|
|
221
|
+
<FileXCorner id={failedId} {...props} className={cn('size-6 shrink-0 text-red-600', props?.className)} />
|
|
222
|
+
)}
|
|
223
|
+
<span className="font-medium line-clamp-1 break-all text-red-600">{name}</span>
|
|
224
|
+
<Spinner loading={pending} layout="overlay" />
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
AttachmentFailed.displayName = 'AttachmentFailed';
|
|
230
|
+
|
|
231
|
+
function AttachmentBase({ mimeType, ...props }: AttachmentProps) {
|
|
232
|
+
const [type = ''] = mimeType?.split('/') || [];
|
|
233
|
+
|
|
234
|
+
if (!props.url) {
|
|
235
|
+
return <AttachmentFailed {...props} />;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
switch (type) {
|
|
239
|
+
case 'image':
|
|
240
|
+
return <AttachmentImage {...props} />;
|
|
241
|
+
case 'video':
|
|
242
|
+
return <AttachmentVideo {...props} />;
|
|
243
|
+
case 'audio':
|
|
244
|
+
return <AttachmentAudio {...props} />;
|
|
245
|
+
default:
|
|
246
|
+
return <AttachmentFile name={mimeType} {...props} />;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const Attachment = memo(AttachmentBase);
|
|
251
|
+
|
|
252
|
+
Attachment.displayName = 'Attachment';
|
|
253
|
+
|
|
254
|
+
export {
|
|
255
|
+
Attachment,
|
|
256
|
+
AttachmentLink,
|
|
257
|
+
AttachmentImage,
|
|
258
|
+
AttachmentAudio,
|
|
259
|
+
AttachmentVideo,
|
|
260
|
+
AttachmentFile,
|
|
261
|
+
AttachmentFailed,
|
|
262
|
+
type AttachmentLinkProps,
|
|
263
|
+
type AttachmentImageProps,
|
|
264
|
+
type AttachmentAudioProps,
|
|
265
|
+
type AttachmentVideoProps,
|
|
266
|
+
type AttachmentFileProps,
|
|
267
|
+
type AttachmentFailedProps,
|
|
268
|
+
type AttachmentProps,
|
|
269
|
+
};
|