Skip to content

Instantly share code, notes, and snippets.

@rphlmr
Last active April 27, 2024 06:19
Show Gist options
  • Save rphlmr/3c478c120f851d61664c6fdfa3f7457c to your computer and use it in GitHub Desktop.
Save rphlmr/3c478c120f851d61664c6fdfa3f7457c to your computer and use it in GitHub Desktop.
Remix Supabase Upload
// 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