who_need_help/lib/who_need_help_web/components/layouts.ex

493 lines
17 KiB
Elixir

defmodule WhoNeedHelpWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use WhoNeedHelpWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)"
attr :page_width, :atom,
default: :wide,
values: [:wide, :form, :reading, :medium, :compact, :narrow, :auth],
doc: "the centralized content width used inside the stable application grid"
attr :page_align, :atom,
default: :start,
values: [:start, :center],
doc: "whether constrained content follows the application grid or is centered"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="sticky top-0 z-30 border-b border-base-300 bg-base-100 px-4 py-2 sm:px-6 lg:px-8">
<div class="mx-auto flex min-h-12 w-full max-w-7xl items-center justify-between gap-3">
<a
href="/"
class="flex min-w-0 items-center gap-3"
aria-label={gettext("Who Need Help home")}
>
<img
src={~p"/images/logo.svg"}
width="36"
height="36"
alt=""
class="size-9 shrink-0"
/>
<span class="truncate font-black tracking-tight">Who Need Help</span>
</a>
<nav class="min-w-0 flex-none" aria-label={gettext("Primary navigation")}>
<div class="hidden items-center gap-1 xl:flex">
<ul class="flex items-center gap-0.5">
<.primary_navigation_links current_scope={@current_scope} />
</ul>
<div
class="ml-1 flex items-center gap-1 border-l border-base-300 pl-2"
aria-label={gettext("Account and language")}
>
<.locale_switcher id="desktop-language-switcher" />
<.theme_toggle />
<%= if @current_scope do %>
<.link
navigate={~p"/notifications"}
class="btn btn-ghost btn-sm btn-square"
aria-label={gettext("Notifications and nearby alerts")}
>
<.icon name="hero-bell" class="size-5" />
</.link>
<.link navigate={~p"/requests/new"} class="btn btn-primary btn-sm whitespace-nowrap">
{gettext("Ask for help")}
</.link>
<.account_menu current_scope={@current_scope} />
<% else %>
<.link navigate={~p"/users/register"} class="btn btn-ghost btn-sm">
{gettext("Register")}
</.link>
<.link navigate={~p"/users/log-in"} class="btn btn-primary btn-sm">
{gettext("Log in")}
</.link>
<% end %>
</div>
</div>
<details
id="primary-navigation-disclosure"
phx-hook="AccessibleDisclosure"
class="dropdown dropdown-end xl:hidden"
>
<summary
class="btn btn-ghost btn-square"
aria-label={gettext("Primary navigation")}
aria-expanded="false"
>
<.icon name="hero-bars-3" class="size-6" />
</summary>
<div class="dropdown-content mt-3 w-[min(20rem,calc(100vw-2rem))] overflow-visible rounded-box border border-base-300 bg-base-100 shadow-xl">
<ul class="menu w-full p-2">
<.primary_navigation_links
current_scope={@current_scope}
link_class="justify-start"
/>
</ul>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-base-300 px-4 py-3">
<.locale_switcher id="mobile-language-switcher" align={:start} />
<.theme_toggle />
</div>
<%= if @current_scope do %>
<ul class="menu w-full border-t border-base-300 p-2">
<li>
<.link navigate={~p"/profile"}>{gettext("Profile")}</.link>
</li>
<li>
<.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link>
</li>
<li>
<.link navigate={~p"/reports"}>{gettext("Your reports")}</.link>
</li>
<li>
<.link href={~p"/support/requests"}>{gettext("Support requests")}</.link>
</li>
<li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li>
<li
:if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}
class="menu-title mt-1 border-t border-base-300 pt-2"
>
{gettext("Operations")}
</li>
<li :if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}>
<.link navigate={~p"/admin"}>{gettext("Staff workspace")}</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
</li>
</ul>
<div class="border-t border-base-300 p-3">
<.link navigate={~p"/requests/new"} class="btn btn-primary btn-block">
{gettext("Ask for help")}
</.link>
</div>
<% else %>
<div class="grid grid-cols-2 gap-2 border-t border-base-300 p-3">
<.link navigate={~p"/users/register"} class="btn btn-outline">
{gettext("Register")}
</.link>
<.link navigate={~p"/users/log-in"} class="btn btn-primary">
{gettext("Log in")}
</.link>
</div>
<% end %>
</div>
</details>
</nav>
</div>
</header>
<main id="main-content" tabindex="-1" class="px-4 py-8 outline-none sm:px-6 lg:px-8">
<div class="mx-auto w-full max-w-7xl">
<div class={[
"w-full space-y-4",
page_width_class(@page_width),
@page_align == :center && "mx-auto"
]}>
{render_slot(@inner_block)}
</div>
</div>
</main>
<footer class="border-t border-base-300 px-4 py-6 text-center text-xs text-base-content/70">
<.link href={~p"/safety"} class="link">{gettext("Safety rules")}</.link>
<span class="mx-2">·</span>
<.link href={~p"/privacy"} class="link">{gettext("Privacy")}</.link>
<span class="mx-2">·</span>
<.link href={~p"/terms"} class="link">{gettext("Terms")}</.link>
<span class="mx-2">·</span>
<.link href={~p"/support"} class="link">{gettext("Support")}</.link>
<span class="mx-2">·</span>
<.link href={~p"/legal/content-removal"} class="link">{gettext("Report content")}</.link>
<span class="mx-2">·</span>
<.link href={~p"/feedback"} class="link">{gettext("Build Week feedback")}</.link>
</footer>
<.flash_group flash={@flash} />
"""
end
defp page_width_class(:wide), do: "max-w-none"
defp page_width_class(:form), do: "max-w-5xl"
defp page_width_class(:reading), do: "max-w-4xl"
defp page_width_class(:medium), do: "max-w-3xl"
defp page_width_class(:compact), do: "max-w-2xl"
defp page_width_class(:narrow), do: "max-w-xl"
defp page_width_class(:auth), do: "max-w-sm"
attr :current_scope, :map, default: nil
attr :link_class, :string, default: "btn btn-ghost btn-sm"
defp primary_navigation_links(assigns) do
~H"""
<li>
<.link navigate={~p"/requests"} class={@link_class}>
{gettext("Requests")}
</.link>
</li>
<li>
<.link navigate={~p"/activities"} class={@link_class}>
{gettext("Activities")}
</.link>
</li>
<li>
<.link navigate={~p"/categories/proposals"} class={@link_class}>
{gettext("Categories")}
</.link>
</li>
<li>
<.link navigate={~p"/leaderboard"} class={@link_class}>
{gettext("Helpers")}
</.link>
</li>
"""
end
attr :id, :string, required: true
attr :align, :atom, default: :end, values: [:start, :end]
defp locale_switcher(assigns) do
current_locale =
WhoNeedHelpWeb.Gettext
|> Gettext.get_locale()
|> WhoNeedHelp.Locales.get()
assigns =
assigns
|> assign(:current_locale, current_locale)
|> assign(:locales, WhoNeedHelp.Locales.all())
~H"""
<details
id={@id}
phx-hook="LanguageSwitcher"
class={["dropdown", @align == :end && "dropdown-end", @align == :start && "dropdown-start"]}
data-language-switcher
>
<summary
class="btn btn-ghost btn-sm gap-1 px-2"
aria-label={gettext("Language: %{language}", language: @current_locale.native_name)}
aria-haspopup="true"
aria-expanded="false"
>
<.icon name="hero-language" class="size-4" />
<span class="text-xs font-bold tracking-wide">{@current_locale.short_name}</span>
<.icon name="hero-chevron-down-micro" class="size-4 opacity-60" />
</summary>
<div class="language-switcher-panel dropdown-content mt-3 overflow-hidden rounded-box border border-base-300 bg-base-100 shadow-xl">
<div class="border-b border-base-300 px-4 py-3">
<h2 class="font-bold">{gettext("Language")}</h2>
<p class="mt-0.5 text-xs text-base-content/60">
{gettext("Choose the language used in the interface.")}
</p>
</div>
<div class="border-b border-base-300 p-3">
<label class="input input-bordered input-sm flex w-full items-center gap-2">
<.icon name="hero-magnifying-glass" class="size-4 shrink-0 opacity-60" />
<input
type="search"
class="grow"
placeholder={gettext("Search languages")}
aria-label={gettext("Search languages")}
autocomplete="off"
spellcheck="false"
data-language-search
/>
</label>
</div>
<ul
class="language-switcher-options menu max-h-72 w-full flex-nowrap overflow-y-auto p-2"
aria-label={gettext("Available languages")}
>
<li
:for={locale <- @locales}
data-language-option
data-search-text={
String.downcase("#{locale.native_name} #{locale.short_name} #{locale.code}")
}
>
<a
href={"?locale=#{locale.code}"}
class={[
"language-switcher-option",
locale.code == @current_locale.code && "active"
]}
lang={locale.code}
hreflang={locale.code}
aria-current={locale.code == @current_locale.code && "page"}
>
<span class="min-w-0 grow">
<span class="block truncate font-semibold">{locale.native_name}</span>
<span class="block text-xs uppercase opacity-55">{locale.short_name}</span>
</span>
<.icon
:if={locale.code == @current_locale.code}
name="hero-check"
class="size-5 shrink-0"
/>
</a>
</li>
</ul>
<p
class="px-4 py-6 text-center text-sm text-base-content/60"
data-language-empty
hidden
>
{gettext("No languages found")}
</p>
</div>
</details>
"""
end
attr :current_scope, :map, required: true
defp account_menu(assigns) do
~H"""
<details
id="account-navigation-disclosure"
phx-hook="AccessibleDisclosure"
class="dropdown dropdown-end"
>
<summary class="btn btn-ghost btn-sm gap-1" aria-expanded="false">
<.icon name="hero-user-circle" class="size-5" />
<span class="flex max-w-36 flex-col items-start overflow-hidden leading-tight">
<span class="max-w-full truncate">
{@current_scope.user.display_name || @current_scope.user.email}
</span>
<span
:if={@current_scope.user.display_name}
class="max-w-full truncate text-[0.65rem] font-normal opacity-60"
>
{@current_scope.user.email}
</span>
</span>
<.icon name="hero-chevron-down-micro" class="size-4 opacity-60" />
</summary>
<div class="dropdown-content mt-3 w-64 overflow-hidden rounded-box border border-base-300 bg-base-100 shadow-xl">
<ul class="menu w-full p-2">
<li>
<.link navigate={~p"/profile"}>{gettext("Profile")}</.link>
</li>
<li>
<.link navigate={~p"/notifications"}>{gettext("Notifications")}</.link>
</li>
<li>
<.link navigate={~p"/reports"}>{gettext("Your reports")}</.link>
</li>
<li>
<.link href={~p"/support/requests"}>{gettext("Support requests")}</.link>
</li>
<li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li>
<li
:if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}
class="menu-title mt-1 border-t border-base-300 pt-2"
>
{gettext("Operations")}
</li>
<li :if={WhoNeedHelp.Accounts.staff?(@current_scope.user)}>
<.link navigate={~p"/admin"}>{gettext("Staff workspace")}</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
</li>
</ul>
</div>
</details>
"""
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", to: ".phx-client-error #client-error")
}
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", to: ".phx-server-error #server-error")
}
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="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 [[data-theme-source=system]_&]:!left-0 transition-[left]" />
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system"
aria-label={gettext("Use system theme")}
>
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light"
aria-label={gettext("Use light theme")}
>
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark"
aria-label={gettext("Use dark theme")}
>
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
</div>
"""
end
end