From 499552233bbfada206a170d9bd64dd240ed19909 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Fri, 22 Mar 2024 19:48:50 +0000 Subject: [PATCH 01/21] draft: first steps --- .../organization_banner_placeholder.ex | 40 +++ .../components/organization_card.ex | 33 +++ lib/atomic_web/live/organization_live/edit.ex | 21 -- .../live/organization_live/edit.html.heex | 1 - .../live/organization_live/form_component.ex | 87 ------ .../form_component.html.heex | 88 ------ .../live/organization_live/index.ex | 30 +- .../live/organization_live/index.html.heex | 43 +-- lib/atomic_web/live/organization_live/new.ex | 24 -- .../live/organization_live/new.html.heex | 1 - lib/atomic_web/live/organization_live/show.ex | 123 -------- .../live/organization_live/show.html.heex | 112 -------- lib/atomic_web/router.ex | 9 - priv/fake/organizations.json | 266 +++++++++--------- 14 files changed, 232 insertions(+), 646 deletions(-) create mode 100644 lib/atomic_web/live/organization_live/components/organization_banner_placeholder.ex create mode 100644 lib/atomic_web/live/organization_live/components/organization_card.ex delete mode 100644 lib/atomic_web/live/organization_live/edit.ex delete mode 100644 lib/atomic_web/live/organization_live/edit.html.heex delete mode 100644 lib/atomic_web/live/organization_live/form_component.ex delete mode 100644 lib/atomic_web/live/organization_live/form_component.html.heex delete mode 100644 lib/atomic_web/live/organization_live/new.ex delete mode 100644 lib/atomic_web/live/organization_live/new.html.heex delete mode 100644 lib/atomic_web/live/organization_live/show.ex delete mode 100644 lib/atomic_web/live/organization_live/show.html.heex diff --git a/lib/atomic_web/live/organization_live/components/organization_banner_placeholder.ex b/lib/atomic_web/live/organization_live/components/organization_banner_placeholder.ex new file mode 100644 index 000000000..ba6debe91 --- /dev/null +++ b/lib/atomic_web/live/organization_live/components/organization_banner_placeholder.ex @@ -0,0 +1,40 @@ +defmodule AtomicWeb.OrganizationLive.Components.OrganizationBannerPlaceholder do + @moduledoc false + use AtomicWeb, :component + + def organization_banner_placeholder(assigns) do + {gradient_color_a, gradient_color_b} = generate_color(assigns.organization.id) + + assigns + |> assign(:gradient_color_a, gradient_color_a) + |> assign(:gradient_color_b, gradient_color_b) + |> render_gradient() + end + + defp render_gradient(assigns) do + ~H""" +
+ """ + end + + defp generate_color(uuid) when is_binary(uuid) do + # List of gradients + colors = [ + {"#000046", "#1CB5E0"}, + {"#007991", "#78ffd6"}, + {"#30E8BF", "#FF8235"}, + {"#C33764", "#1D2671"}, + {"#34e89e", "#0f3443"}, + {"#44A08D", "#093637"}, + {"#DCE35B", "#45B649"}, + {"#c0c0aa", "#1cefff"}, + {"#ee0979", "#ff6a00"} + ] + + # Convert the UUID to an integer + index = :erlang.phash2(uuid, length(colors)) + + # Return the chosen color + Enum.at(colors, index) + end +end diff --git a/lib/atomic_web/live/organization_live/components/organization_card.ex b/lib/atomic_web/live/organization_live/components/organization_card.ex new file mode 100644 index 000000000..15eb388e8 --- /dev/null +++ b/lib/atomic_web/live/organization_live/components/organization_card.ex @@ -0,0 +1,33 @@ +defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do + @moduledoc false + use AtomicWeb, :component + + alias Atomic.Organizations.Organization + + import AtomicWeb.OrganizationLive.Components.OrganizationBannerPlaceholder + + attr :organization, Organization, required: true, doc: "The organization to display." + + def organization_card(assigns) do + ~H""" +
+
+ <.organization_banner_placeholder organization={@organization} class="rounded-t-lg" /> +
+
+

+ <%= @organization.name %> +

+
+

+ <%= @organization.long_name %> +

