Skip to content

Commit e2b50d5

Browse files
feat: profile page (#528)
1 parent 6756c83 commit e2b50d5

File tree

12 files changed

+272
-196
lines changed

12 files changed

+272
-196
lines changed

lib/atomic/accounts.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,10 +491,11 @@ defmodule Atomic.Accounts do
491491
{:error, %Ecto.Changeset{}}
492492
493493
"""
494-
def update_user(%User{} = user, attrs \\ %{}) do
494+
def update_user(%User{} = user, attrs \\ %{}, after_save \\ &{:ok, &1}) do
495495
user
496496
|> User.changeset(attrs)
497497
|> Repo.update()
498+
|> after_save(after_save)
498499
end
499500

500501
@doc """

lib/atomic/accounts/user.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule Atomic.Accounts.User do
1212
alias Atomic.Accounts.Course
1313
alias Atomic.Activities.Enrollment
1414
alias Atomic.Organizations.{Collaborator, Membership, Organization}
15+
alias Atomic.Socials
1516

1617
@required_fields ~w(email password)a
1718
@optional_fields ~w(name slug role confirmed_at phone_number course_id current_organization_id)a
@@ -32,13 +33,16 @@ defmodule Atomic.Accounts.User do
3233
field :confirmed_at, :naive_datetime
3334
field :phone_number, :string
3435
field :profile_picture, Uploaders.ProfilePicture.Type
36+
field :banner, Uploaders.Banner.Type
3537

3638
belongs_to :course, Course
3739
belongs_to :current_organization, Organization
3840

3941
has_many :enrollments, Enrollment
4042
has_many :collaborators, Collaborator
4143

44+
embeds_one :socials, Socials, on_replace: :update
45+
4246
many_to_many :organizations, Organization, join_through: Membership
4347

4448
timestamps()
@@ -70,8 +74,7 @@ defmodule Atomic.Accounts.User do
7074

7175
def picture_changeset(user, attrs) do
7276
user
73-
|> cast(attrs, @required_fields ++ @optional_fields)
74-
|> cast_attachments(attrs, [:profile_picture])
77+
|> cast_attachments(attrs, [:profile_picture, :banner])
7578
end
7679

7780
@doc """
@@ -83,6 +86,7 @@ defmodule Atomic.Accounts.User do
8386
|> validate_email()
8487
|> validate_slug()
8588
|> validate_phone_number()
89+
|> cast_embed(:socials, with: &Socials.changeset/2)
8690
end
8791

8892
defp validate_email(changeset) do

lib/atomic/uploaders/banner.ex

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
defmodule Atomic.Uploaders.Banner do
22
@moduledoc """
3-
Uploader for department banners.
3+
Uploader for user banners.
44
"""
5-
use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png)
6-
7-
alias Atomic.Organizations.Department
5+
use Atomic.Uploader, extensions: ~w(.jpg .jpeg .png .gif)
6+
alias Atomic.Accounts.User
87

98
@versions [:original]
109

11-
def storage_dir(_version, {_file, %Department{} = department}) do
12-
"uploads/atomic/departments/#{department.id}/banner"
10+
def storage_dir(_version, {_file, %User{} = user}) do
11+
"uploads/atomic/users/#{user.id}/banner"
1312
end
1413

1514
def filename(version, _) do

lib/atomic_web/components/image_uploader.ex

Lines changed: 85 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,83 +3,109 @@ defmodule AtomicWeb.Components.ImageUploader do
33
An image uploader component that allows you to upload an image.
44
"""
55

6-
use AtomicWeb, :live_component
6+
use AtomicWeb, :component
7+
8+
attr :id, :string, default: "image-uploader"
9+
attr :upload, :any
10+
attr :class, :string, default: ""
11+
attr :image_class, :string, default: ""
12+
attr :image, :string, default: nil
13+
attr :icon, :string, default: "hero-photo"
14+
attr :preview_disabled, :boolean, default: false
15+
attr :rounded, :boolean, default: false
16+
attr :editable, :boolean, default: true
17+
attr :memory_unit, :string, default: "MB"
18+
19+
slot :placeholder, required: false, doc: "Slot for the placeholder content."
20+
21+
def image_uploader(assigns) do
22+
assigns = update(assigns, %{})
723

8-
def render(assigns) do
924
~H"""
1025
<div id={@id}>
11-
<div class="shrink-0 1.5xl:shrink-0">
26+
<%= if @editable do %>
1227
<.live_file_input upload={@upload} class="hidden" />
13-
<div class={
14-
"#{if length(@upload.entries) != 0 do
15-
"hidden"
16-
end} #{@class} border-2 border-gray-300 border-dashed rounded-md"
17-
} phx-drop-target={@upload.ref}>
18-
<div class="flex h-full items-center justify-center px-6">
19-
<div class="flex flex-col items-center justify-center space-y-1">
20-
<.icon name={@icon} class="size-8 text-zinc-400" />
21-
<div class="flex flex-col items-center text-sm text-zinc-600">
22-
<label for="file-upload" class="relative cursor-pointer rounded-md font-medium text-orange-500 hover:text-red-800">
23-
<a onclick={"document.getElementById('#{@upload.ref}').click()"}>Upload a file</a>
24-
</label>
25-
<p class="pl-1">or drag and drop</p>
26-
</div>
27-
<p class="text-xs text-gray-500">
28-
{extensions_to_string(@upload.accept)} up to {assigns.size_file} {@type}
29-
</p>
30-
</div>
31-
</div>
32-
</div>
33-
<section>
28+
<% end %>
29+
<section
30+
phx-drop-target={@upload.ref}
31+
class={[
32+
"hover:cursor-pointer",
33+
@rounded && "rounded-full overflow-hidden",
34+
not @rounded && "rounded-xl",
35+
@class
36+
]}
37+
onclick={"document.getElementById('#{@upload.ref}').click()"}
38+
>
39+
<%= if @upload.entries == [] do %>
40+
<article class="h-full">
41+
<figure class="flex h-full items-center justify-center">
42+
<%= if @image do %>
43+
<img class={[@rounded && "p-0", not @rounded, @image_class]} src={@image} />
44+
<% else %>
45+
<%= if @placeholder do %>
46+
<div class="flex flex-col items-center gap-2">
47+
{render_slot(@placeholder)}
48+
<p class="text-xs text-gray-500">
49+
{extensions_to_string(@upload.accept)}<br /> up to {@size_file} {@memory_unit}
50+
</p>
51+
</div>
52+
<% else %>
53+
<div class="flex select-none flex-col items-center gap-2">
54+
<.icon name={@icon} class="h-12 w-12" />
55+
<p class="px-4 text-center">{gettext("Upload a file or drag and drop.")}</p>
56+
</div>
57+
<% end %>
58+
<% end %>
59+
</figure>
60+
</article>
61+
<% end %>
62+
<%= if !@preview_disabled do %>
3463
<%= for entry <- @upload.entries do %>
35-
<%= for err <- upload_errors(@upload, entry) do %>
36-
<div class="alert alert-danger relative rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700" role="alert">
37-
<span class="block sm:inline">{Phoenix.Naming.humanize(err)}</span>
38-
<span class="absolute top-0 right-0 bottom-0 px-4 py-3">
39-
<title>Close</title>
40-
</span>
41-
</div>
42-
<% end %>
43-
<article class="upload-entry">
44-
<figure class="w-[100px]">
45-
<.live_img_preview entry={entry} id={"preview-#{entry.ref}"} class="rounded-lg shadow-lg" />
46-
<div class="flex">
47-
<figcaption>
48-
<%= if String.length(entry.client_name) < 30 do %>
49-
{entry.client_name}
50-
<% else %>
51-
{String.slice(entry.client_name, 0..30) <> "... "}
52-
<% end %>
53-
</figcaption>
54-
<button type="button" phx-click="cancel-image" phx-target={@target} phx-value-ref={entry.ref} aria-label="cancel" class="pl-4">
55-
<.icon name="hero-x-mark-solid" class="size-5 text-zinc-400" />
56-
</button>
57-
</div>
64+
<article class="h-full">
65+
<figure class="flex h-full items-center justify-center">
66+
<%= if entry.ref do %>
67+
<.live_img_preview id={"preview-#{entry.ref}"} class={[@rounded && "p-0", not @rounded && "p-4", @image_class]} entry={entry} />
68+
<% else %>
69+
<div class="flex select-none flex-col items-center gap-2">
70+
<.icon name="hero-document" class="h-12 w-12" />
71+
<p class="px-4 text-center">{entry.client_name}</p>
72+
</div>
73+
<% end %>
5874
</figure>
75+
<%= for err <- upload_errors(@upload, entry) do %>
76+
<p class="alert alert-danger">{Phoenix.Naming.humanize(err)}</p>
77+
<% end %>
5978
</article>
6079
<% end %>
61-
</section>
62-
</div>
80+
<% end %>
81+
<%= for err <- upload_errors(@upload) do %>
82+
<p class="alert alert-danger">{Phoenix.Naming.humanize(err)}</p>
83+
<% end %>
84+
</section>
6385
</div>
6486
"""
6587
end
6688

67-
def update(assigns, socket) do
68-
max_size = assigns.upload.max_file_size
69-
type = assigns[:type]
89+
def update(assigns, _socket) do
90+
max_size =
91+
if Map.has_key?(assigns, :upload) do
92+
assigns.upload.max_file_size
93+
else
94+
0
95+
end
96+
97+
memory_unit = assigns[:memory_unit]
7098

71-
size_file = convert_size(max_size, type)
99+
size_file = convert_size(max_size, memory_unit)
72100

73-
{:ok,
74-
socket
75-
|> assign(assigns)
76-
|> assign(:size_file, size_file)}
101+
assigns
102+
|> Map.put(:size_file, size_file)
77103
end
78104

79-
defp convert_size(size_in_bytes, type) do
105+
defp convert_size(size_in_bytes, memory_unit) do
80106
size_in_bytes_float = size_in_bytes * 1.0
81107

82-
case type do
108+
case memory_unit do
83109
"kB" -> Float.round(size_in_bytes_float / 1_000, 2)
84110
"MB" -> Float.round(size_in_bytes_float / 1_000_000, 2)
85111
"GB" -> Float.round(size_in_bytes_float / 1_000_000_000, 2)

lib/atomic_web/components/socials.ex

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ defmodule AtomicWeb.Components.Socials do
1010

1111
~H"""
1212
<div class="grid grid-cols-2 gap-2 md:flex md:flex-row">
13-
<%= for {social, icon, url_base, social_value} <- @socials_with_values do %>
13+
<%= for {social, icon, url_base, social_value} <- assigns.socials_with_values do %>
1414
<%= if social_value do %>
1515
<div class="flex flex-row items-center gap-x-2">
1616
<img src={"/images/" <> icon} class="h-5 w-5" alt={Atom.to_string(social)} />
@@ -25,9 +25,11 @@ defmodule AtomicWeb.Components.Socials do
2525
end
2626

2727
defp get_social_values(entity) do
28+
socials = Map.get(entity, :socials, %{})
29+
2830
get_socials()
2931
|> Enum.map(fn {social, icon, url_base} ->
30-
social_value = Map.get(entity, social)
32+
social_value = Map.get(socials, social)
3133
{social, icon, url_base, social_value}
3234
end)
3335
end

lib/atomic_web/live/organization_live/show.html.heex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
phx-click="unfollow"
5454
@click.away="open = false"
5555
@click="open = false"
56-
class="absolute left-0 z-10 mt-2 -mr-1 w-72 origin-top-left divide-y divide-zinc-200 overflow-hidden rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:left-0 sm:left-auto"
56+
class="absolute left-0 z-10 mt-2 -mr-1 w-72 origin-top-left divide-y divide-zinc-200 overflow-hidden rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:left-auto"
5757
tabindex="-1"
5858
role="listbox"
5959
aria-labelledby="listbox-label"

lib/atomic_web/live/profile_live/edit.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ defmodule AtomicWeb.ProfileLive.Edit do
1414
def handle_params(%{"slug" => user_slug}, _, socket) do
1515
user = Accounts.get_user_by_slug(user_slug)
1616

17-
if socket.assigns.current_user.slug == user_slug do
17+
if socket.assigns.current_user && socket.assigns.current_user.slug == user_slug do
1818
{:noreply,
1919
socket
2020
|> assign(:page_title, user.name)

lib/atomic_web/live/profile_live/form_component.ex

Lines changed: 30 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
defmodule AtomicWeb.ProfileLive.FormComponent do
22
use AtomicWeb, :live_component
33

4-
alias Atomic.Accounts
5-
alias AtomicWeb.Components.ImageUploader
4+
alias Atomic.{Accounts, Organizations}
5+
6+
import AtomicWeb.Components.{Button, Avatar, Gradient, Forms, ImageUploader}
67

78
@extensions_whitelist ~w(.jpg .jpeg .gif .png)
89

@@ -15,7 +16,7 @@ defmodule AtomicWeb.ProfileLive.FormComponent do
1516
max_entries: 1,
1617
max_file_size: 10_000_000
1718
)
18-
|> allow_upload(:image_2,
19+
|> allow_upload(:banner,
1920
accept: @extensions_whitelist,
2021
max_entries: 1,
2122
max_file_size: 100_000_000
@@ -25,10 +26,12 @@ defmodule AtomicWeb.ProfileLive.FormComponent do
2526
@impl true
2627
def update(%{user: user} = assigns, socket) do
2728
changeset = Accounts.change_user(user)
29+
organizations = Organizations.list_user_organizations(user.id)
2830

2931
{:ok,
3032
socket
3133
|> assign(assigns)
34+
|> assign(:organizations, organizations)
3235
|> assign(:changeset, changeset)}
3336
end
3437

@@ -45,7 +48,7 @@ defmodule AtomicWeb.ProfileLive.FormComponent do
4548
end
4649

4750
def handle_event("cancel-image", %{"ref" => ref}, socket) do
48-
uploads = [:profile_picture, :image_2]
51+
uploads = [:profile_picture, :banner]
4952

5053
socket =
5154
Enum.reduce(uploads, socket, fn key, acc ->
@@ -94,38 +97,30 @@ defmodule AtomicWeb.ProfileLive.FormComponent do
9497
end
9598

9699
defp consume_image_data(socket, user) do
97-
consume_uploaded_entries(socket, :profile_picture, fn %{path: path}, entry ->
98-
handle_image_upload(user, path, entry, :profile_picture)
99-
end)
100-
101-
consume_uploaded_entries(socket, :image_2, fn %{path: path}, entry ->
102-
handle_image_upload(user, path, entry, :image_2)
103-
end)
104-
105-
{:ok, user}
100+
results =
101+
[:profile_picture, :banner]
102+
|> Enum.map(&consume_image_entry(socket, user, &1))
103+
|> List.flatten()
104+
105+
if Enum.any?(results, fn result -> match?({:error, _}, result) end) do
106+
{:error, results}
107+
else
108+
{:ok, user}
109+
end
106110
end
107111

108-
defp handle_image_upload(user, path, entry, field) do
109-
Accounts.update_user_picture(user, %{
110-
"#{field}" => %Plug.Upload{
111-
content_type: entry.client_type,
112-
filename: entry.client_name,
113-
path: path
114-
}
115-
})
116-
|> case do
117-
{:ok, user} ->
118-
{:ok, user}
119-
120-
{:error, changeset} ->
121-
if changeset.errors[field] do
122-
{:postpone, "File size exceeds maximum allowed size"}
123-
else
124-
{:error, changeset}
125-
end
126-
127-
{:errors, _changeset} ->
128-
{:error, "An error occurred while updating the user."}
129-
end
112+
defp consume_image_entry(socket, user, field) do
113+
consume_uploaded_entries(socket, field, fn %{path: path}, entry ->
114+
case Accounts.update_user_picture(user, %{
115+
"#{field}" => %Plug.Upload{
116+
content_type: entry.client_type,
117+
filename: entry.client_name,
118+
path: path
119+
}
120+
}) do
121+
{:ok, updated_user} -> {:ok, updated_user}
122+
{:error, _changeset} -> {:error, field}
123+
end
124+
end)
130125
end
131126
end

0 commit comments

Comments
 (0)