feat(elixir): add component list focus mode
This commit is contained in:
@@ -35,6 +35,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|> 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,
|
||||
@@ -207,6 +208,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|> 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
|
||||
nil # Unfocus if clicking on the same component
|
||||
else
|
||||
component_id # Focus on the new component
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :focused_component_id, new_focused_id)}
|
||||
end
|
||||
|
||||
def handle_event("prevent_close", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
@@ -675,26 +689,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<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 min-h-[6rem]">
|
||||
<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={"/user_generated/uploads/images/#{component.image_filename}"} class="hover:opacity-75 transition-opacity block">
|
||||
<img src={"/user_generated/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 -->
|
||||
<li id={"component-#{component.id}"} class={"px-6 py-6 hover:bg-base-200 #{if @focused_component_id == component.id, do: "bg-base-50 border-l-4 border-primary", else: "cursor-pointer"}"} 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">
|
||||
<p class="text-sm font-medium text-primary truncate">
|
||||
<h3 class="text-lg font-semibold text-primary select-text">
|
||||
<%= if component.datasheet_url do %>
|
||||
<a
|
||||
href={component.datasheet_url}
|
||||
@@ -706,91 +708,268 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<% else %>
|
||||
{component.name}
|
||||
<% end %>
|
||||
</p>
|
||||
</h3>
|
||||
<%= 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">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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={"/user_generated/uploads/images/#{component.image_filename}"}
|
||||
class="hover:opacity-75 transition-opacity block"
|
||||
>
|
||||
<img
|
||||
src={"/user_generated/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>
|
||||
<% 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>
|
||||
<!-- 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={"/user_generated/uploads/images/#{component.image_filename}"}
|
||||
class="hover:opacity-75 transition-opacity block"
|
||||
>
|
||||
<img src={"/user_generated/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 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>
|
||||
|
||||
<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>
|
||||
<%= if @sort_criteria == "all" or @sort_criteria == "id" do %>
|
||||
|
||||
<!-- 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-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>
|
||||
<.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>
|
||||
|
||||
<!-- 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 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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user