Files
component-system/lib/components_elixir_web/live/components_live.ex
2025-09-14 15:20:25 +02:00

853 lines
34 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 ComponentsElixirWeb.ComponentsLive do
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.Component
@items_per_page 20
@impl true
def mount(_params, session, socket) do
# Check authentication
unless Auth.authenticated?(session) do
{:ok, socket |> push_navigate(to: ~p"/login")}
else
categories = Inventory.list_categories()
stats = Inventory.component_stats()
{:ok,
socket
|> assign(:session, session)
|> assign(:categories, categories)
|> assign(:stats, stats)
|> assign(:search, "")
|> assign(:sort_criteria, "all_not_id")
|> assign(:selected_category, nil)
|> assign(:offset, 0)
|> assign(:components, [])
|> assign(:has_more, false)
|> assign(:loading, false)
|> assign(:show_add_form, false)
|> assign(:show_edit_form, false)
|> assign(:editing_component, nil)
|> assign(:form, nil)
|> assign(:show_image_modal, false)
|> assign(:modal_image_url, nil)
|> allow_upload(:image,
accept: ~w(.jpg .jpeg .png .gif),
max_entries: 1,
max_file_size: 5_000_000
)
|> load_components()}
end
end
@impl true
def handle_params(params, _uri, socket) do
search = Map.get(params, "search", "")
criteria = Map.get(params, "criteria", "all_not_id")
{:noreply,
socket
|> assign(:search, search)
|> assign(:sort_criteria, criteria)
|> assign(:offset, 0)
|> load_components()}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
{:noreply,
socket
|> assign(:search, search)
|> assign(:offset, 0)
|> load_components()
|> push_patch(to: ~p"/?#{build_query_params(socket, %{search: search})}")}
end
def handle_event("sort_change", %{"sort_criteria" => criteria}, socket) do
{:noreply,
socket
|> assign(:sort_criteria, criteria)
|> assign(:offset, 0)
|> load_components()
|> push_patch(to: ~p"/?#{build_query_params(socket, %{criteria: criteria})}")}
end
def handle_event("category_filter", %{"category_id" => ""}, socket) do
{:noreply,
socket
|> assign(:selected_category, nil)
|> assign(:offset, 0)
|> load_components()}
end
def handle_event("category_filter", %{"category_id" => category_id}, socket) do
category_id = String.to_integer(category_id)
{:noreply,
socket
|> assign(:selected_category, category_id)
|> assign(:offset, 0)
|> load_components()}
end
def handle_event("load_more", _params, socket) do
new_offset = socket.assigns.offset + @items_per_page
{:noreply,
socket
|> assign(:offset, new_offset)
|> load_components(append: true)}
end
def handle_event("increment_count", %{"id" => id}, socket) do
component = Inventory.get_component!(id)
case Inventory.increment_component_count(component) do
{:ok, _updated_component} ->
{:noreply,
socket
|> put_flash(:info, "Count updated")
|> load_components()}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to update count")}
end
end
def handle_event("decrement_count", %{"id" => id}, socket) do
component = Inventory.get_component!(id)
case Inventory.decrement_component_count(component) do
{:ok, _updated_component} ->
{:noreply,
socket
|> put_flash(:info, "Count updated")
|> load_components()}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to update count")}
end
end
def handle_event("delete_component", %{"id" => id}, socket) do
component = Inventory.get_component!(id)
case Inventory.delete_component(component) do
{:ok, _deleted_component} ->
# Clean up the image file if it exists
delete_image_file(component.image_filename)
{:noreply,
socket
|> put_flash(:info, "Component deleted")
|> load_components()}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to delete component")}
end
end
def handle_event("show_add_form", _params, socket) do
changeset = Inventory.change_component(%Component{})
form = to_form(changeset)
{:noreply,
socket
|> assign(:show_add_form, true)
|> assign(:form, form)}
end
def handle_event("hide_add_form", _params, socket) do
{:noreply,
socket
|> assign(:show_add_form, false)
|> assign(:form, nil)}
end
def handle_event("show_edit_form", %{"id" => id}, socket) do
component = Inventory.get_component!(id)
changeset = Inventory.change_component(component)
form = to_form(changeset)
{:noreply,
socket
|> assign(:show_edit_form, true)
|> assign(:editing_component, component)
|> assign(:form, form)}
end
def handle_event("hide_edit_form", _params, socket) do
{:noreply,
socket
|> assign(:show_edit_form, false)
|> assign(:editing_component, nil)
|> assign(:form, nil)}
end
def handle_event("show_image", %{"url" => url}, socket) do
{:noreply,
socket
|> assign(:show_image_modal, true)
|> assign(:modal_image_url, url)}
end
def handle_event("close_image_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_image_modal, false)
|> assign(:modal_image_url, nil)}
end
def handle_event("prevent_close", _params, socket) do
{:noreply, socket}
end
def handle_event("validate", _params, socket) do
{:noreply, socket}
end
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :image, ref)}
end
def handle_event("save_component", %{"component" => component_params}, socket) do
# Handle any uploaded images
updated_params = save_uploaded_image(socket, component_params)
case Inventory.create_component(updated_params) do
{:ok, _component} ->
{:noreply,
socket
|> put_flash(:info, "Component created successfully")
|> assign(:show_add_form, false)
|> assign(:form, nil)
|> load_components()}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
def handle_event("save_edit", %{"component" => component_params}, socket) do
# Handle any uploaded images
updated_params = save_uploaded_image(socket, component_params)
case Inventory.update_component(socket.assigns.editing_component, updated_params) do
{:ok, _component} ->
{:noreply,
socket
|> put_flash(:info, "Component updated successfully")
|> assign(:show_edit_form, false)
|> assign(:editing_component, nil)
|> assign(:form, nil)
|> load_components()}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp load_components(socket, opts \\ []) do
append = Keyword.get(opts, :append, false)
filters = [
search: socket.assigns.search,
sort_criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
limit: @items_per_page,
offset: socket.assigns.offset
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
%{components: new_components, has_more: has_more} =
Inventory.paginate_components(filters)
components =
if append do
socket.assigns.components ++ new_components
else
new_components
end
socket
|> assign(:components, components)
|> assign(:has_more, has_more)
end
defp build_query_params(socket, overrides) do
params = %{
search: Map.get(overrides, :search, socket.assigns.search),
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria)
}
params
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|> URI.encode_query()
end
defp category_options(categories) do
[{"Select a category", nil}] ++
Enum.map(categories, fn category ->
{category.name, category.id}
end)
end
@impl true
def render(assigns) do
~H"""
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="bg-white shadow">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-6">
<div class="flex items-center">
<h1 class="text-3xl font-bold text-gray-900">
Components Inventory
</h1>
<div class="ml-8 text-sm text-gray-500">
{@stats.total_components} components • {@stats.total_stock} items in stock
</div>
</div>
<div class="flex items-center space-x-4">
<button
phx-click="show_add_form"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Component
</button>
<.link
navigate={~p"/categories"}
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<.icon name="hero-folder" class="w-4 h-4 mr-2" /> Categories
</.link>
<.link
navigate={~p"/storage_locations"}
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<.icon name="hero-archive-box" class="w-4 h-4 mr-2" /> Storage
</.link>
<.link
href="/logout"
method="post"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<.icon name="hero-arrow-right-on-rectangle" class="w-4 h-4 mr-2" /> Logout
</.link>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<form phx-change="search" phx-submit="search">
<input
type="search"
name="search"
value={@search}
placeholder="Search components..."
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</form>
</div>
<div>
<form phx-change="category_filter">
<select
name="category_id"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="" selected={is_nil(@selected_category)}>All Categories</option>
<%= for category <- @categories do %>
<option value={category.id} selected={@selected_category == category.id}>
<%= if category.parent do %>
{category.parent.name} > {category.name}
<% else %>
{category.name}
<% end %>
</option>
<% end %>
</select>
</form>
</div>
<div>
<form phx-change="sort_change">
<select
name="sort_criteria"
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
>
<option value="all_not_id" selected={@sort_criteria == "all_not_id"}>
All (without IDs)
</option>
<option value="all" selected={@sort_criteria == "all"}>All</option>
<option value="name" selected={@sort_criteria == "name"}>Name</option>
<option value="description" selected={@sort_criteria == "description"}>
Description
</option>
<option value="id" selected={@sort_criteria == "id"}>ID</option>
<option value="category_id" selected={@sort_criteria == "category_id"}>
Category ID
</option>
</select>
</form>
</div>
</div>
</div>
<!-- Add Component Modal -->
<%= if @show_add_form do %>
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Add New Component</h3>
<button
phx-click="hide_add_form"
class="text-gray-400 hover:text-gray-600"
>
<.icon name="hero-x-mark" class="w-6 h-6" />
</button>
</div>
<.form for={@form} phx-submit="save_component" phx-change="validate" multipart={true} class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<.input field={@form[:name]} type="text" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Category</label>
<.input
field={@form[:category_id]}
type="select"
options={category_options(@categories)}
required
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Description</label>
<.input field={@form[:description]} type="textarea" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Keywords</label>
<.input field={@form[:keywords]} type="text" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Position</label>
<.input field={@form[:position]} type="text" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Count</label>
<.input field={@form[:count]} type="number" min="0" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Datasheet URL</label>
<.input field={@form[:datasheet_url]} type="url" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Component Image</label>
<div class="mt-1">
<.live_file_input upload={@uploads.image} class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" />
</div>
<p class="mt-1 text-xs text-gray-500">
JPG, PNG, GIF up to 5MB
</p>
<%= for err <- upload_errors(@uploads.image) do %>
<p class="text-red-600 text-sm mt-1"><%= Phoenix.Naming.humanize(err) %></p>
<% end %>
<%= for entry <- @uploads.image.entries do %>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<.live_img_preview entry={entry} class="h-10 w-10 rounded-lg object-cover" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900"><%= entry.client_name %></p>
<p class="text-sm text-gray-500"><%= entry.progress %>%</p>
</div>
</div>
<button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref} aria-label="cancel" class="text-red-600 hover:text-red-900">
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.image) do %>
<p class="mt-1 text-sm text-red-600"><%= upload_error_to_string(err) %></p>
<% end %>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
phx-click="hide_add_form"
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Save Component
</button>
</div>
</.form>
</div>
</div>
</div>
<% end %>
<!-- Edit Component Modal -->
<%= if @show_edit_form do %>
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">Edit Component</h3>
<button
phx-click="hide_edit_form"
class="text-gray-400 hover:text-gray-600"
>
<.icon name="hero-x-mark" class="w-6 h-6" />
</button>
</div>
<.form for={@form} phx-submit="save_edit" phx-change="validate" multipart={true} class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<.input field={@form[:name]} type="text" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Category</label>
<.input
field={@form[:category_id]}
type="select"
options={category_options(@categories)}
required
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Description</label>
<.input field={@form[:description]} type="textarea" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Keywords</label>
<.input field={@form[:keywords]} type="text" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Position</label>
<.input field={@form[:position]} type="text" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Count</label>
<.input field={@form[:count]} type="number" min="0" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Datasheet URL</label>
<.input field={@form[:datasheet_url]} type="url" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Component Image</label>
<%= if @editing_component && @editing_component.image_filename do %>
<div class="mt-1 mb-2">
<p class="text-sm text-gray-600">Current image:</p>
<img src={"/uploads/images/#{@editing_component.image_filename}"} alt="Current component" class="h-20 w-20 object-cover rounded-lg" />
</div>
<% end %>
<div class="mt-1">
<.live_file_input upload={@uploads.image} class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100" />
</div>
<p class="mt-1 text-xs text-gray-500">
JPG, PNG, GIF up to 5MB (leave empty to keep current image)
</p>
<%= for err <- upload_errors(@uploads.image) do %>
<p class="text-red-600 text-sm mt-1"><%= Phoenix.Naming.humanize(err) %></p>
<% end %>
<%= for entry <- @uploads.image.entries do %>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<.live_img_preview entry={entry} class="h-10 w-10 rounded-lg object-cover" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-gray-900"><%= entry.client_name %></p>
<p class="text-sm text-gray-500"><%= entry.progress %>%</p>
</div>
</div>
<button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref} aria-label="cancel" class="text-red-600 hover:text-red-900">
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.image) do %>
<p class="mt-1 text-sm text-red-600"><%= upload_error_to_string(err) %></p>
<% end %>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
phx-click="hide_edit_form"
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
>
Update Component
</button>
</div>
</.form>
</div>
</div>
</div>
<% end %>
<!-- Components List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6">
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-gray-200" id="components-list" phx-update="replace">
<%= for component <- @components do %>
<li id={"component-#{component.id}"} class="px-6 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<!-- Component Image -->
<div class="flex-shrink-0 mr-4">
<%= if component.image_filename do %>
<button phx-click="show_image" phx-value-url={"/uploads/images/#{component.image_filename}"} class="hover:opacity-75 transition-opacity">
<img src={"/uploads/images/#{component.image_filename}"} alt={component.name} class="h-12 w-12 rounded-lg object-cover cursor-pointer" />
</button>
<% else %>
<div class="h-12 w-12 rounded-lg bg-gray-200 flex items-center justify-center">
<.icon name="hero-cube-transparent" class="h-6 w-6 text-gray-400" />
</div>
<% end %>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="text-sm font-medium text-indigo-600 truncate">
<%= if component.datasheet_url do %>
<a
href={component.datasheet_url}
target="_blank"
class="hover:text-indigo-500"
>
{component.name}
</a>
<% else %>
{component.name}
<% end %>
</p>
<%= if component.datasheet_url do %>
<span class="ml-2 text-blue-500" title="Datasheet available">📄</span>
<% end %>
</div>
<div class="ml-2 flex-shrink-0 flex">
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
{component.category.name}
</p>
</div>
</div>
<div class="mt-2 flex justify-between">
<div class="sm:flex">
<p class="flex items-center text-sm text-gray-500">
<%= if component.description do %>
{component.description}
<% end %>
</p>
</div>
<div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
<%= if component.position do %>
<p class="mr-6">
<span class="font-medium">Position:</span> {component.position}
</p>
<% end %>
<p class="mr-6">
<span class="font-medium">Count:</span> {component.count}
</p>
<%= if @sort_criteria == "all" or @sort_criteria == "id" do %>
<p class="mr-6">
<span class="font-medium">ID:</span> {component.id}
</p>
<% end %>
</div>
</div>
<%= if component.keywords do %>
<div class="mt-2">
<p class="text-xs text-gray-400">
<span class="font-medium">Keywords:</span> {component.keywords}
</p>
</div>
<% end %>
</div>
<div class="ml-5 flex-shrink-0 flex items-center space-x-2">
<button
phx-click="increment_count"
phx-value-id={component.id}
class="inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<.icon name="hero-plus" class="w-4 h-4" />
</button>
<button
phx-click="decrement_count"
phx-value-id={component.id}
class="inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500"
>
<.icon name="hero-minus" class="w-4 h-4" />
</button>
<button
phx-click="show_edit_form"
phx-value-id={component.id}
class="inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<.icon name="hero-pencil" class="w-4 h-4" />
</button>
<button
phx-click="delete_component"
phx-value-id={component.id}
data-confirm="Are you sure you want to delete this component?"
class="inline-flex items-center p-1 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</div>
</li>
<% end %>
</ul>
<%= if @has_more do %>
<div class="bg-white px-4 py-3 flex items-center justify-center border-t border-gray-200">
<button
phx-click="load_more"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
Load More
</button>
</div>
<% end %>
<%= if Enum.empty?(@components) do %>
<div class="text-center py-12">
<.icon name="hero-cube-transparent" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900">No components found</h3>
<p class="mt-1 text-sm text-gray-500">
<%= if @search != "" do %>
Try adjusting your search terms.
<% else %>
Get started by adding your first component.
<% end %>
</p>
</div>
<% end %>
</div>
</div>
</div>
<!-- Image Modal -->
<%= if @show_image_modal do %>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" phx-click="close_image_modal">
<!-- Background overlay -->
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
<!-- Modal content -->
<div class="relative bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto" phx-click="prevent_close">
<!-- Header -->
<div class="flex justify-between items-center p-4 border-b bg-white rounded-t-lg">
<h3 class="text-lg font-semibold text-gray-900">Component Image</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 text-3xl font-bold leading-none p-1"
phx-click="close_image_modal"
title="Close"
>
×
</button>
</div>
<!-- Content -->
<div class="p-6 bg-white rounded-b-lg">
<div class="text-center">
<%= if @modal_image_url do %>
<img
src={@modal_image_url}
alt="Component image"
class="max-w-full max-h-[70vh] mx-auto rounded-lg shadow-sm"
style="object-fit: contain;"
/>
<% else %>
<p class="text-gray-500 py-8">No image available</p>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
"""
end
# Helper functions for image upload handling
defp save_uploaded_image(socket, component_params) do
uploaded_files =
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
filename = "#{System.unique_integer([:positive])}_#{entry.client_name}"
dest = Path.join(["priv", "static", "uploads", "images", filename])
# Ensure the upload directory exists
File.mkdir_p!(Path.dirname(dest))
# Copy the file
case File.cp(path, dest) do
:ok -> filename
{:error, _reason} -> nil
end
end)
case uploaded_files do
[filename] when is_binary(filename) -> Map.put(component_params, "image_filename", filename)
[] -> component_params
_error -> component_params
end
end
defp delete_image_file(nil), do: :ok
defp delete_image_file(""), do: :ok
defp delete_image_file(filename) do
path = Path.join(["priv", "static", "uploads", "images", filename])
File.rm(path)
end
defp upload_error_to_string(:too_large), do: "File too large"
defp upload_error_to_string(:too_many_files), do: "Too many files"
defp upload_error_to_string(:not_accepted), do: "File type not accepted"
defp upload_error_to_string(error), do: "Upload error: #{inspect(error)}"
end