+
+
+ <%= @organization.description %> +
+
+
+ """ + end +end diff --git a/lib/atomic_web/live/organization_live/edit.ex b/lib/atomic_web/live/organization_live/edit.ex deleted file mode 100644 index bef1e7f98..000000000 --- a/lib/atomic_web/live/organization_live/edit.ex +++ /dev/null @@ -1,21 +0,0 @@ -defmodule AtomicWeb.OrganizationLive.Edit do - use AtomicWeb, :live_view - - alias Atomic.Organizations - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"organization_id" => organization_id}, _, socket) do - organization = Organizations.get_organization!(organization_id) - - {:noreply, - socket - |> assign(:page_title, organization.name) - |> assign(:organization, organization) - |> assign(:current_page, :organizations)} - end -end diff --git a/lib/atomic_web/live/organization_live/edit.html.heex b/lib/atomic_web/live/organization_live/edit.html.heex deleted file mode 100644 index 35ce9de8b..000000000 --- a/lib/atomic_web/live/organization_live/edit.html.heex +++ /dev/null @@ -1 +0,0 @@ -<.live_component module={AtomicWeb.OrganizationLive.FormComponent} id={@organization} title={@page_title} action={@live_action} organization={@organization} return_to={Routes.organization_show_path(@socket, :show, @organization)} /> diff --git a/lib/atomic_web/live/organization_live/form_component.ex b/lib/atomic_web/live/organization_live/form_component.ex deleted file mode 100644 index f578f0d8c..000000000 --- a/lib/atomic_web/live/organization_live/form_component.ex +++ /dev/null @@ -1,87 +0,0 @@ -defmodule AtomicWeb.OrganizationLive.FormComponent do - use AtomicWeb, :live_component - - alias Atomic.Activities - alias Atomic.Organizations - - @impl true - def mount(socket) do - speakers = Activities.list_speakers() - - {:ok, - socket - |> allow_upload(:card, accept: Atomic.Uploader.extensions_whitelist(), max_entries: 1) - |> assign(:speakers, speakers)} - end - - @impl true - def update(%{organization: organization} = assigns, socket) do - changeset = Organizations.change_organization(organization) - - {:ok, - socket - |> assign(assigns) - |> assign(:changeset, changeset)} - end - - @impl true - def handle_event("validate", %{"organization" => organization_params}, socket) do - changeset = - socket.assigns.organization - |> Organizations.change_organization(organization_params) - |> Map.put(:action, :validate) - - {:noreply, assign(socket, :changeset, changeset)} - end - - def handle_event("save", %{"organization" => organization_params}, socket) do - save_organization(socket, socket.assigns.action, organization_params) - end - - defp save_organization(socket, :edit, organization_params) do - consume_card_data(socket, socket.assigns.organization) - - case Organizations.update_organization(socket.assigns.organization, organization_params) do - {:ok, _organization} -> - {:noreply, - socket - |> put_flash(:info, "Organization updated successfully") - |> push_navigate(to: socket.assigns.return_to)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - end - - defp save_organization(socket, :new, organization_params) do - case Organizations.create_organization(organization_params) do - {:ok, _organization} -> - {:noreply, - socket - |> put_flash(:info, "Organization created successfully") - |> push_navigate(to: socket.assigns.return_to)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, changeset: changeset)} - end - end - - defp consume_card_data(socket, organization) do - consume_uploaded_entries(socket, :card, fn %{path: path}, entry -> - Organizations.update_card_image(organization, %{ - "card_image" => %Plug.Upload{ - content_type: entry.client_type, - filename: entry.client_name, - path: path - } - }) - end) - |> case do - [{:ok, organization}] -> - {:ok, organization} - - _errors -> - {:ok, organization} - end - end -end diff --git a/lib/atomic_web/live/organization_live/form_component.html.heex b/lib/atomic_web/live/organization_live/form_component.html.heex deleted file mode 100644 index 3b174cf45..000000000 --- a/lib/atomic_web/live/organization_live/form_component.html.heex +++ /dev/null @@ -1,88 +0,0 @@ -
-

<%= @title %>

- - <.form :let={f} for={@changeset} id="organization-form" phx-target={@myself} phx-change="validate" phx-submit="save"> - <%= label(f, :name) %> - <%= text_input(f, :name) %> - <%= error_tag(f, :name) %> - - <%= label(f, :description) %> - <%= text_input(f, :description) %> - <%= error_tag(f, :description) %> - -
- <.live_file_input upload={@uploads.card} class="hidden" /> -
-
-
-
-
- -

or drag and drop

-
- -

- PNG, JPG, GIF up to 10MB -

-
-
-
-
-
- <%= for entry <- @uploads.card.entries do %> - <%= for err <- upload_errors(@uploads.card, entry) do %> -

<%= Phoenix.Naming.humanize(err) %>

- <% end %> -
-
- <.live_img_preview entry={entry} /> -
-
- <%= if String.length(entry.client_name) < 30 do - entry.client_name - else - String.slice(entry.client_name, 0..30) <> "... " - end %> -
- -
-
-
- <% end %> -
-
- <%= inputs_for f, :card, fn ff -> %> - <%= label(ff, :number_x) %> - <%= number_input(ff, :number_x) %> - <%= label(ff, :number_y) %> - <%= number_input(ff, :number_y) %> - <%= label(ff, :number_size) %> - <%= number_input(ff, :number_size) %> - <%= label(ff, :number_color) %> - <%= text_input(ff, :number_color) %> - <%= label(ff, :name_x) %> - <%= number_input(ff, :name_x) %> - <%= label(ff, :name_y) %> - <%= number_input(ff, :name_y) %> - <%= label(ff, :name_size) %> - <%= number_input(ff, :name_size) %> - <%= label(ff, :name_color) %> - <%= text_input(ff, :name_color) %> - <% end %> - <%= error_tag(f, :departments) %> - -
- <%= submit("Save", phx_disable_with: "Saving...") %> -
- -
diff --git a/lib/atomic_web/live/organization_live/index.ex b/lib/atomic_web/live/organization_live/index.ex index 0639e7fcd..a5da4dcd5 100644 --- a/lib/atomic_web/live/organization_live/index.ex +++ b/lib/atomic_web/live/organization_live/index.ex @@ -1,13 +1,10 @@ defmodule AtomicWeb.OrganizationLive.Index do use AtomicWeb, :live_view - import AtomicWeb.Components.Avatar - import AtomicWeb.Components.Empty - import AtomicWeb.Components.Pagination - import AtomicWeb.Components.Button + alias Atomic.{Accounts, Organizations} - alias Atomic.Accounts - alias Atomic.Organizations + import AtomicWeb.Components.Pagination + import AtomicWeb.OrganizationLive.Components.OrganizationCard @impl true def mount(_params, _session, socket) do @@ -15,21 +12,23 @@ defmodule AtomicWeb.OrganizationLive.Index do end @impl true - def handle_params(params, _url, socket) do - organizations_with_flop = list_organizations(params) + def handle_params(params, _, socket) do + %{organizations: organizations, meta: meta} = list_organizations(params) {:noreply, socket - |> assign(:page_title, gettext("Organizations")) - |> assign(:current_page, :organizations) + |> assign(:page_title, "Organizations") + |> assign(:current_page, :organization) |> assign(:params, params) - |> assign(organizations_with_flop) - |> assign(:empty?, Enum.empty?(organizations_with_flop.organizations)) + |> assign(:meta, meta) + |> assign(:organizations, organizations) |> assign(:has_permissions?, has_permissions?(socket))} end defp list_organizations(params) do - case Organizations.list_organizations(Map.put(params, "page_size", 18)) do + params = Map.put(params, "page_size", 6) + + case Organizations.list_organizations(params) do {:ok, {organizations, meta}} -> %{organizations: organizations, meta: meta} @@ -40,7 +39,6 @@ defmodule AtomicWeb.OrganizationLive.Index do defp has_permissions?(socket) when not socket.assigns.is_authenticated?, do: false - defp has_permissions?(socket) do - Accounts.has_master_permissions?(socket.assigns.current_user.id) - end + defp has_permissions?(socket), + do: Accounts.has_master_permissions?(socket.assigns.current_user.id) end diff --git a/lib/atomic_web/live/organization_live/index.html.heex b/lib/atomic_web/live/organization_live/index.html.heex index 1f46ca745..69237f67c 100644 --- a/lib/atomic_web/live/organization_live/index.html.heex +++ b/lib/atomic_web/live/organization_live/index.html.heex @@ -1,37 +1,18 @@ -<.page title="Organizations"> +<.page title={@page_title}> <:actions> - <%= if not @empty? and @has_permissions? do %> - <.button navigate={Routes.organization_new_path(@socket, :new)}> + <%= if @has_permissions? do %> + <.button patch="/" icon={:plus}> <%= gettext("New") %> <% end %> - - <%= if @empty? and @has_permissions? do %> -
- <.empty_state url={Routes.organization_index_path(@socket, :new)} placeholder="organization" /> -
- <% else %> -
- <%= for organization <- @organizations do %> - <.link navigate={Routes.organization_show_path(@socket, :show, organization)}> -
- <.avatar name={organization.name} src={Uploaders.Logo.url({organization.logo, organization}, :original)} type={:organization} size={:lg} color={:light_gray} /> -
-

- <%= organization.name %> -

- -

- <%= maybe_slice_string(organization.long_name, 85) %> -

-
-
- - <% end %> -
- <.pagination items={@organizations} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> - <% end %> + + + <.pagination items={@organizations} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> diff --git a/lib/atomic_web/live/organization_live/new.ex b/lib/atomic_web/live/organization_live/new.ex deleted file mode 100644 index 47cb1f730..000000000 --- a/lib/atomic_web/live/organization_live/new.ex +++ /dev/null @@ -1,24 +0,0 @@ -defmodule AtomicWeb.OrganizationLive.New do - use AtomicWeb, :live_view - - alias Atomic.Organizations - alias Atomic.Organizations.Organization - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(_params, _, socket) do - {:noreply, - socket - |> assign(:page_title, "New Organization") - |> assign(:organization, %Organization{}) - |> assign( - :allowed_roles, - Organizations.roles_less_than_or_equal(socket.assigns.current_user.role) - ) - |> assign(:current_page, :organization)} - end -end diff --git a/lib/atomic_web/live/organization_live/new.html.heex b/lib/atomic_web/live/organization_live/new.html.heex deleted file mode 100644 index edff8ee8b..000000000 --- a/lib/atomic_web/live/organization_live/new.html.heex +++ /dev/null @@ -1 +0,0 @@ -<.live_component module={AtomicWeb.OrganizationLive.FormComponent} current_user={@current_user} organization={@current_organization} id={:new} title={@page_title} action={@live_action} allowed_roles={@allowed_roles} return_to={Routes.organization_index_path(@socket, :index)} /> diff --git a/lib/atomic_web/live/organization_live/show.ex b/lib/atomic_web/live/organization_live/show.ex deleted file mode 100644 index 8e174e395..000000000 --- a/lib/atomic_web/live/organization_live/show.ex +++ /dev/null @@ -1,123 +0,0 @@ -defmodule AtomicWeb.OrganizationLive.Show do - use AtomicWeb, :live_view - - import AtomicWeb.Components.Avatar - - alias Atomic.Accounts - alias Atomic.Activities - alias Atomic.Departments - alias Atomic.Organizations - alias Atomic.Uploaders.Logo - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"organization_id" => organization_id} = _params, _, socket) do - organization = Organizations.get_organization!(organization_id) - - {:noreply, - socket - |> assign(:page_title, organization.name) - |> assign(:organization, organization) - |> assign(:people, Organizations.list_organizations_members(organization)) - |> assign(:current_page, :organizations) - |> assign(:organization, organization) - |> assign(:departments, Departments.list_departments_by_organization_id(organization_id)) - |> assign(list_activities(organization_id)) - |> assign(:followers_count, Organizations.count_followers(organization_id)) - |> assign(:following?, maybe_put_following(socket, organization)) - |> assign(:has_permissions?, has_permissions?(socket, organization_id))} - end - - @impl true - def handle_event("follow", _payload, socket) do - attrs = %{ - role: :follower, - user_id: socket.assigns.current_user.id, - created_by_id: socket.assigns.current_user.id, - organization_id: socket.assigns.organization.id - } - - case Organizations.create_membership(attrs) do - {:ok, _organization} -> - {:noreply, - socket - |> put_flash(:success, "Started following " <> socket.assigns.organization.name) - |> assign(:following?, true) - |> push_patch( - to: Routes.organization_show_path(socket, :show, socket.assigns.organization.id) - )} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - end - - @impl true - def handle_event("unfollow", _payload, socket) do - membership = - Organizations.get_membership_by_user_id_and_organization_id!( - socket.assigns.current_user.id, - socket.assigns.organization.id - ) - - case Organizations.delete_membership(membership) do - {:ok, _organization} -> - {:noreply, - socket - |> put_flash(:success, "Stopped following " <> socket.assigns.organization.name) - |> assign(:following?, false) - |> push_patch( - to: Routes.organization_show_path(socket, :show, socket.assigns.organization.id) - )} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, :changeset, changeset)} - end - end - - @impl true - def handle_event("must-login", _payload, socket) do - {:noreply, - socket - |> put_flash(:error, gettext("You must be logged in to follow an organization.")) - |> push_navigate(to: Routes.user_session_path(socket, :new))} - end - - defp list_activities(organization_id) do - case Activities.list_activities_by_organization_id(organization_id) do - {:ok, {activities, meta}} -> - %{activities: activities, meta: meta} - - {:error, flop} -> - %{activities: [], meta: flop} - end - end - - defp maybe_put_following(socket, _organization) when not socket.assigns.is_authenticated?, - do: false - - defp maybe_put_following(socket, organization) do - Organizations.is_member_of?(socket.assigns.current_user, organization) - end - - defp has_permissions?(socket, _organization_id) when not socket.assigns.is_authenticated?, - do: false - - defp has_permissions?(socket, _organization_id) - when not is_map_key(socket.assigns, :current_organization) or - is_nil(socket.assigns.current_organization) do - Accounts.has_master_permissions?(socket.assigns.current_user.id) - end - - defp has_permissions?(socket, organization_id) do - Accounts.has_master_permissions?(socket.assigns.current_user.id) || - Accounts.has_permissions_inside_organization?( - socket.assigns.current_user.id, - organization_id - ) - end -end diff --git a/lib/atomic_web/live/organization_live/show.html.heex b/lib/atomic_web/live/organization_live/show.html.heex deleted file mode 100644 index fc855a810..000000000 --- a/lib/atomic_web/live/organization_live/show.html.heex +++ /dev/null @@ -1,112 +0,0 @@ -
-
-
-
-
-
- <.avatar name={@organization.name} type={:organization} src={Logo.url({@organization.logo, @organization}, :original)} size={:xl} color={:light_gray} /> -
-
-
-

- <%= @organization.name %> -

-

- <%= @organization.long_name %> -

-
- <%= @followers_count %> - Followers -
-
- <%= if not @following? do %> - - <% else %> - <%= if @organization.name == "CeSIUM" do %> -
Following
- <% else %> -
-
-
-
-

Following

-
- -
-
-
    -
  • -
    -
    -

    Unfollow

    -
    -
    -
  • -
-
- <% end %> - <% end %> - <%= if @has_permissions? do %> - <.link patch={Routes.organization_edit_path(@socket, :edit, @organization)} class="button"> - - - <%= link to: "#", phx_click: "delete", phx_value_id: @organization.id, data: [confirm: "Are you sure?"] do %> - - <% end %> - <% end %> -
-
-
-
-
- <%= @organization.description %> -
-
-
-
-
- -
-
-
-

- People -

-
- <%= for person <- @people do %> - <.avatar name={person.name} size={:sm} color={:light_gray} /> - <% end %> -
- <.link navigate={Routes.board_index_path(@socket, :index, @organization.id)} class="hover:underline text-blue-500"> - <%= gettext("View all") %> - -
-
diff --git a/lib/atomic_web/router.ex b/lib/atomic_web/router.ex index c8fb62c28..29953080c 100644 --- a/lib/atomic_web/router.ex +++ b/lib/atomic_web/router.ex @@ -73,11 +73,7 @@ defmodule AtomicWeb.Router do ] live_session :admin, on_mount: [{AtomicWeb.Hooks, :current_user_state}] do - live "/organizations/new", OrganizationLive.New, :new - scope "/organizations/:organization_id" do - live "/edit", OrganizationLive.Edit, :edit - scope "/activities" do pipe_through :confirm_activity_association live "/new", ActivityLive.New, :new @@ -139,7 +135,6 @@ defmodule AtomicWeb.Router do live "/announcements", AnnouncementLive.Index, :index live "/activities/:id", ActivityLive.Show, :show - live "/organizations/:organization_id", OrganizationLive.Show, :show live "/announcements/:id", AnnouncementLive.Show, :show live "/profile/:slug", ProfileLive.Show, :show @@ -186,10 +181,6 @@ defmodule AtomicWeb.Router do pipe_through [:member] live "/card/:membership_id", CardLive.Show, :show - - # Only masters can create organizations - pipe_through [:master] - live "/organizations/new", OrganizationLive.New, :new end end diff --git a/priv/fake/organizations.json b/priv/fake/organizations.json index d5f27ae48..7d5752430 100644 --- a/priv/fake/organizations.json +++ b/priv/fake/organizations.json @@ -1,232 +1,232 @@ [ { - "name": "ADAUM", - "long_name": "Associação de Debates Académicos da Universidade do Minho", - "description": "Associação de Debates Académicos da Universidade do Minho" + "name": "ADAUM", + "long_name": "Associação de Debates Académicos da Universidade do Minho", + "description": "A ADAUM é uma associação da Universidade do Minho que tem por fim promover o debate competitivo, bem como o espírito crítico." }, { - "name": "ADEGE", - "long_name": "Associação de Estudantes de Gestão da Universidade do Minho", - "description": "Associação de Estudantes de Gestão da Universidade do Minho" + "name": "ADEGE", + "long_name": "Associação de Estudantes de Gestão da Universidade do Minho", + "description": "A ADEGE é o órgão representativo de todos os estudantes do Licenciatura em Gestão, da Escola de Economia e Gestão da Universidade do Minho (EEG-UM)." }, { - "name": "AEDUM", - "long_name": "Associação de Estudantes de Direito da Universidade do Minho", - "description": "Associação de Estudantes de Direito da Universidade do Minho" + "name": "AEDUM", + "long_name": "Associação de Estudantes de Direito da Universidade do Minho", + "description": "A Associação de Estudantes de Direito da Universidade do Minho é uma associação de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Direito, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "AEECUM", - "long_name": "Associação de Estudantes de Engenharia Civil da Universidade do Minho", - "description": "Associação Estudantes Engenharia Civil da Universidade do Minho" + "name": "AEECUM", + "long_name": "Associação de Estudantes de Engenharia Civil da Universidade do Minho", + "description": "Associação sempre ao serviço dos estudantes de Engenharia Civil da Universidade do Minho!" }, { - "name": "AEESECG", - "long_name": "Associação de Estudantes da Escola Superior de Enfermagem Calouste Gulbenkian", - "description": "Associação de Estudantes da Escola Superior de Enfermagem Calouste Gulbenkian" + "name": "AEESECG", + "long_name": "Associação de Estudantes da Escola Superior de Enfermagem Calouste Gulbenkian", + "description": "A AEESECG tem como objectivo primordial representar e defender os interesses dos estudantes da ESE-UM. Fomentar as relações de cooperação e amizade aos antigos alunos da AEESECG, promover a formação cultural e humana da comunidade estudantil, através da dinamização de actividades cientifico-pedagógicacas, sócio-culturais, recreativas e desportivas. Além disso pretende desenvolver a cooperação e solidariedade entre os estudantes da ESE-UM, promovendo uma política de igualdade de oportunidades." }, { - "name": "AEHUM", - "long_name": "Associação de Estudantes de História da Universidade do Minho", - "description": "Associação de Estudantes da História da Universidade do Minho" + "name": "AEHUM", + "long_name": "Associação de Estudantes de História da Universidade do Minho", + "description": "A Associação de Estudantes de História da Universidade do Minho é uma associação de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de História, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "AEPUM", - "long_name": "Associação de Estudantes de Psicologia da Universidade do Minho", - "description": "Associação de Estudantes de Psicologia da Universidade do Minho" + "name": "AEPUM", + "long_name": "Associação de Estudantes de Psicologia da Universidade do Minho", + "description": "A Associação de Estudantes de Psicologia da Universidade do Minho é uma associação de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Psicologia, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "AIESEC Minho", - "long_name": "Association Internationale des Etudiants en Sciences Economiques et Commerciales – UMinho", - "description": "Association Internationale des Etudiants en Sciences Economiques et Commerciales – UMinho" + "name": "AIESEC Minho", + "long_name": "Association Internationale des Etudiants en Sciences Economiques et Commerciales – UMinho", + "description": "A Association Internationale des Etudiants en Sciences Economiques et Commerciales – UMinho é uma associação de estudantes da Universidade do Minho que tem como objetivo dotar os seus membros de competências de liderança e empreendedorismo, bem como promover a mobilidade internacional dos estudantes." }, { - "name": "AIS.SC", - "long_name": "Association for Information Systems Student Chapter", - "description": "Association for Information Systems Student Chapter" + "name": "AIS.SC", + "long_name": "Association for Information Systems Student Chapter", + "description": "A Association for Information Systems Student Chapter é uma associação de estudantes da Universidade do Minho que tem como objetivo promover a partilha de conhecimento e experiências na área dos sistemas de informação, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "A3RUM", - "long_name": "Associação de Alunos de Arqueologia da Universidade do Minho", - "description": "Associação de Alunos de Arqueologia da Universidade do Minho" + "name": "A3RUM", + "long_name": "Associação de Alunos de Arqueologia da Universidade do Minho", + "description": "A Associação de Alunos de Arqueologia da Universidade do Minho é uma associação de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Arqueologia, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "CEAP", - "long_name": "Centro de Estudos de Administração Pública", - "description": "Centro de Estudos de Administração Pública" + "name": "CEAP", + "long_name": "Centro de Estudos de Administração Pública", + "description": "O Centro de Estudos de Administração Pública é um associação de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Administração Pública, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "CECRI", - "long_name": "Centro de Estudos de Comunicação e Relações Internacionais", - "description": "Centro de Estudos do Curso de Relações Internacionais" + "name": "CECRI", + "long_name": "Centro de Estudos de Comunicação e Relações Internacionais", + "description": "O Centro de Estudos de Comunicação e Relações Internacionais é um associação de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Comunicação e Relações Internacionais, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "CeSIUM", - "long_name": "Centro de Estudantes de Engenharia Informática da Universidade do Minho", - "description": "Centro de Estudantes de Engenharia de Sistemas e Informática da Universidade do Minho" + "name": "CeSIUM", + "long_name": "Centro de Estudantes de Engenharia Informática da Universidade do Minho", + "description": "O CeSIUM é um grupo de estudantes voluntários, que tem como objetivo representar e promover o curso de Engenharia Informática 💾 na UMinho 🎓" }, { - "name": "CineFOCUM", - "long_name": "Núcleo de Cinemas da Universidade do Minho", - "description": "Núcleo de Cinema da Universidade do Minho" + "name": "CineFOCUM", + "long_name": "Núcleo de Cinemas da Universidade do Minho", + "description": "O CineFOCUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a cultura cinematográfica, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "ELSA", - "long_name": "European Law Students Association", - "description": "European Law Students Association" + "name": "ELSA", + "long_name": "European Law Students Association", + "description": "ELSA UMinho é uma afiliação da ELSA Portugal, que por sua vez é uma afiliação da ELSA International. A ELSA é a maior associação de estudantes de Direito do mundo, com mais de 60,000 membros em 43 países." }, { - "name": "GAEB", - "long_name": "Grupo de Alunos de Engenharia Biomédica", - "description": "Grupo de Alunos de Engenharia Biomédica" + "name": "GAEB", + "long_name": "Grupo de Alunos de Engenharia Biomédica", + "description": "O GAEB é um grupo de alunos da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Engenharia Biomédica, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "GACCUM", - "long_name": "Grupo de Alunos de Ciências da Comunicação", - "description": "Grupo dos Alunos de Ciências da Comunicação" + "name": "GACCUM", + "long_name": "Grupo de Alunos de Ciências da Comunicação", + "description": "O Grupo de Alunos de Ciências da Comunicação é uma associação de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Ciências da Comunicação, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "GeoPlanUM", - "long_name": "Associação dos Estudantes de Geografia e Planeamento da Universidade do Minho", - "description": "Associação dos Estudantes de Geografia e Planeamento da Universidade do Minho" + "name": "GeoPlanUM", + "long_name": "Associação dos Estudantes de Geografia e Planeamento da Universidade do Minho", + "description": "O GeoPlanUM é uma associação de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Geografia e Planeamento, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "Music UM", - "long_name": "Núcleo de Estudantes de Música da Universidade do Minho", - "description": "Núcleo de Estudantes de Música da Universidade do Minho" + "name": "Music UM", + "long_name": "Núcleo de Estudantes de Música da Universidade do Minho", + "description": "O Music UM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a cultura musical, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NAECUM", - "long_name": "Núcleo de Alunos de Economia da Universidade do Minho", - "description": "Núcleo de Alunos de Economia da Universidade do Minho" + "name": "NAECUM", + "long_name": "Núcleo de Alunos de Economia da Universidade do Minho", + "description": "O NAECUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Economia, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NAMECUM", - "long_name": "Núcleo de Alunos de Engenharia Mecânica da Universidade do Minho", - "description": "Núcleo de Alunos de Engenharia Mecânica da Universidade do Minho" + "name": "NAMECUM", + "long_name": "Núcleo de Alunos de Engenharia Mecânica da Universidade do Minho", + "description": "O NAMECUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Engenharia Mecânica, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NAQUM", - "long_name": "Núcleo de Alunos de Química da Universidade do Minho", - "description": "Núcleo de Alunos de Química da Universidade do Minho" + "name": "NAQUM", + "long_name": "Núcleo de Alunos de Química da Universidade do Minho", + "description": "O NAQUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Química, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEAUM", - "long_name": "Núcleo de Estudantes de Arquitetura da Universidade do Minho", - "description": "Núcleo de Estudantes de Arquitetura da Universidade do Minho" + "name": "NEAUM", + "long_name": "Núcleo de Estudantes de Arquitetura da Universidade do Minho", + "description": "O NEAUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Arquitetura, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEBAUM", - "long_name": "Núcleo de Estudantes de Biologia Aplicada da Universidade do Minho", - "description": "Núcleo de Estudantes de Biologia Aplicada da Universidade do Minho" + "name": "NEBAUM", + "long_name": "Núcleo de Estudantes de Biologia Aplicada da Universidade do Minho", + "description": "O NEBAUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Biologia Aplicada, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEBQUM", - "long_name": "Núcleo de Estudantes de Bioquímica da Universidade do Minho", - "description": "Núcleo de Estudantes de Bioquímica da Universidade do Minho" + "name": "NEBQUM", + "long_name": "Núcleo de Estudantes de Bioquímica da Universidade do Minho", + "description": "O NEBQUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Bioquímica, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NECC", - "long_name": "Núcleo de Estudantes de Ciências da Comunicação da Universidade do Minho", - "description": "Núcleo de Estudantes de Ciências da Computação da Universidade do Minho" + "name": "NECC", + "long_name": "Núcleo de Estudantes de Ciências da Computação da Universidade do Minho", + "description": "O NECC é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Ciências da Computação, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NECSUM", - "long_name": "Núcleo de Estudantes do Curso da Sociologia da Universidade do Minho", - "description": "Núcleo de Estudantes do Curso de Sociologia da Universidade do Minho" + "name": "NECSUM", + "long_name": "Núcleo de Estudantes do Curso da Sociologia da Universidade do Minho", + "description": "O NECSUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Sociologia, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEDUM", - "long_name": "Núcleo de Estudantes de Educação da Universidade do Minho", - "description": "Núcleo de Estudantes de Educação da Universidade do Minho" + "name": "NEDUM", + "long_name": "Núcleo de Estudantes de Educação da Universidade do Minho", + "description": "O NEDUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Educação, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEEB", - "long_name": "Núcleo de Estudantes de Engenharia Biológica", - "description": "Núcleo de Estudos de Engenharia Biológica" + "name": "NEEB", + "long_name": "Núcleo de Estudantes de Engenharia Biológica", + "description": "O NEEB é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Engenharia Biológica, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEEBUM", - "long_name": "Núcleo de Estudantes de Educação Básica da Universidade do Minho", - "description": "Núcleo de Estudantes de Educação Básica da Universidade do Minho" + "name": "NEEBUM", + "long_name": "Núcleo de Estudantes de Educação Básica da Universidade do Minho", + "description": "O NEEBUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Educação Básica, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEECUM", - "long_name": "Núcleo de Estudantes de Engenharia de Comunicações da Universidade do Minho", - "description": "Núcleo de Estudantes de Engenharia de Comunicações da Universidade do Minho" + "name": "NEECUM", + "long_name": "Núcleo de Estudantes de Engenharia de Comunicações da Universidade do Minho", + "description": "O NEECUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Engenharia de Comunicações, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEEGIUM", - "long_name": "Núcleo de Estudantes de Engenharia e Gestão Industrial da Universidade do Minho", - "description": "Núcleo de Estudantes de Engenharia e Gestão Industrial da Universidade do Minho" + "name": "NEEGIUM", + "long_name": "Núcleo de Estudantes de Engenharia e Gestão Industrial da Universidade do Minho", + "description": "O NEEGIUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Engenharia e Gestão Industrial, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEEUM", - "long_name": "Núcleo de Estudantes de Estatística da Universidade do Minho", - "description": "Núcleo de Estudantes de Estatística da Universidade do Minho" + "name": "NEEUM", + "long_name": "Núcleo de Estudantes de Estatística da Universidade do Minho", + "description": "O NEEUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Estatística, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEFILUM", - "long_name": "Núcleo de Estudantes de Filosofia da Universidade do Minho", - "description": "Núcleo de Estudantes de Filosofia da Universidade do Minho" + "name": "NEFILUM", + "long_name": "Núcleo de Estudantes de Filosofia da Universidade do Minho", + "description": "O NEFILUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Filosofia, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEFUM", - "long_name": "Núcleo de Estudantes de Física da Universidade do Minho", - "description": "Núcleo de Estudantes de Física da Universidade do Minho" + "name": "NEFUM", + "long_name": "Núcleo de Estudantes de Física da Universidade do Minho", + "description": "O NEFUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Física, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEIEEEUM", - "long_name": "Núcleo de Estudantes de Engenharia Eletrotécnica e de Computadores da Universidade do Minho", - "description": "Núcleo Estudantil do Institute of Electrical & Electronics Engineers da Universidade do Minho" + "name": "NEIEEEUM", + "long_name": "Núcleo Estudantil do Institute of Electrical & Electronics Engineers da Universidade do Minho", + "description": "O NEIEEUM é uma afiliação do Institute of Electrical & Electronics Engineers (IEEE) na Universidade do Minho." }, { - "name": "NELAUM", - "long_name": "Núcleo de Estudantes de Línguas Aplicadas da Universidade do Minho", - "description": "Núcleo de Estudantes de Línguas Aplicadas da Universidade do Minho" + "name": "NELAUM", + "long_name": "Núcleo de Estudantes de Línguas Aplicadas da Universidade do Minho", + "description": "O NELAUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Línguas Aplicadas, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEMUM", - "long_name": "Núcleo de Estudantes de Medicina da Universidade do Minho", - "description": "Núcleo de Estudantes de Medicina da Universidade do Minho" + "name": "NEMUM", + "long_name": "Núcleo de Estudantes de Medicina da Universidade do Minho", + "description": "O NEMUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Medicina, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NENIUM", - "long_name": "Núcleo de Estudantes de Negócios Internacionais da Universidade do Minho", - "description": "Núcleo de Estudantes de Negócios Internacionais da Universidade do Minho" + "name": "NENIUM", + "long_name": "Núcleo de Estudantes de Negócios Internacionais da Universidade do Minho", + "description": "O NENIUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Negócios Internacionais, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEOUM", - "long_name": "Núcleo de Estudantes de Optometria da Universidade do Minho", - "description": "Núcleo de Estudantes de Optometria da Universidade do Minho" + "name": "NEOUM", + "long_name": "Núcleo de Estudantes de Optometria da Universidade do Minho", + "description": "O NEOUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Optometria, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEPLUM", - "long_name": "Núcleo de Estudantes de Estudos Portugueses e Lusófonos da Universidade do Minho", - "description": "Núcleo de Estudantes de Estudos Portugueses e Lusófonos da Universidade do Minho" + "name": "NEPLUM", + "long_name": "Núcleo de Estudantes de Estudos Portugueses e Lusófonos da Universidade do Minho", + "description": "O NEPLUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Estudos Portugueses e Lusófonos, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "NEMKT", - "long_name": "Núcleo de Estudantes de Marketing da Universidade do Minho", - "description": "Núcleo de Estudantes de Marketing da Universidade do Minho" + "name": "NEMKT", + "long_name": "Núcleo de Estudantes de Marketing da Universidade do Minho", + "description": "O NEMKT é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Marketing, bem como a realização de atividades culturais, desportivas e de lazer." }, { "name": "NUCCLEUM", "long_name": "Núcleo de Estudantes de Linguas e Literaturas Europeias da Universidade do Minho", - "description": "Núcleo de Estudantes de Linguas e Literaturas Europeias da Universidade do Minho" + "description": "O NUCCLEUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Linguas e Literaturas Europeias, bem como a realização de atividades culturais, desportivas e de lazer." }, { - "name": "Núcleo de Estudante de Contabilidade da Universidade do Minho", - "long_name": "", - "description": "" + "name": "NECONTUM", + "long_name": "Núcleo de Estudante de Contabilidade da Universidade do Minho", + "description": "O NECONTUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Contabilidade, bem como a realização de atividades culturais, desportivas e de lazer." }, { "name": "NUMERUM", "long_name": "Núcleo de Estudantes de Matemática da Universidade do Minho", - "description": "Núcleo de Estudantes de Matemática da Universidade do Minho" + "description": "O NUMERUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Matemática, bem como a realização de atividades culturais, desportivas e de lazer." }, { "name": "PoliticUM", - "long_name": "", - "description": "" + "long_name": "Núcleo de Estudantes de Ciência Política da Universidade do Minho", + "description": "O PoliticUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a integração dos estudantes do curso de Ciência Política, bem como a realização de atividades culturais, desportivas e de lazer." } ] From 2c150fcd03133cec6ecf2e39d6e54690f96c040e Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Sat, 23 Mar 2024 16:53:27 +0000 Subject: [PATCH 02/21] draft: index page --- .../components/organization_card.ex | 47 +++++++++++++++---- .../live/organization_live/index.ex | 5 +- .../live/organization_live/index.html.heex | 19 +++++--- priv/fake/organizations.json | 2 +- 4 files changed, 55 insertions(+), 18 deletions(-) diff --git a/lib/atomic_web/live/organization_live/components/organization_card.ex b/lib/atomic_web/live/organization_live/components/organization_card.ex index 15eb388e8..a00480c2f 100644 --- a/lib/atomic_web/live/organization_live/components/organization_card.ex +++ b/lib/atomic_web/live/organization_live/components/organization_card.ex @@ -4,6 +4,7 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do alias Atomic.Organizations.Organization + import AtomicWeb.Components.Button import AtomicWeb.OrganizationLive.Components.OrganizationBannerPlaceholder attr :organization, Organization, required: true, doc: "The organization to display." @@ -11,21 +12,51 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do def organization_card(assigns) do ~H"""
-
+
<.organization_banner_placeholder organization={@organization} class="rounded-t-lg" />
-

- <%= @organization.name %> -

-
+
+
+
+
+ +
+
+
+

+ <%= @organization.name %> +

+
+
+ <.button> + Follow + +
+
+

<%= @organization.long_name %>

-
- <%= @organization.description %> -
+
    +
  • + <.icon name={:users} outline class="h-4 w-4" /> +

    + 103 followers +

    +
  • +
  • + <.icon name={:map_pin} outline class="h-4 w-4" /> +

    Building 7, 1.04

    +
  • +
  • + <.icon name={:link} outline class="h-4 w-4" /> + <%!-- FIXME: Should be an href --%> +

    https://cesium.di.uminho.pt

    +
  • + <%!-- TODO: List all other socials --%> +
""" diff --git a/lib/atomic_web/live/organization_live/index.ex b/lib/atomic_web/live/organization_live/index.ex index a5da4dcd5..0c2078946 100644 --- a/lib/atomic_web/live/organization_live/index.ex +++ b/lib/atomic_web/live/organization_live/index.ex @@ -2,8 +2,9 @@ defmodule AtomicWeb.OrganizationLive.Index do use AtomicWeb, :live_view alias Atomic.{Accounts, Organizations} + alias Phoenix.LiveView.JS - import AtomicWeb.Components.Pagination + import AtomicWeb.Components.{Forms, Pagination} import AtomicWeb.OrganizationLive.Components.OrganizationCard @impl true @@ -18,7 +19,7 @@ defmodule AtomicWeb.OrganizationLive.Index do {:noreply, socket |> assign(:page_title, "Organizations") - |> assign(:current_page, :organization) + |> assign(:current_page, :organizations) |> assign(:params, params) |> assign(:meta, meta) |> assign(:organizations, organizations) diff --git a/lib/atomic_web/live/organization_live/index.html.heex b/lib/atomic_web/live/organization_live/index.html.heex index 69237f67c..ad870310a 100644 --- a/lib/atomic_web/live/organization_live/index.html.heex +++ b/lib/atomic_web/live/organization_live/index.html.heex @@ -7,12 +7,17 @@ <% end %> -
    - <%= for organization <- @organizations do %> - <.link navigate="/"> - <.organization_card organization={organization} /> - - <% end %> -
+
+ <.field phx-mounted={JS.focus()} type="search" name="search" value="" label="" placeholder={"#{gettext("Search for an organization")}..."} /> +
    + <%= for organization <- @organizations do %> +
  • + <.link navigate="/"> + <.organization_card organization={organization} /> + +
  • + <% end %> +
+
<.pagination items={@organizations} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> diff --git a/priv/fake/organizations.json b/priv/fake/organizations.json index 7d5752430..7a5910b97 100644 --- a/priv/fake/organizations.json +++ b/priv/fake/organizations.json @@ -66,7 +66,7 @@ }, { "name": "CineFOCUM", - "long_name": "Núcleo de Cinemas da Universidade do Minho", + "long_name": "Núcleo de Cinema da Universidade do Minho", "description": "O CineFOCUM é um núcleo de estudantes da Universidade do Minho que tem como objetivo promover a cultura cinematográfica, bem como a realização de atividades culturais, desportivas e de lazer." }, { From d73f4b47b45699cde277aad3b36927620ef6d65b Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Tue, 6 Aug 2024 23:42:37 +0100 Subject: [PATCH 03/21] feat: realtime search feature --- lib/atomic/organizations/organization.ex | 2 +- lib/atomic_web.ex | 3 ++ lib/atomic_web/components/forms.ex | 11 +++++- .../live/organization_live/index.ex | 28 +++++++++++--- .../live/organization_live/index.html.heex | 30 +++++++-------- lib/atomic_web/live/organization_live/show.ex | 37 +++++++++++++++++++ .../live/organization_live/show.html.heex | 9 +++++ lib/atomic_web/router.ex | 3 ++ 8 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 lib/atomic_web/live/organization_live/show.ex create mode 100644 lib/atomic_web/live/organization_live/show.html.heex diff --git a/lib/atomic/organizations/organization.ex b/lib/atomic/organizations/organization.ex index 9db7602bb..2e1291c34 100644 --- a/lib/atomic/organizations/organization.ex +++ b/lib/atomic/organizations/organization.ex @@ -12,7 +12,7 @@ defmodule Atomic.Organizations.Organization do @derive { Flop.Schema, - filterable: [], + filterable: [:name], sortable: [:name], compound_fields: [search: [:name]], default_order: %{ diff --git a/lib/atomic_web.ex b/lib/atomic_web.ex index 53f6c7367..3f9b43358 100644 --- a/lib/atomic_web.ex +++ b/lib/atomic_web.ex @@ -23,6 +23,7 @@ defmodule AtomicWeb do import Plug.Conn import AtomicWeb.Gettext + alias AtomicWeb.Router.Helpers, as: Routes end end @@ -93,6 +94,8 @@ defmodule AtomicWeb do import Phoenix.LiveView.Helpers import Phoenix.Component + alias Phoenix.LiveView.JS + # Import commonly used components unquote(components()) diff --git a/lib/atomic_web/components/forms.ex b/lib/atomic_web/components/forms.ex index 2b224f0f9..daa248091 100644 --- a/lib/atomic_web/components/forms.ex +++ b/lib/atomic_web/components/forms.ex @@ -43,8 +43,15 @@ defmodule AtomicWeb.Components.Forms do """ attr :id, :any, default: nil, doc: "The id of the input. If not provided, it will be generated." attr :name, :any, doc: "The name of the input. If not provided, it will be generated." - attr :label, :string, doc: "The label for the input. If not provided, it will be generated." - attr :value, :any, doc: "The value of the input. If not provided, it will be generated." + + attr :label, :string, + doc: "The label for the input. If not provided, it will be generated.", + default: "" + + attr :value, :any, + doc: "The value of the input. If not provided, it will be generated.", + default: "" + attr :type, :string, default: "text", values: @input_types, doc: "The type of the input." attr :field, HTML.FormField, diff --git a/lib/atomic_web/live/organization_live/index.ex b/lib/atomic_web/live/organization_live/index.ex index 0c2078946..14cf0821b 100644 --- a/lib/atomic_web/live/organization_live/index.ex +++ b/lib/atomic_web/live/organization_live/index.ex @@ -2,33 +2,51 @@ defmodule AtomicWeb.OrganizationLive.Index do use AtomicWeb, :live_view alias Atomic.{Accounts, Organizations} - alias Phoenix.LiveView.JS import AtomicWeb.Components.{Forms, Pagination} import AtomicWeb.OrganizationLive.Components.OrganizationCard @impl true def mount(_params, _session, socket) do - {:ok, socket} + {:ok, + socket + |> assign(:form, to_form(%{"search" => ""})) + |> assign(:query, "")} end @impl true def handle_params(params, _, socket) do - %{organizations: organizations, meta: meta} = list_organizations(params) + %{organizations: organizations, meta: meta} = list_organizations(params, socket.assigns.query) {:noreply, socket |> assign(:page_title, "Organizations") |> assign(:current_page, :organizations) |> assign(:params, params) - |> assign(:meta, meta) |> assign(:organizations, organizations) + |> assign(:meta, meta) |> assign(:has_permissions?, has_permissions?(socket))} end - defp list_organizations(params) do + @impl true + def handle_event("search", %{"search" => query}, socket) do + %{organizations: organizations, meta: meta} = list_organizations(socket.assigns.params, query) + + {:noreply, + socket + |> assign(:query, query) + |> assign(:organizations, organizations) + |> assign(:meta, meta)} + end + + defp list_organizations(params, query) do params = Map.put(params, "page_size", 6) + params = + Map.put(params, "filters", %{ + filters: %{field: :name, op: :ilike, value: "%#{query}%"} + }) + case Organizations.list_organizations(params) do {:ok, {organizations, meta}} -> %{organizations: organizations, meta: meta} diff --git a/lib/atomic_web/live/organization_live/index.html.heex b/lib/atomic_web/live/organization_live/index.html.heex index ad870310a..fe017d0fc 100644 --- a/lib/atomic_web/live/organization_live/index.html.heex +++ b/lib/atomic_web/live/organization_live/index.html.heex @@ -1,23 +1,23 @@ <.page title={@page_title}> - <:actions> - <%= if @has_permissions? do %> - <.button patch="/" icon={:plus}> - <%= gettext("New") %> - - <% end %> + <:actions :if={@has_permissions?}> + <.button patch="/" icon={:plus}> + <%= gettext("New") %> + - +
- <.field phx-mounted={JS.focus()} type="search" name="search" value="" label="" placeholder={"#{gettext("Search for an organization")}..."} /> + <.form id="search-form" for={@form} phx-change="search"> + <.field phx-mounted={JS.focus()} value={@query} type="search" name="search" placeholder={"#{gettext("Search for an organization")}..."} /> + +
    - <%= for organization <- @organizations do %> -
  • - <.link navigate="/"> - <.organization_card organization={organization} /> - -
  • - <% end %> +
  • + <.link navigate={"/organizations/#{organization.id}"}> + <.organization_card organization={organization} /> + +
+ <.pagination items={@organizations} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> diff --git a/lib/atomic_web/live/organization_live/show.ex b/lib/atomic_web/live/organization_live/show.ex new file mode 100644 index 000000000..8f0f4c3fd --- /dev/null +++ b/lib/atomic_web/live/organization_live/show.ex @@ -0,0 +1,37 @@ +defmodule AtomicWeb.OrganizationLive.Show do + use AtomicWeb, :live_view + + alias Atomic.{Accounts, Organizations} + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + organization = Organizations.get_organization!(id) + + {:noreply, + socket + |> assign(:page_title, organization.name) + |> assign(:current_page, :organization) + |> assign(:organization, organization) + |> assign(:has_permissions?, has_permissions?(socket))} + end + + defp has_permissions?(socket) when not socket.assigns.is_authenticated?, do: false + + defp has_permissions?(socket) do + has_current_organization?(socket) and + (Accounts.has_permissions_inside_organization?( + socket.assigns.current_user.id, + socket.assigns.current_organization.id + ) or Accounts.has_master_permissions?(socket.assigns.current_user.id)) + end + + defp has_current_organization?(socket) do + is_map_key(socket.assigns, :current_organization) and + not is_nil(socket.assigns.current_organization) + end +end diff --git a/lib/atomic_web/live/organization_live/show.html.heex b/lib/atomic_web/live/organization_live/show.html.heex new file mode 100644 index 000000000..8e7ba24e0 --- /dev/null +++ b/lib/atomic_web/live/organization_live/show.html.heex @@ -0,0 +1,9 @@ +<.page title={@page_title}> + <:actions :if={@has_permissions?}> + <.button patch="/" icon={:pencil}> + <%= gettext("Edit") %> + + + + <%= @organization.name %> + diff --git a/lib/atomic_web/router.ex b/lib/atomic_web/router.ex index b9bcbd313..3ca57d6a6 100644 --- a/lib/atomic_web/router.ex +++ b/lib/atomic_web/router.ex @@ -92,7 +92,10 @@ defmodule AtomicWeb.Router do live "/", HomeLive.Index, :index live "/calendar", CalendarLive.Show, :show live "/activities", ActivityLive.Index, :index + live "/organizations", OrganizationLive.Index, :index + live "/organizations/:id", OrganizationLive.Show, :show + live "/announcements", AnnouncementLive.Index, :index live "/activities/:id", ActivityLive.Show, :show From 2018baf7e7e7292dbdc0f5bb8a91b0c72439eb8e Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Tue, 6 Aug 2024 23:51:02 +0100 Subject: [PATCH 04/21] refactor: improve responsivity of organization card --- .../live/organization_live/components/organization_card.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/atomic_web/live/organization_live/components/organization_card.ex b/lib/atomic_web/live/organization_live/components/organization_card.ex index a00480c2f..d2aea36e8 100644 --- a/lib/atomic_web/live/organization_live/components/organization_card.ex +++ b/lib/atomic_web/live/organization_live/components/organization_card.ex @@ -34,12 +34,12 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do
-
+

