NEEDS DISCUSSION: Add admin asset library (/asset_library) with editable filename + caption#1953
NEEDS DISCUSSION: Add admin asset library (/asset_library) with editable filename + caption#1953maebeale wants to merge 12 commits into
Conversation
Admins had no way to see every uploaded asset in one place, and Primary/ Gallery asset titles were never editable in the UI (only RichTextAsset was). This surfaces all assets in one searchable index and makes the label editable inline for every type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
||
| private | ||
|
|
||
| def set_image |
There was a problem hiding this comment.
🤖 From Claude: Authorized with with: AssetPolicy explicitly so the rule is admin-only for every STI subtype. Without it, ActionPolicy would resolve to the subtype policy (e.g. PrimaryAssetPolicy#update? = authenticated?), which is looser than we want for the index.
| like = "%#{sanitize_sql_like(query)}%" | ||
| left_joins(:file_blob).where( | ||
| "assets.title LIKE :like OR assets.owner_type LIKE :like OR active_storage_blobs.filename LIKE :like", | ||
| like: like |
There was a problem hiding this comment.
🤖 From Claude: left_joins(:file_blob) is safe from row multiplication here because has_one_attached :file means at most one attachment per asset — so no distinct needed, and counts/pagination stay correct.
Renames the admin index from /images to /asset_library (the sprockets pipeline owns /assets, so a literal /assets resource would be shadowed) and reframes it around "assets" now that PDFs/docs are included. Adds a second inline-editable field for the download filename (blob), distinct from the caption (Asset#title), and orders filename above the attached-to record. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| Rails.application.routes.draw do | ||
| resources :primary_assets | ||
| resources :rich_text_assets | ||
| # Admin asset library. Served at /asset_library rather than /assets because the |
There was a problem hiding this comment.
🤖 From Claude: Routed at /asset_library rather than /assets on purpose: the sprockets pipeline claims the /assets/* prefix, so a resources :assets member route (/assets/:id) would be shadowed by asset serving, and the generated asset_path helper would collide with ActionView's asset_path.
| authorize! @asset, to: :update?, with: AssetPolicy | ||
|
|
||
| @asset.title = params.dig(:asset, :title) if params[:asset]&.key?(:title) | ||
| rename_file(params.dig(:asset, :filename)) |
There was a problem hiding this comment.
🤖 From Claude: Caption (title) and filename (blob) are edited independently — each field is its own turbo frame. The response renders both frames (_editable_fields) and Turbo swaps only the one that submitted, so editing the filename never clobbers an in-progress caption edit and vice versa.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renders a visual thumbnail for previewable files (PDFs via the Poppler previewer, images via variants) using file.representation, matching how resources display PDFs. Non-previewable files (e.g. Word docs) keep the file-type badge; PDFs without a previewer fall back to a red PDF icon. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renders PDFs in an <iframe> pointed at the same-origin inline blob URL so the browser's native viewer shows all pages scrollably, rather than a static first-page image. This also drops the Poppler dependency for PDF display. Images still use an image variant; other files keep the file-type badge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the search form to the collection Stimulus controller so the keyword box debounce-submits (400ms) and the type/attached-to selects submit on change, matching the other index pages — no more hitting Enter. Clear filters resets via collection#clearAndSubmit, and results blur while the next page loads. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The keyword search now matches the specific owner record's title/name (e.g. searching a workshop title like "Befriending" finds its assets), not just the owner type. Owner-name matches are resolved per owner_type since owners are polymorphic across models using title vs name columns; id-only queries strip the controller's polymorphic :owner include to avoid an eager-load JOIN. Attached-to line uses line-clamp-2 so longer owner titles are visible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admins searching the library often want just PDFs, or just images. A content-type filter narrows results without leaning on keyword guesses. Filters on the blob's content type (not the filename string) so a mislabeled extension can't slip through. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| base = Asset.includes(:owner, file_attachment: :blob) | ||
| base = base.where(type: params[:type]) if params[:type].present? | ||
| base = base.where(owner_type: params[:owner_type]) if params[:owner_type].present? | ||
| base = base.where(id: Asset.joins(:file_blob).where(active_storage_blobs: { content_type: params[:content_type] })) if params[:content_type].present? |
There was a problem hiding this comment.
🤖 From Claude: Filtered via a where(id: …) subquery on the blob join rather than chaining .joins(:file_blob) onto base — base already includes(file_attachment: :blob), and a second join referencing active_storage_blobs would collide with the eager-load alias. The subquery keeps the two blob references independent.
Move the Asset library card above Banners so the new library is easier to find among the system tools. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Admins need to stage assets in the library without immediately exposing them to search, and a fast way to add one-off assets that aren't tied to a specific owner record. This adds a hidden_from_search flag (default false), a search-visibility filter, and a New asset form that defaults the flag on so uploads stay hidden until deliberately surfaced. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
|
||
| # STI subclass to instantiate, constrained to the known TYPES so a stray param | ||
| # can't constantize into an arbitrary class. | ||
| def asset_class |
There was a problem hiding this comment.
🤖 From Claude: create instantiates the STI subclass via presence_in(Asset::TYPES) before constantize — a stray type param can't constantize into an arbitrary class, and an unknown/blank type safely falls back to the first known type. The permitted :type is stripped from the attributes hash since the subclass already fixes it.
Assets are created in the context of an owner record, and the standalone asset-create form couldn't set attributes reliably. Resources natively support hidden_from_search, so the library's create button now opens the resource form with "Hidden from search" pre-checked, and the unused asset new/create flow is removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the unused asset-level hidden_from_search column and instead scope the visibility filter through the owning Resource: "Hidden from search" returns only assets on a hidden resource, "Searchable" returns everything else. hidden_from_search stays a Resource-only concept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| base = base.where(type: params[:type]) if params[:type].present? | ||
| base = base.where(owner_type: params[:owner_type]) if params[:owner_type].present? | ||
| base = base.where(id: Asset.joins(:file_blob).where(active_storage_blobs: { content_type: params[:content_type] })) if params[:content_type].present? | ||
| hidden_ids = Asset.attached_to_hidden_resource.select(:id) |
There was a problem hiding this comment.
🤖 From Claude: Visibility keys off the owning Resource's hidden_from_search (a Resource-only flag), via an id-membership subquery so it composes with the type/owner/file-type filters. "searchable" is the complement (where.not(id: hidden_ids)), which correctly includes non-resource-owned assets and assets on non-hidden resources.

🤖 suggested review level: 3 Read 📖 new admin library with contained filter/search logic, a decorator, an admin-only policy, and inline edits that mutate title + Active Storage blob filename; no schema changes
What is the goal of this PR and why is this important?
/asset_library) listing all assets —PrimaryAsset,GalleryAsset,RichTextAsset,DownloadableAsset— including PDFs/docs.Asset#title(metadata label)How did you approach the change?
Asset.searchmatches a keyword against title, attached filename (joinactive_storage_blobs), and the owner model name;present_types/present_owner_types/present_content_typespower the dropdown filters. File-type filter keys off the blobcontent_type(not the filename string), so a mislabeled extension can't slip through.hidden_from_search(a Resource-only concept): "Hidden from search" returns only assets attached to a hidden resource; "Searchable" returns everything else. No asset-level column — the filter composes as an id-membership subquery so it stacks with the other filters.AssetsController#indexfollows the standard lazyturbo_frame+ filter pattern.#updatehandles the two inline fields independently — each is its own turbo frame.new_resource_path(hidden_from_search: true);resources#newnow honors that param to pre-check the box./asset_library, not/assets: the sprockets pipeline owns the/assets/*prefix and would shadow/assets/:id.AssetPolicygates index + inline edits to admins (with: AssetPolicy, so it holds across STI subtypes).AssetDecoratorcentralizes type label, owner label/link, filename, content-type badge, and thumbnail.max-w-none), and its admin-home card now sits above Banners.Anything else to add?
attached_to_hidden_resource+present_content_types, request (auth, filters incl. file type + resource visibility, caption edit, filename rename), policy, page_bg alignment.