rename to memex
This commit is contained in:
		
							
								
								
									
										70
									
								
								lib/memex_web/components/invite_card.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								lib/memex_web/components/invite_card.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| defmodule MemexWeb.Components.InviteCard do | ||||
|   @moduledoc """ | ||||
|   Display card for an invite | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :component | ||||
|   alias Memex.Accounts.{Invite, Invites, User} | ||||
|   alias MemexWeb.Endpoint | ||||
|  | ||||
|   attr :invite, Invite, required: true | ||||
|   attr :current_user, User, required: true | ||||
|   slot(:inner_block) | ||||
|   slot(:code_actions) | ||||
|  | ||||
|   def invite_card(%{invite: invite, current_user: current_user} = assigns) do | ||||
|     assigns = | ||||
|       assigns | ||||
|       |> assign(:use_count, Invites.get_use_count(invite, current_user)) | ||||
|       |> assign_new(:code_actions, fn -> [] end) | ||||
|  | ||||
|     ~H""" | ||||
|     <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||
|       border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|       transition-all duration-300 ease-in-out"> | ||||
|       <h1 class="title text-xl"> | ||||
|         <%= @invite.name %> | ||||
|       </h1> | ||||
|  | ||||
|       <%= if @invite.disabled_at |> is_nil() do %> | ||||
|         <h2 class="title text-md"> | ||||
|           <%= if @invite.uses_left do %> | ||||
|             <%= gettext( | ||||
|               "Uses Left: %{uses_left_count}", | ||||
|               uses_left_count: @invite.uses_left | ||||
|             ) %> | ||||
|           <% else %> | ||||
|             <%= gettext("Uses Left: Unlimited") %> | ||||
|           <% end %> | ||||
|         </h2> | ||||
|       <% else %> | ||||
|         <h2 class="title text-md"> | ||||
|           <%= gettext("Invite Disabled") %> | ||||
|         </h2> | ||||
|       <% end %> | ||||
|  | ||||
|       <.qr_code | ||||
|         content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)} | ||||
|         filename={@invite.name} | ||||
|       /> | ||||
|  | ||||
|       <h2 :if={@use_count != 0} class="title text-md"> | ||||
|         <%= gettext("Uses: %{uses_count}", uses_count: @use_count) %> | ||||
|       </h2> | ||||
|  | ||||
|       <div class="flex flex-row flex-wrap justify-center items-center"> | ||||
|         <code | ||||
|           id={"code-#{@invite.id}"} | ||||
|           class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800" | ||||
|           phx-no-format | ||||
|         ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code> | ||||
|         <%= render_slot(@code_actions) %> | ||||
|       </div> | ||||
|  | ||||
|       <div :if={@inner_block} class="flex space-x-4 justify-center items-center"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
							
								
								
									
										111
									
								
								lib/memex_web/components/table_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								lib/memex_web/components/table_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| defmodule MemexWeb.Components.TableComponent do | ||||
|   @moduledoc """ | ||||
|   Livecomponent that presents a resortable table | ||||
|  | ||||
|   It takes the following required assigns: | ||||
|     - `:columns`: An array of maps containing the following keys | ||||
|       - `:label`: A gettext'd or otherwise user-facing string label for the | ||||
|         column. Can be nil | ||||
|       - `:key`: An atom key used for sorting | ||||
|       - `:class`: Extra classes to be applied to the column element, if desired. | ||||
|         Optional | ||||
|       - `:sortable`: If false, will prevent the user from sorting with it. | ||||
|         Optional | ||||
|     - `:values`: An array of maps containing data for each row. Each map is | ||||
|       string-keyed with the associated column key to the following values: | ||||
|       - A single element, like string, integer or Phoenix.LiveView.Rendered | ||||
|         object, like returned from the ~H sigil | ||||
|       - A tuple, containing a custom value used for sorting, and the displayed | ||||
|         content. | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :live_component | ||||
|   alias Phoenix.LiveView.Socket | ||||
|   require Integer | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{ | ||||
|             required(:columns) => | ||||
|               list(%{ | ||||
|                 required(:label) => String.t() | nil, | ||||
|                 required(:key) => atom() | nil, | ||||
|                 optional(:class) => String.t(), | ||||
|                 optional(:row_class) => String.t(), | ||||
|                 optional(:alternate_row_class) => String.t(), | ||||
|                 optional(:sortable) => false | ||||
|               }), | ||||
|             required(:rows) => | ||||
|               list(%{ | ||||
|                 (key :: atom()) => any() | {custom_sort_value :: String.t(), value :: any()} | ||||
|               }), | ||||
|             optional(:inital_key) => atom(), | ||||
|             optional(:initial_sort_mode) => atom(), | ||||
|             optional(any()) => any() | ||||
|           }, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{columns: columns, rows: rows} = assigns, socket) do | ||||
|     initial_key = | ||||
|       if assigns |> Map.has_key?(:initial_key) do | ||||
|         assigns.initial_key | ||||
|       else | ||||
|         columns |> List.first(%{}) |> Map.get(:key) | ||||
|       end | ||||
|  | ||||
|     initial_sort_mode = | ||||
|       if assigns |> Map.has_key?(:initial_sort_mode) do | ||||
|         assigns.initial_sort_mode | ||||
|       else | ||||
|         :asc | ||||
|       end | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode) | ||||
|  | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign(assigns) | ||||
|       |> assign( | ||||
|         columns: columns, | ||||
|         rows: rows, | ||||
|         last_sort_key: initial_key, | ||||
|         sort_mode: initial_sort_mode | ||||
|       ) | ||||
|       |> assign_new(:row_class, fn -> "bg-white" end) | ||||
|       |> assign_new(:alternate_row_class, fn -> "bg-gray-200" end) | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "sort_by", | ||||
|         %{"sort-key" => key}, | ||||
|         %{assigns: %{rows: rows, last_sort_key: last_sort_key, sort_mode: sort_mode}} = socket | ||||
|       ) do | ||||
|     key = key |> String.to_existing_atom() | ||||
|  | ||||
|     sort_mode = | ||||
|       case {key, sort_mode} do | ||||
|         {^last_sort_key, :asc} -> :desc | ||||
|         {^last_sort_key, :desc} -> :asc | ||||
|         {_new_sort_key, _last_sort_mode} -> :asc | ||||
|       end | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode) | ||||
|     {:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)} | ||||
|   end | ||||
|  | ||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do | ||||
|     rows | ||||
|     |> Enum.sort_by( | ||||
|       fn row -> | ||||
|         case row |> Map.get(key) do | ||||
|           {custom_sort_key, _value} -> custom_sort_key | ||||
|           value -> value | ||||
|         end | ||||
|       end, | ||||
|       sort_mode | ||||
|     ) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										52
									
								
								lib/memex_web/components/table_component.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								lib/memex_web/components/table_component.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| <div id={@id} class="w-full overflow-x-auto border border-gray-600 rounded-lg shadow-lg bg-white"> | ||||
|   <table class="min-w-full table-auto text-center bg-white"> | ||||
|     <thead class="border-b border-primary-600"> | ||||
|       <tr> | ||||
|         <%= for %{key: key, label: label} = column <- @columns do %> | ||||
|           <%= if column |> Map.get(:sortable, true) do %> | ||||
|             <th class={["p-2", column[:class]]}> | ||||
|               <span | ||||
|                 class="cursor-pointer flex justify-center items-center space-x-2" | ||||
|                 phx-click="sort_by" | ||||
|                 phx-value-sort-key={key} | ||||
|                 phx-target={@myself} | ||||
|               > | ||||
|                 <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> | ||||
|                 <span class={if @last_sort_key == key, do: "underline"}><%= label %></span> | ||||
|                 <%= if @last_sort_key == key do %> | ||||
|                   <%= case @sort_mode do %> | ||||
|                     <% :asc -> %> | ||||
|                       <i class="w-0 float-right fas fa-sm fa-chevron-down"></i> | ||||
|                     <% :desc -> %> | ||||
|                       <i class="w-0 float-right fas fa-sm fa-chevron-up"></i> | ||||
|                   <% end %> | ||||
|                 <% else %> | ||||
|                   <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> | ||||
|                 <% end %> | ||||
|               </span> | ||||
|             </th> | ||||
|           <% else %> | ||||
|             <th class={["p-2 cursor-not-allowed", column[:class]]}> | ||||
|               <%= label %> | ||||
|             </th> | ||||
|           <% end %> | ||||
|         <% end %> | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <tr | ||||
|         :for={{values, i} <- @rows |> Enum.with_index()} | ||||
|         class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class} | ||||
|       > | ||||
|         <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}> | ||||
|           <%= case values |> Map.get(key) do %> | ||||
|             <% {_custom_sort_value, value} -> %> | ||||
|               <%= value %> | ||||
|             <% value -> %> | ||||
|               <%= value %> | ||||
|           <% end %> | ||||
|         </td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </div> | ||||
							
								
								
									
										100
									
								
								lib/memex_web/components/topbar.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								lib/memex_web/components/topbar.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| defmodule MemexWeb.Components.Topbar do | ||||
|   @moduledoc """ | ||||
|   Component that renders a topbar with user functions/links | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :component | ||||
|  | ||||
|   alias Memex.Accounts | ||||
|   alias MemexWeb.{Endpoint, HomeLive} | ||||
|  | ||||
|   def topbar(assigns) do | ||||
|     assigns = | ||||
|       %{results: [], title_content: nil, flash: nil, current_user: nil} |> Map.merge(assigns) | ||||
|  | ||||
|     ~H""" | ||||
|     <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-400"> | ||||
|       <div class="flex flex-col sm:flex-row justify-between items-center"> | ||||
|         <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> | ||||
|           <.link | ||||
|             navigate={Routes.live_path(Endpoint, HomeLive)} | ||||
|             class="mx-2 my-1 leading-5 text-xl text-white hover:underline" | ||||
|           > | ||||
|             <%= gettext("Memex") %> | ||||
|           </.link> | ||||
|  | ||||
|           <%= if @title_content do %> | ||||
|             <span class="mx-2 my-1"> | ||||
|               | | ||||
|             </span> | ||||
|             <%= @title_content %> | ||||
|           <% end %> | ||||
|         </div> | ||||
|  | ||||
|         <hr class="mb-2 sm:hidden hr-light" /> | ||||
|  | ||||
|         <ul class="flex flex-row flex-wrap justify-center items-center | ||||
|           text-lg text-white text-ellipsis"> | ||||
|           <%= if @current_user do %> | ||||
|             <li :if={@current_user.role == :admin} class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.invite_index_path(Endpoint, :index)} | ||||
|                 class="text-white text-white hover:underline" | ||||
|               > | ||||
|                 <%= gettext("Invites") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 href={Routes.user_settings_path(Endpoint, :edit)} | ||||
|                 class="text-white text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= @current_user.email %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 href={Routes.user_session_path(Endpoint, :delete)} | ||||
|                 method="delete" | ||||
|                 data-confirm={dgettext("prompts", "Are you sure you want to log out?")} | ||||
|               > | ||||
|                 <i class="fas fa-sign-out-alt"></i> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li | ||||
|               :if={ | ||||
|                 @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) | ||||
|               } | ||||
|               class="mx-2 my-1" | ||||
|             > | ||||
|               <.link | ||||
|                 navigate={Routes.live_dashboard_path(Endpoint, :home)} | ||||
|                 class="text-white text-white hover:underline" | ||||
|               > | ||||
|                 <i class="fas fa-gauge"></i> | ||||
|               </.link> | ||||
|             </li> | ||||
|           <% else %> | ||||
|             <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 href={Routes.user_registration_path(Endpoint, :new)} | ||||
|                 class="text-white text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= dgettext("actions", "Register") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 href={Routes.user_session_path(Endpoint, :new)} | ||||
|                 class="text-white text-white hover:underline truncate" | ||||
|               > | ||||
|                 <%= dgettext("actions", "Log in") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|           <% end %> | ||||
|         </ul> | ||||
|       </div> | ||||
|     </nav> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
							
								
								
									
										52
									
								
								lib/memex_web/components/user_card.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								lib/memex_web/components/user_card.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| defmodule MemexWeb.Components.UserCard do | ||||
|   @moduledoc """ | ||||
|   Display card for a user | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :component | ||||
|   alias Memex.Accounts.User | ||||
|  | ||||
|   attr :user, User, required: true | ||||
|   slot(:inner_block, required: true) | ||||
|  | ||||
|   def user_card(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"user-#{@user.id}"} | ||||
|       class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center | ||||
|         border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|         transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> | ||||
|         <%= @user.email %> | ||||
|       </h1> | ||||
|  | ||||
|       <h3 class="px-4 py-2 rounded-lg title text-lg"> | ||||
|         <p> | ||||
|           <%= if @user.confirmed_at do %> | ||||
|             <%= gettext( | ||||
|               "User was confirmed at%{confirmed_datetime}", | ||||
|               confirmed_datetime: "" | ||||
|             ) %> | ||||
|             <.datetime datetime={@user.confirmed_at} /> | ||||
|           <% else %> | ||||
|             <%= gettext("Email unconfirmed") %> | ||||
|           <% end %> | ||||
|         </p> | ||||
|  | ||||
|         <p> | ||||
|           <%= gettext( | ||||
|             "User registered on%{registered_datetime}", | ||||
|             registered_datetime: "" | ||||
|           ) %> | ||||
|           <.datetime datetime={@user.inserted_at} /> | ||||
|         </p> | ||||
|       </h3> | ||||
|  | ||||
|       <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||
|         <%= render_slot(@inner_block) %> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
							
								
								
									
										23
									
								
								lib/memex_web/controllers/email_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								lib/memex_web/controllers/email_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| defmodule MemexWeb.EmailController do | ||||
|   @moduledoc """ | ||||
|   A dev controller used to develop on emails | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :controller | ||||
|   alias Memex.Accounts.User | ||||
|  | ||||
|   plug :put_layout, {MemexWeb.LayoutView, :email} | ||||
|  | ||||
|   @sample_assigns %{ | ||||
|     email: %{subject: "Example subject"}, | ||||
|     url: "https://memex.bubbletea.dev/sample_url", | ||||
|     user: %User{email: "sample@email.com"} | ||||
|   } | ||||
|  | ||||
|   @doc """ | ||||
|   Debug route used to preview emails | ||||
|   """ | ||||
|   def preview(conn, %{"id" => template}) do | ||||
|     render(conn, "#{template |> to_string()}.html", @sample_assigns) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										11
									
								
								lib/memex_web/controllers/home_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								lib/memex_web/controllers/home_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| defmodule MemexWeb.HomeController do | ||||
|   @moduledoc """ | ||||
|   Controller for home page | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :controller | ||||
|  | ||||
|   def index(conn, _params) do | ||||
|     render(conn, "index.html") | ||||
|   end | ||||
| end | ||||
							
								
								
									
										191
									
								
								lib/memex_web/controllers/user_auth.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								lib/memex_web/controllers/user_auth.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,191 @@ | ||||
| defmodule MemexWeb.UserAuth do | ||||
|   @moduledoc """ | ||||
|   Functions for user session and authentication | ||||
|   """ | ||||
|  | ||||
|   import Plug.Conn | ||||
|   import Phoenix.Controller | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.{Accounts, Accounts.User} | ||||
|   alias MemexWeb.HomeLive | ||||
|   alias MemexWeb.Router.Helpers, as: Routes | ||||
|  | ||||
|   # Make the remember me cookie valid for 60 days. | ||||
|   # If you want bump or reduce this value, also change | ||||
|   # the token expiry itself in UserToken. | ||||
|   @max_age 60 * 60 * 24 * 60 | ||||
|   @remember_me_cookie "_memex_web_user_remember_me" | ||||
|   @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] | ||||
|  | ||||
|   @doc """ | ||||
|   Logs the user in. | ||||
|  | ||||
|   It renews the session ID and clears the whole session | ||||
|   to avoid fixation attacks. See the renew_session | ||||
|   function to customize this behaviour. | ||||
|  | ||||
|   It also sets a `:live_socket_id` key in the session, | ||||
|   so LiveView sessions are identified and automatically | ||||
|   disconnected on log out. The line can be safely removed | ||||
|   if you are not using LiveView. | ||||
|   """ | ||||
|   def log_in_user(conn, user, params \\ %{}) | ||||
|  | ||||
|   def log_in_user(conn, %User{confirmed_at: nil}, _params) do | ||||
|     conn | ||||
|     |> fetch_flash() | ||||
|     |> put_flash( | ||||
|       :error, | ||||
|       dgettext("errors", "You must confirm your account and log in to access this page.") | ||||
|     ) | ||||
|     |> maybe_store_return_to() | ||||
|     |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|     |> halt() | ||||
|   end | ||||
|  | ||||
|   def log_in_user(conn, user, params) do | ||||
|     token = Accounts.generate_user_session_token(user) | ||||
|     user_return_to = get_session(conn, :user_return_to) | ||||
|  | ||||
|     conn | ||||
|     |> renew_session() | ||||
|     |> put_session(:user_token, token) | ||||
|     |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") | ||||
|     |> maybe_write_remember_me_cookie(token, params) | ||||
|     |> redirect(to: user_return_to || signed_in_path(conn)) | ||||
|   end | ||||
|  | ||||
|   @spec maybe_write_remember_me_cookie( | ||||
|           Plug.Conn.t(), | ||||
|           String.t() | any(), | ||||
|           %{required(String.t()) => String.t()} | any() | ||||
|         ) :: Plug.Conn.t() | ||||
|   defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do | ||||
|     put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) | ||||
|   end | ||||
|  | ||||
|   defp maybe_write_remember_me_cookie(conn, _token, _params) do | ||||
|     conn | ||||
|   end | ||||
|  | ||||
|   # This function renews the session ID and erases the whole | ||||
|   # session to avoid fixation attacks. If there is any data | ||||
|   # in the session you may want to preserve after log in/log out, | ||||
|   # you must explicitly fetch the session data before clearing | ||||
|   # and then immediately set it after clearing, for example: | ||||
|   # | ||||
|   #     defp renew_session(conn) do | ||||
|   #       preferred_locale = get_session(conn, :preferred_locale) | ||||
|   # | ||||
|   #       conn | ||||
|   #       |> configure_session(renew: true) | ||||
|   #       |> clear_session() | ||||
|   #       |> put_session(:preferred_locale, preferred_locale) | ||||
|   #     end | ||||
|   # | ||||
|   defp renew_session(conn) do | ||||
|     conn | ||||
|     |> configure_session(renew: true) | ||||
|     |> clear_session() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Logs the user out. | ||||
|  | ||||
|   It clears all session data for safety. See renew_session. | ||||
|   """ | ||||
|   def log_out_user(conn) do | ||||
|     user_token = get_session(conn, :user_token) | ||||
|     user_token && Accounts.delete_session_token(user_token) | ||||
|  | ||||
|     if live_socket_id = get_session(conn, :live_socket_id) do | ||||
|       MemexWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) | ||||
|     end | ||||
|  | ||||
|     conn | ||||
|     |> renew_session() | ||||
|     |> delete_resp_cookie(@remember_me_cookie) | ||||
|     |> redirect(to: "/") | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Authenticates the user by looking into the session | ||||
|   and remember me token. | ||||
|   """ | ||||
|   def fetch_current_user(conn, _opts) do | ||||
|     {user_token, conn} = ensure_user_token(conn) | ||||
|     user = user_token && Accounts.get_user_by_session_token(user_token) | ||||
|     assign(conn, :current_user, user) | ||||
|   end | ||||
|  | ||||
|   defp ensure_user_token(conn) do | ||||
|     if user_token = get_session(conn, :user_token) do | ||||
|       {user_token, conn} | ||||
|     else | ||||
|       conn = fetch_cookies(conn, signed: [@remember_me_cookie]) | ||||
|  | ||||
|       if user_token = conn.cookies[@remember_me_cookie] do | ||||
|         {user_token, put_session(conn, :user_token, user_token)} | ||||
|       else | ||||
|         {nil, conn} | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Used for routes that require the user to not be authenticated. | ||||
|   """ | ||||
|   def redirect_if_user_is_authenticated(conn, _opts) do | ||||
|     if conn.assigns[:current_user] do | ||||
|       conn | ||||
|       |> redirect(to: signed_in_path(conn)) | ||||
|       |> halt() | ||||
|     else | ||||
|       conn | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Used for routes that require the user to be authenticated. | ||||
|  | ||||
|   If you want to enforce the user email is confirmed before | ||||
|   they use the application at all, here would be a good place. | ||||
|   """ | ||||
|   def require_authenticated_user(conn, _opts) do | ||||
|     if conn.assigns[:current_user] do | ||||
|       conn | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash( | ||||
|         :error, | ||||
|         dgettext("errors", "You must confirm your account and log in to access this page.") | ||||
|       ) | ||||
|       |> maybe_store_return_to() | ||||
|       |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Used for routes that require the user to be an admin. | ||||
|   """ | ||||
|   def require_role(conn, role: role_atom) do | ||||
|     if conn.assigns[:current_user] && conn.assigns.current_user.role == role_atom do | ||||
|       conn | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "You are not authorized to view this page.")) | ||||
|       |> maybe_store_return_to() | ||||
|       |> redirect(to: Routes.live_path(conn, HomeLive)) | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp maybe_store_return_to(%{method: "GET"} = conn) do | ||||
|     put_session(conn, :user_return_to, current_path(conn)) | ||||
|   end | ||||
|  | ||||
|   defp maybe_store_return_to(conn), do: conn | ||||
|  | ||||
|   defp signed_in_path(_conn), do: "/" | ||||
| end | ||||
							
								
								
									
										60
									
								
								lib/memex_web/controllers/user_confirmation_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								lib/memex_web/controllers/user_confirmation_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| defmodule MemexWeb.UserConfirmationController do | ||||
|   use MemexWeb, :controller | ||||
|  | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.Accounts | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", page_title: gettext("Confirm your account")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"email" => email}}) do | ||||
|     if user = Accounts.get_user_by_email(email) do | ||||
|       Accounts.deliver_user_confirmation_instructions( | ||||
|         user, | ||||
|         &Routes.user_confirmation_url(conn, :confirm, &1) | ||||
|       ) | ||||
|     end | ||||
|  | ||||
|     # Regardless of the outcome, show an impartial success/error message. | ||||
|     conn | ||||
|     |> put_flash( | ||||
|       :info, | ||||
|       dgettext( | ||||
|         "prompts", | ||||
|         "If your email is in our system and it has not been confirmed yet, " <> | ||||
|           "you will receive an email with instructions shortly." | ||||
|       ) | ||||
|     ) | ||||
|     |> redirect(to: "/") | ||||
|   end | ||||
|  | ||||
|   # Do not log in the user after confirmation to avoid a | ||||
|   # leaked token giving the user access to the account. | ||||
|   def confirm(conn, %{"token" => token}) do | ||||
|     case Accounts.confirm_user(token) do | ||||
|       {:ok, %{email: email}} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email)) | ||||
|         |> redirect(to: "/") | ||||
|  | ||||
|       :error -> | ||||
|         # If there is a current user and the account was already confirmed, | ||||
|         # then odds are that the confirmation link was already visited, either | ||||
|         # by some automation or by the user themselves, so we redirect without | ||||
|         # a warning message. | ||||
|         case conn.assigns do | ||||
|           %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> | ||||
|             redirect(conn, to: "/") | ||||
|  | ||||
|           %{} -> | ||||
|             conn | ||||
|             |> put_flash( | ||||
|               :error, | ||||
|               dgettext("errors", "User confirmation link is invalid or it has expired.") | ||||
|             ) | ||||
|             |> redirect(to: "/") | ||||
|         end | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										77
									
								
								lib/memex_web/controllers/user_registration_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								lib/memex_web/controllers/user_registration_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| defmodule MemexWeb.UserRegistrationController do | ||||
|   use MemexWeb, :controller | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.{Accounts, Accounts.Invites} | ||||
|   alias MemexWeb.{Endpoint, HomeLive} | ||||
|  | ||||
|   def new(conn, %{"invite" => invite_token}) do | ||||
|     if Invites.valid_invite_token?(invite_token) do | ||||
|       conn |> render_new(invite_token) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     if Accounts.allow_registration?() do | ||||
|       conn |> render_new() | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   # renders new user registration page | ||||
|   defp render_new(conn, invite_token \\ nil) do | ||||
|     render(conn, "new.html", | ||||
|       changeset: Accounts.change_user_registration(), | ||||
|       invite_token: invite_token, | ||||
|       page_title: gettext("Register") | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do | ||||
|     if Invites.valid_invite_token?(invite_token) do | ||||
|       conn |> create_user(attrs, invite_token) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def create(conn, attrs) do | ||||
|     if Accounts.allow_registration?() do | ||||
|       conn |> create_user(attrs) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do | ||||
|     case Accounts.register_user(user_params, invite_token) do | ||||
|       {:ok, user} -> | ||||
|         Accounts.deliver_user_confirmation_instructions( | ||||
|           user, | ||||
|           &Routes.user_confirmation_url(conn, :confirm, &1) | ||||
|         ) | ||||
|  | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) | ||||
|         |> redirect(to: Routes.user_session_path(Endpoint, :new)) | ||||
|  | ||||
|       {:error, :invalid_token} -> | ||||
|         conn | ||||
|         |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|         |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|  | ||||
|       {:error, %Ecto.Changeset{} = changeset} -> | ||||
|         conn |> render("new.html", changeset: changeset, invite_token: invite_token) | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										69
									
								
								lib/memex_web/controllers/user_reset_password_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								lib/memex_web/controllers/user_reset_password_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| defmodule MemexWeb.UserResetPasswordController do | ||||
|   use MemexWeb, :controller | ||||
|  | ||||
|   alias Memex.Accounts | ||||
|  | ||||
|   plug :get_user_by_reset_password_token when action in [:edit, :update] | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", page_title: gettext("Forgot your password?")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"email" => email}}) do | ||||
|     if user = Accounts.get_user_by_email(email) do | ||||
|       Accounts.deliver_user_reset_password_instructions( | ||||
|         user, | ||||
|         &Routes.user_reset_password_url(conn, :edit, &1) | ||||
|       ) | ||||
|     end | ||||
|  | ||||
|     # Regardless of the outcome, show an impartial success/error message. | ||||
|     conn | ||||
|     |> put_flash( | ||||
|       :info, | ||||
|       dgettext( | ||||
|         "prompts", | ||||
|         "If your email is in our system, you will receive instructions to " <> | ||||
|           "reset your password shortly." | ||||
|       ) | ||||
|     ) | ||||
|     |> redirect(to: "/") | ||||
|   end | ||||
|  | ||||
|   def edit(conn, _params) do | ||||
|     render(conn, "edit.html", | ||||
|       changeset: Accounts.change_user_password(conn.assigns.user), | ||||
|       page_title: gettext("Reset your password") | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   # Do not log in the user after reset password to avoid a | ||||
|   # leaked token giving the user access to the account. | ||||
|   def update(conn, %{"user" => user_params}) do | ||||
|     case Accounts.reset_user_password(conn.assigns.user, user_params) do | ||||
|       {:ok, _} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Password reset successfully.")) | ||||
|         |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         render(conn, "edit.html", changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp get_user_by_reset_password_token(conn, _opts) do | ||||
|     %{"token" => token} = conn.params | ||||
|  | ||||
|     if user = Accounts.get_user_by_reset_password_token(token) do | ||||
|       conn |> assign(:user, user) |> assign(:token, token) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash( | ||||
|         :error, | ||||
|         dgettext("errors", "Reset password link is invalid or it has expired.") | ||||
|       ) | ||||
|       |> redirect(to: "/") | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										26
									
								
								lib/memex_web/controllers/user_session_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								lib/memex_web/controllers/user_session_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| defmodule MemexWeb.UserSessionController do | ||||
|   use MemexWeb, :controller | ||||
|  | ||||
|   alias Memex.Accounts | ||||
|   alias MemexWeb.UserAuth | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", error_message: nil, page_title: gettext("Log in")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => user_params}) do | ||||
|     %{"email" => email, "password" => password} = user_params | ||||
|  | ||||
|     if user = Accounts.get_user_by_email_and_password(email, password) do | ||||
|       UserAuth.log_in_user(conn, user, user_params) | ||||
|     else | ||||
|       render(conn, "new.html", error_message: dgettext("errors", "Invalid email or password")) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def delete(conn, _params) do | ||||
|     conn | ||||
|     |> put_flash(:info, dgettext("prompts", "Logged out successfully.")) | ||||
|     |> UserAuth.log_out_user() | ||||
|   end | ||||
| end | ||||
							
								
								
									
										110
									
								
								lib/memex_web/controllers/user_settings_controller.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								lib/memex_web/controllers/user_settings_controller.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| defmodule MemexWeb.UserSettingsController do | ||||
|   use MemexWeb, :controller | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.Accounts | ||||
|   alias MemexWeb.{HomeLive, UserAuth} | ||||
|  | ||||
|   plug :assign_email_and_password_changesets | ||||
|  | ||||
|   def edit(conn, _params) do | ||||
|     render(conn, "edit.html", page_title: gettext("Settings")) | ||||
|   end | ||||
|  | ||||
|   def update(%{assigns: %{current_user: user}} = conn, %{ | ||||
|         "action" => "update_email", | ||||
|         "current_password" => password, | ||||
|         "user" => user_params | ||||
|       }) do | ||||
|     case Accounts.apply_user_email(user, password, user_params) do | ||||
|       {:ok, applied_user} -> | ||||
|         Accounts.deliver_update_email_instructions( | ||||
|           applied_user, | ||||
|           user.email, | ||||
|           &Routes.user_settings_url(conn, :confirm_email, &1) | ||||
|         ) | ||||
|  | ||||
|         conn | ||||
|         |> put_flash( | ||||
|           :info, | ||||
|           dgettext( | ||||
|             "prompts", | ||||
|             "A link to confirm your email change has been sent to the new address." | ||||
|           ) | ||||
|         ) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", email_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def update(%{assigns: %{current_user: user}} = conn, %{ | ||||
|         "action" => "update_password", | ||||
|         "current_password" => password, | ||||
|         "user" => user_params | ||||
|       }) do | ||||
|     case Accounts.update_user_password(user, password, user_params) do | ||||
|       {:ok, user} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Password updated successfully.")) | ||||
|         |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) | ||||
|         |> UserAuth.log_in_user(user) | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", password_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def update( | ||||
|         %{assigns: %{current_user: user}} = conn, | ||||
|         %{"action" => "update_locale", "user" => %{"locale" => locale}} | ||||
|       ) do | ||||
|     case Accounts.update_user_locale(user, locale) do | ||||
|       {:ok, _user} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Language updated successfully.")) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", locale_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def confirm_email(%{assigns: %{current_user: user}} = conn, %{"token" => token}) do | ||||
|     case Accounts.update_user_email(user, token) do | ||||
|       :ok -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Email changed successfully.")) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|  | ||||
|       :error -> | ||||
|         conn | ||||
|         |> put_flash( | ||||
|           :error, | ||||
|           dgettext("errors", "Email change link is invalid or it has expired.") | ||||
|         ) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def delete(%{assigns: %{current_user: current_user}} = conn, %{"id" => user_id}) do | ||||
|     if user_id == current_user.id do | ||||
|       current_user |> Accounts.delete_user!(current_user) | ||||
|  | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("prompts", "Your account has been deleted")) | ||||
|       |> redirect(to: Routes.live_path(conn, HomeLive)) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Unable to delete user")) | ||||
|       |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp assign_email_and_password_changesets(%{assigns: %{current_user: user}} = conn, _opts) do | ||||
|     conn | ||||
|     |> assign(:email_changeset, Accounts.change_user_email(user)) | ||||
|     |> assign(:password_changeset, Accounts.change_user_password(user)) | ||||
|     |> assign(:locale_changeset, Accounts.change_user_locale(user)) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										50
									
								
								lib/memex_web/endpoint.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/memex_web/endpoint.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| defmodule MemexWeb.Endpoint do | ||||
|   use Phoenix.Endpoint, otp_app: :memex | ||||
|  | ||||
|   # The session will be stored in the cookie and signed, | ||||
|   # this means its contents can be read but not tampered with. | ||||
|   # Set :encryption_salt if you would also like to encrypt it. | ||||
|   @session_options [ | ||||
|     store: :cookie, | ||||
|     key: "_memex_key", | ||||
|     signing_salt: "fxAnJltS" | ||||
|   ] | ||||
|  | ||||
|   socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] | ||||
|  | ||||
|   # Serve at "/" the static files from "priv/static" directory. | ||||
|   # | ||||
|   # You should set gzip to true if you are running phx.digest | ||||
|   # when deploying your static files in production. | ||||
|   plug Plug.Static, | ||||
|     at: "/", | ||||
|     from: :memex, | ||||
|     gzip: false, | ||||
|     only: ~w(css fonts images js favicon.ico robots.txt) | ||||
|  | ||||
|   # Code reloading can be explicitly enabled under the | ||||
|   # :code_reloader configuration of your endpoint. | ||||
|   if code_reloading? do | ||||
|     socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket | ||||
|     plug Phoenix.LiveReloader | ||||
|     plug Phoenix.CodeReloader | ||||
|     plug Phoenix.Ecto.CheckRepoStatus, otp_app: :memex | ||||
|   end | ||||
|  | ||||
|   plug Phoenix.LiveDashboard.RequestLogger, | ||||
|     param_key: "request_logger", | ||||
|     cookie_key: "request_logger" | ||||
|  | ||||
|   plug Plug.RequestId | ||||
|   plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] | ||||
|  | ||||
|   plug Plug.Parsers, | ||||
|     parsers: [:urlencoded, :multipart, :json], | ||||
|     pass: ["*/*"], | ||||
|     json_decoder: Phoenix.json_library() | ||||
|  | ||||
|   plug Plug.MethodOverride | ||||
|   plug Plug.Head | ||||
|   plug Plug.Session, @session_options | ||||
|   plug MemexWeb.Router | ||||
| end | ||||
							
								
								
									
										24
									
								
								lib/memex_web/gettext.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lib/memex_web/gettext.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| defmodule MemexWeb.Gettext do | ||||
|   @moduledoc """ | ||||
|   A module providing Internationalization with a gettext-based API. | ||||
|  | ||||
|   By using [Gettext](https://hexdocs.pm/gettext), | ||||
|   your module gains a set of macros for translations, for example: | ||||
|  | ||||
|       import MemexWeb.Gettext | ||||
|  | ||||
|       # Simple translation | ||||
|       gettext("Here is the string to translate") | ||||
|  | ||||
|       # Plural translation | ||||
|       ngettext("Here is the string to translate", | ||||
|                "Here are the strings to translate", | ||||
|                3) | ||||
|  | ||||
|       # Domain-based translation | ||||
|       dgettext("errors", "Here is the error message to translate") | ||||
|  | ||||
|   See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. | ||||
|   """ | ||||
|   use Gettext, otp_app: :memex | ||||
| end | ||||
							
								
								
									
										18
									
								
								lib/memex_web/live/home_live.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								lib/memex_web/live/home_live.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| defmodule MemexWeb.HomeLive do | ||||
|   @moduledoc """ | ||||
|   Liveview for the home page | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :live_view | ||||
|   alias Memex.Accounts | ||||
|   alias MemexWeb.Endpoint | ||||
|  | ||||
|   @version Mix.Project.config()[:version] | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, socket) do | ||||
|     admins = Accounts.list_users_by_role(:admin) | ||||
|     socket = socket |> assign(page_title: gettext("Home"), admins: admins, version: @version) | ||||
|     {:ok, socket} | ||||
|   end | ||||
| end | ||||
							
								
								
									
										135
									
								
								lib/memex_web/live/home_live.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								lib/memex_web/live/home_live.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,135 @@ | ||||
| <div class="mx-auto px-8 sm:px-16 flex flex-col justify-center items-center text-center space-y-4 max-w-3xl"> | ||||
|   <h1 class="title text-primary-600 text-2xl"> | ||||
|     <%= gettext("Welcome to Memex") %> | ||||
|   </h1> | ||||
|  | ||||
|   <h2 class="title text-primary-600 text-lg"> | ||||
|     <%= gettext("Shop from your local community") %> | ||||
|   </h2> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <ul class="flex flex-col space-y-4 text-center"> | ||||
|     <li class="flex flex-col justify-center items-center | ||||
|       space-y-2"> | ||||
|       <b class="whitespace-nowrap"> | ||||
|         <%= gettext("Easy to Use:") %> | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= gettext("Memex lets you easily shop from your local community") %> | ||||
|       </p> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center items-center space-y-2"> | ||||
|       <b class="whitespace-nowrap"> | ||||
|         <%= gettext("Secure:") %> | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= gettext("Self-host your own instance, or use an instance from someone you trust.") %> | ||||
|         <%= gettext("Your data stays with you, period") %> | ||||
|       </p> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center items-center | ||||
|       space-y-2"> | ||||
|       <b class="whitespace-nowrap"> | ||||
|         <%= gettext("Simple:") %> | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= gettext("Access from any internet-capable device") %> | ||||
|       </p> | ||||
|     </li> | ||||
|   </ul> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <ul class="flex flex-col space-y-2 text-center justify-center"> | ||||
|     <h2 class="title text-primary-600 text-lg"> | ||||
|       <%= gettext("Instance Information") %> | ||||
|     </h2> | ||||
|  | ||||
|     <li class="flex flex-col justify-center space-x-2"> | ||||
|       <b> | ||||
|         <%= gettext("Admins:") %> | ||||
|       </b> | ||||
|       <p> | ||||
|         <%= if @admins |> Enum.empty?() do %> | ||||
|           <.link href={Routes.user_registration_path(Endpoint, :new)} class="hover:underline"> | ||||
|             <%= dgettext("prompts", "Register to setup Memex") %> | ||||
|           </.link> | ||||
|         <% else %> | ||||
|           <div class="flex flex-wrap justify-center space-x-2"> | ||||
|             <a :for={%{email: email} <- @admins} class="hover:underline" href={"mailto:#{email}"}> | ||||
|               <%= email %> | ||||
|             </a> | ||||
|           </div> | ||||
|         <% end %> | ||||
|       </p> | ||||
|     </li> | ||||
|  | ||||
|     <li class="flex flex-row justify-center space-x-2"> | ||||
|       <b><%= gettext("Registration:") %></b> | ||||
|       <p> | ||||
|         <%= case Application.get_env(:memex, Memex.Accounts)[:registration] do | ||||
|           "public" -> gettext("Public Signups") | ||||
|           _ -> gettext("Invite Only") | ||||
|         end %> | ||||
|       </p> | ||||
|     </li> | ||||
|  | ||||
|     <li class="flex flex-row justify-center items-center space-x-2"> | ||||
|       <b><%= gettext("Version:") %></b> | ||||
|       <.link | ||||
|         href="https://gitea.bubbletea.dev/shibao/memex/src/branch/stable/CHANGELOG.md" | ||||
|         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <p> | ||||
|           <%= @version %> | ||||
|         </p> | ||||
|         <i class="fas fa-md fa-info-circle"></i> | ||||
|       </.link> | ||||
|     </li> | ||||
|   </ul> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <ul class="flex flex-col space-y-2 text-center justify-center"> | ||||
|     <h2 class="title text-primary-600 text-lg"> | ||||
|       <%= gettext("Get involved!") %> | ||||
|     </h2> | ||||
|  | ||||
|     <li class="flex flex-col justify-center space-x-2"> | ||||
|       <.link | ||||
|         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||
|         href="https://gitea.bubbletea.dev/shibao/memex" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <p><%= gettext("View the source code") %></p> | ||||
|         <i class="fas fa-md fa-code"></i> | ||||
|       </.link> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center space-x-2"> | ||||
|       <.link | ||||
|         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||
|         href="https://weblate.bubbletea.dev/engage/memex" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <p><%= gettext("Help translate") %></p> | ||||
|         <i class="fas fa-md fa-language"></i> | ||||
|       </.link> | ||||
|     </li> | ||||
|     <li class="flex flex-col justify-center space-x-2"> | ||||
|       <.link | ||||
|         class="flex flex-row justify-center items-center space-x-2 hover:underline" | ||||
|         href="https://gitea.bubbletea.dev/shibao/memex/issues/new" | ||||
|         target="_blank" | ||||
|         rel="noopener noreferrer" | ||||
|       > | ||||
|         <p><%= gettext("Report bugs or request features") %></p> | ||||
|         <i class="fas fa-md fa-spider"></i> | ||||
|       </.link> | ||||
|     </li> | ||||
|   </ul> | ||||
| </div> | ||||
							
								
								
									
										19
									
								
								lib/memex_web/live/init_assigns.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/memex_web/live/init_assigns.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| defmodule MemexWeb.InitAssigns do | ||||
|   @moduledoc """ | ||||
|   Ensures common `assigns` are applied to all LiveViews attaching this hook. | ||||
|   """ | ||||
|   import Phoenix.Component | ||||
|   alias Memex.Accounts | ||||
|  | ||||
|   def on_mount(:default, _params, %{"locale" => locale, "user_token" => user_token}, socket) do | ||||
|     Gettext.put_locale(locale) | ||||
|  | ||||
|     socket = | ||||
|       socket | ||||
|       |> assign_new(:current_user, fn -> Accounts.get_user_by_session_token(user_token) end) | ||||
|  | ||||
|     {:cont, socket} | ||||
|   end | ||||
|  | ||||
|   def on_mount(:default, _params, _session, socket), do: {:cont, socket} | ||||
| end | ||||
							
								
								
									
										89
									
								
								lib/memex_web/live/invite_live/form_component.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								lib/memex_web/live/invite_live/form_component.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,89 @@ | ||||
| defmodule MemexWeb.InviteLive.FormComponent do | ||||
|   @moduledoc """ | ||||
|   Livecomponent that can update or create an Memex.Accounts.Invite | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :live_component | ||||
|   alias Ecto.Changeset | ||||
|   alias Memex.Accounts.{Invite, Invites, User} | ||||
|   alias Phoenix.LiveView.Socket | ||||
|  | ||||
|   @impl true | ||||
|   @spec update( | ||||
|           %{:invite => Invite.t(), :current_user => User.t(), optional(any) => any}, | ||||
|           Socket.t() | ||||
|         ) :: {:ok, Socket.t()} | ||||
|   def update(%{invite: _invite} = assigns, socket) do | ||||
|     {:ok, socket |> assign(assigns) |> assign_changeset(%{})} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("validate", %{"invite" => invite_params}, socket) do | ||||
|     {:noreply, socket |> assign_changeset(invite_params)} | ||||
|   end | ||||
|  | ||||
|   def handle_event("save", %{"invite" => invite_params}, %{assigns: %{action: action}} = socket) do | ||||
|     save_invite(socket, action, invite_params) | ||||
|   end | ||||
|  | ||||
|   defp assign_changeset( | ||||
|          %{assigns: %{action: action, current_user: user, invite: invite}} = socket, | ||||
|          invite_params | ||||
|        ) do | ||||
|     changeset_action = | ||||
|       case action do | ||||
|         :new -> :insert | ||||
|         :edit -> :update | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case action do | ||||
|         :new -> Invite.create_changeset(user, "example_token", invite_params) | ||||
|         :edit -> invite |> Invite.update_changeset(invite_params) | ||||
|       end | ||||
|  | ||||
|     changeset = | ||||
|       case changeset |> Changeset.apply_action(changeset_action) do | ||||
|         {:ok, _data} -> changeset | ||||
|         {:error, changeset} -> changeset | ||||
|       end | ||||
|  | ||||
|     socket |> assign(:changeset, changeset) | ||||
|   end | ||||
|  | ||||
|   defp save_invite( | ||||
|          %{assigns: %{current_user: current_user, invite: invite, return_to: return_to}} = socket, | ||||
|          :edit, | ||||
|          invite_params | ||||
|        ) do | ||||
|     socket = | ||||
|       case invite |> Invites.update_invite(invite_params, current_user) do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} updated successfully", name: invite_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(:changeset, changeset) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   defp save_invite( | ||||
|          %{assigns: %{current_user: current_user, return_to: return_to}} = socket, | ||||
|          :new, | ||||
|          invite_params | ||||
|        ) do | ||||
|     socket = | ||||
|       case current_user |> Invites.create_invite(invite_params) do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = dgettext("prompts", "%{name} created successfully", name: invite_name) | ||||
|           socket |> put_flash(:info, prompt) |> push_navigate(to: return_to) | ||||
|  | ||||
|         {:error, %Changeset{} = changeset} -> | ||||
|           socket |> assign(changeset: changeset) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
| end | ||||
							
								
								
									
										37
									
								
								lib/memex_web/live/invite_live/form_component.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/memex_web/live/invite_live/form_component.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| <div> | ||||
|   <h2 class="mb-8 text-center title text-xl text-primary-600"> | ||||
|     <%= @title %> | ||||
|   </h2> | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     id="invite-form" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|     phx-target={@myself} | ||||
|     phx-change="validate" | ||||
|     phx-submit="save" | ||||
|   > | ||||
|     <div | ||||
|       :if={@changeset.action && not @changeset.valid?()} | ||||
|       class="invalid-feedback col-span-3 text-center" | ||||
|     > | ||||
|       <%= changeset_errors(@changeset) %> | ||||
|     </div> | ||||
|  | ||||
|     <%= label(f, :name, gettext("Name"), class: "title text-lg text-primary-600") %> | ||||
|     <%= text_input(f, :name, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :name, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :uses_left, gettext("Uses left"), class: "title text-lg text-primary-600") %> | ||||
|     <%= number_input(f, :uses_left, min: 0, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :uses_left, "col-span-3") %> | ||||
|     <span class="col-span-3 text-primary-400 italic text-center"> | ||||
|       <%= gettext(~s/Leave "Uses left" blank to make invite unlimited/) %> | ||||
|     </span> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Save"), | ||||
|       class: "mx-auto btn btn-primary col-span-3", | ||||
|       phx_disable_with: dgettext("prompts", "Saving...") | ||||
|     ) %> | ||||
|   </.form> | ||||
| </div> | ||||
							
								
								
									
										157
									
								
								lib/memex_web/live/invite_live/index.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								lib/memex_web/live/invite_live/index.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | ||||
| defmodule MemexWeb.InviteLive.Index do | ||||
|   @moduledoc """ | ||||
|   Liveview to show a Memex.Accounts.Invite index | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :live_view | ||||
|   import MemexWeb.Components.{InviteCard, UserCard} | ||||
|   alias Memex.Accounts | ||||
|   alias Memex.Accounts.{Invite, Invites} | ||||
|   alias MemexWeb.{Endpoint, HomeLive} | ||||
|   alias Phoenix.LiveView.JS | ||||
|  | ||||
|   @impl true | ||||
|   def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do | ||||
|     socket = | ||||
|       if current_user |> Map.get(:role) == :admin do | ||||
|         socket |> display_invites() | ||||
|       else | ||||
|         prompt = dgettext("errors", "You are not authorized to view this page") | ||||
|         return_to = Routes.live_path(Endpoint, HomeLive) | ||||
|         socket |> put_flash(:error, prompt) |> push_redirect(to: return_to) | ||||
|       end | ||||
|  | ||||
|     {:ok, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_params(params, _url, %{assigns: %{live_action: live_action}} = socket) do | ||||
|     {:noreply, socket |> apply_action(live_action, params)} | ||||
|   end | ||||
|  | ||||
|   defp apply_action(%{assigns: %{current_user: current_user}} = socket, :edit, %{"id" => id}) do | ||||
|     socket | ||||
|     |> assign(page_title: gettext("Edit Invite"), invite: Invites.get_invite!(id, current_user)) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :new, _params) do | ||||
|     socket |> assign(page_title: gettext("New Invite"), invite: %Invite{}) | ||||
|   end | ||||
|  | ||||
|   defp apply_action(socket, :index, _params) do | ||||
|     socket |> assign(page_title: gettext("Invites"), invite: nil) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "delete_invite", | ||||
|         %{"id" => id}, | ||||
|         %{assigns: %{current_user: current_user}} = socket | ||||
|       ) do | ||||
|     %{name: invite_name} = | ||||
|       id |> Invites.get_invite!(current_user) |> Invites.delete_invite!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "%{invite_name} deleted succesfully", invite_name: invite_name) | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_invites()} | ||||
|   end | ||||
|  | ||||
|   def handle_event( | ||||
|         "set_unlimited", | ||||
|         %{"id" => id}, | ||||
|         %{assigns: %{current_user: current_user}} = socket | ||||
|       ) do | ||||
|     socket = | ||||
|       Invites.get_invite!(id, current_user) | ||||
|       |> Invites.update_invite(%{uses_left: nil}, current_user) | ||||
|       |> case do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = | ||||
|             dgettext("prompts", "%{invite_name} updated succesfully", invite_name: invite_name) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> display_invites() | ||||
|  | ||||
|         {:error, changeset} -> | ||||
|           socket |> put_flash(:error, changeset |> changeset_errors()) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   def handle_event( | ||||
|         "enable_invite", | ||||
|         %{"id" => id}, | ||||
|         %{assigns: %{current_user: current_user}} = socket | ||||
|       ) do | ||||
|     socket = | ||||
|       Invites.get_invite!(id, current_user) | ||||
|       |> Invites.update_invite(%{uses_left: nil, disabled_at: nil}, current_user) | ||||
|       |> case do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = | ||||
|             dgettext("prompts", "%{invite_name} enabled succesfully", invite_name: invite_name) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> display_invites() | ||||
|  | ||||
|         {:error, changeset} -> | ||||
|           socket |> put_flash(:error, changeset |> changeset_errors()) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   def handle_event( | ||||
|         "disable_invite", | ||||
|         %{"id" => id}, | ||||
|         %{assigns: %{current_user: current_user}} = socket | ||||
|       ) do | ||||
|     now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||
|  | ||||
|     socket = | ||||
|       Invites.get_invite!(id, current_user) | ||||
|       |> Invites.update_invite(%{uses_left: 0, disabled_at: now}, current_user) | ||||
|       |> case do | ||||
|         {:ok, %{name: invite_name}} -> | ||||
|           prompt = | ||||
|             dgettext("prompts", "%{invite_name} disabled succesfully", invite_name: invite_name) | ||||
|  | ||||
|           socket |> put_flash(:info, prompt) |> display_invites() | ||||
|  | ||||
|         {:error, changeset} -> | ||||
|           socket |> put_flash(:error, changeset |> changeset_errors()) | ||||
|       end | ||||
|  | ||||
|     {:noreply, socket} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event("copy_to_clipboard", _params, socket) do | ||||
|     prompt = dgettext("prompts", "Copied to clipboard") | ||||
|     {:noreply, socket |> put_flash(:info, prompt)} | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def handle_event( | ||||
|         "delete_user", | ||||
|         %{"id" => id}, | ||||
|         %{assigns: %{current_user: current_user}} = socket | ||||
|       ) do | ||||
|     %{email: user_email} = Accounts.get_user!(id) |> Accounts.delete_user!(current_user) | ||||
|  | ||||
|     prompt = dgettext("prompts", "%{user_email} deleted succesfully", user_email: user_email) | ||||
|  | ||||
|     {:noreply, socket |> put_flash(:info, prompt) |> display_invites()} | ||||
|   end | ||||
|  | ||||
|   defp display_invites(%{assigns: %{current_user: current_user}} = socket) do | ||||
|     invites = Invites.list_invites(current_user) | ||||
|     all_users = Accounts.list_all_users_by_role(current_user) | ||||
|  | ||||
|     admins = | ||||
|       all_users | ||||
|       |> Map.get(:admin, []) | ||||
|       |> Enum.reject(fn %{id: user_id} -> user_id == current_user.id end) | ||||
|  | ||||
|     users = all_users |> Map.get(:user, []) | ||||
|     socket |> assign(invites: invites, admins: admins, users: users) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										149
									
								
								lib/memex_web/live/invite_live/index.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								lib/memex_web/live/invite_live/index.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | ||||
| <div class="w-full flex flex-col space-y-8 justify-center items-center"> | ||||
|   <h1 class="title text-2xl title-primary-500"> | ||||
|     <%= gettext("Invites") %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @invites |> Enum.empty?() do %> | ||||
|     <h1 class="title text-xl text-primary-600"> | ||||
|       <%= gettext("No invites 😔") %> | ||||
|     </h1> | ||||
|  | ||||
|     <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Invite someone new!") %> | ||||
|     </.link> | ||||
|   <% else %> | ||||
|     <.link patch={Routes.invite_index_path(Endpoint, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Create Invite") %> | ||||
|     </.link> | ||||
|   <% end %> | ||||
|  | ||||
|   <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|     <.invite_card :for={invite <- @invites} invite={invite} current_user={@current_user}> | ||||
|       <:code_actions> | ||||
|         <form phx-submit="copy_to_clipboard"> | ||||
|           <button | ||||
|             type="submit" | ||||
|             class="mx-2 my-1 btn btn-primary" | ||||
|             phx-click={JS.dispatch("memex:clipcopy", to: "#code-#{invite.id}")} | ||||
|           > | ||||
|             <%= dgettext("actions", "Copy to clipboard") %> | ||||
|           </button> | ||||
|         </form> | ||||
|       </:code_actions> | ||||
|       <.link | ||||
|         patch={Routes.invite_index_path(Endpoint, :edit, invite)} | ||||
|         class="text-primary-600 link" | ||||
|         data-qa={"edit-#{invite.id}"} | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-edit"></i> | ||||
|       </.link> | ||||
|  | ||||
|       <.link | ||||
|         href="#" | ||||
|         class="text-primary-600 link" | ||||
|         phx-click="delete_invite" | ||||
|         phx-value-id={invite.id} | ||||
|         data-confirm={ | ||||
|           dgettext("prompts", "Are you sure you want to delete the invite for %{invite_name}?", | ||||
|             invite_name: invite.name | ||||
|           ) | ||||
|         } | ||||
|         data-qa={"delete-#{invite.id}"} | ||||
|       > | ||||
|         <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|       </.link> | ||||
|  | ||||
|       <a | ||||
|         href="#" | ||||
|         class="btn btn-primary" | ||||
|         phx-click={if invite.disabled_at, do: "enable_invite", else: "disable_invite"} | ||||
|         phx-value-id={invite.id} | ||||
|       > | ||||
|         <%= if invite.disabled_at, do: gettext("Enable"), else: gettext("Disable") %> | ||||
|       </a> | ||||
|  | ||||
|       <a | ||||
|         :if={invite.disabled_at |> is_nil() and not (invite.uses_left |> is_nil())} | ||||
|         href="#" | ||||
|         class="btn btn-primary" | ||||
|         phx-click="set_unlimited" | ||||
|         phx-value-id={invite.id} | ||||
|         data-confirm={ | ||||
|           dgettext("prompts", "Are you sure you want to make %{invite_name} unlimited?", | ||||
|             invite_name: invite.name | ||||
|           ) | ||||
|         } | ||||
|       > | ||||
|         <%= gettext("Set Unlimited") %> | ||||
|       </a> | ||||
|     </.invite_card> | ||||
|   </div> | ||||
|  | ||||
|   <%= unless @admins |> Enum.empty?() do %> | ||||
|     <hr class="hr" /> | ||||
|  | ||||
|     <h1 class="title text-2xl text-primary-600"> | ||||
|       <%= gettext("Admins") %> | ||||
|     </h1> | ||||
|  | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|       <.user_card :for={admin <- @admins} user={admin}> | ||||
|         <.link | ||||
|           href="#" | ||||
|           class="text-primary-600 link" | ||||
|           phx-click="delete_user" | ||||
|           phx-value-id={admin.id} | ||||
|           data-confirm={ | ||||
|             dgettext( | ||||
|               "prompts", | ||||
|               "Are you sure you want to delete %{email}? This action is permanent!", | ||||
|               email: admin.email | ||||
|             ) | ||||
|           } | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|         </.link> | ||||
|       </.user_card> | ||||
|     </div> | ||||
|   <% end %> | ||||
|  | ||||
|   <%= unless @users |> Enum.empty?() do %> | ||||
|     <hr class="hr" /> | ||||
|  | ||||
|     <h1 class="title text-2xl text-primary-600"> | ||||
|       <%= gettext("Users") %> | ||||
|     </h1> | ||||
|  | ||||
|     <div class="w-full flex flex-row flex-wrap justify-center items-stretch"> | ||||
|       <.user_card :for={user <- @users} user={user}> | ||||
|         <.link | ||||
|           href="#" | ||||
|           class="text-primary-600 link" | ||||
|           phx-click="delete_user" | ||||
|           phx-value-id={user.id} | ||||
|           data-confirm={ | ||||
|             dgettext( | ||||
|               "prompts", | ||||
|               "Are you sure you want to delete %{email}? This action is permanent!", | ||||
|               email: user.email | ||||
|             ) | ||||
|           } | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-trash"></i> | ||||
|         </.link> | ||||
|       </.user_card> | ||||
|     </div> | ||||
|   <% end %> | ||||
| </div> | ||||
|  | ||||
| <.modal :if={@live_action in [:new, :edit]} return_to={Routes.invite_index_path(Endpoint, :index)}> | ||||
|   <.live_component | ||||
|     module={MemexWeb.InviteLive.FormComponent} | ||||
|     id={@invite.id || :new} | ||||
|     title={@page_title} | ||||
|     action={@live_action} | ||||
|     invite={@invite} | ||||
|     return_to={Routes.invite_index_path(Endpoint, :index)} | ||||
|     current_user={@current_user} | ||||
|   /> | ||||
| </.modal> | ||||
							
								
								
									
										81
									
								
								lib/memex_web/live/live_helpers.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								lib/memex_web/live/live_helpers.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| defmodule MemexWeb.LiveHelpers do | ||||
|   @moduledoc """ | ||||
|   Contains common helper functions for liveviews | ||||
|   """ | ||||
|  | ||||
|   import Phoenix.Component | ||||
|   alias Phoenix.LiveView.JS | ||||
|  | ||||
|   @doc """ | ||||
|   Renders a live component inside a modal. | ||||
|  | ||||
|   The rendered modal receives a `:return_to` option to properly update | ||||
|   the URL when the modal is closed. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       <.modal return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)}> | ||||
|         <.live_component | ||||
|           module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent} | ||||
|           id={@<%= schema.singular %>.id || :new} | ||||
|           title={@page_title} | ||||
|           action={@live_action} | ||||
|           return_to={Routes.<%= schema.singular %>_index_path(Endpoint, :index)} | ||||
|           <%= schema.singular %>: @<%= schema.singular %> | ||||
|         /> | ||||
|       </.modal> | ||||
|   """ | ||||
|   def modal(assigns) do | ||||
|     ~H""" | ||||
|     <.link | ||||
|       id="modal-bg" | ||||
|       patch={@return_to} | ||||
|       class="fade-in fixed z-10 left-0 top-0 | ||||
|         w-full h-full overflow-hidden | ||||
|         p-8 flex flex-col justify-center items-center cursor-auto" | ||||
|       style="background-color: rgba(0,0,0,0.4);" | ||||
|       phx-remove={hide_modal()} | ||||
|     > | ||||
|       <span class="hidden"></span> | ||||
|     </.link> | ||||
|  | ||||
|     <div | ||||
|       id="modal" | ||||
|       class="fixed z-10 left-0 top-0 pointer-events-none | ||||
|         w-full h-full overflow-hidden | ||||
|         p-4 sm:p-8 flex flex-col justify-center items-center" | ||||
|     > | ||||
|       <div | ||||
|         id="modal-content" | ||||
|         class="fade-in-scale w-full max-w-3xl relative | ||||
|           pointer-events-auto overflow-hidden | ||||
|           px-8 py-4 sm:py-8 | ||||
|           flex flex-col justify-start items-center | ||||
|           bg-white border-2 rounded-lg" | ||||
|       > | ||||
|         <.link | ||||
|           id="close" | ||||
|           href={@return_to} | ||||
|           class="absolute top-8 right-10 | ||||
|             text-gray-500 hover:text-gray-800 | ||||
|             transition-all duration-500 ease-in-out" | ||||
|           phx-remove={hide_modal()} | ||||
|         > | ||||
|           <i class="fa-fw fa-lg fas fa-times"></i> | ||||
|         </.link> | ||||
|  | ||||
|         <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   def hide_modal(js \\ %JS{}) do | ||||
|     js | ||||
|     |> JS.hide(to: "#modal", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-bg", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-content", transition: "fade-out-scale") | ||||
|   end | ||||
| end | ||||
							
								
								
									
										101
									
								
								lib/memex_web/router.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								lib/memex_web/router.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,101 @@ | ||||
| defmodule MemexWeb.Router do | ||||
|   use MemexWeb, :router | ||||
|   import Phoenix.LiveDashboard.Router | ||||
|   import MemexWeb.UserAuth | ||||
|  | ||||
|   pipeline :browser do | ||||
|     plug :accepts, ["html"] | ||||
|     plug :fetch_session | ||||
|     plug :fetch_live_flash | ||||
|     plug :put_root_layout, {MemexWeb.LayoutView, :root} | ||||
|     plug :protect_from_forgery | ||||
|     plug :put_secure_browser_headers | ||||
|     plug :fetch_current_user | ||||
|     plug :put_user_locale | ||||
|   end | ||||
|  | ||||
|   defp put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do | ||||
|     default = Application.fetch_env!(:gettext, :default_locale) | ||||
|     Gettext.put_locale(locale || default) | ||||
|     conn |> put_session(:locale, locale || default) | ||||
|   end | ||||
|  | ||||
|   defp put_user_locale(conn, _opts) do | ||||
|     default = Application.fetch_env!(:gettext, :default_locale) | ||||
|     Gettext.put_locale(default) | ||||
|     conn |> put_session(:locale, default) | ||||
|   end | ||||
|  | ||||
|   pipeline :require_admin do | ||||
|     plug :require_role, role: :admin | ||||
|   end | ||||
|  | ||||
|   pipeline :api do | ||||
|     plug :accepts, ["json"] | ||||
|   end | ||||
|  | ||||
|   scope "/", MemexWeb do | ||||
|     pipe_through :browser | ||||
|  | ||||
|     live "/", HomeLive | ||||
|   end | ||||
|  | ||||
|   ## Authentication routes | ||||
|  | ||||
|   scope "/", MemexWeb do | ||||
|     pipe_through [:browser, :redirect_if_user_is_authenticated] | ||||
|  | ||||
|     get "/users/register", UserRegistrationController, :new | ||||
|     post "/users/register", UserRegistrationController, :create | ||||
|     get "/users/log_in", UserSessionController, :new | ||||
|     post "/users/log_in", UserSessionController, :create | ||||
|     get "/users/reset_password", UserResetPasswordController, :new | ||||
|     post "/users/reset_password", UserResetPasswordController, :create | ||||
|     get "/users/reset_password/:token", UserResetPasswordController, :edit | ||||
|     put "/users/reset_password/:token", UserResetPasswordController, :update | ||||
|   end | ||||
|  | ||||
|   scope "/", MemexWeb do | ||||
|     pipe_through [:browser, :require_authenticated_user] | ||||
|  | ||||
|     get "/users/settings", UserSettingsController, :edit | ||||
|     put "/users/settings", UserSettingsController, :update | ||||
|     delete "/users/settings/:id", UserSettingsController, :delete | ||||
|     get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email | ||||
|   end | ||||
|  | ||||
|   scope "/", MemexWeb do | ||||
|     pipe_through [:browser, :require_authenticated_user, :require_admin] | ||||
|  | ||||
|     live_dashboard "/dashboard", metrics: MemexWeb.Telemetry, ecto_repos: [Memex.Repo] | ||||
|  | ||||
|     live "/invites", InviteLive.Index, :index | ||||
|     live "/invites/new", InviteLive.Index, :new | ||||
|     live "/invites/:id/edit", InviteLive.Index, :edit | ||||
|   end | ||||
|  | ||||
|   scope "/", MemexWeb do | ||||
|     pipe_through [:browser] | ||||
|  | ||||
|     delete "/users/log_out", UserSessionController, :delete | ||||
|     get "/users/confirm", UserConfirmationController, :new | ||||
|     post "/users/confirm", UserConfirmationController, :create | ||||
|     get "/users/confirm/:token", UserConfirmationController, :confirm | ||||
|   end | ||||
|  | ||||
|   # Enables the Swoosh mailbox preview in development. | ||||
|   # | ||||
|   # Note that preview only shows emails that were sent by the same | ||||
|   # node running the Phoenix server. | ||||
|   if Mix.env() == :dev do | ||||
|     scope "/dev" do | ||||
|       pipe_through :browser | ||||
|  | ||||
|       forward "/mailbox", Plug.Swoosh.MailboxPreview | ||||
|     end | ||||
|  | ||||
|     scope "/dev" do | ||||
|       get "/preview/:id", MemexWeb.EmailController, :preview | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										99
									
								
								lib/memex_web/telemetry.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								lib/memex_web/telemetry.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| defmodule MemexWeb.Telemetry do | ||||
|   @moduledoc """ | ||||
|   Collects telemetry | ||||
|   """ | ||||
|  | ||||
|   use Supervisor | ||||
|   import Telemetry.Metrics | ||||
|  | ||||
|   def start_link(arg) do | ||||
|     Supervisor.start_link(__MODULE__, arg, name: __MODULE__) | ||||
|   end | ||||
|  | ||||
|   @impl true | ||||
|   def init(_arg) do | ||||
|     children = [ | ||||
|       # Telemetry poller will execute the given period measurements | ||||
|       # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics | ||||
|       {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} | ||||
|       # Add reporters as children of your supervision tree. | ||||
|       # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} | ||||
|     ] | ||||
|  | ||||
|     Supervisor.init(children, strategy: :one_for_one) | ||||
|   end | ||||
|  | ||||
|   def metrics do | ||||
|     [ | ||||
|       # Phoenix Metrics | ||||
|       summary("phoenix.endpoint.stop.duration", | ||||
|         unit: {:native, :millisecond} | ||||
|       ), | ||||
|       summary("phoenix.router_dispatch.stop.duration", | ||||
|         tags: [:route], | ||||
|         unit: {:native, :millisecond} | ||||
|       ), | ||||
|  | ||||
|       # Database Metrics | ||||
|       summary("memex.repo.query.total_time", | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: "The sum of the other measurements" | ||||
|       ), | ||||
|       summary("memex.repo.query.decode_time", | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: "The time spent decoding the data received from the database" | ||||
|       ), | ||||
|       summary("memex.repo.query.query_time", | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: "The time spent executing the query" | ||||
|       ), | ||||
|       summary("memex.repo.query.queue_time", | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: "The time spent waiting for a database connection" | ||||
|       ), | ||||
|       summary("memex.repo.query.idle_time", | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: | ||||
|           "The time the connection spent waiting before being checked out for the query" | ||||
|       ), | ||||
|  | ||||
|       # Oban Metrics | ||||
|       counter("oban.job.exception", | ||||
|         tags: [:queue, :worker], | ||||
|         event_name: [:oban, :job, :exception], | ||||
|         measurement: :duration, | ||||
|         description: "Number of oban jobs that raised an exception" | ||||
|       ), | ||||
|       counter("oban.job.start", | ||||
|         tags: [:queue, :worker], | ||||
|         event_name: [:oban, :job, :start], | ||||
|         measurement: :system_time, | ||||
|         description: "Number of oban jobs started" | ||||
|       ), | ||||
|       summary("oban.job.stop.duration", | ||||
|         tags: [:queue, :worker], | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: "Length of time spent processing the oban job" | ||||
|       ), | ||||
|       summary("oban.job.stop.queue_time", | ||||
|         tags: [:queue, :worker], | ||||
|         unit: {:native, :millisecond}, | ||||
|         description: "Time the oban job spent waiting in milliseconds" | ||||
|       ), | ||||
|  | ||||
|       # VM Metrics | ||||
|       summary("vm.memory.total", unit: {:byte, :kilobyte}), | ||||
|       summary("vm.total_run_queue_lengths.total"), | ||||
|       summary("vm.total_run_queue_lengths.cpu"), | ||||
|       summary("vm.total_run_queue_lengths.io") | ||||
|     ] | ||||
|   end | ||||
|  | ||||
|   defp periodic_measurements do | ||||
|     [ | ||||
|       # A module, function and arguments to be invoked periodically. | ||||
|       # This function must call :telemetry.execute/3 and a metric must be added above. | ||||
|       # {MemexWeb, :count_users, []} | ||||
|     ] | ||||
|   end | ||||
| end | ||||
							
								
								
									
										23
									
								
								lib/memex_web/templates/email/confirm_email.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								lib/memex_web/templates/email/confirm_email.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||
|   <span style="margin-bottom: 0.75em; font-size: 1.5em;"> | ||||
|     <%= dgettext("emails", "Hi %{email},", email: @user.email) %> | ||||
|   </span> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <span style="margin-bottom: 1em; font-size: 1.25em;"> | ||||
|     <%= dgettext("emails", "Welcome to Memex") %> | ||||
|   </span> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <%= dgettext("emails", "You can confirm your account by visiting the URL below:") %> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <%= dgettext("emails", "If you didn't create an account at Memex, please ignore this.") %> | ||||
| </div> | ||||
							
								
								
									
										12
									
								
								lib/memex_web/templates/email/confirm_email.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								lib/memex_web/templates/email/confirm_email.txt.eex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
|  | ||||
| <%= dgettext("emails", "Hi %{email},", email: @user.email) %> | ||||
|  | ||||
| <%= dgettext("emails", "Welcome to Memex") %> | ||||
|  | ||||
| <%= dgettext("emails", "You can confirm your account by visiting the URL below:") %> | ||||
|  | ||||
| <%= @url %> | ||||
|  | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't create an account at %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
							
								
								
									
										17
									
								
								lib/memex_web/templates/email/reset_password.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/memex_web/templates/email/reset_password.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||
|   <span style="margin-bottom: 0.5em; font-size: 1.5em;"> | ||||
|     <%= dgettext("emails", "Hi %{email},", email: @user.email) %> | ||||
|   </span> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <%= dgettext("emails", "You can reset your password by visiting the URL below:") %> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <%= dgettext("emails", "If you didn't request this change from Memex, please ignore this.") %> | ||||
| </div> | ||||
							
								
								
									
										10
									
								
								lib/memex_web/templates/email/reset_password.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/memex_web/templates/email/reset_password.txt.eex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
|  | ||||
| <%= dgettext("emails", "Hi %{email},", email: @user.email) %> | ||||
|  | ||||
| <%= dgettext("emails", "You can reset your password by visiting the URL below:") %> | ||||
|  | ||||
| <%= @url %> | ||||
|  | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't request this change from %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
							
								
								
									
										20
									
								
								lib/memex_web/templates/email/update_email.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								lib/memex_web/templates/email/update_email.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| <div style="display: flex; flex-direction: column; justify-content: center; align-items: center;"> | ||||
|   <span style="margin-bottom: 0.5em; font-size: 1.5em;"> | ||||
|     <%= dgettext("emails", "Hi %{email},", email: @user.email) %> | ||||
|   </span> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <%= dgettext("emails", "You can change your email by visiting the URL below:") %> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> | ||||
|  | ||||
|   <br /> | ||||
|  | ||||
|   <%= dgettext( | ||||
|     "emails", | ||||
|     "If you didn't request this change from Memex, please ignore this." | ||||
|   ) %> | ||||
| </div> | ||||
							
								
								
									
										10
									
								
								lib/memex_web/templates/email/update_email.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/memex_web/templates/email/update_email.txt.eex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
|  | ||||
| <%= dgettext("emails", "Hi %{email},", email: @user.email) %> | ||||
|  | ||||
| <%= dgettext("emails", "You can change your email by visiting the URL below:") %> | ||||
|  | ||||
| <%= @url %> | ||||
|  | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't request this change from %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
							
								
								
									
										33
									
								
								lib/memex_web/templates/error/error.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								lib/memex_web/templates/error/error.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en" class="m-0 p-0 w-full h-full"> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title> | ||||
|       <%= dgettext("errors", "Error") %> | <%= gettext("Memex") %> | ||||
|     </title> | ||||
|     <link rel="stylesheet" href="/css/app.css" /> | ||||
|     <script defer type="text/javascript" src="/js/app.js"> | ||||
|     </script> | ||||
|   </head> | ||||
|   <body class="pb-8 m-0 p-0 w-full h-full"> | ||||
|     <header> | ||||
|       <.topbar current_user={assigns[:current_user]} /> | ||||
|     </header> | ||||
|  | ||||
|     <div class="pb-8 w-full flex flex-col justify-center items-center text-center"> | ||||
|       <div class="p-8 sm:p-16 w-full flex flex-col justify-center items-center space-y-4 max-w-3xl"> | ||||
|         <h1 class="title text-primary-600 text-3xl"> | ||||
|           <%= @error_string %> | ||||
|         </h1> | ||||
|  | ||||
|         <hr class="w-full hr" /> | ||||
|  | ||||
|         <a href={Routes.live_path(Endpoint, HomeLive)} class="link title text-primary-600 text-lg"> | ||||
|           <%= dgettext("errors", "Go back home") %> | ||||
|         </a> | ||||
|       </div> | ||||
|     </div> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										18
									
								
								lib/memex_web/templates/layout/app.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								lib/memex_web/templates/layout/app.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| <main role="main" class="min-h-full min-w-full"> | ||||
|   <header> | ||||
|     <.topbar current_user={assigns[:current_user]} /> | ||||
|  | ||||
|     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> | ||||
|       <p :if={get_flash(@conn, :info)} class="alert alert-info" role="alert"> | ||||
|         <%= get_flash(@conn, :info) %> | ||||
|       </p> | ||||
|       <p :if={get_flash(@conn, :error)} class="alert alert-danger" role="alert"> | ||||
|         <%= get_flash(@conn, :error) %> | ||||
|       </p> | ||||
|     </div> | ||||
|   </header> | ||||
|  | ||||
|   <div class="mx-4 sm:mx-8 md:mx-16"> | ||||
|     <%= @inner_content %> | ||||
|   </div> | ||||
| </main> | ||||
							
								
								
									
										19
									
								
								lib/memex_web/templates/layout/email.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/memex_web/templates/layout/email.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| <html> | ||||
|   <head> | ||||
|     <title> | ||||
|       <%= @email.subject %> | ||||
|     </title> | ||||
|   </head> | ||||
|   <body style="padding: 2em; color: rgb(31, 31, 31); background-color: rgb(220, 220, 228); font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif; text-align: center;"> | ||||
|     <%= @inner_content %> | ||||
|  | ||||
|     <hr style="margin: 2em auto; border-width: 1px; border-color: rgb(212, 212, 216); width: 100%; max-width: 42rem;" /> | ||||
|  | ||||
|     <a style="color: rgb(31, 31, 31);" href={Routes.live_url(Endpoint, HomeLive)}> | ||||
|       <%= dgettext( | ||||
|         "emails", | ||||
|         "This email was sent from Memex, the self-hosted firearm tracker website." | ||||
|       ) %> | ||||
|     </a> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										11
									
								
								lib/memex_web/templates/layout/email.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								lib/memex_web/templates/layout/email.txt.eex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| <%= @email.subject %> | ||||
|  | ||||
| ==================== | ||||
|  | ||||
| <%= @inner_content %> | ||||
|  | ||||
| ===================== | ||||
|  | ||||
| <%= dgettext("emails", | ||||
|   "This email was sent from Memex at %{url}, the self-hosted firearm tracker website.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
							
								
								
									
										1
									
								
								lib/memex_web/templates/layout/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								lib/memex_web/templates/layout/empty.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <%= @inner_content %> | ||||
							
								
								
									
										45
									
								
								lib/memex_web/templates/layout/live.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								lib/memex_web/templates/layout/live.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| <main class="pb-8 min-w-full"> | ||||
|   <header> | ||||
|     <.topbar current_user={assigns[:current_user]} /> | ||||
|  | ||||
|     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> | ||||
|       <p | ||||
|         :if={@flash && @flash |> Map.has_key?("info")} | ||||
|         class="alert alert-info" | ||||
|         role="alert" | ||||
|         phx-click="lv:clear-flash" | ||||
|         phx-value-key="info" | ||||
|       > | ||||
|         <%= live_flash(@flash, "info") %> | ||||
|       </p> | ||||
|  | ||||
|       <p | ||||
|         :if={@flash && @flash |> Map.has_key?("error")} | ||||
|         class="alert alert-danger" | ||||
|         role="alert" | ||||
|         phx-click="lv:clear-flash" | ||||
|         phx-value-key="error" | ||||
|       > | ||||
|         <%= live_flash(@flash, "error") %> | ||||
|       </p> | ||||
|     </div> | ||||
|   </header> | ||||
|  | ||||
|   <div class="mx-4 sm:mx-8 md:mx-16 flex flex-col justify-center items-stretch"> | ||||
|     <%= @inner_content %> | ||||
|   </div> | ||||
| </main> | ||||
|  | ||||
| <div | ||||
|   id="disconnect" | ||||
|   class="z-50 fixed opacity-0 bottom-12 right-12 px-8 py-4 w-max h-max | ||||
|   border border-primary-200 shadow-lg rounded-lg bg-white | ||||
|   flex justify-center items-center space-x-4 | ||||
|   transition-opacity ease-in-out duration-500 delay-[2000ms]" | ||||
| > | ||||
|   <i class="fas fa-fade text-md fa-satellite-dish"></i> | ||||
|  | ||||
|   <h1 class="title text-md title-primary-500"> | ||||
|     <%= gettext("Reconnecting...") %> | ||||
|   </h1> | ||||
| </div> | ||||
							
								
								
									
										24
									
								
								lib/memex_web/templates/layout/root.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								lib/memex_web/templates/layout/root.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| <!DOCTYPE html> | ||||
| <html lang="en" class="m-0 p-0 w-full h-full"> | ||||
|   <head> | ||||
|     <meta charset="utf-8" /> | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <%= csrf_meta_tag() %> | ||||
|     <.live_title suffix={" | #{gettext("Memex")}"}> | ||||
|       <%= assigns[:page_title] || gettext("Memex") %> | ||||
|     </.live_title> | ||||
|     <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/css/app.css")} /> | ||||
|     <script | ||||
|       defer | ||||
|       phx-track-static | ||||
|       type="text/javascript" | ||||
|       src={Routes.static_path(@conn, "/js/app.js")} | ||||
|     > | ||||
|     </script> | ||||
|   </head> | ||||
|  | ||||
|   <body class="m-0 p-0 w-full h-full subpixel-antialiased"> | ||||
|     <%= @inner_content %> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										34
									
								
								lib/memex_web/templates/user_confirmation/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lib/memex_web/templates/user_confirmation/new.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-600 text-xl"> | ||||
|     <%= dgettext("actions", "Resend confirmation instructions") %> | ||||
|   </h1> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={:user} | ||||
|     action={Routes.user_confirmation_path(@conn, :create)} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Resend confirmation instructions"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										50
									
								
								lib/memex_web/templates/user_registration/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								lib/memex_web/templates/user_registration/new.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-600 text-xl"> | ||||
|     <%= dgettext("actions", "Register") %> | ||||
|   </h1> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     action={Routes.user_registration_path(@conn, :create)} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> | ||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|     </p> | ||||
|  | ||||
|     <%= if @invite_token do %> | ||||
|       <%= hidden_input(f, :invite_token, value: @invite_token) %> | ||||
|     <% end %> | ||||
|  | ||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :email, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :password, gettext("Password"), class: "title text-lg text-primary-600") %> | ||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :password, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :locale, gettext("Language"), class: "title text-lg text-primary-600") %> | ||||
|     <%= select( | ||||
|       f, | ||||
|       :locale, | ||||
|       [{gettext("English"), "en_US"}], | ||||
|       class: "input input-primary col-span-2" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :locale) %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Register"), class: "mx-auto btn btn-primary col-span-3") %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Forgot your password?") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										48
									
								
								lib/memex_web/templates/user_reset_password/edit.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								lib/memex_web/templates/user_reset_password/edit.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-600 text-xl"> | ||||
|     <%= dgettext("actions", "Reset password") %> | ||||
|   </h1> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     action={Routes.user_reset_password_path(@conn, :update, @token)} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> | ||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|     </p> | ||||
|  | ||||
|     <%= label(f, :password, gettext("New password"), class: "title text-lg text-primary-600") %> | ||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :password, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :password_confirmation, gettext("Confirm new password"), | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= password_input(f, :password_confirmation, | ||||
|       required: true, | ||||
|       class: "input input-primary col-span-2" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :password_confirmation, "col-span-3") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Reset password"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										34
									
								
								lib/memex_web/templates/user_reset_password/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								lib/memex_web/templates/user_reset_password/new.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-600 text-xl"> | ||||
|     <%= dgettext("actions", "Forgot your password?") %> | ||||
|   </h1> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={:user} | ||||
|     action={Routes.user_reset_password_path(@conn, :create)} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Send instructions to reset password"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										45
									
								
								lib/memex_web/templates/user_session/new.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								lib/memex_web/templates/user_session/new.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-600 text-xl"> | ||||
|     <%= dgettext("actions", "Log in") %> | ||||
|   </h1> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@conn} | ||||
|     action={Routes.user_session_path(@conn, :create)} | ||||
|     as="user" | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <p :if={@error_message} class="alert alert-danger col-span-3"> | ||||
|       <%= @error_message %> | ||||
|     </p> | ||||
|  | ||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
|  | ||||
|     <%= label(f, :password, gettext("Password"), class: "title text-lg text-primary-600") %> | ||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> | ||||
|  | ||||
|     <%= label(f, :remember_me, gettext("Keep me logged in for 60 days"), | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= checkbox(f, :remember_me, class: "checkbox col-span-2") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Log in"), class: "mx-auto btn btn-primary col-span-3") %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link | ||||
|       :if={Accounts.allow_registration?()} | ||||
|       href={Routes.user_registration_path(@conn, :new)} | ||||
|       class="btn btn-primary" | ||||
|     > | ||||
|       <%= dgettext("actions", "Register") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "Forgot your password?") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										144
									
								
								lib/memex_web/templates/user_settings/edit.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										144
									
								
								lib/memex_web/templates/user_settings/edit.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,144 @@ | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center text-right space-y-4"> | ||||
|   <h1 class="pb-4 title text-primary-600 text-2xl text-center"> | ||||
|     <%= gettext("Settings") %> | ||||
|   </h1> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@email_changeset} | ||||
|     action={Routes.user_settings_path(@conn, :update)} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <h3 class="title text-primary-600 text-lg text-center col-span-3"> | ||||
|       <%= dgettext("actions", "Change email") %> | ||||
|     </h3> | ||||
|  | ||||
|     <div | ||||
|       :if={@email_changeset.action && not @email_changeset.valid?()} | ||||
|       class="alert alert-danger col-span-3" | ||||
|     > | ||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|     </div> | ||||
|  | ||||
|     <%= hidden_input(f, :action, name: "action", value: "update_email") %> | ||||
|  | ||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-600") %> | ||||
|     <%= email_input(f, :email, required: true, class: "mx-2 my-1 input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :email, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :current_password, gettext("Current password"), | ||||
|       for: "current_password_for_email", | ||||
|       class: "mx-2 my-1 title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= password_input(f, :current_password, | ||||
|       required: true, | ||||
|       name: "current_password", | ||||
|       id: "current_password_for_email", | ||||
|       class: "mx-2 my-1 input input-primary col-span-2" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :current_password, "col-span-3") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Change email"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@password_changeset} | ||||
|     action={Routes.user_settings_path(@conn, :update)} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <h3 class="title text-primary-600 text-lg text-center col-span-3"> | ||||
|       <%= dgettext("actions", "Change password") %> | ||||
|     </h3> | ||||
|  | ||||
|     <div | ||||
|       :if={@password_changeset.action && not @password_changeset.valid?()} | ||||
|       class="alert alert-danger col-span-3" | ||||
|     > | ||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|     </div> | ||||
|  | ||||
|     <%= hidden_input(f, :action, name: "action", value: "update_password") %> | ||||
|  | ||||
|     <%= label(f, :password, gettext("New password"), class: "title text-lg text-primary-600") %> | ||||
|     <%= password_input(f, :password, | ||||
|       required: true, | ||||
|       class: "mx-2 my-1 input input-primary col-span-2" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :password, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :password_confirmation, gettext("Confirm new password"), | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= password_input(f, :password_confirmation, | ||||
|       required: true, | ||||
|       class: "mx-2 my-1 input input-primary col-span-2" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :password_confirmation, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :current_password, gettext("Current password"), | ||||
|       for: "current_password_for_password", | ||||
|       class: "title text-lg text-primary-600" | ||||
|     ) %> | ||||
|     <%= password_input(f, :current_password, | ||||
|       required: true, | ||||
|       name: "current_password", | ||||
|       id: "current_password_for_password", | ||||
|       class: "mx-2 my-1 input input-primary col-span-2" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :current_password, "col-span-3") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Change password"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@locale_changeset} | ||||
|     action={Routes.user_settings_path(@conn, :update)} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <h3 class="title text-primary-600 text-lg text-center col-span-3"> | ||||
|       <%= dgettext("actions", "Change Language") %> | ||||
|     </h3> | ||||
|  | ||||
|     <div | ||||
|       :if={@locale_changeset.action && not @locale_changeset.valid?} | ||||
|       class="alert alert-danger col-span-3" | ||||
|     > | ||||
|       <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|     </div> | ||||
|  | ||||
|     <%= hidden_input(f, :action, name: "action", value: "update_locale") %> | ||||
|  | ||||
|     <%= select(f, :locale, [{gettext("English"), "en_US"}, {"Spanish", "es"}], | ||||
|       class: "mx-2 my-1 min-w-md input input-primary col-span-3" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :locale, "col-span-3") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "Change language"), | ||||
|       class: "whitespace-nowrap mx-auto btn btn-primary col-span-3", | ||||
|       data: [qa: dgettext("prompts", "Are you sure you want to change your language?")] | ||||
|     ) %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <.link | ||||
|     href={Routes.user_settings_path(@conn, :delete, @current_user)} | ||||
|     method={:delete} | ||||
|     class="btn btn-alert" | ||||
|     data-confirm={dgettext("prompts", "Are you sure you want to delete your account?")} | ||||
|   > | ||||
|     <%= dgettext("actions", "Delete User") %> | ||||
|   </.link> | ||||
| </div> | ||||
							
								
								
									
										7
									
								
								lib/memex_web/views/email_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								lib/memex_web/views/email_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| defmodule MemexWeb.EmailView do | ||||
|   @moduledoc """ | ||||
|   A view for email-related helper functions | ||||
|   """ | ||||
|   use MemexWeb, :view | ||||
|   alias MemexWeb.{Endpoint, HomeLive} | ||||
| end | ||||
							
								
								
									
										84
									
								
								lib/memex_web/views/error_helpers.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								lib/memex_web/views/error_helpers.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| defmodule MemexWeb.ErrorHelpers do | ||||
|   @moduledoc """ | ||||
|   Conveniences for translating and building error messages. | ||||
|   """ | ||||
|  | ||||
|   use Phoenix.HTML | ||||
|   import Phoenix.Component | ||||
|   alias Ecto.Changeset | ||||
|   alias Phoenix.{HTML.Form, LiveView.Rendered} | ||||
|  | ||||
|   @doc """ | ||||
|   Generates tag for inlined form input errors. | ||||
|   """ | ||||
|   @spec error_tag(Form.t(), Form.field()) :: Rendered.t() | ||||
|   @spec error_tag(Form.t(), Form.field(), String.t()) :: Rendered.t() | ||||
|   def error_tag(form, field, extra_class \\ "") do | ||||
|     assigns = %{extra_class: extra_class, form: form, field: field} | ||||
|  | ||||
|     ~H""" | ||||
|     <span | ||||
|       :for={error <- Keyword.get_values(@form.errors, @field)} | ||||
|       class={["invalid-feedback", @extra_class]} | ||||
|       phx-feedback-for={input_name(@form, @field)} | ||||
|     > | ||||
|       <%= translate_error(error) %> | ||||
|     </span> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Translates an error message using gettext. | ||||
|   """ | ||||
|   @spec translate_error({String.t(), keyword() | map()}) :: String.t() | ||||
|   def translate_error({msg, opts}) do | ||||
|     # When using gettext, we typically pass the strings we want | ||||
|     # to translate as a static argument: | ||||
|     # | ||||
|     #     # Translate "is invalid" in the "errors" domain | ||||
|     #     dgettext("errors", "is invalid") | ||||
|     # | ||||
|     #     # Translate the number of files with plural rules | ||||
|     #     dngettext("errors", "1 file", "%{count} files", count) | ||||
|     # | ||||
|     # Because the error messages we show in our forms and APIs | ||||
|     # are defined inside Ecto, we need to translate them dynamically. | ||||
|     # This requires us to call the Gettext module passing our gettext | ||||
|     # backend as first argument. | ||||
|     # | ||||
|     # Note we use the "errors" domain, which means translations | ||||
|     # should be written to the errors.po file. The :count option is | ||||
|     # set by Ecto and indicates we should also apply plural rules. | ||||
|     if count = opts[:count] do | ||||
|       Gettext.dngettext(MemexWeb.Gettext, "errors", msg, msg, count, opts) | ||||
|     else | ||||
|       Gettext.dgettext(MemexWeb.Gettext, "errors", msg, opts) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Displays all errors from a changeset, or just for a single key | ||||
|   """ | ||||
|   @spec changeset_errors(Changeset.t()) :: String.t() | ||||
|   @spec changeset_errors(Changeset.t(), key :: atom()) :: [String.t()] | nil | ||||
|   def changeset_errors(changeset) do | ||||
|     changeset | ||||
|     |> changeset_error_map() | ||||
|     |> Enum.map_join(". ", fn {key, errors} -> | ||||
|       "#{key |> humanize()}: #{errors |> Enum.join(", ")}" | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   def changeset_errors(changeset, key) do | ||||
|     changeset |> changeset_error_map() |> Map.get(key) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Displays all errors from a changeset in a key value map | ||||
|   """ | ||||
|   @spec changeset_error_map(Changeset.t()) :: %{atom() => [String.t()]} | ||||
|   def changeset_error_map(changeset) do | ||||
|     changeset | ||||
|     |> Changeset.traverse_errors(fn error -> error |> translate_error() end) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										16
									
								
								lib/memex_web/views/error_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/memex_web/views/error_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| defmodule MemexWeb.ErrorView do | ||||
|   use MemexWeb, :view | ||||
|   import MemexWeb.Components.Topbar | ||||
|   alias MemexWeb.{Endpoint, HomeLive} | ||||
|  | ||||
|   def template_not_found(error_path, _assigns) do | ||||
|     error_string = | ||||
|       case error_path do | ||||
|         "404.html" -> dgettext("errors", "Not found") | ||||
|         "401.html" -> dgettext("errors", "Unauthorized") | ||||
|         _other_path -> dgettext("errors", "Internal Server Error") | ||||
|       end | ||||
|  | ||||
|     render("error.html", %{error_string: error_string}) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										3
									
								
								lib/memex_web/views/home_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/memex_web/views/home_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| defmodule MemexWeb.PageView do | ||||
|   use MemexWeb, :view | ||||
| end | ||||
							
								
								
									
										17
									
								
								lib/memex_web/views/layout_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/memex_web/views/layout_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| defmodule MemexWeb.LayoutView do | ||||
|   use MemexWeb, :view | ||||
|   import MemexWeb.Components.Topbar | ||||
|   alias MemexWeb.{Endpoint, HomeLive} | ||||
|  | ||||
|   # Phoenix LiveDashboard is available only in development by default, | ||||
|   # so we instruct Elixir to not warn if the dashboard route is missing. | ||||
|   @compile {:no_warn_undefined, {Routes, :live_dashboard_path, 2}} | ||||
|  | ||||
|   def get_title(%{assigns: %{title: title}}) when title not in [nil, ""] do | ||||
|     gettext("Memex | %{title}", title: title) | ||||
|   end | ||||
|  | ||||
|   def get_title(_conn) do | ||||
|     gettext("Memex") | ||||
|   end | ||||
| end | ||||
							
								
								
									
										4
									
								
								lib/memex_web/views/user_confirmation_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								lib/memex_web/views/user_confirmation_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| defmodule MemexWeb.UserConfirmationView do | ||||
|   use MemexWeb, :view | ||||
|   alias Memex.Accounts | ||||
| end | ||||
							
								
								
									
										3
									
								
								lib/memex_web/views/user_registration_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/memex_web/views/user_registration_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| defmodule MemexWeb.UserRegistrationView do | ||||
|   use MemexWeb, :view | ||||
| end | ||||
							
								
								
									
										4
									
								
								lib/memex_web/views/user_reset_password_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								lib/memex_web/views/user_reset_password_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| defmodule MemexWeb.UserResetPasswordView do | ||||
|   use MemexWeb, :view | ||||
|   alias Memex.Accounts | ||||
| end | ||||
							
								
								
									
										4
									
								
								lib/memex_web/views/user_session_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								lib/memex_web/views/user_session_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| defmodule MemexWeb.UserSessionView do | ||||
|   use MemexWeb, :view | ||||
|   alias Memex.Accounts | ||||
| end | ||||
							
								
								
									
										3
									
								
								lib/memex_web/views/user_settings_view.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/memex_web/views/user_settings_view.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| defmodule MemexWeb.UserSettingsView do | ||||
|   use MemexWeb, :view | ||||
| end | ||||
							
								
								
									
										96
									
								
								lib/memex_web/views/view_helpers.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								lib/memex_web/views/view_helpers.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| defmodule MemexWeb.ViewHelpers do | ||||
|   @moduledoc """ | ||||
|   Contains common helpers that can be used in liveviews and regular views. These | ||||
|   are automatically imported into any Phoenix View using `use MemexWeb, | ||||
|   :view` | ||||
|   """ | ||||
|  | ||||
|   use Phoenix.Component | ||||
|  | ||||
|   @doc """ | ||||
|   Phoenix.Component for a <time> element that renders the naivedatetime in the | ||||
|   user's local timezone with Alpine.js | ||||
|   """ | ||||
|  | ||||
|   attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil" | ||||
|  | ||||
|   def datetime(assigns) do | ||||
|     ~H""" | ||||
|     <time | ||||
|       :if={@datetime} | ||||
|       datetime={cast_datetime(@datetime)} | ||||
|       x-data={"{ | ||||
|         datetime: | ||||
|           Intl.DateTimeFormat([], {dateStyle: 'short', timeStyle: 'long'}) | ||||
|             .format(new Date(\"#{cast_datetime(@datetime)}\")) | ||||
|       }"} | ||||
|       x-text="datetime" | ||||
|     > | ||||
|       <%= cast_datetime(@datetime) %> | ||||
|     </time> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @spec cast_datetime(NaiveDateTime.t() | nil) :: String.t() | ||||
|   defp cast_datetime(%NaiveDateTime{} = datetime) do | ||||
|     datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended) | ||||
|   end | ||||
|  | ||||
|   defp cast_datetime(_datetime), do: "" | ||||
|  | ||||
|   @doc """ | ||||
|   Phoenix.Component for a <date> element that renders the Date in the user's | ||||
|   local timezone with Alpine.js | ||||
|   """ | ||||
|  | ||||
|   attr :date, :any, required: true, doc: "A `Date` struct or nil" | ||||
|  | ||||
|   def date(assigns) do | ||||
|     ~H""" | ||||
|     <time | ||||
|       :if={@date} | ||||
|       datetime={@date |> Date.to_iso8601(:extended)} | ||||
|       x-data={"{ | ||||
|         date: | ||||
|           Intl.DateTimeFormat([], {timeZone: 'Etc/UTC', dateStyle: 'short'}) | ||||
|             .format(new Date(\"#{@date |> Date.to_iso8601(:extended)}\")) | ||||
|       }"} | ||||
|       x-text="date" | ||||
|     > | ||||
|       <%= @date |> Date.to_iso8601(:extended) %> | ||||
|     </time> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Displays content in a QR code as a base64 encoded PNG | ||||
|   """ | ||||
|   @spec qr_code_image(String.t()) :: String.t() | ||||
|   @spec qr_code_image(String.t(), width :: non_neg_integer()) :: String.t() | ||||
|   def qr_code_image(content, width \\ 384) do | ||||
|     img_data = | ||||
|       content | ||||
|       |> EQRCode.encode() | ||||
|       |> EQRCode.png(width: width) | ||||
|       |> Base.encode64() | ||||
|  | ||||
|     "data:image/png;base64," <> img_data | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a downloadable QR Code element | ||||
|   """ | ||||
|  | ||||
|   attr :content, :string, required: true | ||||
|   attr :filename, :string, default: "qrcode", doc: "filename without .png extension" | ||||
|   attr :image_class, :string, default: "w-64 h-max" | ||||
|   attr :width, :integer, default: 384, doc: "width of png to generate" | ||||
|  | ||||
|   def qr_code(assigns) do | ||||
|     ~H""" | ||||
|     <a href={qr_code_image(@content)} download={@filename <> ".png"}> | ||||
|       <img class={@image_class} alt={@filename} src={qr_code_image(@content)} /> | ||||
|     </a> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
		Reference in New Issue
	
	Block a user