Packages

phoenix_kit

1.7.185
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.html.heex
Raw

lib/phoenix_kit_web/components/media_canvas_viewer.html.heex

<div id={@id} class="contents">
<%!-- --%>
<%!-- MediaCanvasViewer — rendered inside a parent's modal shell (a <dialog> --%>
<%!-- in MediaViewer, a <div class="modal"> in MediaBrowser). Owns the --%>
<%!-- per-file content: close button, prev/next chevrons, image canvas / --%>
<%!-- video / pdf / icon, composer popover, and sidebar (filename, Download, --%>
<%!-- metadata, comments thread). --%>
<%!-- --%>
<%!-- All click events on the close + chevron buttons target the parent --%>
<%!-- LiveComponent (`phx-target={"#" <> @parent_id}`) — the parent owns --%>
<%!-- modal open/close state and file navigation, this component just --%>
<%!-- displays whichever file's been handed to it. --%>
<%!-- --%>
<% f = @file %>
<%!-- Close button — top-right of modal-box. Suppressed in viewer_only --%>
<%!-- mode (standalone-page hosts provide their own back navigation). --%>
<button
:if={not @viewer_only}
type="button"
phx-click="close_viewer"
phx-target={"##{@parent_id}"}
class="btn btn-ghost btn-sm btn-circle absolute top-3 right-3 z-30 bg-base-100 hover:bg-base-200 shadow-lg ring-1 ring-base-300"
title={gettext("Close")}
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
<div class="flex flex-col lg:flex-row h-full overflow-hidden">
<%!-- Viewer column (image / video / pdf / icon) --%>
<div class="flex-[7] relative flex items-center justify-center bg-base-200 overflow-hidden p-0 lg:p-2 min-h-[40vh] lg:min-h-0">
<%!-- Chevron positioning sits on a wrapper div, not the button. --%>
<%!-- daisyUI's active-state CSS replaces `transform` with --%>
<%!-- `scale(0.97)` on click, which would clobber a -translate-y-1/2 --%>
<%!-- on the button itself and make it jump down 50% of its height. --%>
<%= if @has_prev do %>
<div class="absolute left-2 top-1/2 -translate-y-1/2 z-10">
<button
type="button"
phx-click="step_viewer"
phx-value-dir="prev"
phx-target={"##{@parent_id}"}
class="btn btn-circle btn-ghost bg-base-100/80 hover:bg-base-100 shadow-md"
title={gettext("Previous (←)")}
>
<.icon name="hero-chevron-left" class="w-5 h-5" />
</button>
</div>
<% end %>
<%= if @has_next do %>
<div class="absolute right-2 top-1/2 -translate-y-1/2 z-10">
<button
type="button"
phx-click="step_viewer"
phx-value-dir="next"
phx-target={"##{@parent_id}"}
class="btn btn-circle btn-ghost bg-base-100/80 hover:bg-base-100 shadow-md"
title={gettext("Next (→)")}
>
<.icon name="hero-chevron-right" class="w-5 h-5" />
</button>
</div>
<% end %>
<%= cond do %>
<% is_binary(f.mime_type) and String.starts_with?(f.mime_type, "image/") -> %>
<%!-- Fresco canvas + Etcher annotation layer + Tessera zoom layer. --%>
<%!-- --%>
<%!-- - <Fresco.canvas> hosts the image at its base (medium) --%>
<%!-- resolution; the Canvas struct is built in --%>
<%!-- build_viewer_canvas/2 with the file's annotations stuffed --%>
<%!-- into extensions.etcher — Etcher hydrates on mount via --%>
<%!-- handle.getExtension("etcher"). --%>
<%!-- - <Etcher.layer> attaches via fresco_id; emits a single bulk --%>
<%!-- etcher:annotations-changed event the parent handles. --%>
<%!-- - <Tessera.layer> swaps medium → large → original as the user --%>
<%!-- zooms in, and (when urls["dzi"] is present) streams DZI tiles --%>
<%!-- past the top raster for deep zoom on gigapixel images. Only --%>
<%!-- variants that exist on the file are included; tile + manifest --%>
<%!-- fetches are lazy so unviewed regions never hit storage. --%>
<%= if @viewer_canvas do %>
<Fresco.canvas
id={"media-zoom-" <> f.file_uuid}
canvas={@viewer_canvas}
class="w-full h-full lg:rounded"
theme={:inherit}
initial_rotation={@viewer_rotation}
persist_rotation={@persist_rotation}
/>
<Etcher.layer
fresco_id={"media-zoom-" <> f.file_uuid}
colors={@etcher_colors}
line_params={@etcher_line_params}
tools={[
:grabber,
:rectangle,
:circle,
:polygon,
:freehand,
:marker,
:callout,
:text,
:dimension,
:line,
:eraser
]}
/>
<%!-- Zoom ladder: medium → large, then for images over 4K we stream
DZI tiles of the original (only the visible area) instead of
ever loading the whole multi-MB original; for ≤4K images the
full original raster is the top of the ladder (no tiles). DZI
tiles also require the storage tile-generation setting (which
is what populates urls["dzi"]). --%>
<% over_4k = max(Map.get(f, :width) || 0, Map.get(f, :height) || 0) > 4096
has_dzi = is_binary(f.urls["dzi"]) and f.urls["dzi"] != ""
use_tiles = over_4k and has_dzi
tessera_sources =
([{f.urls["medium"], 800}, {f.urls["large"], 1920}] ++
if(use_tiles, do: [], else: [{f.urls["original"], Map.get(f, :width) || 4096}]))
|> Enum.filter(fn {url, _w} -> is_binary(url) and url != "" end)
|> Enum.map(fn {url, width} -> %{url: url, width: width} end)
tessera_dzi = if use_tiles, do: f.urls["dzi"], else: nil %>
<%= if tessera_sources != [] do %>
<Tessera.layer
fresco_id={"media-zoom-" <> f.file_uuid}
sources={tessera_sources}
dzi_url={tessera_dzi}
/>
<% end %>
<% end %>
<% is_binary(f.mime_type) and String.starts_with?(f.mime_type, "video/") -> %>
<video
src={f.urls["original"]}
controls
class="max-w-full max-h-full rounded"
preload="metadata"
>
{gettext("Your browser does not support the video tag.")}
</video>
<% f.mime_type == "application/pdf" -> %>
<iframe
src={f.urls["original"]}
sandbox="allow-same-origin"
class="w-full h-full rounded border-0"
title={f.filename}
></iframe>
<% audio?(f) -> %>
<%!-- WaveformPlayer lazy-loads WaveSurfer (only when an audio file is
open) and renders an interactive waveform driven by a single
play/pause button. The hidden native <audio> is the playback
source and the fallback (revealed) if the library can't load. --%>
<div
id={"waveform-" <> f.file_uuid}
phx-hook="WaveformPlayer"
class="flex flex-col gap-4 py-10 w-full px-8"
>
<div class="flex items-center gap-2">
<.icon name="hero-musical-note" class="w-5 h-5 text-base-content/50 shrink-0" />
<p class="text-sm font-semibold truncate">{f.filename}</p>
</div>
<div class="flex items-center gap-4">
<button
type="button"
data-waveform-toggle
class="btn btn-primary btn-circle shrink-0"
aria-label={gettext("Play / Pause")}
>
<span data-waveform-icon="play">
<.icon name="hero-play-solid" class="w-5 h-5" />
</span>
<span data-waveform-icon="pause" class="hidden">
<.icon name="hero-pause-solid" class="w-5 h-5" />
</span>
</button>
<div
id={"waveform-canvas-" <> f.file_uuid}
data-waveform
phx-update="ignore"
class="flex-1 min-h-[96px]"
>
</div>
</div>
<audio src={f.urls["original"]} preload="metadata" data-waveform-audio>
{gettext("Your browser does not support the audio tag.")}
</audio>
</div>
<% true -> %>
<div class="flex flex-col items-center justify-center text-base-content/50 py-12">
<.icon name={file_icon(f.file_type)} class="w-32 h-32 mb-4" />
<p class="text-2xl font-semibold">
{String.upcase(f.file_type || gettext("FILE"))}
</p>
<p class="text-sm text-base-content/70 mt-2">{f.mime_type}</p>
</div>
<% end %>
<%!-- Floating annotation composer — positioned above the just-drawn --%>
<%!-- shape by the AnnotationComposerPosition JS hook (it looks up --%>
<%!-- the shape via [data-uuid="…"]). Sidebar comments stay visible --%>
<%!-- alongside; this popover is for the new shape only. --%>
<%= if @composing_annotation_uuid && @current_user do %>
<div
id={"annotation-composer-popover-" <> @composing_annotation_uuid}
phx-hook="AnnotationComposerPosition"
class="absolute z-30 w-80 max-w-[calc(100%-1rem)] bg-base-100 rounded-lg shadow-xl border border-base-300 p-3"
style="left: 0px; top: 0px;"
>
<div class="flex items-center gap-2 text-sm font-medium mb-2">
<.icon name="hero-pencil-square" class="w-4 h-4 text-warning" />
<span>{gettext("Add a note to your annotation")}</span>
</div>
<p class="text-xs text-base-content/60 mb-3">
{gettext(
"Post to add the note. Cancel to skip — you can edit or delete the annotation later via the toolbar."
)}
</p>
<.live_component
module={PhoenixKitWeb.Components.AnnotationComposer}
id={"annotation-composer-" <> @composing_annotation_uuid}
annotation_uuid={@composing_annotation_uuid}
file_uuid={f.file_uuid}
current_user={@current_user}
parent_id={@id}
parent_module={PhoenixKitWeb.Components.MediaCanvasViewer}
/>
</div>
<% end %>
</div>
<%!-- Sidebar: filename + download + metadata + comments. Suppressed --%>
<%!-- in viewer_only mode — standalone-page hosts render their own --%>
<%!-- sidebar with admin actions / metadata editor / file details. --%>
<div
:if={not @viewer_only}
class="flex-[3] flex flex-col gap-3 p-4 overflow-y-auto lg:min-w-[280px] bg-base-100"
>
<h2 class="text-base font-bold break-all leading-tight pr-10">
{f.filename}
</h2>
<%= if f.urls["original"] do %>
<a
href={f.urls["original"]}
download={f.filename}
target="_blank"
rel="noopener"
class="btn btn-primary btn-sm gap-2 self-start"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4" /> {gettext("Download")}
</a>
<% end %>
<div class="text-sm space-y-2 mt-1">
<div>
<span class="text-base-content/60">{gettext("Type:")}</span>
<span class="ml-1 badge badge-ghost">
{String.upcase(f.file_type || gettext("FILE"))}
</span>
</div>
<div class="break-all">
<span class="text-base-content/60">{gettext("MIME:")}</span>
<span class="ml-1 font-mono text-xs">{f.mime_type}</span>
</div>
<div>
<span class="text-base-content/60">{gettext("Size:")}</span>
<span class="ml-1">{format_file_size(f.size)}</span>
</div>
<div>
<span class="text-base-content/60">{gettext("Uploaded:")}</span>
<span class="ml-1">
{Calendar.strftime(f.inserted_at, "%b %d, %Y at %H:%M")}
</span>
</div>
</div>
<%!-- Comments section — gated on the Comments module being --%>
<%!-- installed AND enabled, plus a logged-in user. Keyed by --%>
<%!-- file_uuid so prev/next remounts the component cleanly. --%>
<%!-- --%>
<%!-- The wrapper applies a compact-mode override: shrinks the --%>
<%!-- comments component's typography, paddings, and avatars --%>
<%!-- via descendant selectors so it fits inside the sidebar --%>
<%!-- without truncation. The component itself isn't modified. --%>
<%= if comments_enabled?() and @current_user do %>
<div class="border-t border-base-300 pt-3 mt-3 leaf-comments-compact">
<.live_component
module={PhoenixKitComments.Web.CommentsComponent}
id={"media-comments-" <> f.file_uuid}
resource_type="file"
resource_uuid={f.file_uuid}
current_user={@current_user}
show_likes={true}
title={gettext("Comments")}
comment_decorations={build_comment_decorations(@viewer_annotations)}
parent_module={PhoenixKitWeb.Components.MediaCanvasViewer}
parent_id={@id}
/>
</div>
<% end %>
</div>
</div>
</div>