feat: add global CSS polish — transitions, hover effects, scrollbar styling, status pulse
This commit is contained in:
parent
5a6643c6aa
commit
ad6e9b91a3
7 changed files with 236 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ defmodule ToweropsWeb do
|
|||
|
||||
import Phoenix.HTML
|
||||
import ToweropsWeb.CoreComponents
|
||||
import ToweropsWeb.Components.Skeletons
|
||||
import ToweropsWeb.GettextHelpers
|
||||
|
||||
# HTML escaping functionality
|
||||
|
|
|
|||
50
lib/towerops_web/components/breadcrumbs.ex
Normal file
50
lib/towerops_web/components/breadcrumbs.ex
Normal file
|
|
@ -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"""
|
||||
<div class="breadcrumbs text-sm mb-4">
|
||||
<ul>
|
||||
<%= for {item, idx} <- Enum.with_index(@items) do %>
|
||||
<li>
|
||||
<%= if idx == 0 && Map.has_key?(item, :navigate) do %>
|
||||
<.link navigate={item.navigate} class="inline-flex items-center gap-1">
|
||||
<.icon name="hero-home-mini" class="h-4 w-4" />
|
||||
{item.label}
|
||||
</.link>
|
||||
<% else %>
|
||||
<%= if Map.has_key?(item, :navigate) do %>
|
||||
<.link navigate={item.navigate}>{item.label}</.link>
|
||||
<% else %>
|
||||
{item.label}
|
||||
<% end %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -457,7 +457,7 @@ defmodule ToweropsWeb.Layouts do
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
<main id="main-content" tabindex="-1" class="flex-1 py-6 sm:py-10">
|
||||
<main id="main-content" tabindex="-1" class="flex-1 py-6 sm:py-10 page-fade-in">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<!-- Beta Testing Banner -->
|
||||
<div
|
||||
|
|
|
|||
94
lib/towerops_web/components/skeletons.ex
Normal file
94
lib/towerops_web/components/skeletons.ex
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
defmodule ToweropsWeb.Components.Skeletons do
|
||||
@moduledoc """
|
||||
Loading skeleton components for placeholder UI during data fetches.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
@doc """
|
||||
A pulsing card placeholder.
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def skeleton_card(assigns) do
|
||||
~H"""
|
||||
<div class={["animate-pulse rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50", @class]}>
|
||||
<div class="h-3 w-24 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-3 h-8 w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Table rows with pulsing placeholders.
|
||||
"""
|
||||
attr :rows, :integer, default: 5
|
||||
attr :cols, :integer, default: 4
|
||||
|
||||
def skeleton_table(assigns) do
|
||||
~H"""
|
||||
<div class="animate-pulse rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50">
|
||||
<div class="border-b border-gray-200 px-4 py-3 dark:border-white/10">
|
||||
<div class="flex gap-6">
|
||||
<div :for={_ <- 1..@cols} class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div :for={_ <- 1..@rows} class="border-b border-gray-100 px-4 py-3 last:border-0 dark:border-white/5">
|
||||
<div class="flex gap-6">
|
||||
<div :for={_ <- 1..@cols} class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
A stat card skeleton (number + label placeholder).
|
||||
"""
|
||||
attr :class, :string, default: nil
|
||||
|
||||
def skeleton_stat(assigns) do
|
||||
~H"""
|
||||
<div class={["animate-pulse rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50", @class]}>
|
||||
<div class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-8 w-12 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Dashboard loading skeleton - full placeholder for the dashboard content.
|
||||
"""
|
||||
def skeleton_dashboard(assigns) do
|
||||
~H"""
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<.skeleton_stat :for={_ <- 1..6} />
|
||||
</div>
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||
<div :for={_ <- 1..4} class="h-8 w-28 animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 grid gap-8 lg:grid-cols-5">
|
||||
<div class="lg:col-span-3">
|
||||
<div class="h-5 w-28 animate-pulse rounded bg-gray-200 dark:bg-gray-700 mb-4"></div>
|
||||
<div class="space-y-3">
|
||||
<div :for={_ <- 1..3} class="animate-pulse rounded-lg border border-gray-200 bg-white p-4 dark:border-white/10 dark:bg-gray-800/50">
|
||||
<div class="h-3 w-32 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-4 w-48 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-3 w-64 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<div class="h-5 w-20 animate-pulse rounded bg-gray-200 dark:bg-gray-700 mb-4"></div>
|
||||
<div class="space-y-3">
|
||||
<div :for={_ <- 1..3} class="animate-pulse rounded-lg border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-800/50">
|
||||
<div class="h-3 w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div class="mt-2 h-4 w-40 rounded bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -26,7 +26,11 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @device_count == 0 do %>
|
||||
<%= if !assigns[:summary] do %>
|
||||
<.skeleton_dashboard />
|
||||
<% end %>
|
||||
|
||||
<%= if assigns[:summary] && @device_count == 0 do %>
|
||||
<div class="mt-8 rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-12 text-center dark:border-white/20 dark:bg-gray-800/50">
|
||||
<.icon
|
||||
name="hero-light-bulb"
|
||||
|
|
@ -103,7 +107,9 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<% end %>
|
||||
|
||||
<%= if assigns[:summary] && @device_count > 0 do %>
|
||||
<%!-- Section A: Health Overview --%>
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<%!-- Health Score --%>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue