Per-file viewer LiveComponent: <Fresco.canvas> + <Etcher.layer> for
images, video / PDF / icon fallback for other types, plus a sidebar
with filename + Download + metadata + comments thread. Owns the
annotation lifecycle (composer popover, persistence sync via
EtcherAdapter, the patch-shape / delete-shape JS bridges).
Shared between MediaBrowser (admin file grid → click image → modal)
and MediaViewer (lightbox embedded by MediaGallery and other
consumers). Both parents own their own modal shell + prev/next
navigation; this component is just the per-file content.
Required assigns
:id— DOM id, must include the file uuid so the parent's prev/next navigation remounts the component on file change (and Fresco'sphx-update="ignore"canvas DOM is replaced wholesale with the new file's image).:file— curated file map (%{file_uuid, mime_type, urls, filename, file_type, size, inserted_at, ...}). Same shapeMediaBrowser'suploaded_filescarries.:current_user— logged-in user; nil-tolerant. When nil the composer + comments thread don't render (no user to attribute a comment to).:parent_id— DOM id of the outer LiveComponent (the modal host). Currently unused by this component, but kept on the assigns map so future cross-component send_updates have a target without re-plumbing the API.
A board instead of a file
Pass :board (and no :file) to draw on an EMPTY canvas — a projects
whiteboard, say: %{target_type: "projects_whiteboard", target_uuid: board.uuid, width: 1920, height: 1080, background: nil}. Fresco renders
the scene with zero images (infinite_canvas), Etcher draws over it,
and every shape persists against the target pair instead of a file
(V183). No file means no rotation, no thumbnail job, no comment thread
and no sidebar — the board is the canvas and its tools. The id should
include the target uuid for the same remount reason as a file.
Optional assigns
:can_annotate(defaulttrue) — whether THIS viewer may change the drawings.falseis read-only: every shape arrives locked (Etcher'sreadonly, so it renders and tooltips but cannot be moved, resized, deleted or edited), the drawing toolbar and the pencil are gone, and the server refusesetcher:annotations-changed/etcher:shape-drawnoutright — Etcher's flag is UX, this is the boundary. Hosts pass their own write permission (a project member'scan_write); the default keeps every existing host as it was.:viewer_only(defaultfalse) — render only the canvas / composer column; suppresses the close button and the sidebar (filename + Download + metadata + comments). Used by standalone-page hosts likeMediaDetailthat have their own surrounding chrome (admin actions, metadata editor, file details).:edit_target(defaultnil) —{module, id}of a LiveComponent that opens the image editor. When set, the sidebar of an editable image shows "Edit image", which sends itsend_update(module, id: id, open_image_editor: file_uuid).MediaBrowserpasses itself.:write_scope(defaultnil) — the host's scope folder uuid. What this component writes onto the FILE row (title/description, a persisted rotation) shows in every folder holding the file, so a scoped host must not write a file that is merely linked into its scope — a content duplicate someone else uploaded first is theirs. With a scope set, those writes need the file's HOME folder inside it (Storage.within_scope?/2); the editor is not offered otherwise and a rotation stays view-only.nilmeans unscoped.MediaBrowserpasses its own scope, the rule its grid rotation already follows.:details_path(defaultnil) — when set, the sidebar shows an "Open details page" button navigating to this path. Admin-context hosts (MediaBrowserwithadmin={true}) pass the file's/admin/media/:uuidpage; public hosts leave it off.:featured(defaultnil) — mirrorsMediaBrowser's own:featuredattr (nil= off, or%{uuid: uuid | nil}). When set and the open file is an image, the sidebar shows a "Set as featured" / "Unset featured" button that posts"set_featured"/"unset_featured"straight to:parent_id(samephx-targetrouting the close/prev/next buttons use) — this component does not own the pointer and has no event handler of its own for it.
Summary
Functions
Build the comment_decorations registry that CommentsComponent
uses to render external labels above comments. The comments
package speaks a generic %{metadata_key => %{value => entry}}
vocabulary; we package annotation titles under the
"annotation_uuid" metadata key (matching the back-reference
comments already store via metadata["annotation_uuid"]).
Runtime check for whether the optional phoenix_kit_comments package
is installed AND enabled. Public so standalone-page hosts can gate
their own embed of CommentsComponent the same way the sidebar does.
Loads annotations for a file into the curated map shape this component
uses internally (uuid / kind / geometry / style / metadata with
comment_* + title injected). Public so standalone-page hosts like
MediaDetail can build their own decoration registry off the same
shape without re-querying the schema by hand.
load_annotations_for/1 for any target: a file goes through the
comment-preview loader; any other target has no comment threads, so
its rows carry the zero-comment shape (badge 0, no preview).
The same shape, for a file nobody is looking at yet.
The picture's shape as a CSS aspect-ratio value, or nil for a file that
has no shape to give.
Callback implementation for Phoenix.LiveComponent.render/1.
Whether the viewer's info sidebar will be open for this user.
Functions
Build the comment_decorations registry that CommentsComponent
uses to render external labels above comments. The comments
package speaks a generic %{metadata_key => %{value => entry}}
vocabulary; we package annotation titles under the
"annotation_uuid" metadata key (matching the back-reference
comments already store via metadata["annotation_uuid"]).
Source for the label: load_annotations_for/1 projects the
schema's title column into metadata["title"] (so Etcher's
JS tooltip can read it as a single map). We read from the same
place rather than bolt a top-level field onto the curated
annotation map. Only entries with non-empty titles are
included; an annotation without a title contributes nothing
(and the comment renders without a label block).
Public so standalone-page hosts (MediaDetail) can build the
same registry against the annotations they loaded via
load_annotations_for/1.
Runtime check for whether the optional phoenix_kit_comments package
is installed AND enabled. Public so standalone-page hosts can gate
their own embed of CommentsComponent the same way the sidebar does.
Loads annotations for a file into the curated map shape this component
uses internally (uuid / kind / geometry / style / metadata with
comment_* + title injected). Public so standalone-page hosts like
MediaDetail can build their own decoration registry off the same
shape without re-querying the schema by hand.
load_annotations_for/1 for any target: a file goes through the
comment-preview loader; any other target has no comment threads, so
its rows carry the zero-comment shape (badge 0, no preview).
The same shape, for a file nobody is looking at yet.
Stepping prev/next inside the viewer repaints the instant stand-in with the neighbour's bitmap, and the popup is sized to the picture now — so a neighbour whose shape nobody announced would hand over by resizing the whole box. The browser reads these off the modal's dataset; rotation comes from the row, since no viewer has loaded that file's metadata yet.
The picture's shape as a CSS aspect-ratio value, or nil for a file that
has no shape to give.
The stacked layout sizes the picture pane to this instead of to a share of the popup's height. A 1080×2840 window makes the popup 2556px tall and a landscape photograph needs about 480 of them: the rest was empty canvas above and below the picture, with the details panel pushed to the far bottom of a popup the picture had no use for. Sized to the picture, the pane asks for the height it needs, the panel sits directly under it, and the popup is as tall as the two of them together.
Rotation is part of the shape. A quarter turn swaps the axes, so the pane has to turn with it or a sideways photograph gets a pane the wrong way round — taller than the picture and narrower than it needs. A half turn leaves the shape alone.
nil for anything with no dimensions recorded — a PDF, an audio file, a
picture whose probe failed — and the pane falls back to a fixed share of
the popup, which is what every file used to get.
Callback implementation for Phoenix.LiveComponent.render/1.
Whether the viewer's info sidebar will be open for this user.
Used by the media browser's instant stand-in to predict the layout it is
standing in for — the sidebar is open by default, so a stand-in that paints
the image over the full popup shrinks it a beat later when the real viewer
mounts, which reads as a flash. Reads the same per-user flag the viewer
itself loads; nil user (or a never-toggled one) means open, matching the
viewer's default.
Deliberately reads the user struct it is handed (usually the LiveView assign) rather than re-fetching the row: this is a prediction, and the stand-in's hook corrects itself from the layout the real viewer actually used, so a stale read costs one mispredicted frame at worst.