Skip to content
Merged
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
8 changes: 6 additions & 2 deletions lib/yearbook/accounts/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ defmodule Yearbook.Accounts.User do
use Ecto.Schema
import Ecto.Changeset

@required_fields ~w(email password)a
@optional_fields ~w(role confirmed_at)a

@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
Expand All @@ -14,6 +17,7 @@ defmodule Yearbook.Accounts.User do
field :hashed_password, :string, redact: true
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
field :role, Ecto.Enum, values: [:student, :admin], default: :student

timestamps(type: :utc_datetime)
end
Expand All @@ -31,14 +35,14 @@ defmodule Yearbook.Accounts.User do
"""
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :password])
|> cast(attrs, @required_fields)
|> validate_email(opts)
|> validate_password(opts)
end

def changeset(user, attrs) do
user
|> cast(attrs, [:email, :password])
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_email([])
|> validate_password([])
end
Expand Down
11 changes: 11 additions & 0 deletions lib/yearbook_web/components/layouts/backoffice.html.heex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<div class="flex flex-col lg:flex-row min-h-screen">
<YearbookWeb.Sidebar.sidebar
pages={YearbookWeb.Config.backoffice_pages()}
current_page={Map.get(assigns, :current_page)}
/>

<main class="flex-1 bg-gray-50 p-6 lg:p-10">
<.flash_group flash={@flash} />
{@inner_content}
</main>
</div>
139 changes: 107 additions & 32 deletions lib/yearbook_web/components/navbar.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,53 +2,128 @@ defmodule YearbookWeb.Navbar do
@moduledoc """
Navbar component.
"""

use YearbookWeb, :html
alias Phoenix.LiveView.JS

attr :current_scope, :any, default: nil

def navbar(assigns) do
~H"""
<div class="bg-white flex items-center justify-between w-full h-16 sm:h-20 px-3 sm:px-4 border-b-2 border-primary">
<div class="bg-white flex items-center justify-between w-full h-16 sm:h-20 px-3 sm:px-4 border-b-2 border-primary sticky top-0 z-40">
<div class="flex z-10 justify-between h-full items-center">
<div class="p-2 sm:p-3 rounded-2xl">
<.link navigate={~p"/"} class="flex items-center gap-2 sm:gap-3 cursor-pointer">
<span class="hero-academic-cap text-black h-7 w-7 sm:h-8 sm:w-8"></span>
<span class="text-xl sm:text-2xl font-bold text-black">YEARBOOK</span>
<span class="text-xl sm:text-2xl font-bold text-black uppercase">YEARBOOK</span>
</.link>
</div>
</div>
<div class="flex items-center gap-1 sm:gap-3">
<%= if assigns[:current_scope] && assigns.current_scope.user do %>
<.link
navigate={~p"/users/settings"}
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-semibold hover:text-primary transition cursor-pointer"
>
Settings
</.link>
<.link
href={~p"/users/log-out"}
method="delete"
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-semibold hover:text-primary transition cursor-pointer"
>
Logout
</.link>
<% else %>
<.link
navigate={~p"/users/register"}
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-semibold hover:text-primary transition cursor-pointer"
>
Sign Up
</.link>
<.link
navigate={~p"/users/log-in"}
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-semibold hover:text-primary transition cursor-pointer"
>
Sign In
</.link>
<% end %>

<div class="hidden lg:flex items-center gap-1 sm:gap-3">
<.nav_content current_scope={@current_scope} />
</div>

<div class="lg:hidden flex items-center">
<button type="button" phx-click={show_mobile_navbar()} id="show-mobile-navbar">
<.icon name="hero-bars-3" class="size-8 text-black" />
</button>
</div>
</div>

<div
id="mobile-navbar-container"
class="fixed inset-0 z-50 lg:hidden"
style="display: none;"
>
<div class="fixed inset-0 bg-black/20" phx-click={hide_mobile_navbar()}></div>

