defmodule BerrypodWeb.Admin.Media do use BerrypodWeb, :live_view alias Berrypod.Media @impl true def mount(_params, _session, socket) do socket = socket |> assign(:page_title, "Media") |> assign(:filter_type, nil) |> assign(:filter_search, "") |> assign(:filter_orphans, false) |> assign(:pagination, nil) |> assign(:selected_image, nil) |> assign(:selected_usages, []) |> assign(:edit_form, nil) |> assign(:upload_alt, "") |> assign(:confirm_delete, false) |> allow_upload(:media_upload, accept: ~w(.png .jpg .jpeg .webp .svg .gif), max_entries: 1, max_file_size: 5_000_000, auto_upload: true, progress: &handle_progress/3 ) {:ok, socket} end @impl true def handle_params(params, _uri, socket) do page_num = Berrypod.Pagination.parse_page(params) opts = image_filter_opts(socket) page = Media.list_images_paginated([page: page_num] ++ opts) socket = socket |> assign(:pagination, page) |> stream(:images, page.items, reset: true) {:noreply, socket} end defp handle_progress(:media_upload, entry, socket) do if entry.done? do alt = socket.assigns.upload_alt consume_uploaded_entries(socket, :media_upload, fn %{path: path}, entry -> extra = if alt != "", do: %{alt: alt}, else: %{} Media.upload_from_entry(path, entry, "media", extra) end) |> case do [image | _] -> # Reload without BLOB to insert into stream image_without_blob = Media.get_image(image.id) |> Map.put(:data, nil) {:noreply, socket |> stream_insert(:images, image_without_blob, at: 0) |> assign(:upload_alt, "") |> put_flash(:info, "Image uploaded")} _ -> {:noreply, put_flash(socket, :error, "Upload failed")} end else {:noreply, socket} end end @impl true def handle_event("filter_type", %{"type" => type}, socket) do type = if type == "", do: nil, else: type {:noreply, socket |> assign(:filter_type, type) |> reload_images()} end def handle_event("filter_search", %{"value" => value}, socket) do {:noreply, socket |> assign(:filter_search, value) |> reload_images()} end def handle_event("toggle_orphans", _params, socket) do {:noreply, socket |> assign(:filter_orphans, !socket.assigns.filter_orphans) |> reload_images()} end def handle_event("select_image", %{"id" => id}, socket) do image = Media.get_image(id) if image do usages = Media.find_usages(id) form = to_form( %{ "alt" => image.alt || "", "caption" => image.caption || "", "tags" => image.tags || "" }, as: :metadata ) {:noreply, socket |> assign(:selected_image, Map.put(image, :data, nil)) |> assign(:selected_usages, usages) |> assign(:edit_form, form) |> assign(:confirm_delete, false)} else {:noreply, socket} end end def handle_event("deselect_image", _params, socket) do {:noreply, assign(socket, selected_image: nil, selected_usages: [], edit_form: nil, confirm_delete: false )} end def handle_event("update_metadata", %{"metadata" => params}, socket) do image = socket.assigns.selected_image case Media.update_image_metadata(image, params) do {:ok, updated} -> updated_no_blob = Map.put(updated, :data, nil) {:noreply, socket |> stream_insert(:images, updated_no_blob) |> assign(:selected_image, updated_no_blob) |> put_flash(:info, "Metadata updated")} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to update metadata")} end end def handle_event("confirm_delete", _params, socket) do {:noreply, assign(socket, :confirm_delete, true)} end def handle_event("cancel_delete", _params, socket) do {:noreply, assign(socket, :confirm_delete, false)} end def handle_event("delete_image", _params, socket) do image = socket.assigns.selected_image case Media.delete_with_cleanup(image) do {:ok, _} -> {:noreply, socket |> stream_delete(:images, image) |> assign(:selected_image, nil) |> assign(:selected_usages, []) |> assign(:edit_form, nil) |> assign(:confirm_delete, false) |> put_flash(:info, "Image deleted")} {:error, :in_use, _usages} -> {:noreply, put_flash(socket, :error, "Cannot delete — image is still in use")} end end def handle_event("set_upload_alt", %{"value" => value}, socket) do {:noreply, assign(socket, :upload_alt, value)} end # ── Private helpers ────────────────────────────────────────────── defp image_filter_opts(socket) do [ type: socket.assigns.filter_type, search: if(socket.assigns.filter_search != "", do: socket.assigns.filter_search), tag: nil ] |> Enum.reject(fn {_, v} -> is_nil(v) end) end defp reload_images(socket) do if socket.assigns.filter_orphans do # Orphan mode: load all, filter in Elixir (no pagination) opts = image_filter_opts(socket) images = Media.list_images(opts) used = Media.used_image_ids() orphans = Enum.reject(images, &MapSet.member?(used, &1.id)) socket |> assign(:pagination, nil) |> stream(:images, orphans, reset: true) else push_patch(socket, to: ~p"/admin/media") end end defp format_file_size(nil), do: "—" defp format_file_size(bytes) when bytes < 1024, do: "#{bytes} B" defp format_file_size(bytes) when bytes < 1_048_576 do kb = Float.round(bytes / 1024, 1) "#{kb} KB" end defp format_file_size(bytes) do mb = Float.round(bytes / 1_048_576, 1) "#{mb} MB" end defp format_dimensions(nil, _), do: "—" defp format_dimensions(_, nil), do: "—" defp format_dimensions(w, h), do: "#{w} × #{h}" defp type_badge_class("product"), do: "admin-badge admin-badge-sm admin-badge-info" defp type_badge_class("media"), do: "admin-badge admin-badge-sm admin-badge-accent" defp type_badge_class("logo"), do: "admin-badge admin-badge-sm admin-badge-warning" defp type_badge_class("header"), do: "admin-badge admin-badge-sm admin-badge-warning" defp type_badge_class("icon"), do: "admin-badge admin-badge-sm admin-badge-warning" defp type_badge_class(_), do: "admin-badge admin-badge-sm admin-badge-neutral" defp image_thumbnail_url(image) do cond do image.is_svg -> nil image.variants_status == "complete" -> "/image_cache/#{image.id}-thumb.jpg" true -> nil end end @impl true def render(assigns) do ~H""" <.header> Media
<%!-- upload zone --%>
<%= for entry <- @uploads.media_upload.entries do %>
{entry.client_name} {entry.progress}%
<% end %> <%= for err <- upload_errors(@uploads.media_upload) do %>

{Phoenix.Naming.humanize(err)}

<% end %>
<%!-- filter bar --%>
<%!-- image grid + pagination --%>
<%= if image.is_svg do %>
<.icon name="hero-code-bracket" class="size-8" /> SVG
<% else %> <%= if thumb = image_thumbnail_url(image) do %> {image.alt <% else %>
<.icon name="hero-photo" class="size-8" />
<% end %> <% end %>
{image.filename}
{image.image_type} {format_file_size(image.file_size)}
<.icon name="hero-exclamation-triangle" class="size-3" /> No alt text
<.admin_pagination :if={@pagination} page={@pagination} patch={~p"/admin/media"} />
<%!-- detail panel --%>
""" end end