1310 lines
53 KiB
Elixir
1310 lines
53 KiB
Elixir
defmodule ComponentsElixirWeb.ComponentsLive do
|
||
use ComponentsElixirWeb, :live_view
|
||
|
||
alias ComponentsElixir.{Inventory, Auth}
|
||
alias ComponentsElixir.Inventory.{Component, Category, StorageLocation, Hierarchical}
|
||
|
||
@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, "name_asc")
|
||
|> assign(:selected_category, nil)
|
||
|> assign(:offset, 0)
|
||
|> assign(:components, [])
|
||
|> assign(:has_more, false)
|
||
|> assign(:loading, false)
|
||
|> assign(:sort_freeze_until, nil)
|
||
|> assign(:interacting_with, nil)
|
||
|> assign(:sort_freeze_timer, nil)
|
||
|> assign(:sort_frozen, 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)
|
||
|> assign(:focused_component_id, 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", "name_asc")
|
||
|
||
{: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
|
||
query_string = build_query_params(socket, %{search: search})
|
||
path = if query_string == "", do: "/", else: "/?" <> query_string
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(:search, search)
|
||
|> assign(:offset, 0)
|
||
|> load_components()
|
||
|> push_patch(to: path)}
|
||
end
|
||
|
||
def handle_event("sort_change", %{"sort_criteria" => criteria}, socket) do
|
||
query_string = build_query_params(socket, %{criteria: criteria})
|
||
path = if query_string == "", do: "/", else: "/?" <> query_string
|
||
|
||
{:noreply,
|
||
socket
|
||
|> assign(:sort_criteria, criteria)
|
||
|> assign(:offset, 0)
|
||
|> load_components()
|
||
|> push_patch(to: path)}
|
||
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} ->
|
||
# Only apply sort freeze for dynamic sorting criteria
|
||
should_freeze =
|
||
socket.assigns.sort_criteria in [
|
||
"count_asc",
|
||
"count_desc",
|
||
"updated_at_asc",
|
||
"updated_at_desc"
|
||
]
|
||
|
||
if should_freeze do
|
||
# Cancel any existing timer
|
||
if socket.assigns.sort_freeze_timer do
|
||
Process.cancel_timer(socket.assigns.sort_freeze_timer)
|
||
end
|
||
|
||
# Set sort freeze for 3 seconds and mark component as interacting
|
||
freeze_until = DateTime.add(DateTime.utc_now(), 3, :second)
|
||
|
||
# Set new timer to clear interaction state
|
||
timer_ref = Process.send_after(self(), {:clear_interaction, id}, 3000)
|
||
|
||
{:noreply,
|
||
socket
|
||
|> put_flash(:info, "Count updated")
|
||
|> assign(:sort_freeze_until, freeze_until)
|
||
|> assign(:interacting_with, id)
|
||
|> assign(:sort_freeze_timer, timer_ref)
|
||
|> assign(:sort_frozen, true)
|
||
|> load_components()}
|
||
else
|
||
# Normal behavior for stable sorts
|
||
{:noreply,
|
||
socket
|
||
|> put_flash(:info, "Count updated")
|
||
|> load_components()}
|
||
end
|
||
|
||
{: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} ->
|
||
# Only apply sort freeze for dynamic sorting criteria
|
||
should_freeze =
|
||
socket.assigns.sort_criteria in [
|
||
"count_asc",
|
||
"count_desc",
|
||
"updated_at_asc",
|
||
"updated_at_desc"
|
||
]
|
||
|
||
if should_freeze do
|
||
# Cancel any existing timer
|
||
if socket.assigns.sort_freeze_timer do
|
||
Process.cancel_timer(socket.assigns.sort_freeze_timer)
|
||
end
|
||
|
||
# Set sort freeze for 3 seconds and mark component as interacting
|
||
freeze_until = DateTime.add(DateTime.utc_now(), 3, :second)
|
||
|
||
# Set new timer to clear interaction state
|
||
timer_ref = Process.send_after(self(), {:clear_interaction, id}, 3000)
|
||
|
||
{:noreply,
|
||
socket
|
||
|> put_flash(:info, "Count updated")
|
||
|> assign(:sort_freeze_until, freeze_until)
|
||
|> assign(:interacting_with, id)
|
||
|> assign(:sort_freeze_timer, timer_ref)
|
||
|> assign(:sort_frozen, true)
|
||
|> load_components()}
|
||
else
|
||
# Normal behavior for stable sorts
|
||
{:noreply,
|
||
socket
|
||
|> put_flash(:info, "Count updated")
|
||
|> load_components()}
|
||
end
|
||
|
||
{: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("toggle_focus", %{"id" => id}, socket) do
|
||
component_id = String.to_integer(id)
|
||
|
||
new_focused_id =
|
||
if socket.assigns.focused_component_id == component_id do
|
||
# Unfocus if clicking on the same component
|
||
nil
|
||
else
|
||
# Focus on the new component
|
||
component_id
|
||
end
|
||
|
||
{:noreply, assign(socket, :focused_component_id, new_focused_id)}
|
||
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
|
||
|
||
@impl true
|
||
def handle_info({:clear_interaction, component_id}, socket) do
|
||
# Only clear if this timer is for the currently interacting component
|
||
if socket.assigns.interacting_with == component_id do
|
||
# Clear interaction state and allow sorting to resume
|
||
{:noreply,
|
||
socket
|
||
|> assign(:sort_freeze_until, nil)
|
||
|> assign(:interacting_with, nil)
|
||
|> assign(:sort_freeze_timer, nil)
|
||
|> assign(:sort_frozen, false)
|
||
|> load_components()}
|
||
else
|
||
# Ignore stale timer messages
|
||
{:noreply, socket}
|
||
end
|
||
end
|
||
|
||
defp load_components(socket, opts \\ []) do
|
||
append = Keyword.get(opts, :append, false)
|
||
|
||
# Check if sorting should be frozen
|
||
now = DateTime.utc_now()
|
||
|
||
should_reload =
|
||
is_nil(socket.assigns.sort_freeze_until) ||
|
||
DateTime.compare(now, socket.assigns.sort_freeze_until) != :lt
|
||
|
||
if should_reload do
|
||
# Normal loading - query database with current sort criteria
|
||
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
|
||
{_, v} when is_nil(v) -> true
|
||
{:search, v} when v == "" -> true
|
||
{_, _} -> false
|
||
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)
|
||
else
|
||
# Frozen - just update the specific component in place without reordering
|
||
if socket.assigns.interacting_with do
|
||
updated_components =
|
||
Enum.map(socket.assigns.components, fn component ->
|
||
if to_string(component.id) == socket.assigns.interacting_with do
|
||
# Reload this specific component to get updated count
|
||
Inventory.get_component!(component.id)
|
||
else
|
||
component
|
||
end
|
||
end)
|
||
|
||
assign(socket, :components, updated_components)
|
||
else
|
||
socket
|
||
end
|
||
end
|
||
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
|
||
Hierarchical.select_options(categories, &(&1.parent), "Select a category")
|
||
end
|
||
|
||
defp storage_location_display_name(location) do
|
||
StorageLocation.full_path(location)
|
||
end
|
||
|
||
defp storage_location_options(storage_locations) do
|
||
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location")
|
||
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_name, category_id} <- Hierarchical.select_options(@categories, &(&1.parent)) do %>
|
||
<option value={category_id} selected={@selected_category == category_id}>
|
||
{category_name}
|
||
</option>
|
||
<% end %>
|
||
</select>
|
||
</form>
|
||
</div>
|
||
<div>
|
||
<form phx-change="sort_change" class="relative">
|
||
<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="name_asc" selected={@sort_criteria == "name_asc"}>
|
||
Name A-Z
|
||
</option>
|
||
<option value="name_desc" selected={@sort_criteria == "name_desc"}>
|
||
Name Z-A
|
||
</option>
|
||
<option value="inserted_at_desc" selected={@sort_criteria == "inserted_at_desc"}>
|
||
Entry Date (Newest)
|
||
</option>
|
||
<option value="inserted_at_asc" selected={@sort_criteria == "inserted_at_asc"}>
|
||
Entry Date (Oldest)
|
||
</option>
|
||
<option value="updated_at_desc" selected={@sort_criteria == "updated_at_desc"}>
|
||
Update Date (Newest)
|
||
</option>
|
||
<option value="updated_at_asc" selected={@sort_criteria == "updated_at_asc"}>
|
||
Update Date (Oldest)
|
||
</option>
|
||
<option value="count_desc" selected={@sort_criteria == "count_desc"}>
|
||
Count (Highest)
|
||
</option>
|
||
<option value="count_asc" selected={@sort_criteria == "count_asc"}>
|
||
Count (Lowest)
|
||
</option>
|
||
</select>
|
||
<%= if @sort_frozen do %>
|
||
<div class="absolute -bottom-5 left-0 text-xs text-yellow-600 flex items-center transition-opacity duration-200 pointer-events-none">
|
||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M10 2L3 7v11h14V7l-7-5z" />
|
||
</svg>
|
||
Sort temporarily frozen
|
||
</div>
|
||
<% end %>
|
||
</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={"/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-6 hover:bg-base-200 transition-all duration-200",
|
||
if(@focused_component_id == component.id,
|
||
do: "bg-base-50 border-l-4 border-primary",
|
||
else: "cursor-pointer"
|
||
),
|
||
if(@interacting_with == to_string(component.id),
|
||
do: "ring-2 ring-yellow-400 ring-opacity-50 bg-yellow-50",
|
||
else: ""
|
||
)
|
||
]}
|
||
phx-click={if @focused_component_id != component.id, do: "toggle_focus", else: nil}
|
||
phx-value-id={component.id}
|
||
>
|
||
<%= if @focused_component_id == component.id do %>
|
||
<!-- Expanded/Focused View -->
|
||
<div class="space-y-6">
|
||
<!-- Header with name, category, and close button -->
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex items-center min-w-0 flex-1">
|
||
<h3 class="text-lg font-semibold text-primary select-text">
|
||
<%= if component.datasheet_url do %>
|
||
<a
|
||
href={component.datasheet_url}
|
||
target="_blank"
|
||
class="hover:text-primary/80"
|
||
>
|
||
{component.name}
|
||
</a>
|
||
<% else %>
|
||
{component.name}
|
||
<% end %>
|
||
</h3>
|
||
<%= if component.datasheet_url do %>
|
||
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
|
||
<% end %>
|
||
</div>
|
||
<div class="ml-4 flex items-center space-x-3">
|
||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-green-100 text-green-800">
|
||
{component.category.name}
|
||
</span>
|
||
<!-- Close/Collapse button -->
|
||
<button
|
||
phx-click="toggle_focus"
|
||
phx-value-id={component.id}
|
||
class="p-1 rounded-full hover:bg-base-200 text-base-content/60 hover:text-base-content transition-colors"
|
||
title="Collapse"
|
||
>
|
||
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content area with image and details -->
|
||
<div class="flex gap-6">
|
||
<!-- Large Image -->
|
||
<div class="flex-shrink-0">
|
||
<%= if component.image_filename do %>
|
||
<button
|
||
phx-click="show_image"
|
||
phx-value-url={"/uploads/images/#{component.image_filename}"}
|
||
class="hover:opacity-75 transition-opacity block"
|
||
>
|
||
<img
|
||
src={"/uploads/images/#{component.image_filename}"}
|
||
alt={component.name}
|
||
class="h-48 w-48 rounded-lg object-contain cursor-pointer border border-base-300"
|
||
/>
|
||
</button>
|
||
<% else %>
|
||
<div class="h-48 w-48 rounded-lg bg-base-200 flex items-center justify-center border border-base-300">
|
||
<.icon
|
||
name="hero-cube-transparent"
|
||
class="h-20 w-20 text-base-content/50"
|
||
/>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
|
||
<!-- Details -->
|
||
<div class="flex-1 space-y-4 select-text">
|
||
<!-- Full Description -->
|
||
<%= if component.description do %>
|
||
<div>
|
||
<h4 class="text-sm font-medium text-base-content mb-2">Description</h4>
|
||
<p class="text-sm text-base-content/70 leading-relaxed">
|
||
{component.description}
|
||
</p>
|
||
</div>
|
||
<% end %>
|
||
|
||
<!-- Metadata Grid -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||
<%= if component.storage_location do %>
|
||
<div class="flex items-start">
|
||
<.icon
|
||
name="hero-map-pin"
|
||
class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0"
|
||
/>
|
||
<div>
|
||
<span class="font-medium text-base-content">Location:</span>
|
||
<div class="text-base-content/70">
|
||
{storage_location_display_name(component.storage_location)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
|
||
<div class="flex items-center">
|
||
<.icon name="hero-cube" class="w-4 h-4 mr-2 text-base-content/50" />
|
||
<span class="font-medium text-base-content">Count:</span>
|
||
<span class="ml-1 text-base-content/70">{component.count}</span>
|
||
</div>
|
||
|
||
<div class="flex items-start">
|
||
<.icon
|
||
name="hero-calendar"
|
||
class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0"
|
||
/>
|
||
<div>
|
||
<span class="font-medium text-base-content">Entry Date:</span>
|
||
<div class="text-base-content/70">
|
||
{Calendar.strftime(component.inserted_at, "%B %d, %Y")}
|
||
</div>
|
||
</div>
|
||
</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-2 text-base-content/50" />
|
||
<span class="font-medium text-base-content">ID:</span>
|
||
<span class="ml-1 text-base-content/70">{component.id}</span>
|
||
</div>
|
||
<% end %>
|
||
|
||
<%= if component.keywords do %>
|
||
<div class="flex items-start">
|
||
<.icon name="hero-tag" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5" />
|
||
<div>
|
||
<span class="font-medium text-base-content">Keywords:</span>
|
||
<div class="text-base-content/70">{component.keywords}</div>
|
||
</div>
|
||
</div>
|
||
<% end %>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Action Buttons -->
|
||
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
|
||
<button
|
||
phx-click="increment_count"
|
||
phx-value-id={component.id}
|
||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md 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 mr-1" /> Add
|
||
</button>
|
||
<button
|
||
phx-click="decrement_count"
|
||
phx-value-id={component.id}
|
||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md 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 mr-1" /> Remove
|
||
</button>
|
||
<button
|
||
phx-click="show_edit_form"
|
||
phx-value-id={component.id}
|
||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md 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 mr-1" /> Edit
|
||
</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 px-3 py-2 border border-transparent text-sm font-medium rounded-md 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 mr-1" /> Delete
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<% else %>
|
||
<!-- Compact/Normal View -->
|
||
<div class="flex items-start justify-between min-h-[5rem]">
|
||
<!-- Component Image -->
|
||
<div class="flex-shrink-0 mr-6 h-20 w-20 grid place-items-center">
|
||
<%= if component.image_filename do %>
|
||
<button
|
||
phx-click="show_image"
|
||
phx-value-url={"/uploads/images/#{component.image_filename}"}
|
||
class="hover:opacity-75 transition-opacity block"
|
||
>
|
||
<img
|
||
src={"/uploads/images/#{component.image_filename}"}
|
||
alt={component.name}
|
||
class="max-h-20 max-w-20 rounded-md object-contain cursor-pointer block"
|
||
/>
|
||
</button>
|
||
<% else %>
|
||
<div class="h-20 w-20 rounded-md bg-base-200 flex items-center justify-center">
|
||
<.icon name="hero-cube-transparent" class="h-10 w-10 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>
|
||
<% end %>
|
||
</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
|
||
IO.puts("=== DEBUG: Starting save_uploaded_image ===")
|
||
IO.inspect(socket.assigns.uploads.image.entries, label: "Upload entries")
|
||
|
||
uploaded_files =
|
||
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
|
||
filename = "#{System.unique_integer([:positive])}_#{entry.client_name}"
|
||
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||
upload_dir = Path.join([uploads_dir, "images"])
|
||
dest = Path.join(upload_dir, filename)
|
||
|
||
IO.puts("=== DEBUG: Processing upload ===")
|
||
IO.puts("Filename: #{filename}")
|
||
IO.puts("Upload dir: #{upload_dir}")
|
||
IO.puts("Destination: #{dest}")
|
||
|
||
# Ensure the upload directory exists
|
||
File.mkdir_p!(upload_dir)
|
||
|
||
# Copy the file
|
||
case File.cp(path, dest) do
|
||
:ok ->
|
||
IO.puts("=== DEBUG: File copy successful ===")
|
||
{:ok, filename}
|
||
|
||
{:error, reason} ->
|
||
IO.puts("=== DEBUG: File copy failed: #{inspect(reason)} ===")
|
||
{:postpone, {:error, reason}}
|
||
end
|
||
end)
|
||
|
||
IO.inspect(uploaded_files, label: "Uploaded files result")
|
||
|
||
result =
|
||
case uploaded_files do
|
||
[filename] when is_binary(filename) ->
|
||
IO.puts("=== DEBUG: Adding filename to params: #{filename} ===")
|
||
Map.put(component_params, "image_filename", filename)
|
||
|
||
[] ->
|
||
IO.puts("=== DEBUG: No files uploaded ===")
|
||
component_params
|
||
|
||
_error ->
|
||
IO.puts("=== DEBUG: Upload error ===")
|
||
IO.inspect(uploaded_files, label: "Unexpected upload result")
|
||
component_params
|
||
end
|
||
|
||
IO.inspect(result, label: "Final component_params")
|
||
IO.puts("=== DEBUG: End save_uploaded_image ===")
|
||
result
|
||
end
|
||
|
||
defp delete_image_file(nil), do: :ok
|
||
defp delete_image_file(""), do: :ok
|
||
|
||
defp delete_image_file(filename) do
|
||
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||
path = Path.join([uploads_dir, "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
|