towerops/lib/towerops_web/components/layouts.ex
Graham McIntire cc6dfab889
Move theme toggle from header to user settings
Remove the theme toggle dropdown from the nav bar and its component
definition. Theme selection already exists in Account Settings → Personal.
2026-03-13 16:51:33 -05:00

1018 lines
40 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
alias ToweropsWeb.Helpers.StatusHelpers
alias ToweropsWeb.Live.Components.GlobalSearchComponent
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)"
attr :unresolved_alert_count, :integer,
default: 0,
doc: "number of unresolved alerts for the current organization"
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)
# Get unresolved alert count from process dictionary (set/updated in on_mount hook)
unresolved_alert_count = Process.get(:unresolved_alert_count, 0)
assigns = Map.put(assigns, :unresolved_alert_count, unresolved_alert_count)
# 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
id="sidebar-wrapper"
phx-hook="SidebarCollapse"
class="min-h-screen 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 relative z-50">
<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 dark:bg-gray-900 text-yellow-900 dark:text-yellow-200 hover:bg-yellow-50 dark:hover:bg-gray-800 rounded font-medium text-sm"
>
Exit
</.link>
</div>
</div>
</div>
<% end %>
<!-- Slim Top Bar -->
<header class="sticky top-0 z-40 border-b border-gray-200 bg-white dark:border-white/10 dark:bg-gray-900">
<div class="flex h-14 items-center justify-between px-4">
<div class="flex items-center gap-2">
<!-- Mobile hamburger -->
<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_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 lg:hidden"
>
<span class="sr-only">{t("Toggle main menu")}</span>
<.icon name="hero-bars-3" class="size-6" />
</button>
<.link
navigate={~p"/dashboard"}
class="text-lg font-bold text-gray-900 dark:text-white"
>
Towerops
</.link>
</div>
<div class="relative flex items-center gap-1 sm:gap-2">
<!-- Global Search Trigger -->
<button
:if={@current_organization}
type="button"
id="global-search-trigger"
phx-hook="GlobalSearchTrigger"
class="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-200 transition-colors"
title="Search (⌘K)"
>
<.icon name="hero-magnifying-glass" class="h-4 w-4" />
<span class="hidden sm:inline text-xs">{t("Search")}</span>
<kbd class="hidden sm:inline-flex items-center gap-0.5 rounded border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:border-white/20 dark:bg-white/10 dark:text-gray-400">
⌘K
</kbd>
</button>
<!-- Notification bell -->
<.link
:if={@current_organization}
navigate={~p"/alerts"}
class="relative 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 transition-colors"
title={t("Alerts")}
>
<.icon name="hero-bell" class="h-5 w-5" />
<%= if @unresolved_alert_count > 0 do %>
<span class="absolute top-0.5 right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-0.5 text-[10px] font-bold leading-none text-white ring-1 ring-white dark:ring-gray-950">
<%= if @unresolved_alert_count > 100 do %>
&nbsp;
<% else %>
{@unresolved_alert_count}
<% end %>
</span>
<% end %>
</.link>
<!-- Help icon -->
<.link
navigate={~p"/help"}
class="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 transition-colors"
title={t("Help")}
>
<.icon name="hero-question-mark-circle" class="h-5 w-5" />
</.link>
<!-- User menu -->
<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 rounded-full p-1.5 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 transition-colors"
title={t("Menu")}
>
<.icon name="hero-user-circle" class="h-6 w-6" />
</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 ring-1 ring-black/5 z-50 dark:bg-gray-800 dark:ring-white/10"
phx-click-away={
JS.hide(to: "#org-menu")
|> JS.set_attribute({"aria-expanded", "false"}, to: "#org-menu-button")
}
>
<div class="py-1">
<%= 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 dark:text-gray-300 dark:hover:bg-white/5"
phx-click={JS.hide(to: "#org-menu")}
>
{t("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 dark:text-gray-300 dark:hover:bg-white/5"
phx-click={JS.hide(to: "#org-menu")}
>
{t("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 dark:text-gray-300 dark:hover:bg-white/5"
phx-click={JS.hide(to: "#org-menu")}
>
{t("My Data")}
</.link>
<.link
role="menuitem"
navigate={~p"/changelog"}
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
phx-click={JS.hide(to: "#org-menu")}
>
🆕 {t("What's New")}
</.link>
<div class="border-t border-gray-100 dark:border-white/10"></div>
<.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 dark:text-gray-300 dark:hover:bg-white/5"
>
{t("Log out")}
</.link>
</div>
</div>
</div>
</div>
</header>
<!-- Desktop Sidebar -->
<aside
:if={@current_organization || (@current_scope && @current_scope.user)}
id="app-sidebar"
class="fixed top-14 left-0 bottom-0 z-30 hidden lg:flex lg:flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-white/10 overflow-y-auto overflow-x-hidden"
>
<!-- Nav sections -->
<nav class="flex-1 px-2 py-4 space-y-1">
<!-- MONITOR section -->
<p
data-sidebar-section
class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
{t("Monitor")}
</p>
<.sidebar_link
navigate={~p"/dashboard"}
active={@active_page == "dashboard"}
icon="hero-home"
label={t("Dashboard")}
/>
<.sidebar_link
navigate={~p"/devices"}
active={@active_page == "devices"}
icon="hero-server"
label={t("Devices")}
/>
<.sidebar_link
:if={@current_organization && @current_organization.use_sites}
navigate={~p"/sites"}
active={@active_page == "sites"}
icon="hero-building-office"
label={t("Sites")}
/>
<.sidebar_link
navigate={~p"/network-map"}
active={@active_page == "network-map"}
icon="hero-map"
label={t("Network Map")}
/>
<!-- RESPOND section -->
<p
data-sidebar-section
class="px-3 mt-4 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
{t("Respond")}
</p>
<.sidebar_link
navigate={~p"/alerts"}
active={@active_page == "alerts"}
icon="hero-bell-alert"
label={t("Alerts")}
badge={@unresolved_alert_count}
/>
<.sidebar_link
navigate={~p"/maintenance"}
active={@active_page == "maintenance"}
icon="hero-wrench-screwdriver"
label={t("Maintenance")}
/>
<.sidebar_link
navigate={~p"/schedules"}
active={@active_page == "schedules"}
icon="hero-calendar-days"
label={t("On-Call")}
/>
<!-- ANALYZE section -->
<p
data-sidebar-section
class="px-3 mt-4 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
>
{t("Analyze")}
</p>
<.sidebar_link
navigate={~p"/insights"}
active={@active_page == "insights"}
icon="hero-light-bulb"
label={t("Insights")}
/>
<.sidebar_link
navigate={~p"/trace"}
active={@active_page == "trace"}
icon="hero-magnifying-glass"
label={t("Trace")}
/>
<.sidebar_link
navigate={~p"/activity"}
active={@active_page == "activity"}
icon="hero-clock"
label={t("Activity")}
/>
</nav>
<!-- Bottom pinned: org switcher + settings + collapse toggle -->
<div class="border-t border-gray-200 dark:border-white/10 px-2 py-3.5 space-y-1">
<!-- Org switcher -->
<div :if={@current_organization} class="relative">
<button
type="button"
id="sidebar-org-switcher-button"
aria-expanded="false"
aria-haspopup="true"
phx-click={
JS.toggle(to: "#sidebar-org-switcher-menu")
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
to: "#sidebar-org-switcher-button"
)
}
class="flex items-center gap-2 w-full rounded-md px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white transition-colors"
data-sidebar-tooltip={@current_organization.name}
>
<.icon name="hero-building-office-2" class="h-5 w-5 shrink-0" />
<span data-sidebar-text class="truncate flex-1 text-left">
{@current_organization.name}
</span>
<span data-sidebar-text>
<.icon name="hero-chevron-up-down" class="h-3 w-3 shrink-0" />
</span>
</button>
<div
id="sidebar-org-switcher-menu"
role="menu"
class="hidden absolute bottom-full left-0 mb-1 w-48 origin-bottom-left 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: "#sidebar-org-switcher-menu")
|> JS.set_attribute({"aria-expanded", "false"}, to: "#sidebar-org-switcher-button")
}
>
<div class="py-1">
<.link
role="menuitem"
navigate={~p"/orgs"}
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
phx-click={JS.hide(to: "#sidebar-org-switcher-menu")}
>
{t("Switch Organization")}
</.link>
<.link
role="menuitem"
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5"
phx-click={JS.hide(to: "#sidebar-org-switcher-menu")}
>
{t("Organization Settings")}
</.link>
</div>
</div>
</div>
<!-- Settings link -->
<.sidebar_link
:if={@current_organization}
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
active={@active_page == "settings"}
icon="hero-cog-6-tooth"
label={t("Settings")}
/>
<!-- Collapse toggle -->
<button
type="button"
data-sidebar-toggle
class="flex items-center justify-center w-full rounded-md px-3 py-2 text-gray-400 hover:bg-gray-50 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 transition-colors"
title={t("Toggle sidebar")}
>
<.icon name="hero-chevron-double-left" class="h-5 w-5 sidebar-collapse-icon" />
</button>
</div>
</aside>
<!-- Mobile slide-out panel -->
<div
:if={@current_organization || (@current_scope && @current_scope.user)}
id="mobile-menu"
class="hidden fixed inset-0 z-50 lg:hidden"
>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/30 dark:bg-black/50"
phx-click={JS.hide(to: "#mobile-menu")}
>
</div>
<!-- Panel -->
<div class="fixed inset-y-0 left-0 w-72 bg-white dark:bg-gray-900 shadow-xl overflow-y-auto">
<div class="flex items-center justify-between px-4 py-4 border-b border-gray-200 dark:border-white/10">
<span class="text-lg font-bold text-gray-900 dark:text-white">Towerops</span>
<button
type="button"
phx-click={JS.hide(to: "#mobile-menu")}
class="p-2 rounded-md text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
<!-- Org name -->
<div
:if={@current_organization}
class="px-4 py-3 border-b border-gray-100 dark:border-white/5"
>
<p class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
{t("Organization")}
</p>
<p class="text-sm font-semibold text-gray-900 dark:text-white mt-0.5">
{@current_organization.name}
</p>
</div>
<!-- MONITOR -->
<div class="px-2 py-3">
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Monitor")}
</p>
<.mobile_nav_link navigate={~p"/dashboard"} active={@active_page == "dashboard"}>
<.icon name="hero-home" class="size-5" /> {t("Dashboard")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/devices"} active={@active_page == "devices"}>
<.icon name="hero-server" class="size-5" /> {t("Devices")}
</.mobile_nav_link>
<.mobile_nav_link
:if={@current_organization && @current_organization.use_sites}
navigate={~p"/sites"}
active={@active_page == "sites"}
>
<.icon name="hero-building-office" class="size-5" /> {t("Sites")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
<.icon name="hero-map" class="size-5" /> {t("Network Map")}
</.mobile_nav_link>
</div>
<!-- RESPOND -->
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Respond")}
</p>
<.mobile_nav_link navigate={~p"/alerts"} active={@active_page == "alerts"}>
<.icon name="hero-bell-alert" class="size-5" /> {t("Alerts")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
<.icon name="hero-wrench-screwdriver" class="size-5" /> {t("Maintenance")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/schedules"} active={@active_page == "schedules"}>
<.icon name="hero-calendar-days" class="size-5" /> {t("On-Call")}
</.mobile_nav_link>
</div>
<!-- ANALYZE -->
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Analyze")}
</p>
<.mobile_nav_link navigate={~p"/insights"} active={@active_page == "insights"}>
<.icon name="hero-light-bulb" class="size-5" /> {t("Insights")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/trace"} active={@active_page == "trace"}>
<.icon name="hero-magnifying-glass" class="size-5" /> {t("Trace")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/activity"} active={@active_page == "activity"}>
<.icon name="hero-clock" class="size-5" /> {t("Activity")}
</.mobile_nav_link>
</div>
<!-- Account -->
<div class="px-2 py-3 border-t border-gray-100 dark:border-white/5">
<p class="px-3 mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{t("Account")}
</p>
<.mobile_nav_link
:if={@current_organization}
navigate={~p"/orgs/#{@current_organization.slug}/settings"}
active={@active_page == "settings"}
>
<.icon name="hero-cog-6-tooth" class="size-5" /> {t("Organization Settings")}
</.mobile_nav_link>
<.mobile_nav_link
navigate={~p"/users/settings"}
active={@active_page == "user_settings"}
>
<.icon name="hero-user-circle" class="size-5" /> {t("My Settings")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/help"} active={@active_page == "help"}>
<.icon name="hero-question-mark-circle" class="size-5" /> {t("Help")}
</.mobile_nav_link>
</div>
</div>
</div>
<!-- Main content -->
<div id="main-wrapper" class="flex flex-col min-h-[calc(100vh-3.5rem)]">
<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
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>
</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 %>
<%= if @current_organization do %>
<.live_component
module={GlobalSearchComponent}
id="global-search"
organization_id={@current_organization.id}
/>
<.live_component
module={ToweropsWeb.Live.Components.StatusTitleComponent}
id="status-title"
organization_id={@current_organization.id}
/>
<% end %>
"""
end
attr :navigate, :string, required: true
attr :active, :boolean, default: false
attr :icon, :string, required: true
attr :label, :string, required: true
attr :badge, :integer, default: 0
defp sidebar_link(assigns) do
~H"""
<.link
navigate={@navigate}
class={[
"relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
if(@active,
do:
"bg-blue-50 text-blue-700 border-l-2 border-blue-600 dark:bg-blue-900/20 dark:text-blue-400",
else:
"text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
)
]}
data-sidebar-tooltip={@label}
>
<.icon name={@icon} class="h-5 w-5 shrink-0" />
<span data-sidebar-text class="truncate">{@label}</span>
<%= if @badge > 0 do %>
<span
data-sidebar-text
class="ml-auto inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{if @badge > 99, do: "99+", else: @badge}
</span>
<% end %>
</.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 """
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>
<.link
navigate={~p"/admin/security"}
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"
>
IP Allowlist
</.link>
<.link
navigate={~p"/admin/errors"}
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"
>
Exceptions
</.link>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-2">
<.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>
<.mobile_nav_link navigate={~p"/admin/security"} active={false}>
<.icon name="hero-shield-check" class="size-5" /> IP Allowlist
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/admin/errors"} active={false}>
<.icon name="hero-bug-ant" class="size-5" /> Exceptions
</.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
@doc """
Returns the page title with status emoji prepended based on organization health.
"""
def status_title(assigns) do
page_title = assigns[:page_title] || "TowerOps"
organization = get_in(assigns, [:current_scope, Access.key(:organization)])
if organization do
StatusHelpers.title_with_status(page_title, organization)
else
page_title
end
end
end