venus_media_library 0.1.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +93 -34
  4. data/app/assets/javascripts/venus_media_library/venus_media_library.js +46 -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 +92 -0
  8. data/app/controllers/venus_media_library/assets_controller.rb +30 -0
  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 +47 -24
  12. data/app/controllers/venus_media_library/legacy_assets_controller.rb +59 -0
  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 +18 -21
  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 +10 -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 +8 -2
  25. data/app/views/venus_media_library/legacy_assets/index.html.erb +20 -0
  26. data/app/views/venus_media_library/pickers/_picker.html.erb +18 -8
  27. data/app/views/venus_media_library/pickers/_tabs.html.erb +14 -0
  28. data/app/views/venus_media_library/pickers/_tile.html.erb +22 -0
  29. data/app/views/venus_media_library/pickers/_tile_body.html.erb +8 -0
  30. data/app/views/venus_media_library/settings/show.html.erb +45 -0
  31. data/app/views/venus_media_library/shared/_host_assets.html.erb +24 -0
  32. data/app/views/venus_media_library/shared/_sidebar.html.erb +24 -0
  33. data/app/views/venus_media_library/static_assets/index.html.erb +4 -0
  34. data/config/routes.rb +11 -0
  35. data/db/migrate/20260817190000_create_venus_media_library_assets.rb +10 -0
  36. data/lib/venus_media_library/engine.rb +8 -2
  37. data/lib/venus_media_library/version.rb +1 -1
  38. data/lib/venus_media_library.rb +34 -10
  39. metadata +49 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8bc0532620557b83e5d9441d9d7d3c8b6908e07de77b902387156212c5f34dbb
4
- data.tar.gz: e55f2d5bd5698921d1725718c5fa3c744a3ee365c78fa17a2fcde0886e195bb8
3
+ metadata.gz: 53f9b683221215b76b95f0248dd87938ebba0a7066c36ccc7b02dc23fece50e0
4
+ data.tar.gz: acf9d06d3f9a7712568f9f05c9584ccc9fda2772de1bcad6e1b2090c1cf07e8f
5
5
  SHA512:
6
- metadata.gz: 8a5b72c1b7b0ba5efbaacfc3e6f2e3fc98e2bfbf1248d2c102c171231b9967e2ce37bf39696a5fcdf596129fc0623c19bbf240292f5c6f5f682956218b99edcc
7
- data.tar.gz: bd469adfdf85aec05d9dd5dd6a69cb5188b5c1354ded20a85e040133c6f3bad3cb2aeaed0722c55ac75f4e5b5f29454b52269c05684ab6fd46dd40d7f3141e9c
6
+ metadata.gz: e1d247beff66e7d45750fcfa96813cdcb0bb7ed5a2cb0eb500486602f4b8354001ffc280fe399fbb504ad094b2e97b3715cb578b29e2aabce34d34b82fd4037f
7
+ data.tar.gz: e8928ccd013a969ff08cfef454886735297e5a74d84acc9b66a09335be91ede3a1a9f4f3dfbe59ede7a3b16acc4438b461b73b8c43c550e5ce01e989555b2999
data/CHANGELOG.md CHANGED
@@ -4,6 +4,82 @@ 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
+
49
+ ## [0.2.0] - 2026-08-17
50
+
51
+ ### Added
52
+ - Owner-scoped media records with private-by-default uploads and optional
53
+ community sharing.
54
+ - Host `current_user` and `current_user.admin?` conventions, configurable for
55
+ applications with a different authentication model.
56
+ - Authorization-aware original and thumbnail delivery routes.
57
+ - A tested Rails compatibility range through the 8.x series.
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
+
66
+ ## [0.1.1] - 2026-08-17
67
+
68
+ ### Added
69
+ - A self-contained SQLite dummy app and test suite; contributors and CI no
70
+ longer need PostgreSQL.
71
+ - CI coverage for RuboCop, the full RSpec suite, gem building, and package
72
+ validation.
73
+ - SVG upload coverage. `image/svg+xml` remains part of the default upload
74
+ allowlist.
75
+
76
+ ### Changed
77
+ - The dummy app and installation guide now mount the browsable library at
78
+ `/venus_media_library`; the engine root at that URL renders the image grid.
79
+ - `allowed_content_types` now strictly controls uploads instead of implicitly
80
+ permitting every `image/*` MIME type.
81
+ - JSON pagination is capped at 100 images per page.
82
+
7
83
  ## [0.1.0] - 2026-08-15
8
84
 
9
85
  ### 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,12 +29,21 @@ 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`:
35
44
 
