Skip to content

Commit e2f0b49

Browse files
joshkclaude
andcommitted
Show a drop overlay when dragging firmware onto the list page
Dragging a firmware file onto the firmware list page already worked. The whole list is a `phx-drop-target`, but nothing on screen said so, so you had to know. Now the list dims and blurs behind a dashed drop zone as soon as a file enters the page, with the accepted extensions spelled out. LiveView 1.2 puts a `phx-drop-target-active` class on the drop target for the duration of a file drag, and handles the drag-depth counting that makes nested `dragleave` events behave. So this is markup and CSS only, no hook. `@custom-variant phx-drop-target-active` joins the four `phx-*-loading` variants already in `app.css`. The overlay is `pointer-events-none`, so the drop still lands on the target underneath it, and `aria-hidden`, since it is decoration over a list that is already there. The drop target now wraps the scrolling list rather than being it, which is what keeps the overlay in place instead of scrolling away with the rows. That reparenting is why the diff re-indents the whole template. The archives list page has the same drop target and no overlay. Left for a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 4fe103b commit e2f0b49

2 files changed

Lines changed: 123 additions & 100 deletions

File tree

assets/css/app.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,12 @@
234234
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
235235
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
236236
@custom-variant phx-no-feedback (.phx-no-feedback&, .phx-no-feedback &);
237+
/* LiveView sets `phx-drop-target-active` on a `phx-drop-target` element while a
238+
file is being dragged over it, which is what reveals a drop overlay. */
239+
@custom-variant phx-drop-target-active (
240+
.phx-drop-target-active&,
241+
.phx-drop-target-active &
242+
);
237243

