PhoenixKitLocations.Attachments (PhoenixKitLocations v0.2.1)

Copy Markdown View Source

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

allow_attachment_upload(socket)

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.

cancel_attachment_upload(socket, ref)

Cancels an in-flight upload entry by ref.

close_media_selector(socket)

Clears modal-state assigns; returns the plain socket.

empty_scope_state()

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.

file_icon(arg1)

Picks a heroicon name for a file based on its Storage type.

folder_name_for(arg1)

@spec folder_name_for(any()) :: {:ok, String.t()} | :pending

Returns {:ok, "<prefix>-<uuid>"} for known resource structs. Public so multi-scope LVs can compute the target name without re-implementing the prefix scheme.

forget_scope(socket, scope)

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.

format_file_size(bytes)

Renders a byte count as a human string. Nil-safe.

handle_media_selected(socket, file_uuids)

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.

init(socket)

Initializes the per-scope map and shared modal assigns. Idempotent — safe to call multiple times; existing scope state is preserved.

inject_attachment_data(params, socket, scope)

Merges files_folder_uuid and featured_image_uuid for scope into params["data"]. Call right before passing params to your context's create/update.

maybe_rename_pending_folder_for(folder_uuid, resource)

@spec maybe_rename_pending_folder_for(String.t() | nil, any()) :: :ok

Renames a known pending folder UUID to match the resource's deterministic name. Non-fatal: rename failures log and return :ok.

mount(socket, opts)

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 (default true) — set to false to 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.

set_active_upload_scope(socket, scope)

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.

state(socket, scope)

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.

trash_file(socket, scope, uuid)

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.

upload_error_message(other)

Translates LiveView upload error atoms to user-facing text.

upload_name()

Returns the upload ref name used by every Files card on the page.