36
45
  ```ruby
37
- mount VenusMediaLibrary::Engine, at: "/media"
46
+ mount VenusMediaLibrary::Engine, at: "/venus_media_library"
38
47
  ```
39
48
 
40
49
  Include the picker JavaScript once in your layout (Propshaft/Sprockets):
@@ -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 |
@@ -92,14 +107,18 @@ permitting the attachment param (e.g. `params.permit(:cover)`).
92
107
 
93
108
  ### Endpoints
94
109
 
95
- Mounted at your chosen path (examples assume `/media`):
110
+ Mounted at your chosen path (examples assume `/venus_media_library`):
96
111
 
97
112
  | Method | Path | Purpose |
98
113
  | --- | --- | --- |
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 |
114
+ | `GET` | `/venus_media_library` | Browsable HTML thumbnail grid |
115
+ | `GET` | `/venus_media_library/images` | HTML thumbnail grid (also `.json`) |
116
+ | `GET` | `/venus_media_library/images.json` | `{ images: [...], page:, has_more:, total: }` |
117
+ | `POST` | `/venus_media_library/images` | Upload a file (param `file`); returns the image JSON |
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`) |
103
122
 
104
123
  Each image payload includes `id`, `signed_id`, `filename`, `content_type`, `byte_size`, `url`, and `thumb_url`.
105
124
 
@@ -109,8 +128,10 @@ In an initializer (e.g. `config/initializers/venus_media_library.rb`):
109
128
 
110
129
  ```ruby
111
130
  VenusMediaLibrary.configure do |config|
112
- # Content types accepted by the uploader (any image/* is always allowed in the grid).
131
+ # Content types accepted by the uploader.
113
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"
114
135
 
115
136
  # [width, height] for the grid thumbnail variant.
116
137
  config.thumbnail_size = [300, 300]
@@ -118,40 +139,64 @@ VenusMediaLibrary.configure do |config|
118
139
  # Images per page in the index / picker.
119
140
  config.per_page = 40
120
141
 
142
+ # Reject uploads larger than this many bytes (10 MiB by default).
143
+ config.max_file_size = 10 * 1024 * 1024
144
+
121
145
  # Which Active Storage service to store uploads on. nil = the host app's
122
146
  # default service (Disk in dev, S3 in prod, etc.).
123
147
  config.storage_service = nil
124
148
 
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).
149
+ # Retained for compatibility. Library URLs are authorization-aware engine URLs.
128
150
  config.url_type = :redirect
129
151
 
130
152
  # Optional access gate. A proc run in the engine controller's context before
131
153
  # 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:
154
+ # main_app). Use it for a custom precondition before engine actions:
134
155
  #
135
156
  # config.authenticate_with = lambda do
136
157
  # redirect_to main_app.root_path unless current_user&.admin?
137
158
  # end
138
159
  config.authenticate_with = nil
160
+
161
+ # Ownership defaults to the host application's authentication convention.
162
+ config.current_user = -> { current_user }
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
+
139
170
  end
140
171
  ```
141
172
 
142
173
  ### Configuration Details
143
174
 
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.
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.
145
176
 
146
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.
147
178
 
148
- - **`per_page`** — Number of images to display per page. Smaller values suit mobile-friendly UIs; larger values reduce pagination clicks.
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.
180
+
181
+ - **`max_file_size`** — Maximum upload size in bytes. The default is 10 MiB.
149
182
 
150
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.
151
184
 
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.
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.
186
+
187
+ - **`admin`** — A callback that determines whether that user can manage all media. It defaults to `->(user) { user.admin? }`.
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
+
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.
155
200
 
156
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:
157
202
  ```ruby
@@ -181,6 +226,13 @@ Without these, variant generation fails and thumbnail images won't display.
181
226
 
182
227
  ## Styling & Customization
183
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
+
184
236
  All picker and library CSS is namespaced under `.ml-*` classes to avoid conflicts with the host app. The engine ships two stylesheets:
185
237
 
186
238
  - **`venus_media_library/application.css`** — Layout and structure (grid, pagination, forms).
@@ -232,16 +284,16 @@ If you must support IE11, you'll need polyfills. No Turbo Drive requirement, but
232
284
 
233
285
  ### The Picker Flow
234
286
 
235
- 1. **User clicks "Choose from library"** — Opens a modal via a Turbo Frame (`GET /media/picker?target=<input_id>`).
287
+ 1. **User clicks "Choose from library"** — Opens a modal via a Turbo Frame (`GET /venus_media_library/picker?target=<input_id>`).
236
288
  2. **Modal loads the image grid** — The frame fetches the index view, listing images newest-first with pagination.
237
289
  3. **User uploads or selects** —
238
290
  - **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.
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.
240
292
  4. **Form submission** — The host app form submits with the image data, storing it as a URL column or Active Storage attachment.
241
293
 
242
- ### URL Signing & Storage Agnosticism
294
+ ### Authorized delivery & storage agnosticism
243
295
 
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.
296
+ The engine serves library URLs through authorization-aware routes. It never directly accesses the storage backend; Active Storage routes the authorized byte requests.
245
297
 
246
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).
247
299
 
@@ -256,7 +308,7 @@ cd spec/dummy && bundle exec rspec ../
256
308
  Or use the included Rakefile:
257
309
 
258
310
  ```bash
259
- bundle exec rake spec
311
+ bundle exec rake app:spec
260
312
  ```
261
313
 
262
314
  When testing a host app that uses Media Library, you can:
@@ -275,6 +327,16 @@ When testing a host app that uses Media Library, you can:
275
327
 
276
328
  ## Troubleshooting
277
329
 
330
+ ### Ownership and community sharing
331
+
332
+ Each upload is owned by the user returned by `config.current_user` and starts private. A member can browse and download their own uploads; checking **Share with community** during upload makes that item visible to other signed-in members. Admins, as determined by `config.admin`, can browse and download every engine-managed upload.
333
+
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.
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
+
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.
339
+
278
340
  ### Thumbnails aren't generating
279
341
 
280
342
  **Symptom:** Gray placeholder squares instead of previews in the grid.
@@ -284,12 +346,6 @@ When testing a host app that uses Media Library, you can:
284
346
  rails active_storage:install && rails db:migrate
285
347
  ```
286
348
 
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
349
  ### Upload endpoint is open to the public
294
350
 
295
351
  **Symptom:** Anyone can upload images to your media library.
@@ -313,12 +369,12 @@ end
313
369
 
314
370
  **Solution:** Check browser console for errors. Verify:
315
371
  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`).
372
+ 2. The engine is mounted and accessible at your chosen path (recommended: `/venus_media_library`).
317
373
  3. No JavaScript errors in other assets are breaking the page.
318
374
 
319
375
  ## Development
320
376
 
321
- The engine ships with a dummy app under `spec/dummy` (Active Storage configured with the Disk service, engine mounted at `/media`).
377
+ The engine ships with a dummy app under `spec/dummy` (Active Storage configured with the Disk service, engine mounted at `/venus_media_library`).
322
378
 
323
379
  ```bash
324
380
  bundle install
@@ -335,7 +391,10 @@ gem build venus_media_library.gemspec # produces venus_media_library-<ver
335
391
  gem push venus_media_library-<version>.gem # publish to RubyGems
336
392
  ```
337
393
 
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.
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.
339
398
 
340
399
  ## License
341
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
 
@@ -90,6 +95,13 @@
90
95
 
91
96
  var form = new FormData();
92
97
  form.append("file", file);
98
+ var share = document.querySelector("[data-ml-community-share]");
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);
93
105
 
94
106
  var headers = { "Accept": "application/json" };
95
107
  var token = csrfToken();
@@ -100,9 +112,9 @@
100
112
  if (!r.ok) throw new Error("Upload failed");
101
113
  return r.json();
102
114
  })
103
- .then(function (image) {
104
- if (status) status.textContent = "Uploaded " + image.filename;
105
- prependTile(image);
115
+ .then(function (media) {
116
+ if (status) status.textContent = "Uploaded " + media.filename;
117
+ prependTile(media);
106
118
  })
107
119
  .catch(function () { if (status) status.textContent = "Upload failed."; });
108
120
  }
@@ -115,7 +127,7 @@
115
127
  return "images";
116
128
  }
117
129
 
118
- function prependTile(image) {
130
+ function prependTile(media) {
119
131
  var grid = document.querySelector("[data-ml-grid]");
120
132
  if (!grid) return;
121
133
  // Build with DOM methods (not innerHTML): filenames are user-controlled, so
@@ -123,21 +135,29 @@
123
135
  var btn = document.createElement("button");
124
136
  btn.type = "button";
125
137
  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";
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
+ }
135
156
 
136
157
  var name = document.createElement("span");
137
158
  name.className = "ml-tile__name";
138
- name.textContent = image.filename;
159
+ name.textContent = media.filename;
139
160
 
140
- btn.appendChild(img);
141
161
  btn.appendChild(name);
142
162
  grid.insertBefore(btn, grid.firstChild);
143
163
  }
@@ -154,6 +174,15 @@
154
174
  closeModal();
155
175
  return;
156
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
+ }
157
186
  var tile = e.target.closest(".ml-tile");
158
187
  if (tile && tile.closest("#" + MODAL_ID)) {
159
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; }