@tuturuuu/ui 0.0.4
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/.checksum +1 -0
- package/README.md +46 -0
- package/components.json +20 -0
- package/eslint.config.mjs +20 -0
- package/jsr.json +10 -0
- package/package.json +120 -0
- package/postcss.config.mjs +8 -0
- package/rollup.config.js +40 -0
- package/src/components/ui/accordion.tsx +70 -0
- package/src/components/ui/alert-dialog.tsx +156 -0
- package/src/components/ui/alert.tsx +58 -0
- package/src/components/ui/aspect-ratio.tsx +11 -0
- package/src/components/ui/avatar.tsx +52 -0
- package/src/components/ui/badge.tsx +49 -0
- package/src/components/ui/breadcrumb.tsx +108 -0
- package/src/components/ui/button.tsx +61 -0
- package/src/components/ui/calendar.tsx +212 -0
- package/src/components/ui/card.tsx +74 -0
- package/src/components/ui/carousel.tsx +240 -0
- package/src/components/ui/chart.tsx +365 -0
- package/src/components/ui/checkbox.tsx +31 -0
- package/src/components/ui/codeblock.tsx +161 -0
- package/src/components/ui/collapsible.tsx +33 -0
- package/src/components/ui/color-picker.tsx +143 -0
- package/src/components/ui/command.tsx +176 -0
- package/src/components/ui/context-menu.tsx +251 -0
- package/src/components/ui/custom/autosize-textarea.tsx +111 -0
- package/src/components/ui/custom/calendar/core.tsx +61 -0
- package/src/components/ui/custom/calendar/day-cell.tsx +74 -0
- package/src/components/ui/custom/calendar/month-header.tsx +59 -0
- package/src/components/ui/custom/calendar/month-view.tsx +110 -0
- package/src/components/ui/custom/calendar/utils.ts +76 -0
- package/src/components/ui/custom/calendar/year-calendar.tsx +64 -0
- package/src/components/ui/custom/calendar/year-view.tsx +58 -0
- package/src/components/ui/custom/combobox.tsx +197 -0
- package/src/components/ui/custom/common-footer.tsx +215 -0
- package/src/components/ui/custom/compared-date-range-picker.tsx +561 -0
- package/src/components/ui/custom/date-input.tsx +279 -0
- package/src/components/ui/custom/empty-card.tsx +39 -0
- package/src/components/ui/custom/feature-summary.tsx +135 -0
- package/src/components/ui/custom/file-uploader.tsx +349 -0
- package/src/components/ui/custom/input-field.tsx +29 -0
- package/src/components/ui/custom/loading-indicator.tsx +28 -0
- package/src/components/ui/custom/modifiable-dialog-trigger.tsx +83 -0
- package/src/components/ui/custom/month-picker.tsx +157 -0
- package/src/components/ui/custom/report-preview.tsx +175 -0
- package/src/components/ui/custom/search-bar.tsx +56 -0
- package/src/components/ui/custom/select-field.tsx +78 -0
- package/src/components/ui/custom/tables/data-table-column-header.tsx +72 -0
- package/src/components/ui/custom/tables/data-table-create-button.tsx +31 -0
- package/src/components/ui/custom/tables/data-table-faceted-filter.tsx +142 -0
- package/src/components/ui/custom/tables/data-table-pagination.tsx +243 -0
- package/src/components/ui/custom/tables/data-table-refresh-button.tsx +45 -0
- package/src/components/ui/custom/tables/data-table-toolbar.tsx +133 -0
- package/src/components/ui/custom/tables/data-table-view-options.tsx +112 -0
- package/src/components/ui/custom/tables/data-table.tsx +228 -0
- package/src/components/ui/custom/uploaded-files-card.tsx +50 -0
- package/src/components/ui/dialog.tsx +137 -0
- package/src/components/ui/drawer.tsx +131 -0
- package/src/components/ui/dropdown-menu.tsx +256 -0
- package/src/components/ui/form.tsx +167 -0
- package/src/components/ui/hover-card.tsx +41 -0
- package/src/components/ui/icons.tsx +506 -0
- package/src/components/ui/input-otp.tsx +78 -0
- package/src/components/ui/input.tsx +18 -0
- package/src/components/ui/label.tsx +23 -0
- package/src/components/ui/markdown.tsx +7 -0
- package/src/components/ui/menubar.tsx +275 -0
- package/src/components/ui/navigation-menu.tsx +169 -0
- package/src/components/ui/pagination.tsx +126 -0
- package/src/components/ui/popover.tsx +47 -0
- package/src/components/ui/progress.tsx +30 -0
- package/src/components/ui/radio-group.tsx +44 -0
- package/src/components/ui/resizable.tsx +55 -0
- package/src/components/ui/scroll-area.tsx +57 -0
- package/src/components/ui/select.tsx +180 -0
- package/src/components/ui/separator.tsx +27 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +734 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/slider.tsx +62 -0
- package/src/components/ui/sonner.tsx +29 -0
- package/src/components/ui/switch.tsx +30 -0
- package/src/components/ui/table.tsx +112 -0
- package/src/components/ui/tabs.tsx +68 -0
- package/src/components/ui/tag-input.tsx +141 -0
- package/src/components/ui/textarea.tsx +17 -0
- package/src/components/ui/time-picker-input.tsx +117 -0
- package/src/components/ui/time-picker-utils.tsx +146 -0
- package/src/components/ui/toast.tsx +128 -0
- package/src/components/ui/toaster.tsx +35 -0
- package/src/components/ui/toggle-group.tsx +72 -0
- package/src/components/ui/toggle.tsx +46 -0
- package/src/components/ui/tooltip.tsx +60 -0
- package/src/globals.css +252 -0
- package/src/hooks/use-callback-ref.ts +28 -0
- package/src/hooks/use-controllable-state.ts +68 -0
- package/src/hooks/use-copy-to-clipboard.ts +46 -0
- package/src/hooks/use-form.ts +23 -0
- package/src/hooks/use-forwarded-ref.ts +17 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +191 -0
- package/src/resolvers.ts +3 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useControllableState } from '../../../hooks/use-controllable-state';
|
|
4
|
+
import { Button } from '../button';
|
|
5
|
+
import { ScrollArea } from '../scroll-area';
|
|
6
|
+
import { Separator } from '../separator';
|
|
7
|
+
import { cn, formatBytes } from '@tuturuuu/utils/format';
|
|
8
|
+
import { File, FileText, Upload, X } from 'lucide-react';
|
|
9
|
+
import { HTMLAttributes, useCallback, useState } from 'react';
|
|
10
|
+
import Dropzone, {
|
|
11
|
+
type DropzoneProps,
|
|
12
|
+
type FileRejection,
|
|
13
|
+
} from 'react-dropzone';
|
|
14
|
+
import { toast } from 'sonner';
|
|
15
|
+
|
|
16
|
+
export interface StatedFile {
|
|
17
|
+
rawFile: File;
|
|
18
|
+
url: string;
|
|
19
|
+
status: 'pending' | 'uploading' | 'uploaded' | 'error';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface FileUploaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
23
|
+
/**
|
|
24
|
+
* Value of the uploader.
|
|
25
|
+
* @type StatedFile[]
|
|
26
|
+
* @default undefined
|
|
27
|
+
* @example value={files}
|
|
28
|
+
*/
|
|
29
|
+
value?: StatedFile[];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Function to be called when the value changes.
|
|
33
|
+
* @type (files: StatedFile[]) => void
|
|
34
|
+
* @default undefined
|
|
35
|
+
* @example onValueChange={(files) => setFiles(files)}
|
|
36
|
+
*/
|
|
37
|
+
// eslint-disable-next-line no-unused-vars
|
|
38
|
+
onValueChange?: (files: StatedFile[]) => void;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Function to be called when files are uploaded.
|
|
42
|
+
* @type (files: StatedFile[]) => Promise<void>
|
|
43
|
+
* @default undefined
|
|
44
|
+
* @example onUpload={(files) => uploadFiles(files)}
|
|
45
|
+
*/
|
|
46
|
+
// eslint-disable-next-line no-unused-vars
|
|
47
|
+
onUpload?: (files: StatedFile[]) => Promise<void>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Accepted file types for the uploader.
|
|
51
|
+
* @type { [key: string]: string[]}
|
|
52
|
+
* @default
|
|
53
|
+
* ```ts
|
|
54
|
+
* { "image/*": [] }
|
|
55
|
+
* ```
|
|
56
|
+
* @example accept={["image/png", "image/jpeg"]}
|
|
57
|
+
*/
|
|
58
|
+
accept?: DropzoneProps['accept'];
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Maximum file size for the uploader.
|
|
62
|
+
* @type number | undefined
|
|
63
|
+
* @default 1024 * 1024 * 2 // 2MB
|
|
64
|
+
* @example maxSize={1024 * 1024 * 2} // 2MB
|
|
65
|
+
*/
|
|
66
|
+
maxSize?: DropzoneProps['maxSize'];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Maximum number of files for the uploader.
|
|
70
|
+
* @type number | undefined
|
|
71
|
+
* @default 1
|
|
72
|
+
* @example maxFileCount={4}
|
|
73
|
+
*/
|
|
74
|
+
maxFileCount?: DropzoneProps['maxFiles'];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether the uploader should accept multiple files.
|
|
78
|
+
* @type boolean
|
|
79
|
+
* @default false
|
|
80
|
+
* @example multiple
|
|
81
|
+
*/
|
|
82
|
+
multiple?: boolean;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether the uploader is disabled.
|
|
86
|
+
* @type boolean
|
|
87
|
+
* @default false
|
|
88
|
+
* @example disabled
|
|
89
|
+
*/
|
|
90
|
+
disabled?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function FileUploader(props: FileUploaderProps) {
|
|
94
|
+
const {
|
|
95
|
+
value: valueProp,
|
|
96
|
+
onValueChange,
|
|
97
|
+
onUpload,
|
|
98
|
+
accept = {},
|
|
99
|
+
maxSize = 1024 * 1024 * 2,
|
|
100
|
+
maxFileCount = 1,
|
|
101
|
+
multiple = false,
|
|
102
|
+
disabled = false,
|
|
103
|
+
className,
|
|
104
|
+
...dropzoneProps
|
|
105
|
+
} = props;
|
|
106
|
+
|
|
107
|
+
const [files, setFiles] = useControllableState({
|
|
108
|
+
prop: valueProp,
|
|
109
|
+
onChange: onValueChange,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const onDrop = useCallback(
|
|
113
|
+
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
|
114
|
+
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
|
|
115
|
+
toast.error('Cannot upload more than 1 file at a time');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if ((files?.length ?? 0) + acceptedFiles.length > maxFileCount) {
|
|
120
|
+
toast.error(`Cannot upload more than ${maxFileCount} files`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const newFiles: StatedFile[] = acceptedFiles.map((file) => ({
|
|
125
|
+
rawFile: file,
|
|
126
|
+
url: URL.createObjectURL(file),
|
|
127
|
+
status: 'pending',
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
const updatedFiles = files ? [...files, ...newFiles] : newFiles;
|
|
131
|
+
setFiles(updatedFiles);
|
|
132
|
+
|
|
133
|
+
if (rejectedFiles.length > 0) {
|
|
134
|
+
rejectedFiles.forEach(({ file }) => {
|
|
135
|
+
toast.error(`File ${file.name} was rejected`);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
[files, maxFileCount, multiple, onUpload, setFiles]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
function onRemove(index: number) {
|
|
143
|
+
if (files?.[index]?.url) {
|
|
144
|
+
URL.revokeObjectURL(files[index].url);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const newFiles = files?.filter((_, i) => i !== index);
|
|
148
|
+
setFiles(newFiles);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const isDisabled = disabled || (files?.length ?? 0) >= maxFileCount;
|
|
152
|
+
|
|
153
|
+
const [uploading, setUploading] = useState(false);
|
|
154
|
+
|
|
155
|
+
async function onSubmit() {
|
|
156
|
+
if (uploading || !files || !files.length) return;
|
|
157
|
+
setUploading(true);
|
|
158
|
+
await onUpload?.(files);
|
|
159
|
+
setUploading(false);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<div className="relative flex flex-col overflow-hidden">
|
|
164
|
+
<Dropzone
|
|
165
|
+
onDrop={onDrop}
|
|
166
|
+
accept={accept}
|
|
167
|
+
maxSize={maxSize}
|
|
168
|
+
maxFiles={maxFileCount}
|
|
169
|
+
multiple={maxFileCount > 1 || multiple}
|
|
170
|
+
disabled={isDisabled}
|
|
171
|
+
>
|
|
172
|
+
{({ getRootProps, getInputProps, isDragActive }) => (
|
|
173
|
+
<div
|
|
174
|
+
{...getRootProps()}
|
|
175
|
+
className={cn(
|
|
176
|
+
'group relative grid h-52 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-muted-foreground/25 px-6 py-2 text-center transition hover:bg-muted/25',
|
|
177
|
+
'ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden',
|
|
178
|
+
isDragActive && 'border-muted-foreground/50',
|
|
179
|
+
isDisabled && 'pointer-events-none opacity-60',
|
|
180
|
+
className
|
|
181
|
+
)}
|
|
182
|
+
{...dropzoneProps}
|
|
183
|
+
>
|
|
184
|
+
<input {...getInputProps()} />
|
|
185
|
+
{isDragActive ? (
|
|
186
|
+
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
|
|
187
|
+
<div className="rounded-full border border-dashed p-3">
|
|
188
|
+
<Upload
|
|
189
|
+
className="size-7 text-muted-foreground"
|
|
190
|
+
aria-hidden="true"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
<p className="font-medium text-muted-foreground">
|
|
194
|
+
Drop the files here
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
) : (
|
|
198
|
+
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
|
|
199
|
+
<div className="rounded-full border border-dashed p-3">
|
|
200
|
+
<Upload
|
|
201
|
+
className="size-7 text-muted-foreground"
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
<div className="flex flex-col gap-px">
|
|
206
|
+
<p className="font-medium text-muted-foreground">
|
|
207
|
+
Drag {`'n'`} drop files here, or click to select files
|
|
208
|
+
</p>
|
|
209
|
+
<p className="text-sm text-muted-foreground/70">
|
|
210
|
+
You can upload
|
|
211
|
+
{maxFileCount > 1
|
|
212
|
+
? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
|
|
213
|
+
files (up to ${formatBytes(maxSize)} each)`
|
|
214
|
+
: ` a file with ${formatBytes(maxSize)}`}
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
</Dropzone>
|
|
222
|
+
|
|
223
|
+
{files?.length ? (
|
|
224
|
+
<ScrollArea className="h-fit w-full p-2 px-3">
|
|
225
|
+
<div className="flex max-h-48 flex-col gap-1 py-4">
|
|
226
|
+
{files?.map((file, index) => (
|
|
227
|
+
<FileCard
|
|
228
|
+
key={index}
|
|
229
|
+
file={file}
|
|
230
|
+
onRemove={() => onRemove(index)}
|
|
231
|
+
/>
|
|
232
|
+
))}
|
|
233
|
+
</div>
|
|
234
|
+
</ScrollArea>
|
|
235
|
+
) : null}
|
|
236
|
+
|
|
237
|
+
<Separator className={files?.length ? 'mb-4' : 'my-4'} />
|
|
238
|
+
|
|
239
|
+
<div className="flex gap-2">
|
|
240
|
+
<Button
|
|
241
|
+
type="button"
|
|
242
|
+
className="w-fit"
|
|
243
|
+
onClick={() => {
|
|
244
|
+
setFiles([]);
|
|
245
|
+
}}
|
|
246
|
+
variant="ghost"
|
|
247
|
+
disabled={
|
|
248
|
+
uploading ||
|
|
249
|
+
(files?.length ?? 0) === 0 ||
|
|
250
|
+
((files?.length ?? 0) > 0 &&
|
|
251
|
+
files?.every((file) => file.status === 'uploaded'))
|
|
252
|
+
}
|
|
253
|
+
>
|
|
254
|
+
Clear Files
|
|
255
|
+
</Button>
|
|
256
|
+
<Button
|
|
257
|
+
type="button"
|
|
258
|
+
className="w-full"
|
|
259
|
+
onClick={onSubmit}
|
|
260
|
+
disabled={
|
|
261
|
+
uploading ||
|
|
262
|
+
(files?.length ?? 0) === 0 ||
|
|
263
|
+
((files?.length ?? 0) > 0 &&
|
|
264
|
+
files?.every((file) => file.status === 'uploaded'))
|
|
265
|
+
}
|
|
266
|
+
>
|
|
267
|
+
Upload Files
|
|
268
|
+
</Button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface FileCardProps {
|
|
275
|
+
file: StatedFile;
|
|
276
|
+
onRemove: () => void;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function FileCard({ file, onRemove }: FileCardProps) {
|
|
280
|
+
return (
|
|
281
|
+
<div className="relative flex items-center gap-2 rounded-md p-2 hover:bg-foreground/5">
|
|
282
|
+
<div className="flex flex-1 gap-2">
|
|
283
|
+
<div className="aspect-square size-10 flex-none">
|
|
284
|
+
<FilePreview file={file} />
|
|
285
|
+
</div>
|
|
286
|
+
<div className="flex w-full flex-col items-start gap-2 text-start">
|
|
287
|
+
<div className="flex flex-col gap-px">
|
|
288
|
+
<p className="line-clamp-1 text-sm font-semibold text-foreground/80">
|
|
289
|
+
{file.rawFile.name}
|
|
290
|
+
</p>
|
|
291
|
+
<div className="text-xs font-semibold text-muted-foreground">
|
|
292
|
+
{file.status === 'pending' && (
|
|
293
|
+
<span>{formatBytes(file.rawFile.size)}</span>
|
|
294
|
+
)}
|
|
295
|
+
{file.status === 'uploading' && (
|
|
296
|
+
<span className="opacity-70">Uploading...</span>
|
|
297
|
+
)}
|
|
298
|
+
{file.status === 'uploaded' && <span>Uploaded</span>}
|
|
299
|
+
{file.status === 'error' && (
|
|
300
|
+
<span className="text-destructive">Error</span>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
</div>
|
|
306
|
+
<div className="flex items-center gap-2">
|
|
307
|
+
<Button
|
|
308
|
+
type="button"
|
|
309
|
+
variant="outline"
|
|
310
|
+
size="icon"
|
|
311
|
+
className="size-7"
|
|
312
|
+
onClick={onRemove}
|
|
313
|
+
>
|
|
314
|
+
<X className="size-4" aria-hidden="true" />
|
|
315
|
+
<span className="sr-only">Remove file</span>
|
|
316
|
+
</Button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function FilePreview({ file }: { file: StatedFile }) {
|
|
323
|
+
const isImage = file.rawFile.type.startsWith('image/');
|
|
324
|
+
const isPdf = file.rawFile.type.startsWith('application/pdf');
|
|
325
|
+
const isOther = !isImage && !isPdf;
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<>
|
|
329
|
+
{isImage && (
|
|
330
|
+
<a href={file.url} target="_blank" rel="noopener noreferrer">
|
|
331
|
+
<img
|
|
332
|
+
src={file.url}
|
|
333
|
+
alt={file.rawFile.name}
|
|
334
|
+
width={48}
|
|
335
|
+
height={48}
|
|
336
|
+
loading="lazy"
|
|
337
|
+
className="rounded-md object-cover"
|
|
338
|
+
/>
|
|
339
|
+
</a>
|
|
340
|
+
)}
|
|
341
|
+
{isPdf && (
|
|
342
|
+
<a href={file.url} target="_blank" rel="noopener noreferrer">
|
|
343
|
+
<FileText className="size-10" aria-hidden="true" />
|
|
344
|
+
</a>
|
|
345
|
+
)}
|
|
346
|
+
{isOther && <File className="size-10" aria-hidden="true" />}
|
|
347
|
+
</>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Input } from '../input';
|
|
2
|
+
import { Label } from '../label';
|
|
3
|
+
import React, { forwardRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface InputFieldProps {
|
|
6
|
+
id: string;
|
|
7
|
+
label?: string;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InputProps
|
|
12
|
+
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
13
|
+
|
|
14
|
+
// Merge the two interfaces
|
|
15
|
+
type Props = InputFieldProps & InputProps;
|
|
16
|
+
|
|
17
|
+
const InputField = forwardRef<HTMLInputElement, Props>(
|
|
18
|
+
({ id, label, ...props }, ref) => {
|
|
19
|
+
return (
|
|
20
|
+
<div className="grid w-full gap-2">
|
|
21
|
+
{label && <Label htmlFor={id}>{label}</Label>}
|
|
22
|
+
<Input id={id} ref={ref} {...props} />
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
InputField.displayName = 'InputField';
|
|
29
|
+
export { InputField };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
+
|
|
3
|
+
export function LoadingIndicator({ className }: { className?: string }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className="inline-flex items-center rounded-md border border-transparent text-base">
|
|
6
|
+
<svg
|
|
7
|
+
className={cn('animate-spin text-foreground', className || 'h-4')}
|
|
8
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
9
|
+
fill="none"
|
|
10
|
+
viewBox="0 0 24 24"
|
|
11
|
+
>
|
|
12
|
+
<circle
|
|
13
|
+
className="opacity-25"
|
|
14
|
+
cx="12"
|
|
15
|
+
cy="12"
|
|
16
|
+
r="10"
|
|
17
|
+
stroke="currentColor"
|
|
18
|
+
strokeWidth="4"
|
|
19
|
+
></circle>
|
|
20
|
+
<path
|
|
21
|
+
className="opacity-75"
|
|
22
|
+
fill="currentColor"
|
|
23
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
24
|
+
></path>
|
|
25
|
+
</svg>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Dialog,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogDescription,
|
|
7
|
+
DialogHeader,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
DialogTrigger,
|
|
10
|
+
} from '../dialog';
|
|
11
|
+
import { ReactElement, ReactNode, cloneElement, useState } from 'react';
|
|
12
|
+
|
|
13
|
+
interface FormProps<T> {
|
|
14
|
+
data?: T;
|
|
15
|
+
forceDefault?: boolean;
|
|
16
|
+
onFinish?: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Props<T> {
|
|
20
|
+
data?: T & { id?: string };
|
|
21
|
+
trigger?: ReactNode;
|
|
22
|
+
form?: ReactElement<FormProps<T>>;
|
|
23
|
+
open?: boolean;
|
|
24
|
+
title?: string;
|
|
25
|
+
editDescription?: string;
|
|
26
|
+
createDescription?: string;
|
|
27
|
+
requireExpansion?: boolean;
|
|
28
|
+
forceDefault?: boolean;
|
|
29
|
+
// eslint-disable-next-line no-unused-vars
|
|
30
|
+
setOpen?: (open: boolean) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default function ModifiableDialogTrigger<T>({
|
|
34
|
+
data,
|
|
35
|
+
trigger,
|
|
36
|
+
form,
|
|
37
|
+
open: externalOpen,
|
|
38
|
+
title,
|
|
39
|
+
editDescription,
|
|
40
|
+
createDescription,
|
|
41
|
+
requireExpansion,
|
|
42
|
+
forceDefault,
|
|
43
|
+
setOpen: setExternalOpen,
|
|
44
|
+
}: Props<T>) {
|
|
45
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
46
|
+
|
|
47
|
+
const open = externalOpen ?? internalOpen;
|
|
48
|
+
const setOpen = setExternalOpen ?? setInternalOpen;
|
|
49
|
+
|
|
50
|
+
const formWithCallback = form
|
|
51
|
+
? cloneElement(
|
|
52
|
+
form as ReactElement,
|
|
53
|
+
{
|
|
54
|
+
data,
|
|
55
|
+
forceDefault,
|
|
56
|
+
onFinish: () => setOpen(false),
|
|
57
|
+
} as FormProps<T>
|
|
58
|
+
)
|
|
59
|
+
: null;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
63
|
+
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
|
|
64
|
+
<DialogContent
|
|
65
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
66
|
+
// onInteractOutside={(e) => e.preventDefault()}
|
|
67
|
+
className={
|
|
68
|
+
requireExpansion
|
|
69
|
+
? 'md:max-w-2xl lg:max-w-4xl xl:max-w-6xl'
|
|
70
|
+
: undefined
|
|
71
|
+
}
|
|
72
|
+
>
|
|
73
|
+
<DialogHeader>
|
|
74
|
+
<DialogTitle>{title}</DialogTitle>
|
|
75
|
+
<DialogDescription>
|
|
76
|
+
{data?.id ? editDescription : createDescription}
|
|
77
|
+
</DialogDescription>
|
|
78
|
+
</DialogHeader>
|
|
79
|
+
{formWithCallback}
|
|
80
|
+
</DialogContent>
|
|
81
|
+
</Dialog>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '../button';
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../popover';
|
|
5
|
+
import {
|
|
6
|
+
add,
|
|
7
|
+
eachMonthOfInterval,
|
|
8
|
+
endOfYear,
|
|
9
|
+
format,
|
|
10
|
+
isFuture,
|
|
11
|
+
parse,
|
|
12
|
+
} from 'date-fns';
|
|
13
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
14
|
+
import { useState } from 'react';
|
|
15
|
+
|
|
16
|
+
interface MonthPickerProps {
|
|
17
|
+
lang: string;
|
|
18
|
+
resetPage?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
defaultMonth?: string;
|
|
21
|
+
onUpdate?: (
|
|
22
|
+
// eslint-disable-next-line no-unused-vars
|
|
23
|
+
args: {
|
|
24
|
+
month: string;
|
|
25
|
+
page?: string;
|
|
26
|
+
},
|
|
27
|
+
// eslint-disable-next-line no-unused-vars
|
|
28
|
+
refresh: boolean
|
|
29
|
+
) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function MonthPicker({
|
|
33
|
+
lang,
|
|
34
|
+
defaultMonth,
|
|
35
|
+
resetPage = true,
|
|
36
|
+
className,
|
|
37
|
+
onUpdate,
|
|
38
|
+
}: MonthPickerProps) {
|
|
39
|
+
const queryMonth = defaultMonth;
|
|
40
|
+
|
|
41
|
+
const currentYYYYMM = Array.isArray(queryMonth)
|
|
42
|
+
? queryMonth?.[0] || format(new Date(), 'yyyy-MM')
|
|
43
|
+
: queryMonth || format(new Date(), 'yyyy-MM');
|
|
44
|
+
|
|
45
|
+
const currentMonth =
|
|
46
|
+
typeof currentYYYYMM === 'string'
|
|
47
|
+
? parse(currentYYYYMM, 'yyyy-MM', new Date())
|
|
48
|
+
: new Date();
|
|
49
|
+
|
|
50
|
+
const [open, setOpen] = useState(false);
|
|
51
|
+
const [previewDate, setPreviewDate] = useState(currentMonth);
|
|
52
|
+
|
|
53
|
+
const updateQuery = (month: string) => {
|
|
54
|
+
if (onUpdate) onUpdate({ month, page: resetPage ? '1' : undefined }, false);
|
|
55
|
+
setOpen(false);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const firstDayCurrentYear = new Date(previewDate.getFullYear(), 0, 1);
|
|
59
|
+
|
|
60
|
+
const months = eachMonthOfInterval({
|
|
61
|
+
start: firstDayCurrentYear,
|
|
62
|
+
end: endOfYear(firstDayCurrentYear),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function previousYear() {
|
|
66
|
+
let firstDayLastYear = add(firstDayCurrentYear, { years: -1 });
|
|
67
|
+
setPreviewDate(firstDayLastYear);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nextYear() {
|
|
71
|
+
let firstDayNextYear = add(firstDayCurrentYear, { years: 1 });
|
|
72
|
+
setPreviewDate(firstDayNextYear);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
77
|
+
<PopoverTrigger asChild>
|
|
78
|
+
<Button
|
|
79
|
+
size="xs"
|
|
80
|
+
variant="outline"
|
|
81
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
82
|
+
className={className}
|
|
83
|
+
>
|
|
84
|
+
{currentMonth.toLocaleString(lang, {
|
|
85
|
+
month: '2-digit',
|
|
86
|
+
year: 'numeric',
|
|
87
|
+
})}
|
|
88
|
+
</Button>
|
|
89
|
+
</PopoverTrigger>
|
|
90
|
+
<PopoverContent className="w-80">
|
|
91
|
+
<div className="flex items-center justify-between pb-2">
|
|
92
|
+
<Button
|
|
93
|
+
size="xs"
|
|
94
|
+
variant="outline"
|
|
95
|
+
name="previous-year"
|
|
96
|
+
aria-label="Go to previous year"
|
|
97
|
+
onClick={previousYear}
|
|
98
|
+
>
|
|
99
|
+
<ChevronLeft className="h-4 w-4" />
|
|
100
|
+
</Button>
|
|
101
|
+
|
|
102
|
+
<div
|
|
103
|
+
className="text-sm font-medium"
|
|
104
|
+
aria-live="polite"
|
|
105
|
+
role="presentation"
|
|
106
|
+
id="month-picker"
|
|
107
|
+
>
|
|
108
|
+
{format(firstDayCurrentYear, 'yyyy')}
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<Button
|
|
112
|
+
size="xs"
|
|
113
|
+
variant="outline"
|
|
114
|
+
name="next-year"
|
|
115
|
+
aria-label="Go to next year"
|
|
116
|
+
onClick={nextYear}
|
|
117
|
+
disabled={
|
|
118
|
+
firstDayCurrentYear.getFullYear() >= new Date().getFullYear()
|
|
119
|
+
}
|
|
120
|
+
>
|
|
121
|
+
<ChevronRight className="h-4 w-4" />
|
|
122
|
+
</Button>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<div
|
|
126
|
+
className="grid w-full grid-cols-3 gap-2"
|
|
127
|
+
role="grid"
|
|
128
|
+
aria-labelledby="month-picker"
|
|
129
|
+
>
|
|
130
|
+
{months.map((month) => (
|
|
131
|
+
<div
|
|
132
|
+
key={month.toString()}
|
|
133
|
+
className="relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-slate-100 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md dark:[&:has([aria-selected])]:bg-slate-800"
|
|
134
|
+
role="presentation"
|
|
135
|
+
>
|
|
136
|
+
<Button
|
|
137
|
+
variant={
|
|
138
|
+
currentMonth.getMonth() === month.getMonth() &&
|
|
139
|
+
currentMonth.getFullYear() === month.getFullYear()
|
|
140
|
+
? 'default'
|
|
141
|
+
: 'ghost'
|
|
142
|
+
}
|
|
143
|
+
className="w-full"
|
|
144
|
+
disabled={isFuture(month)}
|
|
145
|
+
onClick={() => updateQuery(format(month, 'yyyy-MM'))}
|
|
146
|
+
>
|
|
147
|
+
<time dateTime={format(month, 'yyyy-MM-dd')}>
|
|
148
|
+
{month.toLocaleString(lang, { month: 'short' })}
|
|
149
|
+
</time>
|
|
150
|
+
</Button>
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</PopoverContent>
|
|
155
|
+
</Popover>
|
|
156
|
+
);
|
|
157
|
+
}
|