<div
id="mobile-navbar"
class="relative bg-white border-b-2 border-primary w-full flex flex-col p-4 shadow-lg"
>
<div class="flex justify-end mb-4">
<button type="button" phx-click={hide_mobile_navbar()}>
<.icon name="hero-x-mark" class="size-8 text-black" />
</button>
</div>

<div class="flex flex-col items-center gap-4 pb-4">
<.nav_content current_scope={@current_scope} is_mobile={true} />
</div>
</div>
</div>
"""
end

defp nav_content(assigns) do
assigns = assign_new(assigns, :is_mobile, fn -> false end)

~H"""
<%= if @current_scope && @current_scope.user do %>
<%= if @current_scope.user.role == :admin do %>
<.link
navigate={~p"/backoffice/mock"}
phx-click={@is_mobile && hide_mobile_navbar()}
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-bold text-primary hover:opacity-80 transition cursor-pointer"
>
Backoffice
</.link>
<% end %>
<.link
navigate={~p"/users/settings"}
phx-click={@is_mobile && hide_mobile_navbar()}
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-semibold hover:text-primary transition cursor-pointer"
>
Settings
</.link>
<.link
href={~p"/users/log-out"}
method="delete"
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-semibold hover:text-primary transition cursor-pointer"
>
Logout
</.link>
<% else %>
<.link
navigate={~p"/users/register"}
phx-click={@is_mobile && hide_mobile_navbar()}
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-semibold hover:text-primary transition cursor-pointer"
>
Sign Up
</.link>
<.link
navigate={~p"/users/log-in"}
phx-click={@is_mobile && hide_mobile_navbar()}
class="inline-flex items-center justify-center px-3 sm:w-32 h-9 sm:h-10 text-sm sm:text-base font-semibold hover:text-primary transition cursor-pointer"
>
Sign In
</.link>
<% end %>
"""
end

def show_mobile_navbar(js \\ %JS{}) do
js
|> JS.add_class("overflow-hidden", to: "body")
|> JS.show(to: "#mobile-navbar-container", transition: "fade-in")
|> JS.show(
to: "#mobile-navbar",
display: "flex",
time: 300,
transition:
{"transition ease-in-out duration-300 transform", "-translate-y-full", "translate-y-0"}
)
end

def hide_mobile_navbar(js \\ %JS{}) do
js
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.hide(to: "#mobile-navbar-container", transition: "fade-out")
|> JS.hide(
to: "#mobile-navbar",
time: 300,
transition:
{"transition ease-in-out duration-300 transform", "translate-y-0", "-translate-y-full"}
)
end
end
121 changes: 121 additions & 0 deletions lib/yearbook_web/components/sidebar.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
defmodule YearbookWeb.Sidebar do
@moduledoc """
Sidebar component for the application layout.
"""
use Phoenix.Component
use YearbookWeb, :html
alias Phoenix.LiveView.JS

attr :pages, :list, required: true
attr :current_page, :atom, default: nil

def sidebar(assigns) do
~H"""
<div class="lg:hidden bg-white flex items-center justify-between w-full h-16 sm:h-20 px-3 sm:px-4 border-b-2 border-primary sticky top-0 z-40">
<div class="flex z-10 justify-between h-full items-center">
<div class="p-2 sm:p-3 rounded-2xl">
<.link navigate={~p"/"} class="flex items-center gap-2 sm:gap-3 cursor-pointer">
<span class="hero-academic-cap text-black h-7 w-7 sm:h-8 sm:w-8"></span>
<span class="text-xl sm:text-2xl font-bold text-black uppercase">YEARBOOK</span>
</.link>
</div>
</div>

<div class="flex items-center">
<button type="button" phx-click={show_mobile_sidebar()}>
<.icon name="hero-bars-3" class="size-8 text-black" />
</button>
</div>
</div>

