Created
April 28, 2025 15:38
-
-
Save lukaspili/d78c60453becfdc55896b36f43f96d80 to your computer and use it in GitHub Desktop.
filepond react
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"use client"; | |
import { removeUserAvatar, updateUserAvatar } from "@/actions/session/avatar"; | |
import { Text } from "@/catalyst/text"; | |
import { AvatarInput, AvatarInputController } from "@/components/account/avatar/AvatarInput"; | |
import { ActionButton } from "@/components/shared/ActionButton"; | |
import { ApiResponse } from "@/lib/api"; | |
import { SessionUser } from "@/models/user"; | |
import { valibotResolver } from "@hookform/resolvers/valibot"; | |
import { Button } from "catalyst/button"; | |
import { Heading } from "catalyst/heading"; | |
import clsx from "clsx"; | |
import { RefObject, startTransition, useActionState, useEffect, useRef } from "react"; | |
import { FormProvider, useForm, useFormState, useWatch } from "react-hook-form"; | |
import { toast } from "sonner"; | |
import * as v from "valibot"; | |
const fieldName = "uploadId"; | |
const FormSchema = v.object({ | |
[fieldName]: v.pipe(v.string(), v.nonEmpty("Something went wrong. Please try again.")), | |
}); | |
export type FormValues = v.InferOutput<typeof FormSchema>; | |
export function AvatarForm({ user }: { user: SessionUser }) { | |
const form = useForm<FormValues>({ | |
resolver: valibotResolver(FormSchema), | |
mode: "onSubmit", | |
reValidateMode: "onSubmit", | |
shouldUseNativeValidation: false, | |
}); | |
const [response, action, pending] = useActionState(updateUserAvatar, null); | |
useEffect(() => { | |
if (!response) { | |
return; | |
} | |
if (response.successful) { | |
toast.success("Your avatar has been updated."); | |
} else { | |
toast.error(response.simpleError); | |
} | |
}, [response]); | |
const formRef = useRef<HTMLFormElement>(null); | |
const inputRef = useRef<AvatarInputController>(null); | |
return ( | |
<FormProvider {...form}> | |
<form | |
ref={formRef} | |
className="space-y-6" | |
onSubmit={(evt) => { | |
evt.preventDefault(); | |
form.handleSubmit(() => { | |
startTransition(() => { | |
action(new FormData(formRef.current!)); | |
}); | |
})(evt); | |
}} | |
> | |
<ContentView user={user} inputRef={inputRef} /> | |
<FooterView | |
user={user} | |
pending={pending} | |
response={response} | |
onRevert={() => { | |
inputRef.current?.revert(); | |
}} | |
onToggleDisabled={(disabled) => { | |
inputRef.current?.toggleDisabled(disabled); | |
}} | |
/> | |
</form> | |
</FormProvider> | |
); | |
} | |
function ContentView({ inputRef, user }: { inputRef: RefObject<any>; user: SessionUser }) { | |
return ( | |
<div className="flex flex-row gap-8 px-4 py-5 sm:px-6 sm:pb-1 sm:pt-6"> | |
<div className="flex-1"> | |
<Heading level={2}>Avatar</Heading> | |
<Text>Click on the avatar to upload a new one from your files.</Text> | |
</div> | |
<div className="flex-shrink-0"> | |
<AvatarInput ref={inputRef} name={fieldName} user={user} /> | |
</div> | |
</div> | |
); | |
} | |
function FooterView({ | |
user, | |
pending, | |
response, | |
onRevert, | |
onToggleDisabled, | |
}: { | |
user: SessionUser; | |
pending: boolean; | |
response: ApiResponse | null; | |
onRevert: () => void; | |
onToggleDisabled: (disabled: boolean) => void; | |
}) { | |
return ( | |
<div className="border-t border-zinc-950/5 bg-zinc-50 px-4 py-3 sm:px-6"> | |
<div className="flex flex-row gap-4"> | |
<div className="flex flex-1 items-center"> | |
<StatusView pending={pending} response={response} /> | |
</div> | |
<Buttons | |
user={user} | |
pending={pending} | |
onRevert={onRevert} | |
onToggleDisabled={onToggleDisabled} | |
/> | |
</div> | |
</div> | |
); | |
} | |
function Buttons({ | |
user, | |
pending, | |
onRevert, | |
onToggleDisabled, | |
}: { | |
user: SessionUser; | |
pending: boolean; | |
onRevert: () => void; | |
onToggleDisabled: (disabled: boolean) => void; | |
}) { | |
return ( | |
<div className="flex flex-row gap-2"> | |
<RevertButton pending={pending} onRevert={onRevert} /> | |
<SubmitButton pending={pending} /> | |
<DeleteCurrentButton user={user} onToggleDisabled={onToggleDisabled} /> | |
</div> | |
); | |
} | |
function RevertButton({ pending, onRevert }: { pending: boolean; onRevert: () => void }) { | |
const value: string | undefined = useWatch<FormValues>({ name: fieldName }); | |
const hasNewFile = value !== undefined; | |
return ( | |
<Button | |
className={clsx(!hasNewFile && "invisible")} | |
plain={true} | |
disabled={pending} | |
onClick={() => onRevert()} | |
> | |
Discard | |
</Button> | |
); | |
} | |
function SubmitButton({ pending }: { pending: boolean }) { | |
const value: string | undefined = useWatch<FormValues>({ name: fieldName }); | |
const hasNewFile = value !== undefined; | |
return ( | |
<ActionButton className={clsx(!hasNewFile && "invisible")} type="submit" inProgress={pending}> | |
Save | |
</ActionButton> | |
); | |
} | |
function DeleteCurrentButton({ | |
user, | |
onToggleDisabled, | |
}: { | |
user: SessionUser; | |
onToggleDisabled: (disabled: boolean) => void; | |
}) { | |
// const filePond = useFilePond(); | |
// const setFilePondDisabled = useFilePondDisabledToggle(); | |
const [response, action, pending] = useActionState(removeUserAvatar, null); | |
useEffect(() => { | |
if (!response) { | |
return; | |
} | |
if (response.successful) { | |
toast.success("Your avatar has been removed."); | |
} else { | |
toast.error(response.simpleError); | |
} | |
}, [response]); | |
useEffect(() => { | |
onToggleDisabled(pending); | |
}, [pending]); | |
const value: string | undefined = useWatch<FormValues>({ name: fieldName }); | |
const hasNewFile = value !== undefined; | |
return ( | |
<ActionButton | |
secondary={true} | |
className={clsx((hasNewFile || !user.avatar) && "hidden")} | |
type="submit" | |
inProgress={pending} | |
onClick={() => { | |
action(new FormData()); | |
}} | |
> | |
Remove | |
</ActionButton> | |
); | |
} | |
function StatusView({ pending, response }: { pending: boolean; response: ApiResponse | null }) { | |
const { errors } = useFormState<FormValues>({ name: fieldName }); | |
const formError = errors[fieldName]; | |
let message: string | undefined = undefined; | |
let isErrorStyle!: boolean; | |
if (pending) { | |
message = undefined; | |
isErrorStyle = false; | |
} else if (formError) { | |
message = formError.message; | |
isErrorStyle = true; | |
} else if (response && response.hasErrors) { | |
message = response.errors[0]; | |
isErrorStyle = true; | |
} | |
return ( | |
<p | |
className={clsx("text-base/6 sm:text-sm/6", message != undefined ? "block" : "hidden", { | |
"text-red-600 dark:text-red-500": isErrorStyle, | |
"text-zinc-500 dark:text-zinc-400": !isErrorStyle, | |
})} | |
> | |
{message} | |
</p> | |
); | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createUserAvatarUpload } from "@/actions/session/avatar"; | |
import { ApiResponse } from "@/lib/api"; | |
import { SessionUser } from "@/models/user"; | |
import { UserAvatarUpload } from "@/models/user-avatar-upload"; | |
import { | |
ActualFileObject, | |
FilePondFile, | |
FilePondInitialFile, | |
LoadServerConfigFunction, | |
ProcessServerConfigFunction, | |
ProgressServerConfigFunction, | |
} from "filepond"; | |
import FilePondPluginFilePoster from "filepond-plugin-file-poster"; | |
import FilePondPluginImageCrop from "filepond-plugin-image-crop"; | |
import FilePondPluginImageExifOrientation from "filepond-plugin-image-exif-orientation"; | |
import FilePondPluginImagePreview from "filepond-plugin-image-preview"; | |
import "filepond-plugin-image-preview/dist/filepond-plugin-image-preview.css"; | |
import FilePondPluginImageResize from "filepond-plugin-image-resize"; | |
import FilePondPluginImageTransform from "filepond-plugin-image-transform"; | |
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"; | |
import { FilePond, registerPlugin } from "react-filepond"; | |
import { FieldPath, FieldValues, useFormContext } from "react-hook-form"; | |
// Import FilePond styles | |
// 1. | |
import "filepond/dist/filepond.min.css"; | |
// 2. | |
import "filepond-plugin-file-poster/dist/filepond-plugin-file-poster.css"; | |
registerPlugin( | |
FilePondPluginFilePoster, | |
FilePondPluginImageExifOrientation, | |
FilePondPluginImagePreview, | |
FilePondPluginImageCrop, | |
FilePondPluginImageResize, | |
FilePondPluginImageTransform | |
); | |
type FilePondServer = { | |
process: ProcessServerConfigFunction; | |
load: LoadServerConfigFunction; | |
revert: any; | |
restore: any; | |
fetch: any; | |
}; | |
const server: FilePondServer = { | |
// See: https://pqina.nl/filepond/docs/api/server/#process-1 | |
process: async ( | |
/** The name of the input field. */ | |
_fieldName: string, | |
/** The actual file object to send. */ | |
file: ActualFileObject, | |
_metadata: { [key: string]: any }, | |
/** | |
* Should call the load method when done and pass the returned server file id. | |
* This server file id is then used later on when reverting or restoring a file | |
* so that your server knows which file to return without exposing that info | |
* to the client. | |
*/ | |
load: (p: string | { [key: string]: any }) => void, | |
/** Call if something goes wrong, will exit after. */ | |
error: (errorText: string) => void, | |
/** | |
* Should call the progress method to update the progress to 100% before calling load(). | |
* Setting computable to false switches the loading indicator to infinite mode. | |
*/ | |
progress: ProgressServerConfigFunction, | |
/** Let FilePond know the request has been cancelled. */ | |
abort: () => void | |
) => { | |
let response!: ApiResponse<UserAvatarUpload>; | |
try { | |
response = await createUserAvatarUpload({ | |
name: file.name, | |
mimeType: file.type, | |
size: file.size, | |
}); | |
} catch (e) { | |
error("Upload failed. Please try again."); | |
return; | |
} | |
if (response.failed) { | |
error(response.simpleError); | |
return; | |
} | |
const upload = response.data!; | |
console.log("Upload:", upload); | |
const request = new XMLHttpRequest(); | |
request.open("PUT", upload.signedUrl); | |
// Should call the progress method to update the progress to 100% before calling load | |
// Setting computable to false switches the loading indicator to infinite mode | |
request.upload.onprogress = (e) => { | |
progress(e.lengthComputable, e.loaded, e.total); | |
}; | |
// Should call the load method when done and pass the returned server file id | |
// this server file id is then used later on when reverting or restoring a file | |
// so your server knows which file to return without exposing that info to the client | |
request.onload = function () { | |
console.log("REQUEST STATUS:", request.status); | |
if (request.status >= 200 && request.status < 300) { | |
// the load method accepts either a string (id) or an object | |
load(upload.id); | |
} else { | |
// Can call the error method if something is wrong, should exit after | |
error("Upload failed. Please try again."); | |
} | |
}; | |
request.onerror = function () { | |
error("Upload failed. Please try again."); | |
}; | |
request.send(file); | |
// Should expose an abort method so the request can be cancelled | |
return { | |
abort: () => { | |
// This function is entered if the user has tapped the cancel button | |
request.abort(); | |
// Let FilePond know the request has been cancelled | |
abort(); | |
}, | |
}; | |
}, | |
load: async ( | |
source: any, | |
/** Should call the load method with a file object or blob when done. */ | |
load: (file: ActualFileObject | Blob) => void | |
) => { | |
console.log("Load:", source); | |
const file = await fetch(source).then((res) => res.blob()); | |
load(file); | |
}, | |
revert: null, | |
restore: null, | |
fetch: null, | |
}; | |
export type AvatarInputController = { | |
revert: () => void; | |
toggleDisabled: (disabled: boolean) => void; | |
}; | |
export const AvatarInput = forwardRef(function AvatarInputInternal<T extends FieldValues>( | |
{ | |
name, | |
user, | |
}: { | |
name: FieldPath<T>; | |
user: SessionUser; | |
}, | |
ref: React.ForwardedRef<AvatarInputController> | |
) { | |
useImperativeHandle(ref, () => ({ | |
revert, | |
toggleDisabled, | |
})); | |
const revert = () => { | |
if (user.avatar) { | |
setNewFiles([ | |
{ | |
source: user.avatar!.url, | |
options: { | |
type: "local", | |
}, | |
}, | |
]); | |
} else { | |
setNewFiles([]); | |
} | |
}; | |
const toggleDisabled = (disabled: boolean) => { | |
setDisabled(disabled); | |
}; | |
const [files, setNewFiles] = useState<Array<FilePondInitialFile | File | Blob | string>>([]); | |
const [disabled, setDisabled] = useState(false); | |
const filePondRef = useRef<FilePond | null>(null); | |
const form = useFormContext<T>(); | |
useEffect(() => { | |
if (user.avatar) { | |
setNewFiles([ | |
{ | |
source: user.avatar!.url, | |
options: { | |
type: "local", | |
}, | |
}, | |
]); | |
} else { | |
setNewFiles([]); | |
} | |
}, [user]); | |
return ( | |
<div className="size-24"> | |
<input type="hidden" {...form.register(name)} /> | |
<FilePond | |
ref={filePondRef} | |
name="avatar" | |
server={server} | |
files={files} | |
disabled={disabled} | |
allowMultiple={false} | |
// @ts-ignore | |
// This prop is not in the types, but it's a bug. See: https://github.com/pqina/filepond/pull/979/files | |
allowRemove={false} | |
allowReplace={true} | |
allowRevert={false} | |
iconDone="" | |
stylePanelLayout="compact circle" | |
styleLoadIndicatorPosition="center bottom" | |
styleProgressIndicatorPosition="center bottom" | |
styleButtonRemoveItemPosition="center bottom" | |
styleButtonProcessItemPosition="center bottom" | |
imageCropAspectRatio="1:1" | |
imageResizeTargetWidth={400} | |
labelIdle='<span class="filepond--label-xs">Drag or <span class="filepond--label-action">Browse</span></span>' | |
// oninit={() => setFilePond(filePond.current)} | |
onupdatefiles={(files: FilePondFile[]) => { | |
console.log("SET FILES = ", files); | |
setNewFiles(files.map((file) => file.file as File)); | |
}} | |
onactivatefile={() => { | |
filePondRef.current?.browse(); | |
}} | |
onprocessfile={(_error, file) => { | |
form.setValue(name, file.serverId as any); | |
}} | |
onprocessfileabort={() => { | |
form.resetField(name); | |
}} | |
onprocessfilerevert={() => { | |
form.resetField(name); | |
}} | |
onprocessfilestart={() => { | |
form.resetField(name); | |
}} | |
onremovefile={() => { | |
form.resetField(name); | |
}} | |
onerror={() => { | |
form.setError(name, { message: "Upload failed. Please try again." }); | |
}} | |
/> | |
</div> | |
); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment