venus_media_library 0.2.0 → 1.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 +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +60 -13
- data/app/assets/javascripts/venus_media_library/venus_media_library.js +44 -17
- data/app/assets/stylesheets/venus_media_library/layout.css +82 -0
- data/app/assets/stylesheets/venus_media_library/picker.css +32 -0
- data/app/controllers/venus_media_library/application_controller.rb +60 -4
- data/app/controllers/venus_media_library/assets_controller.rb +11 -1
- data/app/controllers/venus_media_library/cloud_assets_controller.rb +12 -0
- data/app/controllers/venus_media_library/community_assets_controller.rb +16 -0
- data/app/controllers/venus_media_library/images_controller.rb +39 -20
- data/app/controllers/venus_media_library/legacy_assets_controller.rb +6 -1
- data/app/controllers/venus_media_library/pickers_controller.rb +127 -10
- data/app/controllers/venus_media_library/settings_controller.rb +43 -0
- data/app/controllers/venus_media_library/static_assets_controller.rb +12 -0
- data/app/helpers/venus_media_library/images_helper.rb +9 -1
- data/app/helpers/venus_media_library/picker_helper.rb +16 -3
- data/app/models/venus_media_library/accepted_types.rb +59 -0
- data/app/models/venus_media_library/asset.rb +2 -0
- data/app/views/layouts/venus_media_library/application.html.erb +17 -5
- data/app/views/venus_media_library/cloud_assets/index.html.erb +4 -0
- data/app/views/venus_media_library/community_assets/index.html.erb +25 -0
- data/app/views/venus_media_library/images/_image.html.erb +6 -2
- data/app/views/venus_media_library/images/index.html.erb +5 -2
- data/app/views/venus_media_library/pickers/_picker.html.erb +17 -8
- data/app/views/venus_media_library/pickers/_tabs.html.erb +14 -0
- data/app/views/venus_media_library/pickers/_tile.html.erb +22 -0
- data/app/views/venus_media_library/pickers/_tile_body.html.erb +8 -0
- data/app/views/venus_media_library/settings/show.html.erb +45 -0
- data/app/views/venus_media_library/shared/_host_assets.html.erb +24 -0
- data/app/views/venus_media_library/shared/_sidebar.html.erb +24 -0
- data/app/views/venus_media_library/static_assets/index.html.erb +4 -0
- data/config/routes.rb +4 -0
- data/lib/venus_media_library/engine.rb +8 -2
- data/lib/venus_media_library/version.rb +1 -1
- data/lib/venus_media_library.rb +25 -1
- metadata +38 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53f9b683221215b76b95f0248dd87938ebba0a7066c36ccc7b02dc23fece50e0
|
|
4
|
+
data.tar.gz: acf9d06d3f9a7712568f9f05c9584ccc9fda2772de1bcad6e1b2090c1cf07e8f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1d247beff66e7d45750fcfa96813cdcb0bb7ed5a2cb0eb500486602f4b8354001ffc280fe399fbb504ad094b2e97b3715cb578b29e2aabce34d34b82fd4037f
|
|
7
|
+
data.tar.gz: e8928ccd013a969ff08cfef454886735297e5a74d84acc9b66a09335be91ede3a1a9f4f3dfbe59ede7a3b16acc4438b461b73b8c43c550e5ce01e989555b2999
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ All notable changes to this project are documented here. The format is based on
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
|
|
5
5
|
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-08-18
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Full-page library navigation.** At the engine mount point (`/media`) the
|
|
11
|
+
library now renders a role-gated vertical left-nav in a 30/70 split — a proper
|
|
12
|
+
browsing/management workspace — while the picker modal stays lean for everyday
|
|
13
|
+
"pick and go." Nav entries are built from the same admin gate that guards the
|
|
14
|
+
controllers, so a link is never shown that the controller would `403`.
|
|
15
|
+
- **Category tabs in the picker modal.** The modal exposes Images / Legacy /
|
|
16
|
+
Static / Cloud / Community tabs (Legacy is admin-only), each loading its
|
|
17
|
+
category into the picker Turbo Frame.
|
|
18
|
+
- **Field-aware type enforcement.** A host field's accepted content types now
|
|
19
|
+
flow (`media_picker_field`/`media_attach_field` → `picker_path(accept:)` →
|
|
20
|
+
frame) into the picker. Assets whose type does not match are rendered
|
|
21
|
+
non-selectable, and uploads of a disallowed type are rejected client-side and
|
|
22
|
+
re-validated server-side (422). Falls back to the configured global allowlist
|
|
23
|
+
when a field declares nothing, and never widens past it.
|
|
24
|
+
|
|
25
|
+
### Notes
|
|
26
|
+
- Static/Cloud assets have no Active Storage `signed_id`; they are pickable into
|
|
27
|
+
URL fields but cannot be attached to `has_one_attached` fields, and selecting
|
|
28
|
+
one never disturbs an existing attachment.
|
|
29
|
+
|
|
30
|
+
## [1.0.0] - 2026-08-17
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- A stable, documented public release of the mountable Rails media-library
|
|
34
|
+
engine, including private-by-default owner access, optional community sharing,
|
|
35
|
+
administrator imports, and tenant-aware scopes.
|
|
36
|
+
- Browsable library and separate host-configured Static Assets pages at the
|
|
37
|
+
engine mount point.
|
|
38
|
+
- A headless Chrome system test covering picker open, existing-image selection,
|
|
39
|
+
upload, and Escape-to-close behavior.
|
|
40
|
+
- Settings-gated PDF uploads and selection. PDFs are disabled by default,
|
|
41
|
+
rendered as document tiles, and served only as protected attachments.
|
|
42
|
+
- CI coverage for Ruby 3.2/Rails 7.1 and Ruby 3.4/Rails 8.1, plus contributor,
|
|
43
|
+
security, and release documentation.
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
- Trusted RubyGems publishing through the tag-triggered GitHub OIDC workflow;
|
|
47
|
+
the required publisher configuration is documented in `docs/RELEASING.md`.
|
|
48
|
+
|
|
7
49
|
## [0.2.0] - 2026-08-17
|
|
8
50
|
|
|
9
51
|
### Added
|
|
@@ -14,6 +56,13 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
14
56
|
- Authorization-aware original and thumbnail delivery routes.
|
|
15
57
|
- A tested Rails compatibility range through the 8.x series.
|
|
16
58
|
|
|
59
|
+
### Security
|
|
60
|
+
- Validate detected upload MIME types, require the declared type to match, and
|
|
61
|
+
enforce a configurable size limit.
|
|
62
|
+
- Deliver SVG originals as attachments instead of inline documents.
|
|
63
|
+
- Add configurable asset and legacy-blob tenant scopes; legacy blobs are denied
|
|
64
|
+
by default until explicitly scoped by the host.
|
|
65
|
+
|
|
17
66
|
## [0.1.1] - 2026-08-17
|
|
18
67
|
|
|
19
68
|
### Added
|
data/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Media Library
|
|
2
2
|
|
|
3
|
-
A mountable Rails engine that turns **Active Storage** into a browsable media library with an
|
|
3
|
+
A mountable Rails engine that turns **Active Storage** into a browsable media library with an image-and-document picker.
|
|
4
4
|
|
|
5
|
-
Content editors get a modal that lists
|
|
5
|
+
Content editors get a modal that lists authorized engine media 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 approved asset instead of typing a path.
|
|
6
6
|
|
|
7
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
8
|
|
|
9
9
|
- Namespaced under `VenusMediaLibrary::` (isolated engine)
|
|
10
|
-
- Lists
|
|
10
|
+
- Lists owner-scoped Active Storage media records, newest first
|
|
11
11
|
- HTML thumbnail grid **and** a JSON API
|
|
12
12
|
- Upload via `ActiveStorage::Blob.create_and_upload!`
|
|
13
13
|
- A modal picker (Turbo Frame + dependency-free vanilla JS)
|
|
@@ -29,6 +29,15 @@ bundle install
|
|
|
29
29
|
|
|
30
30
|
Active Storage must be installed in the host app (`bin/rails active_storage:install && bin/rails db:migrate`).
|
|
31
31
|
|
|
32
|
+
### Compatibility
|
|
33
|
+
|
|
34
|
+
| Component | Supported |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| Ruby | 3.2+ |
|
|
37
|
+
| Rails | 7.1 through 8.x |
|
|
38
|
+
| Database | Any host-supported Active Record database |
|
|
39
|
+
| Assets | Propshaft or Sprockets |
|
|
40
|
+
|
|
32
41
|
## Mount the engine
|
|
33
42
|
|
|
34
43
|
In the host app's `config/routes.rb`:
|
|
@@ -80,6 +89,12 @@ chosen — so submitting the form without picking never detaches the current fil
|
|
|
80
89
|
accepts a `signed_id` natively, so no controller changes are needed beyond
|
|
81
90
|
permitting the attachment param (e.g. `params.permit(:cover)`).
|
|
82
91
|
|
|
92
|
+
### Choosing URL or attachment mode
|
|
93
|
+
|
|
94
|
+
Use `media_picker_field` for a string URL column and `media_attach_field` for
|
|
95
|
+
a `has_one_attached` association. Both modes require host authorization; the
|
|
96
|
+
attachment mode also requires permitting the attachment parameter.
|
|
97
|
+
|
|
83
98
|
### `media_picker_field` options
|
|
84
99
|
|
|
85
100
|
| Option | Default | Description |
|
|
@@ -101,6 +116,9 @@ Mounted at your chosen path (examples assume `/venus_media_library`):
|
|
|
101
116
|
| `GET` | `/venus_media_library/images.json` | `{ images: [...], page:, has_more:, total: }` |
|
|
102
117
|
| `POST` | `/venus_media_library/images` | Upload a file (param `file`); returns the image JSON |
|
|
103
118
|
| `GET` | `/venus_media_library/picker?target=<input_id>` | Turbo Frame body for the modal |
|
|
119
|
+
| `GET` | `/venus_media_library/community_assets` | Tenant-scoped community-shared images (also `.json`) |
|
|
120
|
+
| `GET` | `/venus_media_library/static_assets` | Host-configured static-asset page (also `.json`) |
|
|
121
|
+
| `GET` | `/venus_media_library/cloud_assets` | Host-configured cloud-asset page (also `.json`) |
|
|
104
122
|
|
|
105
123
|
Each image payload includes `id`, `signed_id`, `filename`, `content_type`, `byte_size`, `url`, and `thumb_url`.
|
|
106
124
|
|
|
@@ -112,6 +130,8 @@ In an initializer (e.g. `config/initializers/venus_media_library.rb`):
|
|
|
112
130
|
VenusMediaLibrary.configure do |config|
|
|
113
131
|
# Content types accepted by the uploader.
|
|
114
132
|
config.allowed_content_types = %w[image/png image/jpeg image/webp image/gif image/svg+xml]
|
|
133
|
+
# Add application/pdf only when this host application permits PDF uploads.
|
|
134
|
+
# config.allowed_content_types << "application/pdf"
|
|
115
135
|
|
|
116
136
|
# [width, height] for the grid thumbnail variant.
|
|
117
137
|
config.thumbnail_size = [300, 300]
|
|
@@ -119,6 +139,9 @@ VenusMediaLibrary.configure do |config|
|
|
|
119
139
|
# Images per page in the index / picker.
|
|
120
140
|
config.per_page = 40
|
|
121
141
|
|
|
142
|
+
# Reject uploads larger than this many bytes (10 MiB by default).
|
|
143
|
+
config.max_file_size = 10 * 1024 * 1024
|
|
144
|
+
|
|
122
145
|
# Which Active Storage service to store uploads on. nil = the host app's
|
|
123
146
|
# default service (Disk in dev, S3 in prod, etc.).
|
|
124
147
|
config.storage_service = nil
|
|
@@ -138,23 +161,41 @@ VenusMediaLibrary.configure do |config|
|
|
|
138
161
|
# Ownership defaults to the host application's authentication convention.
|
|
139
162
|
config.current_user = -> { current_user }
|
|
140
163
|
config.admin = ->(user) { user.admin? }
|
|
164
|
+
|
|
165
|
+
# Approved host-supplied records for non-library tabs. Each hash needs a
|
|
166
|
+
# filename and URL, with optional content_type and byte_size.
|
|
167
|
+
config.static_assets = -> { [] }
|
|
168
|
+
config.cloud_assets = -> { [] }
|
|
169
|
+
|
|
141
170
|
end
|
|
142
171
|
```
|
|
143
172
|
|
|
144
173
|
### Configuration Details
|
|
145
174
|
|
|
146
|
-
- **`allowed_content_types`** — Restricts uploads to these exact MIME types. The default explicitly includes SVG (`image/svg+xml`).
|
|
175
|
+
- **`allowed_content_types`** — Restricts uploads to these exact MIME types. The default explicitly includes SVG (`image/svg+xml`) but deliberately excludes PDFs. Add `application/pdf` here to enable PDF selection and uploads; PDFs render as document tiles and are delivered as protected downloads rather than embedded content.
|
|
147
176
|
|
|
148
177
|
- **`thumbnail_size`** — Array of `[width, height]` for grid thumbnails. Larger values give better preview quality at the cost of image processing overhead and bandwidth.
|
|
149
178
|
|
|
150
179
|
- **`per_page`** — Number of images to display per page. Smaller values suit mobile-friendly UIs; larger values reduce pagination clicks. JSON callers can request up to 100 images per page.
|
|
151
180
|
|
|
181
|
+
- **`max_file_size`** — Maximum upload size in bytes. The default is 10 MiB.
|
|
182
|
+
|
|
152
183
|
- **`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.
|
|
153
184
|
|
|
154
185
|
- **`current_user`** — A controller-context callback that returns the signed-in host user. It defaults to `current_user`; every engine endpoint requires it to return a user.
|
|
155
186
|
|
|
156
187
|
- **`admin`** — A callback that determines whether that user can manage all media. It defaults to `->(user) { user.admin? }`.
|
|
157
188
|
|
|
189
|
+
- **`asset_scope`** — A controller-context callback receiving the engine asset relation. Use it to restrict all list, picker, download, and admin queries to the current tenant. It defaults to the supplied relation.
|
|
190
|
+
|
|
191
|
+
- **`legacy_blob_scope`** — A controller-context callback receiving unowned image blobs. It defaults to `scope.none`, so legacy blobs cannot cross tenant boundaries accidentally. Configure it explicitly only when the host can prove which legacy blobs belong to the current tenant.
|
|
192
|
+
|
|
193
|
+
- **`static_assets`** — A controller-context callback that returns approved host asset hashes for the separate `/static_assets` page. Each hash needs `filename` and `url`, with optional `content_type` and `byte_size`; the engine never scans host directories.
|
|
194
|
+
|
|
195
|
+
- **`cloud_assets`** — A controller-context callback with the same hash shape for the separate `/cloud_assets` page. Use it to present approved CDN or object-storage assets. The engine never enumerates a bucket, accepts storage credentials, or makes an arbitrary cloud object public.
|
|
196
|
+
|
|
197
|
+
- **Community Assets** — The `/community_assets` tab shows only `community_shared: true` records after the host's `asset_scope` is applied. Private uploads never appear there.
|
|
198
|
+
|
|
158
199
|
- **`url_type`** — Retained for backward-compatible host configuration. Browsing and picker URLs are always protected engine routes, so private uploads are never exposed via an Active Storage signed URL.
|
|
159
200
|
|
|
160
201
|
- **`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:
|
|
@@ -185,6 +226,13 @@ Without these, variant generation fails and thumbnail images won't display.
|
|
|
185
226
|
|
|
186
227
|
## Styling & Customization
|
|
187
228
|
|
|
229
|
+
Include the engine's explicit entrypoints in the host layout:
|
|
230
|
+
|
|
231
|
+
```erb
|
|
232
|
+
<%= stylesheet_link_tag "venus_media_library/application", "venus_media_library/picker" %>
|
|
233
|
+
<%= javascript_include_tag "venus_media_library/venus_media_library", defer: true %>
|
|
234
|
+
```
|
|
235
|
+
|
|
188
236
|
All picker and library CSS is namespaced under `.ml-*` classes to avoid conflicts with the host app. The engine ships two stylesheets:
|
|
189
237
|
|
|
190
238
|
- **`venus_media_library/application.css`** — Layout and structure (grid, pagination, forms).
|
|
@@ -243,9 +291,9 @@ If you must support IE11, you'll need polyfills. No Turbo Drive requirement, but
|
|
|
243
291
|
- **Upload:** Click the upload button; JavaScript posts the file to `POST /venus_media_library/images`, attaches the new blob to the same inputs, and reloads the grid.
|
|
244
292
|
4. **Form submission** — The host app form submits with the image data, storing it as a URL column or Active Storage attachment.
|
|
245
293
|
|
|
246
|
-
###
|
|
294
|
+
### Authorized delivery & storage agnosticism
|
|
247
295
|
|
|
248
|
-
|
|
296
|
+
The engine serves library URLs through authorization-aware routes. It never directly accesses the storage backend; Active Storage routes the authorized byte requests.
|
|
249
297
|
|
|
250
298
|
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).
|
|
251
299
|
|
|
@@ -285,6 +333,8 @@ Each upload is owned by the user returned by `config.current_user` and starts pr
|
|
|
285
333
|
|
|
286
334
|
The engine sends originals and thumbnails through its own authorization-aware routes. Do not use an Active Storage blob URL as a substitute for an engine media URL, because it bypasses the ownership check.
|
|
287
335
|
|
|
336
|
+
The upload endpoint verifies the detected MIME type with Marcel, requires it to match the browser-declared type, enforces the allowlist and `max_file_size`, and stores the detected type. SVG remains supported, but originals are delivered as downloads rather than inline documents to avoid executing SVG active content in the host application's origin.
|
|
337
|
+
|
|
288
338
|
Blobs that existed before the engine was installed have no owner and are intentionally invisible to normal members. Admins can use **Import unowned legacy uploads** from the library to claim an image into their private library, then use the normal community-sharing control if appropriate. Do not expose legacy blobs by direct Active Storage URLs.
|
|
289
339
|
|
|
290
340
|
### Thumbnails aren't generating
|
|
@@ -296,12 +346,6 @@ Blobs that existed before the engine was installed have no owner and are intenti
|
|
|
296
346
|
rails active_storage:install && rails db:migrate
|
|
297
347
|
```
|
|
298
348
|
|
|
299
|
-
### Private S3 bucket — og:image not visible to crawlers
|
|
300
|
-
|
|
301
|
-
**Symptom:** Social media preview cards show no image for posts with private S3 URLs.
|
|
302
|
-
|
|
303
|
-
**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.
|
|
304
|
-
|
|
305
349
|
### Upload endpoint is open to the public
|
|
306
350
|
|
|
307
351
|
**Symptom:** Anyone can upload images to your media library.
|
|
@@ -347,7 +391,10 @@ gem build venus_media_library.gemspec # produces venus_media_library-<ver
|
|
|
347
391
|
gem push venus_media_library-<version>.gem # publish to RubyGems
|
|
348
392
|
```
|
|
349
393
|
|
|
350
|
-
|
|
394
|
+
Follow [the release guide](docs/RELEASING.md). Releases normally use GitHub
|
|
395
|
+
Actions trusted publishing; no RubyGems token is stored in the repository or
|
|
396
|
+
GitHub secrets. Bump `VenusMediaLibrary::VERSION` in
|
|
397
|
+
`lib/venus_media_library/version.rb` before each release.
|
|
351
398
|
|
|
352
399
|
## License
|
|
353
400
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Wiring (all via delegated events, no framework required):
|
|
4
4
|
// [data-ml-open][data-ml-target][data-ml-src] open the modal for a field
|
|
5
5
|
// [data-ml-close] close the modal
|
|
6
|
+
// [data-ml-picker-nav] switch category / paginate in-frame
|
|
6
7
|
// .ml-tile[data-ml-url][data-ml-signed-id] choose an image
|
|
7
8
|
// input[data-ml-upload] upload a new image
|
|
8
9
|
//
|
|
@@ -61,6 +62,8 @@
|
|
|
61
62
|
|
|
62
63
|
function chooseTile(tile) {
|
|
63
64
|
if (!activeTargetId) { closeModal(); return; }
|
|
65
|
+
// Assets whose type the field doesn't accept render disabled; never pick one.
|
|
66
|
+
if (tile.disabled || tile.classList.contains("ml-tile--disabled")) return;
|
|
64
67
|
var url = tile.getAttribute("data-ml-url");
|
|
65
68
|
var signedId = tile.getAttribute("data-ml-signed-id");
|
|
66
69
|
|
|
@@ -71,10 +74,12 @@
|
|
|
71
74
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
72
75
|
}
|
|
73
76
|
var hidden = document.querySelector('[data-ml-signed-id-for="' + activeTargetId + '"]');
|
|
74
|
-
if (hidden) {
|
|
77
|
+
if (hidden && signedId) {
|
|
75
78
|
hidden.value = signedId;
|
|
76
79
|
// 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.
|
|
80
|
+
// detach the current file; enable it now that a signed_id is set. External
|
|
81
|
+
// assets (static/cloud) carry no signed_id, so we skip this entirely and
|
|
82
|
+
// leave any existing attachment intact.
|
|
78
83
|
hidden.disabled = false;
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -92,6 +97,11 @@
|
|
|
92
97
|
form.append("file", file);
|
|
93
98
|
var share = document.querySelector("[data-ml-community-share]");
|
|
94
99
|
form.append("community_shared", share && share.checked ? "1" : "0");
|
|
100
|
+
// Constrain the upload to the opening field's accepted types (server
|
|
101
|
+
// re-validates); the frame carries them as data-ml-accept.
|
|
102
|
+
var f = frame();
|
|
103
|
+
var accept = f ? f.getAttribute("data-ml-accept") : null;
|
|
104
|
+
if (accept) form.append("accept", accept);
|
|
95
105
|
|
|
96
106
|
var headers = { "Accept": "application/json" };
|
|
97
107
|
var token = csrfToken();
|
|
@@ -102,9 +112,9 @@
|
|
|
102
112
|
if (!r.ok) throw new Error("Upload failed");
|
|
103
113
|
return r.json();
|
|
104
114
|
})
|
|
105
|
-
.then(function (
|
|
106
|
-
if (status) status.textContent = "Uploaded " +
|
|
107
|
-
prependTile(
|
|
115
|
+
.then(function (media) {
|
|
116
|
+
if (status) status.textContent = "Uploaded " + media.filename;
|
|
117
|
+
prependTile(media);
|
|
108
118
|
})
|
|
109
119
|
.catch(function () { if (status) status.textContent = "Upload failed."; });
|
|
110
120
|
}
|
|
@@ -117,7 +127,7 @@
|
|
|
117
127
|
return "images";
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
function prependTile(
|
|
130
|
+
function prependTile(media) {
|
|
121
131
|
var grid = document.querySelector("[data-ml-grid]");
|
|
122
132
|
if (!grid) return;
|
|
123
133
|
// Build with DOM methods (not innerHTML): filenames are user-controlled, so
|
|
@@ -125,21 +135,29 @@
|
|
|
125
135
|
var btn = document.createElement("button");
|
|
126
136
|
btn.type = "button";
|
|
127
137
|
btn.className = "ml-tile";
|
|
128
|
-
btn.setAttribute("data-ml-signed-id",
|
|
129
|
-
btn.setAttribute("data-ml-url",
|
|
130
|
-
btn.setAttribute("data-ml-filename",
|
|
131
|
-
btn.title =
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
138
|
+
btn.setAttribute("data-ml-signed-id", media.signed_id);
|
|
139
|
+
btn.setAttribute("data-ml-url", media.url);
|
|
140
|
+
btn.setAttribute("data-ml-filename", media.filename);
|
|
141
|
+
btn.title = media.filename;
|
|
142
|
+
|
|
143
|
+
if (media.previewable) {
|
|
144
|
+
var img = document.createElement("img");
|
|
145
|
+
img.src = media.thumb_url;
|
|
146
|
+
img.alt = media.filename;
|
|
147
|
+
img.loading = "lazy";
|
|
148
|
+
btn.appendChild(img);
|
|
149
|
+
} else {
|
|
150
|
+
var documentTile = document.createElement("span");
|
|
151
|
+
documentTile.className = "ml-tile__document";
|
|
152
|
+
documentTile.setAttribute("aria-hidden", "true");
|
|
153
|
+
documentTile.textContent = media.content_type === "application/pdf" ? "PDF" : "FILE";
|
|
154
|
+
btn.appendChild(documentTile);
|
|
155
|
+
}
|
|
137
156
|
|
|
138
157
|
var name = document.createElement("span");
|
|
139
158
|
name.className = "ml-tile__name";
|
|
140
|
-
name.textContent =
|
|
159
|
+
name.textContent = media.filename;
|
|
141
160
|
|
|
142
|
-
btn.appendChild(img);
|
|
143
161
|
btn.appendChild(name);
|
|
144
162
|
grid.insertBefore(btn, grid.firstChild);
|
|
145
163
|
}
|
|
@@ -156,6 +174,15 @@
|
|
|
156
174
|
closeModal();
|
|
157
175
|
return;
|
|
158
176
|
}
|
|
177
|
+
// Category tabs / "load older" reload the picker frame in place. When Turbo
|
|
178
|
+
// is present it drives the frame via data-turbo-frame; otherwise fetch it.
|
|
179
|
+
var nav = e.target.closest("[data-ml-picker-nav]");
|
|
180
|
+
if (nav && nav.closest("#" + MODAL_ID)) {
|
|
181
|
+
if (window.Turbo && frame() && frame().tagName.toLowerCase() === "turbo-frame") return;
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
loadFrame(nav.getAttribute("href"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
159
186
|
var tile = e.target.closest(".ml-tile");
|
|
160
187
|
if (tile && tile.closest("#" + MODAL_ID)) {
|
|
161
188
|
e.preventDefault();
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Full-page media library management workspace shell. Namespaced under .ml-* to
|
|
3
|
+
* avoid clashing with the host app. This styles the /media full-page context
|
|
4
|
+
* only; the picker modal keeps its own lean layout in picker.css.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
.ml-body { margin: 0; }
|
|
8
|
+
|
|
9
|
+
/* 30% left navigation column beside a 70% content column. */
|
|
10
|
+
.ml-layout {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: stretch;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
}
|
|
15
|
+
.ml-layout__nav {
|
|
16
|
+
flex: 0 0 30%;
|
|
17
|
+
max-width: 30%;
|
|
18
|
+
box-sizing: border-box;
|
|
19
|
+
background: #f7f8fa;
|
|
20
|
+
border-right: 1px solid #e2e2e2;
|
|
21
|
+
}
|
|
22
|
+
.ml-layout__content {
|
|
23
|
+
flex: 1 1 70%;
|
|
24
|
+
min-width: 0; /* let the grid shrink instead of overflowing the row */
|
|
25
|
+
box-sizing: border-box;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Sidebar navigation. */
|
|
29
|
+
.ml-nav {
|
|
30
|
+
position: sticky;
|
|
31
|
+
top: 0;
|
|
32
|
+
padding: 1rem .75rem;
|
|
33
|
+
}
|
|
34
|
+
.ml-nav__brand {
|
|
35
|
+
font-weight: 700;
|
|
36
|
+
font-size: 1.05rem;
|
|
37
|
+
color: #1d4f80;
|
|
38
|
+
padding: .25rem .75rem 1rem;
|
|
39
|
+
}
|
|
40
|
+
.ml-nav__list {
|
|
41
|
+
list-style: none;
|
|
42
|
+
margin: 0;
|
|
43
|
+
padding: 0;
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-direction: column;
|
|
46
|
+
gap: .15rem;
|
|
47
|
+
}
|
|
48
|
+
.ml-nav__link {
|
|
49
|
+
display: block;
|
|
50
|
+
padding: .55rem .75rem;
|
|
51
|
+
color: #333;
|
|
52
|
+
text-decoration: none;
|
|
53
|
+
border-radius: 6px;
|
|
54
|
+
border-left: 3px solid transparent;
|
|
55
|
+
}
|
|
56
|
+
.ml-nav__link:hover { background: #eef1f5; color: #1d4f80; }
|
|
57
|
+
.ml-nav__link[aria-current="page"] {
|
|
58
|
+
background: #e7eefb;
|
|
59
|
+
color: #1d4f80;
|
|
60
|
+
border-left-color: #2b6cb0;
|
|
61
|
+
font-weight: 600;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Responsive: stack the nav above the content and lay entries out as a wrapping
|
|
65
|
+
* horizontal bar on narrow viewports. */
|
|
66
|
+
@media (max-width: 640px) {
|
|
67
|
+
.ml-layout { flex-direction: column; min-height: 0; }
|
|
68
|
+
.ml-layout__nav {
|
|
69
|
+
flex-basis: auto;
|
|
70
|
+
max-width: none;
|
|
71
|
+
border-right: 0;
|
|
72
|
+
border-bottom: 1px solid #e2e2e2;
|
|
73
|
+
}
|
|
74
|
+
.ml-nav { position: static; padding: .5rem; }
|
|
75
|
+
.ml-nav__brand { padding: .25rem .5rem .5rem; }
|
|
76
|
+
.ml-nav__list { flex-direction: row; flex-wrap: wrap; gap: .25rem; }
|
|
77
|
+
.ml-nav__link { border-left: 0; border-bottom: 3px solid transparent; }
|
|
78
|
+
.ml-nav__link[aria-current="page"] {
|
|
79
|
+
border-left-color: transparent;
|
|
80
|
+
border-bottom-color: #2b6cb0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
.ml-library__header { display: flex; align-items: baseline; gap: 1rem; }
|
|
23
23
|
.ml-library__count { color: #666; }
|
|
24
24
|
|
|
25
|
+
.ml-tabs { display: flex; gap: .25rem; flex-wrap: wrap; margin: 0 0 1rem; border-bottom: 1px solid #d7d7d7; }
|
|
26
|
+
.ml-tabs__tab { padding: .5rem .75rem; color: #444; text-decoration: none; border: 1px solid transparent; border-bottom: 0; border-radius: 4px 4px 0 0; }
|
|
27
|
+
.ml-tabs__tab:hover { background: #f5f5f5; color: #222; }
|
|
28
|
+
.ml-tabs__tab[aria-current="page"] { color: #1d4f80; background: #fff; border-color: #d7d7d7; font-weight: 600; margin-bottom: -1px; }
|
|
29
|
+
|
|
25
30
|
.ml-grid {
|
|
26
31
|
display: grid;
|
|
27
32
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
@@ -42,6 +47,17 @@
|
|
|
42
47
|
}
|
|
43
48
|
.ml-tile:hover { border-color: #2b6cb0; box-shadow: 0 0 0 2px rgba(43,108,176,.2); }
|
|
44
49
|
.ml-tile img { width: 100%; height: 120px; object-fit: cover; display: block; background: #fafafa; }
|
|
50
|
+
.ml-tile__document {
|
|
51
|
+
display: grid;
|
|
52
|
+
place-items: center;
|
|
53
|
+
width: 100%;
|
|
54
|
+
height: 120px;
|
|
55
|
+
background: #f8fafc;
|
|
56
|
+
color: #b42318;
|
|
57
|
+
font-size: 1.05rem;
|
|
58
|
+
font-weight: 700;
|
|
59
|
+
letter-spacing: .08em;
|
|
60
|
+
}
|
|
45
61
|
.ml-tile__name {
|
|
46
62
|
font-size: .75rem;
|
|
47
63
|
padding: .35rem .4rem;
|
|
@@ -51,6 +67,13 @@
|
|
|
51
67
|
text-overflow: ellipsis;
|
|
52
68
|
}
|
|
53
69
|
|
|
70
|
+
/* Disabled tiles: assets whose type the opening field does not accept. */
|
|
71
|
+
.ml-tile--disabled { cursor: not-allowed; opacity: .4; filter: grayscale(1); }
|
|
72
|
+
.ml-tile--disabled:hover { border-color: #e2e2e2; box-shadow: none; }
|
|
73
|
+
|
|
74
|
+
/* Category tabs inside the modal sit just under the upload toolbar. */
|
|
75
|
+
.ml-picker__tabs { margin-top: .25rem; }
|
|
76
|
+
|
|
54
77
|
.ml-empty { color: #777; padding: 1rem; text-align: center; }
|
|
55
78
|
.ml-pagination { display: flex; gap: .5rem; justify-content: center; }
|
|
56
79
|
|
|
@@ -80,3 +103,12 @@
|
|
|
80
103
|
.ml-picker__toolbar { display: flex; align-items: center; gap: 1rem; margin-bottom: .5rem; }
|
|
81
104
|
.ml-picker__hint { color: #666; font-size: .85rem; }
|
|
82
105
|
.ml-picker__more { text-align: center; }
|
|
106
|
+
|
|
107
|
+
/* Administrator configuration overview */
|
|
108
|
+
.ml-settings__intro { max-width: 54rem; color: #555; }
|
|
109
|
+
.ml-settings__section { margin-top: 1.5rem; }
|
|
110
|
+
.ml-settings__section h2 { font-size: 1.05rem; margin-bottom: .5rem; }
|
|
111
|
+
.ml-settings__list { display: grid; grid-template-columns: minmax(10rem, 16rem) 1fr; gap: .5rem 1rem; margin: 0; }
|
|
112
|
+
.ml-settings__list dt { font-weight: 600; }
|
|
113
|
+
.ml-settings__list dd { margin: 0; }
|
|
114
|
+
.ml-settings__types { margin: 0; padding-left: 1.25rem; }
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module VenusMediaLibrary
|
|
2
2
|
class ApplicationController < ActionController::Base
|
|
3
|
-
helper_method :venus_media_library_admin
|
|
3
|
+
helper_method :venus_media_library_admin?,
|
|
4
|
+
:venus_media_library_nav_items,
|
|
5
|
+
:venus_media_library_active_nav
|
|
4
6
|
# Run the host-configured access gate (if any) before every engine action.
|
|
5
7
|
# The engine ships open; the host restricts the picker/upload endpoints by
|
|
6
8
|
# setting VenusMediaLibrary.configuration.authenticate_with to a proc.
|
|
@@ -28,16 +30,70 @@ module VenusMediaLibrary
|
|
|
28
30
|
instance_exec(venus_media_library_user, &VenusMediaLibrary.configuration.admin)
|
|
29
31
|
end
|
|
30
32
|
|
|
33
|
+
# Destinations for the full-page management workspace's left navigation.
|
|
34
|
+
# Role-gated: legacy imports and the settings overview are admin-only,
|
|
35
|
+
# mirroring the `require_venus_media_library_admin!` gate those controllers
|
|
36
|
+
# already enforce, so the sidebar never advertises a forbidden link.
|
|
37
|
+
def venus_media_library_nav_items
|
|
38
|
+
items = [
|
|
39
|
+
{ key: :library, controller: "images", label: "My Library", path: images_path },
|
|
40
|
+
{ key: :legacy, controller: "legacy_assets", label: "Legacy Assets", path: legacy_assets_path, admin: true },
|
|
41
|
+
{ key: :static, controller: "static_assets", label: "Static Assets", path: static_assets_path },
|
|
42
|
+
{ key: :cloud, controller: "cloud_assets", label: "Cloud Assets", path: cloud_assets_path },
|
|
43
|
+
{ key: :community, controller: "community_assets", label: "Community", path: community_assets_path },
|
|
44
|
+
{ key: :settings, controller: "settings", label: "Settings", path: settings_path, admin: true }
|
|
45
|
+
]
|
|
46
|
+
return items if venus_media_library_admin?
|
|
47
|
+
|
|
48
|
+
items.reject { |item| item[:admin] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The nav key for the controller handling the current request, used by the
|
|
52
|
+
# sidebar to highlight the active section without each view passing a local.
|
|
53
|
+
def venus_media_library_active_nav
|
|
54
|
+
venus_media_library_nav_items.find { |item| item[:controller] == controller_name }&.fetch(:key)
|
|
55
|
+
end
|
|
56
|
+
|
|
31
57
|
def visible_media_assets
|
|
32
|
-
scope =
|
|
58
|
+
scope = scoped_media_assets
|
|
33
59
|
return scope if venus_media_library_admin?
|
|
34
60
|
|
|
35
61
|
scope.where(community_shared: true).or(scope.where(owner: venus_media_library_user))
|
|
36
62
|
end
|
|
37
63
|
|
|
38
64
|
def unowned_legacy_image_blobs
|
|
39
|
-
ActiveStorage::Blob.where("content_type LIKE ?", "image/%")
|
|
40
|
-
|
|
65
|
+
scope = ActiveStorage::Blob.where("content_type LIKE ?", "image/%")
|
|
66
|
+
.where.not(id: VenusMediaLibrary::Asset.select(:blob_id))
|
|
67
|
+
instance_exec(scope, &VenusMediaLibrary.configuration.legacy_blob_scope)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def scoped_media_assets
|
|
71
|
+
scope = VenusMediaLibrary::Asset.includes(:blob)
|
|
72
|
+
instance_exec(scope, &VenusMediaLibrary.configuration.asset_scope)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def load_media_assets(scope)
|
|
76
|
+
@page = [ params.fetch(:page, 1).to_i, 1 ].max
|
|
77
|
+
@per_page = requested_per_page
|
|
78
|
+
offset = (@page - 1) * @per_page
|
|
79
|
+
|
|
80
|
+
@total_count = scope.count
|
|
81
|
+
@blobs = scope.order(created_at: :desc).offset(offset).limit(@per_page).to_a
|
|
82
|
+
@has_more = offset + @blobs.size < @total_count
|
|
83
|
+
@images = @blobs.map { |asset| ml_image_payload(asset) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def load_host_assets(callback)
|
|
87
|
+
@assets = Array(instance_exec(&callback)).map do |asset|
|
|
88
|
+
asset.to_h.symbolize_keys.slice(:filename, :url, :content_type, :byte_size)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def requested_per_page
|
|
93
|
+
requested = params[:per_page].presence&.to_i
|
|
94
|
+
value = requested&.positive? ? requested : VenusMediaLibrary.configuration.per_page.to_i
|
|
95
|
+
|
|
96
|
+
value.clamp(1, ImagesController::MAX_PER_PAGE)
|
|
41
97
|
end
|
|
42
98
|
|
|
43
99
|
def require_venus_media_library_admin!
|
|
@@ -3,7 +3,7 @@ module VenusMediaLibrary
|
|
|
3
3
|
def show
|
|
4
4
|
asset = visible_media_assets.find(params[:id])
|
|
5
5
|
send_data asset.blob.download, filename: asset.blob.filename.to_s,
|
|
6
|
-
type: asset.blob.content_type, disposition:
|
|
6
|
+
type: asset.blob.content_type, disposition: delivery_disposition(asset.blob)
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def thumbnail
|
|
@@ -16,5 +16,15 @@ module VenusMediaLibrary
|
|
|
16
16
|
send_data representation.download, filename: blob.filename.to_s,
|
|
17
17
|
type: representation.image.content_type, disposition: "inline"
|
|
18
18
|
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# SVG is accepted for editorial use but is delivered as a download. Inline
|
|
23
|
+
# SVG may execute active content in a host application's origin. PDFs are
|
|
24
|
+
# also attachments: they remain selectable media but are not embedded in
|
|
25
|
+
# the host application's origin.
|
|
26
|
+
def delivery_disposition(blob)
|
|
27
|
+
[ "image/svg+xml", "application/pdf" ].include?(blob.content_type) ? "attachment" : "inline"
|
|
28
|
+
end
|
|
19
29
|
end
|
|
20
30
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module VenusMediaLibrary
|
|
2
|
+
class CloudAssetsController < ApplicationController
|
|
3
|
+
def index
|
|
4
|
+
load_host_assets(VenusMediaLibrary.configuration.cloud_assets)
|
|
5
|
+
|
|
6
|
+
respond_to do |format|
|
|
7
|
+
format.html
|
|
8
|
+
format.json { render json: { assets: @assets } }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module VenusMediaLibrary
|
|
2
|
+
class CommunityAssetsController < ApplicationController
|
|
3
|
+
include VenusMediaLibrary::ImagesHelper
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
load_media_assets(scoped_media_assets.where(community_shared: true))
|
|
7
|
+
|
|
8
|
+
respond_to do |format|
|
|
9
|
+
format.html
|
|
10
|
+
format.json do
|
|
11
|
+
render json: { images: @images, page: @page, has_more: @has_more, total: @total_count }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|