<%= @organization.long_name %>

-
    +
    • <.icon name={:users} outline class="h-4 w-4" />

      From 9c60b0b01800ec479b2bc938d8d35d08643ddf62 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Tue, 6 Aug 2024 23:56:00 +0100 Subject: [PATCH 05/21] refactor: make use of streams in index page --- lib/atomic_web/live/organization_live/index.ex | 8 +++++--- lib/atomic_web/live/organization_live/index.html.heex | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/atomic_web/live/organization_live/index.ex b/lib/atomic_web/live/organization_live/index.ex index 14cf0821b..85f382971 100644 --- a/lib/atomic_web/live/organization_live/index.ex +++ b/lib/atomic_web/live/organization_live/index.ex @@ -8,9 +8,11 @@ defmodule AtomicWeb.OrganizationLive.Index do @impl true def mount(_params, _session, socket) do + form = to_form(%{}, as: "search") + {:ok, socket - |> assign(:form, to_form(%{"search" => ""})) + |> assign(:form, form) |> assign(:query, "")} end @@ -23,7 +25,7 @@ defmodule AtomicWeb.OrganizationLive.Index do |> assign(:page_title, "Organizations") |> assign(:current_page, :organizations) |> assign(:params, params) - |> assign(:organizations, organizations) + |> stream(:organizations, organizations) |> assign(:meta, meta) |> assign(:has_permissions?, has_permissions?(socket))} end @@ -35,7 +37,7 @@ defmodule AtomicWeb.OrganizationLive.Index do {:noreply, socket |> assign(:query, query) - |> assign(:organizations, organizations) + |> stream(:organizations, organizations, reset: true) |> assign(:meta, meta)} end diff --git a/lib/atomic_web/live/organization_live/index.html.heex b/lib/atomic_web/live/organization_live/index.html.heex index fe017d0fc..cd69763ac 100644 --- a/lib/atomic_web/live/organization_live/index.html.heex +++ b/lib/atomic_web/live/organization_live/index.html.heex @@ -11,7 +11,7 @@

        -
      • +
      • <.link navigate={"/organizations/#{organization.id}"}> <.organization_card organization={organization} /> @@ -19,5 +19,5 @@
- <.pagination items={@organizations} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> + <.pagination items={@streams.organizations} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> From 4c8d5b3dbae2a74321d95b34d06e95d34183a286 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Fri, 16 Aug 2024 18:11:52 +0100 Subject: [PATCH 06/21] refactor: organization card and socials module --- lib/atomic/organizations.ex | 44 ++++++++--- lib/atomic/organizations/organization.ex | 10 ++- lib/atomic/socials/socials.ex | 62 +++++++++++++-- lib/atomic_web/components/avatar.ex | 2 +- .../components/organization_card.ex | 78 ++++++++++++------- .../live/organization_live/index.ex | 2 +- .../live/organization_live/index.html.heex | 2 +- mix.exs | 2 +- mix.lock | 2 +- .../2022000000000_create_organizations.exs | 4 +- priv/repo/seeds/organizations.exs | 16 ++-- priv/repo/seeds/partners.exs | 5 +- priv/static/images/facebook.svg | 2 +- priv/static/images/x.svg | 2 +- 14 files changed, 170 insertions(+), 63 deletions(-) diff --git a/lib/atomic/organizations.ex b/lib/atomic/organizations.ex index 98f23f588..1c6396bd5 100644 --- a/lib/atomic/organizations.ex +++ b/lib/atomic/organizations.ex @@ -284,15 +284,15 @@ defmodule Atomic.Organizations do end @doc """ - Verifies if an user is a member of an organization. + Verifies if an user is a member of an organization. - ## Examples + ## Examples - iex> member_of?(user, organization) - true + iex> member_of?(user, organization) + true - iex> member_of?(user, organization) - false + iex> member_of?(user, organization) + false """ def member_of?(%User{} = user, %Organization{} = organization) do @@ -301,6 +301,27 @@ defmodule Atomic.Organizations do |> Repo.exists?() end + @doc """ + Checks if an user is following an organization. + + ## Examples + + iex> user_following?(123, 456) + true + + iex> user_following?(456, 789) + false + + """ + def user_following?(user_id, organization_id) do + Membership + |> where( + [m], + m.user_id == ^user_id and m.organization_id == ^organization_id and m.role == :follower + ) + |> Repo.exists?() + end + @doc """ Gets an user role in an organization. @@ -437,15 +458,16 @@ defmodule Atomic.Organizations do end @doc """ - Returns the amount of followers in an organization. + Counts the number of followers in an organization. ## Examples - iex> count_followers("99d7c9e5-4212-4f59-a097-28aaa33c2621") + iex> count_followers(123) 5 - iex> count_followers("9as7c9e5-4212-4f59-a097-28aaa33c2621") - 100_000_000_000_000_000_000_000_000 + iex> count_followers(456) + 0 + """ def count_followers(organization_id) do Membership @@ -467,6 +489,8 @@ defmodule Atomic.Organizations do |> Repo.aggregate(:count, :id) end + ## Announcements + @doc """ Returns the list of announcements. diff --git a/lib/atomic/organizations/organization.ex b/lib/atomic/organizations/organization.ex index 2e1291c34..dd68f0bc5 100644 --- a/lib/atomic/organizations/organization.ex +++ b/lib/atomic/organizations/organization.ex @@ -3,12 +3,12 @@ defmodule Atomic.Organizations.Organization do use Atomic.Schema alias Atomic.Accounts.User - alias Atomic.Location alias Atomic.Organizations.{Announcement, Department, Membership, Partner} alias Atomic.Uploaders + alias Atomic.Socials @required_fields ~w(name long_name description)a - @optional_fields ~w()a + @optional_fields ~w(location)a @derive { Flop.Schema, @@ -27,7 +27,9 @@ defmodule Atomic.Organizations.Organization do field :description, :string field :logo, Uploaders.Logo.Type - embeds_one :location, Location, on_replace: :delete + field :location, :string + + embeds_one :socials, Socials, on_replace: :update has_many :departments, Department, on_replace: :delete_if_exists, @@ -51,7 +53,7 @@ defmodule Atomic.Organizations.Organization do def changeset(organization, attrs) do organization |> cast(attrs, @required_fields ++ @optional_fields) - |> cast_embed(:location, with: &Location.changeset/2) + |> cast_embed(:socials, with: &Socials.changeset/2) |> validate_required(@required_fields) |> unique_constraint(:name) end diff --git a/lib/atomic/socials/socials.ex b/lib/atomic/socials/socials.ex index 4c82efcdc..b58af1bf5 100644 --- a/lib/atomic/socials/socials.ex +++ b/lib/atomic/socials/socials.ex @@ -1,19 +1,20 @@ defmodule Atomic.Socials do @moduledoc """ - A socials embedded struct schema. + An embedded schema for social media handles or links. + + This schema stores the information just as it is, without any processing. """ use Atomic.Schema - @optional_fields ~w(instagram facebook x youtube tiktok website)a + @optional_fields ~w(facebook instagram x linkedin website)a @derive Jason.Encoder @primary_key false embedded_schema do - field :instagram, :string field :facebook, :string + field :instagram, :string field :x, :string - field :youtube, :string - field :tiktok, :string + field :linkedin, :string field :website, :string end @@ -22,4 +23,55 @@ defmodule Atomic.Socials do |> cast(attrs, @optional_fields) |> validate_format(:website, ~r{^https?://}, message: "must start with http:// or https://") end + + def link(:facebook, handle), do: "https://facebook.com/#{handle}" + def link(:instagram, handle), do: "https://instagram.com/#{handle}" + def link(:x, handle), do: "https://x.com/#{handle}" + def link(:linkedin, handle), do: "https://linkedin.com/#{handle}" + + @doc """ + Function providing SVG icons for social media platforms with a default size of 4 tailwind units. + + ## Examples + + iex> Socials.icon(:facebook) |> raw() + ... + + iex> Socials.icon(:instagram, 6) |> raw() + ... + + """ + def icon(platform, size \\ 4) + + def icon(:facebook, size) do + """ + + """ + end + + def icon(:instagram, size) do + """ + + """ + end + + def icon(:x, size) do + """ + + """ + end + + def icon(:linkedin, size) do + """ + + """ + end end diff --git a/lib/atomic_web/components/avatar.ex b/lib/atomic_web/components/avatar.ex index 9dacd363a..5dafd147f 100644 --- a/lib/atomic_web/components/avatar.ex +++ b/lib/atomic_web/components/avatar.ex @@ -36,7 +36,7 @@ defmodule AtomicWeb.Components.Avatar do :light, :dark ], - doc: "Button color." + doc: "Background color of the avatar." attr :class, :string, default: "", doc: "Additional classes to apply to the component." diff --git a/lib/atomic_web/live/organization_live/components/organization_card.ex b/lib/atomic_web/live/organization_live/components/organization_card.ex index d2aea36e8..518f455ab 100644 --- a/lib/atomic_web/live/organization_live/components/organization_card.ex +++ b/lib/atomic_web/live/organization_live/components/organization_card.ex @@ -2,25 +2,35 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do @moduledoc false use AtomicWeb, :component + alias Atomic.Accounts.User + alias Atomic.Organizations alias Atomic.Organizations.Organization + alias Atomic.Uploaders - import AtomicWeb.Components.Button + import AtomicWeb.Components.{Avatar, Button} import AtomicWeb.OrganizationLive.Components.OrganizationBannerPlaceholder - attr :organization, Organization, required: true, doc: "The organization to display." + attr :organization, Organization, required: true, doc: "The organization to display" + attr :current_user, User, required: false, default: nil, doc: "The current user, if any" def organization_card(assigns) do + assigns = Map.put(assigns, :followers, Organizations.count_followers(assigns.organization.id)) + ~H"""
<.organization_banner_placeholder organization={@organization} class="rounded-t-lg" />
+
-
- +
+ <.avatar name={@organization.name} color={:white} size={:lg} class="!size-32 relative p-1" type={:organization} src={Uploaders.Logo.url({@organization.logo, @organization}, :original)} />
@@ -28,34 +38,48 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do <%= @organization.name %>

-
- <.button> - Follow - -
-
-
-

- <%= @organization.long_name %> -

+ + <%= if @current_user do %> + <%= if Organizations.user_following?(@current_user.id, @organization.id) do %> + <.button disabled><%= gettext("Following") %> + <% else %> + <%!-- TODO: Complete functionality --%> + <.button><%= gettext("Follow") %> + <% end %> + <% end %>
-
    + +

    + <%= @organization.long_name %> +

    + +
    • - <.icon name={:users} outline class="h-4 w-4" /> -

      - 103 followers -

      + <.icon name={:users} outline class="size-4" /> + <%= if @followers != 1 do %> +

      + <%= @followers %> followers +

      + <% else %> +

      + 1 follower +

      + <% end %>
    • -
    • - <.icon name={:map_pin} outline class="h-4 w-4" /> -

      Building 7, 1.04

      + +
    • + <.icon name={:map_pin} outline class="size-4" /> +

      <%= @organization.location %>

    • -
    • - <.icon name={:link} outline class="h-4 w-4" /> - <%!-- FIXME: Should be an href --%> -

      https://cesium.di.uminho.pt

      + +
    • + + <.link href={@organization.socials.website} target="_blank" class="flex items-center space-x-1"> + <.icon name={:link} outline class="size-4" /> +

      <%= @organization.socials.website %>

      + +
    • - <%!-- TODO: List all other socials --%>
diff --git a/lib/atomic_web/live/organization_live/index.ex b/lib/atomic_web/live/organization_live/index.ex index 85f382971..4fb3f4b74 100644 --- a/lib/atomic_web/live/organization_live/index.ex +++ b/lib/atomic_web/live/organization_live/index.ex @@ -46,7 +46,7 @@ defmodule AtomicWeb.OrganizationLive.Index do params = Map.put(params, "filters", %{ - filters: %{field: :name, op: :ilike, value: "%#{query}%"} + filters: %{field: :name, op: :ilike, value: query} }) case Organizations.list_organizations(params) do diff --git a/lib/atomic_web/live/organization_live/index.html.heex b/lib/atomic_web/live/organization_live/index.html.heex index cd69763ac..3f227647a 100644 --- a/lib/atomic_web/live/organization_live/index.html.heex +++ b/lib/atomic_web/live/organization_live/index.html.heex @@ -13,7 +13,7 @@
  • <.link navigate={"/organizations/#{organization.id}"}> - <.organization_card organization={organization} /> + <.organization_card organization={organization} current_user={@current_user} />
diff --git a/mix.exs b/mix.exs index 338e8792a..6c3a90b56 100644 --- a/mix.exs +++ b/mix.exs @@ -44,7 +44,7 @@ defmodule Atomic.MixProject do {:ecto_sql, "~> 3.6"}, {:phoenix_ecto, "~> 4.4"}, {:postgrex, ">= 0.0.0"}, - {:flop, "~> 0.17.0"}, + {:flop, "~> 0.20.2"}, {:paginator, "~> 1.2.0"}, # security diff --git a/mix.lock b/mix.lock index 2d2480764..c4f14431e 100644 --- a/mix.lock +++ b/mix.lock @@ -24,7 +24,7 @@ "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, - "flop": {:hex, :flop, "0.17.2", "9408f71a91350f8904e221a6b82e4429acee5aba5c1abbfd7901d2465b7aa44c", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "8bd987e353bffff3687cc7203bfdeb83942d8de6172dc833d651438d477115fc"}, + "flop": {:hex, :flop, "0.20.3", "68c9de1be58ecb8da54a2613bea6d50ea809a514fd814276bbe000fd5ca4cd45", [:mix], [{:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "9e499a5896d50f5aed1c1d200a0395975902a5623b706591b8ac183e1af09545"}, "flop_phoenix": {:hex, :flop_phoenix, "0.20.0", "acb2d94c79a2184a6c78c542fc552b0a14565a06f4e48479ded9dced398277dc", [:mix], [{:flop, ">= 0.17.1 and < 0.22.0", [hex: :flop, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6.0 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b7a328e2aa634278560973bdaac4eebcb326f945a5832c02413730c1cbc4417f"}, "gen_stage": {:hex, :gen_stage, "1.2.1", "19d8b5e9a5996d813b8245338a28246307fd8b9c99d1237de199d21efc4c76a1", [:mix], [], "hexpm", "83e8be657fa05b992ffa6ac1e3af6d57aa50aace8f691fcf696ff02f8335b001"}, "gettext": {:hex, :gettext, "0.25.0", "98a95a862a94e2d55d24520dd79256a15c87ea75b49673a2e2f206e6ebc42e5d", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "38e5d754e66af37980a94fb93bb20dcde1d2361f664b0a19f01e87296634051f"}, diff --git a/priv/repo/migrations/2022000000000_create_organizations.exs b/priv/repo/migrations/2022000000000_create_organizations.exs index d725bdd89..517a0def8 100644 --- a/priv/repo/migrations/2022000000000_create_organizations.exs +++ b/priv/repo/migrations/2022000000000_create_organizations.exs @@ -10,7 +10,9 @@ defmodule Atomic.Repo.Migrations.CreateOrganizations do add :description, :text, null: false add :logo, :string - add :location, :map + add :location, :string + + add :socials, :map timestamps() end diff --git a/priv/repo/seeds/organizations.exs b/priv/repo/seeds/organizations.exs index 2aaf5fea8..a70199e95 100644 --- a/priv/repo/seeds/organizations.exs +++ b/priv/repo/seeds/organizations.exs @@ -22,13 +22,16 @@ defmodule Atomic.Repo.Seeds.Organizations do # Seed CeSIUM %Organization{ name: "CeSIUM", - long_name: - "CeSIUM - Centro de Estudantes de Engenharia Informática da Universidade do Minho", + long_name: "Centro de Estudantes de Engenharia Informática da Universidade do Minho", description: "O CeSIUM é um grupo de estudantes voluntários, que tem como objetivo representar e promover o curso de Engenharia Informática 💾 na UMinho 🎓", - location: %{ - name: "Departamento de Informática, Campus de Gualtar, Universidade do Minho", - url: "https://cesium.di.uminho.pt" + location: "Edifício 7, Universidade do Minho", + socials: %{ + facebook: "cesiuminho", + instagram: "cesiuminho", + x: "cesiuminho", + linkedin: "cesiuminho", + website: "https://cesium.di.uminho.pt" } } |> Repo.insert!() @@ -47,7 +50,8 @@ defmodule Atomic.Repo.Seeds.Organizations do %{ name: organization["name"], long_name: organization["long_name"], - description: organization["description"] + description: organization["description"], + website: Faker.Internet.url() } |> Organizations.create_organization() end) diff --git a/priv/repo/seeds/partners.exs b/priv/repo/seeds/partners.exs index 0d50bb0bd..74f4f1bd3 100644 --- a/priv/repo/seeds/partners.exs +++ b/priv/repo/seeds/partners.exs @@ -24,11 +24,10 @@ defmodule Atomic.Repo.Seeds.Partners do } socials = %{ - instagram: Faker.Internet.slug(), facebook: Faker.Internet.slug(), + instagram: Faker.Internet.slug(), x: Faker.Internet.slug(), - youtube: Faker.Internet.slug(), - tiktok: Faker.Internet.slug(), + linkedin: Faker.Internet.slug(), website: Faker.Internet.url() } diff --git a/priv/static/images/facebook.svg b/priv/static/images/facebook.svg index 92b7016c8..a3471f59c 100644 --- a/priv/static/images/facebook.svg +++ b/priv/static/images/facebook.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/priv/static/images/x.svg b/priv/static/images/x.svg index e704e9dfb..181016d4d 100644 --- a/priv/static/images/x.svg +++ b/priv/static/images/x.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file From ad46f9db982461a17b4b6de14cf4cd7098a45490 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Fri, 16 Aug 2024 18:37:20 +0100 Subject: [PATCH 07/21] refactor: partner show page to make use of new socials abstraction --- lib/atomic/location/location.ex | 3 + lib/atomic_web/live/partner_live/show.ex | 8 +- .../live/partner_live/show.html.heex | 150 +++++++++--------- 3 files changed, 83 insertions(+), 78 deletions(-) diff --git a/lib/atomic/location/location.ex b/lib/atomic/location/location.ex index 3b6234f43..673bd7e57 100644 --- a/lib/atomic/location/location.ex +++ b/lib/atomic/location/location.ex @@ -19,4 +19,7 @@ defmodule Atomic.Location do |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) end + + def link(location) when is_map_key(location, :url), do: location.url + def link(location), do: "https://www.google.com/maps/search/?api=1&query=#{location.name}" end diff --git a/lib/atomic_web/live/partner_live/show.ex b/lib/atomic_web/live/partner_live/show.ex index c2718ddd3..b0f01acf2 100644 --- a/lib/atomic_web/live/partner_live/show.ex +++ b/lib/atomic_web/live/partner_live/show.ex @@ -3,9 +3,8 @@ defmodule AtomicWeb.PartnerLive.Show do import AtomicWeb.Components.Avatar - alias Atomic.Accounts - alias Atomic.Organizations - alias Atomic.Partners + alias Atomic.{Accounts, Organizations, Partners} + alias Atomic.{Location, Socials} @impl true def mount(_params, _session, socket) do @@ -30,6 +29,9 @@ defmodule AtomicWeb.PartnerLive.Show do |> assign(:has_permissions?, has_permissions?(socket, organization_id))} end + defp related_partners(current, partners), + do: Enum.filter(partners, fn partner -> partner.id != current.id end) + defp has_permissions?(socket, _organization_id) when not socket.assigns.is_authenticated?, do: false diff --git a/lib/atomic_web/live/partner_live/show.html.heex b/lib/atomic_web/live/partner_live/show.html.heex index 1f3815899..89c085af2 100644 --- a/lib/atomic_web/live/partner_live/show.html.heex +++ b/lib/atomic_web/live/partner_live/show.html.heex @@ -1,65 +1,65 @@ -<.page title="Partners"> - <:actions> - <%= if @has_permissions? do %> -
- <.button navigate={Routes.partner_edit_path(@socket, :edit, @organization, @partner)} icon={:pencil}> - <%= gettext("Edit Partner") %> - -
- <% end %> +<.page title={@page_title}> + <:actions :if={@has_permissions?}> + <.button navigate={Routes.partner_edit_path(@socket, :edit, @organization, @partner)} icon={:pencil}> + <%= gettext("Edit Partner") %> + -
-
+ +
+
-
+
<.avatar color={:light_gray} name={@partner.name} src={Uploaders.PartnerImage.url({@partner.image, @partner}, :original)} type={:company} size={:xl} />
+
-

+

<%= @partner.name %>

- <%= if @partner.location do %> -
- <.icon name={:map_pin} class="h-5 w-5 text-zinc-400" /> - <.link class="text-blue-500" href={"https://www.google.com/maps/search/?api=1&query=#{@partner.location.name}"}><%= @partner.location.name %> -
- <% end %> - <%= if @partner.socials do %> -
- <%= if @partner.socials.website do %> -
- <.icon name={:globe_alt} class="h-5 w-5 text-zinc-400" /> - <.link class="text-blue-500" href={@partner.socials.website}>Website -
- <% end %> - <%= if @partner.socials.instagram do %> -
- Instagram - <.link class="text-blue-500" href={"https://instagram.com/" <> @partner.socials.instagram}>Instagram -
- <% end %> - <%= if @partner.socials.facebook do %> -
- Facebook - <.link class="text-blue-500" href={"https://facebook.com/" <> @partner.socials.facebook}>Facebook -
- <% end %> - <%= if @partner.socials.x do %> -
- X - <.link class="text-blue-500" href={"https://x.com/" <> @partner.socials.x}>X -
- <% end %> -
- <% end %> + + <.link :if={@partner.location} href={Location.link(@partner.location)} target="_blank" class="group flex flex-row items-center space-x-1"> + <.icon name={:map_pin} class="size-4" /> +

<%= @partner.location.name %>

+ + +
    +
  • + <.link href={@partner.socials.website} target="_blank" class="group flex flex-row items-center space-x-1"> + <.icon name={:link} class="size-4" /> +

    <%= @partner.socials.website %>

    + +
  • + +
  • + <.link href={Socials.link(:instagram, @partner.socials.instagram)} class="group flex flex-row items-center space-x-1"> + <%= Socials.icon(:instagram) |> raw() %> +

    <%= @partner.socials.instagram %>

    + +
  • + +
  • + <.link href={Socials.link(:facebook, @partner.socials.facebook)} class="group flex flex-row items-center space-x-1"> + <%= Socials.icon(:facebook) |> raw() %> +

    <%= @partner.socials.facebook %>

    + +
  • + +
  • + <.link href={Socials.link(:x, @partner.socials.x)} class="group flex flex-row items-center space-x-1"> + <%= Socials.icon(:x) |> raw() %> +

    <%= @partner.socials.x %>

    + +
  • +
+
-
+
- <.icon name={:clock} class="h-5 w-5 mb-2" /> + <.icon name={:clock} class="mb-2 h-5 w-5" />

Overview

@@ -68,9 +68,9 @@ <% end) %>
-
+
- <.icon name={:signal} class="h-5 w-5 mb-2" /> + <.icon name={:signal} class="mb-2 h-5 w-5" />

Benefits

@@ -83,33 +83,33 @@ <% end) %>
- <%= if @partners do %> -
-
- <.icon name={:star} class="h-5 w-5 mb-2" /> -

Related Partners

-
-
- <%= for partner <- @partners |> Enum.filter(fn partner -> partner.id != @partner.id end) do %> -
- <.link href={Routes.partner_show_path(@socket, :show, @organization, partner)}> -
- <.avatar color={:light_gray} name={partner.name} src={Uploaders.PartnerImage.url({partner.image, partner}, :original)} type={:company} size={:xs} /> -

<%= partner.name %>

-
- <%= if partner.location do %> -
- <.icon name={:map_pin} class="h-5 w-5 text-zinc-400" /> - <%= partner.location.name %> -
- <% end %> - <%= partner.description %> - + +
+
+ <.icon name={:star} class="mb-2 h-5 w-5" /> +

<%= gettext("Related Partners") %>

+
+ +
+
+ <.link href={Routes.partner_show_path(@socket, :show, @organization, partner)}> +
+ <.avatar color={:light_gray} name={partner.name} src={Uploaders.PartnerImage.url({partner.image, partner}, :original)} type={:company} size={:xs} /> + +
+

<%= partner.name %>

+ <.link :if={partner.location} href={Location.link(partner.location)} target="_blank" class="group flex flex-row items-center space-x-1"> + <.icon name={:map_pin} class="size-4" /> +

<%= partner.location.name %>

+ +
- <% end %> + + <%= partner.description %> +
- <% end %> +
From 790609fb91a411ba316f3dd6bcce17fe51f24713 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Fri, 16 Aug 2024 18:40:14 +0100 Subject: [PATCH 08/21] fix: ditch repeated gradient component --- .../organization_banner_placeholder.ex | 40 ------------------- .../components/organization_card.ex | 5 +-- 2 files changed, 2 insertions(+), 43 deletions(-) delete mode 100644 lib/atomic_web/live/organization_live/components/organization_banner_placeholder.ex diff --git a/lib/atomic_web/live/organization_live/components/organization_banner_placeholder.ex b/lib/atomic_web/live/organization_live/components/organization_banner_placeholder.ex deleted file mode 100644 index ba6debe91..000000000 --- a/lib/atomic_web/live/organization_live/components/organization_banner_placeholder.ex +++ /dev/null @@ -1,40 +0,0 @@ -defmodule AtomicWeb.OrganizationLive.Components.OrganizationBannerPlaceholder do - @moduledoc false - use AtomicWeb, :component - - def organization_banner_placeholder(assigns) do - {gradient_color_a, gradient_color_b} = generate_color(assigns.organization.id) - - assigns - |> assign(:gradient_color_a, gradient_color_a) - |> assign(:gradient_color_b, gradient_color_b) - |> render_gradient() - end - - defp render_gradient(assigns) do - ~H""" -
- """ - end - - defp generate_color(uuid) when is_binary(uuid) do - # List of gradients - colors = [ - {"#000046", "#1CB5E0"}, - {"#007991", "#78ffd6"}, - {"#30E8BF", "#FF8235"}, - {"#C33764", "#1D2671"}, - {"#34e89e", "#0f3443"}, - {"#44A08D", "#093637"}, - {"#DCE35B", "#45B649"}, - {"#c0c0aa", "#1cefff"}, - {"#ee0979", "#ff6a00"} - ] - - # Convert the UUID to an integer - index = :erlang.phash2(uuid, length(colors)) - - # Return the chosen color - Enum.at(colors, index) - end -end diff --git a/lib/atomic_web/live/organization_live/components/organization_card.ex b/lib/atomic_web/live/organization_live/components/organization_card.ex index 518f455ab..3e18f3577 100644 --- a/lib/atomic_web/live/organization_live/components/organization_card.ex +++ b/lib/atomic_web/live/organization_live/components/organization_card.ex @@ -7,8 +7,7 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do alias Atomic.Organizations.Organization alias Atomic.Uploaders - import AtomicWeb.Components.{Avatar, Button} - import AtomicWeb.OrganizationLive.Components.OrganizationBannerPlaceholder + import AtomicWeb.Components.{Avatar, Button, Gradient} attr :organization, Organization, required: true, doc: "The organization to display" attr :current_user, User, required: false, default: nil, doc: "The current user, if any" @@ -19,7 +18,7 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do ~H"""
- <.organization_banner_placeholder organization={@organization} class="rounded-t-lg" /> + <.gradient seed={@organization.id} class="rounded-t-lg" />
From 011e8e8a57c9281c6ed5b15ce7356dab56f04b98 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Fri, 16 Aug 2024 19:15:27 +0100 Subject: [PATCH 09/21] refactor: department show page to make use of page component with before slot --- lib/atomic_web/components/page.ex | 17 +- .../live/department_live/show.html.heex | 434 +++++++++--------- .../components/organization_card.ex | 7 +- 3 files changed, 226 insertions(+), 232 deletions(-) diff --git a/lib/atomic_web/components/page.ex b/lib/atomic_web/components/page.ex index cb5ae06af..17e6947b6 100644 --- a/lib/atomic_web/components/page.ex +++ b/lib/atomic_web/components/page.ex @@ -5,24 +5,35 @@ defmodule AtomicWeb.Components.Page do use Phoenix.Component attr :title, :string, required: true, doc: "The title of the page." + attr :description, :string, required: false, default: nil, doc: "The description of the page." attr :bottom_border, :boolean, default: false, doc: "Whether to show a bottom border after the page header." + slot :before, optional: true, doc: "Slot for content to be rendered before the page header." + slot :actions, optional: true, doc: "Slot for actions to be rendered in the page header." slot :inner_block, optional: true, doc: "Slot for the body content of the page." def page(assigns) do ~H""" + <%= render_slot(@before) %> +
-

- <%= @title %> -

+
+

+ <%= @title %> +

+

+ <%= @description %> +

+
+ <%= render_slot(@actions) %>
diff --git a/lib/atomic_web/live/department_live/show.html.heex b/lib/atomic_web/live/department_live/show.html.heex index 2bc70f3f5..b0f3d3f5c 100644 --- a/lib/atomic_web/live/department_live/show.html.heex +++ b/lib/atomic_web/live/department_live/show.html.heex @@ -1,249 +1,227 @@ -
- -
- <%= if @department.banner do %> - - <% else %> - <.gradient seed={@department.id} class="object-cover" /> - <% end %> -
-
-
-
-
-
- -
-

- <%= @department.name %> -

- <.link navigate={Routes.organization_show_path(@socket, :show, @organization)}> -

- @<%= @organization.name %> -

- -
- - -
- <%= if @current_view == "show" do %> - - <%= if !@current_collaborator do %> - <%= if @department.collaborator_applications do %> - <.button - phx-click={ +<.page title={@page_title} description={"@#{@organization.name}"}> + <:before> +
+ <%= if @department.banner do %> + + <% else %> + <.gradient seed={@department.id} class="object-cover" /> + <% end %> +
+ + + <:actions> +
+ <%= if @current_view == "show" do %> + + <%= if !@current_collaborator do %> + <%= if @department.collaborator_applications do %> + <.button + phx-click={ "#{if @is_authenticated? do "collaborate" else "must-login" end}" } - color={:white} - icon={:user_plus} - icon_variant={:solid} - title={gettext("Collaborate")} - /> - <% end %> - <% else %> - <%= if ! @current_collaborator.accepted do %> - <.button color={:white} icon={:user_plus} icon_variant={:solid} aria-label={gettext("You have requested to collaborate with this department. Please wait for the department owner to accept your request.")} disabled /> - <% end %> - <% end %> - <.dropdown - id="actions" - items={ - [ - %{ - name: gettext("Collaborators"), - link: - Routes.department_show_path( - @socket, - :show, - @organization.id, - @department.id, - tab: "collaborators" - ), - icon: :user_group - } - ] ++ - if @has_permissions? || (@current_collaborator && @current_collaborator.accepted) do - [%{name: gettext("Edit"), link: Routes.department_edit_path(@socket, :edit, @organization, @department, @params), icon: :pencil}] - else - [] - end - } - > - <:wrapper> - <.button icon_variant={:solid} color={:white} icon={:ellipsis_horizontal} /> - - - <% end %> -
- - + + + + +
+
+

<%= gettext("Recent Activity") %>

+ + <.link class="mt-4 flex flex-col items-center justify-center" navigate={Routes.organization_show_path(@socket, :show, @organization)}> + <.avatar class="mb-4 p-1" type={:organization} color={:white} size={:lg} name={@organization.name} src={Uploaders.Logo.url({@organization.logo, @organization}, :original)} /> +

<%= gettext("This department doesn't have any recent activity.") %>

+

<%= gettext("In the meantime, check out %{organization_name}.", organization_name: @organization.name) %>

+ +
+ + +
+ + <%= if @current_view == "collaborators" do %> + <%= if length(@collaborators) != 0 do %> + <%= if @has_permissions? do %> + +
+

Collaborators

+
+ <.table items={@collaborators} meta={@meta} filter={[]}> + <:col :let={collaborator} label="Name" field={:string}><%= collaborator.user.name %> + <:col :let={collaborator} label="Email" field={:string}><%= collaborator.user.email %> + <:col :let={collaborator} label="Phone number" field={:string}><%= collaborator.user.phone_number %> + <:col :let={collaborator} label="Accepted" field={:string}> + + + <:col :let={collaborator}> + <%= if collaborator.accepted do %> + <.button icon={:pencil} icon_variant={:solid} color={:white} full_width patch={Routes.department_show_path(@socket, :edit_collaborator, @organization, @department, collaborator, @params)}>Edit <% else %> - <%= if ! @current_collaborator.accepted do %> - <.button color={:white} icon={:user_plus} icon_variant={:solid} aria-label={gettext("You have requested to collaborate with this department. Please wait for the department owner to accept your request.")} disabled> - <%= gettext("Collaborate") %> - - <% end %> - <% end %> - <%= if @has_permissions? || (@current_collaborator && @current_collaborator.accepted) do %> - - <.button - icon={:user_group} - icon_variant={:solid} - color={:white} - patch={ - Routes.department_show_path( - @socket, - :show, - @organization.id, - @department.id, - tab: "collaborators" - ) - } - /> - <% end %> - - <%= if @has_permissions? do %> - <.button icon={:pencil} icon_variant={:solid} color={:white} patch={Routes.department_edit_path(@socket, :edit, @organization, @department, @params)} /> + <.button icon={:envelope} icon_variant={:solid} color={:white} full_width patch={Routes.department_show_path(@socket, :edit_collaborator, @organization, @department, collaborator, @params)}>Review <% end %> - <% else %> - - <.button - icon={:arrow_left} - icon_variant={:solid} - color={:white} - patch={ - Routes.department_show_path( - @socket, - :show, - @organization.id, - @department.id - ) - } - /> - <% end %> -
+ +
+ <.pagination items={@collaborators} meta={@meta} params={@params} class="flex w-full items-center justify-between border border-t-0 pt-2" />
- <%= if @current_view == "show" do %> - -
- <%= @department.description %> -
-
-
-

<%= gettext("Recent Activity") %>

- - <.link class="flex flex-col items-center justify-center mt-4" navigate={Routes.organization_show_path(@socket, :show, @organization)}> - <.avatar class="mb-4 p-1" type={:organization} color={:white} size={:lg} name={@organization.name} src={Uploaders.Logo.url({@organization.logo, @organization}, :original)} /> -

<%= gettext("This department doesn't have any recent activity.") %>

-

<%= gettext("In the meantime, check out %{organization_name}.", organization_name: @organization.name) %>

- -
- - -
- <% end %> - <%= if @current_view == "collaborators" do %> - <%= if length(@collaborators) != 0 do %> - <%= if @has_permissions? do %> - -
-

Collaborators

-
- <.table items={@collaborators} meta={@meta} filter={[]}> - <:col :let={collaborator} label="Name" field={:string}><%= collaborator.user.name %> - <:col :let={collaborator} label="Email" field={:string}><%= collaborator.user.email %> - <:col :let={collaborator} label="Phone number" field={:string}><%= collaborator.user.phone_number %> - <:col :let={collaborator} label="Accepted" field={:string}> - - - <:col :let={collaborator}> - <%= if collaborator.accepted do %> - <.button icon={:pencil} icon_variant={:solid} color={:white} full_width patch={Routes.department_show_path(@socket, :edit_collaborator, @organization, @department, collaborator, @params)}>Edit - <% else %> - <.button icon={:envelope} icon_variant={:solid} color={:white} full_width patch={Routes.department_show_path(@socket, :edit_collaborator, @organization, @department, collaborator, @params)}>Review - <% end %> - - + <% else %> + +
+

Collaborators

+ <%= for collaborator <- @collaborators do %> +
+
+ <.avatar name={collaborator.user.name} /> +
+

<%= collaborator.user.name %>

+

@<%= collaborator.user.slug %>

- <.pagination items={@collaborators} meta={@meta} params={@params} class="flex w-full items-center justify-between border border-t-0 pt-2" /> -
- <% else %> - -
-

Collaborators

- <%= for collaborator <- @collaborators do %> -
-
- <.avatar name={collaborator.user.name} /> -
-

<%= collaborator.user.name %>

-

@<%= collaborator.user.slug %>

-
-
-
- <% end %> - <.pagination items={@collaborators} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" />
- <% end %> +
<% end %> - <% end %> -
-
-
-
-<.modal :if={@live_action in [:edit_collaborator]} id="edit-collaborator" show on_cancel={JS.patch(Routes.department_show_path(@socket, :show, @organization, @department, Map.delete(@params, "collaborator_id")))}> + <.pagination items={@collaborators} meta={@meta} params={@params} class="mt-2 flex w-full items-center justify-between" /> +
+ <% end %> + <% end %> + <% end %> + + +<.modal :if={@live_action == :edit_collaborator} id="edit-collaborator" show on_cancel={JS.patch(Routes.department_show_path(@socket, :show, @organization, @department, Map.delete(@params, "collaborator_id")))}> <.live_component module={AtomicWeb.CollaboratorLive.FormComponent} id={@collaborator.id} title={@page_title} action={@live_action} collaborator={@collaborator} department={@department} /> diff --git a/lib/atomic_web/live/organization_live/components/organization_card.ex b/lib/atomic_web/live/organization_live/components/organization_card.ex index 3e18f3577..fb78ce7eb 100644 --- a/lib/atomic_web/live/organization_live/components/organization_card.ex +++ b/lib/atomic_web/live/organization_live/components/organization_card.ex @@ -17,8 +17,13 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do ~H"""
-
+
+ + <%!-- <%= if @organization.banner do %> + + <% else %> --%> <.gradient seed={@organization.id} class="rounded-t-lg" /> + <%!-- <% end %> --%>
From e2d2219801222f95ea74aa3deedcf0f008aa89c6 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Mon, 19 Aug 2024 18:15:45 +0100 Subject: [PATCH 10/21] feat: show page with about tab --- lib/atomic/organizations/organization.ex | 3 +- lib/atomic_web/components/page.ex | 4 +- lib/atomic_web/components/tabs.ex | 9 ++- lib/atomic_web/config.ex | 2 +- .../live/announcement_live/index.html.heex | 2 +- .../live/department_live/show.html.heex | 4 +- .../components/organization_about.ex | 64 +++++++++++++++ .../components/organization_card.ex | 61 +++++++------- lib/atomic_web/live/organization_live/show.ex | 9 ++- .../live/organization_live/show.html.heex | 79 +++++++++++++++++-- priv/repo/seeds/organizations.exs | 2 +- 11 files changed, 188 insertions(+), 51 deletions(-) create mode 100644 lib/atomic_web/live/organization_live/components/organization_about.ex diff --git a/lib/atomic/organizations/organization.ex b/lib/atomic/organizations/organization.ex index dd68f0bc5..98f1b2464 100644 --- a/lib/atomic/organizations/organization.ex +++ b/lib/atomic/organizations/organization.ex @@ -4,8 +4,7 @@ defmodule Atomic.Organizations.Organization do alias Atomic.Accounts.User alias Atomic.Organizations.{Announcement, Department, Membership, Partner} - alias Atomic.Uploaders - alias Atomic.Socials + alias Atomic.{Socials, Uploaders} @required_fields ~w(name long_name description)a @optional_fields ~w(location)a diff --git a/lib/atomic_web/components/page.ex b/lib/atomic_web/components/page.ex index 17e6947b6..297981852 100644 --- a/lib/atomic_web/components/page.ex +++ b/lib/atomic_web/components/page.ex @@ -11,14 +11,14 @@ defmodule AtomicWeb.Components.Page do default: false, doc: "Whether to show a bottom border after the page header." - slot :before, optional: true, doc: "Slot for content to be rendered before the page header." + slot :header, optional: true, doc: "Slot for content to be rendered as the page header." slot :actions, optional: true, doc: "Slot for actions to be rendered in the page header." slot :inner_block, optional: true, doc: "Slot for the body content of the page." def page(assigns) do ~H""" - <%= render_slot(@before) %> + <%= render_slot(@header) %>
diff --git a/lib/atomic_web/components/tabs.ex b/lib/atomic_web/components/tabs.ex index 58c0fe728..87f08b21f 100644 --- a/lib/atomic_web/components/tabs.ex +++ b/lib/atomic_web/components/tabs.ex @@ -2,24 +2,25 @@ defmodule AtomicWeb.Components.Tabs do @moduledoc false use AtomicWeb, :component - attr :class, :string, default: "", doc: "The class to apply to the tabs" attr :underline, :boolean, default: true, doc: "Whether to show a bottom border on the tabs" + attr :class, :string, default: "", doc: "The class to apply to the tabs" attr :rest, :global + slot :inner_block, required: false def tabs(assigns) do ~H""" -
<%= render_slot(@inner_block) %> -
+ """ end diff --git a/lib/atomic_web/config.ex b/lib/atomic_web/config.ex index 72503fd54..f93c1af3e 100644 --- a/lib/atomic_web/config.ex +++ b/lib/atomic_web/config.ex @@ -45,7 +45,7 @@ defmodule AtomicWeb.Config do %{ key: :partners, title: "Partners", - icon: :user_group, + icon: :heart, url: Routes.partner_index_path(conn, :index, current_organization.id), tabs: [] }, diff --git a/lib/atomic_web/live/announcement_live/index.html.heex b/lib/atomic_web/live/announcement_live/index.html.heex index fe6cce42f..2d969183b 100644 --- a/lib/atomic_web/live/announcement_live/index.html.heex +++ b/lib/atomic_web/live/announcement_live/index.html.heex @@ -7,7 +7,7 @@ <% end %> - <.tabs class="max-w-5-xl mx-auto px-4 sm:px-6 lg:px-8"> + <.tabs class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8"> <.link patch="?tab=all" replace={false}> <.tab active={@current_tab == "all"}> <%= gettext("All") %> diff --git a/lib/atomic_web/live/department_live/show.html.heex b/lib/atomic_web/live/department_live/show.html.heex index b0f3d3f5c..50eaeb782 100644 --- a/lib/atomic_web/live/department_live/show.html.heex +++ b/lib/atomic_web/live/department_live/show.html.heex @@ -1,5 +1,5 @@ <.page title={@page_title} description={"@#{@organization.name}"}> - <:before> + <:header>
<%= if @department.banner do %> @@ -7,7 +7,7 @@ <.gradient seed={@department.id} class="object-cover" /> <% end %>
- + <:actions>
diff --git a/lib/atomic_web/live/organization_live/components/organization_about.ex b/lib/atomic_web/live/organization_live/components/organization_about.ex new file mode 100644 index 000000000..bea7438c4 --- /dev/null +++ b/lib/atomic_web/live/organization_live/components/organization_about.ex @@ -0,0 +1,64 @@ +defmodule AtomicWeb.OrganizationLive.Components.OrganizationAbout do + @moduledoc false + use AtomicWeb, :component + + alias Atomic.Organizations.Organization + alias Atomic.Socials + + attr :organization, Organization, required: true, doc: "The organization which about to display" + + def organization_about(assigns) do + ~H""" +
+

