uploads: allow uploading multiple files from your computer at once.
Allow uploading multiple files from your computer at once. The maximum limit is 100 files at once. There is still a 50MB size limit that applies to the whole upload. This limit is at the Nginx level. The upload widget no longer shows a thumbnail preview of the uploaded file. This is because there isn't room for it in a multi-file upload, and because the next page will show a preview anyway after the files are uploaded. Direct file uploads are processed synchronously, so they may be slow. API change: the `POST /uploads` endpoint now expects the param to be `upload[files][]`, not `upload[file]`.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
# A component for uploading files to Danbooru. Used on the /uploads/new page.
|
||||
class FileUploadComponent < ApplicationComponent
|
||||
attr_reader :url, :referer_url, :drop_target, :max_file_size
|
||||
attr_reader :url, :referer_url, :drop_target, :max_file_size, :max_files_per_upload
|
||||
|
||||
# @param url [String] Optional. The URL to upload. If present, the URL field
|
||||
# will be prefilled in the widget and the upload will be immediately triggered.
|
||||
@@ -11,11 +11,13 @@ class FileUploadComponent < ApplicationComponent
|
||||
# events. If "body", then files can be dropped anywhere on the page, not
|
||||
# just on the upload widget itself.
|
||||
# @param max_file_size [Integer] The max size in bytes of an upload.
|
||||
def initialize(url: nil, referer_url: nil, drop_target: nil, max_file_size: Danbooru.config.max_file_size)
|
||||
# @param max_files_per_upload [Integer] The maximum number of files per upload.
|
||||
def initialize(url: nil, referer_url: nil, drop_target: nil, max_file_size: Danbooru.config.max_file_size, max_files_per_upload: Upload::MAX_FILES_PER_UPLOAD)
|
||||
@url = url
|
||||
@referer_url = referer_url
|
||||
@drop_target = drop_target
|
||||
@max_file_size = max_file_size
|
||||
@max_files_per_upload = max_files_per_upload
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div class="file-upload-component relative card w-md max-w-full" data-drop-target="<%= j drop_target %>" data-max-file-size="<%= j max_file_size %>">
|
||||
<div class="file-upload-component relative card w-md max-w-full" data-drop-target="<%= j drop_target %>" data-max-file-size="<%= j max_file_size %>" data-max-files-per-upload="<%= j max_files_per_upload %>">
|
||||
<%= simple_form_for(Upload.new, url: uploads_path(format: :json), html: { class: "flex flex-col", autocomplete: "off" }, remote: true) do |f| %>
|
||||
<%= f.input :file, as: :file, wrapper_html: { class: "hidden" } %>
|
||||
<%= f.input :files, as: :file, wrapper_html: { class: "hidden" }, input_html: { "multiple": max_files_per_upload > 1 } %>
|
||||
|
||||
<div class="dropzone-container input flex flex-col text-center items-center justify-center rounded-t-lg cursor-pointer p-4">
|
||||
<div class="dropzone-container input text-center rounded-t-lg cursor-pointer p-4 max-h-360px thin-scrollbar">
|
||||
<div class="dropzone-hint py-4">
|
||||
<div>Choose file or drag image here</div>
|
||||
<div class="hint">Max size: <%= number_to_human_size(Danbooru.config.max_file_size) %>.</div>
|
||||
@@ -19,21 +19,17 @@
|
||||
<%= spinner_icon(class: "hidden animate-spin absolute inset-0 m-auto h-12 link-color") %>
|
||||
|
||||
<template class="dropzone-preview-template">
|
||||
<div class="dz-preview dz-file-preview flex flex-col text-center space-y-4">
|
||||
<img class="object-contain px-8 max-h-360px max-w-full" data-dz-thumbnail>
|
||||
|
||||
<div class="dz-details">
|
||||
<div class="dz-filename break-all">
|
||||
<span data-dz-name></span>
|
||||
</div>
|
||||
<div class="dz-size" data-dz-size></div>
|
||||
<div class="dz-preview dz-file-preview flex flex-col text-center">
|
||||
<div class="dz-details text-xxs">
|
||||
<span class="dz-filename break-all" data-dz-name></span>,
|
||||
<span class="dz-size" data-dz-size></span>
|
||||
</div>
|
||||
|
||||
<div class="dz-progress absolute w-full h-1">
|
||||
<div class="dz-upload h-1" data-dz-uploadprogress></div>
|
||||
</div>
|
||||
|
||||
<div class="dz-error-message" data-dz-errormessage></div>
|
||||
<div class="hidden dz-error-message" data-dz-errormessage></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -31,4 +31,8 @@
|
||||
background-color: var(--uploads-dropzone-progress-bar-foreground-color);
|
||||
}
|
||||
}
|
||||
|
||||
&.dz-error .dz-error-message {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,16 +33,19 @@ export default class FileUploadComponent {
|
||||
|
||||
let dropzone = new Dropzone(this.$dropTarget.get(0), {
|
||||
url: "/uploads.json",
|
||||
paramName: "upload[file]",
|
||||
paramName: "upload[files]",
|
||||
clickable: this.$dropzone.get(0),
|
||||
previewsContainer: this.$dropzone.get(0),
|
||||
thumbnailHeight: null,
|
||||
thumbnailWidth: null,
|
||||
addRemoveLinks: false,
|
||||
maxFiles: 1,
|
||||
parallelUploads: this.maxFiles,
|
||||
maxFiles: this.maxFiles,
|
||||
maxFilesize: this.maxFileSize,
|
||||
maxThumbnailFilesize: this.maxFileSize,
|
||||
timeout: 0,
|
||||
uploadMultiple: true,
|
||||
createImageThumbnails: false,
|
||||
acceptedFiles: "image/jpeg,image/png,image/gif,video/mp4,video/webm",
|
||||
previewTemplate: this.$component.find(".dropzone-preview-template").html(),
|
||||
});
|
||||
@@ -54,13 +57,6 @@ export default class FileUploadComponent {
|
||||
dropzone.on("addedfile", file => {
|
||||
this.$dropzone.removeClass("error");
|
||||
this.$dropzone.find(".dropzone-hint").hide();
|
||||
|
||||
// Remove all files except the file just added.
|
||||
dropzone.files.forEach(f => {
|
||||
if (f !== file) {
|
||||
dropzone.removeFile(f);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
dropzone.on("success", file => {
|
||||
@@ -70,7 +66,9 @@ export default class FileUploadComponent {
|
||||
});
|
||||
|
||||
dropzone.on("error", (file, msg) => {
|
||||
this.$dropzone.addClass("error");
|
||||
this.$dropzone.find(".dropzone-hint").show();
|
||||
dropzone.removeFile(file);
|
||||
Utility.error(msg);
|
||||
});
|
||||
|
||||
return dropzone;
|
||||
@@ -164,6 +162,10 @@ export default class FileUploadComponent {
|
||||
return Number(this.$component.attr("data-max-file-size")) / (1024 * 1024);
|
||||
}
|
||||
|
||||
get maxFiles() {
|
||||
return Number(this.$component.attr("data-max-files-per-upload"));
|
||||
}
|
||||
|
||||
// The element to listen for drag and drop events and paste events. By default,
|
||||
// it's the `.file-upload-component` element. If `data-drop-target` is the `body`
|
||||
// element, then you can drop images or paste URLs anywhere on the page.
|
||||
|
||||
@@ -4,7 +4,9 @@ class Upload < ApplicationRecord
|
||||
extend Memoist
|
||||
class Error < StandardError; end
|
||||
|
||||
attr_accessor :file
|
||||
MAX_FILES_PER_UPLOAD = 100
|
||||
|
||||
attr_accessor :files
|
||||
|
||||
belongs_to :uploader, class_name: "User"
|
||||
has_many :upload_media_assets, dependent: :destroy
|
||||
@@ -13,6 +15,7 @@ class Upload < ApplicationRecord
|
||||
|
||||
normalize :source, :normalize_source
|
||||
|
||||
validates :files, length: { maximum: MAX_FILES_PER_UPLOAD, message: "can't have more than #{MAX_FILES_PER_UPLOAD} files per upload" }
|
||||
validates :source, format: { with: %r{\Ahttps?://}i, message: "is not a valid URL" }, if: -> { source.present? }
|
||||
validates :referer_url, format: { with: %r{\Ahttps?://}i, message: "is not a valid URL" }, if: -> { referer_url.present? }
|
||||
validate :validate_file_and_source, on: :create
|
||||
@@ -55,9 +58,9 @@ class Upload < ApplicationRecord
|
||||
|
||||
concerning :ValidationMethods do
|
||||
def validate_file_and_source
|
||||
if file.present? && source.present?
|
||||
if files.present? && source.present?
|
||||
errors.add(:base, "Can't give both a file and a source")
|
||||
elsif file.blank? && source.blank?
|
||||
elsif files.blank? && source.blank?
|
||||
errors.add(:base, "No file or source given")
|
||||
end
|
||||
end
|
||||
@@ -86,8 +89,8 @@ class Upload < ApplicationRecord
|
||||
end
|
||||
|
||||
def async_process_upload!
|
||||
if file.present?
|
||||
ProcessUploadJob.perform_now(self)
|
||||
if files.present?
|
||||
process_upload!
|
||||
elsif source.present?
|
||||
ProcessUploadJob.perform_later(self)
|
||||
else
|
||||
@@ -98,12 +101,10 @@ class Upload < ApplicationRecord
|
||||
def process_upload!
|
||||
update!(status: "processing")
|
||||
|
||||
if file.present?
|
||||
media_file = MediaFile.open(file.tempfile)
|
||||
media_asset = MediaAsset.upload!(media_file)
|
||||
upload_media_asset = UploadMediaAsset.new(media_asset: media_asset, source_url: "file://#{file.original_filename}", status: "active")
|
||||
|
||||
update!(upload_media_assets: [upload_media_asset], status: "completed", media_asset_count: 1)
|
||||
if files.present?
|
||||
upload_media_assets = files.map do |_index, file|
|
||||
UploadMediaAsset.new(file: file.tempfile, source_url: "file://#{file.original_filename}")
|
||||
end
|
||||
elsif source.present?
|
||||
page_url = source_strategy.page_url
|
||||
image_urls = source_strategy.image_urls
|
||||
@@ -115,11 +116,11 @@ class Upload < ApplicationRecord
|
||||
upload_media_assets = image_urls.map do |image_url|
|
||||
UploadMediaAsset.new(source_url: image_url, page_url: page_url, media_asset: nil)
|
||||
end
|
||||
|
||||
update!(upload_media_assets: upload_media_assets, media_asset_count: upload_media_assets.size)
|
||||
else
|
||||
raise Error, "No file or source given" # Should never happen
|
||||
end
|
||||
|
||||
update!(upload_media_assets: upload_media_assets, media_asset_count: upload_media_assets.size)
|
||||
rescue Exception => e
|
||||
update!(status: "error", error: e.message)
|
||||
end
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
class UploadMediaAsset < ApplicationRecord
|
||||
extend Memoist
|
||||
|
||||
attr_accessor :file
|
||||
|
||||
belongs_to :upload
|
||||
belongs_to :media_asset, optional: true
|
||||
has_one :post, through: :media_asset
|
||||
@@ -62,16 +64,22 @@ class UploadMediaAsset < ApplicationRecord
|
||||
end
|
||||
|
||||
def async_process_upload!
|
||||
return if file_upload?
|
||||
ProcessUploadMediaAssetJob.perform_later(self)
|
||||
if file.present?
|
||||
process_upload!
|
||||
else
|
||||
ProcessUploadMediaAssetJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
|
||||
def process_upload!
|
||||
return if file_upload?
|
||||
update!(status: :processing)
|
||||
|
||||
strategy = Sources::Strategies.find(source_url)
|
||||
media_file = strategy.download_file!(source_url)
|
||||
if file.present?
|
||||
media_file = MediaFile.open(file)
|
||||
else
|
||||
media_file = source_strategy.download_file!(source_url)
|
||||
end
|
||||
|
||||
MediaAsset.upload!(media_file) do |media_asset|
|
||||
update!(media_asset: media_asset)
|
||||
end
|
||||
|
||||
@@ -18,6 +18,6 @@ class UploadPolicy < ApplicationPolicy
|
||||
end
|
||||
|
||||
def permitted_attributes
|
||||
%i[file source referer_url]
|
||||
[:source, :referer_url, files: {}]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ FactoryBot.define do
|
||||
status { "completed" }
|
||||
source { nil }
|
||||
media_asset_count { 1 }
|
||||
file { Rack::Test::UploadedFile.new("#{Rails.root}/test/files/test.jpg") }
|
||||
files { { "0" => Rack::Test::UploadedFile.new("#{Rails.root}/test/files/test.jpg") } }
|
||||
|
||||
upload_media_assets do
|
||||
[build(:upload_media_asset, media_asset: build(:media_asset, file: "test/files/test.jpg"), source_url: "file://test.jpg", status: "active")]
|
||||
|
||||
@@ -162,7 +162,7 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_no_difference("Upload.count") do
|
||||
file = File.open("test/files/test.jpg")
|
||||
source = "https://files.catbox.moe/om3tcw.webm"
|
||||
post_auth uploads_path(format: :json), @user, params: { upload: { file: file, source: source }}
|
||||
post_auth uploads_path(format: :json), @user, params: { upload: { files: { "0" => file }, source: source }}
|
||||
end
|
||||
|
||||
assert_response 422
|
||||
@@ -171,7 +171,7 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
should "fail if given an unsupported filetype" do
|
||||
file = Rack::Test::UploadedFile.new("test/files/ugoira.json")
|
||||
post_auth uploads_path(format: :json), @user, params: { upload: { file: file }}
|
||||
post_auth uploads_path(format: :json), @user, params: { upload: { files: { "0" => file } }}
|
||||
|
||||
assert_response 201
|
||||
assert_match("File is not an image or video", Upload.last.error)
|
||||
@@ -247,12 +247,9 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
|
||||
context "when re-uploading a media asset stuck in the 'processing' state" do
|
||||
should "mark the asset as failed" do
|
||||
asset = create(:media_asset, file: File.open("test/files/test.jpg"), status: "processing")
|
||||
file = Rack::Test::UploadedFile.new("test/files/test.jpg")
|
||||
create_upload!("test/files/test.jpg", user: @user)
|
||||
|
||||
post_auth uploads_path, @user, params: { upload: { file: file }}
|
||||
upload = Upload.last
|
||||
|
||||
assert_redirected_to upload
|
||||
assert_match("Upload failed, try again", upload.reload.error)
|
||||
assert_equal("failed", asset.reload.status)
|
||||
end
|
||||
@@ -287,6 +284,24 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
|
||||
# should_upload_successfully("test/files/compressed.swf")
|
||||
end
|
||||
|
||||
context "uploading multiple files from your computer" do
|
||||
should "work" do
|
||||
files = {
|
||||
"0" => Rack::Test::UploadedFile.new("test/files/test.jpg"),
|
||||
"1" => Rack::Test::UploadedFile.new("test/files/test.png"),
|
||||
"2" => Rack::Test::UploadedFile.new("test/files/test.gif"),
|
||||
}
|
||||
|
||||
post_auth uploads_path(format: :json), @user, params: { upload: { files: files }}
|
||||
|
||||
upload = Upload.last
|
||||
assert_response 201
|
||||
assert_equal("", upload.error.to_s)
|
||||
assert_equal("completed", upload.status)
|
||||
assert_equal(3, upload.media_asset_count)
|
||||
end
|
||||
end
|
||||
|
||||
context "uploading a file from a source" do
|
||||
should_upload_successfully("https://www.artstation.com/artwork/04XA4")
|
||||
should_upload_successfully("https://dantewontdie.artstation.com/projects/YZK5q")
|
||||
|
||||
@@ -7,7 +7,7 @@ module UploadTestHelper
|
||||
source = { source: source_or_file_path }
|
||||
else
|
||||
file = Rack::Test::UploadedFile.new(Rails.root.join(source_or_file_path))
|
||||
source = { file: file }
|
||||
source = { files: { "0" => file } }
|
||||
end
|
||||
|
||||
perform_enqueued_jobs do
|
||||
|
||||
Reference in New Issue
Block a user