Packages

phoenix_kit

1.7.154
1.7.210 1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web components media_canvas_viewer.ex
Raw

lib/phoenix_kit_web/components/media_canvas_viewer.ex

defmodule PhoenixKitWeb.Components.MediaCanvasViewer do
# PhoenixKitComments is an optional sibling package — silence undefined
# warnings for parent apps that don't install it. The comments_enabled?/0
# helper guards every actual call at runtime with Code.ensure_loaded?/1.
@compile {:no_warn_undefined, [PhoenixKitComments, PhoenixKitComments.Web.CommentsComponent]}
@moduledoc """
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's `phx-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 shape
`MediaBrowser`'s `uploaded_files` carries.
* `: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.
## Optional assigns
* `:viewer_only` (default `false`) — render only the canvas /
composer column; suppresses the close button and the sidebar
(filename + Download + metadata + comments). Used by
standalone-page hosts like `MediaDetail` that have their own
surrounding chrome (admin actions, metadata editor, file
details).
"""
use PhoenixKitWeb, :live_component
require Logger
alias PhoenixKit.Annotations
alias PhoenixKit.Modules.Storage
alias PhoenixKit.Users.Auth
alias PhoenixKit.Utils.Format
# Etcher's toolbar color slots are a single palette shared across every
# Fresco viewer this user opens — stored in their `custom_fields`
# ("user meta") under this key, not per-file like annotations. The
# default is used until the user saves a palette of their own.
@etcher_colors_key "etcher_colors"
@default_etcher_colors ["#fca5a5", "#fdba74", "#fde68a", "#86efac", "#93c5fd"]
# The palette arrives from a client JS hook, so it's untrusted: keep only
# short color-shaped strings and cap the count before persisting into the
# user's `custom_fields`. Blocks arbitrary/oversized JSON from being stored
# and fed back into `<Etcher.layer colors={…}>`. Permissive on format (hex /
# rgb() / hsl() / named) but bounded in length and count.
@max_etcher_colors 24
@etcher_color_format ~r/\A[#0-9a-zA-Z(),.%\s]{1,32}\z/
# Etcher's global stroke defaults — the "ink" (line width / opacity / dash)
# every new stroke shape adopts — are, like the color palette, one set per
# user shared across every viewer, not per-file. Stored in `custom_fields`
# ("user meta") under this key; seeded from the saved value until the user
# edits the toolbar sliders. Defaults mirror Etcher's own built-in fallback.
@etcher_line_params_key "etcher_line_params"
@default_etcher_line_params %{"width" => 2, "opacity" => 1, "dash" => "solid"}
@etcher_dash_values ~w(solid dashed dotted)
# ──────────────────────────────────────────────────────────────
# Lifecycle
# ──────────────────────────────────────────────────────────────
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:viewer_canvas, nil)
|> assign(:viewer_annotations, [])
|> assign(:composing_annotation_uuid, nil)
|> assign(:etcher_colors, @default_etcher_colors)
|> assign(:etcher_line_params, @default_etcher_line_params)
|> assign(:viewer_only, false)}
end
@impl true
def update(%{action: :annotation_composer_posted} = assigns, socket) do
{:ok, finalize_annotation_compose(socket, assigns[:annotation_uuid], assigns[:title])}
end
def update(%{action: :annotation_composer_cancelled} = assigns, socket) do
{:ok, rollback_annotation_compose(socket, assigns[:annotation_uuid])}
end
# Inline title edit posted from the sidebar's CommentsComponent.
# The payload uses the comments package's generic decoration
# vocabulary (`metadata_key`, `metadata_value`, `label`); we
# translate to annotation-domain terms here. Same wire as
# `finalize_annotation_compose/3` minus the composer-specific
# bookkeeping: write → reload annotations → rebuild canvas →
# push patch-shape so the tooltip reflects the new title →
# CommentsComponent's next render picks up the fresh
# `comment_decorations` via the heex helper.
def update(%{action: :annotation_title_updated} = assigns, socket) do
{:ok, apply_annotation_title_update(socket, assigns[:metadata_value], assigns[:label])}
end
def update(assigns, socket) do
file = assigns[:file]
socket =
socket
|> assign(:id, assigns.id)
|> assign(:file, file)
|> assign(:current_user, assigns[:current_user])
|> assign(:parent_id, assigns[:parent_id])
|> assign(:has_prev, assigns[:has_prev] || false)
|> assign(:has_next, assigns[:has_next] || false)
|> assign(:viewer_only, assigns[:viewer_only] || false)
# First mount (or file changed via re-mount): hydrate annotations
# + canvas. Because the id encodes the file uuid, the parent's
# prev/next destroys this LC and mounts a fresh one, so this
# branch effectively only runs at mount time.
socket =
if socket.assigns[:viewer_canvas] == nil and is_map(file) do
annotations = load_annotations_for(file.file_uuid)
socket
|> assign(:viewer_annotations, annotations)
|> assign(:viewer_canvas, build_viewer_canvas(file, annotations))
# Read fresh from the DB (not the parent-passed struct) so the
# palette is correct even on modal prev/next after an in-session
# edit, where the parent's `current_user` may be stale.
|> assign(:etcher_colors, load_user_colors(assigns[:current_user]))
|> assign(:etcher_line_params, load_user_line_params(assigns[:current_user]))
else
socket
end
{:ok, socket}
end
# ──────────────────────────────────────────────────────────────
# Etcher events
# ──────────────────────────────────────────────────────────────
# Bulk persistence sync. Etcher 0.3 emits this on every shape
# mutation (create, update, drag, delete, color, undo/redo, …) with
# the full current annotations list. Diff against our last-known
# state (`viewer_annotations`) to dispatch row-level
# create/update/delete via `EtcherAdapter`, then reload from DB to
# pick up fresh comment metadata and rebuild the canvas blob.
#
# Persist failures get logged so a stale CHECK constraint (e.g. a
# new kind that hasn't migrated yet) doesn't silently drop
# annotations — without this the only symptom is "tooltip shows the
# kind name with no title sibling," which leads to long debugging
# detours.
@impl true
def handle_event("etcher:annotations-changed", %{"annotations" => new_annotations}, socket) do
case socket.assigns[:file] do
nil -> {:noreply, socket}
file -> {:noreply, sync_annotations(socket, file, new_annotations)}
end
end
# Etcher's color-save hook. The toolbar palette is per-user (one set of
# slots across every viewer), not per-file, so we ignore the fresco_id
# and persist the slots into the user's `custom_fields`, merging into a
# freshly-read copy so a concurrent change elsewhere isn't clobbered.
# `update_user_custom_fields/2` broadcasts `phoenix_kit_user_updated`,
# so hosts that subscribe refresh their `current_user` automatically.
def handle_event("etcher:colors-changed", %{"colors" => colors}, socket) do
with %{uuid: uuid} = user <- socket.assigns[:current_user],
[_ | _] = clean <- sanitize_colors(colors) do
fresh = Auth.get_user(uuid) || user
merged = Map.put(fresh.custom_fields || %{}, @etcher_colors_key, clean)
case Auth.update_user_custom_fields(fresh, merged) do
{:ok, updated} ->
{:noreply, socket |> assign(:current_user, updated) |> assign(:etcher_colors, clean)}
{:error, _changeset} ->
{:noreply, socket}
end
else
# No user, or nothing valid survived sanitization — ignore the event
# rather than persisting garbage or wiping the saved palette.
_ -> {:noreply, socket}
end
end
# Etcher's line-params save hook — the twin of `colors-changed`. The global
# stroke defaults (width / opacity / dash for new shapes) are per-user, one
# set across every viewer, so we ignore the fresco_id and persist the map
# into the user's `custom_fields`, merging into a freshly-read copy so a
# concurrent change elsewhere isn't clobbered. `update_user_custom_fields/2`
# broadcasts `phoenix_kit_user_updated` for subscribed hosts to refresh.
def handle_event("etcher:line-params-changed", %{"line_params" => params}, socket) do
with %{uuid: uuid} = user <- socket.assigns[:current_user],
%{} = clean <- sanitize_line_params(params) do
fresh = Auth.get_user(uuid) || user
merged = Map.put(fresh.custom_fields || %{}, @etcher_line_params_key, clean)
case Auth.update_user_custom_fields(fresh, merged) do
{:ok, updated} ->
{:noreply,
socket |> assign(:current_user, updated) |> assign(:etcher_line_params, clean)}
{:error, _changeset} ->
{:noreply, socket}
end
else
# No user, or nothing valid survived sanitization — ignore rather than
# persisting garbage or wiping the saved ink.
_ -> {:noreply, socket}
end
end
# Fires only on a brand-new user draw (Etcher's `_finalizeShape`).
# Undo/redo, drags, color picks all bypass this — they go through
# `annotations-changed` for persistence but don't re-open the
# composer. Text shapes get Etcher's inline editor and skip the
# popup. Markers are pure marking for now — they still persist (via
# `annotations-changed`) but skip the composer too, so highlighting
# doesn't prompt for a title/comment; it's just a line. If the
# composer is already open mid-compose, keep its target — a second
# quick draw shouldn't ambush an in-flight title/comment.
def handle_event("etcher:shape-drawn", %{"uuid" => uuid, "kind" => kind}, socket) do
socket =
cond do
kind in ["text", "marker"] ->
socket
is_binary(socket.assigns[:composing_annotation_uuid]) ->
socket
true ->
assign(socket, :composing_annotation_uuid, uuid)
end
{:noreply, socket}
end
# ──────────────────────────────────────────────────────────────
# Annotation persistence + canvas blob
# ──────────────────────────────────────────────────────────────
defp sync_annotations(socket, file, new_annotations) do
file_uuid = file.file_uuid
current_by_uuid =
Map.new(socket.assigns.viewer_annotations, fn a -> {to_string(a.uuid), a} end)
new_by_uuid = Map.new(new_annotations, fn a -> {a["uuid"], a} end)
new_in_batch =
Enum.reject(new_annotations, fn a -> Map.has_key?(current_by_uuid, a["uuid"]) end)
wrote? =
Enum.reduce(new_annotations, false, fn a, wrote? ->
uuid = a["uuid"]
current = Map.get(current_by_uuid, uuid)
result =
cond do
current && annotation_unchanged?(a, current) ->
:skip
current ->
Storage.EtcherAdapter.update(uuid, a)
true ->
attrs =
a
|> Map.put("target_type", "file")
|> Map.put("target_uuid", file_uuid)
|> creator_attrs(socket)
|> put_marker_author(socket)
Storage.EtcherAdapter.create(attrs)
end
case result do
:skip ->
wrote?
{:ok, _} ->
true
{:error, reason} ->
Logger.warning(
"[MediaCanvasViewer] annotation persist failed kind=#{inspect(a["kind"])} uuid=#{inspect(uuid)}: #{inspect(reason)}"
)
wrote?
end
end)
# Deletes — uuids in our state that aren't in Etcher's anymore.
to_delete =
Enum.reject(socket.assigns.viewer_annotations, fn a ->
Map.has_key?(new_by_uuid, to_string(a.uuid))
end)
Enum.each(to_delete, fn a ->
uuid = to_string(a.uuid)
case Storage.EtcherAdapter.delete(uuid) do
:ok ->
:ok
{:error, reason} ->
Logger.warning(
"[MediaCanvasViewer] annotation delete failed uuid=#{inspect(uuid)}: #{inspect(reason)}"
)
end
end)
if wrote? or to_delete != [] do
# A row was created / updated / deleted — reload from DB to pick up
# fresh comment metadata + cascade changes (deleted-annotation
# comments cascading out), then rebuild the canvas blob.
refreshed = load_annotations_for(file_uuid)
refresh_file_comments(socket)
socket
|> assign(:viewer_annotations, refreshed)
|> assign(:viewer_canvas, build_viewer_canvas(file, refreshed))
|> push_metadata_patches(file_uuid, new_in_batch, refreshed)
else
# Etcher re-broadcast with no net change — skip the reload and
# canvas rebuild entirely.
socket
end
end
# Etcher re-broadcasts the *entire* annotation list on every shape
# mutation, so a file with N annotations would otherwise issue N
# UPDATEs per interaction. An annotation is worth a DB write only when
# Etcher-owned mutable state actually moved — geometry, style or kind.
# (title / metadata are edited through the composer, not via
# annotations-changed.) Comparing against the last-known struct lets
# the untouched rows skip their no-op UPDATE.
defp annotation_unchanged?(wire, current) do
wire["geometry"] == current.geometry and
wire["style"] == current.style and
wire["kind"] == current.kind
end
# For every annotation newly created in this batch, push an
# `etcher:patch-shape` event with the freshly-loaded metadata
# (comment_count: 0, comment_created_at, etc.). Etcher's
# `_finalizeShape` creates shapes with no `metadata` field, so the
# tooltip would fall back to the shape kind ("Rectangle") until the
# next file open. This patch hydrates the in-DOM shape immediately
# so the tooltip shows correct content even before the user posts a
# title/comment via the AnnotationComposer.
defp push_metadata_patches(socket, _file_uuid, [], _refreshed), do: socket
defp push_metadata_patches(socket, file_uuid, new_in_batch, refreshed) do
refreshed_by_uuid =
Map.new(refreshed, fn a -> {to_string(a.uuid), a} end)
Enum.reduce(new_in_batch, socket, fn raw, acc ->
uuid = raw["uuid"]
case Map.get(refreshed_by_uuid, uuid) do
nil ->
acc
ann ->
Phoenix.LiveView.push_event(acc, "etcher:patch-shape", %{
fresco_id: "media-zoom-" <> file_uuid,
uuid: ann.uuid,
metadata: ann.metadata
})
end
end)
end
# Build the `%Fresco.Canvas{}` struct the viewer renders. Wraps the
# file's image as a single-image canvas at {0,0}, with the file's
# annotations stuffed into `extensions.etcher` so Etcher hydrates
# them via `handle.getExtension("etcher")` on mount. Returns nil
# when there's no usable image url — gates the `<Fresco.canvas>`
# render in the heex.
defp build_viewer_canvas(nil, _annotations), do: nil
defp build_viewer_canvas(file, annotations) when is_map(file) do
# Open on the cheap medium variant; Tessera swaps up to large (and DZI
# tiles for >4K images) as the user zooms. The canvas keeps the full
# original dimensions so the coordinate space matches the DZI pyramid.
src = file.urls["medium"] || file.urls["large"] || file.urls["original"]
if is_binary(src) and src != "" do
width = Map.get(file, :width) || 1000
height = Map.get(file, :height) || 1000
Fresco.Canvas.new(width: width, height: height)
|> Fresco.Canvas.add_image(%{
src: src,
x: 0,
y: 0,
width: width,
natural_width: width,
natural_height: height
})
|> Fresco.Canvas.put_extension("etcher", %{
"version" => "1",
"annotations" => Enum.map(annotations, &etcher_annotation_for_wire/1)
})
end
end
# Map an in-memory annotation (from `load_annotations_for/1`) to
# the Etcher 0.3 wire shape (string-keyed map with uuid/kind/
# geometry plus optional style/metadata).
defp etcher_annotation_for_wire(a) do
base = %{
"uuid" => to_string(a.uuid),
"kind" => a.kind,
"geometry" => a.geometry
}
base =
case Map.get(a, :style) do
nil -> base
style -> Map.put(base, "style", style)
end
case Map.get(a, :metadata) do
nil -> base
metadata -> Map.put(base, "metadata", metadata)
end
end
# Read this user's saved Etcher palette fresh from the DB, falling back
# to the default when nothing is stored (or there's no user). Fresh read
# (not the parent-passed struct) keeps it correct on modal prev/next
# after an in-session edit. Re-sanitize on read too (not just on write) so
# data persisted before the write-side guard shipped, or written by any
# other path, can't reach `<Etcher.layer colors={…}>` untrusted.
defp load_user_colors(%{uuid: uuid} = user) do
fresh = Auth.get_user(uuid) || user
case sanitize_colors(Auth.get_user_field(fresh, @etcher_colors_key)) do
[_ | _] = colors -> colors
[] -> @default_etcher_colors
end
end
defp load_user_colors(_), do: @default_etcher_colors
# Keep only color-shaped strings, trimmed, deduped, and capped — the input
# is client-supplied. Returns `[]` when nothing valid remains so the caller
# can ignore the update.
defp sanitize_colors(colors) when is_list(colors) do
colors
|> Enum.filter(&(is_binary(&1) and Regex.match?(@etcher_color_format, &1)))
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
|> Enum.take(@max_etcher_colors)
end
defp sanitize_colors(_), do: []
# Read this user's saved Etcher line params fresh from the DB, falling back
# to the default when nothing valid is stored (or there's no user). Fresh
# read (not the parent-passed struct) keeps it correct on modal prev/next
# after an in-session edit. Re-sanitize on read too (not just on write) so
# data persisted before this guard shipped, or by any other path, can't reach
# `<Etcher.layer line_params={…}>` untrusted.
defp load_user_line_params(%{uuid: uuid} = user) do
fresh = Auth.get_user(uuid) || user
case sanitize_line_params(Auth.get_user_field(fresh, @etcher_line_params_key)) do
%{} = params -> params
nil -> @default_etcher_line_params
end
end
defp load_user_line_params(_), do: @default_etcher_line_params
# Line params arrive from the same untrusted client hook as the palette.
# Keep only the three known keys, clamped to Etcher's own ranges (width
# 1..40, opacity 0..1, dash enum), then merge over the default so a partial
# or garbage payload still yields a usable map. Returns `nil` when nothing
# valid survives so the caller can ignore the update rather than wipe the
# saved ink.
defp sanitize_line_params(params) when is_map(params) do
clean =
Enum.reduce(params, %{}, fn
{"width", w}, acc when is_number(w) -> Map.put(acc, "width", clamp_number(w, 1, 40))
{"opacity", o}, acc when is_number(o) -> Map.put(acc, "opacity", clamp_number(o, 0, 1))
{"dash", d}, acc when d in @etcher_dash_values -> Map.put(acc, "dash", d)
_, acc -> acc
end)
if map_size(clean) == 0, do: nil, else: Map.merge(@default_etcher_line_params, clean)
end
defp sanitize_line_params(_), do: nil
defp clamp_number(n, lo, hi), do: n |> max(lo) |> min(hi)
@doc """
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.
"""
def load_annotations_for(file_uuid) do
file_uuid
|> Annotations.list_for_file_with_previews()
|> Enum.map(fn %{annotation: a, first_comment: fc, comment_count: count} ->
# `metadata` flows through to Etcher's tooltip. The JS reads
# `metadata.label` (consumer-set) plus the comment_* fields we
# populate here for the auto-rendered preview.
base_meta = a.metadata || %{}
comment_meta =
case fc do
nil ->
%{"comment_created_at" => format_date(a.inserted_at), "comment_count" => 0}
%{} = c ->
%{
"comment_text" => truncate(c.content, 80),
"comment_author" => c.author,
"comment_thumbnail_url" => c.thumbnail_url,
"comment_has_attachment" => Map.get(c, :has_attachment, false),
"comment_count" => count,
"comment_created_at" => format_date(a.inserted_at)
}
end
# Surface the dedicated title column as `metadata.title` so the
# JS overlay can render it inline. The column is the source of
# truth; the metadata key is the JS-facing contract.
title_meta = if a.title, do: %{"title" => a.title}, else: %{}
%{
uuid: a.uuid,
kind: a.kind,
geometry: a.geometry,
style: a.style,
metadata: base_meta |> Map.merge(comment_meta) |> Map.merge(title_meta)
}
end)
end
defp creator_attrs(attrs, socket) do
creator_uuid =
case socket.assigns[:current_user] do
%{uuid: uuid} when is_binary(uuid) -> uuid
_ -> nil
end
Map.put(attrs, "creator_uuid", creator_uuid)
end
# Markers skip the composer, so they never collect a title/comment — but
# the tooltip should still say who drew it and when. Stamp the author's
# display name into the annotation's `metadata` server-side (trusted, from
# the socket — not the untrusted wire payload, which the adapter strips
# `creator_uuid` from anyway). The tooltip's header slot reads
# `metadata.comment_author`; `comment_created_at` is derived from the row's
# `inserted_at` in `load_annotations_for/1`. Persisting the name in
# `metadata` (an adapter-writable field) keeps the tooltip identical on the
# instant patch-shape push and after a reload. Non-markers are untouched.
defp put_marker_author(%{"kind" => "marker"} = attrs, socket) do
case current_user_display_name(socket) do
name when is_binary(name) ->
meta = Map.put(attrs["metadata"] || %{}, "comment_author", name)
Map.put(attrs, "metadata", meta)
_ ->
attrs
end
end
defp put_marker_author(attrs, _socket), do: attrs
defp current_user_display_name(socket) do
case socket.assigns[:current_user] do
%{} = user -> user_display_name(user)
_ -> nil
end
end
# Mirror of `PhoenixKit.Annotations.author_display/1` (private there) so
# marker bylines read the same as comment-author bylines: "First Last",
# else first name, else the email local-part.
defp user_display_name(%{first_name: fn_, last_name: ln})
when is_binary(fn_) and is_binary(ln) and fn_ != "" and ln != "" do
"#{fn_} #{ln}"
end
defp user_display_name(%{first_name: fn_}) when is_binary(fn_) and fn_ != "", do: fn_
defp user_display_name(%{email: email}) when is_binary(email) do
email |> String.split("@", parts: 2) |> hd()
end
defp user_display_name(_), do: nil
# ──────────────────────────────────────────────────────────────
# Composer Post / Cancel — driven by AnnotationComposer's
# send_update with `action: :annotation_composer_posted/_cancelled`
# ──────────────────────────────────────────────────────────────
# Post path: comment was created, annotation is solidified.
# Reload annotations so the tooltip's comment_* fields refresh,
# poke the file's CommentsComponent so the freshly-posted comment
# appears in the sidebar without a page reload, and push the
# updated metadata directly to Etcher's in-DOM shape via the
# patch-shape bridge (Fresco's `phx-update="ignore"` blocks a
# canvas-extensions rebuild from reaching the client).
defp finalize_annotation_compose(socket, annotation_uuid, title) do
file_uuid =
case socket.assigns[:file] do
%{file_uuid: uuid} -> uuid
_ -> nil
end
if annotation_uuid do
title_val =
case title do
nil -> nil
str when is_binary(str) -> if String.trim(str) == "", do: nil, else: str
_ -> nil
end
_ = PhoenixKit.Annotations.update(annotation_uuid, %{title: title_val})
end
refresh_file_comments(socket)
fresh = if file_uuid, do: load_annotations_for(file_uuid), else: []
socket =
socket
|> assign(:composing_annotation_uuid, nil)
|> assign(:viewer_annotations, fresh)
|> rebuild_viewer_canvas(fresh)
|> put_flash(:info, gettext("Annotation saved"))
case file_uuid && Enum.find(fresh, fn a -> a.uuid == annotation_uuid end) do
%{} = ann ->
Phoenix.LiveView.push_event(socket, "etcher:patch-shape", %{
fresco_id: "media-zoom-" <> file_uuid,
uuid: ann.uuid,
metadata: ann.metadata
})
_ ->
socket
end
end
defp rebuild_viewer_canvas(socket, annotations) do
case socket.assigns[:file] do
nil -> socket
file -> assign(socket, :viewer_canvas, build_viewer_canvas(file, annotations))
end
end
# Apply a title edit that came from the comments sidebar
# (CommentsComponent's inline-edit). Writes the annotation row,
# reloads `viewer_annotations` so the heex re-derives
# `build_annotation_titles/1` for the comments component on the
# next render, and pushes `etcher:patch-shape` so the shape
# tooltip refreshes. No flash, no `composing_annotation_uuid`
# reset — the user is not interacting with the composer here.
defp apply_annotation_title_update(socket, annotation_uuid, title)
when is_binary(annotation_uuid) do
file_uuid =
case socket.assigns[:file] do
%{file_uuid: uuid} -> uuid
_ -> nil
end
persist_annotation_title(annotation_uuid, normalize_annotation_title(title))
fresh = if file_uuid, do: load_annotations_for(file_uuid), else: []
socket =
socket
|> assign(:viewer_annotations, fresh)
|> rebuild_viewer_canvas(fresh)
case file_uuid && Enum.find(fresh, fn a -> a.uuid == annotation_uuid end) do
%{} = ann ->
Phoenix.LiveView.push_event(socket, "etcher:patch-shape", %{
fresco_id: "media-zoom-" <> file_uuid,
uuid: ann.uuid,
metadata: ann.metadata
})
_ ->
socket
end
end
defp apply_annotation_title_update(socket, _, _), do: socket
# Blank/whitespace-only titles collapse to nil (clears the column).
defp normalize_annotation_title(str) when is_binary(str) do
if String.trim(str) == "", do: nil, else: str
end
defp normalize_annotation_title(_), do: nil
# Inline title edits intentionally show no flash (see above), but a failed
# write would otherwise be indistinguishable from a no-op — log it so the
# failure is diagnosable.
defp persist_annotation_title(annotation_uuid, title_val) do
case PhoenixKit.Annotations.update(annotation_uuid, %{title: title_val}) do
{:ok, _} ->
:ok
{:error, reason} ->
Logger.warning(
"Failed to update annotation #{annotation_uuid} title from comments sidebar: #{inspect(reason)}"
)
end
end
@doc """
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`.
"""
def build_comment_decorations(annotations) when is_list(annotations) do
entries =
Enum.reduce(annotations, %{}, fn a, acc ->
title = a |> Map.get(:metadata, %{}) |> Map.get("title")
if is_binary(title) and title != "" do
Map.put(acc, to_string(a.uuid), %{
label: title,
on_save: :annotation_title_updated
})
else
acc
end
end)
if map_size(entries) == 0, do: %{}, else: %{"annotation_uuid" => entries}
end
def build_comment_decorations(_), do: %{}
# Cancel path: drop the just-drawn shape so the canvas doesn't
# carry an untitled placeholder. The `etcher:delete-shape` JS
# bridge calls `layer.deleteShape(uuid)` — that removes the shape
# from Etcher's local state + DOM, pushes the delete onto Etcher's
# undo stack (Cmd+Z restores), and fires `annotations-changed`,
# which sync_annotations picks up to delete the DB row + cascade
# the comment hard-delete.
defp rollback_annotation_compose(socket, annotation_uuid) do
socket = assign(socket, :composing_annotation_uuid, nil)
case {annotation_uuid, socket.assigns[:file]} do
{uuid, %{file_uuid: file_uuid}}
when is_binary(uuid) and is_binary(file_uuid) ->
Phoenix.LiveView.push_event(socket, "etcher:delete-shape", %{
fresco_id: "media-zoom-" <> file_uuid,
uuid: uuid
})
_ ->
socket
end
end
# Poke the file's CommentsComponent to reload after server-side
# changes the component didn't drive itself (new annotation
# comment, cascade-deleted annotation comments, etc.). Flipping
# `loaded?` to false makes its `update/2` rerun `load_comments/1`.
defp refresh_file_comments(socket) do
with %{file_uuid: file_uuid} when is_binary(file_uuid) <- socket.assigns[:file],
true <- Code.ensure_loaded?(PhoenixKitComments.Web.CommentsComponent) do
Phoenix.LiveView.send_update(PhoenixKitComments.Web.CommentsComponent,
id: "media-comments-" <> file_uuid,
loaded?: false
)
end
:ok
end
# ──────────────────────────────────────────────────────────────
# Format helpers (duplicated from MediaBrowser — small and stable;
# not worth a shared module yet)
# ──────────────────────────────────────────────────────────────
# Tooltip date format. The format string is gettext-wrapped so
# locales can reorder components ("%d %b %Y" in en-GB / fr / de
# etc.) without code changes. `strftime`'s month + day-name
# expansion already resolves through `Calendar` translations.
defp format_date(%DateTime{} = dt), do: Calendar.strftime(dt, gettext("%b %d, %Y"))
defp format_date(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, gettext("%b %d, %Y"))
defp format_date(_), do: nil
defp truncate(nil, _), do: nil
defp truncate("", _), do: nil
defp truncate(text, limit) when is_binary(text) do
text = String.trim(text)
if String.length(text) > limit do
String.slice(text, 0, limit - 1) <> "…"
else
text
end
end
defp format_file_size(nil), do: ""
defp format_file_size(bytes), do: Format.bytes(bytes, base: 1000, decimals: 2)
defp file_icon("image"), do: "hero-photo"
defp file_icon("video"), do: "hero-play-circle"
defp file_icon("pdf"), do: "hero-document-text"
defp file_icon("document"), do: "hero-document"
defp file_icon(_), do: "hero-document-arrow-down"
@doc """
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.
"""
@dialyzer {:nowarn_function, comments_enabled?: 0}
def comments_enabled? do
Code.ensure_loaded?(PhoenixKitComments) and PhoenixKitComments.enabled?()
rescue
_ -> false
end
end