"use client"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"; import { useMutation, useMutationState } from "@tanstack/react-query"; import { createContext, useContext, useState } from "react"; import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod"; import { handle } from "@turbostarter/api/utils"; import { useTranslation } from "@turbostarter/i18n"; import { cn } from "@turbostarter/ui"; import { Avatar, AvatarFallback, AvatarImage, } from "@turbostarter/ui-web/avatar"; import { Button } from "@turbostarter/ui-web/button"; import { Icons } from "@turbostarter/ui-web/icons"; import { api } from "~/lib/api/client"; interface AvatarFormProps { readonly id: string; readonly image?: string | null; readonly update: ( image: string | null, ) => Promise<{ error: { message?: string } | null }>; } const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB const ACCEPTED_IMAGE_TYPES = [ "image/jpeg", "image/jpg", "image/png", "image/webp", ]; const mutations = { upload: { mutationKey: ["avatar", "upload"], mutationFn: async ({ avatar, id, image, update, }: AvatarFormProps & { avatar?: File }) => { const extension = avatar?.type.split("/").pop(); const uuid = crypto.randomUUID(); const path = `avatars/${id}-${uuid}.${extension}`; const { url: uploadUrl } = await handle(api.storage.upload.$get)({ query: { path }, }); const response = await fetch(uploadUrl, { method: "PUT", body: avatar, headers: { "Content-Type": avatar?.type ?? "", }, }); if (!response.ok) { throw new Error(); } const { url: publicUrl } = await handle(api.storage.public.$get)({ query: { path }, }); const { error } = await update(publicUrl); if (error) { throw new Error(error.message); } return { publicUrl, oldImage: image }; }, }, remove: { mutationKey: ["avatar", "remove"], mutationFn: async ({ image, update }: Omit) => { const path = image?.split("/").pop(); if (!path) { return; } const { url: deleteUrl } = await handle(api.storage.delete.$get)({ query: { path: `avatars/${path}` }, }); const { error } = await update(null); if (error) { throw new Error(error.message); } void fetch(deleteUrl, { method: "DELETE", }); }, }, }; const useAvatarFormSchema = () => { const { t, i18n } = useTranslation("validation"); return z.object({ avatar: z .custom() .refine( (files) => files.length === 1, t("error.file.maxCount", { count: 1 }), ) .transform((files) => files[0]) .refine( (file) => (file?.size ?? 0) <= MAX_FILE_SIZE, t("error.tooBig.file.inclusive", { maximum: MAX_FILE_SIZE, }), ) .refine( (file) => ACCEPTED_IMAGE_TYPES.includes(file?.type ?? ""), t("error.file.type", { types: new Intl.ListFormat(i18n.language, { style: "long", type: "conjunction", }).format(ACCEPTED_IMAGE_TYPES.map((t) => t.replace("image/", "."))), }), ), }); }; interface AvatarFormContextValue extends AvatarFormProps { previewUrl: string | null; setPreviewUrl: (previewUrl: string | null) => void; } const AvatarFormContext = createContext(null); const useAvatarFormContext = () => { const context = useContext(AvatarFormContext); if (!context) { throw new Error("useAvatarFormContext must be used within a AvatarForm!"); } return context; }; const AvatarForm = ({ id, image, update, children, }: AvatarFormProps & { children: React.ReactNode; }) => { const [previewUrl, setPreviewUrl] = useState(image ?? null); const avatarSchema = useAvatarFormSchema(); const form = useForm({ resolver: standardSchemaResolver(avatarSchema), }); return ( {children} ); }; const AvatarFormInput = ({ className, children, onUpload, disabled, ...props }: React.ComponentProps<"input"> & { onUpload?: () => void }) => { const { t } = useTranslation("common"); const { image, setPreviewUrl, id, update } = useAvatarFormContext(); const avatarSchema = useAvatarFormSchema(); const { register, handleSubmit, reset } = useFormContext>(); const upload = useMutation({ ...mutations.upload, onError: (error) => { setPreviewUrl(image ?? null); toast.error(error.message || t("error.general")); }, onSuccess: async ({ publicUrl, oldImage }) => { await new Promise((resolve) => { const img = new Image(); img.src = publicUrl; img.onload = resolve; }); if (oldImage) { const oldPath = oldImage.split("/").pop(); if (oldPath) { const { url: deleteUrl } = await handle(api.storage.delete.$get)({ query: { path: `avatars/${oldPath}` }, }); void fetch(deleteUrl, { method: "DELETE" }); } } onUpload?.(); }, }); const [removeStatus] = useMutationState({ filters: { mutationKey: mutations.remove.mutationKey }, select: (mutation) => mutation.state.status, }); const onSubmit = (data: z.infer) => { upload.mutate({ ...data, id, image, update, }); reset(); }; return ( ); }; const AvatarFormPreview = ({ className, fallback, ...props }: React.ComponentProps & { fallback?: React.ReactNode }) => { const { previewUrl } = useAvatarFormContext(); const { formState } = useFormContext(); const mutationStatues = useMutationState({ filters: { predicate: (mutation) => [mutations.upload.mutationKey, mutations.remove.mutationKey].includes( mutation.options.mutationKey as string[], ), }, select: (mutation) => mutation.state.status, }); const hasError = formState.errors.avatar ?? mutationStatues.some((status) => status === "error"); return ( {previewUrl && } {mutationStatues.some((status) => status === "pending") && (
)} {fallback ?? }
); }; const AvatarFormRemoveButton = ({ className, onRemove, ...props }: React.ComponentProps & { onRemove?: () => void }) => { const { t } = useTranslation("common"); const { image, update, previewUrl, setPreviewUrl } = useAvatarFormContext(); const { clearErrors } = useFormContext(); const [uploadStatus] = useMutationState({ filters: { mutationKey: mutations.upload.mutationKey }, select: (mutation) => mutation.state.status, }); const remove = useMutation({ ...mutations.remove, onMutate: () => { setPreviewUrl(null); }, onSuccess: () => { setPreviewUrl(null); onRemove?.(); }, }); return ( previewUrl && uploadStatus !== "pending" && ( ) ); }; const AvatarFormErrorMessage = (props: React.ComponentProps<"span">) => { const _avatarSchema = useAvatarFormSchema(); const { formState } = useFormContext>(); if (!formState.errors.avatar) { return null; } return ( {formState.errors.avatar.message} ); }; export { AvatarForm, AvatarFormInput, AvatarFormPreview, AvatarFormRemoveButton, AvatarFormErrorMessage, };