From 96616d7b236e315880f6a187f49c221d472f2e1e Mon Sep 17 00:00:00 2001 From: a111430 Date: Tue, 21 Apr 2026 21:55:36 +0100 Subject: [PATCH 1/3] feat: Upload photo component --- config/config.exs | 6 + lib/yearbook/schema.ex | 27 +++++ lib/yearbook/uploader.ex | 15 +++ lib/yearbook_web.ex | 10 ++ .../components/photo_upload_card.ex | 114 ++++++++++++++++++ lib/yearbook_web/components/photo_uploader.ex | 82 +++++++++++++ lib/yearbook_web/endpoint.ex | 8 ++ mix.exs | 10 ++ 8 files changed, 272 insertions(+) create mode 100644 lib/yearbook/schema.ex create mode 100644 lib/yearbook/uploader.ex create mode 100644 lib/yearbook_web/components/photo_upload_card.ex create mode 100644 lib/yearbook_web/components/photo_uploader.ex diff --git a/config/config.exs b/config/config.exs index f161645..3f55d12 100644 --- a/config/config.exs +++ b/config/config.exs @@ -24,6 +24,12 @@ config :yearbook, ecto_repos: [Yearbook.Repo], generators: [timestamp_type: :utc_datetime, binary_id: true] +# Waffle configuration +config :waffle, + storage: Waffle.Storage.Local, + storage_dir_prefix: "priv", + asset_host: {:system, "ASSET_HOST"} + # Configures the endpoint config :yearbook, YearbookWeb.Endpoint, url: [host: "localhost"], diff --git a/lib/yearbook/schema.ex b/lib/yearbook/schema.ex new file mode 100644 index 0000000..14ebd4d --- /dev/null +++ b/lib/yearbook/schema.ex @@ -0,0 +1,27 @@ +defmodule Yearbook.Schema do + @moduledoc """ + Base schema module. + """ + defmacro __using__(_) do + quote do + use Ecto.Schema + use Waffle.Ecto.Schema + + import Ecto.Changeset + + alias Yearbook.Uploaders + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + def validate_url(changeset, field) do + changeset + |> validate_format( + field, + ~r/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/, + message: "must start with http:// or https:// and have a valid domain" + ) + end + end + end +end diff --git a/lib/yearbook/uploader.ex b/lib/yearbook/uploader.ex new file mode 100644 index 0000000..6af382e --- /dev/null +++ b/lib/yearbook/uploader.ex @@ -0,0 +1,15 @@ +defmodule Yearbook.Uploader do + @moduledoc """ + Base uploader module. + """ + defmacro __using__(_) do + quote do + use Waffle.Definition + use Waffle.Ecto.Definition + + def s3_object_headers(_version, {file, _scope}) do + [content_type: MIME.from_path(file.file_name)] + end + end + end +end diff --git a/lib/yearbook_web.ex b/lib/yearbook_web.ex index 1c7e6a5..dad6485 100644 --- a/lib/yearbook_web.ex +++ b/lib/yearbook_web.ex @@ -64,6 +64,14 @@ defmodule YearbookWeb do end end + def component do + quote do + use Phoenix.Component + + unquote(html_helpers()) + end + end + def auth_view do quote do use Phoenix.LiveView, @@ -120,6 +128,8 @@ defmodule YearbookWeb do # Routes generation with the ~p sigil unquote(verified_routes()) + + alias Yearbook.Uploaders end end diff --git a/lib/yearbook_web/components/photo_upload_card.ex b/lib/yearbook_web/components/photo_upload_card.ex new file mode 100644 index 0000000..d8765ae --- /dev/null +++ b/lib/yearbook_web/components/photo_upload_card.ex @@ -0,0 +1,114 @@ +defmodule YearbookWeb.Components.PhotoUploadCard do + @moduledoc """ + Photo upload card component. + """ + + use YearbookWeb, :live_component + + import YearbookWeb.Components.PhotoUploader + + @impl true + def render(assigns) do + ~H""" +
+ <%= if @staged_photo_path do %> +
+ +

Foto carregada com sucesso!

+
+ <% else %> +
+ <.photo_uploader + class="h-48 w-48 mx-auto rounded-xl border-primary/30! hover:border-primary! hover:bg-primary/5! transition-all! overflow-hidden cursor-pointer" + upload={@uploads.photo} + image_class="w-full h-full object-cover" + > + <:placeholder> +
+ <.icon name="hero-arrow-up-tray" class="w-8 h-8" /> +

Escolha uma foto

+

+ PNG · JPG · JPEG · max 5 MB +

+
+ + + + <%= for err <- upload_errors(@uploads.photo) do %> +

+ {error_to_string(err)} +

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

+ {error_to_string(err)} +

