<turbo-frame data-turbo-action="advance" id="lui-main-layout" data-turbo-frame="lui-main-layout" class="min-h-0 " style=""> <div class="lui-layout-loading"> <div class="lui-layout-loading__content"> <div class="lui-skeleton "> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__footer"> <div class="lui-skeleton__bar--footer"></div> <div class="lui-skeleton__bar--footer lui-skeleton__bar--footer--invisible"></div> </div> </div> <div class="lui-skeleton lui-skeleton--full"> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__footer"> <div class="lui-skeleton__bar--footer"></div> <div class="lui-skeleton__bar--footer lui-skeleton__bar--footer--invisible"></div> </div> </div> <div class="lui-skeleton lui-skeleton--full"> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__item"> <div class="lui-skeleton__bar" style="width: 100%"></div> </div> <div class="lui-skeleton__footer"> <div class="lui-skeleton__bar--footer"></div> <div class="lui-skeleton__bar--footer lui-skeleton__bar--footer--invisible"></div> </div> </div> </div> </div> <div class="lui-main_layout__content" data-skeleton-loading="true"> <turbo-frame id="images_test"> <turbo-frame id="multiple_image_image_pokemon_1_full"> <div data-controller="images" data-images-editable-value="true" data-images-standalone-value="false" data-images-form-id-value="" data-images-with-attached-value="false" data-images-turbo-frame-id-value="multiple_image_image_pokemon_1_full" data-images-unique-id-value="multiple_image_image_pokemon_1_full" data-images-list-view-value="false" data-images-has-many-value="false" data-images-force-new-value="false" data-images-direct-upload-authorization-value="" data-images-direct-upload-url-value="/rails/active_storage/direct_uploads" data-images-urls-value="{"attach":"/loopos_ui/digimon_images","detach":"/loopos_ui/digimon_images"}"> <div class="lui-image lui-image--full" data-images-target="imageComponent" data-action="pubsub:action@window->images#executeAction"> <div role="status" class="hidden lui-image__loader" data-images-target="loader" > <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny lui-button--disabled w-fit w-fit relative" data-controller="lui--button" disabled="disabled"> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-spinner" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-outlined" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button> </div> <img class="hidden lui-image__image" data-images-target="image" src="" /> <div class="lui-image__placeholder lui-image__placeholder--editable flex" data-images-target="placeholder" > <div class="lui-icon h-[16px] w-[16px]"> <i class="fa-regular fa-image lui-icon__icon" style="font-size: 16px; color: white;"></i> </div> </div> <div class="overflow-visible lui-image__image-edit"> <form data-images-target="form" enctype="multipart/form-data" action="#" accept-charset="UTF-8" method="post"><input type="hidden" name="authenticity_token" value="FSPcIbwTHgOkRlxjk9DAS2bOtxJWIwY8Ujxm1Iqvk-CgdBRBCqZNJIl0CyW8Yl1U2mclsQJs8ym9OOdILj1Eeg" /> <input type="hidden" name="authenticity_token" id="authenticity_token" value="JfGwFsAeqV61ecQKqZtXzCR92cJN6h2XYVkhz2BiWrATXovZ415GRfjptxrl57njZOIJZztjaQnvHrau-u7z3A" /> <input type="hidden" autocomplete="off" value="6FrvgsVl3KM/maeUsHbaUDshXitCQxQUG6qxZ8wwqrvMQ9MeGD0jireOle2ajA7fWsLI4zyYnuXus7Jj2XqqGUFBph7DzlWixpfBhK6a0nUr1SoxUAZHGURyevo=--2bnxMxQ9uz6GjGib--5CgzOJIqOKQbmaFFcFWkmw==" data-images-target="uploadContext" name="context"> <div class="hidden" data-images-target="noImageUploader"> <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny w-fit w-fit relative" data-controller="lui--button" data-action="click->images#openFilePicker"> <div class="lui-tooltip hidden" data-controller="tooltips" data-tooltips-tippy-target-id-value="" data-tooltips-position-value="top" data-tooltips-interactive-value="false" > <div class="lui-tooltip__title"> Upload image here </div> </div> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-upload" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-outlined" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button> </div> <div class="hidden" data-images-target="withImageUploader"> <div data-controller="action-menu" data-action="modal:open@window->action-menu#disableTippy modal:close@window->action-menu#enableTippy" class="lui-action-menu"> <div data-action-menu-target="trigger"> <button class="lui-button lui-button--icon-only lui-button--neutral--secondary lui-button--size-tiny w-fit w-fit relative" data-controller="lui--button" type="button"> <div class="opacity-100 inline-flex" data-lui--button-target="leadingIcon"> <div class="flex items-center justify-center" style="width: 12px; height: 12px;"><i class="lui-button__icon lui-button__icon--tiny fa-regular fa-ellipsis-vertical" data-lui--button-target="leadingIcon"></i></div> </div> <div class="absolute w-full flex items-center justify-center opacity-0" data-lui--button-target="loadingIcon"> <i class="lui-m_icon animate-spin material-symbols-outlined" style="--lui-micon-size: 12px;"> progress_activity </i> </div> </button> </div> <div data-action-menu-target="menu" class="hidden lui-action-menu__wrapper" data-controller="modal form-submit pubsub"> <div class="lui-action-menu__options" role="menu" aria-orientation="vertical" aria-labelledby="options-menu"> <div class="contents cursor-pointer"> <div class="lui-action-menu__option " data-action="click->pubsub#publish" data-pubsub-unique-id-param="multiple_image_image_pokemon_1_full" data-pubsub-action-param="openFilePicker"> <div class="lui-action-menu__option-text cursor-default"> <i class="fa-regular fa-arrows-rotate"></i> <span>Upload new image</span> </div> </div> </div> <div class="contents cursor-pointer"> <div class="lui-action-menu__option " data-action="click->pubsub#publish" data-pubsub-unique-id-param="multiple_image_image_pokemon_1_full" data-pubsub-action-param="detachImage"> <div class="lui-action-menu__option-text cursor-default"> <i class="fa-regular fa-trash"></i> <span>Remove image</span> </div> </div> </div> </div> </div> </div> </div> <input accept="image/jpeg,image/png" class="hidden" data-direct-upload-url="/rails/active_storage/direct_uploads" data-images-target="file" data-action="change->images#previewAndSubmit" type="file" name="file" id="file" /> <input name="files[][]" type="hidden" value="" /><input accept="image/jpeg,image/png" multiple="multiple" class="hidden" data-direct-upload-url="/rails/active_storage/direct_uploads" data-images-target="fileMultiple" data-action="change->images#previewAndSubmitMultiple" type="file" name="files[][]" id="files[]" /> <input type="submit" name="commit" value="Save" data-images-target="submit" data-disable-with="Save" class="hidden" /> </form> </div> </div> <span class="lui-image__error" data-images-target="error"> Error message </span> </div> </turbo-frame> </turbo-frame> </div></turbo-frame><turbo-frame id="lui-main-layout-actions"></turbo-frame>No Usage documentation to display.
Create a markdown file in /project/test/components/loopos_ui/usage_documentation/image_v2.md.
<%= render LooposUi::MainLayout.new do %> <%= turbo_frame_tag "images_test" do %> <%= render LooposUi::V2::Image.new( model: Pokemon.first, association: :image, editable: true, rounded: params[:rounded] || false, size: params[:size] || "full", # accept: ommit this option to use the default config # accept: :all, # accept: [:svg, :gif] accept: [:jpeg, :png, "image/svg+xml"], path: main_app.loopos_ui_digimon_images_path, ) %> <% end %><% end %>No notes provided.
| Param | Description | Input |
|---|---|---|
|
— |
|
|
|
— |
|
Description
The Image V2 component is an enhanced version of the base Image component that provides image upload and management capabilities. It supports both single and multiple image handling, with options for different sizes, shapes, and editing capabilities.
Arguments
| Property | Default | Description |
|---|---|---|
model |
nil |
ActiveRecord model that the image belongs to |
association |
nil |
Name of the image association on the model (:image or :images) |
editable |
false |
Enables image upload and management controls |
size |
:full |
Size of the image container (:full or :small) |
rounded |
false |
Makes the image circular when true |
image_url |
nil |
Direct URL for preview when there is no attachment (image_tag). This is the display source; it does not set the upload field’s HTML name. |
name |
nil |
Optional base HTML name for the upload inputs. Defaults: file (has-one field) and files[] (has-many field). If set, that string is used for the single-file input; the multi-file input uses the same base with [] appended when missing (e.g. protocol_answers[0][values][] stays unchanged for the multi-file input when the name already ends in []). Client info protocol images use protocol_answers[n][values][] so params submit as values: ["<signed_id>"]. |
form_id |
nil |
When set, the component does not render an inner <form>; file inputs use the HTML form attribute to associate with the element with that id (avoids nested forms). Use the same id as your outer form_with. In standalone mode with form_id, the direct-upload encryption field is not submitted as name="context" (so it cannot override the host page’s params[:context]). After a successful upload, the linked form is auto-submitted via requestSubmit(). |
Standalone upload (editable without model)
When editable is true and model is not passed, the component runs in standalone mode (for example when you only pass name so the signed blob id participates in a parent form’s params).
- The encrypted payload for direct upload (
X-Lui-Context) containsacceptonly when there is no model. It does not includemodel_class/model_id/association. - The Stimulus controller does not POST to LoopOS
images#createin standalone mode (that endpoint attaches to a record and requires a full context). After direct upload it sets the hidden field with the signed id under yournameand updates the preview. - With
form_id, inputs are associated to that outer form; after success the controller callsrequestSubmit()on that form. Withoutform_id, the component keeps an internal form for upload/attach flows that needname="context"in the attach request body.
If you need LoopOS to attach blobs to an ActiveStorage association server-side via images#create, pass model and association as before.
Direct upload URL
File inputs set data-direct-upload-url to Rails.application.routes.url_helpers.rails_direct_uploads_path (typically /rails/active_storage/direct_uploads), with a prefix fallback if the helper is unavailable. This avoids generating a path under the engine mount when the component is rendered inside a nested route (which would cause RoutingError on POST to /…/loopos_ui/rails/active_storage/direct_uploads), and avoids *_url helpers that require default_url_options[:host].
| accept | configurable | Array of accepted file types (e.g., [:jpeg, :png, "image/svg+xml"]), or :all for all registerer image MIME types |
| list_view | false | Enables list view mode for multiple images |
| path | loopos_ui.images_path | Path to backend controller. Use when you want to extend the current LoopOS UI one |
accept get its default value from LooposUi.config.image_default_accept_formats, by default it accepts png, jpeg and webp.
You can change this in the initializer.
When extending the LoopOS UI images controller, set the ActiveSupport::Callbacks needed in your own controller, and then call run_callbacks around the block you want your changes to act. These callbacks have to be named :create or :destroy, since those are the hooks available. Each of them allowing the programmer to insert operations before and after their respective methods.
# Your custom controllerclass LooposUi::ExtendedImagesController < LooposUi::ImagesController #We can declare using Lambda, or methods set_callback :create, :before, -> { Rails.logger.info "Before" } set_callback :destroy, :after, :foo def foo Rails.logger.info "After" endendFeatures
Single Image Upload
- Default placeholder for empty state
- Upload button appears on hover when editable
- Supports direct image URLs
- Configurable accepted file types
Multiple Image Upload
- Supports ActiveStorage has_many relationships
- Optional list view mode
- Bulk upload capability
- Image deletion functionality
Styling Options
- Two size options: full and small
- Optional rounded/circular display
- Responsive design
- Hover states for interactive elements
Validations
The accept option is used to validate the file type during upload.
- In the frontend: checking the declared MIME type
- In the backend: checking the actual uploaded content type
In the case of multiple upload, the accept option is applied to each uploaded file, and the upload will fail if any of the files does not match the declared MIME type.
Leaving the accept option empty will allow all image MIME types to be uploaded (image/*), and in the backend will check if the uploaded MIME type is an image.
Note: This component uses it's own controller to handle the upload, using Direct Uploads. It also modifies the blob directly, so the model validation will not trigger. In a future version, this can change.