<%= gettext("Description") %>

+

<%= @organization.description %>

+ +
+

<%= gettext("Location") %>

+

<%= @organization.location %>

+
+ +
+

<%= gettext("Socials") %>

+ +
    +
  • + <.link href={@organization.socials.website} target="_blank" class="group flex items-center space-x-1"> + <.icon name={:link} outline class="size-4" /> +

    <%= @organization.socials.website %>

    + +
  • + +
  • + <.link href={Socials.link(:facebook, @organization.socials.facebook)} target="_blank" class="group flex items-center space-x-1"> + <%= Socials.icon(:facebook) |> raw() %> +

    <%= @organization.socials.facebook %>

    + +
  • + +
  • + <.link href={Socials.link(:instagram, @organization.socials.instagram)} target="_blank" class="group flex items-center space-x-1"> + <%= Socials.icon(:instagram) |> raw() %> +

    <%= @organization.socials.instagram %>

    + +
  • + +
  • + <.link href={Socials.link(:x, @organization.socials.x)} target="_blank" class="group flex items-center space-x-1"> + <%= Socials.icon(:x) |> raw() %> +

    <%= @organization.socials.x %>

    + +
  • + +
  • + <.link href={Socials.link(:linkedin, @organization.socials.linkedin)} target="_blank" class="group flex items-center space-x-1"> + <%= Socials.icon(:linkedin) |> raw() %> +

    <%= @organization.socials.linkedin %>

    + +
  • +
+
+
+ """ + end +end diff --git a/lib/atomic_web/live/organization_live/components/organization_card.ex b/lib/atomic_web/live/organization_live/components/organization_card.ex index fb78ce7eb..dd1ef1092 100644 --- a/lib/atomic_web/live/organization_live/components/organization_card.ex +++ b/lib/atomic_web/live/organization_live/components/organization_card.ex @@ -34,8 +34,7 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do "relative rounded-lg", @organization.logo && "bg-white" ]}> - <.avatar name={@organization.name} color={:white} size={:lg} class="!size-32 relative p-1" type={:organization} src={Uploaders.Logo.url({@organization.logo, @organization}, :original)} /> -
+ <.avatar name={@organization.name} color={:white} size={:lg} class="!size-32 p-1" type={:organization} src={Uploaders.Logo.url({@organization.logo, @organization}, :original)} />

@@ -43,12 +42,12 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do

+ <%!-- TODO: Maybe show button when there's no current user, but with a must login warning? --%> <%= if @current_user do %> <%= if Organizations.user_following?(@current_user.id, @organization.id) do %> - <.button disabled><%= gettext("Following") %> + <.button icon={:star} icon_variant={:solid}><%= gettext("Following") %> <% else %> - <%!-- TODO: Complete functionality --%> - <.button><%= gettext("Follow") %> + <.button icon={:star}><%= gettext("Follow") %> <% end %> <% end %>
@@ -57,34 +56,34 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationCard do <%= @organization.long_name %>

-
    -
  • - <.icon name={:users} outline class="size-4" /> - <%= if @followers != 1 do %> -

    - <%= @followers %> followers -

    - <% else %> -

    - 1 follower -

    - <% end %> -
  • +
    +
      +
    • + <.icon name={:users} outline class="size-4" /> + <%= if @followers != 1 do %> +

      + <%= @followers %> followers +

      + <% else %> +

      + 1 follower +

      + <% end %> +
    • -
    • - <.icon name={:map_pin} outline class="size-4" /> -

      <%= @organization.location %>

      -
    • +
    • + <.icon name={:map_pin} outline class="size-4" /> +

      <%= @organization.location %>

      +
    • +
    -
  • - - <.link href={@organization.socials.website} target="_blank" class="flex items-center space-x-1"> - <.icon name={:link} outline class="size-4" /> -

    <%= @organization.socials.website %>

    - -
    -
  • -
+
+ <.link href={@organization.socials.website} target="_blank" class="flex items-center space-x-1"> + <.icon name={:link} outline class="size-4" /> +

<%= @organization.socials.website %>

+ +
+
""" diff --git a/lib/atomic_web/live/organization_live/show.ex b/lib/atomic_web/live/organization_live/show.ex index 8f0f4c3fd..ada697179 100644 --- a/lib/atomic_web/live/organization_live/show.ex +++ b/lib/atomic_web/live/organization_live/show.ex @@ -3,23 +3,30 @@ defmodule AtomicWeb.OrganizationLive.Show do alias Atomic.{Accounts, Organizations} + import AtomicWeb.OrganizationLive.Components.OrganizationAbout + import AtomicWeb.Components.{Gradient, Tabs} + @impl true def mount(_params, _session, socket) do {:ok, socket} end @impl true - def handle_params(%{"id" => id}, _, socket) do + def handle_params(%{"id" => id} = params, _, socket) do organization = Organizations.get_organization!(id) {:noreply, socket |> assign(:page_title, organization.name) |> assign(:current_page, :organization) + |> assign(:current_tab, current_tab(socket, params)) |> assign(:organization, organization) |> assign(:has_permissions?, has_permissions?(socket))} end + defp current_tab(_socket, params) when is_map_key(params, "tab"), do: params["tab"] + defp current_tab(_socket, _params), do: "about" + defp has_permissions?(socket) when not socket.assigns.is_authenticated?, do: false defp has_permissions?(socket) do diff --git a/lib/atomic_web/live/organization_live/show.html.heex b/lib/atomic_web/live/organization_live/show.html.heex index 8e7ba24e0..6aef37887 100644 --- a/lib/atomic_web/live/organization_live/show.html.heex +++ b/lib/atomic_web/live/organization_live/show.html.heex @@ -1,9 +1,76 @@ -<.page title={@page_title}> - <:actions :if={@has_permissions?}> - <.button patch="/" icon={:pencil}> - <%= gettext("Edit") %> - +<.page title={@page_title} description="@cesium"> + <:header> +
+ <%!-- FIXME: Add banner support --%> + <%!-- <%= if @organization.banner do %> + + <% else %> --%> + <.gradient seed={@organization.id} class="object-cover" /> + <%!-- <% end %> --%> +
+ + + <:actions> +
+ <%= if @has_permissions? do %> +

<%= gettext("Edit") %>

+ <.button icon={:pencil} icon_variant={:solid} color={:white} /> + <% end %> + + <%= if @current_user do %> +

<%= gettext("Contact") %>

