towerops/lib/towerops_web/components/layouts.ex
Graham McIntire 5026614bf3
fix: nav overflow on small screens and fix bad gettext translations
Bump desktop nav breakpoint from md (768px) to lg (1024px) so the
mobile hamburger menu is used on medium-width screens instead of
nav items overflowing outside the viewport.

Clear all 65 bad fuzzy translations in English .po files (e.g.
"Navigation" was incorrectly translated to "Pagination") and fix
49 fuzzy entries in Spanish .po files with correct translations.
2026-03-11 14:00:31 -05:00

1094 lines
45 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)"
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 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 %>
<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 lg: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 lg:ml-0">
<.link
navigate={~p"/dashboard"}
class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white"
>
Towerops
</.link>
</div>
<!-- Org selector (next to logo) -->
<div :if={@current_organization} class="hidden lg:flex items-center ml-3">
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600 mr-3"></div>
<button
type="button"
id="org-switcher-button"
aria-expanded="false"
aria-haspopup="true"
aria-controls="org-switcher-menu"
phx-click={
JS.toggle(to: "#org-switcher-menu")
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
to: "#org-switcher-button"
)
}
class="inline-flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors max-w-xs"
>
<span class="truncate">{@current_organization.name}</span>
<.icon name="hero-chevron-down" class="h-3 w-3 shrink-0" />
</button>
<div
id="org-switcher-menu"
role="menu"
class="hidden absolute top-12 left-auto mt-1 w-48 origin-top 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-switcher-menu")
|> JS.set_attribute({"aria-expanded", "false"}, to: "#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: "#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: "#org-switcher-menu")}
>
{t("Organization Settings")}
</.link>
</div>
</div>
</div>
<!-- Desktop navigation: Primary -->
<div
:if={@current_organization || (@current_scope && @current_scope.user)}
class="hidden lg:ml-6 lg:flex lg:items-center lg:space-x-1"
>
<.nav_link
navigate={~p"/dashboard"}
active={@active_page == "dashboard"}
>
{t("Dashboard")}
</.nav_link>
<.nav_link
navigate={~p"/devices"}
active={@active_page == "devices"}
>
{t("Devices")}
</.nav_link>
<.nav_link
:if={@current_organization && @current_organization.use_sites}
navigate={~p"/sites"}
active={@active_page == "sites"}
>
{t("Sites")}
</.nav_link>
<.nav_link
navigate={~p"/alerts"}
active={@active_page == "alerts"}
>
{t("Alerts")}
</.nav_link>
<.nav_link
navigate={~p"/maintenance"}
active={@active_page == "maintenance"}
>
{t("Maintenance")}
</.nav_link>
<.nav_link
navigate={~p"/schedules"}
active={@active_page == "schedules"}
>
{t("Schedules")}
</.nav_link>
<!-- Separator -->
<div class="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1"></div>
<!-- More dropdown -->
<div class="relative">
<button
type="button"
id="more-nav-button"
aria-expanded="false"
aria-haspopup="true"
phx-click={
JS.toggle(to: "#more-nav-menu")
|> JS.toggle_attribute({"aria-expanded", "true", "false"},
to: "#more-nav-button"
)
}
class={[
"inline-flex items-center gap-1 h-14 sm:h-16 px-2 text-sm font-medium border-b-2 transition-colors",
if(@active_page in ["network-map", "insights", "trace", "activity", "capacity"],
do:
"border-gray-900 text-gray-900 font-semibold dark:border-white dark:text-white",
else:
"border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
)
]}
>
{t("More")}
<.icon name="hero-chevron-down" class="h-3 w-3" />
</button>
<div
id="more-nav-menu"
role="menu"
class="hidden absolute top-full left-0 mt-0 w-44 origin-top-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: "#more-nav-menu")
|> JS.set_attribute({"aria-expanded", "false"}, to: "#more-nav-button")
}
>
<div class="py-1">
<.link
navigate={~p"/network-map"}
role="menuitem"
class={[
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
if(@active_page == "network-map",
do: "text-gray-900 font-medium dark:text-white",
else: "text-gray-700 dark:text-gray-300"
)
]}
>
<.icon name="hero-map" class="h-4 w-4 inline mr-2" />{t("Network Map")}
</.link>
<.link
navigate={~p"/insights"}
role="menuitem"
class={[
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
if(@active_page == "insights",
do: "text-gray-900 font-medium dark:text-white",
else: "text-gray-700 dark:text-gray-300"
)
]}
>
<.icon name="hero-light-bulb" class="h-4 w-4 inline mr-2" />{t("Insights")}
</.link>
<.link
navigate={~p"/trace"}
role="menuitem"
class={[
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
if(@active_page == "trace",
do: "text-gray-900 font-medium dark:text-white",
else: "text-gray-700 dark:text-gray-300"
)
]}
>
<.icon name="hero-magnifying-glass" class="h-4 w-4 inline mr-2" />{t("Trace")}
</.link>
<.link
navigate={~p"/activity"}
role="menuitem"
class={[
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
if(@active_page == "activity",
do: "text-gray-900 font-medium dark:text-white",
else: "text-gray-700 dark:text-gray-300"
)
]}
>
<.icon name="hero-clock" class="h-4 w-4 inline mr-2" />{t("Activity")}
</.link>
<.link
navigate={~p"/capacity"}
role="menuitem"
class={[
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
if(@active_page == "capacity",
do: "text-gray-900 font-medium dark:text-white",
else: "text-gray-700 dark:text-gray-300"
)
]}
>
<.icon name="hero-signal" class="h-4 w-4 inline mr-2" />{t("Capacity")}
</.link>
</div>
</div>
</div>
</div>
</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="hidden lg: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="text-xs">{t("Search")}</span>
<kbd class="hidden lg: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" />
</.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>
<.theme_toggle />
<!-- 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>
</div>
<!-- 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")
|> JS.toggle(to: "#mobile-menu-icon-open")
|> JS.toggle(to: "#mobile-menu-icon-close")
}
>
</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")
|> JS.toggle(to: "#mobile-menu-icon-open")
|> JS.toggle(to: "#mobile-menu-icon-close")
}
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>
<!-- Primary nav -->
<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("Navigation")}
</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"/alerts"} active={@active_page == "alerts"}>
<.icon name="hero-bell" 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("Schedules")}
</.mobile_nav_link>
</div>
<!-- Secondary nav -->
<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("Tools")}
</p>
<.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>
<.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>
<.mobile_nav_link navigate={~p"/capacity"} active={@active_page == "capacity"}>
<.icon name="hero-signal" class="size-5" /> {t("Capacity")}
</.mobile_nav_link>
</div>
<!-- Organization & 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>
</nav>
<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>
<.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
slot :inner_block, required: true
defp nav_link(assigns) do
~H"""
<.link
navigate={@navigate}
class={[
"inline-flex items-center border-b-2 px-2 h-14 sm:h-16 text-sm transition-colors",
if(@active,
do: "border-gray-900 text-gray-900 font-semibold dark:border-white dark:text-white",
else:
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 font-medium dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-200"
)
]}
>
{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>
<.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">
<.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>
<.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