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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +60 -13
  4. data/app/assets/javascripts/venus_media_library/venus_media_library.js +44 -17
  5. data/app/assets/stylesheets/venus_media_library/layout.css +82 -0
  6. data/app/assets/stylesheets/venus_media_library/picker.css +32 -0
  7. data/app/controllers/venus_media_library/application_controller.rb +60 -4
  8. data/app/controllers/venus_media_library/assets_controller.rb +11 -1
  9. data/app/controllers/venus_media_library/cloud_assets_controller.rb +12 -0
  10. data/app/controllers/venus_media_library/community_assets_controller.rb +16 -0
  11. data/app/controllers/venus_media_library/images_controller.rb +39 -20
  12. data/app/controllers/venus_media_library/legacy_assets_controller.rb +6 -1
  13. data/app/controllers/venus_media_library/pickers_controller.rb +127 -10
  14. data/app/controllers/venus_media_library/settings_controller.rb +43 -0
  15. data/app/controllers/venus_media_library/static_assets_controller.rb +12 -0
  16. data/app/helpers/venus_media_library/images_helper.rb +9 -1
  17. data/app/helpers/venus_media_library/picker_helper.rb +16 -3
  18. data/app/models/venus_media_library/accepted_types.rb +59 -0
  19. data/app/models/venus_media_library/asset.rb +2 -0
  20. data/app/views/layouts/venus_media_library/application.html.erb +17 -5
  21. data/app/views/venus_media_library/cloud_assets/index.html.erb +4 -0
  22. data/app/views/venus_media_library/community_assets/index.html.erb +25 -0
  23. data/app/views/venus_media_library/images/_image.html.erb +6 -2
  24. data/app/views/venus_media_library/images/index.html.erb +5 -2
  25. data/app/views/venus_media_library/pickers/_picker.html.erb +17 -8
  26. data/app/views/venus_media_library/pickers/_tabs.html.erb +14 -0
  27. data/app/views/venus_media_library/pickers/_tile.html.erb +22 -0
  28. data/app/views/venus_media_library/pickers/_tile_body.html.erb +8 -0
  29. data/app/views/venus_media_library/settings/show.html.erb +45 -0
  30. data/app/views/venus_media_library/shared/_host_assets.html.erb +24 -0
  31. data/app/views/venus_media_library/shared/_sidebar.html.erb +24 -0
  32. data/app/views/venus_media_library/static_assets/index.html.erb +4 -0
  33. data/config/routes.rb +4 -0
  34. data/lib/venus_media_library/engine.rb +8 -2
  35. data/lib/venus_media_library/version.rb +1 -1
  36. data/lib/venus_media_library.rb +25 -1
  37. metadata +38 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1499112ecf8687cfa0f34bd242c7443117c945d4800e4d74a5d163e33d406c42
4
- data.tar.gz: ac7593b69b25cd85b6e1c029320ab776bd02e65284559f3814c69a9136ff5c95
3
+ metadata.gz: 53f9b683221215b76b95f0248dd87938ebba0a7066c36ccc7b02dc23fece50e0
4
+ data.tar.gz: acf9d06d3f9a7712568f9f05c9584ccc9fda2772de1bcad6e1b2090c1cf07e8f
5
5
  SHA512:
6
- metadata.gz: f4281c9f9c2b06fdab878293cf5607a181f3bc31e6db14ec5c83cf7f7e330ac5401e4e8a8855090f08367f2f29b6babf6440ed18a69e16427356b97cf66ba6a3
7
- data.tar.gz: 10e093ac59f1622f0f952977aab386e92194b309caa3f669f7dc0e48b49b54b4b6bcb868d948f7a0856c16bf874a60cb250fc48cb9f9e8be81b1c1bc4ea19e60
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 **image picker**.
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 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.
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 `ActiveStorage::Blob` records with an `image/*` content type, newest first
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
- ### URL Signing & Storage Agnosticism
294
+ ### Authorized delivery & storage agnosticism
247
295
 
248
- 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.
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
- `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.
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 (image) {
106
- if (status) status.textContent = "Uploaded " + image.filename;
107
- prependTile(image);
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(image) {
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", image.signed_id);
129
- btn.setAttribute("data-ml-url", image.url);
130
- btn.setAttribute("data-ml-filename", image.filename);
131
- btn.title = image.filename;
132
-
133
- var img = document.createElement("img");
134
- img.src = image.thumb_url;
135
- img.alt = image.filename;
136
- img.loading = "lazy";
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 = image.filename;
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 = VenusMediaLibrary::Asset.includes(:blob)
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
- .where.not(id: VenusMediaLibrary::Asset.select(:blob_id))
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: "inline"
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