berrypod/lib/berrypod_web/live/admin/media.ex
jamey db28cb8d9f migrate remaining admin pages to inline feedback
Replace put_flash with inline feedback for form saves:
- Media library: metadata save shows "Saved" checkmark
- Product show: storefront controls save shows "Saved" checkmark
- Newsletter campaign form: draft save shows "Saved" checkmark

Page-level outcomes (uploads, deletes, async operations) remain as
flash/banner messages — these are the correct pattern for non-form
actions.

Completes Task 4 of notification overhaul.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-08 07:34:17 +00:00

457 lines
15 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
|> assign(:metadata_status, :idle)
|> 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)
|> assign(:metadata_status, :idle)}
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)
|> assign(:metadata_status, :saved)}
{:error, _changeset} ->
{:noreply, assign(socket, :metadata_status, :error)}
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
</.header>
<div class="media-layout">
<%!-- upload zone --%>
<div class="media-upload-zone" phx-drop-target={@uploads.media_upload.ref}>
<form phx-change="set_upload_alt" class="media-upload-form">
<div class="media-upload-row">
<label class="admin-btn admin-btn-primary">
<.icon name="hero-arrow-up-tray" class="size-4" /> Upload image
<.live_file_input upload={@uploads.media_upload} class="sr-only" />
</label>
<input
type="text"
name="value"
value={@upload_alt}
placeholder="Alt text (recommended)"
class="admin-input admin-input-fill"
phx-debounce="200"
/>
</div>
</form>
<%= for entry <- @uploads.media_upload.entries do %>
<div class="media-upload-progress">
<span>{entry.client_name}</span>
<progress value={entry.progress} max="100">{entry.progress}%</progress>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.media_upload) do %>
<p class="admin-error">{Phoenix.Naming.humanize(err)}</p>
<% end %>
</div>
<%!-- filter bar --%>
<div class="admin-filter-row">
<form phx-change="filter_type" class="contents">
<select name="type" class="admin-select">
<option value="" selected={is_nil(@filter_type)}>All types</option>
<option value="media" selected={@filter_type == "media"}>Media</option>
<option value="product" selected={@filter_type == "product"}>Product</option>
<option value="logo" selected={@filter_type == "logo"}>Logo</option>
<option value="header" selected={@filter_type == "header"}>Header</option>
<option value="icon" selected={@filter_type == "icon"}>Icon</option>
</select>
</form>
<input
type="search"
placeholder="Search filename or alt text..."
value={@filter_search}
phx-keyup="filter_search"
phx-debounce="300"
class="admin-input admin-input-fill"
/>
<button
phx-click="toggle_orphans"
class={[
"admin-btn admin-btn-sm",
@filter_orphans && "admin-btn-primary",
!@filter_orphans && "admin-btn-ghost"
]}
>
<.icon name="hero-trash" class="size-4" /> Orphans
</button>
</div>
<div class="media-main">
<%!-- image grid + pagination --%>
<div class="media-grid-wrapper">
<div id="media-grid" phx-update="stream" class="media-grid">
<div
:for={{dom_id, image} <- @streams.images}
id={dom_id}
phx-click="select_image"
phx-value-id={image.id}
class={[
"media-card",
@selected_image && @selected_image.id == image.id && "media-card-selected"
]}
>
<div class="media-card-thumb">
<%= if image.is_svg do %>
<div class="media-card-svg-placeholder">
<.icon name="hero-code-bracket" class="size-8" />
<span>SVG</span>
</div>
<% else %>
<%= if thumb = image_thumbnail_url(image) do %>
<img src={thumb} alt={image.alt || image.filename} loading="lazy" />
<% else %>
<div class="media-card-svg-placeholder">
<.icon name="hero-photo" class="size-8" />
</div>
<% end %>
<% end %>
</div>
<div class="media-card-info">
<span class="media-card-filename" title={image.filename}>{image.filename}</span>
<div class="media-card-meta">
<span class={type_badge_class(image.image_type)}>{image.image_type}</span>
<span class="media-card-size">{format_file_size(image.file_size)}</span>
</div>
<span
:if={!image.alt || image.alt == ""}
class="media-card-no-alt"
title="Missing alt text"
>
<.icon name="hero-exclamation-triangle" class="size-3" /> No alt text
</span>
</div>
</div>
</div>
<.admin_pagination :if={@pagination} page={@pagination} patch={~p"/admin/media"} />
</div>
<%!-- detail panel --%>
<div :if={@selected_image} class="media-detail-scrim" phx-click="deselect_image"></div>
<aside :if={@selected_image} class="media-detail">
<div class="media-detail-header">
<h3>Image details</h3>
<button phx-click="deselect_image" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-x-mark" class="size-4" />
</button>
</div>
<div class="media-detail-preview">
<%= if @selected_image.is_svg do %>
<div class="media-detail-svg">
<.icon name="hero-code-bracket" class="size-12" />
<span>SVG image</span>
</div>
<% else %>
<%= if thumb = image_thumbnail_url(@selected_image) do %>
<img src={thumb} alt={@selected_image.alt || @selected_image.filename} />
<% end %>
<% end %>
</div>
<dl class="media-detail-meta">
<dt>Filename</dt>
<dd>{@selected_image.filename}</dd>
<dt>Type</dt>
<dd>{@selected_image.image_type}</dd>
<dt>Size</dt>
<dd>{format_file_size(@selected_image.file_size)}</dd>
<dt>Dimensions</dt>
<dd>{format_dimensions(@selected_image.source_width, @selected_image.source_height)}</dd>
<dt>Uploaded</dt>
<dd>{Calendar.strftime(@selected_image.inserted_at, "%d %b %Y %H:%M")}</dd>
</dl>
<.form for={@edit_form} phx-submit="update_metadata" class="media-detail-form">
<.input field={@edit_form[:alt]} label="Alt text" placeholder="Describe this image..." />
<.input field={@edit_form[:caption]} label="Caption" placeholder="Optional caption..." />
<.input field={@edit_form[:tags]} label="Tags" placeholder="hero, homepage, banner..." />
<div class="admin-form-actions-sm">
<button type="submit" class="admin-btn admin-btn-primary admin-btn-sm">
Save metadata
</button>
<.inline_feedback status={@metadata_status} />
</div>
</.form>
<%= if @selected_usages != [] do %>
<div class="media-detail-usages">
<h4>Used in</h4>
<ul>
<%= for usage <- @selected_usages do %>
<li>
<span class={type_badge_class(to_string(usage.type))}>{usage.type}</span>
{usage.label}
</li>
<% end %>
</ul>
</div>
<% end %>
<div class="media-detail-actions">
<%= if @confirm_delete do %>
<p class="admin-error">
<%= if @selected_usages != [] do %>
This image is in use. Deleting it may break pages.
<% else %>
Are you sure?
<% end %>
</p>
<div class="admin-row">
<button phx-click="delete_image" class="admin-btn admin-btn-sm admin-btn-danger">
Yes, delete
</button>
<button phx-click="cancel_delete" class="admin-btn admin-btn-sm admin-btn-ghost">
Cancel
</button>
</div>
<% else %>
<button
phx-click="confirm_delete"
class="admin-btn admin-btn-sm admin-btn-ghost admin-text-error"
>
<.icon name="hero-trash" class="size-4" /> Delete image
</button>
<% end %>
</div>
</aside>
</div>
</div>
"""
end
end