238244
@utility shadow-filter-slider {
239245
box-shadow: var(--shadow-filter-slider);

lib/nerves_hub_web/live/firmware_templates/list_firmware_template.html.heex

Lines changed: 117 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,110 @@
11
<NervesHubWeb.Layouts.sidebar flash={@flash} current_scope={@current_scope} sidebar_tab={@sidebar_tab}>
2-
<div class="h-0 flex-1 overflow-y-auto" phx-drop-target={@uploads.firmware.ref}>
3-
<div class="border-base-700 h-header flex items-center gap-4 border-b px-6 py-7 text-sm font-medium">
4-
<h1 class="text-base-50 text-xl leading-[30px] font-semibold">All Firmware</h1>
5-
<div class="bg-base-800 text-base-300 mr-auto rounded-sm px-1.5 py-0.5 text-xs">
6-
{@pager_meta.total_count}
7-
</div>
8-
9-
<div :for={entry <- @uploads.firmware.entries} class="flex w-2/5 flex-col items-center pr-2 pl-4">
10-
<div class="mb-1 flex justify-between">
11-
<span class="text-primary text-sm dark:text-white">Uploading firmware... </span>
12-
<span class="text-primary text-sm dark:text-white">{entry.progress}%</span>
13-
</div>
14-
<div class="h-2.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
15-
<div class="bg-primary h-2.5 animate-pulse rounded-full" style={"width: #{entry.progress}%"}></div>
2+
<%!-- The drop target wraps the scrolling list instead of being it, so the
3+
overlay can cover the whole area without scrolling along with the rows. --%>
4+
<div class="relative flex h-0 flex-1 flex-col" phx-drop-target={@uploads.firmware.ref}>
5+
<div class="h-0 flex-1 overflow-y-auto">
6+
<div class="border-base-700 h-header flex items-center gap-4 border-b px-6 py-7 text-sm font-medium">
7+
<h1 class="text-base-50 text-xl leading-[30px] font-semibold">All Firmware</h1>
8+
<div class="bg-base-800 text-base-300 mr-auto rounded-sm px-1.5 py-0.5 text-xs">
9+
{@pager_meta.total_count}
1610
</div>
17-
</div>
1811

19-
<form id="firmware-form" phx-change="validate-firmware" class={[Enum.any?(@uploads.firmware.entries) && "hidden"]}>
20-
<div class="bg-base-800 border-base-600 flex gap-2 rounded border px-3 py-1.5 hover:cursor-pointer">
21-
<svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
22-
<path
23-
d="M4.1665 10.0001H9.99984M15.8332 10.0001H9.99984M9.99984 10.0001V4.16675M9.99984 10.0001V15.8334"
24-
stroke="#A1A1AA"
25-
stroke-width="1.2"
26-
stroke-linecap="round"
27-
stroke-linejoin="round"
28-
/>
29-
</svg>
30-
<label for={@uploads.firmware.ref} class="text-base-300 text-sm font-medium hover:cursor-pointer">Upload Firmware</label>
31-
<.live_file_input upload={@uploads.firmware} class="hidden" />
12+
<div :for={entry <- @uploads.firmware.entries} class="flex w-2/5 flex-col items-center pr-2 pl-4">
13+
<div class="mb-1 flex justify-between">
14+
<span class="text-primary text-sm dark:text-white">Uploading firmware... </span>
15+
<span class="text-primary text-sm dark:text-white">{entry.progress}%</span>
16+
</div>
17+
<div class="h-2.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
18+
<div class="bg-primary h-2.5 animate-pulse rounded-full" style={"width: #{entry.progress}%"}></div>
19+
</div>
3220
</div>
33-
</form>
34-
</div>
35-
<%= if Enum.any?(@firmware) do %>
36-
<div class="listing">
37-
<table>
38-
<thead>
39-
<tr>
40-
<th>UUID</th>
41-
<th phx-click="sort" phx-value-sort="version" class="cursor-pointer">
42-
<Sorting.sort_icon text="Version" field="version" selected_field={@current_sort} selected_direction={@sort_direction} />
43-
</th>
44-
<th>Tool</th>
45-
<th>Platform</th>
46-
<th>Architecture</th>
47-
<th phx-click="sort" phx-value-sort="install_count" class="cursor-pointer">
48-
<Sorting.sort_icon text="Install count" field="install_count" selected_field={@current_sort} selected_direction={@sort_direction} />
49-
</th>
50-
<th>Signing Key</th>
51-
<th phx-click="sort" phx-value-sort="inserted_at" class="cursor-pointer">
52-
<Sorting.sort_icon text={"Uploaded on (#{zone_abbr(@time_zone)})"} field="inserted_at" selected_field={@current_sort} selected_direction={@sort_direction} />
53-
</th>
54-
</tr>
55-
</thead>
56-
<tbody>
57-
<tr :for={firmware <- @firmware} class="border-base-800 border-b">
58-
<td>
59-
<div class="flex items-center gap-2">
60-
<.link patch={~p"/org/#{@current_scope.org}/#{@product}/firmware/#{firmware}"}>
61-
{firmware.uuid}
62-
</.link>
63-
</div>
64-
</td>
6521

66-
<td>
67-
<div class="flex items-center gap-2">
68-
{firmware.version}
69-
</div>
70-
</td>
22+
<form id="firmware-form" phx-change="validate-firmware" class={[Enum.any?(@uploads.firmware.entries) && "hidden"]}>
23+
<div class="bg-base-800 border-base-600 flex gap-2 rounded border px-3 py-1.5 hover:cursor-pointer">
24+
<svg class="size-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none">
25+
<path
26+
d="M4.1665 10.0001H9.99984M15.8332 10.0001H9.99984M9.99984 10.0001V4.16675M9.99984 10.0001V15.8334"
27+
stroke="#A1A1AA"
28+
stroke-width="1.2"
29+
stroke-linecap="round"
30+
stroke-linejoin="round"
31+
/>
32+
</svg>
33+
<label for={@uploads.firmware.ref} class="text-base-300 text-sm font-medium hover:cursor-pointer">Upload Firmware</label>
34+
<.live_file_input upload={@uploads.firmware} class="hidden" />
35+
</div>
36+
</form>
37+
</div>
38+
<%= if Enum.any?(@firmware) do %>
39+
<div class="listing">
40+
<table>
41+
<thead>
42+
<tr>
43+
<th>UUID</th>
44+
<th phx-click="sort" phx-value-sort="version" class="cursor-pointer">
45+
<Sorting.sort_icon text="Version" field="version" selected_field={@current_sort} selected_direction={@sort_direction} />
46+
</th>
47+
<th>Tool</th>
48+
<th>Platform</th>
49+
<th>Architecture</th>
50+
<th phx-click="sort" phx-value-sort="install_count" class="cursor-pointer">
51+
<Sorting.sort_icon text="Install count" field="install_count" selected_field={@current_sort} selected_direction={@sort_direction} />
52+
</th>
53+
<th>Signing Key</th>
54+
<th phx-click="sort" phx-value-sort="inserted_at" class="cursor-pointer">
55+
<Sorting.sort_icon text={"Uploaded on (#{zone_abbr(@time_zone)})"} field="inserted_at" selected_field={@current_sort} selected_direction={@sort_direction} />
56+
</th>
57+
</tr>
58+
</thead>
59+
<tbody>
60+
<tr :for={firmware <- @firmware} class="border-base-800 border-b">
61+
<td>
62+
<div class="flex items-center gap-2">
63+
<.link patch={~p"/org/#{@current_scope.org}/#{@product}/firmware/#{firmware}"}>
64+
{firmware.uuid}
65+
</.link>
66+
</div>
67+
</td>
7168

72-
<td>
73-
<span class="bg-base-800 border-base-700 inline-flex gap-1 rounded-full border py-0.5 pr-3 pl-2.5">
74-
<code class="text-base-300 text-xs">{firmware.tool}</code>
75-
</span>
76-
</td>
69+
<td>
70+
<div class="flex items-center gap-2">
71+
{firmware.version}
72+
</div>
73+
</td>
7774

78-
<td>
79-
{firmware.platform}
80-
</td>
75+
<td>
76+
<span class="bg-base-800 border-base-700 inline-flex gap-1 rounded-full border py-0.5 pr-3 pl-2.5">
77+
<code class="text-base-300 text-xs">{firmware.tool}</code>
78+
</span>
79+
</td>
8180

82-
<td>
83-
{firmware.architecture}
84-
</td>
81+
<td>
82+
{firmware.platform}
83+
</td>
8584

86-
<td>
87-
{firmware.install_count || 0}
88-
</td>
85+
<td>
86+
{firmware.architecture}
87+
</td>
8988

90-
<td>
91-
<span class="bg-base-800 border-base-700 inline-flex gap-1 rounded-full border py-0.5 pr-3 pl-2.5">
92-
<code class="text-base-300 text-xs">{format_signed(firmware, @org_keys)}</code>
93-
</span>
94-
</td>
89+
<td>
90+
{firmware.install_count || 0}
91+
</td>
9592

96-
<td>
97-
<%= if is_nil(firmware.inserted_at) do %>
98-
Never
99-
<% else %>
100-
<.local_datetime at={firmware.inserted_at} time_zone={@time_zone} zone_label={false} />
101-
<% end %>
102-
</td>
93+
<td>
94+
<span class="bg-base-800 border-base-700 inline-flex gap-1 rounded-full border py-0.5 pr-3 pl-2.5">
95+
<code class="text-base-300 text-xs">{format_signed(firmware, @org_keys)}</code>
96+
</span>
97+
</td>
10398

104-
<%!-- <td class="actions">
99+
<td>
100+
<%= if is_nil(firmware.inserted_at) do %>
101+
Never
102+
<% else %>
103+
<.local_datetime at={firmware.inserted_at} time_zone={@time_zone} zone_label={false} />
104+
<% end %>
105+
</td>
106+
107+
<%!-- <td class="actions">
105108
<div class="">
106109
<a class="" data-target="#" id={"actions-#{device.id}"} data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" phx-click={show_menu("actions-menu-#{device.id}")}>
107110
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none">
@@ -139,15 +142,29 @@
139142
</div>
140143
</div>
141144
</td> --%>
142-
</tr>
143-
</tbody>
144-
</table>
145-
</div>
146-
<% else %>
147-
<div class="flex h-full items-center justify-center pb-16">
148-
<span class="text-base-50 text-xl font-medium">{@product.name} doesn’t have any firmware yet.</span>
145+
</tr>
146+
</tbody>
147+
</table>
148+
</div>
149+
<% else %>
150+
<div class="flex h-full items-center justify-center pb-16">
151+
<span class="text-base-50 text-xl font-medium">{@product.name} doesn’t have any firmware yet.</span>
152+
</div>
153+
<% end %>
154+
</div>
155+
156+
<%!-- Only shown while a file is being dragged over the page. `pointer-events-none`
157+
keeps the drop itself landing on the target underneath. --%>
158+
<div
159+
aria-hidden="true"
160+
class="bg-surface/70 phx-drop-target-active:opacity-100 pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-6 opacity-0 backdrop-blur-xs transition-opacity duration-150"
161+
>
162+
<div class="bg-primary/5 border-primary flex size-full flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed">
163+
<span class="lucide-upload--light text-primary size-10"></span>
164+
<span class="text-base-50 text-lg font-medium">Drop to upload firmware</span>
165+
<span class="text-base-400 text-sm">Accepts .fw, .bin, .raucb and .avm files</span>
149166
</div>
150-
<% end %>
167+
</div>
151168
</div>
152169

153170
<Pager.render_with_page_sizes pager={@pager_meta} page_sizes={[25, 50, 100]} />

0 commit comments

Comments
 (0)