+ <% end %> + + +
+ <% end %> +
+ """ + end + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:staged_photo_path, nil) + |> allow_upload(:photo, + accept: ~w(.jpg .jpeg .png), + max_entries: 1, + max_file_size: 5_000_000 + )} + end + + def handle_event("validate", _params, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("save", %{}, socket) do + uploaded_files = + consume_uploaded_entries(socket, :photo, fn %{path: path}, entry -> + filename = "#{Ecto.UUID.generate()}-#{entry.client_name}" + upload_dir = Path.join([:code.priv_dir(:yearbook), "uploads"]) + File.mkdir_p!(upload_dir) + dest = Path.join(upload_dir, filename) + File.cp!(path, dest) + {:ok, "/uploads/#{filename}"} + end) + + case uploaded_files do + [file_path] -> + send(self(), {:photo_staged, file_path}) + + {:noreply, assign(socket, :staged_photo_path, file_path)} + + [] -> + {:noreply, put_flash(socket, :error, "Ocorreu um erro ao processar a foto!")} + end + end + + defp error_to_string(:too_large), do: "Foto demasiado grande (máx 5MB)!" + defp error_to_string(:not_accepted), do: "Formato inválido!" + defp error_to_string(:too_many_files), do: "Apenas uma foto permitida!" + defp error_to_string(_), do: "Erro desconhecido!" +end diff --git a/lib/yearbook_web/components/photo_uploader.ex b/lib/yearbook_web/components/photo_uploader.ex new file mode 100644 index 0000000..999e1c6 --- /dev/null +++ b/lib/yearbook_web/components/photo_uploader.ex @@ -0,0 +1,82 @@ +defmodule YearbookWeb.Components.PhotoUploader do + @moduledoc """ + Photo uploader component. + """ + use YearbookWeb, :component + + attr :upload, :any, required: true + attr :class, :string, default: "" + attr :image_class, :string, default: "" + attr :image, :string, default: nil + attr :icon, :string, default: "hero-photo" + attr :preview_disabled, :boolean, default: false + attr :rounded, :boolean, default: false + attr :capture, :string, default: nil + attr :accept, :string, default: nil + + slot :placeholder, required: false, doc: "Slot for the placeholder content." + + def photo_uploader(assigns) do + ~H""" + <.live_file_input upload={@upload} class="sr-only" capture={@capture} accept={@accept} /> + + """ + end + + defp image_file?(entry) do + entry.client_type in ["image/jpeg", "image/png", "image/gif", "image/heic", "image/webp"] + end +end diff --git a/lib/yearbook_web/endpoint.ex b/lib/yearbook_web/endpoint.ex index 07a89a1..3cc8976 100644 --- a/lib/yearbook_web/endpoint.ex +++ b/lib/yearbook_web/endpoint.ex @@ -26,6 +26,14 @@ defmodule YearbookWeb.Endpoint do gzip: not code_reloading?, only: YearbookWeb.static_paths() + # Serve uploads from the "uploads" directory in development + if Mix.env() == :dev do + plug Plug.Static, + at: "/uploads", + from: Path.expand("./priv/uploads"), + gzip: false + end + # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if code_reloading? do diff --git a/mix.exs b/mix.exs index d0dad27..2528edb 100644 --- a/mix.exs +++ b/mix.exs @@ -53,6 +53,16 @@ defmodule Yearbook.MixProject do {:postgrex, ">= 0.0.0"}, {:phoenix_html, "~> 4.1"}, + # uploads + {:waffle_ecto, "~> 0.0.12"}, + {:waffle, "~> 1.1.9"}, + {:ex_aws, "~> 2.6.0"}, + {:ex_aws_s3, "~> 2.5.8"}, + {:hackney, "~> 1.25.0"}, + {:httpoison, "~> 2.2.3"}, + {:sweet_xml, "~> 0.7.5"}, + {:zstream, "~> 0.6.7"}, + # mailer {:swoosh, "~> 1.16"}, From 9abb18d1fb15445f9b5272b5b436a20dbeacb5b0 Mon Sep 17 00:00:00 2001 From: Matheus <50931752+matheusm18@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:23:24 +0100 Subject: [PATCH 2/3] refactor: photo_upload_card.ex Co-authored-by: Afonso Martins <145707571+AfonsoMartins26@users.noreply.github.com> --- .../components/photo_upload_card.ex | 132 ++++++++++++++---- 1 file changed, 102 insertions(+), 30 deletions(-) diff --git a/lib/yearbook_web/components/photo_upload_card.ex b/lib/yearbook_web/components/photo_upload_card.ex index d8765ae..0708d87 100644 --- a/lib/yearbook_web/components/photo_upload_card.ex +++ b/lib/yearbook_web/components/photo_upload_card.ex @@ -7,8 +7,13 @@ defmodule YearbookWeb.Components.PhotoUploadCard do import YearbookWeb.Components.PhotoUploader - @impl true - def render(assigns) do + attr :upload, :any, required: true + attr :staged_photo_path, :string, default: nil + attr :show_upload_button, :boolean, default: true + attr :upload_error, :string, default: nil + attr :phx_target, :any, default: nil + + def photo_upload_card(assigns) do ~H"""
<%= if @staged_photo_path do %> @@ -20,16 +25,19 @@ defmodule YearbookWeb.Components.PhotoUploadCard do

Foto carregada com sucesso!

<% else %> -
+
+ <%= if @upload_error do %> +

+ {@upload_error} +

+ <% end %> + <.photo_uploader + phx_target={@phx_target} + phx_change="validate" + phx_drop_target={@upload.ref} class="h-48 w-48 mx-auto rounded-xl border-primary/30! hover:border-primary! hover:bg-primary/5! transition-all! overflow-hidden cursor-pointer" - upload={@uploads.photo} + upload={@upload} image_class="w-full h-full object-cover" > <:placeholder> @@ -43,36 +51,74 @@ defmodule YearbookWeb.Components.PhotoUploadCard do - <%= for err <- upload_errors(@uploads.photo) do %> + <%= for err <- upload_errors(@upload) do %>

{error_to_string(err)}

<% end %> - <%= for entry <- @uploads.photo.entries, err <- upload_errors(@uploads.photo, entry) do %> + <%= for entry <- @upload.entries, err <- upload_errors(@upload, entry) do %>

{error_to_string(err)}

<% end %> - - + <%= if @show_upload_button do %> + + <% else %> + <%= for entry <- @upload.entries, upload_errors(@upload, entry) == [] do %> +
+
+
+
+

+ A CARREGAR {entry.progress}% +

+
+ <% end %> + <% end %> +
<% end %>
""" end + @impl true + def render(assigns) do + ~H""" +
+ <.photo_upload_card + upload={@uploads.photo} + staged_photo_path={@staged_photo_path} + show_upload_button={@show_upload_button} + upload_error={@upload_error} + phx_target={@myself} + /> +
+ """ + end + @impl true def mount(socket) do {:ok, socket |> assign(:staged_photo_path, nil) + |> assign(:upload_error, nil) |> allow_upload(:photo, accept: ~w(.jpg .jpeg .png), max_entries: 1, @@ -80,31 +126,57 @@ defmodule YearbookWeb.Components.PhotoUploadCard do )} end + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:show_upload_button, fn -> true end) + |> assign_new(:staged_photo_path, fn -> nil end) + |> assign_new(:upload_error, fn -> nil end)} + end + + @impl true def handle_event("validate", _params, socket) do {:noreply, socket} end + @impl true + def handle_event("reset_upload", _params, socket) do + {:noreply, assign(socket, :staged_photo_path, nil)} + end + @impl true def handle_event("save", %{}, socket) do uploaded_files = consume_uploaded_entries(socket, :photo, fn %{path: path}, entry -> - filename = "#{Ecto.UUID.generate()}-#{entry.client_name}" - upload_dir = Path.join([:code.priv_dir(:yearbook), "uploads"]) - File.mkdir_p!(upload_dir) - dest = Path.join(upload_dir, filename) - File.cp!(path, dest) - {:ok, "/uploads/#{filename}"} + {:ok, store_photo(path, entry)} end) case uploaded_files do [file_path] -> - send(self(), {:photo_staged, file_path}) + send(self(), {:photo_uploaded, socket.assigns.id, file_path}) - {:noreply, assign(socket, :staged_photo_path, file_path)} + {:noreply, + socket + |> assign(:staged_photo_path, file_path) + |> assign(:upload_error, nil)} [] -> - {:noreply, put_flash(socket, :error, "Ocorreu um erro ao processar a foto!")} + {:noreply, assign(socket, :upload_error, "Ocorreu um erro ao processar a foto!")} end + rescue + _exception -> + {:noreply, assign(socket, :upload_error, "Ocorreu um erro ao processar a foto!")} + end + + def store_photo(path, entry) do + filename = "#{Ecto.UUID.generate()}-#{entry.client_name}" + upload_dir = Path.expand("priv/uploads") + File.mkdir_p!(upload_dir) + dest = Path.join(upload_dir, filename) + File.cp!(path, dest) + "/uploads/#{filename}" end defp error_to_string(:too_large), do: "Foto demasiado grande (máx 5MB)!" From 86ab327e06d49668841e85bd9bd02ffeb6b8f183 Mon Sep 17 00:00:00 2001 From: Matheus <50931752+matheusm18@users.noreply.github.com> Date: Fri, 12 Jun 2026 20:39:52 +0100 Subject: [PATCH 3/3] refactor: photo_uploader.ex Co-authored-by: Afonso Martins <145707571+AfonsoMartins26@users.noreply.github.com> --- lib/yearbook_web/components/photo_uploader.ex | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/yearbook_web/components/photo_uploader.ex b/lib/yearbook_web/components/photo_uploader.ex index 999e1c6..e1667b6 100644 --- a/lib/yearbook_web/components/photo_uploader.ex +++ b/lib/yearbook_web/components/photo_uploader.ex @@ -14,14 +14,25 @@ defmodule YearbookWeb.Components.PhotoUploader do attr :capture, :string, default: nil attr :accept, :string, default: nil + attr :phx_target, :any, default: nil + attr :phx_change, :string, default: nil + attr :phx_drop_target, :any, default: nil + slot :placeholder, required: false, doc: "Slot for the placeholder content." def photo_uploader(assigns) do ~H""" - <.live_file_input upload={@upload} class="sr-only" capture={@capture} accept={@accept} /> + <.live_file_input + upload={@upload} + class="sr-only" + capture={@capture} + accept={@accept} + phx-change={@phx_change} + phx-target={@phx_target} + />