Skip to content

Commit 14801cb

Browse files
feat: landing page with dynamic filtering (#51)
* feat: landing page * feat: pagination * feat: Upload photo component * feat: entry form * fix: make photo uploader reusable * fix: check --------- Co-authored-by: a111430 <a111430@alunos.uminho.pt>
1 parent 9abb18d commit 14801cb

19 files changed

Lines changed: 694 additions & 55 deletions

File tree

assets/css/app.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
@theme {
1414
--color-primary: #ee7749;
15+
--color-app-bg: #F9F9F9;
1516
}
1617

1718

lib/yearbook/entries.ex

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,34 @@ defmodule Yearbook.Entries do
8686
}
8787
end
8888

89+
@doc """
90+
Returns a paginated map of accepted entries based on filters and page.
91+
"""
92+
def list_accepted_entries_pagination(params \\ %{}) do
93+
query = from e in Entry, where: e.status == :accepted
94+
95+
query =
96+
case Map.get(params, "masters") do
97+
"true" -> from e in query, where: e.masters == true
98+
"false" -> from e in query, where: e.masters == false
99+
_ -> query
100+
end
101+
102+
query =
103+
case Map.get(params, "year") do
104+
year_str when year_str in [nil, ""] ->
105+
query
106+
107+
year_str ->
108+
year_int = if is_binary(year_str), do: String.to_integer(year_str), else: year_str
109+
from e in query, where: e.year == ^year_int
110+
end
111+
112+
query = from e in query, order_by: [asc: e.name]
113+
114+
paginate(query, params)
115+
end
116+
89117
@doc """
90118
Gets a single entry.
91119

lib/yearbook/entries/entry.ex

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,29 @@ defmodule Yearbook.Entries.Entry do
66
use Ecto.Schema
77
import Ecto.Changeset
88

9-
@required_fields ~w(photo name text)a
10-
@optional_fields ~w(status)a
9+
@required_fields ~w(photo name text year)a
10+
@optional_fields ~w(status masters)a
1111
@primary_key {:id, :binary_id, autogenerate: true}
1212
@foreign_key_type :binary_id
1313
schema "entries" do
1414
field :photo, :string
1515
field :name, :string
1616
field :text, :string
1717
field :status, Ecto.Enum, values: [:accepted, :pending, :denied], default: :pending
18+
field :masters, :boolean, default: false
19+
field :year, :integer
1820

1921
timestamps(type: :utc_datetime)
2022
end
2123

2224
@doc false
2325
def changeset(entry, attrs) do
26+
current_year = Date.utc_today().year
27+
2428
entry
2529
|> cast(attrs, @required_fields ++ @optional_fields)
2630
|> validate_required(@required_fields)
31+
|> validate_number(:year, greater_than: 2020, less_than_or_equal_to: current_year)
32+
|> validate_length(:text, max: 200)
2733
end
2834
end

lib/yearbook_web/components/core_components.ex

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -521,27 +521,41 @@ defmodule YearbookWeb.CoreComponents do
521521
phx-mounted={@show && show_modal(@id)}
522522
phx-remove={hide_modal(@id)}
523523
data-cancel={JS.exec(@on_cancel, "phx-remove") |> hide_modal(@id)}
524-
class="relative z-60 hidden"
524+
class="relative z-50 hidden"
525525
>
526526
<div
527527
id={"#{@id}-bg"}
528-
class="bg-black/60 backdrop-blur-sm fixed inset-0 transition-opacity"
529-
aria-hidden="true"
528+
class="fixed inset-0 bg-black/50 backdrop-blur-md transition-opacity"
530529
/>
531-
<div class="fixed inset-0 overflow-y-auto">
532-
<div class="flex min-h-full items-center justify-center p-4">
533-
<div
534-
id={"#{@id}-container"}
535-
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
536-
class="relative bg-white rounded-xl max-w-sm w-full shadow-2xl p-8 text-center transition"
530+
531+
<div class="fixed inset-0 flex min-h-full items-start justify-center overflow-y-auto px-4 py-6 sm:items-center sm:py-10">
532+
<div
533+
id={"#{@id}-container"}
534+
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
535+
class="
536+
relative w-full max-w-2xl
537+
bg-white rounded-3xl
538+
shadow-[0_20px_60px_rgba(0,0,0,0.15)]
539+
border border-gray-100
540+
p-6 sm:p-10
541+
542+
animate-in fade-in zoom-in-95 duration-200
543+
"
544+
>
545+
<button
546+
phx-click={JS.exec("data-cancel", to: "##{@id}")}
547+
class="
548+
absolute top-5 right-5
549+
w-9 h-9 flex items-center justify-center
550+
text-gray-500 hover:text-gray-700
551+
transition-all focus:outline-none
552+
cursor-pointer
553+
"
537554
>
538-
<button
539-
phx-click={JS.exec("data-cancel", to: "##{@id}")}
540-
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors cursor-pointer"
541-
>
542-
<.icon name="hero-x-mark" class="size-5" />
543-
</button>
555+
<.icon name="hero-x-mark" class="size-5" />
556+
</button>
544557
558+
<div class="text-left">
545559
{render_slot(@inner_block)}
546560
</div>
547561
</div>

lib/yearbook_web/components/entry_card.ex

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,25 @@ defmodule YearbookWeb.Components.EntryCard do
1111

1212
def entry_card(assigns) do
1313
~H"""
14-
<div class="flex flex-col items-center gap-2 p-2 overflow-auto bg-white rounded-xl shadow-md w-full min-h-56 sm:min-h-72 lg:min-h-96">
15-
<div class="w-full shrink-0 overflow-hidden aspect-square sm:aspect-4/3">
14+
<div class="flex flex-col items-center gap-3 p-4 bg-white rounded-2xl border border-gray-100 w-full h-full transition-all">
15+
<div class="w-full aspect-square overflow-hidden rounded-xl border border-gray-50">
1616
<%= if @src do %>
17-
<img
18-
src={@src}
19-
alt={@name}
20-
class="w-full h-full object-cover rounded-md"
21-
/>
17+
<img src={@src} alt={@name} class="w-full h-full object-cover" />
2218
<% else %>
23-
<div class="flex items-center justify-center w-full h-full rounded-xl bg-gray-300 text-white font-bold text-2xl">
19+
<div class="flex items-center justify-center w-full h-full bg-gray-100 text-gray-400 font-bold text-xl">
2420
{get_initials(@name)}
2521
</div>
2622
<% end %>
2723
</div>
28-
<h1 class="text-center w-full wrap-break-word font-bold px-2 text-md sm:text-lg lg:text-xl">
29-
{@name}
30-
</h1>
31-
<p class="flex-1 place-items-center p-2 text-sm sm:text-md lg:text-lg">{@text}</p>
24+
25+
<div class="text-center w-full">
26+
<h1 class="font-bold text-gray-900 text-sm md:text-base leading-tight">
27+
{@name}
28+
</h1>
29+
<p class="italic text-[11px] md:text-xs text-gray-500 mt-2 line-clamp-3 leading-relaxed">
30+
"{@text}"
31+
</p>
32+
</div>
3233
</div>
3334
"""
3435
end
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
defmodule YearbookWeb.EntryFormComponent do
2+
@moduledoc """
3+
Entry form component.
4+
"""
5+
use YearbookWeb, :live_component
6+
alias Yearbook.Entries
7+
alias YearbookWeb.Components.PhotoUploadCard
8+
import YearbookWeb.Components.PhotoUploadCard
9+
10+
@impl true
11+
def render(assigns) do
12+
~H"""
13+
<div class="w-full flex justify-center">
14+
<div class="w-full max-w-2xl">
15+
<header class="mb-10">
16+
<h2 class="text-3xl font-bold text-gray-900 tracking-tight">
17+
Novo Pedido
18+
</h2>
19+
<p class="text-sm text-gray-500 mt-2">
20+
Preenche os dados para apareceres no Yearbook.
21+
</p>
22+
</header>
23+
24+
<.form
25+
for={@form}
26+
id="entry-form"
27+
as={:entry}
28+
phx-target={@myself}
29+
phx-change="validate"
30+
phx-submit="save"
31+
class="space-y-6"
32+
>
33+
<.input field={@form[:year]} type="hidden" value={@current_year} />
34+
35+
<div class="flex flex-col md:flex-row gap-12 items-start">
36+
<div class="grow w-full space-y-6 mt-2">
37+
<div>
38+
<label class="block text-xs font-semibold text-gray-600 mb-2">Nome</label>
39+
<.input
40+
field={@form[:name]}
41+
type="text"
42+
placeholder="Ex: Angela Martin"
43+
class="w-full px-5 py-4 rounded-2xl bg-white border border-gray-300 focus:bg-white focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition-all outline-none text-sm"
44+
/>
45+
</div>
46+
47+
<div>
48+
<label class="block text-xs font-semibold text-gray-600 mb-2">Frase</label>
49+
<.input
50+
field={@form[:text]}
51+
type="textarea"
52+
maxlength="200"
53+
rows="7"
54+
placeholder="Max 200 caracteres"
55+
class="w-full px-5 py-4 rounded-2xl bg-white border border-gray-300 focus:bg-white focus:border-orange-500 focus:ring-4 focus:ring-orange-500/10 transition-all outline-none text-sm resize-none"
56+
/>
57+
</div>
58+
</div>
59+
60+
<div class="shrink-0 w-full md:w-64 flex justify-center md:pt-8">
61+
<.photo_upload_card
62+
upload={@uploads.photo}
63+
staged_photo_path={@staged_photo_path}
64+
show_upload_button={false}
65+
/>
66+
</div>
67+
</div>
68+
69+
<label class="group flex items-center gap-3 px-2 py-4 rounded-2xl bg-orange-50 border border-orange-100 cursor-pointer">
70+
<.input
71+
field={@form[:masters]}
72+
type="checkbox"
73+
class="hidden"
74+
/>
75+
76+
<div class="flex justify-center items-center w-5 h-5 rounded border border-gray-300 bg-white group-has-checked:bg-orange-200 group-has-checked:border-orange-300">
77+
<.icon
78+
name="hero-check"
79+
class="size-4 text-gray-900 hidden group-has-checked:block"
80+
/>
81+
</div>
82+
83+
<span class="text-sm font-medium text-gray-700 mt-1">
84+
Acabei o Mestrado
85+
</span>
86+
</label>
87+
88+
<div class="pt-4">
89+
<button
90+
type="submit"
91+
phx-disable-with="A enviar..."
92+
disabled={upload_in_progress?(@uploads.photo, @staged_photo_path)}
93+
class="w-full py-4 rounded-2xl bg-black text-white text-xs font-bold tracking-widest uppercase hover:bg-gray-900 hover:shadow-lg transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
94+
>
95+
<%= if upload_in_progress?(@uploads.photo, @staged_photo_path) do %>
96+
A carregar foto...
97+
<% else %>
98+
Enviar Pedido
99+
<% end %>
100+
</button>
101+
</div>
102+
</.form>
103+
</div>
104+
</div>
105+
"""
106+
end
107+
108+
@impl true
109+
def mount(socket) do
110+
{:ok,
111+
socket
112+
|> assign(:staged_photo_path, nil)
113+
|> allow_upload(:photo,
114+
accept: ~w(.jpg .jpeg .png),
115+
auto_upload: true,
116+
progress: &handle_progress/3,
117+
max_entries: 1,
118+
max_file_size: 5_000_000
119+
)}
120+
end
121+
122+
@impl true
123+
def update(%{entry: entry} = assigns, socket) do
124+
current_year = Date.utc_today().year
125+
changeset = Entries.change_entry(entry, %{"year" => current_year})
126+
127+
{:ok,
128+
socket
129+
|> assign(assigns)
130+
|> assign(:current_year, current_year)
131+
|> assign(:staged_photo_path, Map.get(assigns, :staged_photo_path))
132+
|> assign_form(changeset)}
133+
end
134+
135+
@impl true
136+
def update(assigns, socket) do
137+
{:ok, assign(socket, assigns)}
138+
end
139+
140+
@impl true
141+
def handle_event("validate", %{"entry" => entry_params}, socket) do
142+
changeset =
143+
socket.assigns.entry
144+
|> Entries.change_entry(entry_params)
145+
|> Map.put(:action, :validate)
146+
147+
{:noreply, assign_form(socket, changeset)}
148+
end
149+
150+
@impl true
151+
def handle_event("validate", _params, socket) do
152+
{:noreply, socket}
153+
end
154+
155+
@impl true
156+
def handle_event("save", %{"entry" => entry_params}, socket) do
157+
entry_params =
158+
entry_params
159+
|> Map.put("photo", socket.assigns.staged_photo_path)
160+
|> Map.put_new("year", socket.assigns.current_year)
161+
162+
case Entries.create_entry(entry_params) do
163+
{:ok, _entry} ->
164+
{:noreply,
165+
socket
166+
|> put_flash(:info, "Entrada criada com sucesso!")
167+
|> redirect(to: socket.assigns.patch)}
168+
169+
{:error, %Ecto.Changeset{} = changeset} ->
170+
{:noreply, assign_form(socket, changeset)}
171+
end
172+
end
173+
174+
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
175+
assign(socket, :form, to_form(changeset))
176+
end
177+
178+
defp handle_progress(:photo, entry, socket) do
179+
if entry.done? do
180+
file_path =
181+
consume_uploaded_entry(socket, entry, fn %{path: path} ->
182+
{:ok, PhotoUploadCard.store_photo(path, entry)}
183+
end)
184+
185+
{:noreply, assign(socket, :staged_photo_path, file_path)}
186+
else
187+
{:noreply, socket}
188+
end
189+
end
190+
191+
defp upload_in_progress?(upload, staged_photo_path) do
192+
staged_photo_path == nil and upload.entries != []
193+
end
194+
end

lib/yearbook_web/components/layouts/landing.html.heex

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,16 @@
3232
})();
3333
</script>
3434
</head>
35-
<body class="bg-gray-100">
35+
<body class="bg-app-bg">
3636
<div class="relative min-h-screen w-full overflow-hidden">
37-
<img
38-
src="/images/0.png"
39-
class="absolute inset-0 h-full w-full object-cover"
40-
alt="background"
41-
/>
42-
4337
<div class="relative z-10 flex flex-col min-h-screen">
4438
<.navbar current_scope={assigns[:current_scope]} />
4539

4640
<div class="px-4 pt-4 md:px-8">
4741
<.flash_group flash={@flash} />
4842
</div>
4943

50-
<div class="flex grow md:items-center md:justify-center">
44+
<div class="flex grow md:items-start md:justify-center">
5145
{@inner_content}
5246
</div>
5347
<.footer />

lib/yearbook_web/components/layouts/root.html.heex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
})();
3333
</script>
3434
</head>
35-
<body class="bg-gray-100">
35+
<body class="bg-app-bg">
3636
{@inner_content}
3737
</body>
3838
</html>

0 commit comments

Comments
 (0)