Skip to content

Instantly share code, notes, and snippets.

@lukaspili
Created April 28, 2025 15:38
Show Gist options
  • Save lukaspili/d78c60453becfdc55896b36f43f96d80 to your computer and use it in GitHub Desktop.
Save lukaspili/d78c60453becfdc55896b36f43f96d80 to your computer and use it in GitHub Desktop.
filepond react
"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>
);
}
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