Skip to content

Commit d3a6194

Browse files
committed
fix: uplaod button and add missing examplee
1 parent b868c02 commit d3a6194

File tree

5 files changed

+331
-85
lines changed

5 files changed

+331
-85
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { Meta } from '@storybook/react-vite';
2+
import { useUploadFile } from 'better-upload/client';
3+
import { UploadIcon } from 'lucide-react';
4+
import { useForm } from 'react-hook-form';
5+
6+
import {
7+
Form,
8+
FormField,
9+
FormFieldController,
10+
FormFieldError,
11+
} from '@/components/form';
12+
import { UploadButton } from '@/components/ui/upload-button';
13+
14+
import { BookCover } from '@/features/book/book-cover';
15+
16+
export default {
17+
title: 'Upload Button',
18+
} satisfies Meta<typeof UploadButton>;
19+
20+
export const Default = () => {
21+
const { control } = useUploadFile({ route: 'bookCover' });
22+
23+
return (
24+
<UploadButton
25+
control={control}
26+
inputProps={{
27+
accept: 'png,jpeg,gif',
28+
}}
29+
onChange={(file) => console.log('uploaded file', file)}
30+
/>
31+
);
32+
};
33+
34+
export const WithChildren = () => {
35+
const { control } = useUploadFile({ route: 'bookCover' });
36+
37+
return (
38+
<div className="flex space-x-2">
39+
<UploadButton
40+
control={control}
41+
onChange={(file) => console.log('uploaded file', file)}
42+
>
43+
<UploadIcon />
44+
Upload a new file
45+
</UploadButton>
46+
47+
<UploadButton
48+
control={control}
49+
onChange={(file) => console.log('uploaded file', file)}
50+
>
51+
Upload a new file
52+
<UploadIcon />
53+
</UploadButton>
54+
55+
<UploadButton
56+
control={control}
57+
onChange={(file) => console.log('uploaded file', file)}
58+
>
59+
Upload a new file
60+
</UploadButton>
61+
</div>
62+
);
63+
};
64+
65+
export const Disabled = () => {
66+
const { control } = useUploadFile({ route: 'bookCover' });
67+
68+
return (
69+
<div className="flex space-x-2">
70+
<UploadButton
71+
disabled
72+
control={control}
73+
onChange={(file) => console.log('uploaded file', file)}
74+
>
75+
<UploadIcon />
76+
Upload a new file
77+
</UploadButton>
78+
79+
<UploadButton
80+
disabled
81+
control={control}
82+
onChange={(file) => console.log('uploaded file', file)}
83+
>
84+
Upload a new file
85+
<UploadIcon />
86+
</UploadButton>
87+
</div>
88+
);
89+
};
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+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {
2+
type FileUploadInfo,
3+
type UploadHookControl,
4+
} from 'better-upload/client';
5+
import { UploadIcon } from 'lucide-react';
6+
import {
7+
type ChangeEvent,
8+
type ComponentProps,
9+
useEffect,
10+
useId,
11+
useRef,
12+
} from 'react';
13+
14+
import { cn } from '@/lib/tailwind/utils';
15+
16+
import { Button } from '@/components/ui/button';
17+
18+
export type UploadButtonProps = {
19+
control: UploadHookControl<false>;
20+
/**
21+
* Called only if the file was uploaded successfully.
22+
*/
23+
onChange?: (file: FileUploadInfo<'complete'>) => void;
24+
inputProps?: ComponentProps<'input'>;
25+
} & Omit<ComponentProps<typeof Button>, 'onChange'>;
26+
27+
/**
28+
* Upload button that should be used with useUploadFile() better-upload hook.
29+
*/
30+
export const UploadButton = ({
31+
children,
32+
inputProps,
33+
control,
34+
onChange,
35+
disabled,
36+
...rest
37+
}: UploadButtonProps) => {
38+
const innerId = useId();
39+
40+
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
41+
const file = event.target.files?.[0];
42+
if (file) {
43+
control.upload(file);
44+
}
45+
};
46+
47+
const inputRef = useRef<HTMLInputElement>(null);
48+
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+
55+
return (
56+
<>
57+
<Button
58+
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;
70+
71+
// Prevent space key to trigger page scroll (and Enter to bubble)
72+
keyboardEvent.preventDefault();
73+
74+
inputRef.current?.click();
75+
}}
76+
>
77+
{!children ? <UploadIcon /> : children}
78+
</label>
79+
</Button>
80+
<input
81+
{...inputProps}
82+
id={innerId}
83+
ref={inputRef}
84+
className={cn('hidden', inputProps?.className)}
85+
type="file"
86+
disabled={control.isPending || disabled}
87+
onChange={(onChangeEvent) => {
88+
handleFileChange(onChangeEvent);
89+
inputProps?.onChange?.(onChangeEvent);
90+
}}
91+
/>
92+
</>
93+
);
94+
};

src/features/book/book-cover.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useTranslation } from 'react-i18next';
2-
import { match, P } from 'ts-pattern';
32

43
import { cn } from '@/lib/tailwind/utils';
54

@@ -12,25 +11,24 @@ export const BookCover = (props: {
1211
className?: string;
1312
}) => {
1413
const { t } = useTranslation(['book']);
15-
console.log(props.book);
16-
const style = match(props.book.coverId || null)
17-
.with(P.nullish, () => ({
18-
backgroundColor: props.book.genre?.color ?? '#333',
19-
}))
20-
.with(P._, (coverId) => ({
21-
backgroundImage: `url(${envClient.VITE_S3_BUCKET_PUBLIC_URL}/${coverId})`,
22-
}))
23-
.exhaustive();
2414

2515
return (
2616
<div
2717
className={cn(
28-
'@container relative flex aspect-[2/3] flex-col justify-between overflow-hidden rounded-sm bg-neutral-800 bg-cover bg-center p-[10%] pl-[16%] text-white shadow-2xl',
18+
'@container relative flex aspect-[2/3] flex-col justify-between overflow-hidden rounded-sm bg-neutral-800 p-[10%] pl-[16%] text-white shadow-2xl',
2919
props.variant === 'tiny' && 'w-8 rounded-xs',
3020
props.className
3121
)}
32-
style={style}
22+
style={{
23+
backgroundColor: props.book.genre?.color ?? '#333',
24+
}}
3325
>
26+
{!!props.book.coverId && (
27+
<img
28+
className="absolute inset-0 h-full w-full object-cover mix-blend-overlay"
29+
src={`${envClient.VITE_S3_BUCKET_PUBLIC_URL}/${props.book.coverId}`}
30+
/>
31+
)}
3432
<div className="absolute inset-y-0 left-0 w-[5%] bg-gradient-to-r from-black/0 to-black/10 bg-blend-screen" />
3533
<div className="absolute inset-y-0 left-[5%] w-[2%] bg-gradient-to-r from-white/0 to-white/20 bg-blend-screen" />
3634
<div className="absolute inset-y-0 left-[7%] w-[2%] bg-gradient-to-r from-white/0 to-white/20 bg-blend-screen" />
Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import { useQuery } from '@tanstack/react-query';
22
import { useUploadFile } from 'better-upload/client';
3-
import { Upload } from 'lucide-react';
43
import { useFormContext, useWatch } from 'react-hook-form';
54
import { useTranslation } from 'react-i18next';
6-
import { toast } from 'sonner';
75

86
import { orpc } from '@/lib/orpc/client';
97

8+
import {
9+
FormField,
10+
FormFieldController,
11+
FormFieldError,
12+
} from '@/components/form';
13+
import { UploadButton } from '@/components/ui/upload-button';
14+
1015
import { BookCover } from '@/features/book/book-cover';
1116
import { FormFieldsBook } from '@/features/book/schema';
1217

@@ -33,52 +38,62 @@ export const FormBookCover = () => {
3338
control: form.control,
3439
});
3540

36-
const { upload, uploadedFile } = useUploadFile({
41+
const { uploadedFile, control } = useUploadFile({
3742
route: 'bookCover',
3843
onUploadComplete: ({ file }) => {
44+
form.clearErrors('coverId');
3945
form.setValue('coverId', file.objectKey);
4046
},
4147
onError: (error) => {
4248
if (error.type === 'rejected') {
4349
// In this specific case, error should be a translated message
4450
// because rejected are custom errors thrown by the developper
45-
toast.error(error.message);
51+
form.setError('coverId', {
52+
type: 'custom',
53+
message: error.message,
54+
});
4655
} else {
47-
toast.error(t(`book:manager.uploadErrors.${error.type}`));
56+
form.setError('coverId', {
57+
type: 'custom',
58+
message: t(`book:manager.uploadErrors.${error.type}`),
59+
});
4860
}
4961
},
5062
});
5163

5264
return (
53-
<div className="relative">
54-
<label htmlFor="coverId">
55-
<input
56-
className="hidden"
57-
id="coverId"
58-
type="file"
59-
name="coverId"
60-
onChange={(e) => {
61-
if (e.target.files?.[0]) {
62-
upload(e.target.files[0]);
63-
}
64-
}}
65-
/>
66-
<input type="hidden" {...form.register('coverId')} />
67-
<BookCover
68-
className="hover:cursor-pointer"
69-
book={{
70-
title,
71-
author,
72-
genre,
73-
coverId: uploadedFile?.objectKey ?? coverId,
74-
}}
75-
/>
65+
<FormField>
66+
<FormFieldController
67+
control={form.control}
68+
type="custom"
69+
name="coverId"
70+
render={() => {
71+
return (
72+
<>
73+
<div className="relative mb-2">
74+
<span className="sr-only">{t('book:manager.uploadCover')}</span>
75+
<BookCover
76+
className="opacity-60"
77+
book={{
78+
title,
79+
author,
80+
genre,
81+
coverId: uploadedFile?.objectKey ?? coverId,
82+
}}
83+
/>
7684

77-
<span className="absolute top-1/2 left-1/2 z-10 flex origin-center -translate-1/2 cursor-pointer items-center gap-2 rounded bg-white px-2 py-1 text-black">
78-
<Upload size="16" />
79-
{t('book:manager.uploadCover')}
80-
</span>
81-
</label>
82-
</div>
85+
<UploadButton
86+
className="absolute top-1/2 left-1/2 -translate-1/2 bg-black/50 text-white"
87+
variant="ghost"
88+
control={control}
89+
disabled={form.formState.isSubmitting}
90+
/>
91+
</div>
92+
<FormFieldError />
93+
</>
94+
);
95+
}}
96+
/>
97+
</FormField>
8398
);
8499
};

0 commit comments

Comments
 (0)