diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index e5ffe65ff..191b85f9c 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -117,7 +117,7 @@ module.exports = { }, { values }) }), - // Embeds Tabler icons (https://tablericons.com) into app.css bundle + // Embeds Tabler icons (https://tabler.io/icons) into app.css bundle plugin(function ({ matchComponents, theme }) { let iconsDir = path.join(__dirname, "../deps/tabler_icons/icons") let values = {} diff --git a/lib/atomic/departments.ex b/lib/atomic/departments.ex index 32be471ad..d0b7adb0a 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/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/organizations.ex b/lib/atomic/organizations.ex index e2c58e8ae..78e8b8612 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,43 +264,51 @@ 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) + |> where([m], m.organization_id == ^organization_id and m.role != :follower) + |> apply_filters(opts) |> Repo.all() - |> Repo.preload(preloads) end - def list_memberships(%{"user_id" => user_id}, preloads) do + @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([a], a.user_id == ^user_id) - |> Repo.preload(preloads) - |> Repo.all() + |> 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. + 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 +317,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. @@ -453,19 +490,7 @@ defmodule Atomic.Organizations do |> 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 """ Returns the list of announcements. 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 9db7602bb..c52e61007 100644 --- a/lib/atomic/organizations/organization.ex +++ b/lib/atomic/organizations/organization.ex @@ -3,17 +3,16 @@ 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, Uploaders} - @required_fields ~w(name long_name description)a - @optional_fields ~w()a + @required_fields ~w(name email long_name description)a + @optional_fields ~w(location)a @derive { Flop.Schema, - filterable: [], - sortable: [:name], + filterable: [:name], + sortable: [:name, :follower_count], compound_fields: [search: [:name]], default_order: %{ order_by: [:name], @@ -23,11 +22,18 @@ defmodule Atomic.Organizations.Organization do schema "organizations" do field :name, :string + field :email, :string field :long_name, :string field :description, :string field :logo, Uploaders.Logo.Type - embeds_one :location, Location, on_replace: :delete + 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, on_replace: :delete_if_exists, @@ -51,7 +57,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/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/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.ex b/lib/atomic_web.ex index 6e7292c9e..01169a362 100644 --- a/lib/atomic_web.ex +++ b/lib/atomic_web.ex @@ -106,6 +106,11 @@ defmodule AtomicWeb do import Phoenix.LiveView.Helpers import Phoenix.Component + alias Phoenix.LiveView.JS + + # Import commonly used components + unquote(components()) + # Import basic rendering functionality (render, render_layout, etc) import Phoenix.View diff --git a/lib/atomic_web/components/avatar.ex b/lib/atomic_web/components/avatar.ex index 88e1ffb22..d35a3db91 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: "Avatar 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/components/dropdown.ex b/lib/atomic_web/components/dropdown.ex index 449ecbf25..4fab96436 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." @@ -22,7 +23,7 @@ defmodule AtomicWeb.Components.Dropdown do def dropdown(assigns) do ~H""" -
<%= gettext("This department doesn't have any recent activity.") %>
+<%= gettext("In the meantime, check out %{organization_name}.", organization_name: @organization.name) %>
+ +<%= gettext("This department doesn't have any collaborators yet!") %>
+ <% end %>{gettext("This department doesn't have any recent activity.")}
-{gettext("In the meantime, check out %{organization_name}.", organization_name: @organization.name)}
- -{gettext("This department doesn't have any collaborators yet!")}
+ <.link :if={length(@all_collaborators) != 0} patch={~p"/organizations/#{@organization}/departments/#{@department}?tab=collaborators"} class="text-orange-500 hover:cursor-pointer hover:underline"> + <%= gettext("View all collaborators") %> + +<%= collaborator.user.name %>
+@<%= collaborator.user.slug %>
{collaborator.user.name}
-@{collaborator.user.slug}
-<%= @organization.description %>
+ +<%= @organization.location %>
+<%= @organization.socials.website %>
+ +<%= @organization.socials.facebook %>
+ +<%= @organization.socials.instagram %>
+ +<%= @organization.socials.x %>
+ +<%= @organization.socials.linkedin %>
+ +Access to our room facilities
+Free access to all activities
+Official member t-shirt
+<%= gettext("Pay once, be a member forever") %>
++ 10€ + EUR +
+ <.button icon="hero-banknotes" class="mt-10 text-sm"><%= gettext("Request your membership") %> +<%= gettext("Payments should be made within our location.") %> <%= @organization.location %>
+<%= gettext("Name") %> | +<%= gettext("Role") %> | +<%= gettext("Joined At") %> | +
---|---|---|
+
+ <.avatar name={member.user.name} size={:sm} color={:light_zinc} class="ring-1 ring-white" />
+
+
+
+ <%= member.user.name %>
+ <%= member.user.email %>
+ |
+ <%= capitalize_first_letter(member.role) %> | +<%= relative_datetime(member.inserted_at) %> | +
+ <%= @organization.name %> +
++ <%= @organization.long_name %> +
+ ++ <%= @organization.follower_count %> followers +
+ <% else %> ++ 1 follower +
+ <% end %> +<%= @organization.location %>
+<%= @organization.socials.website %>
+ +- {maybe_slice_string(organization.long_name, 35)} -
-- {maybe_slice_string(organization.long_name, 85)} -
-- {@organization.long_name} -
-Following
-Unfollow
-<%= gettext("Edit") %>
+ <.button icon="hero-pencil-solid" color={:white} /> + <% end %> + +<%= gettext("Contact") %>
+ <.link href={"mailto:#{@organization.email}"} target="_blank"> + <.button icon="hero-envelope-solid" color={:white} /> + + + <%= if @current_user && Organizations.user_following?(@current_user.id, @organization.id) do %> + <%!-- TODO: Dropdown with unfollow option --%> + <.button icon="hero-star-solid" color={:white}><%= gettext("Following") %> + <% else %> + <%!-- TODO: Follow functionality --%> + <.button phx-click={(!@current_user && "must-login") || "follow"} icon="hero-star"><%= gettext("Follow") %> + <% end %> + + + <.tabs class="scrollbar-hide flex overflow-scroll px-4 sm:px-6 lg:px-8"> + <.link patch="?tab=about" replace={false}> + <.tab id="about-tab" active={@current_tab == "about"}> + <.icon name="hero-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="hero-newspaper" class="size-5 mr-2" /> + <%= gettext("Posts") %> + + + + <.link patch="?tab=departments" replace={false}> + <.tab id="department-tab" active={@current_tab == "departments"}> + <.icon name="hero-cube" class="size-5 mr-2" /> + <%= gettext("Departments") %> + + -- People -
-<%= @partner.location.name %>
+ <%= if @partner.socials do %> @@ -48,6 +41,7 @@ <% end %>