Files
component-system/lib/components_elixir_web/live/components_live.ex
Schuwi a0348c7df9 fix: image path regression
- probably lost during rebase
2025-09-19 20:21:31 +02:00

1310 lines
53 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, 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