venus_media_library 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +31 -0
- data/MIT-LICENSE +20 -0
- data/README.md +342 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/venus_media_library/venus_media_library.js +171 -0
- data/app/assets/stylesheets/venus_media_library/application.css +15 -0
- data/app/assets/stylesheets/venus_media_library/picker.css +82 -0
- data/app/controllers/venus_media_library/application_controller.rb +19 -0
- data/app/controllers/venus_media_library/images_controller.rb +82 -0
- data/app/controllers/venus_media_library/pickers_controller.rb +22 -0
- data/app/helpers/venus_media_library/application_helper.rb +4 -0
- data/app/helpers/venus_media_library/images_helper.rb +46 -0
- data/app/helpers/venus_media_library/picker_helper.rb +144 -0
- data/app/jobs/venus_media_library/application_job.rb +4 -0
- data/app/mailers/venus_media_library/application_mailer.rb +6 -0
- data/app/models/venus_media_library/application_record.rb +5 -0
- data/app/views/layouts/venus_media_library/application.html.erb +17 -0
- data/app/views/venus_media_library/images/_image.html.erb +10 -0
- data/app/views/venus_media_library/images/index.html.erb +23 -0
- data/app/views/venus_media_library/pickers/_modal.html.erb +16 -0
- data/app/views/venus_media_library/pickers/_picker.html.erb +27 -0
- data/config/routes.rb +9 -0
- data/lib/tasks/venus_media_library_tasks.rake +4 -0
- data/lib/venus_media_library/engine.rb +25 -0
- data/lib/venus_media_library/version.rb +3 -0
- data/lib/venus_media_library.rb +73 -0
- metadata +104 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module VenusMediaLibrary
|
|
2
|
+
class ApplicationController < ActionController::Base
|
|
3
|
+
# Run the host-configured access gate (if any) before every engine action.
|
|
4
|
+
# The engine ships open; the host restricts the picker/upload endpoints by
|
|
5
|
+
# setting VenusMediaLibrary.configuration.authenticate_with to a proc.
|
|
6
|
+
before_action :authenticate_venus_media_library_access!
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def authenticate_venus_media_library_access!
|
|
11
|
+
gate = VenusMediaLibrary.configuration.authenticate_with
|
|
12
|
+
return unless gate.respond_to?(:call)
|
|
13
|
+
|
|
14
|
+
# instance_exec so the proc runs in this controller's context and can use
|
|
15
|
+
# host helpers (current_user, redirect_to, head, main_app, ...).
|
|
16
|
+
instance_exec(&gate)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module VenusMediaLibrary
|
|
2
|
+
# Lists and uploads images backed by Active Storage. Storage-agnostic: it uses
|
|
3
|
+
# whatever service the host app configures (Disk in dev, S3 in prod, ...).
|
|
4
|
+
class ImagesController < ApplicationController
|
|
5
|
+
include VenusMediaLibrary::ImagesHelper
|
|
6
|
+
|
|
7
|
+
# GET /images
|
|
8
|
+
# Lists image blobs, newest first, with simple offset pagination.
|
|
9
|
+
# Responds with an HTML grid or a JSON payload for the picker.
|
|
10
|
+
def index
|
|
11
|
+
@page = [ params.fetch(:page, 1).to_i, 1 ].max
|
|
12
|
+
@per_page = per_page
|
|
13
|
+
offset = (@page - 1) * @per_page
|
|
14
|
+
|
|
15
|
+
scope = image_blobs
|
|
16
|
+
@total_count = scope.count
|
|
17
|
+
@blobs = scope.order(created_at: :desc).offset(offset).limit(@per_page).to_a
|
|
18
|
+
@has_more = offset + @blobs.size < @total_count
|
|
19
|
+
@images = @blobs.map { |blob| ml_image_payload(blob) }
|
|
20
|
+
|
|
21
|
+
respond_to do |format|
|
|
22
|
+
format.html # index.html.erb
|
|
23
|
+
format.json do
|
|
24
|
+
render json: { images: @images, page: @page, has_more: @has_more, total: @total_count }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# POST /images
|
|
30
|
+
# Accepts an uploaded file and stores it via Active Storage.
|
|
31
|
+
def create
|
|
32
|
+
uploaded = params[:file] || params.dig(:image, :file)
|
|
33
|
+
|
|
34
|
+
if uploaded.blank?
|
|
35
|
+
return respond_error("No file was uploaded.", :unprocessable_entity)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
unless allowed_content_type?(uploaded.content_type)
|
|
39
|
+
return respond_error("Content type #{uploaded.content_type} is not allowed.", :unprocessable_entity)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
blob = ActiveStorage::Blob.create_and_upload!(
|
|
43
|
+
io: uploaded.tempfile,
|
|
44
|
+
filename: uploaded.original_filename,
|
|
45
|
+
content_type: uploaded.content_type,
|
|
46
|
+
service_name: VenusMediaLibrary.configuration.storage_service
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
respond_to do |format|
|
|
50
|
+
format.json { render json: ml_image_payload(blob), status: :created }
|
|
51
|
+
format.html { redirect_to images_path }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def image_blobs
|
|
58
|
+
ActiveStorage::Blob.where("content_type LIKE ?", "image/%")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def per_page
|
|
62
|
+
value = params[:per_page].presence&.to_i
|
|
63
|
+
value && value.positive? ? value : VenusMediaLibrary.configuration.per_page
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def allowed_content_type?(content_type)
|
|
67
|
+
return false if content_type.blank?
|
|
68
|
+
|
|
69
|
+
allowed = VenusMediaLibrary.configuration.allowed_content_types
|
|
70
|
+
return content_type.start_with?("image/") if allowed.blank?
|
|
71
|
+
|
|
72
|
+
allowed.include?(content_type) || content_type.start_with?("image/")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def respond_error(message, status)
|
|
76
|
+
respond_to do |format|
|
|
77
|
+
format.json { render json: { error: message }, status: status }
|
|
78
|
+
format.html { redirect_to images_path, alert: message }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module VenusMediaLibrary
|
|
2
|
+
# Renders the picker modal body as a Turbo Frame. `target` is the DOM id of the
|
|
3
|
+
# host input that a chosen image URL / signed_id should be written into.
|
|
4
|
+
class PickersController < ApplicationController
|
|
5
|
+
include VenusMediaLibrary::ImagesHelper
|
|
6
|
+
|
|
7
|
+
def show
|
|
8
|
+
@target = params[:target].to_s
|
|
9
|
+
@page = [ params.fetch(:page, 1).to_i, 1 ].max
|
|
10
|
+
per = VenusMediaLibrary.configuration.per_page
|
|
11
|
+
offset = (@page - 1) * per
|
|
12
|
+
|
|
13
|
+
scope = ActiveStorage::Blob.where("content_type LIKE ?", "image/%")
|
|
14
|
+
@has_more = offset + per < scope.count
|
|
15
|
+
@images = scope.order(created_at: :desc).offset(offset).limit(per).map { |blob| ml_image_payload(blob) }
|
|
16
|
+
|
|
17
|
+
render partial: "venus_media_library/pickers/picker",
|
|
18
|
+
locals: { target: @target, images: @images, page: @page, has_more: @has_more },
|
|
19
|
+
layout: false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module VenusMediaLibrary
|
|
2
|
+
# Shared image URL/payload logic. Included in the views (automatically) and in
|
|
3
|
+
# ImagesController so the HTML grid and the JSON response stay in sync.
|
|
4
|
+
module ImagesHelper
|
|
5
|
+
# A serializable description of a blob used by the JSON API and the tiles.
|
|
6
|
+
def ml_image_payload(blob)
|
|
7
|
+
{
|
|
8
|
+
id: blob.id,
|
|
9
|
+
signed_id: blob.signed_id,
|
|
10
|
+
filename: blob.filename.to_s,
|
|
11
|
+
content_type: blob.content_type,
|
|
12
|
+
byte_size: blob.byte_size,
|
|
13
|
+
created_at: blob.created_at,
|
|
14
|
+
url: ml_blob_url(blob),
|
|
15
|
+
thumb_url: ml_thumb_url(blob)
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Permanent URL to the original file. Active Storage route helpers live on the
|
|
20
|
+
# host app, so they are reached through the `main_app` proxy. With
|
|
21
|
+
# url_type = :proxy the URL streams through Rails (works for private buckets
|
|
22
|
+
# and external crawlers); the default :redirect 302s to the storage URL.
|
|
23
|
+
def ml_blob_url(blob)
|
|
24
|
+
if VenusMediaLibrary.configuration.url_type == :proxy
|
|
25
|
+
main_app.rails_storage_proxy_url(blob)
|
|
26
|
+
else
|
|
27
|
+
main_app.rails_blob_url(blob)
|
|
28
|
+
end
|
|
29
|
+
rescue StandardError
|
|
30
|
+
main_app.rails_blob_path(blob)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Small variant used for the grid thumbnail. Falls back to the original URL
|
|
34
|
+
# when the blob can't be variated (e.g. SVG).
|
|
35
|
+
def ml_thumb_url(blob)
|
|
36
|
+
if blob.variable?
|
|
37
|
+
w, h = VenusMediaLibrary.configuration.thumbnail_size
|
|
38
|
+
main_app.rails_representation_url(blob.variant(resize_to_limit: [ w, h ]))
|
|
39
|
+
else
|
|
40
|
+
ml_blob_url(blob)
|
|
41
|
+
end
|
|
42
|
+
rescue StandardError
|
|
43
|
+
ml_blob_url(blob)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
module VenusMediaLibrary
|
|
2
|
+
# Host-facing helper. Included into the host app's views by the engine (see
|
|
3
|
+
# VenusMediaLibrary::Engine), so apps can drop the picker next to any URL field.
|
|
4
|
+
#
|
|
5
|
+
# <%= form_with model: @page do |f| %>
|
|
6
|
+
# <%= media_picker_field f, :og_image %>
|
|
7
|
+
# <% end %>
|
|
8
|
+
#
|
|
9
|
+
# Renders a text input (the field the host already had) plus a "Choose from
|
|
10
|
+
# library" button wired to open the modal. Selecting an image writes its URL
|
|
11
|
+
# into the input; a hidden `<field>_signed_id` input also receives the blob's
|
|
12
|
+
# signed id so the host can attach it if desired.
|
|
13
|
+
module PickerHelper
|
|
14
|
+
# form - a form builder (form_with / form_for)
|
|
15
|
+
# field - the attribute holding the image URL (e.g. :og_image)
|
|
16
|
+
# opts:
|
|
17
|
+
# :label - button label (default "Choose from library")
|
|
18
|
+
# :placeholder - text input placeholder
|
|
19
|
+
# :signed_id_field - name for the hidden signed-id input
|
|
20
|
+
# (default "<field>_signed_id"); pass false to skip
|
|
21
|
+
# :input_html - extra HTML options merged into the text input
|
|
22
|
+
# :class - wrapper CSS class
|
|
23
|
+
def media_picker_field(form, field, **opts)
|
|
24
|
+
input_id = "#{form.object_name}_#{field}".parameterize.tr("-", "_")
|
|
25
|
+
label = opts.fetch(:label, "Choose from library")
|
|
26
|
+
wrapper = opts.fetch(:class, "ml-field")
|
|
27
|
+
input_html = {
|
|
28
|
+
id: input_id,
|
|
29
|
+
class: "ml-field__input",
|
|
30
|
+
placeholder: opts[:placeholder]
|
|
31
|
+
}.merge(opts[:input_html] || {})
|
|
32
|
+
|
|
33
|
+
signed_id_field = opts.fetch(:signed_id_field, "#{field}_signed_id")
|
|
34
|
+
|
|
35
|
+
# Endpoint the JS uses to load the picker frame for this field.
|
|
36
|
+
picker_src = venus_media_library.picker_path(target: input_id)
|
|
37
|
+
|
|
38
|
+
content_tag(:div, class: wrapper, data: { ml_field: input_id }) do
|
|
39
|
+
parts = []
|
|
40
|
+
parts << form.text_field(field, input_html)
|
|
41
|
+
|
|
42
|
+
if signed_id_field
|
|
43
|
+
hidden_id = "#{input_id}_signed_id"
|
|
44
|
+
parts << form.hidden_field(signed_id_field, id: hidden_id, data: { ml_signed_id_for: input_id })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
parts << button_tag(label,
|
|
48
|
+
type: "button",
|
|
49
|
+
class: "ml-btn ml-btn--primary ml-field__button",
|
|
50
|
+
data: { ml_open: true, ml_target: input_id, ml_src: picker_src })
|
|
51
|
+
|
|
52
|
+
safe_join(parts)
|
|
53
|
+
end + venus_media_library_modal_shell
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Attach-mode picker. Use this for an Active Storage attachment (has_one_attached)
|
|
57
|
+
# rather than a URL column: the picker writes the chosen blob's signed_id into a
|
|
58
|
+
# hidden field NAMED FOR THE ATTACHMENT (e.g. product[cover]), so Rails attaches
|
|
59
|
+
# that blob on save.
|
|
60
|
+
#
|
|
61
|
+
# <%= form_with model: @product do |f| %>
|
|
62
|
+
# <%= media_attach_field f, :cover %>
|
|
63
|
+
# <% end %>
|
|
64
|
+
#
|
|
65
|
+
# The visible input is a read-only preview (it is NOT submitted). The hidden
|
|
66
|
+
# attachment field is `disabled` until an image is picked, because submitting an
|
|
67
|
+
# empty string for a has_one_attached would DETACH the current file; the JS sets
|
|
68
|
+
# the signed_id and enables the field on selection.
|
|
69
|
+
#
|
|
70
|
+
# form - a form builder bound to the record
|
|
71
|
+
# attachment - the has_one_attached name (e.g. :cover)
|
|
72
|
+
# opts:
|
|
73
|
+
# :label - button label (default "Choose from library")
|
|
74
|
+
# :placeholder - preview input placeholder
|
|
75
|
+
# :input_html - extra HTML options merged into the preview input
|
|
76
|
+
# :class - wrapper CSS class
|
|
77
|
+
def media_attach_field(form, attachment, **opts)
|
|
78
|
+
input_id = "#{form.object_name}_#{attachment}".parameterize.tr("-", "_")
|
|
79
|
+
label = opts.fetch(:label, "Choose from library")
|
|
80
|
+
wrapper = opts.fetch(:class, "ml-field")
|
|
81
|
+
|
|
82
|
+
preview_html = {
|
|
83
|
+
id: input_id,
|
|
84
|
+
class: "ml-field__input",
|
|
85
|
+
type: "text",
|
|
86
|
+
readonly: true,
|
|
87
|
+
value: ml_attachment_preview_value(form.object, attachment),
|
|
88
|
+
placeholder: opts[:placeholder]
|
|
89
|
+
}.merge(opts[:input_html] || {})
|
|
90
|
+
|
|
91
|
+
picker_src = venus_media_library.picker_path(target: input_id)
|
|
92
|
+
|
|
93
|
+
content_tag(:div, class: wrapper, data: { ml_field: input_id }) do
|
|
94
|
+
parts = []
|
|
95
|
+
|
|
96
|
+
# Read-only preview of the current/chosen image. No name -> not submitted.
|
|
97
|
+
parts << tag.input(**preview_html)
|
|
98
|
+
|
|
99
|
+
# Hidden field named for the attachment. Disabled (so a blank value can't
|
|
100
|
+
# detach on save) until the JS writes a signed_id and enables it.
|
|
101
|
+
parts << form.hidden_field(attachment,
|
|
102
|
+
value: "",
|
|
103
|
+
disabled: true,
|
|
104
|
+
id: "#{input_id}_signed_id",
|
|
105
|
+
data: { ml_signed_id_for: input_id })
|
|
106
|
+
|
|
107
|
+
parts << button_tag(label,
|
|
108
|
+
type: "button",
|
|
109
|
+
class: "ml-btn ml-btn--primary ml-field__button",
|
|
110
|
+
data: { ml_open: true, ml_target: input_id, ml_src: picker_src })
|
|
111
|
+
|
|
112
|
+
safe_join(parts)
|
|
113
|
+
end + venus_media_library_modal_shell
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Best-effort URL for the currently attached file, used to seed the preview.
|
|
117
|
+
# Rescues to the filename (or blank) so a missing host/route never breaks the form.
|
|
118
|
+
def ml_attachment_preview_value(record, attachment)
|
|
119
|
+
attached = record.respond_to?(attachment) && record.public_send(attachment)
|
|
120
|
+
return "" unless attached && attached.respond_to?(:attached?) && attached.attached?
|
|
121
|
+
|
|
122
|
+
if VenusMediaLibrary.configuration.url_type == :proxy
|
|
123
|
+
rails_storage_proxy_url(attached)
|
|
124
|
+
else
|
|
125
|
+
rails_blob_url(attached)
|
|
126
|
+
end
|
|
127
|
+
rescue StandardError
|
|
128
|
+
begin
|
|
129
|
+
attached && attached.attached? ? attached.filename.to_s : ""
|
|
130
|
+
rescue StandardError
|
|
131
|
+
""
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Renders the shared modal shell once per page. The picker frame is lazy
|
|
136
|
+
# loaded into it when a button is clicked.
|
|
137
|
+
def venus_media_library_modal_shell
|
|
138
|
+
return "".html_safe if @_venus_media_library_modal_rendered
|
|
139
|
+
|
|
140
|
+
@_venus_media_library_modal_rendered = true
|
|
141
|
+
render partial: "venus_media_library/pickers/modal"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Media library</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<%= stylesheet_link_tag "venus_media_library/application", media: "all" %>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
|
|
14
|
+
<%= yield %>
|
|
15
|
+
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<%# Renders one selectable image tile. `image` is a payload hash from the controller. %>
|
|
2
|
+
<button type="button"
|
|
3
|
+
class="ml-tile"
|
|
4
|
+
data-ml-signed-id="<%= image[:signed_id] %>"
|
|
5
|
+
data-ml-url="<%= image[:url] %>"
|
|
6
|
+
data-ml-filename="<%= image[:filename] %>"
|
|
7
|
+
title="<%= image[:filename] %>">
|
|
8
|
+
<img src="<%= image[:thumb_url] %>" alt="<%= image[:filename] %>" loading="lazy">
|
|
9
|
+
<span class="ml-tile__name"><%= image[:filename] %></span>
|
|
10
|
+
</button>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<div class="ml-library">
|
|
2
|
+
<header class="ml-library__header">
|
|
3
|
+
<h1>Media Library</h1>
|
|
4
|
+
<p class="ml-library__count"><%= @total_count %> image<%= "s" unless @total_count == 1 %></p>
|
|
5
|
+
</header>
|
|
6
|
+
|
|
7
|
+
<% if @images.empty? %>
|
|
8
|
+
<p class="ml-empty">No images yet. Upload one to get started.</p>
|
|
9
|
+
<% else %>
|
|
10
|
+
<div class="ml-grid" data-ml-grid>
|
|
11
|
+
<%= render partial: "venus_media_library/images/image", collection: @images, as: :image %>
|
|
12
|
+
</div>
|
|
13
|
+
<% end %>
|
|
14
|
+
|
|
15
|
+
<nav class="ml-pagination">
|
|
16
|
+
<% if @page > 1 %>
|
|
17
|
+
<%= link_to "← Newer", images_path(page: @page - 1), class: "ml-btn" %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% if @has_more %>
|
|
20
|
+
<%= link_to "Older →", images_path(page: @page + 1), class: "ml-btn" %>
|
|
21
|
+
<% end %>
|
|
22
|
+
</nav>
|
|
23
|
+
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<%# Shared modal shell. Rendered once per page by media_picker_field. The picker
|
|
2
|
+
Turbo Frame is loaded into #venus_media_library_picker on open. %>
|
|
3
|
+
<div id="ml-modal" class="ml-modal" hidden aria-hidden="true" role="dialog" aria-modal="true" aria-label="Media Library">
|
|
4
|
+
<div class="ml-modal__backdrop" data-ml-close></div>
|
|
5
|
+
<div class="ml-modal__dialog">
|
|
6
|
+
<div class="ml-modal__header">
|
|
7
|
+
<h2 class="ml-modal__title">Media Library</h2>
|
|
8
|
+
<button type="button" class="ml-modal__close" data-ml-close aria-label="Close">×</button>
|
|
9
|
+
</div>
|
|
10
|
+
<div class="ml-modal__body">
|
|
11
|
+
<turbo-frame id="venus_media_library_picker">
|
|
12
|
+
<p class="ml-empty">Loading…</p>
|
|
13
|
+
</turbo-frame>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<%# Picker body loaded into the modal as a Turbo Frame. `target` is the id of the
|
|
2
|
+
host input to receive the chosen image. %>
|
|
3
|
+
<turbo-frame id="venus_media_library_picker" class="ml-picker" data-ml-target="<%= target %>">
|
|
4
|
+
<div class="ml-picker__toolbar">
|
|
5
|
+
<label class="ml-btn ml-btn--primary">
|
|
6
|
+
Upload image
|
|
7
|
+
<input type="file" accept="image/*" hidden data-ml-upload>
|
|
8
|
+
</label>
|
|
9
|
+
<span class="ml-picker__hint" data-ml-status></span>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<% if images.empty? %>
|
|
13
|
+
<p class="ml-empty">No images yet. Upload one above.</p>
|
|
14
|
+
<% else %>
|
|
15
|
+
<div class="ml-grid" data-ml-grid>
|
|
16
|
+
<%= render partial: "venus_media_library/images/image", collection: images, as: :image %>
|
|
17
|
+
</div>
|
|
18
|
+
<% end %>
|
|
19
|
+
|
|
20
|
+
<% if has_more %>
|
|
21
|
+
<div class="ml-picker__more">
|
|
22
|
+
<a href="<%= picker_path(target: target, page: page + 1) %>"
|
|
23
|
+
data-turbo-frame="venus_media_library_picker"
|
|
24
|
+
class="ml-btn">Load older</a>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
27
|
+
</turbo-frame>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
VenusMediaLibrary::Engine.routes.draw do
|
|
2
|
+
# Image library: index lists images (HTML grid + JSON), create handles uploads.
|
|
3
|
+
resources :images, only: [ :index, :create ]
|
|
4
|
+
|
|
5
|
+
# Turbo Frame that renders the picker modal body for a given target field.
|
|
6
|
+
get "picker", to: "pickers#show", as: :picker
|
|
7
|
+
|
|
8
|
+
root to: "images#index"
|
|
9
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module VenusMediaLibrary
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace VenusMediaLibrary
|
|
4
|
+
|
|
5
|
+
# Make the host-facing picker helper available in the host app's views so
|
|
6
|
+
# apps can call `media_picker_field` without any manual include.
|
|
7
|
+
initializer "venus_media_library.host_helpers" do
|
|
8
|
+
ActiveSupport.on_load(:action_view) do
|
|
9
|
+
include VenusMediaLibrary::PickerHelper
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Ensure the engine's JS is discoverable by Propshaft/Sprockets in the host.
|
|
14
|
+
initializer "venus_media_library.assets" do |app|
|
|
15
|
+
if app.config.respond_to?(:assets)
|
|
16
|
+
app.config.assets.paths << root.join("app/assets/javascripts")
|
|
17
|
+
app.config.assets.precompile += %w[venus_media_library/venus_media_library.js]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config.generators do |g|
|
|
22
|
+
g.test_framework :rspec
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "venus_media_library/version"
|
|
2
|
+
require "venus_media_library/engine"
|
|
3
|
+
|
|
4
|
+
module VenusMediaLibrary
|
|
5
|
+
# Host apps configure the engine through `VenusMediaLibrary.configure`:
|
|
6
|
+
#
|
|
7
|
+
# VenusMediaLibrary.configure do |config|
|
|
8
|
+
# config.allowed_content_types = %w[image/png image/jpeg image/webp]
|
|
9
|
+
# config.thumbnail_size = [ 300, 300 ]
|
|
10
|
+
# config.per_page = 40
|
|
11
|
+
# config.storage_service = :amazon
|
|
12
|
+
# end
|
|
13
|
+
class Configuration
|
|
14
|
+
# Content types accepted by the uploader and shown in the library grid.
|
|
15
|
+
# Every entry is matched literally; the index additionally shows any blob
|
|
16
|
+
# whose content_type starts with "image/".
|
|
17
|
+
attr_accessor :allowed_content_types
|
|
18
|
+
|
|
19
|
+
# [width, height] used for the grid thumbnail variant.
|
|
20
|
+
attr_accessor :thumbnail_size
|
|
21
|
+
|
|
22
|
+
# Number of images per page in the index.
|
|
23
|
+
attr_accessor :per_page
|
|
24
|
+
|
|
25
|
+
# Optional Active Storage service name to attach uploads to. When nil the
|
|
26
|
+
# host app's default service (`config.active_storage.service`) is used, so
|
|
27
|
+
# the engine stays storage-agnostic (Disk in dev, S3 in prod, etc.).
|
|
28
|
+
attr_accessor :storage_service
|
|
29
|
+
|
|
30
|
+
# How image URLs in the payload/picker are built:
|
|
31
|
+
# :redirect (default) -> rails_blob_url (302s to the storage URL)
|
|
32
|
+
# :proxy -> rails_storage_proxy_url (Rails streams the bytes)
|
|
33
|
+
# Use :proxy when the bucket is private and images must be publicly fetchable
|
|
34
|
+
# by external crawlers (e.g. an og:image on a private S3 bucket in proxy mode).
|
|
35
|
+
# Still storage-agnostic: both are Active Storage route helpers.
|
|
36
|
+
attr_accessor :url_type
|
|
37
|
+
|
|
38
|
+
# Optional access gate. A proc run in the engine controller's context before
|
|
39
|
+
# every action (via instance_exec), so it can call host helpers like
|
|
40
|
+
# `current_user`, `redirect_to`, or `head`. Left nil the engine is open; the
|
|
41
|
+
# host sets this to restrict the picker/upload endpoints to admins.
|
|
42
|
+
#
|
|
43
|
+
# VenusMediaLibrary.configure do |c|
|
|
44
|
+
# c.authenticate_with = -> { redirect_to main_app.root_path unless current_user&.admin? }
|
|
45
|
+
# end
|
|
46
|
+
attr_accessor :authenticate_with
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
@allowed_content_types = %w[image/png image/jpeg image/jpg image/gif image/webp image/svg+xml]
|
|
50
|
+
@thumbnail_size = [ 300, 300 ]
|
|
51
|
+
@per_page = 40
|
|
52
|
+
@storage_service = nil
|
|
53
|
+
@url_type = :redirect
|
|
54
|
+
@authenticate_with = nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
def configuration
|
|
60
|
+
@configuration ||= Configuration.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def configure
|
|
64
|
+
yield(configuration) if block_given?
|
|
65
|
+
configuration
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Test/host reset hook.
|
|
69
|
+
def reset_configuration!
|
|
70
|
+
@configuration = Configuration.new
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: venus_media_library
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Good Works On Earth
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: image_processing
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '1.12'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '1.12'
|
|
40
|
+
description: |-
|
|
41
|
+
Media Library is a mountable Rails engine that lists images stored in Active Storage
|
|
42
|
+
(S3, Google Cloud Storage, Disk — whatever the host app configures) and gives content
|
|
43
|
+
editors a modal image picker. Drop `media_picker_field` next to any URL field (such as
|
|
44
|
+
an og:image field) so editors can select an existing image or upload a new one instead
|
|
45
|
+
of typing a path. Storage-agnostic, namespaced, and publishable.
|
|
46
|
+
email:
|
|
47
|
+
- hello@goodworksonearth.org
|
|
48
|
+
executables: []
|
|
49
|
+
extensions: []
|
|
50
|
+
extra_rdoc_files: []
|
|
51
|
+
files:
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- MIT-LICENSE
|
|
54
|
+
- README.md
|
|
55
|
+
- Rakefile
|
|
56
|
+
- app/assets/javascripts/venus_media_library/venus_media_library.js
|
|
57
|
+
- app/assets/stylesheets/venus_media_library/application.css
|
|
58
|
+
- app/assets/stylesheets/venus_media_library/picker.css
|
|
59
|
+
- app/controllers/venus_media_library/application_controller.rb
|
|
60
|
+
- app/controllers/venus_media_library/images_controller.rb
|
|
61
|
+
- app/controllers/venus_media_library/pickers_controller.rb
|
|
62
|
+
- app/helpers/venus_media_library/application_helper.rb
|
|
63
|
+
- app/helpers/venus_media_library/images_helper.rb
|
|
64
|
+
- app/helpers/venus_media_library/picker_helper.rb
|
|
65
|
+
- app/jobs/venus_media_library/application_job.rb
|
|
66
|
+
- app/mailers/venus_media_library/application_mailer.rb
|
|
67
|
+
- app/models/venus_media_library/application_record.rb
|
|
68
|
+
- app/views/layouts/venus_media_library/application.html.erb
|
|
69
|
+
- app/views/venus_media_library/images/_image.html.erb
|
|
70
|
+
- app/views/venus_media_library/images/index.html.erb
|
|
71
|
+
- app/views/venus_media_library/pickers/_modal.html.erb
|
|
72
|
+
- app/views/venus_media_library/pickers/_picker.html.erb
|
|
73
|
+
- config/routes.rb
|
|
74
|
+
- lib/tasks/venus_media_library_tasks.rake
|
|
75
|
+
- lib/venus_media_library.rb
|
|
76
|
+
- lib/venus_media_library/engine.rb
|
|
77
|
+
- lib/venus_media_library/version.rb
|
|
78
|
+
homepage: https://github.com/goodworksonearth/venus_media_library
|
|
79
|
+
licenses:
|
|
80
|
+
- MIT
|
|
81
|
+
metadata:
|
|
82
|
+
source_code_uri: https://github.com/goodworksonearth/venus_media_library
|
|
83
|
+
changelog_uri: https://github.com/goodworksonearth/venus_media_library/blob/main/CHANGELOG.md
|
|
84
|
+
bug_tracker_uri: https://github.com/goodworksonearth/venus_media_library/issues
|
|
85
|
+
rubygems_mfa_required: 'true'
|
|
86
|
+
rdoc_options: []
|
|
87
|
+
require_paths:
|
|
88
|
+
- lib
|
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '3.2'
|
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '0'
|
|
99
|
+
requirements: []
|
|
100
|
+
rubygems_version: 3.6.9
|
|
101
|
+
specification_version: 4
|
|
102
|
+
summary: A mountable Rails engine that turns Active Storage into a browsable media
|
|
103
|
+
library with an image picker.
|
|
104
|
+
test_files: []
|