Folder-scoped file attachments + featured image for Locations resources (Location, Space). Designed for multi-resource LVs: many Files cards can live on the same page, each keyed by an opaque string "scope" — typically the resource's id or a draft id.
This is a re-shape of the single-resource Attachments pattern in
PhoenixKitCatalogue.Attachments. Catalogue's version stores
everything in top-level socket assigns (:files_folder_uuid,
:featured_image_uuid, …), which forces one resource per LV.
Here, all that state lives in a per-scope map:
socket.assigns.attachments_by_scope = %{
"location" => %{folder_uuid: …, featured_image_uuid: …, files: […], …},
"floor-uuid-1" => %{…},
"new-uuid-XYZ" => %{…}
}Modal state (:show_media_selector and friends) stays shared at
the socket level — only one modal opens at a time — but tracks
:media_selector_scope so the picker's result applies to the
right resource.
Uploads use a single shared config (@upload_name). The dropzone
in each Files card calls set_active_upload_scope/2 on click so
handle_progress/3 knows which scope's folder the file belongs to.
Edge case: clicking dropzone A then dropzone B before either file
picker resolves will route the next-picked file to B — accepted
trade for keeping one upload config instead of N atom-named refs.
Usage
# Mount
socket
|> Attachments.mount(scope: "location", resource: location)
|> Attachments.mount(scope: "floor-1", resource: floor)
|> Attachments.allow_attachment_upload()
# Render — pass scope to each Files card render
<FilesCard scope="location" state={Attachments.state(@socket, "location")} … />
<FilesCard scope="floor-1" state={Attachments.state(@socket, "floor-1")} … />
# Events take scope via phx-value
def handle_event("open_featured_image_picker", %{"scope" => scope}, s),
do: Attachments.open_featured_image_picker(s, scope)
def handle_event("set_active_upload_scope", %{"scope" => scope}, s),
do: {:noreply, Attachments.set_active_upload_scope(s, scope)}
# Save-time — inject for each scope
params = Attachments.inject_attachment_data(params, socket, "location")
# After a `:new` resource is persisted, rename its pending folder
:ok = Attachments.maybe_rename_pending_folder_for(folder_uuid, saved_resource)Resource shape
Each scope's resource carries a data JSONB with
files_folder_uuid and featured_image_uuid keys. Add a clause
to folder_name_for/1 to support additional resource structs.
Summary
Functions
Registers the shared file input with a 20-file, 100MB ceiling and
auto-upload. Progress routes to handle_progress/3 which reads the
active upload scope to figure out the target folder.
Cancels an in-flight upload entry by ref.
Nulls the featured image pointer in the given scope (save persists).
Clears modal-state assigns; returns the plain socket.
Default empty per-scope state. Returned by state/2 when the scope
hasn't been mounted yet — keeps render-time templates safe to call
before mount runs.
Picks a heroicon name for a file based on its Storage type.
Returns {:ok, "<prefix>-<uuid>"} for known resource structs.
Public so multi-scope LVs can compute the target name without
re-implementing the prefix scheme.
Drops a scope's state (and clears the active-upload / modal scope pointers if they were pointing at this scope). Call when a draft is discarded so the per-scope map doesn't grow unbounded.
Renders a byte count as a human string. Nil-safe.
Routes the :media_selected reply by the modal's target. Featured-
image target promotes the first selected UUID into the scope tracked
by :media_selector_scope.
Initializes the per-scope map and shared modal assigns. Idempotent — safe to call multiple times; existing scope state is preserved.
Merges files_folder_uuid and featured_image_uuid for scope
into params["data"]. Call right before passing params to your
context's create/update.
Renames a known pending folder UUID to match the resource's
deterministic name. Non-fatal: rename failures log and return :ok.
Populates a single scope's state from resource.data. Pulls the
folder uuid (if any) + featured image (if any) + the folder's file
list. Existing other-scope entries are left untouched.
Opens the featured-image picker scoped to this resource's folder.
Stores scope in :media_selector_scope so the picker's reply
knows which scope's :featured_image_uuid to update.
Marks which scope is about to receive the next upload. Wire this
to phx-click on each Files card's dropzone so concurrent dropzones
route to the right folder.
Returns the per-scope state map (or the empty-state default if the scope hasn't been mounted). Always safe to call from a template.
Removes the file from the scope's folder. See per-case comments in
do_detach/2 — soft-trash for single-owner home folders, link
deletion when the file is multi-resource. Also clears featured if
the removed file was featured.
Translates LiveView upload error atoms to user-facing text.
Returns the upload ref name used by every Files card on the page.
Functions
Registers the shared file input with a 20-file, 100MB ceiling and
auto-upload. Progress routes to handle_progress/3 which reads the
active upload scope to figure out the target folder.
Cancels an in-flight upload entry by ref.
Nulls the featured image pointer in the given scope (save persists).
Clears modal-state assigns; returns the plain socket.
Default empty per-scope state. Returned by state/2 when the scope
hasn't been mounted yet — keeps render-time templates safe to call
before mount runs.
Picks a heroicon name for a file based on its Storage type.
Returns {:ok, "<prefix>-<uuid>"} for known resource structs.
Public so multi-scope LVs can compute the target name without
re-implementing the prefix scheme.
Drops a scope's state (and clears the active-upload / modal scope pointers if they were pointing at this scope). Call when a draft is discarded so the per-scope map doesn't grow unbounded.
Renders a byte count as a human string. Nil-safe.
Routes the :media_selected reply by the modal's target. Featured-
image target promotes the first selected UUID into the scope tracked
by :media_selector_scope.
Initializes the per-scope map and shared modal assigns. Idempotent — safe to call multiple times; existing scope state is preserved.
Merges files_folder_uuid and featured_image_uuid for scope
into params["data"]. Call right before passing params to your
context's create/update.
Renames a known pending folder UUID to match the resource's
deterministic name. Non-fatal: rename failures log and return :ok.
Populates a single scope's state from resource.data. Pulls the
folder uuid (if any) + featured image (if any) + the folder's file
list. Existing other-scope entries are left untouched.
Options
:files_grid(defaulttrue) — set tofalseto skip the per-mount DB query that enumerates the folder's files. Useful when the card only renders the featured-image control and doesn't need the grid.
Opens the featured-image picker scoped to this resource's folder.
Stores scope in :media_selector_scope so the picker's reply
knows which scope's :featured_image_uuid to update.
Marks which scope is about to receive the next upload. Wire this
to phx-click on each Files card's dropzone so concurrent dropzones
route to the right folder.
Returns the per-scope state map (or the empty-state default if the scope hasn't been mounted). Always safe to call from a template.
Removes the file from the scope's folder. See per-case comments in
do_detach/2 — soft-trash for single-owner home folders, link
deletion when the file is multi-resource. Also clears featured if
the removed file was featured.
Translates LiveView upload error atoms to user-facing text.
Returns the upload ref name used by every Files card on the page.