Skip to content

Instantly share code, notes, and snippets.

@mariochavez
Forked from lazaronixon/_form.html.erb
Created February 26, 2025 03:32
Dropzone.js + Stimulus + Active Storage + CSS Zero (2025)
<%= form_with(model: billboard) do |form| %>
<%= tag.div class: "dropzone", data: { controller: "dropzone", dropzone_param_name_value: "billboard[images][]", dropzone_url_value: rails_direct_uploads_url, dropzone_accepted_files_value: "image/*", dropzone_max_files_value: 3, dropzone_max_filesize_value: 0.300 } do %>
<div class="dz-default dz-message flex flex-col items-center">
<%= image_tag "upload.svg", size: 28, class: "colorize-black", aria: { hidden: true } %>
<h5 class="font-semibold mbs-4">Drop files here or click to upload.</h5>
<p class="text-sm text-subtle">Upload up to 10 files.</p>
</div>
<% end %>
<div class="inline-flex items-center mbs-2 mie-1">
<%= form.submit class: "btn btn--primary" %>
</div>
<% end %>
@import url("https://esm.sh/dropzone@6.0.0-beta.2/dist/dropzone.css");
.dropzone {
border-radius: var(--rounded-xl);
border: 2px dashed var(--color-border);
padding: var(--size-2);
text-align: center;
.dz-preview.dz-image-preview {
background: transparent;
}
.dz-preview .dz-error-message {
background: var(--color-negative);
}
.dz-preview .dz-error-message::after {
border-bottom: 6px solid var(--color-negative);
}
.dz-preview:has(.dz-remove:hover) .dz-error-message {
display: none;
}
}
import { Controller } from "@hotwired/stimulus"
import { DirectUpload } from "https://esm.sh/@rails/activestorage@8.0.100?standalone"
import Dropzone from "https://esm.sh/dropzone@6.0.0-beta.2?standalone"
export default class extends Controller {
static values = {
url: String,
paramName: String,
maxFiles: { type: Number, default: null },
maxFilesize: { type: Number, default: 256 },
acceptedFiles: { type: String, default: null },
addRemoveLinks: { type: Boolean, default: true },
dictFileTooBig: { type: String, default: "File is too big ({{filesize}}MiB). Max filesize: {{maxFilesize}}MiB." },
dictCancelUpload: { type: String, default: "Cancel upload" },
dictCancelUploadConfirmation: { type: String, default: "Are you sure you want to cancel this upload?" },
dictRemoveFile: { type: String, default: "Remove file" },
dictMaxFilesExceeded: { type: String, default: "You can not upload any more files." }
}
connect() {
this.dropZone = this.#createDropZone()
this.dropZone.on("addedfile", it => setTimeout(() => it.accepted && this.#upload(it), 0))
this.dropZone.on("removedfile", it => it.uploader?.remove())
this.dropZone.on("canceled", it => it.uploader?.abort())
}
disconnect() {
this.dropZone.destroy()
}
#createDropZone() {
return new Dropzone(this.element, {
autoQueue: false,
url: this.urlValue,
paramName: this.paramNameValue,
maxFiles: this.maxFilesValue,
maxFilesize: this.maxFilesizeValue,
acceptedFiles: this.acceptedFilesValue,
addRemoveLinks: this.addRemoveLinksValue,
dictFileTooBig: this.dictFileTooBigValue,
dictCancelUpload: this.dictCancelUploadValue,
dictCancelUploadConfirmation: this.dictCancelUploadConfirmationValue,
dictRemoveFile: this.dictRemoveFileValue,
dictMaxFilesExceeded: this.dictMaxFilesExceededValue
})
}
#upload(file) {
new Uploader(file, this.dropZone).start()
}
}
class Uploader {
constructor(file, dropZone) {
this.file = file; this.dropZone = dropZone; this.file.uploader = this;
}
start() {
this.#createDirectUpload((error, { signed_id }) => {
if (error) {
this.#emitDropzoneError(error)
} else {
this.#createHiddenInput(signed_id)
this.#emitDropzoneSuccess()
}
})
}
abort() {
this.xhr?.abort()
}
remove() {
this.hiddenInput?.parentNode?.removeChild(this.hiddenInput)
}
directUploadWillStoreFileWithXHR(xhr) {
this.xhr = xhr
this.#bindProgress()
this.#emitDropzoneProcessing()
}
#createDirectUpload(callback) {
new DirectUpload(this.file, this.dropZone.options.url, this).create(callback)
}
#createHiddenInput(signed_id) {
this.hiddenInput = document.createElement("input")
this.hiddenInput.type = "hidden"
this.hiddenInput.name = this.dropZone.options.paramName
this.hiddenInput.value = signed_id
this.dropZone.element.appendChild(this.hiddenInput)
}
#bindProgress() {
this.xhr.upload.addEventListener("progress", it => this.#updateProgress(it))
}
#updateProgress(event) {
this.#progress.style.width = `${Math.round((event.loaded / event.total) * 100)}%`
}
#emitDropzoneProcessing() {
this.file.status = Dropzone.PROCESSING
this.dropZone.emit("processing", this.file)
}
#emitDropzoneError(error) {
this.file.status = Dropzone.ERROR
this.dropZone.emit("error", this.file, error)
this.dropZone.emit("complete", this.file)
}
#emitDropzoneSuccess() {
this.file.status = Dropzone.SUCCESS
this.dropZone.emit("success", this.file)
this.dropZone.emit("complete", this.file)
}
get #progress() {
return this.file.previewTemplate.querySelector(".dz-upload")
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment