Packages

phoenix_kit

1.7.181
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 modules shared components image_set.ex
Raw

lib/modules/shared/components/image_set.ex

defmodule PhoenixKit.Modules.Shared.Components.ImageSet do
@moduledoc """
Responsive image component that renders a `<picture>` element with `<source>` tags.
The browser automatically picks the best format it supports (AVIF > WebP > JPEG)
AND the right size for the viewport width, minimizing bandwidth.
## Usage
### Basic (auto-loads variants from DB):
<.image_set file_uuid="018e3c4a-..." alt="Photo" />
### With pre-loaded variants (for list pages, avoids N+1):
<.image_set file_uuid="018e3c4a-..." variants={@variants["018e3c4a-..."]} alt="Photo" />
### With custom sizes attribute:
<.image_set file_uuid="018e3c4a-..." alt="Photo" sizes="(max-width: 768px) 100vw, 50vw" />
## How it works
1. Groups file variants by format (AVIF, WebP, JPEG, etc.)
2. Renders a `<source>` per format with `srcset` using width descriptors
3. Falls back to a standard `<img>` tag with the primary format srcset
4. Format priority: AVIF > WebP > others (browsers pick the first supported `<source>`)
"""
use Phoenix.Component
alias PhoenixKit.Modules.Storage
alias PhoenixKit.Modules.Storage.VariantNaming
attr :file_uuid, :string, required: true, doc: "File UUID from PhoenixKit Storage"
attr :variants, :list,
default: nil,
doc: "Pre-loaded variant data from Storage.list_image_set_variants/1"
attr :alt, :string, default: "", doc: "Alt text for accessibility"
attr :sizes, :string, default: "100vw", doc: "Sizes attribute for responsive images"
attr :class, :string, default: "", doc: "CSS classes for the img element"
attr :loading, :string, default: "lazy", doc: "Loading strategy: lazy or eager"
attr :rest, :global
def image_set(assigns) do
variants = assigns.variants || load_variants(assigns.file_uuid)
# Parse variant names and group by format
grouped = group_variants_by_format(variants)
# Format priority: AVIF first, then WebP, then fallback
source_formats = prioritize_formats(grouped)
fallback_variants = Map.get(grouped, nil, [])
# Pick a good fallback src (prefer medium, then the largest available)
fallback_src = pick_fallback_src(fallback_variants, variants)
assigns =
assigns
|> assign(:source_formats, source_formats)
|> assign(:fallback_variants, fallback_variants)
|> assign(:fallback_src, fallback_src)
|> assign(:fallback_srcset, build_srcset(fallback_variants))
|> assign(:has_sources, source_formats != [] or fallback_variants != [])
~H"""
<%= if @has_sources do %>
<picture>
<%= for {format, format_variants} <- @source_formats do %>
<source
type={VariantNaming.mime_type_for_format(format)}
srcset={build_srcset(format_variants)}
sizes={@sizes}
/>
<% end %>
<img
src={@fallback_src}
srcset={if(@fallback_srcset != "", do: @fallback_srcset)}
sizes={if(@fallback_srcset != "", do: @sizes)}
alt={@alt}
class={@class}
loading={@loading}
{@rest}
/>
</picture>
<% else %>
<img
src={@fallback_src}
alt={@alt}
class={@class}
loading={@loading}
{@rest}
/>
<% end %>
"""
end
defp load_variants(file_uuid) do
if Code.ensure_loaded?(Storage) and function_exported?(Storage, :list_image_set_variants, 1) do
Storage.list_image_set_variants(file_uuid)
else
[]
end
end
defp group_variants_by_format(variants) do
# Group by actual mime_type, not variant name — this way files that failed
# format conversion (e.g. AVIF without libheif) end up in the correct group.
variants
|> Enum.filter(&(&1.width && &1.width > 0))
|> Enum.reject(&String.starts_with?(&1.variant_name, "video_"))
|> Enum.group_by(&mime_to_format(&1.mime_type))
|> Enum.map(fn {fmt, vs} ->
# Deduplicate by width — keep the best variant per width per format
deduped =
vs
|> Enum.sort_by(& &1.width)
|> Enum.uniq_by(& &1.width)
{fmt, deduped}
end)
|> Map.new()
end
defp mime_to_format("image/webp"), do: "webp"
defp mime_to_format("image/avif"), do: "avif"
# PNG goes into the fallback <img> group — no separate <source> needed
defp mime_to_format(_), do: nil
# Returns [{format, sorted_variants}] in priority order: AVIF > WebP > others
# Excludes nil (primary/fallback format) — that goes in the <img> fallback
defp prioritize_formats(grouped) do
priority = ["avif", "webp"]
grouped
|> Enum.reject(fn {fmt, _} -> is_nil(fmt) end)
|> Enum.sort_by(fn {fmt, _} ->
case Enum.find_index(priority, &(&1 == fmt)) do
nil -> 999
idx -> idx
end
end)
end
defp build_srcset(variants) when is_list(variants) do
variants
|> Enum.filter(&(&1.url && &1.width))
|> Enum.map_join(", ", fn v -> "#{v.url} #{v.width}w" end)
end
defp pick_fallback_src(fallback_variants, all_variants) do
# Prefer "medium" primary variant, then largest primary, then any variant
medium =
Enum.find(fallback_variants, fn v ->
{base, _} = VariantNaming.parse_variant_name(v.variant_name)
base == "medium"
end)
cond do
medium -> medium.url
fallback_variants != [] -> List.last(fallback_variants).url
all_variants != [] -> List.first(all_variants).url
true -> ""
end
end
end