From 96616d7b236e315880f6a187f49c221d472f2e1e Mon Sep 17 00:00:00 2001 From: a111430 Date: Tue, 21 Apr 2026 21:55:36 +0100 Subject: [PATCH] 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"},