towerops/lib/towerops_web/components/layouts.ex

618 lines
23 KiB
Elixir

defmodule ToweropsWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use ToweropsWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
attr :timezone, :string, default: "UTC", doc: "the user's timezone"
slot :inner_block, required: true
def app(assigns) do
~H"""
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
<header class="border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<nav class="flex h-16 items-center justify-between">
<div class="flex items-center">
<.link navigate={~p"/"} class="text-xl font-bold text-gray-900 dark:text-white">
Towerops
</.link>
</div>
<div class="flex items-center gap-x-4">
<.link
navigate={~p"/users/log-in"}
class="text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
>
Log in
</.link>
<.link
navigate={~p"/users/register"}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
>
Sign up
</.link>
</div>
</nav>
</div>
</header>
<main class="flex-1 px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl">
{render_slot(@inner_block)}
</div>
</main>
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
</div>
<.flash_group flash={@flash} />
"""
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} active_page="dashboard">
<h1>Content</h1>
</Layouts.authenticated>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current scope (contains user and impersonation state)"
attr :current_organization, :any,
default: nil,
doc: "the current organization (can be nil for pages without org context)"
attr :active_page, :string,
default: nil,
doc: "the currently active page (dashboard, sites, device, or alerts)"
attr :timezone, :string, default: "UTC", doc: "the user's timezone"
slot :inner_block, required: true
def authenticated(assigns) do
~H"""
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
<!-- Impersonation Banner -->
<%= if @current_scope && @current_scope.impersonating? do %>
<div class="bg-yellow-400 border-b-2 border-yellow-600">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<span class="font-medium text-sm sm:text-base">
Impersonating: {@current_scope.user.email}
</span>
</div>
<.link
href={~p"/admin/impersonate"}
method="delete"
class="px-3 py-1 bg-white text-yellow-900 hover:bg-yellow-50 rounded font-medium text-sm"
>
Exit
</.link>
</div>
</div>
</div>
<% end %>
<nav class="border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-14 sm:h-16 justify-between">
<div class="flex items-center">
<!-- Mobile menu button -->
<button
:if={@current_organization}
type="button"
id="mobile-menu-button"
phx-click={
JS.toggle(to: "#mobile-menu")
|> JS.toggle(to: "#mobile-menu-icon-open")
|> JS.toggle(to: "#mobile-menu-icon-close")
}
class="inline-flex items-center justify-center rounded-md p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 md:hidden"
aria-expanded="false"
>
<span class="sr-only">Open main menu</span>
<span id="mobile-menu-icon-open"><.icon name="hero-bars-3" class="size-6" /></span>
<span id="mobile-menu-icon-close" class="hidden">
<.icon name="hero-x-mark" class="size-6" />
</span>
</button>
<div class="flex flex-shrink-0 items-center ml-2 md:ml-0">
<.link
navigate={~p"/orgs"}
class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white"
>
Towerops
</.link>
</div>
<!-- Desktop navigation -->
<div :if={@current_organization} class="hidden md:ml-8 md:flex md:space-x-6">
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}"}
active={@active_page == "dashboard"}
>
Dashboard
</.nav_link>
<.nav_link
navigate={~p"/sites"}
active={@active_page == "sites"}
>
Sites
</.nav_link>
<.nav_link
navigate={~p"/devices"}
active={@active_page == "devices"}
>
Devices
</.nav_link>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
active={@active_page == "alerts"}
>
Alerts
</.nav_link>
<.nav_link
navigate={~p"/orgs/#{@current_organization.slug}/agents"}
active={@active_page == "agents"}
>
Agents
</.nav_link>
</div>
</div>
<div class="relative flex items-center gap-1 sm:gap-2">
<.theme_toggle />
<button
type="button"
id="org-menu-button"
phx-click={JS.toggle(to: "#org-menu")}
class="inline-flex items-center justify-center gap-x-1 rounded-md bg-white px-2 py-1.5 sm:px-3 sm:py-2 text-xs sm:text-sm font-semibold text-gray-900 shadow-xs inset-ring-1 inset-ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:shadow-none dark:inset-ring-white/5 dark:hover:bg-white/20"
>
<span class="hidden sm:inline">
{if @current_organization, do: @current_organization.name, else: "Menu"}
</span>
<span class="sm:hidden">
{if @current_organization,
do: String.slice(@current_organization.name, 0..10),
else: "Menu"}
</span>
<svg
viewBox="0 0 20 20"
fill="currentColor"
data-slot="icon"
aria-hidden="true"
class="-mr-0.5 size-4 sm:size-5 text-gray-400"
>
<path
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
fill-rule="evenodd"
/>
</svg>
</button>
<div
id="org-menu"
class="hidden absolute top-full right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg outline-1 outline-black/5 z-50 dark:bg-gray-800 dark:shadow-none dark:-outline-offset-1 dark:outline-white/10"
phx-click-away={JS.hide(to: "#org-menu")}
>
<div class="py-1">
<.link
:if={@current_organization}
navigate={~p"/orgs"}
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
Switch Org
</.link>
<.link
:if={@current_organization}
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-cog-6-tooth" class="w-4 h-4" /> Organization Settings
</.link>
<%= if @current_scope && @current_scope.user && @current_scope.user.is_superuser do %>
<.link
navigate={~p"/admin"}
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-shield-check" class="w-4 h-4" /> Admin Panel
</.link>
<% end %>
<.link
navigate={~p"/users/settings"}
class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
phx-click={JS.hide(to: "#org-menu")}
>
<.icon name="hero-user-circle" class="w-4 h-4" /> User Settings
</.link>
<.link
href={~p"/users/log-out"}
method="delete"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-hidden dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-white"
>
Log out
</.link>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile menu -->
<div :if={@current_organization} class="hidden md:hidden" id="mobile-menu">
<div class="space-y-1 px-2 pb-3 pt-2 border-t border-gray-200 dark:border-white/10">
<.mobile_nav_link
navigate={~p"/orgs/#{@current_organization.slug}"}
active={@active_page == "dashboard"}
>
<.icon name="hero-home" class="size-5" /> Dashboard
</.mobile_nav_link>
<.mobile_nav_link
navigate={~p"/sites"}
active={@active_page == "sites"}
>
<.icon name="hero-building-office" class="size-5" /> Sites
</.mobile_nav_link>
<.mobile_nav_link
navigate={~p"/devices"}
active={@active_page == "devices"}
>
<.icon name="hero-server" class="size-5" /> Devices
</.mobile_nav_link>
<.mobile_nav_link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
active={@active_page == "alerts"}
>
<.icon name="hero-bell" class="size-5" /> Alerts
</.mobile_nav_link>
<.mobile_nav_link
navigate={~p"/orgs/#{@current_organization.slug}/agents"}
active={@active_page == "agents"}
>
<.icon name="hero-cpu-chip" class="size-5" /> Agents
</.mobile_nav_link>
</div>
</div>
</nav>
<main class="flex-1 py-6 sm:py-10">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{render_slot(@inner_block)}
</div>
</main>
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
</div>
<.flash_group flash={@flash} />
"""
end
attr :navigate, :string, required: true
attr :active, :boolean, default: false
slot :inner_block, required: true
defp nav_link(assigns) do
~H"""
<.link
navigate={@navigate}
class={[
"inline-flex items-center border-b-2 px-1 pt-1 pb-4 text-sm font-medium",
if(@active,
do: "border-gray-900 text-gray-900 dark:border-white dark:text-white",
else:
"border-transparent text-gray-700 hover:border-gray-300 hover:text-gray-900 dark:text-gray-300 dark:hover:border-gray-600 dark:hover:text-white"
)
]}
>
{render_slot(@inner_block)}
</.link>
"""
end
attr :navigate, :string, required: true
attr :active, :boolean, default: false
slot :inner_block, required: true
defp mobile_nav_link(assigns) do
~H"""
<.link
navigate={@navigate}
class={[
"flex items-center gap-3 rounded-md px-3 py-2 text-base font-medium",
if(@active,
do: "bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-white",
else:
"text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-white"
)
]}
>
{render_slot(@inner_block)}
</.link>
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<div class="relative" id="theme-toggle" phx-update="ignore">
<button
type="button"
id="theme-menu-button"
phx-click={JS.toggle(to: "#theme-menu")}
class="flex items-center justify-center p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-800"
aria-label="Toggle theme"
>
<.icon name="hero-sun" class="size-5 block dark:hidden" />
<.icon name="hero-moon" class="size-5 hidden dark:block" />
</button>
<div
id="theme-menu"
class="hidden absolute top-full right-0 mt-2 w-36 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black/5 z-50 dark:bg-gray-800 dark:ring-white/10"
phx-click-away={JS.hide(to: "#theme-menu")}
>
<div class="py-1">
<button
type="button"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
phx-click={JS.dispatch("phx:set-theme") |> JS.hide(to: "#theme-menu")}
data-phx-theme="system"
>
<.icon name="hero-computer-desktop" class="size-4" /> Auto
</button>
<button
type="button"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
phx-click={JS.dispatch("phx:set-theme") |> JS.hide(to: "#theme-menu")}
data-phx-theme="light"
>
<.icon name="hero-sun" class="size-4" /> Light
</button>
<button
type="button"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
phx-click={JS.dispatch("phx:set-theme") |> JS.hide(to: "#theme-menu")}
data-phx-theme="dark"
>
<.icon name="hero-moon" class="size-4" /> Dark
</button>
</div>
</div>
</div>
"""
end
@doc """
Renders the admin layout.
Use this for admin pages that require superuser access.
## Examples
<Layouts.admin flash={@flash}>
<h1>Admin Content</h1>
</Layouts.admin>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :timezone, :string, default: "UTC", doc: "the user's timezone"
slot :inner_block, required: true
def admin(assigns) do
~H"""
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
<nav class="bg-white border-b border-gray-200 dark:border-white/10 dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-14 sm:h-16">
<div class="flex items-center">
<!-- Mobile menu button -->
<button
type="button"
id="admin-mobile-menu-button"
phx-click={
JS.toggle(to: "#admin-mobile-menu")
|> JS.toggle(to: "#admin-menu-icon-open")
|> JS.toggle(to: "#admin-menu-icon-close")
}
class="inline-flex items-center justify-center rounded-md p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 focus:outline-none dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 sm:hidden"
>
<span class="sr-only">Open admin menu</span>
<span id="admin-menu-icon-open"><.icon name="hero-bars-3" class="size-6" /></span>
<span id="admin-menu-icon-close" class="hidden">
<.icon name="hero-x-mark" class="size-6" />
</span>
</button>
<.link
navigate={~p"/admin"}
class="flex items-center text-lg sm:text-xl font-bold text-gray-900 dark:text-white ml-2 sm:ml-0"
>
<span class="hidden sm:inline">Towerops Admin</span>
<span class="sm:hidden">Admin</span>
</.link>
<!-- Desktop navigation -->
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
<.link
navigate={~p"/admin/users"}
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
>
Users
</.link>
<.link
navigate={~p"/admin/organizations"}
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
>
Organizations
</.link>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2">
<.theme_toggle />
<.link
navigate={~p"/orgs"}
class="text-xs sm:text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<span class="hidden sm:inline">Back to App</span>
<span class="sm:hidden">App</span>
</.link>
</div>
</div>
</div>
<!-- Mobile menu -->
<div class="hidden sm:hidden" id="admin-mobile-menu">
<div class="space-y-1 px-2 pb-3 pt-2 border-t border-gray-200 dark:border-white/10">
<.mobile_nav_link navigate={~p"/admin/users"} active={false}>
<.icon name="hero-users" class="size-5" /> Users
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/admin/organizations"} active={false}>
<.icon name="hero-building-office-2" class="size-5" /> Organizations
</.mobile_nav_link>
</div>
</div>
</nav>
<main class="flex-1">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{render_slot(@inner_block)}
</div>
</main>
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
</div>
<.flash_group flash={@flash} />
"""
end
@doc """
Renders the application footer with copyright notice and deploy timestamp.
"""
attr :timezone, :string, default: "UTC"
def footer(assigns) do
alias ToweropsWeb.TimeHelpers
build_time = Towerops.Application.build_timestamp()
timezone = Map.get(assigns, :timezone, "UTC")
assigns =
assigns
|> assign(:build_time, build_time)
|> assign(:time_ago, TimeHelpers.format_time_ago(build_time))
|> assign(:formatted_time, TimeHelpers.format_iso8601(build_time, timezone))
~H"""
<footer class="border-t border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div class="flex flex-col items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<p>Copyright © {Date.utc_today().year} Towerops</p>
<p class="text-xs">
Last deploy <span title={@formatted_time}>{@time_ago}</span> · {@formatted_time}
</p>
<div class="flex items-center gap-1 text-xs opacity-60">
<span class="leading-none">made in</span>
<img src="/images/texas.svg" alt="Texas" class="h-5 w-5" />
</div>
</div>
</div>
</footer>
"""
end
end