Skip to content

Commit b1e194b

Browse files
committed
feat: clean upload button component
1 parent d3a6194 commit b1e194b

File tree

12 files changed

+194
-1264
lines changed

12 files changed

+194
-1264
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@
4242
"db:seed": "dotenv -- cross-env node ./run-jiti ./prisma/seed/index.ts"
4343
},
4444
"dependencies": {
45-
"@aws-sdk/client-s3": "3.922.0",
4645
"@base-ui-components/react": "1.0.0-beta.1",
4746
"@bearstudio/ui-state": "1.0.2",
4847
"@better-auth/expo": "1.3.27",
48+
"@better-upload/client": "3.0.2",
49+
"@better-upload/server": "3.0.2",
4950
"@fontsource-variable/inter": "5.2.8",
5051
"@headlessui/react": "2.2.9",
5152
"@hookform/resolvers": "5.2.2",
@@ -68,7 +69,6 @@
6869
"@tanstack/zod-adapter": "1.132.47",
6970
"@uidotdev/usehooks": "2.4.1",
7071
"better-auth": "1.3.27",
71-
"better-upload": "2.0.3",
7272
"boring-avatars": "2.0.4",
7373
"class-variance-authority": "0.7.1",
7474
"clsx": "2.1.1",

pnpm-lock.yaml

Lines changed: 43 additions & 1068 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 13 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,46 @@
11
import type { Meta } from '@storybook/react-vite';
2-
import { useUploadFile } from 'better-upload/client';
32
import { UploadIcon } from 'lucide-react';
4-
import { useForm } from 'react-hook-form';
53

6-
import {
7-
Form,
8-
FormField,
9-
FormFieldController,
10-
FormFieldError,
11-
} from '@/components/form';
124
import { UploadButton } from '@/components/ui/upload-button';
135

14-
import { BookCover } from '@/features/book/book-cover';
15-
166
export default {
177
title: 'Upload Button',
188
} satisfies Meta<typeof UploadButton>;
199

2010
export const Default = () => {
21-
const { control } = useUploadFile({ route: 'bookCover' });
22-
2311
return (
2412
<UploadButton
25-
control={control}
13+
uploadRoute="bookCover"
2614
inputProps={{
27-
accept: 'png,jpeg,gif',
15+
accept: 'image/png,image/jpeg,image/gif',
2816
}}
29-
onChange={(file) => console.log('uploaded file', file)}
17+
onUploadSuccess={(file) => console.log('uploaded file', file)}
3018
/>
3119
);
3220
};
3321

3422
export const WithChildren = () => {
35-
const { control } = useUploadFile({ route: 'bookCover' });
36-
3723
return (
3824
<div className="flex space-x-2">
3925
<UploadButton
40-
control={control}
41-
onChange={(file) => console.log('uploaded file', file)}
26+
uploadRoute="bookCover"
27+
onUploadSuccess={(file) => console.log('uploaded file', file)}
4228
>
4329
<UploadIcon />
4430
Upload a new file
4531
</UploadButton>
4632

4733
<UploadButton
48-
control={control}
49-
onChange={(file) => console.log('uploaded file', file)}
34+
uploadRoute="bookCover"
35+
onUploadSuccess={(file) => console.log('uploaded file', file)}
5036
>
5137
Upload a new file
5238
<UploadIcon />
5339
</UploadButton>
5440

5541
<UploadButton
56-
control={control}
57-
onChange={(file) => console.log('uploaded file', file)}
42+
uploadRoute="bookCover"
43+
onUploadSuccess={(file) => console.log('uploaded file', file)}
5844
>
5945
Upload a new file
6046
</UploadButton>
@@ -63,73 +49,25 @@ export const WithChildren = () => {
6349
};
6450

6551
export const Disabled = () => {
66-
const { control } = useUploadFile({ route: 'bookCover' });
67-
6852
return (
6953
<div className="flex space-x-2">
7054
<UploadButton
7155
disabled
72-
control={control}
73-
onChange={(file) => console.log('uploaded file', file)}
56+
uploadRoute="bookCover"
57+
onUploadSuccess={(file) => console.log('uploaded file', file)}
7458
>
7559
<UploadIcon />
7660
Upload a new file
7761
</UploadButton>
7862

7963
<UploadButton
8064
disabled
81-
control={control}
82-
onChange={(file) => console.log('uploaded file', file)}
65+
uploadRoute="bookCover"
66+
onUploadSuccess={(file) => console.log('uploaded file', file)}
8367
>
8468
Upload a new file
8569
<UploadIcon />
8670
</UploadButton>
8771
</div>
8872
);
8973
};
90-
91-
export const RealWorldUseCase = () => {
92-
const form = useForm({
93-
defaultValues: {
94-
coverId: '',
95-
},
96-
});
97-
const { control, uploadedFile } = useUploadFile({ route: 'bookCover' });
98-
99-
return (
100-
<Form {...form}>
101-
<FormField>
102-
<FormFieldController
103-
control={form.control}
104-
type="custom"
105-
name="coverId"
106-
render={() => {
107-
return (
108-
<div className="max-w-xs">
109-
<div className="relative mb-2">
110-
<span className="sr-only">Upload cover</span>
111-
<BookCover
112-
className="opacity-60"
113-
book={{
114-
title: 'Title',
115-
author: 'Author',
116-
coverId: uploadedFile?.objectKey,
117-
}}
118-
/>
119-
120-
<UploadButton
121-
className="absolute top-1/2 left-1/2 -translate-1/2 bg-black/50 text-white"
122-
variant="ghost"
123-
control={control}
124-
disabled={form.formState.isSubmitting}
125-
/>
126-
</div>
127-
<FormFieldError />
128-
</div>
129-
);
130-
}}
131-
/>
132-
</FormField>
133-
</Form>
134-
);
135-
};

src/components/ui/upload-button.tsx

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import {
2+
type ClientUploadError,
23
type FileUploadInfo,
3-
type UploadHookControl,
4-
} from 'better-upload/client';
4+
uploadFile,
5+
type UploadStatus,
6+
} from '@better-upload/client';
7+
import { useIsMutating, useMutation } from '@tanstack/react-query';
58
import { UploadIcon } from 'lucide-react';
69
import {
710
type ChangeEvent,
811
type ComponentProps,
9-
useEffect,
12+
type ReactElement,
1013
useId,
1114
useRef,
1215
} from 'react';
@@ -15,75 +18,95 @@ import { cn } from '@/lib/tailwind/utils';
1518

1619
import { Button } from '@/components/ui/button';
1720

21+
import type { UploadRoutes } from '@/routes/api/upload';
22+
1823
export type UploadButtonProps = {
19-
control: UploadHookControl<false>;
24+
uploadRoute: UploadRoutes;
2025
/**
2126
* Called only if the file was uploaded successfully.
2227
*/
23-
onChange?: (file: FileUploadInfo<'complete'>) => void;
28+
onUploadSuccess?: (file: FileUploadInfo<'complete'>) => void;
29+
onUploadStateChange?: <T extends UploadStatus>(
30+
file: FileUploadInfo<T>
31+
) => void;
32+
onError?: (error: Error | ClientUploadError) => void;
2433
inputProps?: ComponentProps<'input'>;
34+
icon?: ReactElement;
2535
} & Omit<ComponentProps<typeof Button>, 'onChange'>;
2636

27-
/**
28-
* Upload button that should be used with useUploadFile() better-upload hook.
29-
*/
37+
export const useIsUploadingFiles = (uploadRoute: UploadRoutes) =>
38+
useIsMutating({
39+
mutationKey: ['fileUpload', uploadRoute],
40+
}) > 0;
41+
3042
export const UploadButton = ({
3143
children,
3244
inputProps,
33-
control,
34-
onChange,
45+
onUploadStateChange,
46+
onUploadSuccess,
47+
onError,
3548
disabled,
49+
icon,
50+
uploadRoute,
3651
...rest
3752
}: UploadButtonProps) => {
3853
const innerId = useId();
3954

55+
const uploadMutation = useMutation({
56+
mutationKey: ['fileUpload', uploadRoute],
57+
mutationFn: async (file: File) => {
58+
return uploadFile({
59+
file,
60+
route: uploadRoute,
61+
onFileStateChange: ({ file }) => {
62+
onUploadStateChange?.(file);
63+
},
64+
});
65+
},
66+
onSuccess: ({ file }) => {
67+
onUploadSuccess?.(file);
68+
},
69+
onError: (error) => {
70+
onError?.(error);
71+
},
72+
});
73+
4074
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
4175
const file = event.target.files?.[0];
4276
if (file) {
43-
control.upload(file);
77+
uploadMutation.mutate(file);
4478
}
4579
};
4680

4781
const inputRef = useRef<HTMLInputElement>(null);
4882

49-
useEffect(() => {
50-
if (!control.isSuccess || !control.uploadedFile) return;
51-
// if success, it means the file was successfully uploaded
52-
onChange?.(control.uploadedFile);
53-
}, [control.isSuccess, control.uploadedFile, onChange]);
54-
5583
return (
5684
<>
5785
<Button
5886
size={!children ? 'icon' : undefined}
59-
loading={control.isPending}
60-
disabled={control.isPending || disabled}
61-
asChild
62-
{...rest}
63-
>
64-
<label
65-
tabIndex={0}
66-
htmlFor={innerId}
67-
onKeyDown={(keyboardEvent) => {
68-
// Skip if the pressed key is neither enter or space
69-
if (!['Enter', ' '].includes(keyboardEvent.key)) return;
87+
loading={uploadMutation.isPending}
88+
disabled={uploadMutation.isPending || disabled}
89+
onClick={() => inputRef.current?.click()}
90+
onKeyDown={(keyboardEvent) => {
91+
// Skip if the pressed key is neither enter or space
92+
if (!['Enter', ' '].includes(keyboardEvent.key)) return;
7093

71-
// Prevent space key to trigger page scroll (and Enter to bubble)
72-
keyboardEvent.preventDefault();
94+
// Prevent space key to trigger page scroll (and Enter to bubble)
95+
keyboardEvent.preventDefault();
7396

74-
inputRef.current?.click();
75-
}}
76-
>
77-
{!children ? <UploadIcon /> : children}
78-
</label>
97+
inputRef.current?.click();
98+
}}
99+
{...rest}
100+
>
101+
{!children ? (icon ?? <UploadIcon />) : children}
79102
</Button>
80103
<input
81104
{...inputProps}
82105
id={innerId}
83106
ref={inputRef}
84107
className={cn('hidden', inputProps?.className)}
85108
type="file"
86-
disabled={control.isPending || disabled}
109+
disabled={uploadMutation.isPending || disabled}
87110
onChange={(onChangeEvent) => {
88111
handleFileChange(onChangeEvent);
89112
inputProps?.onChange?.(onChangeEvent);

src/env/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const envClient = createEnv({
4242
.string()
4343
.optional()
4444
.transform((value) => value ?? (isDev ? 'gold' : 'plum')),
45-
VITE_S3_BUCKET_PUBLIC_URL: z.string().url(),
45+
VITE_S3_BUCKET_PUBLIC_URL: z.url(),
4646
},
4747
runtimeEnv: {
4848
...envMetaOrProcess,

src/features/book/book-cover.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const BookCover = (props: {
2525
>
2626
{!!props.book.coverId && (
2727
<img
28+
alt=""
2829
className="absolute inset-0 h-full w-full object-cover mix-blend-overlay"
2930
src={`${envClient.VITE_S3_BUCKET_PUBLIC_URL}/${props.book.coverId}`}
3031
/>

0 commit comments

Comments
 (0)