mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-12-12 02:35:42 +00:00
Initial commit
This commit is contained in:
59
lib/wanderer_app_web/components/alerts.ex
Normal file
59
lib/wanderer_app_web/components/alerts.ex
Normal file
@@ -0,0 +1,59 @@
|
||||
defmodule WandererAppWeb.Alerts do
|
||||
@moduledoc """
|
||||
Component that shows alerts.
|
||||
"""
|
||||
use WandererAppWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
# this function will look and `push_event()` for each existing flash type
|
||||
|> trigger_fade_out_flashes(assigns)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div role="alert" data-handle-fadeout-flash={delayed_fade_out_flash()} phx-value-key="info">
|
||||
<.flash id="client-info" kind={:info} title="Info!" flash={@view_flash} />
|
||||
</div>
|
||||
<div role="alert" phx-value-key="error">
|
||||
<.flash id="client-error" kind={:error} title="Error!" flash={@view_flash} />
|
||||
</div>
|
||||
<div role="alert" phx-value-key="warning">
|
||||
<.flash id="client-warning" kind={:warning} title="Warning!" flash={@view_flash} />
|
||||
</div>
|
||||
<div role="alert" phx-value-key="loading" data-handle-fadeout-flash={delayed_fade_out_flash()}>
|
||||
<.flash id="client-loading" kind={:loading} title="Loading..." flash={@view_flash} />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# depending on how you structured your code, `socket.assigns.flash` might have your flash map.
|
||||
# for me I was running this as a component, so it was being passed @flash into `view_flash`.
|
||||
defp trigger_fade_out_flashes(socket, %{view_flash: nil} = _assigns), do: socket
|
||||
|
||||
defp trigger_fade_out_flashes(socket, %{view_flash: flash} = _assigns) do
|
||||
# push event for each flash type.
|
||||
Map.keys(flash)
|
||||
|> Enum.reduce(socket, fn flash_key, piped_socket ->
|
||||
piped_socket
|
||||
|> push_event("fade-out-flash", %{type: flash_key})
|
||||
end)
|
||||
end
|
||||
|
||||
# use TailwindCSS to wait 2 seconds before starting transition. Afterwards, send event to server to clear out flash.
|
||||
# `lv:clear-flash` will use `phx-value-key` attribute in element to remove flash per type.
|
||||
def delayed_fade_out_flash() do
|
||||
JS.hide(
|
||||
transition:
|
||||
{"transition-opacity ease-out delay-2000 duration-1000", "opacity-100", "opacity-0"},
|
||||
time: 6000
|
||||
)
|
||||
|> JS.push("lv:clear-flash")
|
||||
end
|
||||
end
|
||||
902
lib/wanderer_app_web/components/core_components.ex
Normal file
902
lib/wanderer_app_web/components/core_components.ex
Normal file
@@ -0,0 +1,902 @@
|
||||
defmodule WandererAppWeb.CoreComponents do
|
||||
@moduledoc """
|
||||
Provides core UI components.
|
||||
|
||||
At first glance, this module may seem daunting, but its goal is to provide
|
||||
core building blocks for your application, such as modals, tables, and
|
||||
forms. The components consist mostly of markup and are well-documented
|
||||
with doc strings and declarative assigns. You may customize and style
|
||||
them in any way you want, based on your application growth and needs.
|
||||
|
||||
The default components use Tailwind CSS, a utility-first CSS framework.
|
||||
See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
|
||||
how to customize them or feel free to swap in another framework altogether.
|
||||
|
||||
Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
alias Phoenix.LiveView.JS
|
||||
import WandererAppWeb.Gettext
|
||||
|
||||
@image_base_url "https://images.evetech.net"
|
||||
|
||||
attr(:url, :string, required: true)
|
||||
attr(:label, :string, required: false)
|
||||
|
||||
def avatar(assigns) do
|
||||
~H"""
|
||||
<div class="avatar">
|
||||
<div class="rounded-md w-8 h-8">
|
||||
<img src={@url} alt={@label} />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a modal.
|
||||
|
||||
## Examples
|
||||
|
||||
<.modal id="confirm-modal">
|
||||
This is a modal.
|
||||
</.modal>
|
||||
|
||||
JS commands may be passed to the `:on_cancel` to configure
|
||||
the closing/cancel event, for example:
|
||||
|
||||
<.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}>
|
||||
This is another modal.
|
||||
</.modal>
|
||||
|
||||
"""
|
||||
attr(:id, :string, required: true)
|
||||
attr(:title, :string, default: nil)
|
||||
attr(:show, :boolean, default: false)
|
||||
attr(:on_cancel, JS, default: %JS{})
|
||||
slot(:inner_block, required: true)
|
||||
attr(:class, :string, default: nil)
|
||||
|
||||
def modal(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={@id}
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
data-cancel={JS.exec(@on_cancel, "phx-remove")}
|
||||
class="relative z-50 hidden overflow-visible"
|
||||
class=""
|
||||
>
|
||||
<div id={"#{@id}-bg"} class="overflow-visible p-dialog-resizable" aria-hidden="true" />
|
||||
<div
|
||||
class="fixed inset-0 overflow-visible"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
aria-describedby={"#{@id}-description"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex items-center justify-center w-full h-full p-4 sm:p-6 lg:py-8 p-dialog-mask p-dialog-center p-component-overlay p-component-overlay-enter p-dialog-resizable">
|
||||
<.focus_wrap
|
||||
id={"#{@id}-container"}
|
||||
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
||||
phx-key="escape"
|
||||
class={[
|
||||
"relative hidden transition p-dialog p-component p-dialog-default p-ripple-disabled p-dialog-enter-done !overflow-visible max-w-full",
|
||||
@class
|
||||
]}
|
||||
>
|
||||
<h3 class="p-dialog-header font-bold text-base">
|
||||
<div><%= @title %></div>
|
||||
<div class="absolute right-4">
|
||||
<button
|
||||
phx-click={JS.exec("data-cancel", to: "##{@id}")}
|
||||
type="button"
|
||||
class="p-link opacity-70 hover:opacity-100"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</h3>
|
||||
<div id={"#{@id}-content"} class="p-dialog-content !overflow-visible">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</.focus_wrap>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
slot :inner_block
|
||||
|
||||
def connection_status(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="connection-status"
|
||||
class="hidden fixed z-50"
|
||||
js-show={show("#connection-status")}
|
||||
js-hide={hide("#connection-status")}
|
||||
>
|
||||
<div class="hs-overlay-backdrop transition duration fixed inset-0 bg-gray-900 bg-opacity-50 dark:bg-opacity-80 dark:bg-neutral-900 z-50">
|
||||
</div>
|
||||
<div class="alert fixed top-20 right-4 shadow-lg w-72 fade-in-scale z-50">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<span class="loading loading-ring loading-md"></span>
|
||||
</div>
|
||||
<div class="ml-3 flex items-center">
|
||||
<p class="text-sm font-medium text-red-800" role="alert">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:online, :boolean, default: false)
|
||||
|
||||
def server_status(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="flex flex-col p-4 items-center absolute bottom-16 left-1 gap-2 tooltip tooltip-right"
|
||||
data-tip="server: Tranquility"
|
||||
>
|
||||
<div class={"block w-4 h-4 rounded-full shadow-inner #{if @online, do: " bg-green-500 animate-pulse", else: "bg-red-500"}"}>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr(:id, :string, doc: "the optional id of flash container")
|
||||
attr(:flash, :map, default: %{}, doc: "the map of flash messages to display")
|
||||
attr(:title, :string, default: nil)
|
||||
|
||||
attr(:kind, :atom,
|
||||
values: [:info, :warning, :error, :loading],
|
||||
doc: "used for styling and flash lookup"
|
||||
)
|
||||
|
||||
attr(:rest, :global, doc: "the arbitrary HTML attributes to add to the flash container")
|
||||
|
||||
slot(:inner_block, doc: "the optional inner block that renders the flash message")
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-click={
|
||||
JS.push("lv:clear-flash", value: %{key: @kind})
|
||||
|> JS.remove_class("fade-in-scale")
|
||||
|> hide("##{@id}")
|
||||
}
|
||||
role="alert"
|
||||
class={[
|
||||
"alert shadow-lg flex items-center justify-between fixed top-12 right-2 w-80 z-50 fade-in-scale !rounded text-white !bg-black !bg-opacity-70 ",
|
||||
@kind == :info && "alert-info ",
|
||||
@kind == :warning && "alert-warning ",
|
||||
@kind == :error && "alert-error",
|
||||
@kind == :loading && "alert-success"
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<div>
|
||||
<div class="flex gap-2 text-xs items-center">
|
||||
<.icon
|
||||
:if={@kind == :info}
|
||||
name="hero-information-circle"
|
||||
class="h-5 !w-[50px] text-blue-500"
|
||||
/>
|
||||
<.icon
|
||||
:if={@kind == :warning}
|
||||
name="hero-exclamation-triangle"
|
||||
class="h-5 !w-[50px] text-orange-500"
|
||||
/>
|
||||
<.icon :if={@kind == :error} name="hero-x-circle" class="h-5 !w-[50px] text-red-500" />
|
||||
<span :if={@kind == :loading} class="loading loading-ring loading-md"></span> <%= msg %>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="flex items-center" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 !w-[50px] opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
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}>
|
||||
<.flash id="client-info" kind={:info} title="Success!" flash={@flash} />
|
||||
<.flash id="client-error" kind={:error} title="Error!" flash={@flash} />
|
||||
<.flash id="client-loading" kind={:loading} title="Loading..." flash={@flash} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a simple form.
|
||||
|
||||
## Examples
|
||||
|
||||
<.simple_form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label="Email"/>
|
||||
<.input field={@form[:username]} label="Username" />
|
||||
<:actions>
|
||||
<.button>Save</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
"""
|
||||
attr(:for, :any, required: true, doc: "the datastructure for the form")
|
||||
attr(:as, :any, default: nil, doc: "the server side parameter to collect all input under")
|
||||
|
||||
attr(:rest, :global,
|
||||
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
|
||||
doc: "the arbitrary HTML attributes to apply to the form tag"
|
||||
)
|
||||
|
||||
slot(:inner_block, required: true)
|
||||
slot(:actions, doc: "the slot for form actions, such as a submit button")
|
||||
|
||||
def simple_form(assigns) do
|
||||
~H"""
|
||||
<.form :let={f} for={@for} as={@as} {@rest}>
|
||||
<div class="w-full space-y-8">
|
||||
<%= render_slot(@inner_block, f) %>
|
||||
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
|
||||
<%= render_slot(action, f) %>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" class="ml-2">Send!</.button>
|
||||
"""
|
||||
attr(:type, :string, default: nil)
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:rest, :global, include: ~w(disabled form name value))
|
||||
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type={@type}
|
||||
class={[
|
||||
"phx-submit-loading:opacity-75 p-button p-component p-button-outlined p-button-sm",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information.
|
||||
|
||||
## Examples
|
||||
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
"""
|
||||
attr(:id, :any, default: nil)
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:name, :any)
|
||||
attr(:label, :string, default: nil)
|
||||
attr(:prefix, :string, default: nil)
|
||||
attr(:value, :any)
|
||||
|
||||
attr(:type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file hidden month number password
|
||||
range radio search select tel text textarea time url week)
|
||||
)
|
||||
|
||||
attr(:field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
)
|
||||
|
||||
attr(:errors, :list, default: [])
|
||||
attr(:checked, :boolean, doc: "the checked flag for checkbox inputs")
|
||||
attr(:show_value, :boolean, doc: "show current value")
|
||||
attr(:prompt, :string, default: nil, doc: "the prompt for select inputs")
|
||||
attr(:options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2")
|
||||
attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs")
|
||||
|
||||
attr(:rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
)
|
||||
|
||||
slot(:inner_block)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div phx-feedback-for={@name} class="form-control mt-8">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text"><%= @label %></span>
|
||||
<input type="hidden" name={@name} value="false" />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="checkbox"
|
||||
{@rest}
|
||||
/>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "range"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<label class="form-control w-full">
|
||||
<.label for={@id}>
|
||||
<span class="label-text"><%= @label %></span>
|
||||
<span class="label-value"><%= @value %></span>
|
||||
</.label>
|
||||
<input
|
||||
type="range"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value={@value}
|
||||
class={[
|
||||
"p-component w-full",
|
||||
@class,
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div phx-feedback-for={@name}>
|
||||
<.label :if={@label} for={@id}><%= @label %></.label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"w-full",
|
||||
@class
|
||||
]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<label phx-feedback-for={@name} class="form-control">
|
||||
<.label for={@id}><span class="label-text"><%= @label %></span></.label>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"p-inputtextarea p-inputtext p-component w-full h-24",
|
||||
@class,
|
||||
@errors != [] && "p-invalid"
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<label class="form-control w-full" phx-feedback-for={@name}>
|
||||
<.label for={@id}><span class="label-text"><%= @label %></span></.label>
|
||||
<div class="join">
|
||||
<input :if={@prefix} class="p-inputtext bg-neutral-700 join-item" disabled value={@prefix} />
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"p-inputtext p-component w-full",
|
||||
@class,
|
||||
@errors != [] && "p-invalid"
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="label">
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a label.
|
||||
"""
|
||||
attr(:for, :string, default: nil)
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<div for={@for} class="label">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a generic error message.
|
||||
"""
|
||||
slot(:inner_block, required: true)
|
||||
|
||||
def error(assigns) do
|
||||
~H"""
|
||||
<p class="label-text-alt text-rose-600 phx-no-feedback:hidden">
|
||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a header with title.
|
||||
"""
|
||||
attr(:class, :string, default: nil)
|
||||
|
||||
slot(:inner_block, required: true)
|
||||
slot(:subtitle)
|
||||
slot(:actions)
|
||||
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[
|
||||
"flex flex-col justify-between gap-2 p-2 bg-gray-400 bg-opacity-5 border border-gray-500 ",
|
||||
@class
|
||||
]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6">
|
||||
<%= render_slot(@subtitle) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Renders a table with generic styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id"><%= user.id %></:col>
|
||||
<:col :let={user} label="username"><%= user.username %></:col>
|
||||
</.table>
|
||||
"""
|
||||
attr(:id, :string, required: true)
|
||||
attr(:class, :string, default: nil)
|
||||
attr(:empty_label, :string, default: nil)
|
||||
attr(:rows, :list, required: true)
|
||||
attr(:row_id, :any, default: nil, doc: "the function for generating the row id")
|
||||
attr(:row_selected, :boolean, default: false, doc: "the function for generating the row id")
|
||||
attr(:row_click, :any, default: nil, doc: "the function for handling phx-click on each row")
|
||||
|
||||
attr(:row_item, :any,
|
||||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
)
|
||||
|
||||
slot :col, required: true do
|
||||
attr(:label, :string)
|
||||
end
|
||||
|
||||
slot(:action, doc: "the slot for showing user actions in the last table column")
|
||||
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
|
||||
assign(assigns, row_selected: assigns.row_selected || fn {_id, _item} -> false end)
|
||||
end
|
||||
|
||||
~H"""
|
||||
<div class={["overflow-y-auto px-4 sm:overflow-visible sm:px-0", @class]}>
|
||||
<table class="table overflow-y-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}><%= col[:label] %></th>
|
||||
<th :if={@action != []} class="relative p-0 pb-4">
|
||||
<span class="sr-only"><%= gettext("Actions") %></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}>
|
||||
<tr :if={@rows |> Enum.empty?()}>
|
||||
<td colspan={@col |> Enum.count()}>
|
||||
<%= @empty_label %>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
:for={row <- @rows}
|
||||
id={@row_id && @row_id.(row)}
|
||||
class={"hover #{if @row_selected && @row_selected.(row), do: "!bg-slate-600", else: ""} #{if @row_click, do: "cursor-pointer", else: ""}"}
|
||||
>
|
||||
<td
|
||||
:for={{col, _index} <- Enum.with_index(@col)}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
>
|
||||
<%= render_slot(col, @row_item.(row)) %>
|
||||
</td>
|
||||
<td :if={@action != []}>
|
||||
<div class="relative whitespace-nowrap text-right text-sm font-medium">
|
||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
<span :for={action <- @action} class="relative pl-4 font-semibold leading-6">
|
||||
<%= render_slot(action, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title"><%= @post.title %></:item>
|
||||
<:item title="Views"><%= @post.views %></:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
attr(:title, :string, required: true)
|
||||
end
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<div class="mt-14">
|
||||
<dl class="-my-4 divide-y divide-zinc-100">
|
||||
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
|
||||
<dd class="text-zinc-700"><%= render_slot(item) %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:placeholder, :string, default: nil)
|
||||
attr(:label, :string, default: nil)
|
||||
attr(:label_class, :string, default: nil)
|
||||
attr(:input_class, :string, default: nil)
|
||||
attr(:dropdown_extra_class, :string, default: nil)
|
||||
attr(:option_extra_class, :string, default: nil)
|
||||
slot(:inner_block)
|
||||
|
||||
def live_select(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|
||||
|> assign(
|
||||
:live_select_opts,
|
||||
assigns_to_attributes(assigns, [
|
||||
:errors,
|
||||
:label,
|
||||
:value_mapper,
|
||||
:label_class,
|
||||
:input_class,
|
||||
:dropdown_extra_class,
|
||||
:option_extra_class
|
||||
])
|
||||
)
|
||||
|
||||
~H"""
|
||||
<label
|
||||
phx-feedback-for={@field.name}
|
||||
class={[
|
||||
"form-control",
|
||||
@label_class
|
||||
]}
|
||||
>
|
||||
<div for="form_description" class="label">
|
||||
<span class="label-text"></span>
|
||||
</div>
|
||||
<LiveSelect.live_select
|
||||
field={@field}
|
||||
dropdown_class={[
|
||||
"absolute shadow z-50 w-full max-h-64 bg-neutral-900 text-neutral-50 overflow-y-auto",
|
||||
@dropdown_extra_class
|
||||
]}
|
||||
available_option_class="w-full"
|
||||
option_class="p-2 hover:bg-neutral-800 hover:text-neutral-50"
|
||||
tag_extra_class="rounded-none"
|
||||
text_input_class={[
|
||||
"p-autocomplete-input p-component p-inputtext w-full",
|
||||
@errors != [] && "p-invalid",
|
||||
@input_class
|
||||
]}
|
||||
text_input_selected_class="p-inputtext"
|
||||
{@live_select_opts}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</LiveSelect.live_select>
|
||||
<div for="form_description" class="label">
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a back navigation link.
|
||||
|
||||
## Examples
|
||||
|
||||
<.back navigate={~p"/posts"}>Back to posts</.back>
|
||||
"""
|
||||
attr(:navigate, :any, required: true)
|
||||
slot(:inner_block, required: true)
|
||||
attr(:class, :string, default: nil)
|
||||
|
||||
def back(assigns) do
|
||||
~H"""
|
||||
<div class="pt-16">
|
||||
<.link
|
||||
navigate={@navigate}
|
||||
class={[
|
||||
"text-sm font-semibold leading-6",
|
||||
@class
|
||||
]}
|
||||
>
|
||||
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add conditional class names to a component.
|
||||
|
||||
## Examples
|
||||
|
||||
<span class={["text-green-600 ", classes("text-red-600": @value < 0)]} />
|
||||
"""
|
||||
def classes(classes) do
|
||||
([" ": true] ++ classes)
|
||||
|> Enum.filter(&elem(&1, 1))
|
||||
|> Enum.map_join(" ", &elem(&1, 0))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from your `assets/vendor/heroicons` directory and bundled
|
||||
within your compiled app.css by the plugin in your `assets/tailwind.config.js`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.icon name="hero-x-mark-solid" />
|
||||
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
|
||||
"""
|
||||
attr(:name, :string, required: true)
|
||||
attr(:class, :string, default: nil)
|
||||
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
"""
|
||||
end
|
||||
|
||||
def local_time(assigns) do
|
||||
~H"""
|
||||
<time phx-hook="LocalTime" id={"time-#{@id}"} class="invisible"><%= @at %></time>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:at, :any, required: true)
|
||||
attr(:id, :any, required: true)
|
||||
|
||||
def client_time(assigns) do
|
||||
~H"""
|
||||
<time phx-hook="ClientTime" id={"client-time-#{@id}"} class="invisible"><%= @at %></time>
|
||||
"""
|
||||
end
|
||||
|
||||
## JS Commands
|
||||
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
transition:
|
||||
{"transition-all transform ease-out duration-300",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
|
||||
"opacity-100 translate-y-0 sm:scale-100"}
|
||||
)
|
||||
end
|
||||
|
||||
def hide(js \\ %JS{}, selector) do
|
||||
JS.hide(js,
|
||||
to: selector,
|
||||
time: 200,
|
||||
transition:
|
||||
{"transition-all transform ease-in duration-200",
|
||||
"opacity-100 translate-y-0 sm:scale-100",
|
||||
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
|
||||
)
|
||||
end
|
||||
|
||||
def show_modal(js \\ %JS{}, id) when is_binary(id) do
|
||||
js
|
||||
|> JS.show(to: "##{id}")
|
||||
|> JS.show(
|
||||
to: "##{id}-bg",
|
||||
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"}
|
||||
)
|
||||
|> show("##{id}-container")
|
||||
|> JS.add_class("overflow-hidden", to: "body")
|
||||
|> JS.focus_first(to: "##{id}-content")
|
||||
end
|
||||
|
||||
def hide_modal(js \\ %JS{}, id) do
|
||||
js
|
||||
|> JS.hide(
|
||||
to: "##{id}-bg",
|
||||
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"}
|
||||
)
|
||||
|> hide("##{id}-container")
|
||||
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|
||||
|> JS.remove_class("overflow-hidden", to: "body")
|
||||
|> JS.pop_focus()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# However the error messages in our forms and APIs are generated
|
||||
# dynamically, so we need to translate them by calling Gettext
|
||||
# with our gettext backend as first argument. Translations are
|
||||
# available in the errors.po file (as we use the "errors" domain).
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(WandererAppWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(WandererAppWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates the errors for a field from a keyword list of errors.
|
||||
"""
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
|
||||
def less(a, b) do
|
||||
a < b
|
||||
end
|
||||
|
||||
def more_or_equal(a, b) do
|
||||
a >= b
|
||||
end
|
||||
|
||||
def member_icon_url(eve_character_id)
|
||||
when is_binary(eve_character_id) or is_integer(eve_character_id) do
|
||||
"#{@image_base_url}/characters/#{eve_character_id}/portrait"
|
||||
end
|
||||
|
||||
def member_icon_url(%{eve_character_id: eve_character_id} = _member)
|
||||
when is_binary(eve_character_id) or is_integer(eve_character_id) do
|
||||
"#{@image_base_url}/characters/#{eve_character_id}/portrait"
|
||||
end
|
||||
|
||||
def member_icon_url(%{eve_corporation_id: eve_corporation_id} = _member)
|
||||
when is_binary(eve_corporation_id) or is_integer(eve_corporation_id) do
|
||||
"#{@image_base_url}/corporations/#{eve_corporation_id}/logo?size=32"
|
||||
end
|
||||
|
||||
def member_icon_url(%{eve_alliance_id: eve_alliance_id} = _member)
|
||||
when is_binary(eve_alliance_id) or is_integer(eve_alliance_id) do
|
||||
"#{@image_base_url}/alliances/#{eve_alliance_id}/logo?size=32"
|
||||
end
|
||||
end
|
||||
178
lib/wanderer_app_web/components/layouts.ex
Normal file
178
lib/wanderer_app_web/components/layouts.ex
Normal file
@@ -0,0 +1,178 @@
|
||||
defmodule WandererAppWeb.Layouts do
|
||||
use WandererAppWeb, :html
|
||||
|
||||
embed_templates "layouts/*"
|
||||
|
||||
attr :rtt_class, :string
|
||||
|
||||
def ping_container(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="ping-container"
|
||||
class={[
|
||||
"flex flex-col p-4 items-center absolute bottom-28 left-1 gap-2 tooltip tooltip-right text-gray-400 hover:text-white",
|
||||
@rtt_class
|
||||
]}
|
||||
phx-hook="Ping"
|
||||
phx-update="ignore"
|
||||
>
|
||||
<.icon name="hero-wifi-solid" class="h-4 w-4" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :app_version, :string
|
||||
|
||||
def new_version_banner(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="new-version-banner"
|
||||
phx-hook="NewVersionUpdate"
|
||||
phx-update="ignore"
|
||||
data-version={@app_version}
|
||||
class="!z-100 hidden group alert items-center fixed bottom-52 left-2 fade-in-scale text-white !bg-opacity-70 w-10 h-10 hover:w-[250px] hover:h-[70px] rounded p-px overflow-hidden"
|
||||
>
|
||||
<div class="group animate-rotate absolute inset-0 h-full w-full rounded-full bg-[conic-gradient(#0ea5e9_20deg,transparent_120deg)] group-hover:bg-[#0ea5e9]" />
|
||||
|
||||
<div class="!bg-black rounded w-9 h-9 hover:m-0 group-hover:w-[246px] group-hover:h-[66px] flex items-center justify-center p-2 relative z-20">
|
||||
<.icon name="hero-bell-alert" class="animate-pulse group-hover:hidden absolute top-2 h-5 w-5" />
|
||||
<div class="opacity-0 group-hover:opacity-100 flex flex-col items-center justify-center w-[250px] h-full">
|
||||
<div class="text-white text-nowrap text-sm">
|
||||
New Version Available
|
||||
</div>
|
||||
<a href="/changelog" target="_blank" class="text-sm link-secondary">What's new?</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="invisible group-hover:visible update-button p-button p-component p-button-outlined p-button-sm p-0 px-1 w-[76px]"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def feedback_container(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="feeback-container"
|
||||
data-az-l="6e9c41f4-8f3f-4e3b-bbc6-e808f9e46808"
|
||||
class={[
|
||||
"flex flex-col p-4 items-center absolute bottom-40 left-1 gap-2 tooltip tooltip-right text-gray-400 hover:text-white"
|
||||
]}
|
||||
data-tip="Leave Feedback"
|
||||
>
|
||||
<.icon name="hero-hand-thumb-up-solid" class="h-4 w-4" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :id, :string
|
||||
attr :active_tab, :atom
|
||||
attr :show_admin, :boolean
|
||||
attr :map_subscriptions_enabled, :boolean
|
||||
|
||||
def sidebar_nav_links(assigns) do
|
||||
~H"""
|
||||
<ul class="text-center flex flex-col w-full">
|
||||
<div class="dropdown dropdown-right">
|
||||
<div tabindex="0" role="button">
|
||||
<li class="flex-1 w-full h-14 block text-gray-400 hover:text-white p-3">
|
||||
<.icon name="hero-bars-3-solid" class="w-6 h-6" />
|
||||
</li>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li><a href="/changelog">Changelog</a></li>
|
||||
<li><a href="/news">News</a></li>
|
||||
<li><a href="/license">License</a></li>
|
||||
<li><a href="/contacts">Contact Us</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<.nav_link
|
||||
href="/last"
|
||||
active={@active_tab == :map}
|
||||
icon="hero-viewfinder-circle-solid"
|
||||
tip="Map"
|
||||
/>
|
||||
<.nav_link href="/maps" active={@active_tab == :maps} icon="hero-map-solid" tip="Maps" />
|
||||
<.nav_link
|
||||
href="/access-lists"
|
||||
active={@active_tab == :access_lists}
|
||||
icon="hero-user-group-solid"
|
||||
tip="Access Lists"
|
||||
/>
|
||||
<.nav_link
|
||||
href="/characters"
|
||||
active={@active_tab == :characters}
|
||||
icon="hero-user-plus-solid"
|
||||
tip="Characters"
|
||||
/>
|
||||
<.nav_link
|
||||
href="/tracking"
|
||||
active={@active_tab == :characters_tracking}
|
||||
icon="hero-signal-solid"
|
||||
tip="Characters Tracking"
|
||||
/>
|
||||
|
||||
<div class="absolute bottom-0 left-0 border-t border-gray-600 dropdown dropdown-right dropdown-end">
|
||||
<div tabindex="0" role="button" class="h-full w-full text-gray-400 hover:text-white block p-4">
|
||||
<.icon name="hero-user-solid" class="w-6 h-6" />
|
||||
</div>
|
||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
|
||||
<li :if={@show_admin}>
|
||||
<.link navigate="/admin">
|
||||
Admin
|
||||
</.link>
|
||||
</li>
|
||||
<li :if={@show_admin}>
|
||||
<.link navigate="/admin/errors">
|
||||
Errors
|
||||
</.link>
|
||||
</li>
|
||||
<li :if={@map_subscriptions_enabled}>
|
||||
<.link navigate="/profile">
|
||||
Profile
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link navigate="/auth/signout">
|
||||
Logout
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :href, :string
|
||||
attr :active, :boolean, default: false
|
||||
attr :class, :string, default: ""
|
||||
attr :icon, :string
|
||||
attr :tip, :string
|
||||
|
||||
defp nav_link(assigns) do
|
||||
~H"""
|
||||
<li class={["flex-1 w-full ", @class]}>
|
||||
<div class="tooltip tooltip-right" data-tip={@tip}>
|
||||
<.link
|
||||
navigate={@href}
|
||||
class={[
|
||||
"h-full w-full text-gray-400 hover:text-white block p-3",
|
||||
classes("border-r-4 text-white border-r-orange-400": @active)
|
||||
]}
|
||||
aria-current={if @active, do: "true", else: "false"}
|
||||
>
|
||||
<.icon name={@icon} class="w-6 h-6" />
|
||||
</.link>
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
end
|
||||
3
lib/wanderer_app_web/components/layouts/app.html.heex
Normal file
3
lib/wanderer_app_web/components/layouts/app.html.heex
Normal file
@@ -0,0 +1,3 @@
|
||||
<main class="bg-gradient-to-r from-stone-950 to-stone-900">
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
51
lib/wanderer_app_web/components/layouts/blog.html.heex
Normal file
51
lib/wanderer_app_web/components/layouts/blog.html.heex
Normal file
@@ -0,0 +1,51 @@
|
||||
<main
|
||||
class="main flex-col !min-h-screen justify-between flex z-0 focus:outline-none transition-all duration-500 opacity-0 phx-page-loading:opacity-0 bg-gradient-to-r from-stone-950 to-stone-900 ccp-font"
|
||||
phx-mounted={JS.remove_class("opacity-0")}
|
||||
>
|
||||
<navbar class="navbar bg-base-100 !sticky top-0 z-50 bg-opacity-0 ">
|
||||
<div class="navbar-start">
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
|
||||
>
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/changelog">Changelog</a></li>
|
||||
<li><a href="/news">News</a></li>
|
||||
<li><a href="/license">License</a></li>
|
||||
<li><a href="/contacts">Contact Us</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-center">
|
||||
<a href="/" class="btn btn-ghost text-xl">Wanderer</a>
|
||||
</div>
|
||||
<div class="navbar-end"></div>
|
||||
</navbar>
|
||||
<div class="!z-10 min-h-[calc(100vh-7rem)]">
|
||||
<%= @inner_content %>
|
||||
</div>
|
||||
<!--Footer-->
|
||||
<footer class="!z-10 w-full pb-4 text-sm text-center fade-in">
|
||||
<a class="text-gray-500 no-underline hover:no-underline" href="#">
|
||||
© Wanderer 2024
|
||||
</a>
|
||||
</footer>
|
||||
<div class="fixed top-0 left-0 w-full h-full !-z-1 maps_bg" />
|
||||
</main>
|
||||
13
lib/wanderer_app_web/components/layouts/landing.html.heex
Normal file
13
lib/wanderer_app_web/components/layouts/landing.html.heex
Normal file
@@ -0,0 +1,13 @@
|
||||
<div class="flex flex-col w-0 flex-1 overflow-hidden">
|
||||
<.flash_group flash={@flash} />
|
||||
<.connection_status>
|
||||
Re-establishing connection...
|
||||
</.connection_status>
|
||||
|
||||
<main
|
||||
class="main flex-1 relative z-0 overflow-hidden focus:outline-none transition-all duration-500 opacity-0 phx-page-loading:opacity-0 bg-gradient-to-r from-stone-950 to-stone-900 maps_bg ccp-font"
|
||||
phx-mounted={JS.remove_class("opacity-0")}
|
||||
>
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
</div>
|
||||
29
lib/wanderer_app_web/components/layouts/live.html.heex
Normal file
29
lib/wanderer_app_web/components/layouts/live.html.heex
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="flex flex-col w-0 flex-1 overflow-hidden bg-gradient-to-r from-stone-950 to-stone-900">
|
||||
<.connection_status>
|
||||
Re-establishing connection...
|
||||
</.connection_status>
|
||||
|
||||
<main
|
||||
class="main flex-1 relative z-0 overflow-hidden focus:outline-none transition-all duration-500 opacity-0 phx-page-loading:opacity-0"
|
||||
phx-mounted={JS.remove_class("opacity-0")}
|
||||
>
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
<aside class="h-full w-14 left-0 absolute bg-gray-400 bg-opacity-5 text-gray-200 shadow-lg border-r border-gray-900 bg-opacity-70 bg-neutral-900">
|
||||
<.sidebar_nav_links
|
||||
active_tab={@active_tab}
|
||||
show_admin={@show_admin}
|
||||
map_subscriptions_enabled={@map_subscriptions_enabled?}
|
||||
/>
|
||||
</aside>
|
||||
<.ping_container rtt_class={@rtt_class} />
|
||||
<.feedback_container />
|
||||
<.new_version_banner app_version={@app_version} />
|
||||
</div>
|
||||
|
||||
<%= live_render(@socket, WandererAppWeb.ServerStatusLive,
|
||||
container: {:div, class: ""},
|
||||
id: "server-status"
|
||||
) %>
|
||||
|
||||
<.live_component module={WandererAppWeb.Alerts} id="notifications" view_flash={@flash} />
|
||||
70
lib/wanderer_app_web/components/layouts/root.html.heex
Normal file
70
lib/wanderer_app_web/components/layouts/root.html.heex
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<.live_title suffix=" · Wanderer">
|
||||
<%= assigns[:page_title] || "Welcome" %>
|
||||
</.live_title>
|
||||
|
||||
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
|
||||
<link
|
||||
href="https://web.ccpgamescdn.com/aws/webfonts/shentox/webfonts-2.0/Shentox-SemiBold.woff2"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
href="https://web.ccpgamescdn.com/aws/webfonts/shentox/webfonts-2.0/Shentox-Medium.woff2"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
href="https://web.ccpgamescdn.com/aws/webfonts/shentox/webfonts-2.0/Shentox-Light.woff2"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
href="https://web.ccpgamescdn.com/aws/webfonts/shentox/webfonts-2.0/Shentox-Regular.woff2"
|
||||
type="font/woff2"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
src="https://unpkg.com/react@18/umd/react.production.min.js"
|
||||
integrity={integrity_hash("https://unpkg.com/react@18/umd/react.production.min.js")}
|
||||
>
|
||||
</script>
|
||||
<script
|
||||
crossorigin="anonymous"
|
||||
src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"
|
||||
integrity={integrity_hash("https://unpkg.com/react-dom@18/umd/react-dom.production.min.js")}
|
||||
>
|
||||
</script>
|
||||
<!-- Appzi: Capture Insightful Feedback -->
|
||||
<script async src="https://w.appzi.io/w.js?token=yddv0">
|
||||
</script>
|
||||
<!-- End Appzi -->
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-61PHLLS0LD"
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
</script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-61PHLLS0LD');
|
||||
</script>
|
||||
<script defer phx-track-static type="module" src={~p"/assets/app.js"} crossorigin="anonymous">
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<%= @inner_content %>
|
||||
</body>
|
||||
</html>
|
||||
203
lib/wanderer_app_web/components/user_activity.ex
Normal file
203
lib/wanderer_app_web/components/user_activity.ex
Normal file
@@ -0,0 +1,203 @@
|
||||
defmodule WandererAppWeb.UserActivity do
|
||||
use WandererAppWeb, :live_component
|
||||
|
||||
attr(:stream, :any, required: true)
|
||||
attr(:page, :integer, required: true)
|
||||
attr(:end_of_stream?, :boolean, required: true)
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<span
|
||||
:if={@page > 1}
|
||||
class="text-1xl fixed bottom-10 right-10 bg-zinc-700 text-white rounded-lg p-1 text-center min-w-[65px] z-50 opacity-70"
|
||||
>
|
||||
<%= @page %>
|
||||
</span>
|
||||
<ul
|
||||
id="events"
|
||||
class="space-y-4"
|
||||
phx-update="stream"
|
||||
phx-viewport-top={@page > 1 && "prev-page"}
|
||||
phx-viewport-bottom={!@end_of_stream? && "next-page"}
|
||||
phx-page-loading
|
||||
class={[
|
||||
if(@end_of_stream?, do: "pb-10", else: "pb-[calc(200vh)]"),
|
||||
if(@page == 1, do: "pt-10", else: "pt-[calc(200vh)]")
|
||||
]}
|
||||
>
|
||||
<li :for={{dom_id, activity} <- @stream} id={dom_id}>
|
||||
<.activity_entry activity={activity} />
|
||||
</li>
|
||||
</ul>
|
||||
<div :if={@end_of_stream?} class="mt-5 text-center">
|
||||
No more activity
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:activity, WandererApp.Api.UserActivity, required: true)
|
||||
|
||||
defp activity_entry(%{} = assigns) do
|
||||
~H"""
|
||||
<div class="flex w-full items-center justify-between space-x-2">
|
||||
<div class="flex items-center space-x-3 text-xs">
|
||||
<p class="flex items-center space-x-1">
|
||||
<span class="w-[150px] line-clamp-1 block text-sm font-normal leading-none text-gray-400 dark:text-gray-500">
|
||||
<.local_time id={@activity.id} at={@activity.inserted_at} />
|
||||
</span>
|
||||
</p>
|
||||
<p class="flex shrink-0 items-center space-x-1 min-w-[200px]">
|
||||
<.character_item character={@activity.character} />
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm leading-[150%] text-[var(--color-gray-4)]">
|
||||
<%= _get_event_name(@activity.event_type) %>
|
||||
</p>
|
||||
<.activity_event event_type={@activity.event_type} event_data={@activity.event_data} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:character, WandererApp.Api.Character, required: true)
|
||||
|
||||
def character_item(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div class="avatar">
|
||||
<div class="rounded-md w-8 h-8">
|
||||
<img src={member_icon_url(@character.eve_id)} alt={@character.name} />
|
||||
</div>
|
||||
</div>
|
||||
<%= @character.name %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:event_type, :string, required: true)
|
||||
attr(:event_data, :string, required: true)
|
||||
|
||||
def activity_event(assigns) do
|
||||
~H"""
|
||||
<div class="w-[40%]">
|
||||
<div class="flex items-center gap-1">
|
||||
<h6 class="text-base leading-[150%] font-semibold dark:text-white">
|
||||
<%= _get_event_data(@event_type, Jason.decode!(@event_data) |> Map.drop(["character_id"])) %>
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp _get_event_name(:hub_added), do: "Hub Added"
|
||||
defp _get_event_name(:hub_removed), do: "Hub Removed"
|
||||
defp _get_event_name(:map_connection_added), do: "Connection Added"
|
||||
defp _get_event_name(:map_connection_updated), do: "Connection Updated"
|
||||
defp _get_event_name(:map_connection_removed), do: "Connection Removed"
|
||||
defp _get_event_name(:map_acl_added), do: "Acl Added"
|
||||
defp _get_event_name(:map_acl_removed), do: "Acl Removed"
|
||||
defp _get_event_name(:system_added), do: "System Added"
|
||||
defp _get_event_name(:system_updated), do: "System Updated"
|
||||
defp _get_event_name(:systems_removed), do: "System(s) Removed"
|
||||
defp _get_event_name(name), do: name
|
||||
|
||||
# defp _get_event_data(:hub_added, data), do: Jason.encode!(data)
|
||||
# defp _get_event_data(:hub_removed, data), do: data
|
||||
|
||||
# defp _get_event_data(:map_acl_added, data), do: data
|
||||
# defp _get_event_data(:map_acl_removed, data), do: data
|
||||
# defp _get_event_data(:system_added, data), do: data
|
||||
#
|
||||
|
||||
defp _get_event_data(:system_updated, %{
|
||||
"key" => "labels",
|
||||
"solar_system_id" => solar_system_id,
|
||||
"value" => value
|
||||
}) do
|
||||
system_name = _get_system_name(solar_system_id)
|
||||
|
||||
try do
|
||||
%{"customLabel" => customLabel, "labels" => labels} = Jason.decode!(value)
|
||||
|
||||
"#{system_name} labels - #{inspect(labels)}, customLabel - #{customLabel}"
|
||||
rescue
|
||||
_ ->
|
||||
"#{system_name} labels - #{inspect(value)}"
|
||||
end
|
||||
end
|
||||
|
||||
defp _get_event_data(:system_added, %{
|
||||
"solar_system_id" => solar_system_id
|
||||
}),
|
||||
do: _get_system_name(solar_system_id)
|
||||
|
||||
defp _get_event_data(:hub_added, %{
|
||||
"solar_system_id" => solar_system_id
|
||||
}),
|
||||
do: _get_system_name(solar_system_id)
|
||||
|
||||
defp _get_event_data(:hub_removed, %{
|
||||
"solar_system_id" => solar_system_id
|
||||
}),
|
||||
do: _get_system_name(solar_system_id)
|
||||
|
||||
defp _get_event_data(:system_updated, %{
|
||||
"key" => key,
|
||||
"solar_system_id" => solar_system_id,
|
||||
"value" => value
|
||||
}) do
|
||||
system_name = _get_system_name(solar_system_id)
|
||||
"#{system_name} #{key} - #{inspect(value)}"
|
||||
end
|
||||
|
||||
defp _get_event_data(:systems_removed, %{
|
||||
"solar_system_ids" => solar_system_ids
|
||||
}),
|
||||
do:
|
||||
solar_system_ids
|
||||
|> Enum.map(&_get_system_name/1)
|
||||
|> Enum.join(", ")
|
||||
|
||||
defp _get_event_data(:map_connection_added, %{
|
||||
"solar_system_source_id" => solar_system_source_id,
|
||||
"solar_system_target_id" => solar_system_target_id
|
||||
}) do
|
||||
source_system_name = _get_system_name(solar_system_source_id)
|
||||
target_system_name = _get_system_name(solar_system_target_id)
|
||||
"[#{source_system_name}:#{target_system_name}]"
|
||||
end
|
||||
|
||||
defp _get_event_data(:map_connection_removed, %{
|
||||
"solar_system_source_id" => solar_system_source_id,
|
||||
"solar_system_target_id" => solar_system_target_id
|
||||
}) do
|
||||
source_system_name = _get_system_name(solar_system_source_id)
|
||||
target_system_name = _get_system_name(solar_system_target_id)
|
||||
"[#{source_system_name}:#{target_system_name}]"
|
||||
end
|
||||
|
||||
defp _get_event_data(:map_connection_updated, %{
|
||||
"key" => key,
|
||||
"solar_system_source_id" => solar_system_source_id,
|
||||
"solar_system_target_id" => solar_system_target_id,
|
||||
"value" => value
|
||||
}) do
|
||||
source_system_name = _get_system_name(solar_system_source_id)
|
||||
target_system_name = _get_system_name(solar_system_target_id)
|
||||
"[#{source_system_name}:#{target_system_name}] #{key} - #{inspect(value)}"
|
||||
end
|
||||
|
||||
defp _get_event_data(_name, data), do: Jason.encode!(data)
|
||||
|
||||
defp _get_system_name(solar_system_id) do
|
||||
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do
|
||||
{:ok, nil} ->
|
||||
solar_system_id
|
||||
|
||||
{:ok, system_static_info} ->
|
||||
Map.get(system_static_info, :solar_system_name, "")
|
||||
|
||||
_ ->
|
||||
""
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user