Last active
July 24, 2024 13:02
-
-
Save rphlmr/3c478c120f851d61664c6fdfa3f7457c to your computer and use it in GitHub Desktop.
Remix Supabase Upload
This file contains 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
// if you don't plan to upload only images : | |
/* | |
async function convertToFile(data: AsyncIterable<Uint8Array>) { | |
const chunks = []; | |
for await (const chunk of data) { | |
chunks.push(chunk); | |
} | |
return chunks; | |
} | |
*/ | |
async function convertToFile(data: AsyncIterable<Uint8Array>) { | |
const chunks = []; | |
for await (const chunk of data) { | |
chunks.push(chunk); | |
} | |
const sharp = (await import("sharp")).default; | |
return sharp(Buffer.concat(chunks)) | |
.rotate() | |
.resize({ | |
height: 200, | |
width: 200, | |
fit: sharp.fit.cover, | |
position: sharp.strategy.entropy, | |
withoutEnlargement: true, | |
}) | |
.toBuffer(); | |
} | |
export function getPublicFileURL(filePath: string, bucketName: string) { | |
try { | |
const { data: url } = getSupabaseAdmin() | |
.storage.from(bucketName) | |
.getPublicUrl(filePath); | |
return url.publicUrl; | |
} catch (cause) { | |
throw new AppError({ | |
cause, | |
message: "Failed to get public file URL", | |
metadata: { filePath, bucketName }, | |
tag, | |
}); | |
} | |
} | |
export interface UploadOptions { | |
bucketName?: string; | |
filename: string; | |
contentType: string; | |
} | |
async function uploadFile( | |
data: AsyncIterable<Uint8Array>, | |
{ filename, contentType, bucketName = "avatars" }: UploadOptions | |
) { | |
try { | |
const file = await convertToFile(data); | |
const { error } = await getSupabaseAdmin() | |
.storage.from(bucketName) | |
.upload(filename, file, { contentType, upsert: true }); | |
if (error) { | |
throw error; | |
} | |
const publicURL = getPublicFileURL(filename, bucketName); | |
return publicURL; | |
} catch (cause) { | |
throw new AppError({ | |
cause, | |
message: "Failed to upload file", | |
metadata: { | |
filename, | |
contentType, | |
bucketName, | |
}, | |
tag, | |
}); | |
} | |
} | |
async function deleteFile(url: string) { | |
try { | |
if (!url.startsWith(`${SUPABASE_URL}/storage/v1/object/public/avatars/`)) { | |
throw new AppError({ | |
message: "Invalid file URL", | |
metadata: { url }, | |
tag, | |
}); | |
} | |
const { error } = await getSupabaseAdmin() | |
.storage.from("avatars") | |
.remove([url.split("avatars/")[1]]); | |
if (error) { | |
throw error; | |
} | |
} catch (cause) { | |
Logger.error( | |
new AppError({ cause, message: "Failed to delete file", tag }) | |
); | |
} | |
} | |
async function parseFileFormData(request: Request) { | |
try { | |
const uploadHandler = unstable_composeUploadHandlers( | |
async ({ name, data, contentType }) => { | |
await parseData( | |
{ name, contentType }, | |
z.object({ | |
name: z.literal("avatar"), | |
contentType: z.enum(AVATAR_ALLOWED_TYPES), | |
}), | |
"Invalid payload" | |
); | |
const fileExtension = contentType.split("/")[1]; | |
const uploadedFileURL = await uploadFile(data, { | |
filename: `${createId()}.${fileExtension}`, | |
contentType, | |
}); | |
return uploadedFileURL; | |
} | |
); | |
const formData = await unstable_parseMultipartFormData( | |
request, | |
uploadHandler | |
); | |
return formData; | |
} catch (cause) { | |
throw new AppError({ | |
cause, | |
message: "Unable to upload avatar", | |
tag, | |
}); | |
} | |
} | |
export type AvatarDataAPI = typeof loader; | |
export const AVATAR_ALLOWED_TYPES = ["image/png", "image/jpeg"] as const; | |
export async function loader({ request }: LoaderArgs) { | |
const authSession = await requireAuthSession(request); | |
const { userId } = authSession; | |
try { | |
const { avatarURL, updatedAt } = await getUserProfile(userId); | |
return response.ok({ avatarURL, updatedAt }, { authSession }); | |
} catch (cause) { | |
throw response.error(cause, { authSession }); | |
} | |
} | |
export async function action({ request }: ActionArgs) { | |
const authSession = await requireAuthSession(request); | |
const { userId } = authSession; | |
try { | |
const { avatarURL: previousAvatarURL } = await getUserProfile(userId); | |
const fileForm = await parseFileFormData(request); | |
const avatarURL = fileForm.get("avatar") as string; | |
const { updatedAt } = await updateUserAvatar(authSession.userId, avatarURL); | |
if (previousAvatarURL !== DEFAULT_AVATAR_URL) { | |
await deleteFile(previousAvatarURL); | |
} | |
return response.ok( | |
{ avatarURL, updatedAt }, | |
{ | |
authSession, | |
} | |
); | |
} catch (cause) { | |
return response.error(cause, { authSession }); | |
} | |
} | |
export function AvatarUploader({ label }: { label?: string }) { | |
const name = "avatar"; | |
const avatarAPI = useFetcher<AvatarDataAPI>(); | |
const disabled = isFormProcessing(avatarAPI); | |
const avatarURL = `${ | |
avatarAPI.data?.avatarURL ?? "/assets/profiles/default_avatar.jpg" | |
}`; | |
useEffect(() => { | |
if (avatarAPI.type === "init") { | |
avatarAPI.load("/api/avatar"); | |
} | |
}, [avatarAPI]); | |
return ( | |
<avatarAPI.Form | |
className="flex w-fit flex-col items-center space-y-1 text-center" | |
onChange={(e) => { | |
const formData = new FormData(e.currentTarget); | |
if (!(formData.get("avatar") as File).name) { | |
return; | |
} | |
avatarAPI.submit(formData, { | |
method: "post", | |
action: "/api/avatar", | |
encType: "multipart/form-data", | |
}); | |
}} | |
> | |
<label | |
htmlFor={name} | |
className="relative flex cursor-pointer justify-center" | |
> | |
<img | |
className={tw( | |
"inline-block h-32 w-32 shrink-0 self-center rounded-full object-contain", | |
disabled && "opacity-50" | |
)} | |
src={avatarURL} | |
alt="Avatar" | |
/> | |
{disabled ? ( | |
<div className="absolute flex h-full flex-col justify-center"> | |
<LoadingIndicator /> | |
</div> | |
) : null} | |
<input | |
name={name} | |
id={name} | |
disabled={disabled} | |
type="file" | |
className="sr-only" | |
accept={AVATAR_ALLOWED_TYPES.join(", ")} | |
/> | |
</label> | |
<Label name={name} label={label} /> | |
</avatarAPI.Form> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment