Skip to content

Commit ee1be54

Browse files
committed
Merge branch 'develop' into nm/improve-sidebar
2 parents 805354d + b0832eb commit ee1be54

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

63 files changed

+1493
-384
lines changed

lib/atomic/accounts.ex

+2-1
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

+6-2
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/accounts/user_notifier.ex

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ defmodule Atomic.Accounts.UserNotifier do
3434
|> subject("Confirm your Account")
3535
|> assign(:user, user)
3636
|> assign(:url, url)
37-
|> render_body("user_confirmation.txt")
37+
|> render_body("user_confirmation.html")
3838

3939
case Mailer.deliver(email) do
4040
{:ok, _term} -> {:ok, email}
@@ -51,7 +51,7 @@ defmodule Atomic.Accounts.UserNotifier do
5151
|> subject("Reset Password Instructions")
5252
|> assign(:user, user)
5353
|> assign(:url, url)
54-
|> render_body("user_reset_password.txt")
54+
|> render_body("user_reset_password.html")
5555

5656
case Mailer.deliver(email) do
5757
{:ok, _term} -> {:ok, email}

lib/atomic/departments.ex

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule Atomic.Departments do
55
use Atomic.Context
66

77
alias Atomic.Accounts.User
8-
alias Atomic.Organizations.{Collaborator, Department}
8+
alias Atomic.Organizations.{Collaborator, Department, Membership}
99
alias AtomicWeb.DepartmentEmails
1010
alias AtomicWeb.Router.Helpers
1111

@@ -405,7 +405,7 @@ defmodule Atomic.Departments do
405405
User
406406
|> join(:inner, [u], c in assoc(u, :collaborators))
407407
|> where([u, c], c.department_id == ^department.id and c.accepted == true)
408-
|> join(:inner, [u, c], m in assoc(u, :memberships))
408+
|> join(:inner, [u, c], m in Membership, on: m.user_id == u.id)
409409
|> where(
410410
[u, c, m],
411411
m.organization_id == ^department.organization_id and m.role in [:admin, :owner]
@@ -439,7 +439,7 @@ defmodule Atomic.Departments do
439439
collaborator,
440440
tab: "collaborators"
441441
),
442-
to: get_admin_collaborators(department) |> Enum.map(& &1.email)
442+
to: get_admin_collaborators(department)
443443
)
444444

445445
{:ok, collaborator}

lib/atomic/generate_avatar.ex

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
defmodule Atomic.GenerateAvatar do
2+
@moduledoc """
3+
A module for generating unique, GitHub-style avatars for organizations.
4+
"""
5+
6+
import Phoenix.HTML
7+
8+
@grid_size 5
9+
@cell_size 50
10+
11+
def generate_avatar(seed, output_type) do
12+
hash = :crypto.hash(:sha256, seed) |> :binary.bin_to_list()
13+
color = Enum.take(hash, 3)
14+
grid = build_grid(hash)
15+
svg = draw(grid, color)
16+
17+
handle_output(svg, output_type)
18+
end
19+
20+
defp handle_output(svg, output) when is_binary(output), do: File.write(output, svg)
21+
22+
defp handle_output(svg, :svg), do: svg
23+
defp handle_output(svg, :blob), do: :erlang.term_to_binary(svg)
24+
defp handle_output(svg, :html), do: raw(svg)
25+
26+
defp handle_output(_svg, invalid) do
27+
raise ArgumentError,
28+
"Invalid output type: #{inspect(invalid)}. Expected one of :svg, :blob, :html, or a file path string."
29+
end
30+
31+
defp build_grid(hash) do
32+
hash
33+
|> Enum.chunk_every(@grid_size, @grid_size, :discard)
34+
|> Enum.map(&mirror/1)
35+
|> List.flatten()
36+
end
37+
38+
defp mirror([a, b, c | _]), do: [a, b, c, b, a]
39+
40+
defp draw(grid, [r, g, b]) do
41+
header = """
42+
<svg width="#{@grid_size * @cell_size}" height="#{@grid_size * @cell_size}" xmlns="http://www.w3.org/2000/svg">
43+
"""
44+
45+
footer = "</svg>"
46+
47+
body =
48+
Enum.map_join(
49+
grid
50+
|> Enum.with_index()
51+
|> Enum.filter(fn {val, _} -> rem(val, 2) == 0 end),
52+
"\n",
53+
fn {_val, index} ->
54+
x = rem(index, @grid_size) * @cell_size
55+
y = div(index, @grid_size) * @cell_size
56+
57+
"<rect x='#{x}' y='#{y}' width='#{@cell_size}' height='#{@cell_size}' fill='rgb(#{r},#{g},#{b})' />"
58+
end
59+
)
60+
61+
header <> body <> footer
62+
end
63+
end

lib/atomic/uploaders/banner.ex

+5-6
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.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ defmodule AtomicWeb do
1717
and import those modules here.
1818
"""
1919

20-
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
20+
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt humans.txt)
2121

2222
def controller do
2323
quote do

lib/atomic_web/components/avatar.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ defmodule AtomicWeb.Components.Avatar do
4444
~H"""
4545
<span class={generate_avatar_classes(assigns)}>
4646
<%= if @src do %>
47-
<img src={@src} class={"atomic-avatar--#{assigns.type} h-full w-full"} />
47+
<img src={@src} class={"atomic-avatar--#{assigns.type} h-full w-full"} alt={assigns.name} />
4848
<% else %>
4949
<%= if @auto_generate_initials do %>
5050
{extract_initials(@name)}

lib/atomic_web/components/image_uploader.ex

+85-59
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)

0 commit comments

Comments
 (0)