UI function components

This commit is contained in:
Chris McCord 2021-10-29 12:12:23 -04:00
parent 708bf715e1
commit 2552a32865
25 changed files with 826 additions and 259 deletions

View file

@ -91,12 +91,10 @@
transition: opacity 1s ease-out;
}
.phx-disconnected{
.phx-loading{
cursor: wait;
}
.phx-disconnected *{
pointer-events: none;
}
.phx-modal {
opacity: 1!important;

View file

@ -0,0 +1,46 @@
defmodule LiveBeats.MediaLibrary do
@moduledoc """
The MediaLibrary context.
"""
import Ecto.Query, warn: false
alias LiveBeats.Repo
alias LiveBeats.MediaLibrary.{Song, Genre}
def create_genre(attrs \\ %{}) do
%Genre{}
|> Genre.changeset(attrs)
|> Repo.insert()
end
def list_genres do
Repo.all(Genre, order_by: [asc: :title])
end
def list_songs do
Repo.all(Song)
end
def get_song!(id), do: Repo.get!(Song, id)
def create_song(attrs \\ %{}) do
%Song{}
|> Song.changeset(attrs)
|> Repo.insert()
end
def update_song(%Song{} = song, attrs) do
song
|> Song.changeset(attrs)
|> Repo.update()
end
def delete_song(%Song{} = song) do
Repo.delete(song)
end
def change_song(%Song{} = song, attrs \\ %{}) do
Song.changeset(song, attrs)
end
end

View file

@ -0,0 +1,26 @@
defmodule LiveBeats.MediaLibrary.Genre do
use Ecto.Schema
import Ecto.Changeset
schema "genres" do
field :title, :string
field :slug, :string
end
@doc false
def changeset(song, attrs) do
song
|> cast(attrs, [:title])
|> validate_required([:title])
|> put_slug()
end
defp put_slug(%Ecto.Changeset{valid?: false} = changeset), do: changeset
defp put_slug(%Ecto.Changeset{valid?: true} = changeset) do
if title = get_change(changeset, :title) do
put_change(changeset, :slug, Phoenix.Naming.underscore(title))
else
changeset
end
end
end

View file

@ -0,0 +1,24 @@
defmodule LiveBeats.MediaLibrary.Song do
use Ecto.Schema
import Ecto.Changeset
schema "songs" do
field :album_artist, :string
field :artist, :string
field :date_recorded, :naive_datetime
field :date_released, :naive_datetime
field :duration, :integer
field :title, :string
belongs_to :user, LiveBeats.Accounts.User
belongs_to :genre, LiveBeats.MediaLibrary.Genre
timestamps()
end
@doc false
def changeset(song, attrs) do
song
|> cast(attrs, [:album_artist, :artist, :duration, :title, :date_recorded, :date_released])
|> validate_required([:artist, :duration, :title])
end
end

View file

@ -91,6 +91,7 @@ defmodule LiveBeatsWeb do
import LiveBeatsWeb.LiveHelpers
import LiveBeatsWeb.Gettext
alias LiveBeatsWeb.Router.Helpers, as: Routes
alias Phoenix.LiveView.JS
end
end

View file

@ -6,8 +6,9 @@ defmodule LiveBeatsWeb.UserAuth do
alias LiveBeats.Accounts
alias LiveBeatsWeb.Router.Helpers, as: Routes
def on_mount(:default, _params, session, socket) do
def on_mount(:current_user, _params, session, socket) do
socket = LiveView.assign(socket, :nonce, Map.fetch!(session, "nonce"))
case session do
%{"user_id" => user_id} ->
{:cont, LiveView.assign_new(socket, :current_user, fn -> Accounts.get_user!(user_id) end)}
@ -17,6 +18,19 @@ defmodule LiveBeatsWeb.UserAuth do
end
end
def on_mount(:ensure_authenticated, _params, session, socket) do
case session do
%{"user_id" => user_id} ->
{:cont, LiveView.assign_new(socket, :current_user, fn -> Accounts.get_user!(user_id) end)}
%{} ->
{:halt,
socket
|> LiveView.put_flash(:error, "Please sign in")
|> LiveView.redirect(to: Routes.sign_in_path(socket, :index))}
end
end
@doc """
Logs the user in.
@ -102,6 +116,7 @@ defmodule LiveBeatsWeb.UserAuth do
def require_authenticated_admin(conn, _opts) do
user = conn.assigns[:current_user]
if user && LiveBeats.Accounts.admin?(user) do
assign(conn, :current_admin, user)
else

View file

@ -1,14 +1,19 @@
defmodule LiveBeatsWeb.HomeLive do
use LiveBeatsWeb, :live_view
alias LiveBeats.MediaLibrary
def render(assigns) do
~H"""
<.title_bar>
LiveBeats - Chill
<:action>Share</:action>
<:action primary phx-click={show_modal("add-songs")}>Add Songs</:action>
<:actions>
<.button>Share</.button>
<.button primary phx-click={show_modal("add-songs")}>Add Songs</.button>
</:actions>
</.title_bar>
<.modal id="add-songs">
<:title>Add Songs</:title>
a modal
@ -321,164 +326,26 @@ defmodule LiveBeatsWeb.HomeLive do
</div>
<!-- Songs table (small breakpoint and up) -->
<div class="hidden mt-8 sm:block">
<div class="align-middle inline-block min-w-full border-b border-gray-200">
<table class="min-w-full">
<thead>
<tr class="border-t border-gray-200">
<th
class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<span class="lg:pl-2">Nextup</span>
</th>
<th
class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
likes
</th>
<th
class="hidden md:table-cell px-6 py-3 border-b border-gray-200 bg-gray-50 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
user
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<%= for _ <- 1..20 do %>
<tr>
<td class="px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900">
<div class="flex items-center space-x-3 lg:pl-2">
<div class="flex-shrink-0 w-2.5 h-2.5 rounded-full bg-pink-600" aria-hidden="true"></div>
<a href="#" class="truncate hover:text-gray-600">
<span>
GraphQL API
<!-- space -->
<span class="text-gray-500 font-normal">in Engineering</span>
</span>
</a>
</div>
</td>
<td class="px-6 py-3 text-sm text-gray-500 font-medium">
<div class="flex items-center space-x-2">
<div class="flex flex-shrink-0 -space-x-1">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Dries Vincent">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1517841905240-472988babdf9?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Lindsay Walton">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1438761681033-6461ffad8d80?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Courtney Henry">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Tom Cook">
</div>
<span class="flex-shrink-0 text-xs leading-5 font-medium">+8</span>
</div>
</td>
<td class="hidden md:table-cell px-6 py-3 whitespace-nowrap text-sm text-gray-500 text-right">
March 17, 2020
</td>
</tr>
<% end %>
<tr>
<td class="px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900">
<div class="flex items-center space-x-3 lg:pl-2">
<div class="flex-shrink-0 w-2.5 h-2.5 rounded-full bg-purple-600" aria-hidden="true"></div>
<a href="#" class="truncate hover:text-gray-600">
<span>
New Benefits Plan
<!-- space -->
<span class="text-gray-500 font-normal">in Human Resources</span>
</span>
</a>
</div>
</td>
<td class="px-6 py-3 text-sm text-gray-500 font-medium">
<div class="flex items-center space-x-2">
<div class="flex flex-shrink-0 -space-x-1">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1519345182560-3f2917c472ef?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Leonard Krasner">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1463453091185-61582044d556?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Floyd Miles">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1502685104226-ee32379fefbe?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Emily Selman">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1500917293891-ef795e70e1f6?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Kristin Watson">
</div>
<span class="flex-shrink-0 text-xs leading-5 font-medium">+4</span>
</div>
</td>
<td class="hidden md:table-cell px-6 py-3 whitespace-nowrap text-sm text-gray-500 text-right">
April 4, 2020
</td>
</tr>
<tr>
<td class="px-6 py-3 max-w-0 w-full whitespace-nowrap text-sm font-medium text-gray-900">
<div class="flex items-center space-x-3 lg:pl-2">
<div class="flex-shrink-0 w-2.5 h-2.5 rounded-full bg-yellow-500" aria-hidden="true"></div>
<a href="#" class="truncate hover:text-gray-600">
<span>
Onboarding Emails
<!-- space -->
<span class="text-gray-500 font-normal">in Customer Success</span>
</span>
</a>
</div>
</td>
<td class="px-6 py-3 text-sm text-gray-500 font-medium">
<div class="flex items-center space-x-2">
<div class="flex flex-shrink-0 -space-x-1">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1502685104226-ee32379fefbe?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Emily Selman">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1500917293891-ef795e70e1f6?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Kristin Watson">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1505840717430-882ce147ef2d?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Emma Dorsey">
<img class="max-w-none h-6 w-6 rounded-full ring-2 ring-white"
src="https://images.unsplash.com/photo-1509783236416-c9ad59bae472?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80"
alt="Alicia Bell">
</div>
<span class="flex-shrink-0 text-xs leading-5 font-medium">+10</span>
</div>
</td>
<td class="hidden md:table-cell px-6 py-3 whitespace-nowrap text-sm text-gray-500 text-right">
March 30, 2020
</td>
</tr>
</tbody>
</table>
</div>
</div>
<.table rows={@songs}>
<:col let={song} label="Song">
<%= song.title %>
</:col>
<:col let={song} label="Artist">
<%= song.artist %>
</:col>
<:col let={song} label="Time">
<%= song.duration %>
</:col>
<:col label=""></:col>
</.table>
"""
end
def mount(_parmas, _session, socket) do
{:ok, socket}
{:ok, assign(socket, :songs, fetch_songs(socket))}
end
defp fetch_songs(_socket) do
MediaLibrary.list_songs()
end
end

View file

@ -1,16 +1,33 @@
defmodule LiveBeatsWeb.LiveHelpers do
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
import Phoenix.HTML
alias Phoenix.LiveView.JS
def link_patch(assigns) do
def link(%{redirect_to: to} = assigns) do
opts = assigns |> assigns_to_attributes() |> Keyword.put(:to, to)
assigns = assign(assigns, :opts, opts)
~H"""
<%= live_patch @text, [
to: @to,
class: "phx-modal-close"
] ++ assigns_to_attributes(assigns, [:to, :text]) %>
<%= live_redirect @opts do %><%= render_slot(@inner_block) %><% end %>
"""
end
def link(%{patch_to: to} = assigns) do
opts = assigns |> assigns_to_attributes() |> Keyword.put(:to, to)
assigns = assign(assigns, :opts, opts)
~H"""
<%= live_patch @opts do %><%= render_slot(@inner_block) %><% end %>
"""
end
def link(%{} = assigns) do
opts = assigns |> assigns_to_attributes() |> Keyword.put(:to, assigns[:href] || "#")
assigns = assign(assigns, :opts, opts)
~H"""
<%= Phoenix.HTML.Link.link @opts do %><%= render_slot(@inner_block) %><% end %>
"""
end
@ -63,7 +80,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
transition: {"ease-out duration-300", "opacity-0", "opacity-100"}
)
|> JS.show(
to: "##{id} .modal-content",
to: "##{id}-content",
display: "inline-block",
transition:
{"ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
@ -73,6 +90,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
def hide_modal(js \\ %JS{}, id) do
js
|> JS.remove_class("fade-in", to: "##{id}")
|> JS.hide(
to: "##{id}",
transition: {"ease-in duration-200", "opacity-100", "opacity-0"}
@ -83,26 +101,35 @@ defmodule LiveBeatsWeb.LiveHelpers do
{"ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
|> JS.dispatch("click", to: "##{id} [data-modal-return]")
end
def modal(assigns) do
assigns =
assigns
|> assign_new(:title, fn -> "" end)
|> assign_new(:show, fn -> false end)
|> assign_new(:title, fn -> [] end)
|> assign_new(:confirm, fn -> nil end)
|> assign_new(:cancel, fn -> nil end)
|> assign_new(:return_to, fn -> nil end)
~H"""
<div id={@id} class="hidden fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div id={@id} class={"fixed z-10 inset-0 overflow-y-auto #{if @show, do: "fade-in", else: "hidden"}"} aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div
class="modal-content inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"
id={"#{@id}-content"}
class={"#{if @show, do: "fade-in-scale", else: "hidden"} modal-content inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"}
phx-window-keydown={hide_modal(@id)} phx-key="escape"
phx-click-away={hide_modal(@id)}
>
<%= if @return_to do %>
<%= live_redirect "close", to: @return_to, data: [modal_return: true], class: "hidden" %>
<% end %>
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
<!-- Heroicon name: outline/exclamation -->
<!-- Heroicon name: outline/plus -->
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@ -119,17 +146,20 @@ defmodule LiveBeatsWeb.LiveHelpers do
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
<%= render_slot(@confirm) %>
</button>
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
phx-window-keydown={hide_modal(@id)} phx-key="escape"
phx-click={hide_modal(@id)}
>
<%= render_slot(@cancel) %>
</button>
<%= if @confirm do %>
<button type="button" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
<%= render_slot(@confirm) %>
</button>
<% end %>
<%= if @cancel do %>
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
phx-click={hide_modal(@id)}
>
<%= render_slot(@cancel) %>
</button>
<% end %>
</div>
</div>
</div>
@ -158,28 +188,90 @@ defmodule LiveBeatsWeb.LiveHelpers do
end
def title_bar(assigns) do
assigns = assign_new(assigns, :action, fn -> [] end)
assigns = assign_new(assigns, :actions, fn -> [] end)
~H"""
<!-- Page title & actions -->
<div class="border-b border-gray-200 px-4 py-4 sm:flex sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div class="border-b border-gray-200 px-4 py-4 sm:flex sm:items-center sm:justify-between sm:px-6 lg:px-8 h-16">
<div class="flex-1 min-w-0">
<h1 class="text-lg font-medium leading-6 text-gray-900 sm:truncate">
<%= render_slot(@inner_block) %>
</h1>
</div>
<div class="mt-4 flex sm:mt-0 sm:ml-4">
<%= for action <- @action, rest = assigns_to_attributes(action) do %>
<%= if action[:primary] do %>
<button type="button" class="order-0 inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 sm:order-1 sm:ml-3" {rest}>
<%= render_slot(action) %>
</button>
<% else %>
<button type="button" class="order-1 inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 sm:order-0 sm:ml-0 lg:ml-3" {rest}>
<%= render_slot(action) %>
</button>
<% end %>
<% end %>
<%= render_slot(@actions) %>
</div>
</div>
"""
end
def button(%{patch_to: _} = assigns) do
assigns = assign_new(assigns, :primary, fn -> false end)
~H"""
<%= if @primary do %>
<%= live_patch to: @patch_to, class: "order-0 inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 sm:order-1 sm:ml-3" do %>
<%= render_slot(@inner_block) %>
<% end %>
<% else %>
<%= live_patch to: @patch_to, class: "order-1 inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 sm:order-0 sm:ml-0 lg:ml-3" do %>
<%= render_slot(@inner_block) %>
<% end %>
<% end %>
"""
end
def button(%{} = assigns) do
assigns =
assigns
|> assign_new(:primary, fn -> false end)
|> assign(:rest, assigns_to_attributes(assigns))
~H"""
<%= if @primary do %>
<button type="button" class="order-0 inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 sm:order-1 sm:ml-3" {@rest}>
<%= render_slot(@inner_block) %>
</button>
<% else %>
<button type="button" class="order-1 inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500 sm:order-0 sm:ml-0 lg:ml-3" {@rest}>
<%= render_slot(@inner_block) %>
</button>
<% end %>
"""
end
def table(assigns) do
~H"""
<div class="hidden mt-8 sm:block">
<div class="align-middle inline-block min-w-full border-b border-gray-200">
<table class="min-w-full">
<thead>
<tr class="border-t border-gray-200">
<%= for col <- @col do %>
<th
class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<span class="lg:pl-2"><%= col.label %></span>
</th>
<% end %>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<%= for row <- @rows do %>
<tr class="hover:bg-gray-50">
<%= for {col, i} <- Enum.with_index(@col) do %>
<td class={"px-6 py-3 whitespace-nowrap text-sm font-medium text-gray-900 #{if i == 0, do: "max-w-0 w-full"}"}>
<div class="flex items-center space-x-3 lg:pl-2">
<%= if i == 0 do %>
<div class="flex-shrink-0 w-2.5 h-2.5 rounded-full bg-pink-600 mr-2" aria-hidden="true"></div>
<% end %>
<%= render_slot(col, row) %>
</div>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
"""

View file

@ -0,0 +1,28 @@
defmodule LiveBeatsWeb.ModalComponent do
use LiveBeatsWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div
id={@id}
class="phx-modal"
phx-capture-click="close"
phx-window-keydown="close"
phx-key="escape"
phx-target={@myself}
phx-page-loading>
<div class="phx-modal-content">
<%= live_patch raw("&times;"), to: @return_to, class: "phx-modal-close" %>
<%= live_component @component, @opts %>
</div>
</div>
"""
end
@impl true
def handle_event("close", _, socket) do
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
end

View file

@ -0,0 +1,8 @@
defmodule LiveBeatsWeb.Nav do
import Phoenix.LiveView
import Phoenix.LiveView.Helpers
def on_mount(:default, _params, session, socket) do
{:cont, assign(socket, genres: LiveBeats.MediaLibrary.list_genres())}
end
end

View file

@ -1,7 +1,7 @@
defmodule LiveBeatsWeb.PlayerLive do
use LiveBeatsWeb, :live_view
on_mount LiveBeatsWeb.UserAuth
on_mount {LiveBeatsWeb.UserAuth, :current_user}
def render(assigns) do
~H"""
@ -76,7 +76,7 @@ defmodule LiveBeatsWeb.PlayerLive do
end
def mount(_parmas, _session, socket) do
if connected?(socket), do: Process.send_after(self(), :tick, 1000)
# if connected?(socket), do: Process.send_after(self(), :tick, 1000)
{:ok, assign(socket, time: inspect(System.system_time()), count: 0), layout: false}
end

View file

@ -1,4 +1,4 @@
defmodule LiveBeatsWeb.SigninLive do
defmodule LiveBeatsWeb.SignInLive do
use LiveBeatsWeb, :live_view
def render(assigns) do

View file

@ -0,0 +1,55 @@
defmodule LiveBeatsWeb.SongLive.FormComponent do
use LiveBeatsWeb, :live_component
alias LiveBeats.MediaLibrary
@impl true
def update(%{song: song} = assigns, socket) do
changeset = MediaLibrary.change_song(song)
{:ok,
socket
|> assign(assigns)
|> assign(:changeset, changeset)}
end
@impl true
def handle_event("validate", %{"song" => song_params}, socket) do
changeset =
socket.assigns.song
|> MediaLibrary.change_song(song_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
def handle_event("save", %{"song" => song_params}, socket) do
save_song(socket, socket.assigns.action, song_params)
end
defp save_song(socket, :edit, song_params) do
case MediaLibrary.update_song(socket.assigns.song, song_params) do
{:ok, _song} ->
{:noreply,
socket
|> put_flash(:info, "Song updated successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
defp save_song(socket, :new, song_params) do
case MediaLibrary.create_song(song_params) do
{:ok, _song} ->
{:noreply,
socket
|> put_flash(:info, "Song created successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end

View file

@ -0,0 +1,40 @@
<div>
<h2><%= @title %></h2>
<.form
let={f}
for={@changeset}
id="song-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save">
<%= label f, :album_artist %>
<%= text_input f, :album_artist %>
<%= error_tag f, :album_artist %>
<%= label f, :artist %>
<%= text_input f, :artist %>
<%= error_tag f, :artist %>
<%= label f, :duration %>
<%= number_input f, :duration %>
<%= error_tag f, :duration %>
<%= label f, :title %>
<%= text_input f, :title %>
<%= error_tag f, :title %>
<%= label f, :date_recorded %>
<%= datetime_select f, :date_recorded %>
<%= error_tag f, :date_recorded %>
<%= label f, :date_released %>
<%= datetime_select f, :date_released %>
<%= error_tag f, :date_released %>
<div>
<%= submit "Save", phx_disable_with: "Saving..." %>
</div>
</.form>
</div>

View file

@ -0,0 +1,79 @@
defmodule LiveBeatsWeb.SongLive.Index do
use LiveBeatsWeb, :live_view
alias LiveBeats.MediaLibrary
alias LiveBeats.MediaLibrary.Song
def render(assigns) do
~H"""
<.title_bar>
Listing Songs
<:actions>
<.button primary patch_to={Routes.song_index_path(@socket, :new)}>New Song</.button>
</:actions>
</.title_bar>
<%= if @live_action in [:new, :edit] do %>
<.modal show id="add-songs" return_to={Routes.song_index_path(@socket, :index)}>
<.live_component
module={LiveBeatsWeb.SongLive.FormComponent}
title={@page_title}
id={@song.id || :new}
action={@live_action}
return_to={Routes.song_index_path(@socket, :index)}
song={@song}
/>
</.modal>
<% end %>
<.table rows={@songs}>
<:col let={song} label="Title"><%= song.title %></:col>
<:col let={song} label="Artist"><%= song.artist %></:col>
<:col let={song} label="Duration"><%= song.duration %></:col>
<:col let={song} label="">
<.link redirect_to={Routes.song_show_path(@socket, :show, song)}>Show</.link>
<.link patch_to={Routes.song_index_path(@socket, :edit, song)}>Edit</.link>
<.link phx-click={JS.push("delete", value: %{id: song.id})} data-confirm="Are you sure?">Delete</.link>
</:col>
</.table>
"""
end
def mount(_params, _session, socket) do
{:ok, assign(socket, :songs, list_songs())}
end
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Song")
|> assign(:song, MediaLibrary.get_song!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Song")
|> assign(:song, %Song{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Songs")
|> assign(:song, nil)
end
def handle_event("delete", %{"id" => id}, socket) do
song = MediaLibrary.get_song!(id)
{:ok, _} = MediaLibrary.delete_song(song)
{:noreply, assign(socket, :songs, list_songs())}
end
defp list_songs do
MediaLibrary.list_songs()
end
end

View file

@ -0,0 +1,21 @@
defmodule LiveBeatsWeb.SongLive.Show do
use LiveBeatsWeb, :live_view
alias LiveBeats.MediaLibrary
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:song, MediaLibrary.get_song!(id))}
end
defp page_title(:show), do: "Show Song"
defp page_title(:edit), do: "Edit Song"
end

View file

@ -0,0 +1,47 @@
<h1>Show Song</h1>
<%= if @live_action in [:edit] do %>
<%#= live_modal LiveBeatsWeb.SongLive.FormComponent,
id: @song.id,
title: @page_title,
action: @live_action,
song: @song,
return_to: Routes.song_show_path(@socket, :show, @song) %>
<% end %>
<ul>
<li>
<strong>Album artist:</strong>
<%= @song.album_artist %>
</li>
<li>
<strong>Artist:</strong>
<%= @song.artist %>
</li>
<li>
<strong>Duration:</strong>
<%= @song.duration %>
</li>
<li>
<strong>Title:</strong>
<%= @song.title %>
</li>
<li>
<strong>Date recorded:</strong>
<%= @song.date_recorded %>
</li>
<li>
<strong>Date released:</strong>
<%= @song.date_released %>
</li>
</ul>
<span><%= live_patch "Edit", to: Routes.song_show_path(@socket, :edit, @song), class: "button" %></span> |
<span><%= live_redirect "Back", to: Routes.song_index_path(@socket, :index) %></span>

View file

@ -20,10 +20,19 @@ defmodule LiveBeatsWeb.Router do
scope "/", LiveBeatsWeb do
pipe_through :browser
live_session :default, on_mount: LiveBeatsWeb.UserAuth do
delete "/signout", OAuthCallbackController, :sign_out
live_session :default, on_mount: [{LiveBeatsWeb.UserAuth, :current_user}, LiveBeatsWeb.Nav] do
live "/signin", SignInLive, :index
end
live_session :authenticated, on_mount: [{LiveBeatsWeb.UserAuth, :ensure_authenticated}, LiveBeatsWeb.Nav] do
live "/", HomeLive, :index
live "/signin", SigninLive, :index
delete "/signout", OAuthCallbackController, :sign_out
live "/songs", SongLive.Index, :index
live "/songs/new", SongLive.Index, :new
live "/songs/:id/edit", SongLive.Index, :edit
live "/songs/:id", SongLive.Show, :show
live "/songs/:id/show/edit", SongLive.Show, :edit
end
end

View file

@ -104,7 +104,7 @@
</div>
<% else %>
<%= live_redirect to: Routes.signin_path(@conn, :index),
<%= live_redirect to: Routes.sign_in_path(@conn, :index),
class: "text-gray-600 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md" do %>
<svg class="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6"
@ -123,32 +123,16 @@
Rooms
</h3>
<div class="mt-1 space-y-1" role="group" aria-labelledby="mobile-teams-headline">
<a href="#"
class="group flex items-center px-3 py-2 text-base leading-5 font-medium text-gray-600 rounded-md hover:text-gray-900 hover:bg-gray-50">
<span class="w-2.5 h-2.5 mr-4 bg-indigo-500 rounded-full" aria-hidden="true"></span>
<span class="truncate">
Chill
</span>
</a>
<a href="#"
class="group flex items-center px-3 py-2 text-base leading-5 font-medium text-gray-600 rounded-md hover:text-gray-900 hover:bg-gray-50">
<span class="w-2.5 h-2.5 mr-4 bg-green-500 rounded-full" aria-hidden="true"></span>
<span class="truncate">
Pop
</span>
</a>
<a href="#"
class="group flex items-center px-3 py-2 text-base leading-5 font-medium text-gray-600 rounded-md hover:text-gray-900 hover:bg-gray-50">
<span class="w-2.5 h-2.5 mr-4 bg-yellow-500 rounded-full" aria-hidden="true"></span>
<span class="truncate">
Techo
</span>
</a>
</div>
<%= for genre <- @genres do %>
<a href="#"
class="group flex items-center px-3 py-2 text-base leading-5 font-medium text-gray-600 rounded-md hover:text-gray-900 hover:bg-gray-50">
<span class="w-2.5 h-2.5 mr-4 bg-indigo-500 rounded-full" aria-hidden="true"></span>
<span class="truncate">
<%= genre.title %>
</span>
</a>
<% end %>
</div>
</div>
</nav>
</div>
@ -246,8 +230,8 @@
Home
<% end %>
<a href="#"
class="text-gray-700 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-sm font-medium rounded-md">
<%= live_redirect to: Routes.song_index_path(@conn, :index),
class: "text-gray-700 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-sm font-medium rounded-md" do %>
<svg class="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6"
xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
@ -255,10 +239,10 @@
d="M4 6h16M4 10h16M4 14h16M4 18h16"></path>
</svg>
My Songs
</a>
<% end %>
<%= unless @current_user do %>
<%= live_redirect to: Routes.signin_path(@conn, :index),
<%= live_redirect to: Routes.sign_in_path(@conn, :index),
class: "text-gray-700 hover:text-gray-900 hover:bg-gray-50 group flex items-center px-2 py-2 text-sm font-medium rounded-md" do %>
<svg class="text-gray-400 group-hover:text-gray-500 mr-3 flex-shrink-0 h-6 w-6"
xmlns="http://www.w3.org/2000/svg" fill="none"
@ -277,31 +261,15 @@
Rooms
</h3>
<div class="mt-1 space-y-1" role="group" aria-labelledby="desktop-teams-headline">
<a href="#"
class="group flex items-center px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:text-gray-900 hover:bg-gray-50">
<span class="w-2.5 h-2.5 mr-4 bg-indigo-500 rounded-full" aria-hidden="true"></span>
<span class="truncate">
Chill
</span>
</a>
<a href="#"
class="group flex items-center px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:text-gray-900 hover:bg-gray-50">
<span class="w-2.5 h-2.5 mr-4 bg-green-500 rounded-full" aria-hidden="true"></span>
<span class="truncate">
Pop
</span>
</a>
<a href="#"
class="group flex items-center px-3 py-2 text-sm font-medium text-gray-700 rounded-md hover:text-gray-900 hover:bg-gray-50">
<span class="w-2.5 h-2.5 mr-4 bg-yellow-500 rounded-full" aria-hidden="true"></span>
<span class="truncate">
Techo
</span>
</a>
<%= for genre <- @genres do %>
<a href="#"
class="group flex items-center px-3 py-2 text-base leading-5 font-medium text-gray-600 rounded-md hover:text-gray-900 hover:bg-gray-50">
<span class="w-2.5 h-2.5 mr-4 bg-indigo-500 rounded-full" aria-hidden="true"></span>
<span class="truncate">
<%= genre.title %>
</span>
</a>
<% end %>
</div>
</div>
</nav>

View file

@ -4,6 +4,10 @@ defmodule LiveBeats.Repo.Migrations.CreateGenres do
def change do
create table(:genres) do
add :title, :text, null: false
add :slug, :text, null: false
end
create unique_index(:genres, [:title])
create unique_index(:genres, [:slug])
end
end

View file

@ -0,0 +1,22 @@
defmodule LiveBeats.Repo.Migrations.CreateSongs do
use Ecto.Migration
def change do
create table(:songs) do
add :album_artist, :string
add :artist, :string
add :duration, :integer
add :title, :string
add :date_recorded, :naive_datetime
add :date_released, :naive_datetime
add :user_id, references(:users, on_delete: :nothing)
add :genre_id, references(:genres, on_delete: :nothing)
timestamps()
end
create unique_index(:songs, [:user_id, :title, :artist])
create index(:songs, [:user_id])
create index(:songs, [:genre_id])
end
end

View file

@ -9,3 +9,16 @@
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
for title <- ~w(Chill Pop Hip-hop Electronic) do
{:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title})
end
for i <- 1..20 do
{:ok, _} =
LiveBeats.MediaLibrary.create_song(%{
artist: "Bonobo",
title: "Black Sands #{i}",
duration: 180_000
})
end

View file

@ -0,0 +1,69 @@
defmodule LiveBeats.MediaLibraryTest do
use LiveBeats.DataCase
alias LiveBeats.MediaLibrary
describe "songs" do
alias LiveBeats.MediaLibrary.Song
import LiveBeats.MediaLibraryFixtures
@invalid_attrs %{album_artist: nil, artist: nil, date_recorded: nil, date_released: nil, duration: nil, title: nil}
test "list_songs/0 returns all songs" do
song = song_fixture()
assert MediaLibrary.list_songs() == [song]
end
test "get_song!/1 returns the song with given id" do
song = song_fixture()
assert MediaLibrary.get_song!(song.id) == song
end
test "create_song/1 with valid data creates a song" do
valid_attrs = %{album_artist: "some album_artist", artist: "some artist", date_recorded: ~N[2021-10-26 20:11:00], date_released: ~N[2021-10-26 20:11:00], duration: 42, title: "some title"}
assert {:ok, %Song{} = song} = MediaLibrary.create_song(valid_attrs)
assert song.album_artist == "some album_artist"
assert song.artist == "some artist"
assert song.date_recorded == ~N[2021-10-26 20:11:00]
assert song.date_released == ~N[2021-10-26 20:11:00]
assert song.duration == 42
assert song.title == "some title"
end
test "create_song/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = MediaLibrary.create_song(@invalid_attrs)
end
test "update_song/2 with valid data updates the song" do
song = song_fixture()
update_attrs = %{album_artist: "some updated album_artist", artist: "some updated artist", date_recorded: ~N[2021-10-27 20:11:00], date_released: ~N[2021-10-27 20:11:00], duration: 43, title: "some updated title"}
assert {:ok, %Song{} = song} = MediaLibrary.update_song(song, update_attrs)
assert song.album_artist == "some updated album_artist"
assert song.artist == "some updated artist"
assert song.date_recorded == ~N[2021-10-27 20:11:00]
assert song.date_released == ~N[2021-10-27 20:11:00]
assert song.duration == 43
assert song.title == "some updated title"
end
test "update_song/2 with invalid data returns error changeset" do
song = song_fixture()
assert {:error, %Ecto.Changeset{}} = MediaLibrary.update_song(song, @invalid_attrs)
assert song == MediaLibrary.get_song!(song.id)
end
test "delete_song/1 deletes the song" do
song = song_fixture()
assert {:ok, %Song{}} = MediaLibrary.delete_song(song)
assert_raise Ecto.NoResultsError, fn -> MediaLibrary.get_song!(song.id) end
end
test "change_song/1 returns a song changeset" do
song = song_fixture()
assert %Ecto.Changeset{} = MediaLibrary.change_song(song)
end
end
end

View file

@ -0,0 +1,110 @@
defmodule LiveBeatsWeb.SongLiveTest do
use LiveBeatsWeb.ConnCase
import Phoenix.LiveViewTest
import LiveBeats.MediaLibraryFixtures
@create_attrs %{album_artist: "some album_artist", artist: "some artist", date_recorded: %{day: 26, hour: 20, minute: 11, month: 10, year: 2021}, date_released: %{day: 26, hour: 20, minute: 11, month: 10, year: 2021}, duration: 42, title: "some title"}
@update_attrs %{album_artist: "some updated album_artist", artist: "some updated artist", date_recorded: %{day: 27, hour: 20, minute: 11, month: 10, year: 2021}, date_released: %{day: 27, hour: 20, minute: 11, month: 10, year: 2021}, duration: 43, title: "some updated title"}
@invalid_attrs %{album_artist: nil, artist: nil, date_recorded: %{day: 30, hour: 20, minute: 11, month: 2, year: 2021}, date_released: %{day: 30, hour: 20, minute: 11, month: 2, year: 2021}, duration: nil, title: nil}
defp create_song(_) do
song = song_fixture()
%{song: song}
end
describe "Index" do
setup [:create_song]
test "lists all songs", %{conn: conn, song: song} do
{:ok, _index_live, html} = live(conn, Routes.song_index_path(conn, :index))
assert html =~ "Listing Songs"
assert html =~ song.album_artist
end
test "saves new song", %{conn: conn} do
{:ok, index_live, _html} = live(conn, Routes.song_index_path(conn, :index))
assert index_live |> element("a", "New Song") |> render_click() =~
"New Song"
assert_patch(index_live, Routes.song_index_path(conn, :new))
assert index_live
|> form("#song-form", song: @invalid_attrs)
|> render_change() =~ "is invalid"
{:ok, _, html} =
index_live
|> form("#song-form", song: @create_attrs)
|> render_submit()
|> follow_redirect(conn, Routes.song_index_path(conn, :index))
assert html =~ "Song created successfully"
assert html =~ "some album_artist"
end
test "updates song in listing", %{conn: conn, song: song} do
{:ok, index_live, _html} = live(conn, Routes.song_index_path(conn, :index))
assert index_live |> element("#song-#{song.id} a", "Edit") |> render_click() =~
"Edit Song"
assert_patch(index_live, Routes.song_index_path(conn, :edit, song))
assert index_live
|> form("#song-form", song: @invalid_attrs)
|> render_change() =~ "is invalid"
{:ok, _, html} =
index_live
|> form("#song-form", song: @update_attrs)
|> render_submit()
|> follow_redirect(conn, Routes.song_index_path(conn, :index))
assert html =~ "Song updated successfully"
assert html =~ "some updated album_artist"
end
test "deletes song in listing", %{conn: conn, song: song} do
{:ok, index_live, _html} = live(conn, Routes.song_index_path(conn, :index))
assert index_live |> element("#song-#{song.id} a", "Delete") |> render_click()
refute has_element?(index_live, "#song-#{song.id}")
end
end
describe "Show" do
setup [:create_song]
test "displays song", %{conn: conn, song: song} do
{:ok, _show_live, html} = live(conn, Routes.song_show_path(conn, :show, song))
assert html =~ "Show Song"
assert html =~ song.album_artist
end
test "updates song within modal", %{conn: conn, song: song} do
{:ok, show_live, _html} = live(conn, Routes.song_show_path(conn, :show, song))
assert show_live |> element("a", "Edit") |> render_click() =~
"Edit Song"
assert_patch(show_live, Routes.song_show_path(conn, :edit, song))
assert show_live
|> form("#song-form", song: @invalid_attrs)
|> render_change() =~ "is invalid"
{:ok, _, html} =
show_live
|> form("#song-form", song: @update_attrs)
|> render_submit()
|> follow_redirect(conn, Routes.song_show_path(conn, :show, song))
assert html =~ "Song updated successfully"
assert html =~ "some updated album_artist"
end
end
end

View file

@ -0,0 +1,25 @@
defmodule LiveBeats.MediaLibraryFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `LiveBeats.MediaLibrary` context.
"""
@doc """
Generate a song.
"""
def song_fixture(attrs \\ %{}) do
{:ok, song} =
attrs
|> Enum.into(%{
album_artist: "some album_artist",
artist: "some artist",
date_recorded: ~N[2021-10-26 20:11:00],
date_released: ~N[2021-10-26 20:11:00],
duration: 42,
title: "some title"
})
|> LiveBeats.MediaLibrary.create_song()
song
end
end