Files
component-system/lib/components_elixir_web/live/components_live.ex
2025-09-14 22:50:44 +02:00

895 lines
36 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()
storage_locations = Inventory.list_storage_locations()
stats = Inventory.component_stats()
{:ok,
socket
|> assign(:session, session)
|> assign(:categories, categories)
|> assign(:storage_locations, storage_locations)
|> 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
defp storage_location_display_name(location) do
# Use the computed path from Inventory context for full hierarchy, or fall back to location.path
path = Inventory.compute_storage_location_path(location) || location.path
if path do
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
path
|> String.split("/")
|> Enum.join(" > ")
else
location.name
end
end
defp storage_location_options(storage_locations) do
[{"No storage location", nil}] ++
Enum.map(storage_locations, fn location ->
{storage_location_display_name(location), location.id}
end)
end
@impl true
def render(assigns) do
~H"""
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="bg-base-100 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-base-content">
Components Inventory
</h1>
<div class="ml-8 text-sm text-base-content/60">
{@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-base-300 text-sm font-medium rounded-md text-base-content bg-base-100 hover:bg-base-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<.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-base-300 text-sm font-medium rounded-md text-base-content bg-base-100 hover:bg-base-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<.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-base-300 text-sm font-medium rounded-md text-base-content bg-base-100 hover:bg-base-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<.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-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary 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-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary 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-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary 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-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-base-content">Add New Component</h3>
<button
phx-click="hide_add_form"
class="text-base-content/60 hover:text-base-content"
>
<.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-base-content">Name</label>
<.input field={@form[:name]} type="text" required />
</div>
<div>
<label class="block text-sm font-medium text-base-content">Category</label>
<.input
field={@form[:category_id]}
type="select"
options={category_options(@categories)}
required
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content">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-base-content">Keywords</label>
<.input field={@form[:keywords]} type="text" />
</div>
<div>
<label class="block text-sm font-medium text-base-content">Storage Location</label>
<.input
field={@form[:storage_location_id]}
type="select"
options={storage_location_options(@storage_locations)}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-base-content">Count</label>
<.input field={@form[:count]} type="number" min="0" />
</div>
<div>
<label class="block text-sm font-medium text-base-content">Datasheet URL</label>
<.input field={@form[:datasheet_url]} type="url" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-base-content">Component Image</label>
<div class="mt-1">
<.live_file_input upload={@uploads.image} class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20" />
</div>
<p class="mt-1 text-xs text-base-content/60">
JPG, PNG, GIF up to 5MB
</p>
<%= for err <- upload_errors(@uploads.image) do %>
<p class="text-error 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-base-content"><%= entry.client_name %></p>
<p class="text-sm text-base-content/60"><%= entry.progress %>%</p>
</div>
</div>
<button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref} aria-label="cancel" class="text-error hover:text-error/80">
<.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-error"><%= 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-base-300 rounded-md text-sm font-medium text-base-content hover:bg-base-200"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary-content bg-primary hover:bg-primary/90"
>
Save Component
</button>
</div>
</.form>
</div>
</div>
</div>
<% end %>
<!-- Edit Component Modal -->
<%= if @show_edit_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-base-content">Edit Component</h3>
<button
phx-click="hide_edit_form"
class="text-base-content/60 hover:text-base-content"
>
<.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-base-content">Name</label>
<.input field={@form[:name]} type="text" required />
</div>
<div>
<label class="block text-sm font-medium text-base-content">Category</label>
<.input
field={@form[:category_id]}
type="select"
options={category_options(@categories)}
required
/>
</div>
<div>
<label class="block text-sm font-medium text-base-content">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-base-content">Keywords</label>
<.input field={@form[:keywords]} type="text" />
</div>
<div>
<label class="block text-sm font-medium text-base-content">Storage Location</label>
<.input
field={@form[:storage_location_id]}
type="select"
options={storage_location_options(@storage_locations)}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-base-content">Count</label>
<.input field={@form[:count]} type="number" min="0" />
</div>
<div>
<label class="block text-sm font-medium text-base-content">Datasheet URL</label>
<.input field={@form[:datasheet_url]} type="url" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-base-content">Component Image</label>
<%= if @editing_component && @editing_component.image_filename do %>
<div class="mt-1 mb-2">
<p class="text-sm text-base-content/70">Current image:</p>
<img src={"/user_generated/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-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20" />
</div>
<p class="mt-1 text-xs text-base-content/60">
JPG, PNG, GIF up to 5MB (leave empty to keep current image)
</p>
<%= for err <- upload_errors(@uploads.image) do %>
<p class="text-error 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-base-content"><%= entry.client_name %></p>
<p class="text-sm text-base-content/60"><%= entry.progress %>%</p>
</div>
</div>
<button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref} aria-label="cancel" class="text-error hover:text-error/80">
<.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-error"><%= 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-base-300 rounded-md text-sm font-medium text-base-content hover:bg-base-200"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary-content bg-primary hover:bg-primary/90"
>
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-base-100 shadow overflow-hidden sm:rounded-md">
<ul class="divide-y divide-base-300" id="components-list" phx-update="replace">
<%= for component <- @components do %>
<li id={"component-#{component.id}"} class="px-6 py-4 hover:bg-base-200">
<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={"/user_generated/uploads/images/#{component.image_filename}"} class="hover:opacity-75 transition-opacity">
<img src={"/user_generated/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-base-200 flex items-center justify-center">
<.icon name="hero-cube-transparent" class="h-6 w-6 text-base-content/50" />
</div>
<% end %>
</div>
<div class="flex-1 min-w-0">
<!-- Top row: Name and Category -->
<div class="flex items-start justify-between">
<div class="flex items-center min-w-0 flex-1">
<p class="text-sm font-medium text-primary truncate">
<%= if component.datasheet_url do %>
<a
href={component.datasheet_url}
target="_blank"
class="hover:text-primary/80"
>
{component.name}
</a>
<% else %>
{component.name}
<% end %>
</p>
<%= if component.datasheet_url do %>
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
<% end %>
</div>
<div class="ml-4 flex-shrink-0">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
{component.category.name}
</span>
</div>
</div>
<!-- Middle row: Description -->
<%= if component.description do %>
<div class="mt-1">
<p class="text-sm text-base-content/70 line-clamp-2">
{component.description}
</p>
</div>
<% end %>
<!-- Bottom row: Metadata -->
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 text-sm text-base-content/60">
<%= if component.storage_location do %>
<div class="flex items-center min-w-0">
<.icon name="hero-map-pin" class="w-4 h-4 mr-1 text-base-content/50 flex-shrink-0" />
<span class="font-medium">Location:</span>
<span class="ml-1 truncate">{storage_location_display_name(component.storage_location)}</span>
</div>
<% end %>
<div class="flex items-center">
<.icon name="hero-cube" class="w-4 h-4 mr-1 text-base-content/50" />
<span class="font-medium">Count:</span>
<span class="ml-1">{component.count}</span>
</div>
<%= if @sort_criteria == "all" or @sort_criteria == "id" do %>
<div class="flex items-center">
<.icon name="hero-hashtag" class="w-4 h-4 mr-1 text-base-content/50" />
<span class="font-medium">ID:</span>
<span class="ml-1">{component.id}</span>
</div>
<% end %>
</div>
<!-- Keywords row -->
<%= if component.keywords do %>
<div class="mt-2">
<p class="text-xs text-base-content/50">
<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-base-300 text-sm font-medium rounded-md text-base-content bg-base-100 hover:bg-base-200"
>
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-base-content/50" />
<h3 class="mt-2 text-sm font-medium text-base-content">No components found</h3>
<p class="mt-1 text-sm text-base-content/60">
<%= 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-base-100 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 border-base-300 bg-base-100 rounded-t-lg">
<h3 class="text-lg font-semibold text-base-content">Component Image</h3>
<button
type="button"
class="text-base-content/50 hover:text-base-content text-3xl font-bold leading-none p-1"
phx-click="close_image_modal"
title="Close"
>
×
</button>
</div>
<!-- Content -->
<div class="p-6 bg-base-100 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-base-content/60 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", "user_generated", "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", "user_generated", "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