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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8bc0532620557b83e5d9441d9d7d3c8b6908e07de77b902387156212c5f34dbb
|
|
4
|
+
data.tar.gz: e55f2d5bd5698921d1725718c5fa3c744a3ee365c78fa17a2fcde0886e195bb8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8a5b72c1b7b0ba5efbaacfc3e6f2e3fc98e2bfbf1248d2c102c171231b9967e2ce37bf39696a5fcdf596129fc0623c19bbf240292f5c6f5f682956218b99edcc
|
|
7
|
+
data.tar.gz: bd469adfdf85aec05d9dd5dd6a69cb5188b5c1354ded20a85e040133c6f3bad3cb2aeaed0722c55ac75f4e5b5f29454b52269c05684ab6fd46dd40d7f3141e9c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-08-15
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Mountable, isolated Rails engine namespaced under `VenusMediaLibrary`.
|
|
11
|
+
- `VenusMediaLibrary::ImagesController` — `index` (HTML grid + JSON) listing `image/*`
|
|
12
|
+
Active Storage blobs newest-first with simple pagination, and `create` for
|
|
13
|
+
uploads via `ActiveStorage::Blob.create_and_upload!` (storage-agnostic).
|
|
14
|
+
- `VenusMediaLibrary::PickersController` — Turbo Frame picker body for a target field.
|
|
15
|
+
- `media_picker_field` host helper (auto-included into host views) that renders a
|
|
16
|
+
URL text input plus a "Choose from library" button and a shared modal shell.
|
|
17
|
+
- Dependency-free vanilla JS picker (opens the modal, selects an image, uploads).
|
|
18
|
+
- `media_attach_field` host helper — **attach mode** for `has_one_attached`
|
|
19
|
+
associations: the picker writes the chosen blob's `signed_id` into a hidden
|
|
20
|
+
field named for the attachment (e.g. `product[cover]`) so Rails attaches it on
|
|
21
|
+
save. The field is disabled until an image is picked, so saving without picking
|
|
22
|
+
never detaches the current file.
|
|
23
|
+
- `config.url_type` (`:redirect` default, or `:proxy`) — build image URLs with
|
|
24
|
+
`rails_storage_proxy_url` so images on a private bucket in proxy mode are
|
|
25
|
+
absolute and publicly fetchable (e.g. for an `og:image`).
|
|
26
|
+
- `config.authenticate_with` — a proc run in the engine controller's context
|
|
27
|
+
before every action, so the host can gate the picker/upload endpoints (admins
|
|
28
|
+
only). The engine ships open.
|
|
29
|
+
- Configurable allowed content types, thumbnail size, per-page count, and storage
|
|
30
|
+
service via `VenusMediaLibrary.configure`.
|
|
31
|
+
- Dummy app + RSpec request, model, and helper specs.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 Good Works On Earth
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# Media Library
|
|
2
|
+
|
|
3
|
+
A mountable Rails engine that turns **Active Storage** into a browsable media library with an **image picker**.
|
|
4
|
+
|
|
5
|
+
Content editors get a modal that lists every image already in Active Storage and lets them upload new ones. Drop `media_picker_field` next to any URL field (for example an `og:image` field) so editors *select* an image instead of typing a path.
|
|
6
|
+
|
|
7
|
+
It is **storage-agnostic**: it uses whatever Active Storage service the host app configures — local Disk in development, Amazon S3 (or GCS, Azure, ...) in production. The engine never talks to a storage backend directly.
|
|
8
|
+
|
|
9
|
+
- Namespaced under `VenusMediaLibrary::` (isolated engine)
|
|
10
|
+
- Lists `ActiveStorage::Blob` records with an `image/*` content type, newest first
|
|
11
|
+
- HTML thumbnail grid **and** a JSON API
|
|
12
|
+
- Upload via `ActiveStorage::Blob.create_and_upload!`
|
|
13
|
+
- A modal picker (Turbo Frame + dependency-free vanilla JS)
|
|
14
|
+
- One host helper: `media_picker_field`
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add it to the host app's `Gemfile`:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "venus_media_library"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bundle install
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Active Storage must be installed in the host app (`bin/rails active_storage:install && bin/rails db:migrate`).
|
|
31
|
+
|
|
32
|
+
## Mount the engine
|
|
33
|
+
|
|
34
|
+
In the host app's `config/routes.rb`:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
mount VenusMediaLibrary::Engine, at: "/media"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Include the picker JavaScript once in your layout (Propshaft/Sprockets):
|
|
41
|
+
|
|
42
|
+
```erb
|
|
43
|
+
<%= javascript_include_tag "venus_media_library/venus_media_library", defer: true %>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
(Using importmap? `pin "venus_media_library", to: "venus_media_library/venus_media_library.js"` and `import "venus_media_library"`.)
|
|
47
|
+
|
|
48
|
+
The picker styles are shipped as `venus_media_library/application.css`; require them or add your own — every class is namespaced under `.ml-*`.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Replace a plain URL text box with the picker in any form:
|
|
53
|
+
|
|
54
|
+
```erb
|
|
55
|
+
<%= form_with model: @page do |f| %>
|
|
56
|
+
<%= media_picker_field f, :og_image %>
|
|
57
|
+
<% end %>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That renders the text input you already had, plus a **"Choose from library"** button. Clicking it opens the modal; selecting an image writes its URL into the input (and its Active Storage `signed_id` into a hidden `og_image_signed_id` companion field), then closes the modal. Editors can also upload a new image from inside the modal.
|
|
61
|
+
|
|
62
|
+
### Attach mode — `media_attach_field` (for `has_one_attached`)
|
|
63
|
+
|
|
64
|
+
Use `media_attach_field` when the field is an **Active Storage attachment**
|
|
65
|
+
(`has_one_attached`) rather than a URL column. Instead of a URL, the picker writes
|
|
66
|
+
the chosen blob's `signed_id` into a hidden field **named for the attachment**, so
|
|
67
|
+
Rails attaches that blob on save:
|
|
68
|
+
|
|
69
|
+
```erb
|
|
70
|
+
<%= form_with model: @product do |f| %>
|
|
71
|
+
<%= media_attach_field f, :cover %>
|
|
72
|
+
<% end %>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This renders a read-only preview input (not submitted) plus the "Choose from
|
|
76
|
+
library" button, and a hidden `product[cover]` field carrying the signed id. That
|
|
77
|
+
hidden field ships **disabled** and is enabled by the picker only once an image is
|
|
78
|
+
chosen — so submitting the form without picking never detaches the current file
|
|
79
|
+
(an empty value for a `has_one_attached` would otherwise purge it). `has_one_attached`
|
|
80
|
+
accepts a `signed_id` natively, so no controller changes are needed beyond
|
|
81
|
+
permitting the attachment param (e.g. `params.permit(:cover)`).
|
|
82
|
+
|
|
83
|
+
### `media_picker_field` options
|
|
84
|
+
|
|
85
|
+
| Option | Default | Description |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `:label` | `"Choose from library"` | Button label |
|
|
88
|
+
| `:placeholder` | `nil` | Placeholder for the text input |
|
|
89
|
+
| `:signed_id_field` | `"<field>_signed_id"` | Name of the hidden signed-id input; pass `false` to omit |
|
|
90
|
+
| `:input_html` | `{}` | Extra HTML options merged into the text input |
|
|
91
|
+
| `:class` | `"ml-field"` | Wrapper CSS class |
|
|
92
|
+
|
|
93
|
+
### Endpoints
|
|
94
|
+
|
|
95
|
+
Mounted at your chosen path (examples assume `/media`):
|
|
96
|
+
|
|
97
|
+
| Method | Path | Purpose |
|
|
98
|
+
| --- | --- | --- |
|
|
99
|
+
| `GET` | `/media/images` | HTML thumbnail grid (also `.json`) |
|
|
100
|
+
| `GET` | `/media/images.json` | `{ images: [...], page:, has_more:, total: }` |
|
|
101
|
+
| `POST` | `/media/images` | Upload a file (param `file`); returns the image JSON |
|
|
102
|
+
| `GET` | `/media/picker?target=<input_id>` | Turbo Frame body for the modal |
|
|
103
|
+
|
|
104
|
+
Each image payload includes `id`, `signed_id`, `filename`, `content_type`, `byte_size`, `url`, and `thumb_url`.
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
In an initializer (e.g. `config/initializers/venus_media_library.rb`):
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
VenusMediaLibrary.configure do |config|
|
|
112
|
+
# Content types accepted by the uploader (any image/* is always allowed in the grid).
|
|
113
|
+
config.allowed_content_types = %w[image/png image/jpeg image/webp image/gif image/svg+xml]
|
|
114
|
+
|
|
115
|
+
# [width, height] for the grid thumbnail variant.
|
|
116
|
+
config.thumbnail_size = [300, 300]
|
|
117
|
+
|
|
118
|
+
# Images per page in the index / picker.
|
|
119
|
+
config.per_page = 40
|
|
120
|
+
|
|
121
|
+
# Which Active Storage service to store uploads on. nil = the host app's
|
|
122
|
+
# default service (Disk in dev, S3 in prod, etc.).
|
|
123
|
+
config.storage_service = nil
|
|
124
|
+
|
|
125
|
+
# How image URLs are built. :redirect (default) uses rails_blob_url; :proxy
|
|
126
|
+
# uses rails_storage_proxy_url so images on a PRIVATE bucket in proxy mode are
|
|
127
|
+
# absolute and publicly fetchable by crawlers (e.g. for an og:image).
|
|
128
|
+
config.url_type = :redirect
|
|
129
|
+
|
|
130
|
+
# Optional access gate. A proc run in the engine controller's context before
|
|
131
|
+
# every action, so it can use host helpers (current_user, redirect_to, head,
|
|
132
|
+
# main_app). Left nil the engine is open — set it to restrict the picker and
|
|
133
|
+
# upload endpoints to admins:
|
|
134
|
+
#
|
|
135
|
+
# config.authenticate_with = lambda do
|
|
136
|
+
# redirect_to main_app.root_path unless current_user&.admin?
|
|
137
|
+
# end
|
|
138
|
+
config.authenticate_with = nil
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Configuration Details
|
|
143
|
+
|
|
144
|
+
- **`allowed_content_types`** — Restricts uploads to these MIME types. The grid always shows any blob with `image/*` content type regardless. Defaults to common image formats; customize only if you need to block certain types.
|
|
145
|
+
|
|
146
|
+
- **`thumbnail_size`** — Array of `[width, height]` for grid thumbnails. Larger values give better preview quality at the cost of image processing overhead and bandwidth.
|
|
147
|
+
|
|
148
|
+
- **`per_page`** — Number of images to display per page. Smaller values suit mobile-friendly UIs; larger values reduce pagination clicks.
|
|
149
|
+
|
|
150
|
+
- **`storage_service`** — Active Storage service name (e.g. `:amazon`, `:google`). Leave `nil` to use the host app's default, making the engine truly storage-agnostic. Uploads automatically inherit the configured service.
|
|
151
|
+
|
|
152
|
+
- **`url_type`** — Controls how image URLs are built:
|
|
153
|
+
- `:redirect` (default) — `rails_blob_url` returns a temporary redirect URL that points to your storage backend (S3, GCS, etc.). Works for public URLs but blocks crawlers if your bucket is private.
|
|
154
|
+
- `:proxy` — `rails_storage_proxy_url` returns a proxy URL through Rails, which streams bytes from storage. Useful for private buckets where external crawlers (e.g., social media bots fetching `og:image`) need public, absolute URLs.
|
|
155
|
+
|
|
156
|
+
- **`authenticate_with`** — A proc that gates access to the engine. Runs before every action in the engine's controller context, so you can call host helpers like `current_user`, `redirect_to`, and `head`. Return nothing to allow, or redirect/deny to block. Example:
|
|
157
|
+
```ruby
|
|
158
|
+
config.authenticate_with = lambda do
|
|
159
|
+
redirect_to main_app.root_path unless current_user&.admin?
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Image Processing & Thumbnails
|
|
164
|
+
|
|
165
|
+
Thumbnails use Active Storage variants, which require two system dependencies:
|
|
166
|
+
|
|
167
|
+
1. **`image_processing`** gem — Already a runtime dependency of this gem.
|
|
168
|
+
2. **Image library** — Either `libvips` (recommended) or ImageMagick on the host server.
|
|
169
|
+
|
|
170
|
+
On macOS, install via Homebrew:
|
|
171
|
+
```bash
|
|
172
|
+
brew install vips
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
On Linux (Ubuntu/Debian):
|
|
176
|
+
```bash
|
|
177
|
+
apt-get install libvips libvips-dev
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Without these, variant generation fails and thumbnail images won't display.
|
|
181
|
+
|
|
182
|
+
## Styling & Customization
|
|
183
|
+
|
|
184
|
+
All picker and library CSS is namespaced under `.ml-*` classes to avoid conflicts with the host app. The engine ships two stylesheets:
|
|
185
|
+
|
|
186
|
+
- **`venus_media_library/application.css`** — Layout and structure (grid, pagination, forms).
|
|
187
|
+
- **`venus_media_library/picker.css`** — Button and interactive styles.
|
|
188
|
+
|
|
189
|
+
To override styles, require the engine CSS first, then add your own:
|
|
190
|
+
|
|
191
|
+
```erb
|
|
192
|
+
<%= stylesheet_link_tag "venus_media_library/application" %>
|
|
193
|
+
<%= stylesheet_link_tag "venus_media_library/picker" %>
|
|
194
|
+
<style>
|
|
195
|
+
.ml-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
|
|
196
|
+
.ml-tile:hover { border-color: #ff6b6b; }
|
|
197
|
+
</style>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Or, in your own CSS file:
|
|
201
|
+
```css
|
|
202
|
+
.ml-grid {
|
|
203
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.ml-btn--primary {
|
|
207
|
+
background: #your-brand-color;
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### CSS Classes Reference
|
|
212
|
+
|
|
213
|
+
- `.ml-field` — Wrapper for the picker field + button.
|
|
214
|
+
- `.ml-field__input` — The text input element.
|
|
215
|
+
- `.ml-btn`, `.ml-btn--primary` — Buttons.
|
|
216
|
+
- `.ml-library` — Main container.
|
|
217
|
+
- `.ml-grid` — Image grid.
|
|
218
|
+
- `.ml-tile` — Individual image tile in the grid.
|
|
219
|
+
- `.ml-pagination` — Pagination navigation.
|
|
220
|
+
- `.ml-modal` — The modal container (in Turbo Frame).
|
|
221
|
+
|
|
222
|
+
## JavaScript Requirements
|
|
223
|
+
|
|
224
|
+
The picker uses **dependency-free vanilla JavaScript** (no jQuery, no Stimulus required). It needs:
|
|
225
|
+
|
|
226
|
+
- ES6 support (const, arrow functions, template literals) — works in all modern browsers (Chrome, Firefox, Safari, Edge).
|
|
227
|
+
- `fetch` API for uploading files and fetching paginated images.
|
|
228
|
+
|
|
229
|
+
If you must support IE11, you'll need polyfills. No Turbo Drive requirement, but Turbo Frames are optional for better UX.
|
|
230
|
+
|
|
231
|
+
## How It Works
|
|
232
|
+
|
|
233
|
+
### The Picker Flow
|
|
234
|
+
|
|
235
|
+
1. **User clicks "Choose from library"** — Opens a modal via a Turbo Frame (`GET /media/picker?target=<input_id>`).
|
|
236
|
+
2. **Modal loads the image grid** — The frame fetches the index view, listing images newest-first with pagination.
|
|
237
|
+
3. **User uploads or selects** —
|
|
238
|
+
- **Select:** Click an image tile; JavaScript writes the blob's `signed_id` and URL into the form inputs and closes the modal.
|
|
239
|
+
- **Upload:** Click the upload button; JavaScript posts the file to `POST /media/images`, attaches the new blob to the same inputs, and reloads the grid.
|
|
240
|
+
4. **Form submission** — The host app form submits with the image data, storing it as a URL column or Active Storage attachment.
|
|
241
|
+
|
|
242
|
+
### URL Signing & Storage Agnosticism
|
|
243
|
+
|
|
244
|
+
All image URLs are built via Active Storage helpers (`rails_blob_url` or `rails_storage_proxy_url`), which handle signed URLs and expiration. The engine never directly accesses the storage backend; it trusts Active Storage to route the request appropriately.
|
|
245
|
+
|
|
246
|
+
Upload destinations are determined by `config.storage_service` — if `nil`, the host app's default service is used, allowing per-environment configuration (Disk locally, S3 in production).
|
|
247
|
+
|
|
248
|
+
## Testing
|
|
249
|
+
|
|
250
|
+
The engine ships with RSpec request, model, and helper specs under `spec/`. To run them:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
cd spec/dummy && bundle exec rspec ../
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Or use the included Rakefile:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
bundle exec rake spec
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
When testing a host app that uses Media Library, you can:
|
|
263
|
+
|
|
264
|
+
1. **Mock the picker** — Test your form without hitting the engine:
|
|
265
|
+
```ruby
|
|
266
|
+
it "saves og_image from the picker" do
|
|
267
|
+
visit new_post_path
|
|
268
|
+
fill_in "og_image", with: "https://cdn.example.com/og.jpg"
|
|
269
|
+
click_button "Create"
|
|
270
|
+
expect(post.og_image).to eq("https://cdn.example.com/og.jpg")
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
2. **Test the engine in isolation** — Use the dummy app to verify the picker opens, uploads work, and pagination handles large image sets.
|
|
275
|
+
|
|
276
|
+
## Troubleshooting
|
|
277
|
+
|
|
278
|
+
### Thumbnails aren't generating
|
|
279
|
+
|
|
280
|
+
**Symptom:** Gray placeholder squares instead of previews in the grid.
|
|
281
|
+
|
|
282
|
+
**Solution:** Ensure `libvips` or ImageMagick is installed, and Active Storage is configured. Run:
|
|
283
|
+
```bash
|
|
284
|
+
rails active_storage:install && rails db:migrate
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Private S3 bucket — og:image not visible to crawlers
|
|
288
|
+
|
|
289
|
+
**Symptom:** Social media preview cards show no image for posts with private S3 URLs.
|
|
290
|
+
|
|
291
|
+
**Solution:** Set `config.url_type = :proxy` so Rails proxies image bytes through a public endpoint. This requires Rails to stream the file, so monitor for performance impact with large images.
|
|
292
|
+
|
|
293
|
+
### Upload endpoint is open to the public
|
|
294
|
+
|
|
295
|
+
**Symptom:** Anyone can upload images to your media library.
|
|
296
|
+
|
|
297
|
+
**Solution:** Set `config.authenticate_with` in the initializer to gate access. Example:
|
|
298
|
+
```ruby
|
|
299
|
+
config.authenticate_with = lambda do
|
|
300
|
+
head :forbidden unless current_user&.admin?
|
|
301
|
+
end
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Form helper `media_picker_field` is undefined
|
|
305
|
+
|
|
306
|
+
**Symptom:** `undefined method 'media_picker_field'` when rendering a form.
|
|
307
|
+
|
|
308
|
+
**Solution:** Ensure you've mounted the engine in `config/routes.rb` and the JavaScript is included in your layout with `javascript_include_tag`.
|
|
309
|
+
|
|
310
|
+
### Picker modal doesn't open or closes immediately
|
|
311
|
+
|
|
312
|
+
**Symptom:** Button click does nothing or modal opens then closes.
|
|
313
|
+
|
|
314
|
+
**Solution:** Check browser console for errors. Verify:
|
|
315
|
+
1. JavaScript is loaded: `<%= javascript_include_tag "venus_media_library/venus_media_library", defer: true %>`
|
|
316
|
+
2. The engine is mounted and accessible at your chosen path (default: `/media`).
|
|
317
|
+
3. No JavaScript errors in other assets are breaking the page.
|
|
318
|
+
|
|
319
|
+
## Development
|
|
320
|
+
|
|
321
|
+
The engine ships with a dummy app under `spec/dummy` (Active Storage configured with the Disk service, engine mounted at `/media`).
|
|
322
|
+
|
|
323
|
+
```bash
|
|
324
|
+
bundle install
|
|
325
|
+
cd spec/dummy && bin/rails db:prepare && cd - # set up Active Storage tables
|
|
326
|
+
bundle exec rspec # run the test suite
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Publishing
|
|
330
|
+
|
|
331
|
+
This gem is built to be published to RubyGems under a **Good Works On Earth** name.
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
gem build venus_media_library.gemspec # produces venus_media_library-<version>.gem
|
|
335
|
+
gem push venus_media_library-<version>.gem # publish to RubyGems
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
`gem push` requires RubyGems credentials (and 2FA/OTP if enabled) — **the gem owner enters these**; they are not stored in the repo. Bump `VenusMediaLibrary::VERSION` in `lib/venus_media_library/version.rb` before each release.
|
|
339
|
+
|
|
340
|
+
## License
|
|
341
|
+
|
|
342
|
+
MIT — see [MIT-LICENSE](MIT-LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// VenusMediaLibrary picker — dependency-free vanilla controller.
|
|
2
|
+
//
|
|
3
|
+
// Wiring (all via delegated events, no framework required):
|
|
4
|
+
// [data-ml-open][data-ml-target][data-ml-src] open the modal for a field
|
|
5
|
+
// [data-ml-close] close the modal
|
|
6
|
+
// .ml-tile[data-ml-url][data-ml-signed-id] choose an image
|
|
7
|
+
// input[data-ml-upload] upload a new image
|
|
8
|
+
//
|
|
9
|
+
// Works alongside Turbo if present, but does not require it.
|
|
10
|
+
(function () {
|
|
11
|
+
"use strict";
|
|
12
|
+
|
|
13
|
+
var MODAL_ID = "ml-modal";
|
|
14
|
+
var FRAME_ID = "venus_media_library_picker";
|
|
15
|
+
var activeTargetId = null;
|
|
16
|
+
|
|
17
|
+
function modal() { return document.getElementById(MODAL_ID); }
|
|
18
|
+
function frame() { return document.getElementById(FRAME_ID); }
|
|
19
|
+
|
|
20
|
+
function csrfToken() {
|
|
21
|
+
var el = document.querySelector('meta[name="csrf-token"]');
|
|
22
|
+
return el ? el.getAttribute("content") : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function openModal(targetId, src) {
|
|
26
|
+
var m = modal();
|
|
27
|
+
if (!m) return;
|
|
28
|
+
activeTargetId = targetId;
|
|
29
|
+
m.hidden = false;
|
|
30
|
+
m.setAttribute("aria-hidden", "false");
|
|
31
|
+
m.classList.add("ml-modal--open");
|
|
32
|
+
loadFrame(src);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function closeModal() {
|
|
36
|
+
var m = modal();
|
|
37
|
+
if (!m) return;
|
|
38
|
+
m.hidden = true;
|
|
39
|
+
m.setAttribute("aria-hidden", "true");
|
|
40
|
+
m.classList.remove("ml-modal--open");
|
|
41
|
+
activeTargetId = null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadFrame(src) {
|
|
45
|
+
var f = frame();
|
|
46
|
+
if (!f || !src) return;
|
|
47
|
+
// If Turbo is present, let it drive the frame; otherwise fetch manually.
|
|
48
|
+
if (window.Turbo && f.tagName.toLowerCase() === "turbo-frame") {
|
|
49
|
+
f.setAttribute("src", src);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
fetch(src, { headers: { "Accept": "text/html", "X-Requested-With": "XMLHttpRequest" } })
|
|
53
|
+
.then(function (r) { return r.text(); })
|
|
54
|
+
.then(function (html) {
|
|
55
|
+
var doc = new DOMParser().parseFromString(html, "text/html");
|
|
56
|
+
var incoming = doc.getElementById(FRAME_ID);
|
|
57
|
+
f.innerHTML = incoming ? incoming.innerHTML : html;
|
|
58
|
+
})
|
|
59
|
+
.catch(function () { f.innerHTML = '<p class="ml-empty">Could not load the library.</p>'; });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function chooseTile(tile) {
|
|
63
|
+
if (!activeTargetId) { closeModal(); return; }
|
|
64
|
+
var url = tile.getAttribute("data-ml-url");
|
|
65
|
+
var signedId = tile.getAttribute("data-ml-signed-id");
|
|
66
|
+
|
|
67
|
+
var input = document.getElementById(activeTargetId);
|
|
68
|
+
if (input) {
|
|
69
|
+
input.value = url;
|
|
70
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
71
|
+
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
72
|
+
}
|
|
73
|
+
var hidden = document.querySelector('[data-ml-signed-id-for="' + activeTargetId + '"]');
|
|
74
|
+
if (hidden) {
|
|
75
|
+
hidden.value = signedId;
|
|
76
|
+
// Attach mode ships the hidden field disabled so an empty value can't
|
|
77
|
+
// detach the current file; enable it now that a signed_id is set.
|
|
78
|
+
hidden.disabled = false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
closeModal();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function upload(fileInput) {
|
|
85
|
+
var file = fileInput.files && fileInput.files[0];
|
|
86
|
+
if (!file) return;
|
|
87
|
+
|
|
88
|
+
var status = document.querySelector("[data-ml-status]");
|
|
89
|
+
if (status) status.textContent = "Uploading…";
|
|
90
|
+
|
|
91
|
+
var form = new FormData();
|
|
92
|
+
form.append("file", file);
|
|
93
|
+
|
|
94
|
+
var headers = { "Accept": "application/json" };
|
|
95
|
+
var token = csrfToken();
|
|
96
|
+
if (token) headers["X-CSRF-Token"] = token;
|
|
97
|
+
|
|
98
|
+
fetch(uploadUrl(), { method: "POST", body: form, headers: headers, credentials: "same-origin" })
|
|
99
|
+
.then(function (r) {
|
|
100
|
+
if (!r.ok) throw new Error("Upload failed");
|
|
101
|
+
return r.json();
|
|
102
|
+
})
|
|
103
|
+
.then(function (image) {
|
|
104
|
+
if (status) status.textContent = "Uploaded " + image.filename;
|
|
105
|
+
prependTile(image);
|
|
106
|
+
})
|
|
107
|
+
.catch(function () { if (status) status.textContent = "Upload failed."; });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Derives the images upload URL from the picker frame src (…/picker → …/images).
|
|
111
|
+
function uploadUrl() {
|
|
112
|
+
var f = document.querySelector("[data-ml-target]");
|
|
113
|
+
var base = f ? f.getAttribute("data-ml-src") : null;
|
|
114
|
+
if (base) return base.replace(/\/picker(\?.*)?$/, "/images");
|
|
115
|
+
return "images";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function prependTile(image) {
|
|
119
|
+
var grid = document.querySelector("[data-ml-grid]");
|
|
120
|
+
if (!grid) return;
|
|
121
|
+
// Build with DOM methods (not innerHTML): filenames are user-controlled, so
|
|
122
|
+
// assigning them as properties/attributes avoids any HTML injection.
|
|
123
|
+
var btn = document.createElement("button");
|
|
124
|
+
btn.type = "button";
|
|
125
|
+
btn.className = "ml-tile";
|
|
126
|
+
btn.setAttribute("data-ml-signed-id", image.signed_id);
|
|
127
|
+
btn.setAttribute("data-ml-url", image.url);
|
|
128
|
+
btn.setAttribute("data-ml-filename", image.filename);
|
|
129
|
+
btn.title = image.filename;
|
|
130
|
+
|
|
131
|
+
var img = document.createElement("img");
|
|
132
|
+
img.src = image.thumb_url;
|
|
133
|
+
img.alt = image.filename;
|
|
134
|
+
img.loading = "lazy";
|
|
135
|
+
|
|
136
|
+
var name = document.createElement("span");
|
|
137
|
+
name.className = "ml-tile__name";
|
|
138
|
+
name.textContent = image.filename;
|
|
139
|
+
|
|
140
|
+
btn.appendChild(img);
|
|
141
|
+
btn.appendChild(name);
|
|
142
|
+
grid.insertBefore(btn, grid.firstChild);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
document.addEventListener("click", function (e) {
|
|
146
|
+
var opener = e.target.closest("[data-ml-open]");
|
|
147
|
+
if (opener) {
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
openModal(opener.getAttribute("data-ml-target"), opener.getAttribute("data-ml-src"));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (e.target.closest("[data-ml-close]")) {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
closeModal();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
var tile = e.target.closest(".ml-tile");
|
|
158
|
+
if (tile && tile.closest("#" + MODAL_ID)) {
|
|
159
|
+
e.preventDefault();
|
|
160
|
+
chooseTile(tile);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
document.addEventListener("change", function (e) {
|
|
165
|
+
if (e.target.matches("[data-ml-upload]")) upload(e.target);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
document.addEventListener("keydown", function (e) {
|
|
169
|
+
if (e.key === "Escape") closeModal();
|
|
170
|
+
});
|
|
171
|
+
})();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
|
3
|
+
* listed below.
|
|
4
|
+
*
|
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
|
7
|
+
*
|
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
|
11
|
+
* It is generally better to create a new file per style scope.
|
|
12
|
+
*
|
|
13
|
+
*= require_tree .
|
|
14
|
+
*= require_self
|
|
15
|
+
*/
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* VenusMediaLibrary picker styles. Namespaced under .ml-* to avoid clashing with the host app. */
|
|
2
|
+
|
|
3
|
+
.ml-field { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
|
|
4
|
+
.ml-field__input { flex: 1 1 16rem; padding: .4rem .6rem; }
|
|
5
|
+
|
|
6
|
+
.ml-btn {
|
|
7
|
+
display: inline-block;
|
|
8
|
+
padding: .4rem .8rem;
|
|
9
|
+
border: 1px solid #c9c9c9;
|
|
10
|
+
border-radius: 4px;
|
|
11
|
+
background: #f5f5f5;
|
|
12
|
+
color: #222;
|
|
13
|
+
cursor: pointer;
|
|
14
|
+
font: inherit;
|
|
15
|
+
text-decoration: none;
|
|
16
|
+
}
|
|
17
|
+
.ml-btn:hover { background: #ececec; }
|
|
18
|
+
.ml-btn--primary { background: #2b6cb0; border-color: #2b6cb0; color: #fff; }
|
|
19
|
+
.ml-btn--primary:hover { background: #245a94; }
|
|
20
|
+
|
|
21
|
+
.ml-library { max-width: 1100px; margin: 0 auto; padding: 1rem; }
|
|
22
|
+
.ml-library__header { display: flex; align-items: baseline; gap: 1rem; }
|
|
23
|
+
.ml-library__count { color: #666; }
|
|
24
|
+
|
|
25
|
+
.ml-grid {
|
|
26
|
+
display: grid;
|
|
27
|
+
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
28
|
+
gap: .75rem;
|
|
29
|
+
margin: 1rem 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ml-tile {
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-direction: column;
|
|
35
|
+
border: 1px solid #e2e2e2;
|
|
36
|
+
border-radius: 6px;
|
|
37
|
+
background: #fff;
|
|
38
|
+
padding: 0;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
text-align: center;
|
|
42
|
+
}
|
|
43
|
+
.ml-tile:hover { border-color: #2b6cb0; box-shadow: 0 0 0 2px rgba(43,108,176,.2); }
|
|
44
|
+
.ml-tile img { width: 100%; height: 120px; object-fit: cover; display: block; background: #fafafa; }
|
|
45
|
+
.ml-tile__name {
|
|
46
|
+
font-size: .75rem;
|
|
47
|
+
padding: .35rem .4rem;
|
|
48
|
+
color: #444;
|
|
49
|
+
white-space: nowrap;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
text-overflow: ellipsis;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.ml-empty { color: #777; padding: 1rem; text-align: center; }
|
|
55
|
+
.ml-pagination { display: flex; gap: .5rem; justify-content: center; }
|
|
56
|
+
|
|
57
|
+
/* Modal */
|
|
58
|
+
.ml-modal { position: fixed; inset: 0; z-index: 1050; }
|
|
59
|
+
.ml-modal[hidden] { display: none; }
|
|
60
|
+
.ml-modal__backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.5); }
|
|
61
|
+
.ml-modal__dialog {
|
|
62
|
+
position: relative;
|
|
63
|
+
max-width: 900px;
|
|
64
|
+
margin: 4vh auto;
|
|
65
|
+
max-height: 90vh;
|
|
66
|
+
background: #fff;
|
|
67
|
+
border-radius: 8px;
|
|
68
|
+
display: flex;
|
|
69
|
+
flex-direction: column;
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
}
|
|
72
|
+
.ml-modal__header {
|
|
73
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
74
|
+
padding: .75rem 1rem; border-bottom: 1px solid #eee;
|
|
75
|
+
}
|
|
76
|
+
.ml-modal__title { margin: 0; font-size: 1.1rem; }
|
|
77
|
+
.ml-modal__close { border: 0; background: none; font-size: 1.5rem; line-height: 1; cursor: pointer; color: #666; }
|
|
78
|
+
.ml-modal__body { padding: 1rem; overflow: auto; }
|
|
79
|
+
|
|
80
|
+
.ml-picker__toolbar { display: flex; align-items: center; gap: 1rem; margin-bottom: .5rem; }
|
|
81
|
+
.ml-picker__hint { color: #666; font-size: .85rem; }
|
|
82
|
+
.ml-picker__more { text-align: center; }
|