+ <.link href="mailto:cesium@di.uminho.pt" target="_blank"> + <.button icon={:envelope} icon_variant={:solid} color={:white} /> + + + <%!-- TODO: Maybe show button when there's no current user, but with a must login warning? --%> + <%= if !Organizations.user_following?(@current_user.id, @organization.id) do %> + <.button icon={:star} icon_variant={:solid} color={:white}><%= gettext("Following") %> + <% else %> + <.button icon={:star}><%= gettext("Follow") %> + <% end %> + <% end %> +
- <%= @organization.name %> + <.tabs class="overflow-scroll scrollbar-hide flex px-4 sm:px-6 lg:px-8"> + <.link patch="?tab=about" replace={false}> + <.tab id="about-tab" active={@current_tab == "about"}> + <.icon name={:information_circle} class="size-5 mr-2" /> + <%= gettext("About") %> + + + + <.link patch="?tab=posts" replace={false}> + <.tab id="posts-tab" active={@current_tab == "posts"}> + <.icon name={:newspaper} class="size-5 mr-2" /> + <%= gettext("Posts") %> + + + + <.link patch="?tab=departments" replace={false}> + <.tab id="department-tab" active={@current_tab == "departments"}> + <.icon name={:cube} class="size-5 mr-2" /> + <%= gettext("Departments") %> + + + + <.link patch="?tab=partners" replace={false}> + <.tab id="partners-tab" active={@current_tab == "partners"}> + <.icon name={:heart} class="size-5 mr-2" /> + <%= gettext("Partners") %> + + + + <.link patch="?tab=members" replace={false}> + <.tab id="members-tab" active={@current_tab == "members"}> + <.icon name={:user_group} class="size-5 mr-2" /> + <%= gettext("Members") %> + + + + +
+ <.organization_about organization={@organization} /> +
diff --git a/priv/repo/seeds/organizations.exs b/priv/repo/seeds/organizations.exs index a70199e95..75f132750 100644 --- a/priv/repo/seeds/organizations.exs +++ b/priv/repo/seeds/organizations.exs @@ -31,7 +31,7 @@ defmodule Atomic.Repo.Seeds.Organizations do instagram: "cesiuminho", x: "cesiuminho", linkedin: "cesiuminho", - website: "https://cesium.di.uminho.pt" + website: "https://cesium.pt" } } |> Repo.insert!() From b31e981b73e7390501f25e2df57bfa2a8fbf7e28 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Tue, 10 Sep 2024 12:49:06 +0100 Subject: [PATCH 11/21] feat: departments tab --- lib/atomic/departments.ex | 19 ++------- lib/atomic_web/live/department_live/index.ex | 4 +- lib/atomic_web/live/department_live/show.ex | 4 +- .../components/organization_about.ex | 4 +- .../components/organization_departments.ex | 42 +++++++++++++++++++ lib/atomic_web/live/organization_live/show.ex | 2 +- .../live/organization_live/show.html.heex | 12 ++++-- 7 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 lib/atomic_web/live/organization_live/components/organization_departments.ex diff --git a/lib/atomic/departments.ex b/lib/atomic/departments.ex index 1abf710d0..068776b75 100644 --- a/lib/atomic/departments.ex +++ b/lib/atomic/departments.ex @@ -194,19 +194,6 @@ defmodule Atomic.Departments do Repo.all(Collaborator) end - @doc """ - Returns the list of collaborators belonging to an organization. - - ## Examples - - iex> list_collaborators_by_organization_id("99d7c9e5-4212-4f59-a097-28aaa33c2621") - [%Collaborator{}, ...] - - """ - def list_collaborators_by_organization_id(id) do - Repo.all(from p in Collaborator, where: p.organization_id == ^id) - end - @doc """ Gets a single collaborator. @@ -363,14 +350,14 @@ defmodule Atomic.Departments do ## Examples - iex> list_collaborators_by_department_id("99d7c9e5-4212-4f59-a097-28aaa33c2621") + iex> list_department_collaborators(123) [%Collaborator{}, ...] """ - def list_collaborators_by_department_id(id, opts \\ []) do + def list_department_collaborators(id, opts \\ []) do Collaborator - |> apply_filters(opts) |> where([c], c.department_id == ^id) + |> apply_filters(opts) |> Repo.all() end diff --git a/lib/atomic_web/live/department_live/index.ex b/lib/atomic_web/live/department_live/index.ex index a5396b57a..dfb26d9c4 100644 --- a/lib/atomic_web/live/department_live/index.ex +++ b/lib/atomic_web/live/department_live/index.ex @@ -45,7 +45,7 @@ defmodule AtomicWeb.DepartmentLive.Index do |> Enum.map(fn department -> collaborators = department.id - |> Departments.list_collaborators_by_department_id( + |> Departments.list_department_collaborators( preloads: [:user], where: [accepted: true] ) @@ -60,7 +60,7 @@ defmodule AtomicWeb.DepartmentLive.Index do |> Enum.map(fn department -> collaborators = department.id - |> Departments.list_collaborators_by_department_id( + |> Departments.list_department_collaborators( preloads: [:user], where: [accepted: true] ) diff --git a/lib/atomic_web/live/department_live/show.ex b/lib/atomic_web/live/department_live/show.ex index 85f88883c..ee5d70bf9 100644 --- a/lib/atomic_web/live/department_live/show.ex +++ b/lib/atomic_web/live/department_live/show.ex @@ -39,7 +39,7 @@ defmodule AtomicWeb.DepartmentLive.Show do |> assign(list_collaborators(department.id, params, has_permissions)) |> assign( :all_collaborators, - Departments.list_collaborators_by_department_id(department.id, + Departments.list_department_collaborators(department.id, preloads: [:user], where: [accepted: true] ) @@ -73,7 +73,7 @@ defmodule AtomicWeb.DepartmentLive.Show do |> assign(list_collaborators(department.id, params, has_permissions)) |> assign( :all_collaborators, - Departments.list_collaborators_by_department_id(department.id, + Departments.list_department_collaborators(department.id, preloads: [:user], where: [accepted: true] ) diff --git a/lib/atomic_web/live/organization_live/components/organization_about.ex b/lib/atomic_web/live/organization_live/components/organization_about.ex index bea7438c4..bd186fb85 100644 --- a/lib/atomic_web/live/organization_live/components/organization_about.ex +++ b/lib/atomic_web/live/organization_live/components/organization_about.ex @@ -5,11 +5,11 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationAbout do alias Atomic.Organizations.Organization alias Atomic.Socials - attr :organization, Organization, required: true, doc: "The organization which about to display" + attr :organization, Organization, required: true, doc: "the organization which about to display" def organization_about(assigns) do ~H""" -
+

<%= gettext("Description") %>

<%= @organization.description %>

diff --git a/lib/atomic_web/live/organization_live/components/organization_departments.ex b/lib/atomic_web/live/organization_live/components/organization_departments.ex new file mode 100644 index 000000000..9c03680df --- /dev/null +++ b/lib/atomic_web/live/organization_live/components/organization_departments.ex @@ -0,0 +1,42 @@ +defmodule AtomicWeb.OrganizationLive.Components.OrganizationDepartments do + @moduledoc """ + Internal organization-related component for displaying its departments. + """ + use AtomicWeb, :component + + alias Atomic.Departments + alias Atomic.Organizations.{Department, Organization} + + # FIXME: This should be a shared component + import AtomicWeb.DepartmentLive.Components.DepartmentCard + + attr :organization, Organization, + required: true, + doc: "the organization which departments to display" + + def organization_departments(assigns) do + ~H""" +
+ <%= for department <- list_departments(@organization) do %> + <.link navigate={Routes.department_show_path(AtomicWeb.Endpoint, :show, @organization, department)}> + <.department_card department={department} collaborators={list_department_collaborators(department)} /> + + <% end %> +
+ """ + end + + defp list_departments(%Organization{} = organization) do + Departments.list_departments_by_organization_id(organization.id, + preloads: [:organization], + where: [archived: false] + ) + end + + defp list_department_collaborators(%Department{} = department) do + Departments.list_department_collaborators(department.id, + preloads: [:user], + where: [accepted: true] + ) + end +end diff --git a/lib/atomic_web/live/organization_live/show.ex b/lib/atomic_web/live/organization_live/show.ex index ada697179..8897753b4 100644 --- a/lib/atomic_web/live/organization_live/show.ex +++ b/lib/atomic_web/live/organization_live/show.ex @@ -3,8 +3,8 @@ defmodule AtomicWeb.OrganizationLive.Show do alias Atomic.{Accounts, Organizations} - import AtomicWeb.OrganizationLive.Components.OrganizationAbout import AtomicWeb.Components.{Gradient, Tabs} + import AtomicWeb.OrganizationLive.Components.{OrganizationAbout, OrganizationDepartments} @impl true def mount(_params, _session, socket) do diff --git a/lib/atomic_web/live/organization_live/show.html.heex b/lib/atomic_web/live/organization_live/show.html.heex index 6aef37887..b151c7828 100644 --- a/lib/atomic_web/live/organization_live/show.html.heex +++ b/lib/atomic_web/live/organization_live/show.html.heex @@ -24,7 +24,7 @@ <%!-- TODO: Maybe show button when there's no current user, but with a must login warning? --%> - <%= if !Organizations.user_following?(@current_user.id, @organization.id) do %> + <%= if Organizations.user_following?(@current_user.id, @organization.id) do %> <.button icon={:star} icon_variant={:solid} color={:white}><%= gettext("Following") %> <% else %> <.button icon={:star}><%= gettext("Follow") %> @@ -70,7 +70,13 @@ -
- <.organization_about organization={@organization} /> +
+
+ <.organization_about organization={@organization} /> +
+ +
+ <.organization_departments organization={@organization} /> +
From 54c627e2c4bc6aa3f497463a217dfaad1b6785a5 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Wed, 11 Sep 2024 16:59:48 +0100 Subject: [PATCH 12/21] feat: members tab --- lib/atomic/organizations.ex | 39 ++++++------- lib/atomic_web/live/home_live/index.ex | 4 +- .../{organization_about.ex => about.ex} | 4 +- ...ion_departments.ex => departments_grid.ex} | 4 +- .../components/membership_banner.ex | 55 +++++++++++++++++++ .../components/memberships_table.ex | 52 ++++++++++++++++++ lib/atomic_web/live/organization_live/show.ex | 15 ++++- .../live/organization_live/show.html.heex | 21 +++++-- test/atomic/organizations_test.exs | 12 +--- 9 files changed, 162 insertions(+), 44 deletions(-) rename lib/atomic_web/live/organization_live/components/{organization_about.ex => about.ex} (96%) rename lib/atomic_web/live/organization_live/components/{organization_departments.ex => departments_grid.ex} (91%) create mode 100644 lib/atomic_web/live/organization_live/components/membership_banner.ex create mode 100644 lib/atomic_web/live/organization_live/components/memberships_table.ex diff --git a/lib/atomic/organizations.ex b/lib/atomic/organizations.ex index 1c6396bd5..96e5e3b0d 100644 --- a/lib/atomic/organizations.ex +++ b/lib/atomic/organizations.ex @@ -136,17 +136,25 @@ defmodule Atomic.Organizations do end @doc """ - Returns the list of organizations where an user is an admin or owner. + Returns the list of organizations which are connected with the user. + By default, it returns the organizations where the user is an admin or owner. ## Examples - iex> list_user_organizations(user_id) + iex> list_user_organizations(123) [%Organization{}, ...] + + iex> list_user_organizations(456) + [] + + iex> list_user_organizations(123, [:follower]) + [%Organization{}, ...] + """ - def list_user_organizations(user_id, opts \\ []) do + def list_user_organizations(user_id, roles \\ [:admin, :owner], opts \\ []) do Organization |> join(:inner, [o], m in Membership, on: m.organization_id == o.id) - |> where([o, m], m.user_id == ^user_id and m.role in [:admin, :owner]) + |> where([o, m], m.user_id == ^user_id and m.role in ^roles) |> apply_filters(opts) |> Repo.all() end @@ -256,30 +264,19 @@ defmodule Atomic.Organizations do end @doc """ - Returns the list of memberships. + Returns the list of members in an organization. + A member is someone who is connected to the organization with a role other than `:follower`. ## Examples - iex> list_memberships(%{"organization_id" => id}) - [%Organization{}, ...] - - iex> list_memberships(%{"user_id" => id}) + iex> list_memberships(123) [%Organization{}, ...] """ - def list_memberships(params, preloads \\ []) - - def list_memberships(%{"organization_id" => organization_id}, preloads) do + def list_memberships(organization_id, opts \\ []) do Membership - |> where([a], a.organization_id == ^organization_id and a.role != :follower) - |> Repo.all() - |> Repo.preload(preloads) - end - - def list_memberships(%{"user_id" => user_id}, preloads) do - Membership - |> where([a], a.user_id == ^user_id) - |> Repo.preload(preloads) + |> where([m], m.organization_id == ^organization_id and m.role != :follower) + |> apply_filters(opts) |> Repo.all() end diff --git a/lib/atomic_web/live/home_live/index.ex b/lib/atomic_web/live/home_live/index.ex index f4ebf82a5..a30dab65a 100644 --- a/lib/atomic_web/live/home_live/index.ex +++ b/lib/atomic_web/live/home_live/index.ex @@ -75,8 +75,8 @@ defmodule AtomicWeb.HomeLive.Index do current_user = socket.assigns.current_user %{entries: entries, metadata: metadata} = - Organizations.list_memberships(%{"user_id" => current_user.id}) - |> Enum.map(& &1.organization_id) + Organizations.list_user_organizations(current_user.id, [:follower]) + |> Enum.map(& &1.id) |> Feed.list_posts_following_paginated([]) {:noreply, diff --git a/lib/atomic_web/live/organization_live/components/organization_about.ex b/lib/atomic_web/live/organization_live/components/about.ex similarity index 96% rename from lib/atomic_web/live/organization_live/components/organization_about.ex rename to lib/atomic_web/live/organization_live/components/about.ex index bd186fb85..27400dcba 100644 --- a/lib/atomic_web/live/organization_live/components/organization_about.ex +++ b/lib/atomic_web/live/organization_live/components/about.ex @@ -1,4 +1,4 @@ -defmodule AtomicWeb.OrganizationLive.Components.OrganizationAbout do +defmodule AtomicWeb.OrganizationLive.Components.About do @moduledoc false use AtomicWeb, :component @@ -7,7 +7,7 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationAbout do attr :organization, Organization, required: true, doc: "the organization which about to display" - def organization_about(assigns) do + def about(assigns) do ~H"""

<%= gettext("Description") %>

diff --git a/lib/atomic_web/live/organization_live/components/organization_departments.ex b/lib/atomic_web/live/organization_live/components/departments_grid.ex similarity index 91% rename from lib/atomic_web/live/organization_live/components/organization_departments.ex rename to lib/atomic_web/live/organization_live/components/departments_grid.ex index 9c03680df..967482f24 100644 --- a/lib/atomic_web/live/organization_live/components/organization_departments.ex +++ b/lib/atomic_web/live/organization_live/components/departments_grid.ex @@ -1,4 +1,4 @@ -defmodule AtomicWeb.OrganizationLive.Components.OrganizationDepartments do +defmodule AtomicWeb.OrganizationLive.Components.DepartmentsGrid do @moduledoc """ Internal organization-related component for displaying its departments. """ @@ -14,7 +14,7 @@ defmodule AtomicWeb.OrganizationLive.Components.OrganizationDepartments do required: true, doc: "the organization which departments to display" - def organization_departments(assigns) do + def departments_grid(assigns) do ~H"""
<%= for department <- list_departments(@organization) do %> diff --git a/lib/atomic_web/live/organization_live/components/membership_banner.ex b/lib/atomic_web/live/organization_live/components/membership_banner.ex new file mode 100644 index 000000000..4f15f97d6 --- /dev/null +++ b/lib/atomic_web/live/organization_live/components/membership_banner.ex @@ -0,0 +1,55 @@ +defmodule AtomicWeb.OrganizationLive.Components.MembershipBanner do + @moduledoc """ + Organization membership banner component. Displays information about the organization's membership benefits and price. + """ + use AtomicWeb, :component + + alias Atomic.Organizations.Organization + + attr :organization, Organization, + required: true, + doc: "the organization which membership banner to display" + + def membership_banner(assigns) do + ~H""" +
+
+

<%= gettext("Lifetime membership") %>

+
+

<%= gettext("What’s included") %>

+
+
+ +
    +
  • + <.icon name={:check} class="h-6 w-5 flex-none text-orange-600" /> +

    Access to our room facilities

    +
  • +
  • + <.icon name={:check} class="h-6 w-5 flex-none text-orange-600" /> +

    Free access to all activities

    +
  • +
  • + <.icon name={:check} class="h-6 w-5 flex-none text-orange-600" /> +

    Official member t-shirt

    +
  • +
+
+ +
+
+
+

<%= gettext("Pay once, be a member forever") %>

+

+ 10€ + EUR +

+ <.button icon={:banknotes} class="mt-10 text-sm"><%= gettext("Request your membership") %> +

<%= gettext("Payments should be made within our location.") %> <%= @organization.location %>

+
+
+
+
+ """ + end +end diff --git a/lib/atomic_web/live/organization_live/components/memberships_table.ex b/lib/atomic_web/live/organization_live/components/memberships_table.ex new file mode 100644 index 000000000..aab028646 --- /dev/null +++ b/lib/atomic_web/live/organization_live/components/memberships_table.ex @@ -0,0 +1,52 @@ +defmodule AtomicWeb.OrganizationLive.Components.MembershipsTable do + @moduledoc """ + Internal organization-related component for displaying its memberships in the form of a table. + """ + use AtomicWeb, :component + + import AtomicWeb.Components.Avatar + + attr :members, :list, required: true, doc: "the list of memberships to display" + + # TODO: Make use of table component + def memberships_table(assigns) do + ~H""" +
+
+
+ + + + + + + + + + + + + + + + +
<%= gettext("Name") %><%= gettext("Role") %><%= gettext("Joined At") %>
+
+ <.avatar name={member.user.name} size={:sm} color={:light_gray} class="ring-1 ring-white" /> +
+
<%= member.user.name %>
+
<%= member.user.email %>
+
+
+
<%= capitalize_first_letter(member.role) %><%= relative_datetime(member.inserted_at) %>
+
+
+
+ """ + end + + defp row_click(member) do + Routes.profile_show_path(AtomicWeb.Endpoint, :show, member.user) + |> JS.navigate() + end +end diff --git a/lib/atomic_web/live/organization_live/show.ex b/lib/atomic_web/live/organization_live/show.ex index 8897753b4..05334371f 100644 --- a/lib/atomic_web/live/organization_live/show.ex +++ b/lib/atomic_web/live/organization_live/show.ex @@ -4,7 +4,13 @@ defmodule AtomicWeb.OrganizationLive.Show do alias Atomic.{Accounts, Organizations} import AtomicWeb.Components.{Gradient, Tabs} - import AtomicWeb.OrganizationLive.Components.{OrganizationAbout, OrganizationDepartments} + + import AtomicWeb.OrganizationLive.Components.{ + About, + DepartmentsGrid, + MembershipsTable, + MembershipBanner + } @impl true def mount(_params, _session, socket) do @@ -14,6 +20,7 @@ defmodule AtomicWeb.OrganizationLive.Show do @impl true def handle_params(%{"id" => id} = params, _, socket) do organization = Organizations.get_organization!(id) + members = maybe_list_members(organization.id, params["tab"]) {:noreply, socket @@ -21,9 +28,15 @@ defmodule AtomicWeb.OrganizationLive.Show do |> assign(:current_page, :organization) |> assign(:current_tab, current_tab(socket, params)) |> assign(:organization, organization) + |> assign(:members, members) |> assign(:has_permissions?, has_permissions?(socket))} end + defp maybe_list_members(organization_id, "members"), + do: Organizations.list_memberships(organization_id, preloads: [:user]) + + defp maybe_list_members(_organization, _tab), do: nil + defp current_tab(_socket, params) when is_map_key(params, "tab"), do: params["tab"] defp current_tab(_socket, _params), do: "about" diff --git a/lib/atomic_web/live/organization_live/show.html.heex b/lib/atomic_web/live/organization_live/show.html.heex index b151c7828..039c6f3e6 100644 --- a/lib/atomic_web/live/organization_live/show.html.heex +++ b/lib/atomic_web/live/organization_live/show.html.heex @@ -70,13 +70,24 @@ -
-
- <.organization_about organization={@organization} /> +
+
+ <.about organization={@organization} />
-
- <.organization_departments organization={@organization} /> +
+ <.departments_grid organization={@organization} /> +
+ +
+ <.membership_banner organization={@organization} /> +
+ +
+ +
+ + <.memberships_table members={@members} />
diff --git a/test/atomic/organizations_test.exs b/test/atomic/organizations_test.exs index a116ba5a0..b42d25f88 100644 --- a/test/atomic/organizations_test.exs +++ b/test/atomic/organizations_test.exs @@ -78,17 +78,7 @@ defmodule Atomic.OrganizationsTest do membership = insert(:membership, role: :admin) memberships = - Organizations.list_memberships(%{"organization_id" => membership.organization_id}) - |> Enum.map(& &1.id) - - assert memberships == [membership.id] - end - - test "list_memberships/1 returns all memberships of user" do - membership = insert(:membership) - - memberships = - Organizations.list_memberships(%{"user_id" => membership.user_id}) + Organizations.list_memberships(membership.organization_id) |> Enum.map(& &1.id) assert memberships == [membership.id] From 5b4fe7a021e5de8f5a9fd3774e3bb0341ae6b09c Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Wed, 11 Sep 2024 17:10:17 +0100 Subject: [PATCH 13/21] feat: add member count to tab --- lib/atomic/organizations.ex | 69 ++++++++++--------- lib/atomic/repo.ex | 2 + lib/atomic_web/live/organization_live/show.ex | 2 + .../live/organization_live/show.html.heex | 2 +- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/lib/atomic/organizations.ex b/lib/atomic/organizations.ex index 96e5e3b0d..e24181a05 100644 --- a/lib/atomic/organizations.ex +++ b/lib/atomic/organizations.ex @@ -280,6 +280,43 @@ defmodule Atomic.Organizations do |> Repo.all() end + @doc """ + Counts the number of members in an organization. + A member is someone who is connected to the organization with a role other than `:follower`. + + ## Examples + + iex> count_memberships(123) + 5 + + iex> count_memberships(456) + 0 + + """ + def count_memberships(organization_id) do + Membership + |> where([m], m.organization_id == ^organization_id and m.role != :follower) + |> Repo.count() + end + + @doc """ + Counts the number of followers in an organization. + + ## Examples + + iex> count_followers(123) + 5 + + iex> count_followers(456) + 0 + + """ + def count_followers(organization_id) do + Membership + |> where([m], m.organization_id == ^organization_id and m.role == :follower) + |> Repo.count() + end + @doc """ Verifies if an user is a member of an organization. @@ -454,38 +491,6 @@ defmodule Atomic.Organizations do |> Enum.drop_while(fn elem -> elem != role end) end - @doc """ - Counts the number of followers in an organization. - - ## Examples - - iex> count_followers(123) - 5 - - iex> count_followers(456) - 0 - - """ - def count_followers(organization_id) do - Membership - |> where([m], m.organization_id == ^organization_id and m.role == :follower) - |> Repo.aggregate(:count, :id) - end - - @doc """ - Returns the amount of members in an organization. - - ## Examples - - iex> get_total_organization_members(organization_id) - 5 - - """ - def get_total_organization_members(organization_id) do - from(m in Membership, where: m.organization_id == ^organization_id) - |> Repo.aggregate(:count, :id) - end - ## Announcements @doc """ diff --git a/lib/atomic/repo.ex b/lib/atomic/repo.ex index f5ed46269..85f49def5 100644 --- a/lib/atomic/repo.ex +++ b/lib/atomic/repo.ex @@ -4,4 +4,6 @@ defmodule Atomic.Repo do adapter: Ecto.Adapters.Postgres use Paginator + + def count(query), do: aggregate(query, :count) end diff --git a/lib/atomic_web/live/organization_live/show.ex b/lib/atomic_web/live/organization_live/show.ex index 05334371f..76dbf79b1 100644 --- a/lib/atomic_web/live/organization_live/show.ex +++ b/lib/atomic_web/live/organization_live/show.ex @@ -21,6 +21,7 @@ defmodule AtomicWeb.OrganizationLive.Show do def handle_params(%{"id" => id} = params, _, socket) do organization = Organizations.get_organization!(id) members = maybe_list_members(organization.id, params["tab"]) + member_count = Organizations.count_memberships(organization.id) {:noreply, socket @@ -29,6 +30,7 @@ defmodule AtomicWeb.OrganizationLive.Show do |> assign(:current_tab, current_tab(socket, params)) |> assign(:organization, organization) |> assign(:members, members) + |> assign(:member_count, member_count) |> assign(:has_permissions?, has_permissions?(socket))} end diff --git a/lib/atomic_web/live/organization_live/show.html.heex b/lib/atomic_web/live/organization_live/show.html.heex index 039c6f3e6..0cfcaaa12 100644 --- a/lib/atomic_web/live/organization_live/show.html.heex +++ b/lib/atomic_web/live/organization_live/show.html.heex @@ -63,7 +63,7 @@ <.link patch="?tab=members" replace={false}> - <.tab id="members-tab" active={@current_tab == "members"}> + <.tab id="members-tab" active={@current_tab == "members"} number={@member_count}> <.icon name={:user_group} class="size-5 mr-2" /> <%= gettext("Members") %> From d1bfd005a768d33995a560126312b4d6e224251c Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Thu, 12 Sep 2024 09:32:32 +0100 Subject: [PATCH 14/21] feat: add email to organization schema --- lib/atomic/organizations/organization.ex | 3 ++- lib/atomic_web/live/organization_live/show.ex | 9 ++++++++ .../live/organization_live/show.html.heex | 21 +++++++++---------- .../2022000000000_create_organizations.exs | 1 + priv/repo/seeds/organizations.exs | 6 +++++- 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/lib/atomic/organizations/organization.ex b/lib/atomic/organizations/organization.ex index 98f1b2464..8280d30fe 100644 --- a/lib/atomic/organizations/organization.ex +++ b/lib/atomic/organizations/organization.ex @@ -6,7 +6,7 @@ defmodule Atomic.Organizations.Organization do alias Atomic.Organizations.{Announcement, Department, Membership, Partner} alias Atomic.{Socials, Uploaders} - @required_fields ~w(name long_name description)a + @required_fields ~w(name email long_name description)a @optional_fields ~w(location)a @derive { @@ -22,6 +22,7 @@ defmodule Atomic.Organizations.Organization do schema "organizations" do field :name, :string + field :email, :string field :long_name, :string field :description, :string diff --git a/lib/atomic_web/live/organization_live/show.ex b/lib/atomic_web/live/organization_live/show.ex index 76dbf79b1..62f068d56 100644 --- a/lib/atomic_web/live/organization_live/show.ex +++ b/lib/atomic_web/live/organization_live/show.ex @@ -34,6 +34,15 @@ defmodule AtomicWeb.OrganizationLive.Show do |> assign(:has_permissions?, has_permissions?(socket))} end + # FIXME: Notification, somehow, is not appearing + @impl true + def handle_event("must-login", _, socket) do + {:noreply, + socket + |> put_flash(:error, gettext("You must be logged in to follow an organization")) + |> push_navigate(to: Routes.user_session_path(socket, :new))} + end + defp maybe_list_members(organization_id, "members"), do: Organizations.list_memberships(organization_id, preloads: [:user]) diff --git a/lib/atomic_web/live/organization_live/show.html.heex b/lib/atomic_web/live/organization_live/show.html.heex index 0cfcaaa12..781e9b74c 100644 --- a/lib/atomic_web/live/organization_live/show.html.heex +++ b/lib/atomic_web/live/organization_live/show.html.heex @@ -17,18 +17,17 @@ <.button icon={:pencil} icon_variant={:solid} color={:white} /> <% end %> - <%= if @current_user do %> -

<%= gettext("Contact") %>

- <.link href="mailto:cesium@di.uminho.pt" target="_blank"> - <.button icon={:envelope} icon_variant={:solid} color={:white} /> - +

<%= gettext("Contact") %>

+ <.link href={"mailto:#{@organization.email}"} target="_blank"> + <.button icon={:envelope} icon_variant={:solid} color={:white} /> + - <%!-- TODO: Maybe show button when there's no current user, but with a must login warning? --%> - <%= if Organizations.user_following?(@current_user.id, @organization.id) do %> - <.button icon={:star} icon_variant={:solid} color={:white}><%= gettext("Following") %> - <% else %> - <.button icon={:star}><%= gettext("Follow") %> - <% end %> + <%= if @current_user && Organizations.user_following?(@current_user.id, @organization.id) do %> + <%!-- TODO: Dropdown with unfollow option --%> + <.button icon={:star} icon_variant={:solid} color={:white}><%= gettext("Following") %> + <% else %> + <%!-- TODO: Follow functionality --%> + <.button phx-click={(!@current_user && "must-login") || "follow"} icon={:star}><%= gettext("Follow") %> <% end %>
diff --git a/priv/repo/migrations/2022000000000_create_organizations.exs b/priv/repo/migrations/2022000000000_create_organizations.exs index 517a0def8..6d5b7109e 100644 --- a/priv/repo/migrations/2022000000000_create_organizations.exs +++ b/priv/repo/migrations/2022000000000_create_organizations.exs @@ -6,6 +6,7 @@ defmodule Atomic.Repo.Migrations.CreateOrganizations do add :id, :binary_id, primary_key: true add :name, :string, null: false + add :email, :string, null: false add :long_name, :string, null: false add :description, :text, null: false diff --git a/priv/repo/seeds/organizations.exs b/priv/repo/seeds/organizations.exs index 75f132750..f08949c18 100644 --- a/priv/repo/seeds/organizations.exs +++ b/priv/repo/seeds/organizations.exs @@ -22,6 +22,7 @@ defmodule Atomic.Repo.Seeds.Organizations do # Seed CeSIUM %Organization{ name: "CeSIUM", + email: "cesium@di.uminho.pt", long_name: "Centro de Estudantes de Engenharia Informática da Universidade do Minho", description: "O CeSIUM é um grupo de estudantes voluntários, que tem como objetivo representar e promover o curso de Engenharia Informática 💾 na UMinho 🎓", @@ -49,9 +50,12 @@ defmodule Atomic.Repo.Seeds.Organizations do |> Enum.each(fn organization -> %{ name: organization["name"], + email: Faker.Internet.email(), long_name: organization["long_name"], description: organization["description"], - website: Faker.Internet.url() + socials: %{ + website: Faker.Internet.url() + } } |> Organizations.create_organization() end) From 378c32aaf2a366e6cd1b3d8d4cf47a74c8ab80c0 Mon Sep 17 00:00:00 2001 From: Rui Lopes Date: Thu, 12 Sep 2024 16:06:14 +0100 Subject: [PATCH 15/21] feat: sort by follower count --- lib/atomic/organizations.ex | 18 ------- lib/atomic/organizations/membership.ex | 15 +++++- lib/atomic/organizations/organization.ex | 6 ++- lib/atomic_web/components/dropdown.ex | 52 ++++++++++++------- .../components/organization_card.ex | 6 +-- .../live/organization_live/index.ex | 20 +++++-- .../live/organization_live/index.html.heex | 29 +++++++++-- .../2022000000000_create_organizations.exs | 2 + 8 files changed, 98 insertions(+), 50 deletions(-) diff --git a/lib/atomic/organizations.ex b/lib/atomic/organizations.ex index e24181a05..e376f0cb1 100644 --- a/lib/atomic/organizations.ex +++ b/lib/atomic/organizations.ex @@ -299,24 +299,6 @@ defmodule Atomic.Organizations do |> Repo.count() end - @doc """ - Counts the number of followers in an organization. - - ## Examples - - iex> count_followers(123) - 5 - - iex> count_followers(456) - 0 - - """ - def count_followers(organization_id) do - Membership - |> where([m], m.organization_id == ^organization_id and m.role == :follower) - |> Repo.count() - end - @doc """ Verifies if an user is a member of an organization. diff --git a/lib/atomic/organizations/membership.ex b/lib/atomic/organizations/membership.ex index 22c058eb1..29dfe52ef 100644 --- a/lib/atomic/organizations/membership.ex +++ b/lib/atomic/organizations/membership.ex @@ -9,7 +9,7 @@ defmodule Atomic.Organizations.Membership do * `admin` - The user can control the organization's departments, activities and partners. * `follower` - The user is following the organization. - This schema can be further extended to include additional roles, such as `member`. + This schema can be further extended to include additional roles, such as `member` (with even different denominations). """ use Atomic.Schema @@ -34,6 +34,19 @@ defmodule Atomic.Organizations.Membership do organization |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) + |> prepare_changes(&maybe_increment_follower_count/1) + end + + defp maybe_increment_follower_count(changeset) do + organization_id = get_change(changeset, :organization_id) + role = get_change(changeset, :role) + + if organization_id && role && role == :follower do + query = from Organization, where: [id: ^organization_id] + changeset.repo.update_all(query, inc: [follower_count: 1]) + end + + changeset end def roles, do: @roles diff --git a/lib/atomic/organizations/organization.ex b/lib/atomic/organizations/organization.ex index 8280d30fe..c52e61007 100644 --- a/lib/atomic/organizations/organization.ex +++ b/lib/atomic/organizations/organization.ex @@ -12,7 +12,7 @@ defmodule Atomic.Organizations.Organization do @derive { Flop.Schema, filterable: [:name], - sortable: [:name], + sortable: [:name, :follower_count], compound_fields: [search: [:name]], default_order: %{ order_by: [:name], @@ -29,6 +29,10 @@ defmodule Atomic.Organizations.Organization do field :logo, Uploaders.Logo.Type field :location, :string + # field used to better track the number of followers + # can only be updated by the system and through the memberships schema + field :follower_count, :integer, default: 0 + embeds_one :socials, Socials, on_replace: :update has_many :departments, Department, diff --git a/lib/atomic_web/components/dropdown.ex b/lib/atomic_web/components/dropdown.ex index 01c52222c..03564db35 100644 --- a/lib/atomic_web/components/dropdown.ex +++ b/lib/atomic_web/components/dropdown.ex @@ -4,9 +4,10 @@ defmodule AtomicWeb.Components.Dropdown do """ use Phoenix.Component - import AtomicWeb.Components.Icon alias Phoenix.LiveView.JS + import AtomicWeb.Components.Icon + attr :id, :string, required: true, doc: "The id of the dropdown." attr :items, :list, default: [], doc: "The items to display in the dropdown." @@ -27,29 +28,16 @@ defmodule AtomicWeb.Components.Dropdown do def dropdown(assigns) do ~H""" -
+
<%= render_slot(@wrapper) %>
- From 8e85903b8990c0a842fedc651d96f20dbe38ff9d Mon Sep 17 00:00:00 2001 From: FilipeR13 Date: Tue, 4 Feb 2025 15:35:36 +0000 Subject: [PATCH 18/21] fix: warnings --- lib/atomic/organizations.ex | 17 +++++++++++++++++ lib/atomic_web/components/tabs.ex | 2 +- .../live/organization_live/components/about.ex | 2 +- .../components/departments_grid.ex | 2 +- .../components/memberships_table.ex | 4 ++-- lib/atomic_web/live/organization_live/index.ex | 3 +-- 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/lib/atomic/organizations.ex b/lib/atomic/organizations.ex index e376f0cb1..2c9f54c56 100644 --- a/lib/atomic/organizations.ex +++ b/lib/atomic/organizations.ex @@ -473,6 +473,23 @@ defmodule Atomic.Organizations do |> Enum.drop_while(fn elem -> elem != role end) end + @doc """ + Returns the amount of followers in an organization. + + ## Examples + + iex> count_followers("99d7c9e5-4212-4f59-a097-28aaa33c2621") + 5 + + iex> count_followers("9as7c9e5-4212-4f59-a097-28aaa33c2621") + 100_000_000_000_000_000_000_000_000 + """ + def count_followers(organization_id) do + Membership + |> where([m], m.organization_id == ^organization_id and m.role == :follower) + |> Repo.aggregate(:count, :id) + end + ## Announcements @doc """ diff --git a/lib/atomic_web/components/tabs.ex b/lib/atomic_web/components/tabs.ex index 5e77fadce..de65f2872 100644 --- a/lib/atomic_web/components/tabs.ex +++ b/lib/atomic_web/components/tabs.ex @@ -13,7 +13,7 @@ defmodule AtomicWeb.Components.Tabs do