@connectycube/react-ui-kit 0.0.22 → 0.1.2
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/README.md +33 -6
- package/gen/components/alert-dialog.jsx +2 -2
- package/gen/components/attachment.jsx +10 -11
- package/gen/components/avatar.jsx +2 -2
- package/gen/components/badge.jsx +2 -2
- package/gen/components/button.jsx +4 -3
- package/gen/components/chat-bubble.jsx +8 -8
- package/gen/components/chat-input.jsx +10 -10
- package/gen/components/chat-list.jsx +13 -13
- package/gen/components/checkbox.jsx +2 -2
- package/gen/components/dialog-item.jsx +2 -2
- package/gen/components/dialogs-list.jsx +4 -4
- package/gen/components/dismiss-layer.jsx +7 -7
- package/gen/components/file-picker.jsx +4 -4
- package/gen/components/formatted-date.jsx +2 -2
- package/gen/components/input.jsx +2 -2
- package/gen/components/label.jsx +2 -2
- package/gen/components/link-preview.jsx +7 -7
- package/gen/components/linkify-text.jsx +5 -5
- package/gen/components/placeholder-text.jsx +1 -2
- package/gen/components/presence.jsx +1 -0
- package/gen/components/quick-actions.jsx +2 -2
- package/gen/components/search.jsx +3 -3
- package/gen/components/spinner.jsx +3 -2
- package/gen/components/status-call.jsx +1 -0
- package/gen/components/status-indicator.jsx +2 -2
- package/gen/components/status-sent.jsx +1 -0
- package/gen/components/stream-view.jsx +16 -16
- package/package.json +14 -14
- package/src/components/alert-dialog.tsx +2 -3
- package/src/components/attachment.tsx +12 -13
- package/src/components/avatar.tsx +2 -3
- package/src/components/badge.tsx +2 -3
- package/src/components/button.tsx +4 -4
- package/src/components/chat-bubble.tsx +9 -10
- package/src/components/chat-input.tsx +17 -13
- package/src/components/chat-list.tsx +31 -24
- package/src/components/checkbox.tsx +2 -3
- package/src/components/dialog-item.tsx +2 -3
- package/src/components/dialogs-list.tsx +4 -5
- package/src/components/dismiss-layer.tsx +7 -8
- package/src/components/file-picker.tsx +4 -5
- package/src/components/formatted-date.tsx +4 -3
- package/src/components/input.tsx +2 -3
- package/src/components/label.tsx +2 -3
- package/src/components/link-preview.tsx +16 -26
- package/src/components/linkify-text.tsx +5 -6
- package/src/components/placeholder-text.tsx +1 -2
- package/src/components/presence.tsx +1 -1
- package/src/components/quick-actions.tsx +2 -3
- package/src/components/search.tsx +3 -4
- package/src/components/spinner.tsx +3 -2
- package/src/components/status-call.tsx +1 -0
- package/src/components/status-indicator.tsx +2 -3
- package/src/components/status-sent.tsx +1 -0
- package/src/components/stream-view.tsx +18 -17
- package/src/components/connectycube-ui/attachment.tsx +0 -269
- package/src/components/connectycube-ui/avatar.jsx +0 -54
- package/src/components/connectycube-ui/avatar.tsx +0 -77
- package/src/components/connectycube-ui/badge.jsx +0 -45
- package/src/components/connectycube-ui/badge.tsx +0 -42
- package/src/components/connectycube-ui/chat-input.tsx +0 -174
- package/src/components/connectycube-ui/chat-message.tsx +0 -138
- package/src/components/connectycube-ui/dialog-item.jsx +0 -149
- package/src/components/connectycube-ui/dialog-item.tsx +0 -188
- package/src/components/connectycube-ui/file-picker.jsx +0 -200
- package/src/components/connectycube-ui/file-picker.tsx +0 -231
- package/src/components/connectycube-ui/formatted-date.jsx +0 -57
- package/src/components/connectycube-ui/formatted-date.tsx +0 -57
- package/src/components/connectycube-ui/label.jsx +0 -22
- package/src/components/connectycube-ui/label.tsx +0 -23
- package/src/components/connectycube-ui/link-preview.tsx +0 -149
- package/src/components/connectycube-ui/linkify-text.tsx +0 -40
- package/src/components/connectycube-ui/presence.jsx +0 -81
- package/src/components/connectycube-ui/presence.tsx +0 -96
- package/src/components/connectycube-ui/status-sent.jsx +0 -21
- package/src/components/connectycube-ui/status-sent.tsx +0 -25
- package/src/components/connectycube-ui/utils.js +0 -10
- package/src/components/connectycube-ui/utils.ts +0 -10
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
import { forwardRef, useState } from 'react';
|
|
3
|
-
import { FilePlusCorner, LucideProps, Paperclip } from 'lucide-react';
|
|
4
|
-
import { Label, type LabelProps } from './label';
|
|
5
|
-
import { cn } from './utils';
|
|
6
|
-
|
|
7
|
-
const areValidFiles = (files: File[], accept?: string): boolean => {
|
|
8
|
-
if (files.length === 0) {
|
|
9
|
-
return false;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const acceptList =
|
|
13
|
-
typeof accept === 'string' ? accept.split(/\s*,\s*|\s+/).map((type) => type.toLowerCase().trim()) : null;
|
|
14
|
-
|
|
15
|
-
if (acceptList === null || acceptList.length === 0) {
|
|
16
|
-
return false;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return files.every((file) => {
|
|
20
|
-
const { type, size } = file;
|
|
21
|
-
|
|
22
|
-
if (typeof type !== 'string' || typeof size !== 'number' || size <= 0 || type.length === 0) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const fileMimeType = type.toLowerCase();
|
|
27
|
-
const [fileType, fileSubtype] = fileMimeType.split('/');
|
|
28
|
-
|
|
29
|
-
return acceptList.some(
|
|
30
|
-
(acceptType) =>
|
|
31
|
-
acceptType.startsWith('*') ||
|
|
32
|
-
acceptType === fileMimeType ||
|
|
33
|
-
(fileType && acceptType.endsWith('/*') && acceptType.startsWith(fileType)) ||
|
|
34
|
-
(acceptType.startsWith('.') && acceptType.slice(1) === fileSubtype)
|
|
35
|
-
);
|
|
36
|
-
});
|
|
37
|
-
};
|
|
38
|
-
const handleFiles = (
|
|
39
|
-
fileList: FileList | null,
|
|
40
|
-
accept: string,
|
|
41
|
-
multiple: boolean,
|
|
42
|
-
onSelectFile: (files: File[]) => void,
|
|
43
|
-
onInvalidFile: () => void
|
|
44
|
-
) => {
|
|
45
|
-
const files = Array.from(fileList || []).filter(Boolean);
|
|
46
|
-
|
|
47
|
-
if (files.length === 0) {
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const validated = areValidFiles(files, accept);
|
|
52
|
-
|
|
53
|
-
if (validated) {
|
|
54
|
-
onSelectFile(multiple ? files : files[0] ? [files[0]] : []);
|
|
55
|
-
} else {
|
|
56
|
-
onInvalidFile();
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
interface FilePickerInputProps extends React.ComponentProps<'input'> {
|
|
61
|
-
onSelectFile: (files: File[]) => void;
|
|
62
|
-
onInvalidFile?: () => void;
|
|
63
|
-
iconElement?: React.ReactNode;
|
|
64
|
-
labelProps?: LabelProps;
|
|
65
|
-
iconProps?: LucideProps;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface FilePickerDropzoneProps extends FilePickerInputProps {
|
|
69
|
-
children?: React.ReactNode;
|
|
70
|
-
placeholder?: string;
|
|
71
|
-
dropZoneProps?: React.ComponentProps<'div'>;
|
|
72
|
-
placeholderContainerProps?: React.ComponentProps<'div'>;
|
|
73
|
-
iconProps?: LucideProps;
|
|
74
|
-
placeholderProps?: React.ComponentProps<'span'>;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function FilePickerInputBase(
|
|
78
|
-
{
|
|
79
|
-
onSelectFile = () => {},
|
|
80
|
-
onInvalidFile = () => {},
|
|
81
|
-
multiple = false,
|
|
82
|
-
accept = '*/*',
|
|
83
|
-
iconElement,
|
|
84
|
-
labelProps,
|
|
85
|
-
iconProps,
|
|
86
|
-
...props
|
|
87
|
-
}: FilePickerInputProps,
|
|
88
|
-
ref?: React.ForwardedRef<HTMLInputElement>
|
|
89
|
-
) {
|
|
90
|
-
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
91
|
-
handleFiles(event.currentTarget.files, accept, multiple, onSelectFile, onInvalidFile);
|
|
92
|
-
event.currentTarget.value = '';
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
return (
|
|
96
|
-
<Label
|
|
97
|
-
{...labelProps}
|
|
98
|
-
htmlFor="file-uploader"
|
|
99
|
-
className={cn(
|
|
100
|
-
'group p-2 rounded-full hover:bg-ring/10 transition-all duration-200 ease-in-out cursor-pointer',
|
|
101
|
-
labelProps?.className
|
|
102
|
-
)}
|
|
103
|
-
>
|
|
104
|
-
{iconElement || (
|
|
105
|
-
<Paperclip
|
|
106
|
-
{...iconProps}
|
|
107
|
-
className={cn(
|
|
108
|
-
'text-foreground group-hover:scale-110 transition-all duration-200 ease-in-out',
|
|
109
|
-
iconProps?.className
|
|
110
|
-
)}
|
|
111
|
-
/>
|
|
112
|
-
)}
|
|
113
|
-
<input
|
|
114
|
-
ref={ref}
|
|
115
|
-
id="file-uploader"
|
|
116
|
-
type="file"
|
|
117
|
-
multiple={multiple}
|
|
118
|
-
accept={accept}
|
|
119
|
-
onChange={handleChange}
|
|
120
|
-
{...props}
|
|
121
|
-
className={cn('hidden', props?.className)}
|
|
122
|
-
/>
|
|
123
|
-
</Label>
|
|
124
|
-
);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const FilePickerInput = forwardRef<HTMLInputElement, FilePickerInputProps>(FilePickerInputBase);
|
|
128
|
-
|
|
129
|
-
FilePickerInput.displayName = 'FilePickerInput';
|
|
130
|
-
|
|
131
|
-
function FilePickerDropzoneBase(
|
|
132
|
-
{
|
|
133
|
-
onSelectFile = () => {},
|
|
134
|
-
onInvalidFile = () => {},
|
|
135
|
-
multiple = false,
|
|
136
|
-
accept = '*/*',
|
|
137
|
-
children,
|
|
138
|
-
iconElement,
|
|
139
|
-
placeholder,
|
|
140
|
-
dropZoneProps,
|
|
141
|
-
placeholderContainerProps,
|
|
142
|
-
iconProps,
|
|
143
|
-
placeholderProps,
|
|
144
|
-
...props
|
|
145
|
-
}: FilePickerDropzoneProps,
|
|
146
|
-
ref: React.ForwardedRef<HTMLDivElement>
|
|
147
|
-
) {
|
|
148
|
-
const [isDragging, setIsDragging] = useState(false);
|
|
149
|
-
const handleDragEvent = (event: React.DragEvent<HTMLDivElement>) => {
|
|
150
|
-
event.preventDefault();
|
|
151
|
-
event.stopPropagation();
|
|
152
|
-
};
|
|
153
|
-
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
|
154
|
-
handleDragEvent(event);
|
|
155
|
-
setIsDragging(false);
|
|
156
|
-
handleFiles(event.dataTransfer.files, accept, multiple, onSelectFile, onInvalidFile);
|
|
157
|
-
event.dataTransfer.clearData();
|
|
158
|
-
};
|
|
159
|
-
const handleDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
|
|
160
|
-
handleDragEvent(event);
|
|
161
|
-
|
|
162
|
-
if (event.dataTransfer.items && event.dataTransfer.items.length > 0) {
|
|
163
|
-
setIsDragging(true);
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
const handleDragLeave = (event: React.DragEvent<HTMLDivElement>) => {
|
|
167
|
-
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
|
|
168
|
-
setIsDragging(false);
|
|
169
|
-
}
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
return (
|
|
173
|
-
<div
|
|
174
|
-
ref={ref}
|
|
175
|
-
onDragEnter={handleDragEnter}
|
|
176
|
-
onDragLeave={handleDragLeave}
|
|
177
|
-
onDragOver={handleDragEvent}
|
|
178
|
-
{...props}
|
|
179
|
-
className={cn('size-full relative', props?.className)}
|
|
180
|
-
>
|
|
181
|
-
{children}
|
|
182
|
-
<div
|
|
183
|
-
onDrop={handleDrop}
|
|
184
|
-
{...dropZoneProps}
|
|
185
|
-
className={cn(
|
|
186
|
-
'group absolute top-0 left-0 size-full',
|
|
187
|
-
'flex items-center justify-center',
|
|
188
|
-
'transition-all duration-300 ease-out',
|
|
189
|
-
'border-2 border-dashed border-ring bg-ring/25 rounded-md',
|
|
190
|
-
isDragging
|
|
191
|
-
? 'opacity-100 pointer-events-auto visible scale-100'
|
|
192
|
-
: 'opacity-0 pointer-events-none invisible scale-98',
|
|
193
|
-
dropZoneProps?.className
|
|
194
|
-
)}
|
|
195
|
-
>
|
|
196
|
-
<div
|
|
197
|
-
{...placeholderContainerProps}
|
|
198
|
-
className={cn(
|
|
199
|
-
'bg-muted flex flex-row items-center gap-2 rounded-full py-2 px-4 transition-all',
|
|
200
|
-
isDragging ? 'scale-100' : 'scale-90',
|
|
201
|
-
placeholderContainerProps?.className
|
|
202
|
-
)}
|
|
203
|
-
>
|
|
204
|
-
{iconElement || (
|
|
205
|
-
<FilePlusCorner
|
|
206
|
-
absoluteStrokeWidth
|
|
207
|
-
{...iconProps}
|
|
208
|
-
className={cn('text-ring size-6', iconProps?.className)}
|
|
209
|
-
/>
|
|
210
|
-
)}
|
|
211
|
-
<span {...placeholderProps} className={cn('text-ring text-md font-bold', placeholderProps?.className)}>
|
|
212
|
-
{placeholder || 'Drop files here'}
|
|
213
|
-
</span>
|
|
214
|
-
</div>
|
|
215
|
-
</div>
|
|
216
|
-
</div>
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const FilePickerDropzone = forwardRef<HTMLDivElement, FilePickerDropzoneProps>(FilePickerDropzoneBase);
|
|
221
|
-
|
|
222
|
-
FilePickerDropzone.displayName = 'FilePickerDropzone';
|
|
223
|
-
|
|
224
|
-
export {
|
|
225
|
-
FilePickerInput,
|
|
226
|
-
FilePickerDropzone,
|
|
227
|
-
FilePickerInput as Input,
|
|
228
|
-
FilePickerDropzone as Dropzone,
|
|
229
|
-
type FilePickerInputProps,
|
|
230
|
-
type FilePickerDropzoneProps,
|
|
231
|
-
};
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { forwardRef, memo } from 'react';
|
|
2
|
-
import { differenceInCalendarDays, format, formatDistanceToNow, isToday } from 'date-fns';
|
|
3
|
-
import { el, enUS, uk } from 'date-fns/locale';
|
|
4
|
-
import { cn } from './utils';
|
|
5
|
-
|
|
6
|
-
const locales = {
|
|
7
|
-
en: enUS,
|
|
8
|
-
el: el,
|
|
9
|
-
ua: uk,
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
function formatDate(date, language = 'en', distanceToNow = false) {
|
|
13
|
-
const locale = locales[language] ?? enUS;
|
|
14
|
-
|
|
15
|
-
if (distanceToNow) {
|
|
16
|
-
return formatDistanceToNow(date, {
|
|
17
|
-
locale,
|
|
18
|
-
addSuffix: true,
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const isWithinLast7Days = (date) => {
|
|
23
|
-
const diff = differenceInCalendarDays(new Date(), date);
|
|
24
|
-
|
|
25
|
-
return diff >= 0 && diff <= 6;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
return isToday(date)
|
|
29
|
-
? format(date, 'p', {
|
|
30
|
-
locale,
|
|
31
|
-
})
|
|
32
|
-
: isWithinLast7Days(date)
|
|
33
|
-
? format(date, 'eeee', {
|
|
34
|
-
locale,
|
|
35
|
-
})
|
|
36
|
-
: format(date, 'P', {
|
|
37
|
-
locale,
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function FormattedDateBase({ date, language, distanceToNow, ...props }, ref) {
|
|
42
|
-
if (!date) {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<span ref={ref} {...props} className={cn('text-xs text-muted-foreground', props?.className)}>
|
|
48
|
-
{formatDate(date, language, distanceToNow)}
|
|
49
|
-
</span>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const FormattedDate = memo(forwardRef(FormattedDateBase));
|
|
54
|
-
|
|
55
|
-
FormattedDate.displayName = 'FormattedDate';
|
|
56
|
-
|
|
57
|
-
export { FormattedDate };
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { forwardRef, memo } from 'react';
|
|
2
|
-
import { differenceInCalendarDays, format, formatDistanceToNow, isToday } from 'date-fns';
|
|
3
|
-
import { el, enUS, uk, type Locale } from 'date-fns/locale';
|
|
4
|
-
import { cn } from './utils';
|
|
5
|
-
|
|
6
|
-
interface FormattedDateProps extends React.ComponentProps<'span'> {
|
|
7
|
-
date?: Date | string | number | null;
|
|
8
|
-
language?: string;
|
|
9
|
-
distanceToNow?: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const locales: Record<string, Locale> = {
|
|
13
|
-
en: enUS,
|
|
14
|
-
el: el,
|
|
15
|
-
ua: uk,
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
function formatDate(date: string | number | Date, language: string = 'en', distanceToNow = false): string {
|
|
19
|
-
const locale = locales[language] ?? enUS;
|
|
20
|
-
|
|
21
|
-
if (distanceToNow) {
|
|
22
|
-
return formatDistanceToNow(date, { locale, addSuffix: true });
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const isWithinLast7Days = (date: Date | string | number) => {
|
|
26
|
-
const diff = differenceInCalendarDays(new Date(), date);
|
|
27
|
-
|
|
28
|
-
return diff >= 0 && diff <= 6;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
return isToday(date)
|
|
32
|
-
? format(date, 'p', { locale })
|
|
33
|
-
: isWithinLast7Days(date)
|
|
34
|
-
? format(date, 'eeee', { locale })
|
|
35
|
-
: format(date, 'P', { locale });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function FormattedDateBase(
|
|
39
|
-
{ date, language, distanceToNow, ...props }: FormattedDateProps,
|
|
40
|
-
ref: React.ForwardedRef<HTMLSpanElement>
|
|
41
|
-
) {
|
|
42
|
-
if (!date) {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<span ref={ref} {...props} className={cn('text-xs text-muted-foreground', props?.className)}>
|
|
48
|
-
{formatDate(date, language, distanceToNow)}
|
|
49
|
-
</span>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const FormattedDate = memo(forwardRef<HTMLSpanElement, FormattedDateProps>(FormattedDateBase));
|
|
54
|
-
|
|
55
|
-
FormattedDate.displayName = 'FormattedDate';
|
|
56
|
-
|
|
57
|
-
export { FormattedDate, type FormattedDateProps };
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { forwardRef } from 'react';
|
|
2
|
-
import { Root as LabelRoot } from '@radix-ui/react-label';
|
|
3
|
-
import { cn } from './utils';
|
|
4
|
-
|
|
5
|
-
function LabelBase({ ...props }, ref) {
|
|
6
|
-
return (
|
|
7
|
-
<LabelRoot
|
|
8
|
-
ref={ref}
|
|
9
|
-
{...props}
|
|
10
|
-
className={cn(
|
|
11
|
-
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
|
12
|
-
props?.className
|
|
13
|
-
)}
|
|
14
|
-
/>
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const Label = forwardRef(LabelBase);
|
|
19
|
-
|
|
20
|
-
Label.displayName = 'Label';
|
|
21
|
-
|
|
22
|
-
export { Label };
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
import { forwardRef } from 'react';
|
|
3
|
-
import { Root as LabelRoot, type LabelProps } from '@radix-ui/react-label';
|
|
4
|
-
import { cn } from './utils';
|
|
5
|
-
|
|
6
|
-
function LabelBase({ ...props }: LabelProps, ref: React.ForwardedRef<HTMLLabelElement>) {
|
|
7
|
-
return (
|
|
8
|
-
<LabelRoot
|
|
9
|
-
ref={ref}
|
|
10
|
-
{...props}
|
|
11
|
-
className={cn(
|
|
12
|
-
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
|
13
|
-
props?.className
|
|
14
|
-
)}
|
|
15
|
-
/>
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const Label = forwardRef(LabelBase);
|
|
20
|
-
|
|
21
|
-
Label.displayName = 'Label';
|
|
22
|
-
|
|
23
|
-
export { Label, type LabelProps };
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
import { forwardRef, memo, useCallback, useState } from 'react';
|
|
3
|
-
import { Globe, type LucideProps } from 'lucide-react';
|
|
4
|
-
import { cn } from './utils';
|
|
5
|
-
|
|
6
|
-
const decodeHtmlEntities = (text: string) => {
|
|
7
|
-
if (text.length === 0) {
|
|
8
|
-
return text;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const element = document.createElement('div');
|
|
12
|
-
|
|
13
|
-
element.innerHTML = text;
|
|
14
|
-
|
|
15
|
-
return element.textContent.trim();
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
interface LinkPreviewProps extends React.ComponentProps<'a'> {
|
|
19
|
-
thin?: boolean;
|
|
20
|
-
title?: string;
|
|
21
|
-
description?: string;
|
|
22
|
-
icon?: React.ComponentProps<'img'>['src'];
|
|
23
|
-
image?: React.ComponentProps<'img'>['src'];
|
|
24
|
-
onReady?: () => void;
|
|
25
|
-
iconFallbackElement?: React.ReactElement;
|
|
26
|
-
titleContainerProps?: React.ComponentProps<'div'>;
|
|
27
|
-
iconProps?: React.ComponentProps<'img'>;
|
|
28
|
-
iconFallbackProps?: LucideProps;
|
|
29
|
-
titleProps?: React.ComponentProps<'span'>;
|
|
30
|
-
descriptionProps?: React.ComponentProps<'div'>;
|
|
31
|
-
imageContainerProps?: React.ComponentProps<'div'>;
|
|
32
|
-
imageProps?: React.ComponentProps<'img'>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function LinkPreviewBase(
|
|
36
|
-
{
|
|
37
|
-
thin = false,
|
|
38
|
-
title = '',
|
|
39
|
-
description = '',
|
|
40
|
-
icon,
|
|
41
|
-
iconFallbackElement,
|
|
42
|
-
image,
|
|
43
|
-
onReady = () => {},
|
|
44
|
-
titleContainerProps,
|
|
45
|
-
iconProps,
|
|
46
|
-
iconFallbackProps,
|
|
47
|
-
titleProps,
|
|
48
|
-
descriptionProps,
|
|
49
|
-
imageContainerProps,
|
|
50
|
-
imageProps,
|
|
51
|
-
...props
|
|
52
|
-
}: LinkPreviewProps,
|
|
53
|
-
ref: React.ForwardedRef<HTMLAnchorElement>
|
|
54
|
-
) {
|
|
55
|
-
const [iconSrc, setIconSrc] = useState<React.ComponentProps<'img'>['src']>(icon);
|
|
56
|
-
const [imageSrc, setImageSrc] = useState<React.ComponentProps<'img'>['src']>(image);
|
|
57
|
-
const handleOnLoad = useCallback(
|
|
58
|
-
(event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
59
|
-
imageProps?.onLoad?.(event);
|
|
60
|
-
onReady();
|
|
61
|
-
},
|
|
62
|
-
[onReady, imageProps]
|
|
63
|
-
);
|
|
64
|
-
const handleIconOnError = useCallback(
|
|
65
|
-
(event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
66
|
-
iconProps?.onError?.(event);
|
|
67
|
-
setIconSrc(undefined);
|
|
68
|
-
},
|
|
69
|
-
[iconProps]
|
|
70
|
-
);
|
|
71
|
-
const handleImageOnError = useCallback(
|
|
72
|
-
(event: React.SyntheticEvent<HTMLImageElement, Event>) => {
|
|
73
|
-
imageProps?.onError?.(event);
|
|
74
|
-
setImageSrc(undefined);
|
|
75
|
-
},
|
|
76
|
-
[imageProps]
|
|
77
|
-
);
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<a
|
|
81
|
-
ref={ref}
|
|
82
|
-
target="_blank"
|
|
83
|
-
rel="noopener noreferrer"
|
|
84
|
-
{...props}
|
|
85
|
-
className={cn(
|
|
86
|
-
'transition-color duration-300 ease-out',
|
|
87
|
-
'grid items-start gap-2 p-2 mt-1 overflow-hidden rounded border-l-4 border-l-ring bg-white hover:bg-ring/20',
|
|
88
|
-
thin ? 'grid-cols-3' : 'grid-cols-1',
|
|
89
|
-
props?.className
|
|
90
|
-
)}
|
|
91
|
-
>
|
|
92
|
-
<div
|
|
93
|
-
{...titleContainerProps}
|
|
94
|
-
className={cn('flex items-start gap-2', thin ? 'col-span-3' : 'col-span-1', titleContainerProps?.className)}
|
|
95
|
-
>
|
|
96
|
-
{iconSrc ? (
|
|
97
|
-
<img
|
|
98
|
-
alt="icon"
|
|
99
|
-
src={iconSrc}
|
|
100
|
-
{...iconProps}
|
|
101
|
-
onError={handleIconOnError}
|
|
102
|
-
className={cn('size-5', iconProps?.className)}
|
|
103
|
-
/>
|
|
104
|
-
) : (
|
|
105
|
-
iconFallbackElement || <Globe {...iconFallbackProps} className={cn('size-5', iconFallbackProps?.className)} />
|
|
106
|
-
)}
|
|
107
|
-
<span
|
|
108
|
-
{...titleProps}
|
|
109
|
-
className={cn('text-sm font-bold', thin ? 'line-clamp-1' : 'line-clamp-2', titleProps?.className)}
|
|
110
|
-
>
|
|
111
|
-
{decodeHtmlEntities(title)}
|
|
112
|
-
</span>
|
|
113
|
-
</div>
|
|
114
|
-
{description && (
|
|
115
|
-
<span
|
|
116
|
-
{...descriptionProps}
|
|
117
|
-
className={cn(
|
|
118
|
-
'text-sm line-clamp-5',
|
|
119
|
-
thin ? (imageSrc ? 'col-span-2' : 'col-span-3') : 'col-span-1',
|
|
120
|
-
descriptionProps?.className
|
|
121
|
-
)}
|
|
122
|
-
>
|
|
123
|
-
{decodeHtmlEntities(description)}
|
|
124
|
-
</span>
|
|
125
|
-
)}
|
|
126
|
-
{imageSrc && (
|
|
127
|
-
<div
|
|
128
|
-
{...imageContainerProps}
|
|
129
|
-
className={cn('flex items-center justify-center', imageContainerProps?.className)}
|
|
130
|
-
>
|
|
131
|
-
<img
|
|
132
|
-
alt="banner"
|
|
133
|
-
src={imageSrc}
|
|
134
|
-
{...imageProps}
|
|
135
|
-
onLoad={handleOnLoad}
|
|
136
|
-
onError={handleImageOnError}
|
|
137
|
-
className={cn('rounded max-w-full object-contain', thin ? 'max-h-25' : 'max-h-60', imageProps?.className)}
|
|
138
|
-
/>
|
|
139
|
-
</div>
|
|
140
|
-
)}
|
|
141
|
-
</a>
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
const LinkPreview = memo(forwardRef<HTMLAnchorElement, LinkPreviewProps>(LinkPreviewBase));
|
|
146
|
-
|
|
147
|
-
LinkPreview.displayName = 'LinkPreview';
|
|
148
|
-
|
|
149
|
-
export { LinkPreview, type LinkPreviewProps };
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import type React from 'react';
|
|
2
|
-
import type { Opts } from 'linkifyjs';
|
|
3
|
-
import { forwardRef, memo, useMemo } from 'react';
|
|
4
|
-
import Linkify from 'linkify-react';
|
|
5
|
-
import { cn } from './utils';
|
|
6
|
-
|
|
7
|
-
const DEFAULT_LINKIFY_OPTIONS: Opts = {
|
|
8
|
-
target: '_blank',
|
|
9
|
-
rel: 'noopener noreferrer',
|
|
10
|
-
className: 'text-blue-500 hover:underline hover:text-blue-600 transition-color duration-300',
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
type LinkifyTextProps = React.ComponentProps<'p'> & {
|
|
14
|
-
text: string;
|
|
15
|
-
linkifyProps?: Opts;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
function LinkifyTextBase(
|
|
19
|
-
{ text, linkifyProps, ...props }: LinkifyTextProps,
|
|
20
|
-
ref: React.ForwardedRef<HTMLParagraphElement>
|
|
21
|
-
) {
|
|
22
|
-
const options = useMemo(() => {
|
|
23
|
-
return {
|
|
24
|
-
...DEFAULT_LINKIFY_OPTIONS,
|
|
25
|
-
...linkifyProps,
|
|
26
|
-
};
|
|
27
|
-
}, [linkifyProps]);
|
|
28
|
-
|
|
29
|
-
return (
|
|
30
|
-
<p ref={ref} {...props} className={cn('wrap-break-word text-base', props?.className)}>
|
|
31
|
-
<Linkify options={options}>{text}</Linkify>
|
|
32
|
-
</p>
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const LinkifyText = memo(forwardRef<HTMLParagraphElement, LinkifyTextProps>(LinkifyTextBase));
|
|
37
|
-
|
|
38
|
-
LinkifyText.displayName = 'LinkifyText';
|
|
39
|
-
|
|
40
|
-
export { LinkifyText, type LinkifyTextProps };
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { capitalize, cn } from './utils';
|
|
2
|
-
|
|
3
|
-
function PresenceBadge({ status, ...props }) {
|
|
4
|
-
switch (status) {
|
|
5
|
-
case 'available':
|
|
6
|
-
return (
|
|
7
|
-
<svg
|
|
8
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
-
width="16"
|
|
10
|
-
height="16"
|
|
11
|
-
fill="currentColor"
|
|
12
|
-
viewBox="0 0 16 16"
|
|
13
|
-
{...props}
|
|
14
|
-
className={cn('rounded-full size-4.5 text-green-600 bg-white', props?.className)}
|
|
15
|
-
>
|
|
16
|
-
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z" />
|
|
17
|
-
</svg>
|
|
18
|
-
);
|
|
19
|
-
case 'busy':
|
|
20
|
-
return (
|
|
21
|
-
<svg
|
|
22
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
23
|
-
width="16"
|
|
24
|
-
height="16"
|
|
25
|
-
fill="currentColor"
|
|
26
|
-
viewBox="0 0 16 16"
|
|
27
|
-
{...props}
|
|
28
|
-
className={cn('rounded-full size-4.5 text-red-600 bg-white', props?.className)}
|
|
29
|
-
>
|
|
30
|
-
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z" />
|
|
31
|
-
</svg>
|
|
32
|
-
);
|
|
33
|
-
case 'away':
|
|
34
|
-
return (
|
|
35
|
-
<svg
|
|
36
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
37
|
-
width="16"
|
|
38
|
-
height="16"
|
|
39
|
-
fill="currentColor"
|
|
40
|
-
viewBox="0 0 16 16"
|
|
41
|
-
{...props}
|
|
42
|
-
className={cn('rounded-full size-4.5 text-yellow-500 bg-white', props?.className)}
|
|
43
|
-
>
|
|
44
|
-
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71z" />
|
|
45
|
-
</svg>
|
|
46
|
-
);
|
|
47
|
-
case 'unknown':
|
|
48
|
-
return (
|
|
49
|
-
<svg
|
|
50
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
51
|
-
width="16"
|
|
52
|
-
height="16"
|
|
53
|
-
fill="currentColor"
|
|
54
|
-
viewBox="0 0 16 16"
|
|
55
|
-
{...props}
|
|
56
|
-
className={cn('rounded-full size-4.5 text-gray-500 bg-white', props?.className)}
|
|
57
|
-
>
|
|
58
|
-
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z" />
|
|
59
|
-
</svg>
|
|
60
|
-
);
|
|
61
|
-
default:
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
PresenceBadge.displayName = 'PresenceBadge';
|
|
67
|
-
|
|
68
|
-
function Presence({ badge = true, status, label, badgeProps, labelProps, ...props }) {
|
|
69
|
-
const presence = capitalize(label || status);
|
|
70
|
-
|
|
71
|
-
return (
|
|
72
|
-
<div {...props} className={cn('flex items-center gap-2', props?.className)}>
|
|
73
|
-
{badge && <PresenceBadge status={status} {...badgeProps} />}
|
|
74
|
-
<span {...labelProps}>{presence}</span>
|
|
75
|
-
</div>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
Presence.displayName = 'Presence';
|
|
80
|
-
|
|
81
|
-
export { Presence, PresenceBadge };
|