Improve UI styling and remove DaisyUI dependencies

- Remove default Phoenix branding and create custom layouts
- Replace DaisyUI classes with custom Tailwind components throughout
- Add authenticated layout with navigation bar for organization pages
- Redesign all core components (buttons, forms, tables, alerts)
- Make dashboard stat cards clickable to navigate to respective pages
- Update alert acknowledge button to only show for equipment down alerts
- Add consistent dark mode support across all pages
- Implement modern design with zinc color palette and improved spacing
This commit is contained in:
Graham McIntire 2026-01-03 11:02:38 -06:00
parent da42d64494
commit dffa46dfde
No known key found for this signature in database
12 changed files with 700 additions and 527 deletions

View file

@ -8,12 +8,8 @@ defmodule ToweropsWeb.CoreComponents do
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
and themes. Here are useful references:
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
started and see the available components.
The foundation for styling is Tailwind CSS, a utility-first CSS framework.
Here are useful references:
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
we build on. You will use it for layout, sizing, flexbox, grid, and
@ -57,24 +53,62 @@ defmodule ToweropsWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
<div class="flex w-full flex-col items-center space-y-4 sm:items-end">
<div class={[
"pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg shadow-lg ring-1 ring-black ring-opacity-5",
@kind == :info && "bg-blue-50 dark:bg-blue-950",
@kind == :error && "bg-red-50 dark:bg-red-950"
]}>
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<.icon
:if={@kind == :info}
name="hero-information-circle"
class="h-6 w-6 text-blue-600 dark:text-blue-400"
/>
<.icon
:if={@kind == :error}
name="hero-exclamation-circle"
class="h-6 w-6 text-red-600 dark:text-red-400"
/>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p :if={@title} class={[
"text-sm font-medium",
@kind == :info && "text-blue-900 dark:text-blue-100",
@kind == :error && "text-red-900 dark:text-red-100"
]}>
{@title}
</p>
<p class={[
"text-sm",
@title && "mt-1",
@kind == :info && "text-blue-700 dark:text-blue-200",
@kind == :error && "text-red-700 dark:text-red-200"
]}>
{msg}
</p>
</div>
<div class="ml-4 flex flex-shrink-0">
<button
type="button"
class={[
"inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2",
@kind == :info && "text-blue-500 hover:text-blue-600 focus:ring-blue-500 dark:text-blue-400",
@kind == :error && "text-red-500 hover:text-red-600 focus:ring-red-500 dark:text-red-400"
]}
aria-label={gettext("close")}
>
<.icon name="hero-x-mark" class="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
@ -95,11 +129,19 @@ defmodule ToweropsWeb.CoreComponents do
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
variants = %{
"primary" =>
"bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600",
nil =>
"bg-white text-zinc-900 ring-1 ring-inset ring-zinc-300 hover:bg-zinc-50 focus:ring-blue-500 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:hover:bg-zinc-700"
}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
[
"inline-flex items-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed",
Map.fetch!(variants, assigns[:variant])
]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
@ -204,8 +246,8 @@ defmodule ToweropsWeb.CoreComponents do
end)
~H"""
<div class="fieldset mb-2">
<label>
<div class="mb-4">
<label class="flex items-center gap-3">
<input
type="hidden"
name={@name}
@ -213,17 +255,19 @@ defmodule ToweropsWeb.CoreComponents do
disabled={@rest[:disabled]}
form={@rest[:form]}
/>
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={
@class ||
"h-4 w-4 rounded border-zinc-300 text-blue-600 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800"
}
{@rest}
/>
<span class="text-sm font-medium text-zinc-900 dark:text-zinc-100">{@label}</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
@ -232,13 +276,23 @@ defmodule ToweropsWeb.CoreComponents do
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<div class="mb-4">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
{@label}
</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
class={[
@class ||
"block w-full rounded-lg border-0 py-2 px-3 text-zinc-900 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 dark:bg-zinc-800 dark:text-zinc-100",
@errors == [] &&
"ring-zinc-300 focus:ring-blue-600 dark:ring-zinc-700 dark:focus:ring-blue-500",
@errors != [] &&
(@error_class ||
"ring-red-300 focus:ring-red-500 dark:ring-red-700 dark:focus:ring-red-500")
]}
multiple={@multiple}
{@rest}
>
@ -253,15 +307,22 @@ defmodule ToweropsWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<div class="mb-4">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
{@label}
</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
@class ||
"block w-full rounded-lg border-0 py-2 px-3 text-zinc-900 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 dark:bg-zinc-800 dark:text-zinc-100",
@errors == [] &&
"ring-zinc-300 focus:ring-blue-600 dark:ring-zinc-700 dark:focus:ring-blue-500",
@errors != [] &&
(@error_class ||
"ring-red-300 focus:ring-red-500 dark:ring-red-700 dark:focus:ring-red-500")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
@ -274,17 +335,24 @@ defmodule ToweropsWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<div class="mb-4">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
{@label}
</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
@class ||
"block w-full rounded-lg border-0 py-2 px-3 text-zinc-900 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 dark:bg-zinc-800 dark:text-zinc-100",
@errors == [] &&
"ring-zinc-300 focus:ring-blue-600 dark:ring-zinc-700 dark:focus:ring-blue-500",
@errors != [] &&
(@error_class ||
"ring-red-300 focus:ring-red-500 dark:ring-red-700 dark:focus:ring-red-500")
]}
{@rest}
/>
@ -297,8 +365,8 @@ defmodule ToweropsWeb.CoreComponents do
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
<p class="mt-2 flex gap-2 items-center text-sm text-red-600 dark:text-red-400">
<.icon name="hero-exclamation-circle" class="h-5 w-5 flex-shrink-0" />
{render_slot(@inner_block)}
</p>
"""
@ -313,12 +381,12 @@ defmodule ToweropsWeb.CoreComponents do
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<header class={[@actions != [] && "flex items-center justify-between gap-6", "mb-8"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
<h1 class="text-3xl font-bold text-zinc-900 dark:text-zinc-100">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
<p :if={@subtitle != []} class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{render_slot(@subtitle)}
</p>
</div>
@ -359,34 +427,52 @@ defmodule ToweropsWeb.CoreComponents do
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 rounded-lg">
<table class="min-w-full divide-y divide-zinc-300 dark:divide-zinc-700">
<thead class="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th
:for={col <- @col}
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-zinc-700 dark:text-zinc-300"
>
{col[:label]}
</th>
<th :if={@action != []} class="px-6 py-3">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody
id={@id}
phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}
class="divide-y divide-zinc-200 bg-white dark:divide-zinc-800 dark:bg-zinc-950"
>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class="hover:bg-zinc-50 dark:hover:bg-zinc-900"
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={[
"px-6 py-4 whitespace-nowrap text-sm text-zinc-900 dark:text-zinc-100",
@row_click && "cursor-pointer"
]}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex gap-4 justify-end">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@ -406,14 +492,12 @@ defmodule ToweropsWeb.CoreComponents do
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
<div class="overflow-hidden bg-white shadow rounded-lg divide-y divide-zinc-200 dark:bg-zinc-900 dark:divide-zinc-800">
<div :for={item <- @item} class="px-6 py-4">
<dt class="text-sm font-medium text-zinc-900 dark:text-zinc-100">{item.title}</dt>
<dd class="mt-1 text-sm text-zinc-700 dark:text-zinc-300">{render_slot(item)}</dd>
</div>
</div>
"""
end

View file

@ -35,35 +35,8 @@ defmodule ToweropsWeb.Layouts do
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<img src={~p"/images/logo.svg"} width="36" />
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
<div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
</li>
<li>
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
</li>
<li>
<.theme_toggle />
</li>
<li>
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
Get Started <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
<main class="min-h-screen bg-zinc-50 px-4 py-12 sm:px-6 lg:px-8 dark:bg-zinc-950">
<div class="mx-auto max-w-2xl">
{render_slot(@inner_block)}
</div>
</main>
@ -72,6 +45,106 @@ defmodule ToweropsWeb.Layouts do
"""
end
@doc """
Renders the authenticated app layout with navigation.
Use this for pages that require organization context.
## Examples
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<h1>Content</h1>
</Layouts.authenticated>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_organization, :map,
default: nil,
doc: "the current organization"
slot :inner_block, required: true
def authenticated(assigns) do
~H"""
<div class="min-h-screen bg-zinc-50 dark:bg-zinc-950">
<nav class="border-b border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="flex flex-shrink-0 items-center">
<.link navigate={~p"/orgs"} class="text-xl font-bold text-zinc-900 dark:text-zinc-100">
Towerops
</.link>
</div>
<div :if={@current_organization} class="ml-10 flex space-x-8">
<.nav_link navigate={~p"/orgs/#{@current_organization.slug}"}>
Dashboard
</.nav_link>
<.nav_link navigate={~p"/orgs/#{@current_organization.slug}/sites"}>
Sites
</.nav_link>
<.nav_link navigate={~p"/orgs/#{@current_organization.slug}/equipment"}>
Equipment
</.nav_link>
<.nav_link navigate={~p"/orgs/#{@current_organization.slug}/alerts"}>
Alerts
</.nav_link>
</div>
</div>
<div class="flex items-center space-x-6">
<span :if={@current_organization} class="text-sm text-zinc-600 dark:text-zinc-400">
{@current_organization.name}
</span>
<.link
navigate={~p"/orgs"}
class="text-sm font-medium text-zinc-700 hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
>
Switch Org
</.link>
<.link
navigate={~p"/users/settings"}
class="text-sm font-medium text-zinc-700 hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
>
Settings
</.link>
<.link
href={~p"/users/log-out"}
method="delete"
class="text-sm font-medium text-zinc-700 hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100"
>
Log out
</.link>
</div>
</div>
</div>
</nav>
<main class="py-10">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{render_slot(@inner_block)}
</div>
</main>
</div>
<.flash_group flash={@flash} />
"""
end
attr :navigate, :string, required: true
slot :inner_block, required: true
defp nav_link(assigns) do
~H"""
<.link
navigate={@navigate}
class="inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium text-zinc-700 hover:border-zinc-300 hover:text-zinc-900 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:text-zinc-100"
>
{render_slot(@inner_block)}
</.link>
"""
end
@doc """
Shows the flash group with standard titles and content.

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="Towerops" suffix=" · Phoenix Framework">
<.live_title default="Towerops">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
@ -31,26 +31,6 @@
</script>
</head>
<body>
<ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_scope do %>
<li>
{@current_scope.user.email}
</li>
<li>
<.link href={~p"/users/settings"}>Settings</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>Register</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>Log in</.link>
</li>
<% end %>
</ul>
{@inner_content}
</body>
</html>

View file

@ -5,7 +5,10 @@
Register for an account
<:subtitle>
Already registered?
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
<.link
navigate={~p"/users/log-in"}
class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Log in
</.link>
to your account now.
@ -13,19 +16,21 @@
</.header>
</div>
<.form :let={f} for={@changeset} action={~p"/users/register"}>
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<div class="mt-8">
<.form :let={f} for={@changeset} action={~p"/users/register"}>
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
Create an account
</.button>
</.form>
<.button phx-disable-with="Creating account..." class="w-full" variant="primary">
Create an account
</.button>
</.form>
</div>
</div>
</Layouts.app>

View file

@ -4,55 +4,66 @@
<.header>Welcome {@user.email}</.header>
</div>
<.form
:if={!@user.confirmed_at}
for={@form}
id="confirmation_form"
action={~p"/users/log-in?_action=confirmed"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Confirming..."
class="btn btn-primary w-full"
<div class="mt-8 space-y-3">
<.form
:if={!@user.confirmed_at}
for={@form}
id="confirmation_form"
action={~p"/users/log-in?_action=confirmed"}
phx-mounted={JS.focus_first()}
>
Confirm and stay logged in
</.button>
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
Confirm and log in only this time
</.button>
</.form>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<div class="space-y-3">
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Confirming..."
class="w-full"
variant="primary"
>
Confirm and stay logged in
</.button>
<.button phx-disable-with="Confirming..." class="w-full">
Confirm and log in only this time
</.button>
</div>
</.form>
<.form
:if={@user.confirmed_at}
for={@form}
id="login_form"
action={~p"/users/log-in"}
phx-mounted={JS.focus_first()}
<.form
:if={@user.confirmed_at}
for={@form}
id="login_form"
action={~p"/users/log-in"}
phx-mounted={JS.focus_first()}
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %>
<.button variant="primary" phx-disable-with="Logging in..." class="w-full">
Log in
</.button>
<% else %>
<div class="space-y-3">
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Logging in..."
class="w-full"
variant="primary"
>
Log in and stay logged in
</.button>
<.button phx-disable-with="Logging in..." class="w-full">
Log in only this time
</.button>
</div>
<% end %>
</.form>
</div>
<p
:if={!@user.confirmed_at}
class="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200"
>
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %>
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
Log in
</.button>
<% else %>
<.button
name={@form[:remember_me].name}
value="true"
phx-disable-with="Logging in..."
class="btn btn-primary w-full"
>
Keep me logged in on this device
</.button>
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
Log me in only this time
</.button>
<% end %>
</.form>
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
Tip: If you prefer passwords, you can enable them in the user settings.
</p>
</div>

View file

@ -1,70 +1,89 @@
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="mx-auto max-w-sm space-y-4">
<div class="mx-auto max-w-sm">
<div class="text-center">
<.header>
<p>Log in</p>
Log in
<:subtitle>
<%= if @current_scope do %>
You need to reauthenticate to perform sensitive actions on your account.
<% else %>
Don't have an account? <.link
Don't have an account?
<.link
navigate={~p"/users/register"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Sign up</.link> for an account now.
class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Sign up
</.link>
for an account now.
<% end %>
</:subtitle>
</.header>
</div>
<div :if={local_mail_adapter?()} class="alert alert-info">
<.icon name="hero-information-circle" class="size-6 shrink-0" />
<div>
<p>You are running the local mail adapter.</p>
<p>
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
</p>
<div
:if={local_mail_adapter?()}
class="mt-6 rounded-lg bg-blue-50 p-4 dark:bg-blue-950"
>
<div class="flex">
<.icon name="hero-information-circle" class="h-6 w-6 flex-shrink-0 text-blue-600 dark:text-blue-400" />
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-200">
You are running the local mail adapter.
</p>
<p class="mt-1 text-sm text-blue-700 dark:text-blue-200">
To see sent emails, visit
<.link href="/dev/mailbox" class="font-medium underline">the mailbox page</.link>.
</p>
</div>
</div>
</div>
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.button class="btn btn-primary w-full">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="mt-8 space-y-6">
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<.button class="w-full" variant="primary">
Log in with email <span aria-hidden="true">→</span>
</.button>
</.form>
<div class="divider">or</div>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-zinc-300 dark:border-zinc-700"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-zinc-50 px-2 text-zinc-600 dark:bg-zinc-950 dark:text-zinc-400">
or
</span>
</div>
</div>
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
/>
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="btn btn-primary btn-soft w-full mt-2">
Log in only this time
</.button>
</.form>
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
<.input
readonly={!!@current_scope}
field={f[:email]}
type="email"
label="Email"
autocomplete="email"
required
/>
<.input field={f[:password]} type="password" label="Password" autocomplete="current-password" />
<div class="space-y-3">
<.button class="w-full" variant="primary" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true">→</span>
</.button>
<.button class="w-full">
Log in only this time
</.button>
</div>
</.form>
</div>
</div>
</Layouts.app>

View file

@ -1,31 +1,43 @@
<.header>
Alerts
<:subtitle>Monitor and acknowledge system alerts</:subtitle>
</.header>
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<.header>
Alerts
<:subtitle>Monitor and acknowledge system alerts</:subtitle>
</.header>
<div class="mt-6">
<div class="tabs tabs-boxed">
<.link
patch={~p"/orgs/#{@current_organization.slug}/alerts"}
class={["tab", @filter == "all" && "tab-active"]}
>
All Alerts
</.link>
<.link
patch={~p"/orgs/#{@current_organization.slug}/alerts?filter=active"}
class={["tab", @filter == "active" && "tab-active"]}
>
Active Alerts
</.link>
<div class="mb-6">
<div class="inline-flex rounded-lg border border-zinc-200 dark:border-zinc-800">
<.link
patch={~p"/orgs/#{@current_organization.slug}/alerts"}
class={[
"px-4 py-2 text-sm font-medium rounded-l-lg",
@filter == "all" &&
"bg-blue-600 text-white dark:bg-blue-500",
@filter != "all" &&
"bg-white text-zinc-700 hover:bg-zinc-50 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
]}
>
All Alerts
</.link>
<.link
patch={~p"/orgs/#{@current_organization.slug}/alerts?filter=active"}
class={[
"px-4 py-2 text-sm font-medium rounded-r-lg border-l border-zinc-200 dark:border-zinc-800",
@filter == "active" &&
"bg-blue-600 text-white dark:bg-blue-500",
@filter != "active" &&
"bg-white text-zinc-700 hover:bg-zinc-50 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:bg-zinc-800"
]}
>
Active Alerts
</.link>
</div>
</div>
</div>
<div class="mt-8">
<%= if Enum.empty?(@alerts) do %>
<div class="text-center py-12">
<.icon name="hero-bell-slash" class="w-12 h-12 mx-auto text-base-content/40" />
<h3 class="mt-2 text-sm font-semibold text-base-content/80">No alerts</h3>
<p class="mt-1 text-sm text-base-content/60">
<div class="text-center py-16">
<.icon name="hero-bell-slash" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h3 class="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">No alerts</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
<%= if @filter == "active" do %>
There are no active alerts for this organization.
<% else %>
@ -37,104 +49,102 @@
<div class="space-y-4">
<%= for alert <- @alerts do %>
<div class={[
"card bg-base-200",
"rounded-lg border p-6 shadow-sm",
alert.resolved_at && "opacity-60",
alert.alert_type == :equipment_down && is_nil(alert.resolved_at) &&
"border-l-4 border-error"
"border-l-4 border-red-500 bg-red-50 dark:bg-red-950 dark:border-red-700",
(alert.alert_type != :equipment_down || alert.resolved_at) &&
"border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900"
]}>
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
<span class={[
"badge badge-lg",
alert.alert_type == :equipment_down && "badge-error",
alert.alert_type == :equipment_up && "badge-success"
]}>
<%= case alert.alert_type do %>
<% :equipment_down -> %>
<.icon name="hero-exclamation-triangle" class="w-4 h-4 mr-1" />
Equipment Down
<% :equipment_up -> %>
<.icon name="hero-check-circle" class="w-4 h-4 mr-1" />
Equipment Recovered
<% end %>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 flex-wrap">
<span class={[
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium",
alert.alert_type == :equipment_down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
alert.alert_type == :equipment_up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
]}>
<%= case alert.alert_type do %>
<% :equipment_down -> %>
<.icon name="hero-exclamation-triangle" class="h-4 w-4" />
Equipment Down
<% :equipment_up -> %>
<.icon name="hero-check-circle" class="h-4 w-4" />
Equipment Recovered
<% end %>
</span>
<%= if alert.resolved_at do %>
<span class="inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200">
Resolved
</span>
<% end %>
<%= if alert.resolved_at do %>
<span class="badge badge-ghost">Resolved</span>
<% end %>
<%= if alert.acknowledged_at do %>
<span class="badge badge-info badge-sm">Acknowledged</span>
<% end %>
</div>
<h3 class="font-semibold mt-3">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"
}
class="link link-hover"
>
{alert.equipment.name}
</.link>
</h3>
<p class="text-sm text-base-content/70 mt-1">{alert.message}</p>
<div class="text-xs text-base-content/50 mt-2 space-y-1">
<div>
<strong>Triggered:</strong>
{Calendar.strftime(alert.triggered_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
<%= if alert.acknowledged_at do %>
<div>
<strong>Acknowledged:</strong>
{Calendar.strftime(alert.acknowledged_at, "%Y-%m-%d %H:%M:%S UTC")}
<%= if alert.acknowledged_by do %>
by {alert.acknowledged_by.email}
<% end %>
</div>
<% end %>
<%= if alert.resolved_at do %>
<div>
<strong>Resolved:</strong>
{Calendar.strftime(alert.resolved_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
<% end %>
<div>
<strong>Site:</strong>
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/sites/#{alert.equipment.site.id}"
}
class="link link-hover"
>
{alert.equipment.site.name}
</.link>
</div>
</div>
</div>
<div class="flex gap-2">
<%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<.button
phx-click="acknowledge"
phx-value-id={alert.id}
class="btn-sm btn-primary"
>
<.icon name="hero-check" class="w-4 h-4" /> Acknowledge
</.button>
<%= if alert.acknowledged_at do %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Acknowledged
</span>
<% end %>
</div>
<h3 class="mt-3 font-semibold text-zinc-900 dark:text-zinc-100">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.equipment.name}
</.link>
</h3>
<p class="mt-1 text-sm text-zinc-700 dark:text-zinc-300">{alert.message}</p>
<div class="mt-3 space-y-1 text-xs text-zinc-600 dark:text-zinc-400">
<div>
<strong class="font-medium">Triggered:</strong>
{Calendar.strftime(alert.triggered_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
<%= if alert.acknowledged_at do %>
<div>
<strong class="font-medium">Acknowledged:</strong>
{Calendar.strftime(alert.acknowledged_at, "%Y-%m-%d %H:%M:%S UTC")}
<%= if alert.acknowledged_by do %>
by {alert.acknowledged_by.email}
<% end %>
</div>
<% end %>
<%= if alert.resolved_at do %>
<div>
<strong class="font-medium">Resolved:</strong>
{Calendar.strftime(alert.resolved_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
<% end %>
<div>
<strong class="font-medium">Site:</strong>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{alert.equipment.site.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.equipment.site.name}
</.link>
</div>
</div>
</div>
<div class="ml-4">
<%= if alert.alert_type == :equipment_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<.button phx-click="acknowledge" phx-value-id={alert.id} variant="primary">
<.icon name="hero-check" class="h-4 w-4" /> Acknowledge
</.button>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -1,16 +1,23 @@
<.header>
Dashboard
<:subtitle>Welcome to {@current_organization.name}</:subtitle>
</.header>
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<.header>
Dashboard
<:subtitle>Welcome to {@current_organization.name}</:subtitle>
</.header>
<div class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites"}
class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sites</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@sites_count}</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Total sites</p>
</div>
</.link>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment"}
class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Equipment</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment_count}</p>
<div class="mt-3 space-y-1.5 text-sm">
@ -27,9 +34,12 @@
{@equipment_unknown} Unknown
</div>
</div>
</div>
</.link>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Active Alerts</h3>
<p class={[
"mt-2 text-3xl font-bold",
@ -39,7 +49,7 @@
{length(@active_alerts)}
</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Requires attention</p>
</div>
</.link>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">System Status</h3>
@ -130,3 +140,4 @@
</.link>
</div>
</div>
</Layouts.authenticated>

View file

@ -1,84 +1,71 @@
<.header>
{@page_title}
<:subtitle>Monitor and manage all your network equipment</:subtitle>
<:actions>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"}
class="btn btn-primary"
>
<.icon name="hero-plus" class="w-5 h-5" /> New Equipment
</.link>
</:actions>
</.header>
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<.header>
{@page_title}
<:subtitle>Monitor and manage all your network equipment</:subtitle>
<:actions>
<.button navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Equipment
</.button>
</:actions>
</.header>
<div class="mt-8">
<%= if @equipment == [] do %>
<div class="text-center py-12">
<.icon name="hero-server" class="mx-auto w-12 h-12 text-base-content/40" />
<h3 class="mt-2 text-sm font-semibold text-base-content">No equipment</h3>
<p class="mt-1 text-sm text-base-content/60">Get started by adding your first equipment.</p>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h3 class="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">No equipment</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Get started by adding your first equipment.
</p>
<div class="mt-6">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"}
class="btn btn-primary"
>
<.icon name="hero-plus" class="w-5 h-5" /> New Equipment
</.link>
<.button navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Equipment
</.button>
</div>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>IP Address</th>
<th>Site</th>
<th>Status</th>
<th>Last Checked</th>
<th></th>
</tr>
</thead>
<tbody>
<tr :for={eq <- @equipment}>
<td class="font-medium">{eq.name}</td>
<td class="font-mono text-sm">{eq.ip_address}</td>
<td>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{eq.site.id}"}
class="link link-primary"
>
{eq.site.name}
</.link>
</td>
<td>
<span class={[
"badge",
eq.status == :up && "badge-success",
eq.status == :down && "badge-error",
eq.status == :unknown && "badge-ghost"
]}>
{eq.status |> to_string() |> String.upcase()}
</span>
</td>
<td class="text-sm text-base-content/60">
<%= if eq.last_checked_at do %>
{Calendar.strftime(eq.last_checked_at, "%Y-%m-%d %H:%M")}
<% else %>
Never
<% end %>
</td>
<td>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{eq.id}"}
class="btn btn-xs"
>
View
</.link>
</td>
</tr>
</tbody>
</table>
</div>
<.table id="equipment" rows={@equipment}>
<:col :let={eq} label="Name">
<span class="font-medium">{eq.name}</span>
</:col>
<:col :let={eq} label="IP Address">
<span class="font-mono text-sm">{eq.ip_address}</span>
</:col>
<:col :let={eq} label="Site">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{eq.site.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{eq.site.name}
</.link>
</:col>
<:col :let={eq} label="Status">
<span class={[
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
eq.status == :up && "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
eq.status == :down && "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
eq.status == :unknown &&
"bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200"
]}>
{eq.status |> to_string() |> String.upcase()}
</span>
</:col>
<:col :let={eq} label="Last Checked">
<span class="text-sm text-zinc-600 dark:text-zinc-400">
<%= if eq.last_checked_at do %>
{Calendar.strftime(eq.last_checked_at, "%Y-%m-%d %H:%M")}
<% else %>
Never
<% end %>
</span>
</:col>
<:action :let={eq}>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{eq.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
View
</.link>
</:action>
</.table>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -1,39 +1,40 @@
<.header>
{@page_title}
<:actions>
<.link navigate={~p"/orgs/new"} class="btn btn-primary">
<.icon name="hero-plus" class="w-5 h-5" /> New Organization
</.link>
<.button navigate={~p"/orgs/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Organization
</.button>
</:actions>
</.header>
<div class="mt-8 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div :for={org <- @organizations} class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title">{org.name}</h2>
<p class="text-sm text-base-content/60">
{Enum.find(org.memberships, &(&1.user_id == @current_scope.user.id)).role
|> to_string()
|> String.capitalize()}
</p>
<div class="card-actions justify-end mt-4">
<.link navigate={~p"/orgs/#{org.slug}"} class="btn btn-primary">
Open
</.link>
</div>
<div :if={@organizations != []} class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div
:for={org <- @organizations}
class="relative overflow-hidden rounded-lg border border-zinc-200 bg-white p-6 shadow-sm hover:shadow-md transition-shadow dark:border-zinc-800 dark:bg-zinc-900"
>
<h2 class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{org.name}</h2>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
{Enum.find(org.memberships, &(&1.user_id == @current_scope.user.id)).role
|> to_string()
|> String.capitalize()}
</p>
<div class="mt-6">
<.button navigate={~p"/orgs/#{org.slug}"} variant="primary">
Open
</.button>
</div>
</div>
</div>
<%= if @organizations == [] do %>
<div class="text-center py-12">
<.icon name="hero-building-office" class="mx-auto w-12 h-12 text-base-content/40" />
<h3 class="mt-2 text-sm font-semibold text-base-content">No organizations</h3>
<p class="mt-1 text-sm text-base-content/60">Get started by creating a new organization.</p>
<div class="mt-6">
<.link navigate={~p"/orgs/new"} class="btn btn-primary">
<.icon name="hero-plus" class="w-5 h-5" /> New Organization
</.link>
</div>
<div :if={@organizations == []} class="text-center py-16">
<.icon name="hero-building-office" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h3 class="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">No organizations</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Get started by creating a new organization.
</p>
<div class="mt-6">
<.button navigate={~p"/orgs/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Organization
</.button>
</div>
<% end %>
</div>

View file

@ -3,15 +3,13 @@
<:subtitle>Create a new organization to manage your sites and equipment</:subtitle>
</.header>
<div class="max-w-2xl mt-8">
<div class="max-w-2xl">
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save">
<div class="space-y-4">
<.input field={@form[:name]} type="text" label="Organization Name" required />
<.input field={@form[:name]} type="text" label="Organization Name" required />
<div class="flex gap-2">
<.button phx-disable-with="Creating...">Create Organization</.button>
<.link navigate={~p"/orgs"} class="btn">Cancel</.link>
</div>
<div class="flex gap-3 mt-6">
<.button phx-disable-with="Creating..." variant="primary">Create Organization</.button>
<.button navigate={~p"/orgs"}>Cancel</.button>
</div>
</.form>
</div>

View file

@ -1,56 +1,50 @@
<.header>
{@page_title}
<:subtitle>Manage your site locations and hierarchy</:subtitle>
<:actions>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/new"}
class="btn btn-primary"
>
<.icon name="hero-plus" class="w-5 h-5" /> New Site
</.link>
</:actions>
</.header>
<Layouts.authenticated flash={@flash} current_organization={@current_organization}>
<.header>
{@page_title}
<:subtitle>Manage your site locations and hierarchy</:subtitle>
<:actions>
<.button navigate={~p"/orgs/#{@current_organization.slug}/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Site
</.button>
</:actions>
</.header>
<div class="mt-8">
<%= if @sites == [] do %>
<div class="text-center py-12">
<.icon name="hero-building-office" class="mx-auto w-12 h-12 text-base-content/40" />
<h3 class="mt-2 text-sm font-semibold text-base-content">No sites</h3>
<p class="mt-1 text-sm text-base-content/60">Get started by creating your first site.</p>
<div class="text-center py-16">
<.icon name="hero-building-office" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h3 class="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">No sites</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Get started by creating your first site.
</p>
<div class="mt-6">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/new"}
class="btn btn-primary"
>
<.icon name="hero-plus" class="w-5 h-5" /> New Site
</.link>
<.button navigate={~p"/orgs/#{@current_organization.slug}/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Site
</.button>
</div>
</div>
<% else %>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div :for={site <- @sites} class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title">{site.name}</h3>
<%= if site.location do %>
<p class="text-sm text-base-content/60">
<.icon name="hero-map-pin" class="w-4 h-4 inline" /> {site.location}
</p>
<% end %>
<%= if site.parent_site do %>
<p class="text-xs text-base-content/40">
Parent: {site.parent_site.name}
</p>
<% end %>
<div class="card-actions justify-end mt-2">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{site.id}"}
class="btn btn-sm"
>
View
</.link>
</div>
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div
:for={site <- @sites}
class="relative overflow-hidden rounded-lg border border-zinc-200 bg-white p-6 shadow-sm hover:shadow-md transition-shadow dark:border-zinc-800 dark:bg-zinc-900"
>
<h3 class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{site.name}</h3>
<%= if site.location do %>
<p class="mt-2 flex items-center gap-1.5 text-sm text-zinc-600 dark:text-zinc-400">
<.icon name="hero-map-pin" class="h-4 w-4" /> {site.location}
</p>
<% end %>
<%= if site.parent_site do %>
<p class="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
Parent: {site.parent_site.name}
</p>
<% end %>
<div class="mt-6">
<.button navigate={~p"/orgs/#{@current_organization.slug}/sites/#{site.id}"}>
View
</.button>
</div>
</div>
</div>
<% end %>
</div>
</Layouts.authenticated>