Saltar al contenido principal

Archivos

1. Crear bucket

  1. Crear bucket en AWS S3, Cloudflare R2, Digital Ocean Spaces, etc.
  2. Obtener variables de entorno (en R2 se crea en R2/API/Manage API Tokens)
  3. Crear archivo .env y .env.template y agregar al .gitignore
# ...

# Storage
STORAGE_BUCKET_NAME=abc
STORAGE_ENDPOINT=https://{ACCOUNT_ID}.r2.cloudflarestorage.com
STORAGE_ACCESS_KEY_ID=abc123 # or ACCOUNT_ID
STORAGE_SECRET_ACCESS_KEY=abc123
# In Cloudflare the SECRET_ACCESS_KEY is the API key

2. Configuración

1. Instalar AWS SDK S3

bun i @aws-sdk/client-s3
#npm i @aws-sdk/client-s3

2. Configuración del cliente

src\libs\services\storage-client.ts
import { S3Client } from "@aws-sdk/client-s3";

export const storageClient = new S3Client({
  region: "auto",
  endpoint: import.meta.env.STORAGE_ENDPOINT,
  credentials: {
    accessKeyId: import.meta.env.STORAGE_ACCESS_KEY_ID,
    secretAccessKey: import.meta.env.STORAGE_SECRET_ACCESS_KEY,
  },
});

3. Funciones reutilizables

src\libs\services\storage.service.ts
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { v4 as UUID } from "uuid";
import { storageClient } from "./storage-client";

export async function uploadFile(file: File): Promise<string> {
  const fileExtension = file.name.split(".").pop();
  if (!fileExtension) throw new Error("File extension not found");

  const buffer = Buffer.from(await file.arrayBuffer());
  const params = {
    Bucket: import.meta.env.STORAGE_BUCKET_NAME,
    Key: `${UUID()}.${fileExtension}`,
    Body: buffer,
    ContentType: file.type,
  };

  const res = await storageClient.send(new PutObjectCommand(params));
  if (res.$metadata.httpStatusCode === 200) {
    return params.Key;
  }

  throw new Error("Failed to upload file");
}

export async function deleteFile(fileKey: string): Promise<void> {
  try {
    await storageClient.send(
      new DeleteObjectCommand({
        Bucket: import.meta.env.STORAGE_BUCKET_NAME,
        Key: fileKey,
      })
    );
  } catch (error) {
    console.error("Failed to delete file", error);
  }
}

3. Uso

1. Action (o endpoint)

src\actions\products\update-product.action.ts
import { processImage } from "@libs/process-image";
import { deleteFile, uploadFile } from "@libs/storage";
import { ActionError, defineAction } from "astro:actions";
import { db, eq, Product } from "astro:db";
import { z } from "astro:schema";

const MAX_FILE_SIZE = 2 * 1024 * 1024;
const ACCEPTED_IMAGE_TYPES = [
  "image/svg+xml",
  "image/avif",
  "image/webp",
  "image/png",
  "image/jpeg",
  "image/jpg",
];

export const updateProduct = defineAction({
  accept: "form",
  input: z.object({
    id: z.number().positive(),
    name: z.string().min(1),
    price: z.number().positive(),
    image: z
      .instanceof(File)
      .optional()
      .refine(
        (file) =>
          !file || file.size === 0 || ACCEPTED_IMAGE_TYPES.includes(file.type),
        "Only .svg, .avif, .webp, .png, .jpeg and .jpg formats are supported."
      )
      .refine(
        (file) => !file || file.size === 0 || file.size <= MAX_FILE_SIZE,
        `Max image size is 2MB.`
      ),
  }),
  handler: async ({ id, name, price, image }, { locals }) => {
    if (!locals.isLoggedIn) throw new ActionError({ code: "UNAUTHORIZED" });

    // Check if the product exists
    const existingProduct = await db
      .select()
      .from(Product)
      .where(eq(Product.id, id));

    if (existingProduct.length === 0) {
      throw new ActionError({ code: "NOT_FOUND" });
    }

    // Save the image
    let imageId: string | null = null;
    if (image && image instanceof File && image.size > 0 && image.name !== "") {
      try {
        const processedImage = await processImage(image);
        imageId = await uploadFile(processedImage);
      } catch (error) {
        throw new ActionError({
          code: "UNPROCESSABLE_CONTENT",
          message:
            error instanceof Error ? error.message : "Failed to upload image",
        });
      }
    }

    // Update in the database
    const { rowsAffected } = await db
      .update(Product)
      .set({ name, price, ...(imageId ? { image: imageId } : {}) })
      .where(eq(Product.id, id));

    if (rowsAffected === 0) {
      // Delete the image if the update failed
      if (imageId) await deleteFile(imageId);

      throw new ActionError({
        code: "NOT_FOUND",
        message: "Product not found",
      });
    }

    // Delete the old image if a new one was uploaded
    if (imageId && existingProduct[0].image) {
      await deleteFile(existingProduct[0].image);
    }
   
    return true;
  },
});

2. Form

src\pages\products\edit[id].astro
<form>
<!-- Fields... -->

<div>
{
unit.imageUrl && (
<>
<p>Imagen actual</p>
<img
src={`https://junlaj-assets.042025.xyz/${unit.imageUrl}`}
alt="Unit"
class="rounded-md my-4"
width={500}
height={500}
/>
</>
)
}

<p>New image</p>
<input id="imageInput" type="file" name="image" accept="image/*" />
<img
id="imagePreview"
src="#"
alt="Preview"
class="rounded-md max-h-96 hidden my-4"
/>
</div>

<button id="btn-submit" class="w-full">Update</button>
</form>

<script>
  import { toast } from "@/components/ui/toast";
  import { ActionInputError, actions } from "astro:actions";

  // Form submit
  const form = document.querySelector("form") as HTMLFormElement;
  const btnSubmit = document.getElementById("btn-submit") as HTMLButtonElement;

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    btnSubmit.setAttribute("disabled", "disabled");

    const formData = new FormData(form);
    const { error } = await actions.updateUnit(formData);

    if (error) {
      const errorMessage =
        error instanceof ActionInputError
          ? Object.entries(error.fields)
              .map(([field, messages]) => `${field}: ${messages?.join(", ")}`)
              .join("\n")
          : error.message;

      toast.error(errorMessage);
      btnSubmit.removeAttribute("disabled");
      return;
    }

    const courseId = form.getAttribute("data-courseId");
    window.location.replace(`/course/${courseId}`);
  });

  // Image preview
  const imageInput = document.getElementById("imageInput") as HTMLInputElement;
  const imagePreview = document.getElementById(
    "imagePreview"
  ) as HTMLImageElement;

  if (imageInput && imagePreview) {
    imageInput.addEventListener("change", function () {
      if (this.files && this.files[0]) {
        // Create URL for the selected image
        const objectUrl = URL.createObjectURL(this.files[0]);
        imagePreview.src = objectUrl;

        // Show the image element
        imagePreview.classList.remove("hidden");
      } else {
        // Hide the image element if no image is selected
        imagePreview.classList.add("hidden");
        imagePreview.src = "#";
      }
    });
  }
</script>

3. Op. Estilo

src\styles\global.css
@layer base {
  input[type="file"] {
    @apply file:mr-4 file:px-4 file:py-1 file:rounded-md file:cursor-pointer file:bg-muted hover:file:bg-foreground/40;
  }
}