<.navbar current_scope={assigns[:current_scope]} />
@@ -47,7 +41,7 @@
<.flash_group flash={@flash} />
+
{@inner_content}
<.footer />
diff --git a/lib/yearbook_web/components/layouts/root.html.heex b/lib/yearbook_web/components/layouts/root.html.heex
index 79316eb..d8a27d3 100644
--- a/lib/yearbook_web/components/layouts/root.html.heex
+++ b/lib/yearbook_web/components/layouts/root.html.heex
@@ -32,7 +32,7 @@
})();
-
+
{@inner_content}
diff --git a/lib/yearbook_web/components/pagination.ex b/lib/yearbook_web/components/pagination.ex
new file mode 100644
index 0000000..f7a36cf
--- /dev/null
+++ b/lib/yearbook_web/components/pagination.ex
@@ -0,0 +1,117 @@
+defmodule YearbookWeb.Components.Pagination do
+ @moduledoc """
+ UI components for paginated navigation.
+ """
+ use Phoenix.Component
+
+ alias Plug.Conn.Query
+ use Gettext, backend: YearbookWeb.Gettext
+
+ attr :meta, :any, required: true
+ attr :params, :map, required: true
+
+ def pagination(assigns) do
+ ~H"""
+
+
+ {gettext("Showing")}
+
+ {@meta.current_offset + 1}-{@meta.next_offset || @meta.total_count}
+
+ {gettext("of")}
+
+ {@meta.total_count}
+
+
+
+
+ <.pagination_button
+ text={gettext("Previous")}
+ left_corner={true}
+ disabled={!@meta.has_previous_page?}
+ page={@meta.previous_page}
+ params={@params}
+ />
+
+ <%= if max(1, @meta.current_page - 2) != 1 do %>
+ <.pagination_button page={1} params={@params} />
+ 2}
+ class="flex items-center justify-center px-3 h-8 leading-tight text-gray-400 bg-white border border-gray-200 cursor-default"
+ >
+ ...
+
+ <% end %>
+
+ <%= for page <- max(1, @meta.current_page - 2)..max(min(@meta.total_pages, @meta.current_page + 2), 1) do %>
+ <.pagination_button page={page} params={@params} is_current={@meta.current_page == page} />
+ <% end %>
+
+ <%= if min(@meta.total_pages, @meta.current_page + 2) != @meta.total_pages do %>
+
+ ...
+
+ <.pagination_button page={@meta.total_pages} params={@params} />
+ <% end %>
+
+ <.pagination_button
+ text={gettext("Next")}
+ right_corner={true}
+ disabled={!@meta.has_next_page?}
+ page={@meta.next_page}
+ params={@params}
+ />
+
+
+ """
+ end
+
+ attr :text, :string, default: ""
+ attr :disabled, :boolean, default: false
+ attr :left_corner, :boolean, default: false
+ attr :right_corner, :boolean, default: false
+ attr :is_current, :boolean, default: false
+ attr :page, :integer
+ attr :params, :map
+
+ defp pagination_button(assigns) do
+ if assigns.disabled do
+ ~H"""
+
+
+ {if @text == "", do: @page, else: @text}
+
+
+ """
+ else
+ ~H"""
+
+ <.link
+ patch={build_query("page", @page, @params)}
+ class={[
+ "flex items-center justify-center px-3 h-8 leading-tight border border-gray-200 transition-colors",
+ @right_corner && "rounded-r-lg",
+ @left_corner && "rounded-l-lg",
+ !@is_current && "text-gray-500 bg-white hover:bg-gray-50 hover:text-gray-700",
+ @is_current && "text-gray-800 bg-gray-50 font-medium z-10"
+ ]}
+ >
+ {if @text == "", do: @page, else: @text}
+
+
+ """
+ end
+ end
+
+ defp build_query(key, value, params) do
+ query = Map.put(params, key, value)
+ "?#{Query.encode(query)}"
+ 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..0708d87
--- /dev/null
+++ b/lib/yearbook_web/components/photo_upload_card.ex
@@ -0,0 +1,186 @@
+defmodule YearbookWeb.Components.PhotoUploadCard do
+ @moduledoc """
+ Photo upload card component.
+ """
+
+ use YearbookWeb, :live_component
+
+ import YearbookWeb.Components.PhotoUploader
+
+ 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 %>
+
+
+
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={@upload}
+ 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(@upload) do %>
+
+ {error_to_string(err)}
+
+ <% end %>
+ <%= for entry <- @upload.entries, err <- upload_errors(@upload, entry) do %>
+
+ {error_to_string(err)}
+
+ <% end %>
+
+ <%= if @show_upload_button do %>
+
+ UPLOAD
+
+ <% 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"""
+
+ """
+ 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,
+ max_file_size: 5_000_000
+ )}
+ 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 ->
+ {:ok, store_photo(path, entry)}
+ end)
+
+ case uploaded_files do
+ [file_path] ->
+ send(self(), {:photo_uploaded, socket.assigns.id, file_path})
+
+ {:noreply,
+ socket
+ |> assign(:staged_photo_path, file_path)
+ |> assign(:upload_error, nil)}
+
+ [] ->
+ {: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)!"
+ 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..e1667b6
--- /dev/null
+++ b/lib/yearbook_web/components/photo_uploader.ex
@@ -0,0 +1,93 @@
+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
+
+ 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}
+ phx-change={@phx_change}
+ phx-target={@phx_target}
+ />
+
+
+ <%= if @upload.entries == [] do %>
+
+
+ <%= if @image do %>
+
+ <% else %>
+ <%= if @placeholder do %>
+ {render_slot(@placeholder)}
+ <% else %>
+
+ <.icon name={@icon} class="w-12 h-12" />
+
{gettext("Upload a file or drag and drop.")}
+
+ <% end %>
+ <% end %>
+
+
+ <% end %>
+ <%= if !@preview_disabled do %>
+ <%= for entry <- @upload.entries do %>
+
+
+ <%= if image_file?(entry) do %>
+ <.live_img_preview
+ class={[@rounded && "p-0", not @rounded && "p-4", @image_class]}
+ entry={entry}
+ />
+ <% else %>
+
+ <.icon name="hero-document" class="w-12 h-12" />
+
{entry.client_name}
+
+ <% end %>
+
+ <%= for err <- upload_errors(@upload, entry) do %>
+ {Phoenix.Naming.humanize(err)}
+ <% end %>
+
+ <% end %>
+ <% end %>
+ <%= for err <- upload_errors(@upload) do %>
+ {Phoenix.Naming.humanize(err)}
+ <% end %>
+
+
+ """
+ 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/lib/yearbook_web/live/home_live/index.ex b/lib/yearbook_web/live/home_live/index.ex
index 785bb3e..70c4d65 100644
--- a/lib/yearbook_web/live/home_live/index.ex
+++ b/lib/yearbook_web/live/home_live/index.ex
@@ -1,12 +1,69 @@
defmodule YearbookWeb.HomeLive.Index do
- use YearbookWeb, :landing_view
-
@moduledoc """
Landing Page
"""
+ use YearbookWeb, :landing_view
+ alias Yearbook.Entries
+ import YearbookWeb.Components.EntryCard
+ import YearbookWeb.Components.Pagination
+
+ @page_size 10
+
def mount(_params, _session, socket) do
{:ok,
socket
- |> assign(page_title: "Home | Yearbook")}
+ |> assign(page_title: "Home | Yearbook")
+ |> assign(filters: %{"masters" => "", "year" => ""})
+ |> assign(page_size: @page_size)}
+ end
+
+ def handle_params(params, _url, socket) do
+ page = params["page"] || "1"
+ new_filters = Map.take(params, ["masters", "year"])
+ current_filters = Map.merge(socket.assigns.filters, new_filters)
+
+ results =
+ Entries.list_accepted_entries_pagination(Map.merge(current_filters, %{"page" => page}))
+
+ socket =
+ socket
+ |> assign(entries: results.entries)
+ |> assign(filters: current_filters)
+ |> assign(params: params)
+ |> assign(current_page_num: results.current_page)
+ |> assign(total_pages: results.total_pages)
+ |> assign(total_count: results.total_count)
+
+ {:noreply, apply_action(socket, socket.assigns.live_action, params)}
+ end
+
+ defp apply_action(socket, :new, _params) do
+ socket
+ |> assign(:page_title, "Pedido | Yearbook")
+ |> assign(:entry, %Entries.Entry{})
+ end
+
+ defp apply_action(socket, _action, _params) do
+ socket
+ end
+
+ def handle_event("filter_changed", params, socket) do
+ new_params = Map.put(params, "page", 1)
+ {:noreply, push_patch(socket, to: ~p"/?#{new_params}")}
+ end
+
+ defp list_academic_years do
+ current_year = Date.utc_today().year
+
+ years =
+ 2020..current_year
+ |> Enum.map(fn y -> {format_academic_year(y), "#{y}"} end)
+ |> Enum.reverse()
+
+ [{"Todos os Anos", ""} | years]
+ end
+
+ defp format_academic_year(year) do
+ "#{year - 1}/#{rem(year, 100)}"
end
end
diff --git a/lib/yearbook_web/live/home_live/index.html.heex b/lib/yearbook_web/live/home_live/index.html.heex
index e69de29..3ba7f71 100644
--- a/lib/yearbook_web/live/home_live/index.html.heex
+++ b/lib/yearbook_web/live/home_live/index.html.heex
@@ -0,0 +1,174 @@
+
+
+
+
+
+ Yearbook
+
+
+
+
+
+
+
+
+
+ Todos
+
+ <%= if @filters["masters"] == "" do %>
+
+ <% end %>
+
+
+
+
+ Licenciatura
+
+ <%= if @filters["masters"] == "false" do %>
+
+ <% end %>
+
+
+
+
+ Mestrado
+
+ <%= if @filters["masters"] == "true" do %>
+
+ <% end %>
+
+
+
+ <%= if assigns[:current_scope] && @current_scope.user do %>
+ <.link
+ patch={~p"/entries/new?#{@params}"}
+ class="flex items-center justify-center gap-2 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-xs font-bold uppercase tracking-wider text-gray-600 hover:bg-gray-50 hover:border-orange-200 hover:text-orange-600 transition-all shadow-sm min-w-10"
+ >
+ <.icon name="hero-plus" class="size-4 " />
+
+
Adicionar
+
+ <% end %>
+
+
+
+ <%= case {@filters["masters"], @filters["year"]} do %>
+ <% {"", ""} -> %>
+ Todos os alunos registados no Yearbook.
+ <% {"true", ""} -> %>
+ Alunos do Mestrado de todos os anos letivos.
+ <% {"false", ""} -> %>
+ Alunos da Licenciatura de todos os anos letivos.
+ <% {masters, year} -> %>
+ Alunos {if masters == "true", do: "do Mestrado", else: "da Licenciatura"} do Ano Letivo {format_academic_year(
+ String.to_integer(year)
+ )}.
+ <% end %>
+
+
+
+
+ <%= for entry <- @entries do %>
+ <.entry_card name={entry.name} text={entry.text} src={entry.photo} />
+ <% end %>
+
+
1} class="mt-16 border-t border-gray-100">
+ <.pagination
+ params={@params}
+ meta={
+ %{
+ total_pages: @total_pages,
+ current_page: @current_page_num,
+ total_count: @total_count,
+ current_offset: (@current_page_num - 1) * 10,
+ next_offset: min(@current_page_num * 10, @total_count),
+ previous_page: @current_page_num - 1,
+ next_page: @current_page_num + 1,
+ has_previous_page?: @current_page_num > 1,
+ has_next_page?: @current_page_num < @total_pages
+ }
+ }
+ />
+
+
+
+ <%= if @live_action == :new do %>
+ <.modal
+ id="entry-modal"
+ show
+ on_cancel={JS.patch(~p"/?#{@params}")}
+ >
+
+ <.live_component
+ module={YearbookWeb.EntryFormComponent}
+ id={:new}
+ action={@live_action}
+ entry={%Yearbook.Entries.Entry{}}
+ patch={~p"/?#{@params}"}
+ />
+
+
+ <% end %>
+
diff --git a/lib/yearbook_web/router.ex b/lib/yearbook_web/router.ex
index 04aa2d9..4609e9c 100644
--- a/lib/yearbook_web/router.ex
+++ b/lib/yearbook_web/router.ex
@@ -24,12 +24,15 @@ defmodule YearbookWeb.Router do
end
scope "/", YearbookWeb do
- pipe_through :browser
+ pipe_through [:browser]
- live_session :navbar,
- on_mount: [{YearbookWeb.UserAuth, :mount_current_scope}] do
+ live_session :public_home, on_mount: [{YearbookWeb.UserAuth, :mount_current_scope}] do
live "/", HomeLive.Index, :index
end
+
+ live_session :private_home, on_mount: [{YearbookWeb.UserAuth, :require_authenticated}] do
+ live "/entries/new", HomeLive.Index, :new
+ end
end
scope "/backoffice", YearbookWeb 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"},
diff --git a/mix.lock b/mix.lock
index d56bf65..e1a25b4 100644
--- a/mix.lock
+++ b/mix.lock
@@ -3,6 +3,7 @@
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
+ "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
@@ -12,20 +13,27 @@
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
+ "ex_aws": {:hex, :ex_aws, "2.6.1", "194582c7b09455de8a5ab18a0182e6dd937d53df82be2e63c619d01bddaccdfa", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "67842a08c90a1d9a09dbe4ac05754175c7ca253abe4912987c759395d4bd9d26"},
+ "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.9", "862b7792f2e60d7010e2920d79964e3fab289bc0fd951b0ba8457a3f7f9d1199", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "a480d2bb2da64610014021629800e1e9457ca5e4a62f6775bffd963360c2bf90"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
+ "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
+ "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
+ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
+ "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
+ "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
@@ -38,6 +46,8 @@
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
+ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
+ "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
@@ -45,6 +55,9 @@
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
+ "waffle": {:hex, :waffle, "1.1.10", "0f847ed6f95349af258a90f0f70ffea02b3d3729c4eb78f6fae7bf776e91779e", [:mix], [{:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.1", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "859ba6377b78f0a51bc9596227b194f26241efbbd408bd217450c22b0f359cc4"},
+ "waffle_ecto": {:hex, :waffle_ecto, "0.0.12", "e5c17c49b071b903df71861c642093281123142dc4e9908c930db3e06795b040", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:waffle, "~> 1.0", [hex: :waffle, repo: "hexpm", optional: false]}], "hexpm", "585fe6371057066d2e8e3383ddd7a2437ff0668caf3f4cbf5a041e0de9837168"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
+ "zstream": {:hex, :zstream, "0.6.7", "ae74c7544f4e0563ffbe71324bf1c9bbc0ab33bb580a0ae718da511fb8a5c9d6", [:mix], [], "hexpm", "48c43ae0f00cfcda1ccb69c1d044755663d43b2ee8a0a65763648bf2078d634d"},
}
diff --git a/priv/repo/migrations/20260417215718_add_masters_to_entries.exs b/priv/repo/migrations/20260417215718_add_masters_to_entries.exs
new file mode 100644
index 0000000..dc04d60
--- /dev/null
+++ b/priv/repo/migrations/20260417215718_add_masters_to_entries.exs
@@ -0,0 +1,9 @@
+defmodule Yearbook.Repo.Migrations.AddMastersToEntries do
+ use Ecto.Migration
+
+ def change do
+ alter table(:entries) do
+ add :masters, :boolean, default: false, null: false
+ end
+ end
+end
diff --git a/priv/repo/migrations/20260418120002_add_year_to_entries.exs b/priv/repo/migrations/20260418120002_add_year_to_entries.exs
new file mode 100644
index 0000000..d855f39
--- /dev/null
+++ b/priv/repo/migrations/20260418120002_add_year_to_entries.exs
@@ -0,0 +1,9 @@
+defmodule Yearbook.Repo.Migrations.AddYearToEntries do
+ use Ecto.Migration
+
+ def change do
+ alter table(:entries) do
+ add :year, :integer, null: false
+ end
+ end
+end
diff --git a/priv/repo/seeds/entries.exs b/priv/repo/seeds/entries.exs
index 94b1480..34d74f8 100644
--- a/priv/repo/seeds/entries.exs
+++ b/priv/repo/seeds/entries.exs
@@ -18,7 +18,10 @@ defmodule Yearbook.Repo.Seeds.Entries do
name: name,
text: Enum.random(@quotes),
status: :pending,
- photo: "https://i.pravatar.cc/150?u=entry#{i}"
+ photo: "https://i.pravatar.cc/150?u=entry#{i}",
+ masters: Enum.random([true, false]),
+ year: Enum.random(2020..2026)
+
}
case %Entry{} |> Entry.changeset(attrs) |> Repo.insert() do
diff --git a/test/support/fixtures/entries_fixtures.ex b/test/support/fixtures/entries_fixtures.ex
index fe39f9a..1c55a09 100644
--- a/test/support/fixtures/entries_fixtures.ex
+++ b/test/support/fixtures/entries_fixtures.ex
@@ -14,7 +14,8 @@ defmodule Yearbook.EntriesFixtures do
name: "some name",
photo: "some photo",
status: :pending,
- text: "some text"
+ text: "some text",
+ year: 2026
})
|> Yearbook.Entries.create_entry()
diff --git a/test/yearbook/entries_test.exs b/test/yearbook/entries_test.exs
index 4e274df..25ad792 100644
--- a/test/yearbook/entries_test.exs
+++ b/test/yearbook/entries_test.exs
@@ -8,7 +8,7 @@ defmodule Yearbook.EntriesTest do
import Yearbook.EntriesFixtures
- @invalid_attrs %{name: nil, status: nil, text: nil, photo: nil}
+ @invalid_attrs %{name: nil, status: nil, text: nil, photo: nil, year: nil}
test "list_entries/0 returns all entries" do
entry = entry_fixture()
@@ -21,13 +21,20 @@ defmodule Yearbook.EntriesTest do
end
test "create_entry/1 with valid data creates a entry" do
- valid_attrs = %{name: "some name", status: :pending, text: "some text", photo: "some photo"}
+ valid_attrs = %{
+ name: "some name",
+ status: :pending,
+ text: "some text",
+ photo: "some photo",
+ year: 2026
+ }
assert {:ok, %Entry{} = entry} = Entries.create_entry(valid_attrs)
assert entry.name == "some name"
assert entry.status == :pending
assert entry.text == "some text"
assert entry.photo == "some photo"
+ assert entry.year == 2026
end
test "create_entry/1 with invalid data returns error changeset" do
@@ -41,7 +48,8 @@ defmodule Yearbook.EntriesTest do
name: "some updated name",
status: :accepted,
text: "some updated text",
- photo: "some updated photo"
+ photo: "some updated photo",
+ year: 2025
}
assert {:ok, %Entry{} = entry} = Entries.update_entry(entry, update_attrs)
@@ -49,6 +57,7 @@ defmodule Yearbook.EntriesTest do
assert entry.status == :accepted
assert entry.text == "some updated text"
assert entry.photo == "some updated photo"
+ assert entry.year == 2025
end
test "update_entry/2 with invalid data returns error changeset" do