810 lines
31 KiB
Elixir
810 lines
31 KiB
Elixir
defmodule ToweropsWeb.Layouts do
|
|
@moduledoc """
|
|
This module holds layouts and related functionality
|
|
used by your application.
|
|
"""
|
|
use ToweropsWeb, :html
|
|
|
|
alias ToweropsWeb.Components.ConsentPrompt
|
|
alias ToweropsWeb.Components.CookieConsent
|
|
|
|
require Logger
|
|
|
|
# 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"
|
|
|
|
attr :requires_cookie_consent, :boolean,
|
|
default: false,
|
|
doc: "whether to show the cookie consent banner"
|
|
|
|
slot :inner_block, required: true
|
|
|
|
def app(assigns) do
|
|
# Get cookie consent from process dictionary (set in plug for non-authenticated pages)
|
|
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
|
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
|
|
|
~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} />
|
|
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
|
"""
|
|
end
|
|
|
|
@doc """
|
|
Renders the authenticated app layout with navigation.
|
|
|
|
Use this for pages that require organization context.
|
|
|
|
## Examples
|
|
|
|
<Layouts.authenticated flash={@flash} current_scope={@current_scope} 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, organization, timezone, and impersonation state)"
|
|
|
|
attr :active_page, :string,
|
|
default: nil,
|
|
doc: "the currently active page (dashboard, sites, device, or alerts)"
|
|
|
|
slot :inner_block, required: true
|
|
|
|
def authenticated(assigns) do
|
|
# Get cookie consent from process dictionary (set in on_mount hook)
|
|
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
|
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
|
|
|
# Extract organization and timezone from scope for template convenience
|
|
current_organization = assigns[:current_scope] && assigns.current_scope.organization
|
|
timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC"
|
|
|
|
# Get policies needing consent from assigns (set in CheckPolicyConsent plug)
|
|
policies_needing_consent = Map.get(assigns, :policies_needing_consent, [])
|
|
|
|
# Check if this is the help page for banner display
|
|
always_show_banner = if Map.get(assigns, :active_page) == "help", do: "true", else: "false"
|
|
|
|
assigns =
|
|
assigns
|
|
|> Map.put(:current_organization, current_organization)
|
|
|> Map.put(:timezone, timezone)
|
|
|> Map.put(:policies_needing_consent, policies_needing_consent)
|
|
|> Map.put(:always_show_banner, always_show_banner)
|
|
|
|
~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 || (@current_scope && @current_scope.user)}
|
|
type="button"
|
|
id="mobile-menu-button"
|
|
aria-expanded="false"
|
|
aria-controls="mobile-menu"
|
|
phx-click={
|
|
JS.toggle(to: "#mobile-menu")
|
|
|> JS.toggle(to: "#mobile-menu-icon-open")
|
|
|> JS.toggle(to: "#mobile-menu-icon-close")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
|
|
to: "#mobile-menu-button"
|
|
)
|
|
}
|
|
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"
|
|
>
|
|
<span class="sr-only">Toggle 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 || (@current_scope && @current_scope.user)}
|
|
class="hidden md:ml-8 md:flex md:space-x-6"
|
|
>
|
|
<.nav_link
|
|
navigate={~p"/"}
|
|
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"/network-map"}
|
|
active={@active_page == "network-map"}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-beaker" class="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" />
|
|
Network Map
|
|
</span>
|
|
</.nav_link>
|
|
<.nav_link
|
|
navigate={~p"/alerts"}
|
|
active={@active_page == "alerts"}
|
|
>
|
|
<span class="flex items-center gap-1.5">
|
|
<.icon name="hero-beaker" class="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" />
|
|
Alerts
|
|
</span>
|
|
</.nav_link>
|
|
<.nav_link
|
|
navigate={~p"/agents"}
|
|
active={@active_page == "agents"}
|
|
>
|
|
Agents
|
|
</.nav_link>
|
|
<.nav_link
|
|
navigate={~p"/help"}
|
|
active={@active_page == "help"}
|
|
>
|
|
Help
|
|
</.nav_link>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="relative flex items-center gap-1 sm:gap-2">
|
|
<.theme_toggle />
|
|
<button
|
|
type="button"
|
|
id="org-menu-button"
|
|
aria-expanded="false"
|
|
aria-haspopup="true"
|
|
aria-controls="org-menu"
|
|
phx-click={
|
|
JS.toggle(to: "#org-menu")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"}, to: "#org-menu-button")
|
|
}
|
|
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"
|
|
role="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")
|
|
|> JS.set_attribute({"aria-expanded", "false"}, to: "#org-menu-button")
|
|
}
|
|
>
|
|
<div class="py-1">
|
|
<.link
|
|
:if={@current_organization}
|
|
role="menuitem"
|
|
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}
|
|
role="menuitem"
|
|
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
|
|
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")}
|
|
>
|
|
Organization Settings
|
|
</.link>
|
|
<%= if @current_scope && @current_scope.user && @current_scope.user.is_superuser do %>
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/admin"}
|
|
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")}
|
|
>
|
|
Admin Panel
|
|
</.link>
|
|
<% end %>
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/users/settings"}
|
|
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")}
|
|
>
|
|
User Settings
|
|
</.link>
|
|
<.link
|
|
role="menuitem"
|
|
navigate={~p"/users/my-data"}
|
|
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")}
|
|
>
|
|
My Data
|
|
</.link>
|
|
<.link
|
|
role="menuitem"
|
|
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 || (@current_scope && @current_scope.user)}
|
|
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"/"}
|
|
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"/alerts"}
|
|
active={@active_page == "alerts"}
|
|
>
|
|
<.icon name="hero-bell" class="size-5" /> Alerts
|
|
</.mobile_nav_link>
|
|
<.mobile_nav_link
|
|
navigate={~p"/agents"}
|
|
active={@active_page == "agents"}
|
|
>
|
|
<.icon name="hero-cpu-chip" class="size-5" /> Agents
|
|
</.mobile_nav_link>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main id="main-content" tabindex="-1" class="flex-1 py-6 sm:py-10">
|
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
<!-- Beta Testing Banner -->
|
|
<div
|
|
id="beta-banner"
|
|
phx-hook="BetaBannerDismiss"
|
|
data-always-show={@always_show_banner}
|
|
class="mb-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4 border border-blue-200 dark:border-blue-800"
|
|
>
|
|
<div class="flex items-start gap-3">
|
|
<.icon
|
|
name="hero-heart"
|
|
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
|
/>
|
|
<div class="flex-1">
|
|
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
|
|
Thanks for testing Towerops!
|
|
</h3>
|
|
<p class="text-sm text-blue-800 dark:text-blue-300">
|
|
We'd love to hear your feedback, suggestions, or help you with any questions. Please email us at
|
|
<a
|
|
href="mailto:hi@towerops.net"
|
|
class="font-medium underline hover:text-blue-900 dark:hover:text-blue-200"
|
|
>
|
|
hi@towerops.net
|
|
</a>
|
|
for support, feature requests, or just to say hello!
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
data-dismiss
|
|
class="flex-shrink-0 rounded-md p-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-800/50 focus:outline-none focus:ring-2 focus:ring-blue-600 dark:focus:ring-blue-400"
|
|
aria-label="Dismiss banner"
|
|
>
|
|
<.icon name="hero-x-mark" class="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
var banner = document.getElementById('beta-banner');
|
|
if (banner) {
|
|
var isHelpPage = banner.dataset.alwaysShow === 'true';
|
|
|
|
if (isHelpPage) {
|
|
// On help page: keep banner visible, hide dismiss button
|
|
var dismissBtn = banner.querySelector('[data-dismiss]');
|
|
if (dismissBtn) {
|
|
dismissBtn.style.display = 'none';
|
|
}
|
|
} else {
|
|
// On other pages: check if dismissed
|
|
var isDismissed = localStorage.getItem('betaBannerDismissed') === 'true';
|
|
if (isDismissed) {
|
|
banner.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
</script>
|
|
|
|
{render_slot(@inner_block)}
|
|
</div>
|
|
</main>
|
|
|
|
<.footer timezone={Map.get(assigns, :timezone, "UTC")} />
|
|
</div>
|
|
|
|
<.flash_group flash={@flash} />
|
|
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
|
<%= if @current_scope && @current_scope.user do %>
|
|
<ConsentPrompt.consent_modal
|
|
user_id={@current_scope.user.id}
|
|
policies={@policies_needing_consent}
|
|
/>
|
|
<% end %>
|
|
"""
|
|
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 h-14 sm:h-16 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"
|
|
aria-expanded="false"
|
|
aria-haspopup="true"
|
|
aria-controls="theme-menu"
|
|
phx-click={
|
|
JS.toggle(to: "#theme-menu")
|
|
|> JS.toggle_attribute({"aria-expanded", "true", "false"}, to: "#theme-menu-button")
|
|
}
|
|
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"
|
|
role="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")
|
|
|> JS.set_attribute({"aria-expanded", "false"}, to: "#theme-menu-button")
|
|
}
|
|
>
|
|
<div class="py-1">
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
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"
|
|
role="menuitem"
|
|
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"
|
|
role="menuitem"
|
|
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"
|
|
|
|
attr :requires_cookie_consent, :boolean,
|
|
default: false,
|
|
doc: "whether to show the cookie consent banner"
|
|
|
|
slot :inner_block, required: true
|
|
|
|
def admin(assigns) do
|
|
# Get cookie consent from process dictionary (set in on_mount hook)
|
|
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
|
|
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
|
|
|
|
~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 pb-4 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 pb-4 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} />
|
|
<CookieConsent.banner requires_consent={@requires_cookie_consent} />
|
|
"""
|
|
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">
|
|
<.link
|
|
navigate={~p"/privacy"}
|
|
class="hover:text-gray-900 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
|
>
|
|
Privacy Policy
|
|
</.link>
|
|
<span class="mx-2">·</span>
|
|
<.link
|
|
navigate={~p"/terms"}
|
|
class="hover:text-gray-900 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
|
>
|
|
Terms of Service
|
|
</.link>
|
|
<span class="mx-2">·</span>
|
|
<.link
|
|
href="/changelog.txt"
|
|
class="hover:text-gray-900 dark:hover:text-gray-200 underline decoration-dotted underline-offset-2"
|
|
>
|
|
Changelog
|
|
</.link>
|
|
</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
|