Need to create a file uploader with grid preview

  1. My goal: Create a file uploader with grid preview of 20 slots that when the user clicks on any slot, they can select images from their PC and after selection, thumbnails would appear on the slots which the user can delete individual slots, delete all slots as well as reorder slots.
  2. Issue: Retool does not render any JS upon calling. I used the HTML component as well as the Reorderable component. both fail at deleting images when calling JS queries. No reordering happens at all with the HTML component. I don’t know why scripting is not accepted in retool natively which is a basic feature. I spent an entire week on this without able to solve it not knowing what the real issue is. I tried all sorts of different things. Whereas using pure HTML+CSS+JS in one single file when testing on my browser worked flawlessly. I have provided the full code below.
  3. Additional info: Cloud version used.
  4. I have an example of how this should look in this pure HTML+CSS+JS file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Image Grid Upload</title>
<style>
  body {
    background: #1a1a1a;
    color: #eee;
    font-family: Inter, sans-serif;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
  }

  .upload-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, 100px);
    gap: 12px;
    width: 90%;
    max-width: 600px;
  }

  .slot {
    width: 100px;
    height: 100px;
    border-radius: 8px;
    border: 1px dashed #666;
    display: flex;
    justify-content: center;
    align-items: center;
    background: #2a2a2a;
    position: relative;
    overflow: hidden;
    transition: all 0.2s ease;
  }

  .slot img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }

  .slot.empty:hover {
    background: #333;
    cursor: pointer;
  }

  .delete-btn {
    position: absolute;
    top: 4px;
    right: 4px;
    background: rgba(0,0,0,0.5);
    border: none;
    color: white;
    width: 22px;
    height: 22px;
    border-radius: 50%;
    font-size: 14px;
    cursor: pointer;
    opacity: 0;
    transition: 0.2s;
  }

  .slot:hover .delete-btn {
    opacity: 1;
  }

  /* Dragging effects */
  .slot.dragging {
    opacity: 0.8;
    transform: rotate(-5deg) scale(1.05);
    z-index: 100;
    box-shadow: 0 8px 18px rgba(0,0,0,0.5);
  }

  .slot.drag-over {
    border: 2px solid #00b4ff;
    background: rgba(0,180,255,0.1);
  }

  input[type="file"] {
    display: none;
  }

  .add-icon {
    width: 40px;
    height: 40px;
    fill: #777;
  }

</style>
</head>
<body>

<input type="file" id="fileInput" accept="image/*" multiple>

<div class="upload-grid" id="uploadGrid"></div>

<script>
  const grid = document.getElementById("uploadGrid");
  const fileInput = document.getElementById("fileInput");
  const maxSlots = 20;
  let files = [];

  // Initialize empty grid
  function renderGrid() {
    grid.innerHTML = "";

    for (let i = 0; i < maxSlots; i++) {
      const slot = document.createElement("div");
      slot.classList.add("slot");
      slot.dataset.index = i;

      const file = files[i];
      if (file) {
        const img = document.createElement("img");
        img.src = file.data;
        slot.appendChild(img);

        const delBtn = document.createElement("button");
        delBtn.innerText = "Γ—";
        delBtn.className = "delete-btn";
        delBtn.onclick = () => {
          files.splice(i, 1);
          renderGrid();
        };
        slot.appendChild(delBtn);
      } else {
        slot.classList.add("empty");
        slot.innerHTML = `
          <svg class="add-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
            <path d="M12 5v14m-7-7h14" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round"/>
          </svg>
        `;
        slot.onclick = () => fileInput.click();
      }

      // Enable drag-and-drop
      slot.draggable = true;
      slot.addEventListener("dragstart", handleDragStart);
      slot.addEventListener("dragover", handleDragOver);
      slot.addEventListener("drop", handleDrop);
      slot.addEventListener("dragend", handleDragEnd);

      grid.appendChild(slot);
    }
  }

  // Handle upload
  fileInput.addEventListener("change", (e) => {
    const newFiles = Array.from(e.target.files);
    newFiles.forEach(file => {
      if (files.length < maxSlots) {
        const reader = new FileReader();
        reader.onload = function(event) {
          files.push({ name: file.name, data: event.target.result });
          renderGrid();
        };
        reader.readAsDataURL(file);
      }
    });
    fileInput.value = "";
  });

  // Drag logic
  let dragSrcIndex = null;

  function handleDragStart(e) {
    dragSrcIndex = +e.currentTarget.dataset.index;
    e.currentTarget.classList.add("dragging");
  }

  function handleDragOver(e) {
    e.preventDefault();
    e.currentTarget.classList.add("drag-over");
  }

  function handleDrop(e) {
    e.preventDefault();
    e.currentTarget.classList.remove("drag-over");
    const targetIndex = +e.currentTarget.dataset.index;
    if (dragSrcIndex !== targetIndex && files[dragSrcIndex]) {
      const moved = files.splice(dragSrcIndex, 1)[0];
      files.splice(targetIndex, 0, moved);
      renderGrid();
    }
  }

  function handleDragEnd(e) {
    e.currentTarget.classList.remove("dragging");
    grid.querySelectorAll(".slot").forEach(slot => slot.classList.remove("drag-over"));
  }

  renderGrid();
</script>

</body>
</html>

Hey @Retooler

Something we often do when we want to trigger a component is use the Query JSON with SQL resource and do something like

select * from {{ filepicker1.value }}

and have a success handler that has the JS. When you have your query JSON with SQL referencing the filepicker.value && on run automatically, it should trigger a run whenever it sees a change and then the JS will run on success. you could then have that JS update a variable that contains your gridview data and a image component that hides/unhides when data is added

Let me know if that works and if it all makes sense :slight_smile:

1 Like

Hello @DavidD

No worries I will be creating a custom component for that in which I have full control over DOM events. I can customize every aspect of what I need. Thank you anyways.

Looks like you had already done most of the work in the post already. Always a good workaround!

1 Like

I was going to recommend a custom component, as well! Building one out can be a little intimidating, but it enables a ton of things that Retool doesn't support natively. Plus it looks like you've done a lot of the work already!

One thing to note is that you'll probably want to convert the raw HTML/CSS/JS you've written into React. ChatGPT is honestly pretty good at stuff like that, but let me know if you have any questions!

1 Like