<div
id="mobile-sidebar-container"
class="fixed inset-0 z-50 flex lg:hidden"
aria-modal="true"
style="display: none;"
>
<div
id="sidebar-overlay"
class="fixed inset-0 bg-black/20"
phx-click={hide_mobile_sidebar()}
>
</div>
<div
id="mobile-sidebar"
class="relative w-64 bg-white border-r border-black/10 h-full flex flex-col"
>
<div class="p-4 flex justify-end">
<button phx-click={hide_mobile_sidebar()}>
<.icon name="hero-x-mark" class="size-6" />
</button>
</div>
<.sidebar_content pages={@pages} current_page={@current_page} />
</div>
</div>

<aside class="hidden lg:flex w-64 h-screen bg-white flex-col border-r border-black/10 sticky top-0">
<.sidebar_content pages={@pages} current_page={@current_page} />
</aside>
"""
end

defp sidebar_content(assigns) do
~H"""
<div class="font-bold text-3xl p-10 flex justify-center">
<.link navigate={~p"/"} class="uppercase">YEARBOOK</.link>
</div>

<nav class="flex flex-col px-5 gap-2">
<%= for page <- @pages do %>
<.link
navigate={page.url}
phx-click={hide_mobile_sidebar()}
class={[
"flex items-center gap-2 w-full px-3 h-10 rounded-md transition-colors",
@current_page == page.key && "bg-black text-white",
@current_page != page.key && "hover:bg-gray-100"
]}
>
<.icon name={page.icon} class="size-4.5" />
<span class="text-md">{page.title}</span>
</.link>
<% end %>
</nav>
"""
end

def show_mobile_sidebar(js \\ %JS{}) do
js
|> JS.add_class("overflow-hidden", to: "body")
|> JS.show(
to: "#mobile-sidebar-container",
transition: {"transition fade-in duration-200", "opacity-0", "opacity-100"}
)
|> JS.show(
to: "#mobile-sidebar",
display: "flex",
time: 300,
transition:
{"transition ease-in-out duration-300 transform", "-translate-x-full", "translate-x-0"}
)
|> JS.hide(to: "#show-mobile-sidebar", transition: "fade-out")
|> JS.dispatch("js:call", to: "#hide-mobile-sidebar", detail: %{call: "focus", args: []})
end

def hide_mobile_sidebar(js \\ %JS{}) do
js
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.hide(
to: "#mobile-sidebar-container",
transition: {"transition fade-out duration-200", "opacity-100", "opacity-0"}
)
|> JS.hide(
to: "#mobile-sidebar",
time: 300,
transition:
{"transition ease-in-out duration-300 transform", "translate-x-0", "-translate-x-full"}
)
|> JS.show(to: "#show-mobile-sidebar", transition: "fade-in")
|> JS.dispatch("js:call", to: "#show-mobile-sidebar", detail: %{call: "focus", args: []})
end
end
15 changes: 15 additions & 0 deletions lib/yearbook_web/config.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
defmodule YearbookWeb.Config do
@moduledoc """
Web configuration for the app.
"""
def backoffice_pages do
[
%{
key: :mock,
title: "Mock",
icon: "hero-pencil",
url: "/backoffice/mock"
}
]
end
end
13 changes: 13 additions & 0 deletions lib/yearbook_web/live/backoffice/mock_live/index.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule YearbookWeb.MockLive do
use YearbookWeb, :live_view

def mount(_params, _session, socket) do
{:ok, assign(socket, current_page: :mock)}
end

def render(assigns) do
~H"""
<div>Mock page</div>
"""
end
end
Empty file.
29 changes: 29 additions & 0 deletions lib/yearbook_web/live/plugs/user_roles.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
defmodule YearbookWeb.Plugs.UserRoles do
@moduledoc """
Plugs for user type verification.
"""
use YearbookWeb, :verified_routes

import Plug.Conn
import Phoenix.Controller

@doc """
Used for routes that require the user to be admin.
"""
def require_admin_user(conn, _opts) do
require_user_type(conn, :admin)
end

defp require_user_type(conn, role) do
user = conn.assigns[:current_scope] && conn.assigns.current_scope.user

if user && user.role == role do
conn
else
conn
|> put_flash(:error, "You don't have access to this page.")
|> redirect(to: ~p"/")
|> halt()
end
end
end
Loading
Loading