towerops/lib/towerops_web/components/layouts.ex
2026-07-22 08:25:04 -05:00

1048 lines
41 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
alias ToweropsWeb.TimeHelpers
# 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
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
~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
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
# 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"/mobile/qr-login"}
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")}
>
<span class="flex items-center gap-2">
<.icon name="hero-device-phone-mobile" class="h-4 w-4" />
{t("Link Mobile App")}
</span>
</.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>
<!-- Sidebar + Main content wrapper -->
<div class="flex">
<!-- Desktop Sidebar -->
<aside
:if={@current_organization || (@current_scope && @current_scope.user)}
id="app-sidebar"
class="sticky top-14 left-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 h-[calc(100vh-3.5rem)]"
>
<!-- 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")}
/>
<.sidebar_link
navigate={~p"/coverage"}
active={@active_page == "coverage"}
icon="hero-signal"
label={t("Coverage")}
/>
<%!-- <.sidebar_link
navigate={~p"/weathermap"}
active={@active_page == "weathermap"}
icon="hero-signal"
label={t("Weathermap")}
/> --%>
<!-- 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>
<%!-- RF Links - hidden from nav, code kept for future use
<.sidebar_link
navigate={~p"/rf-links"}
active={@active_page == "rf-links"}
icon="hero-signal"
label={t("RF Links")}
/>
--%>
<.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")}
/>
<.sidebar_link
navigate={~p"/reports"}
active={@active_page == "reports"}
icon="hero-document-chart-bar"
label={t("Reports")}
/>
</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"}>
{t("Dashboard")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/devices"} active={@active_page == "devices"}>
{t("Devices")}
</.mobile_nav_link>
<.mobile_nav_link
:if={@current_organization && @current_organization.use_sites}
navigate={~p"/sites"}
active={@active_page == "sites"}
>
{t("Sites")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/network-map"} active={@active_page == "network-map"}>
{t("Network Map")}
</.mobile_nav_link>
<%!-- <.mobile_nav_link navigate={~p"/weathermap"} active={@active_page == "weathermap"}>
<.icon name="hero-signal" class="size-5" /> {t("Weathermap")}
</.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"}>
{t("Alerts")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/maintenance"} active={@active_page == "maintenance"}>
{t("Maintenance")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/schedules"} active={@active_page == "schedules"}>
{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"}>
{t("Insights")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/trace"} active={@active_page == "trace"}>
{t("Trace")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/activity"} active={@active_page == "activity"}>
{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"}
>
{t("Organization Settings")}
</.mobile_nav_link>
<.mobile_nav_link
navigate={~p"/users/settings"}
active={@active_page == "user_settings"}
>
{t("My Settings")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/help"} active={@active_page == "help"}>
{t("Help")}
</.mobile_nav_link>
</div>
</div>
</div>
<!-- Main content -->
<div id="main-wrapper" class="flex flex-1 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:pl-4 lg:pr-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>
<!-- End sidebar + main content wrapper -->
</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}
>
<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("Reconnecting to server")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Hang tight — we'll be back in a moment")}
<.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
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
~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}>
Users
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/admin/organizations"} active={false}>
Organizations
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/admin/security"} active={false}>
IP Allowlist
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/admin/errors"} active={false}>
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
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