diff --git a/assets/css/app.css b/assets/css/app.css index f3ba08d3..2ab1ed1c 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -105,4 +105,61 @@ /* Hide 1Password live region */ [id="1p-live-region"] { display: none !important; } +/* Page transition animation */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.page-fade-in { + animation: fadeIn 0.15s ease-out; +} + /* This file is for your main application CSS */ + +/* Smooth page transitions */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.page-fade-in { animation: fadeIn 0.15s ease-out; } + +/* Smooth transitions on interactive elements */ +.card, .btn, .badge, .alert { + transition: box-shadow 0.15s ease, transform 0.15s ease; +} + +/* Subtle hover lift on cards */ +.card-hover:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); +} +@media (prefers-color-scheme: dark) { + .card-hover:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + } +} + +/* Status pulse animation for live indicators */ +@keyframes statusPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +.status-pulse { animation: statusPulse 2s ease-in-out infinite; } + +/* Scrollbar styling for webkit */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.15); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.25); } +[data-theme="dark"] ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); } +[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.25); } + +/* Table row hover highlight */ +tbody tr:hover td { background-color: rgba(59, 130, 246, 0.04); } +[data-theme="dark"] tbody tr:hover td { background-color: rgba(59, 130, 246, 0.08); } + +/* Focus ring styling */ +:focus-visible { + outline: 2px solid oklch(58% 0.233 277.117); + outline-offset: 2px; +} diff --git a/lib/towerops_web.ex b/lib/towerops_web.ex index 1a839f20..ff42afaa 100644 --- a/lib/towerops_web.ex +++ b/lib/towerops_web.ex @@ -84,6 +84,7 @@ defmodule ToweropsWeb do import Phoenix.HTML import ToweropsWeb.CoreComponents + import ToweropsWeb.Components.Skeletons import ToweropsWeb.GettextHelpers # HTML escaping functionality diff --git a/lib/towerops_web/components/breadcrumbs.ex b/lib/towerops_web/components/breadcrumbs.ex new file mode 100644 index 00000000..cb24c454 --- /dev/null +++ b/lib/towerops_web/components/breadcrumbs.ex @@ -0,0 +1,50 @@ +defmodule ToweropsWeb.Components.Breadcrumbs do + @moduledoc """ + Reusable breadcrumb navigation component using DaisyUI. + """ + use Phoenix.Component + + import ToweropsWeb.CoreComponents, only: [icon: 1] + + @doc """ + Renders a breadcrumb navigation bar. + + Each item in `items` should be a map with: + - `:label` (required) — the display text + - `:navigate` (optional) — path for navigation; omit for the current (last) page + + ## Examples + + <.breadcrumb items={[ + %{label: "Dashboard", navigate: ~p"/dashboard"}, + %{label: "Devices", navigate: ~p"/devices"}, + %{label: "My Device"} + ]} /> + """ + attr :items, :list, required: true + + def breadcrumb(assigns) do + ~H""" + + """ + end +end diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 98429368..f3512066 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -457,7 +457,7 @@ defmodule ToweropsWeb.Layouts do -
+
+
+
+
+
+ """ + end + + @doc """ + Table rows with pulsing placeholders. + """ + attr :rows, :integer, default: 5 + attr :cols, :integer, default: 4 + + def skeleton_table(assigns) do + ~H""" +
+
+
+
+
+
+
+
+
+
+
+
+ """ + end + + @doc """ + A stat card skeleton (number + label placeholder). + """ + attr :class, :string, default: nil + + def skeleton_stat(assigns) do + ~H""" +
+
+
+
+ """ + end + + @doc """ + Dashboard loading skeleton - full placeholder for the dashboard content. + """ + def skeleton_dashboard(assigns) do + ~H""" +
+ <.skeleton_stat :for={_ <- 1..6} /> +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """ + end +end diff --git a/lib/towerops_web/live/dashboard_live.html.heex b/lib/towerops_web/live/dashboard_live.html.heex index 7c2c6e73..c2f8fb4c 100644 --- a/lib/towerops_web/live/dashboard_live.html.heex +++ b/lib/towerops_web/live/dashboard_live.html.heex @@ -26,7 +26,11 @@
<% end %> - <%= if @device_count == 0 do %> + <%= if !assigns[:summary] do %> + <.skeleton_dashboard /> + <% end %> + + <%= if assigns[:summary] && @device_count == 0 do %>
<.icon name="hero-light-bulb" @@ -103,7 +107,9 @@ <% end %>
- <% else %> + <% end %> + + <%= if assigns[:summary] && @device_count > 0 do %> <%!-- Section A: Health Overview --%>
<%!-- Health Score --%> diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index f28c130b..dc2728f4 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -36,6 +36,11 @@ defmodule ToweropsWeb.DeviceLive.Index do |> assign(:sites_enabled, organization.use_sites) |> assign(:has_sites, sites != []) |> assign(:reorder_mode, false) + |> assign(:compact_mode, false) + |> assign(:total_devices, length(devices)) + |> assign(:total_up, Enum.count(devices, &(&1.status == :up))) + |> assign(:total_down, Enum.count(devices, &(&1.status == :down))) + |> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown))) |> assign(:device_quota, %{current: current, limit: limit}) |> assign(:site_subscribers, site_subscribers) |> assign(:pending_reload_ref, nil)} @@ -151,6 +156,11 @@ defmodule ToweropsWeb.DeviceLive.Index do {:noreply, assign(socket, :reorder_mode, !socket.assigns.reorder_mode)} end + @impl true + def handle_event("toggle_compact_mode", _params, socket) do + {:noreply, assign(socket, :compact_mode, !socket.assigns.compact_mode)} + end + @impl true def handle_event("reset_order", _params, socket) do organization_id = socket.assigns.current_scope.organization.id @@ -293,6 +303,10 @@ defmodule ToweropsWeb.DeviceLive.Index do |> assign(:has_devices, devices != []) |> assign(:device_quota, %{current: current, limit: limit}) |> assign(:site_subscribers, load_site_subscribers(devices)) + |> assign(:total_devices, length(devices)) + |> assign(:total_up, Enum.count(devices, &(&1.status == :up))) + |> assign(:total_down, Enum.count(devices, &(&1.status == :down))) + |> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown))) end # Lightweight reload: stream only, skip quota query (for status/update changes) @@ -304,6 +318,10 @@ defmodule ToweropsWeb.DeviceLive.Index do |> stream(:device_rows, build_device_rows(devices), reset: true) |> assign(:has_devices, devices != []) |> assign(:pending_reload_ref, nil) + |> assign(:total_devices, length(devices)) + |> assign(:total_up, Enum.count(devices, &(&1.status == :up))) + |> assign(:total_down, Enum.count(devices, &(&1.status == :down))) + |> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown))) end defp enqueue_discovery(device_id) do @@ -399,6 +417,13 @@ defmodule ToweropsWeb.DeviceLive.Index do |> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end) end + defp device_row_title(device) do + {last_seen, _color} = last_seen_text(device.last_seen_at) + snmp = if device.snmp_enabled, do: "SNMP: #{device.snmp_version || "v2c"}", else: "SNMP: off" + type_label = device.device_type |> to_string() |> String.capitalize() + "#{type_label} | Last Poll: #{last_seen} | #{snmp}" + end + # Health indicator helpers defp health_dot_color(nil), do: "bg-gray-400"