Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
27 changes: 27 additions & 0 deletions lib/yearbook/schema.ex
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions lib/yearbook/uploader.ex
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions lib/yearbook_web.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -120,6 +128,8 @@ defmodule YearbookWeb do

# Routes generation with the ~p sigil
unquote(verified_routes())

alias Yearbook.Uploaders
end
end

Expand Down
114 changes: 114 additions & 0 deletions lib/yearbook_web/components/photo_upload_card.ex
Original file line number Diff line number Diff line change
@@ -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"""
<div class="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 w-full max-w-md mx-auto">
<%= if @staged_photo_path do %>
<div class="flex flex-col gap-6 text-center">
<img
src={@staged_photo_path}
class="w-48 h-48 object-cover rounded-xl mx-auto border-2 border-primary"
/>
<p class="text-sm font-bold text-primary">Foto carregada com sucesso!</p>
</div>
<% else %>
<form
id="photo-upload-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
class="flex flex-col gap-4"
>
<.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>
<div class="flex flex-col gap-2 items-center justify-center h-full text-primary/50 hover:text-primary transition-colors p-4">
<.icon name="hero-arrow-up-tray" class="w-8 h-8" />
<p class="text-sm font-medium text-gray-600">Escolha uma foto</p>
<p class="text-[10px] text-gray-400 uppercase tracking-widest mt-1">
PNG · JPG · JPEG · max 5 MB
</p>
</div>
</:placeholder>
</.photo_uploader>

<%= for err <- upload_errors(@uploads.photo) do %>
<p class="text-xs text-red-500 text-center font-bold tracking-wider">
{error_to_string(err)}
</p>
<% end %>
<%= for entry <- @uploads.photo.entries, err <- upload_errors(@uploads.photo, entry) do %>
<p class="text-xs text-red-500 text-center font-bold tracking-wider">
{error_to_string(err)}
</p>
<% end %>

<button
type="submit"
class="w-full py-3.5 rounded-full bg-primary text-white font-bold tracking-widest text-xs hover:bg-primary/85 active:scale-[0.98] transition-all duration-200 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={@uploads.photo.entries == [] or upload_errors(@uploads.photo) != []}
phx-disable-with="A CARREGAR..."
>
UPLOAD
</button>
</form>
<% end %>
</div>
"""
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
82 changes: 82 additions & 0 deletions lib/yearbook_web/components/photo_uploader.ex
Original file line number Diff line number Diff line change
@@ -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} />
<label for={@upload.ref} class="block">
<section
phx-drop-target={@upload.ref}
class={[
"transition-colors hover:cursor-pointer border-2 border-dashed",
@rounded && "rounded-full overflow-hidden",
@class
]}
>
<%= if @upload.entries == [] do %>
<article class="h-full">
<figure class="h-full flex items-center justify-center">
<%= if @image do %>
<img class={[@rounded && "p-0", not @rounded && "p-4", @image_class]} src={@image} />
<% else %>
<%= if @placeholder do %>
{render_slot(@placeholder)}
<% else %>
<div class="select-none flex flex-col gap-2 items-center">
<.icon name={@icon} class="w-12 h-12" />
<p class="px-4 text-center">{gettext("Upload a file or drag and drop.")}</p>
</div>
<% end %>
<% end %>
</figure>
</article>
<% end %>
<%= if !@preview_disabled do %>
<%= for entry <- @upload.entries do %>
<article class="h-full">
<figure class="h-full flex items-center justify-center">
<%= if image_file?(entry) do %>
<.live_img_preview
class={[@rounded && "p-0", not @rounded && "p-4", @image_class]}
entry={entry}
/>
<% else %>
<div class="select-none flex flex-col gap-2 items-center">
<.icon name="hero-document" class="w-12 h-12" />
<p class="px-4 text-center">{entry.client_name}</p>
</div>
<% end %>
</figure>
<%= for err <- upload_errors(@upload, entry) do %>
<p class="alert alert-danger">{Phoenix.Naming.humanize(err)}</p>
<% end %>
</article>
<% end %>
<% end %>
<%= for err <- upload_errors(@upload) do %>
<p class="alert alert-danger">{Phoenix.Naming.humanize(err)}</p>
<% end %>
</section>
</label>
"""
end

defp image_file?(entry) do
entry.client_type in ["image/jpeg", "image/png", "image/gif", "image/heic", "image/webp"]
end
end
8 changes: 8 additions & 0 deletions lib/yearbook_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"},

Expand Down
Loading