mirror of
https://github.com/fly-apps/live_beats.git
synced 2024-11-22 08:01:00 +00:00
Initial file uploads with copying
This commit is contained in:
parent
5a19383137
commit
50ecdb8ced
12 changed files with 314 additions and 27 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -29,3 +29,4 @@ live_beats-*.tar
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
/assets/node_modules/
|
/assets/node_modules/
|
||||||
|
|
||||||
|
/priv/static/uploads
|
||||||
|
|
|
@ -8,6 +8,46 @@ defmodule LiveBeats.MediaLibrary do
|
||||||
|
|
||||||
alias LiveBeats.MediaLibrary.{Song, Genre}
|
alias LiveBeats.MediaLibrary.{Song, Genre}
|
||||||
|
|
||||||
|
def store_mp3(%Song{} = song, tmp_path) do
|
||||||
|
File.mkdir_p!("priv/static/uploads/songs")
|
||||||
|
File.cp!(tmp_path, song.mp3_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_songs(changesets, consome_file)
|
||||||
|
when is_map(changesets) and is_function(consome_file, 2) do
|
||||||
|
changesets
|
||||||
|
|> Enum.reduce(Ecto.Multi.new(), fn {ref, chset}, acc ->
|
||||||
|
chset =
|
||||||
|
chset
|
||||||
|
|> Song.put_mp3_path()
|
||||||
|
|> Map.put(:action, nil)
|
||||||
|
|
||||||
|
Ecto.Multi.insert(acc, {:song, ref}, chset)
|
||||||
|
end)
|
||||||
|
|> LiveBeats.Repo.transaction()
|
||||||
|
|> case do
|
||||||
|
{:ok, results} ->
|
||||||
|
{:ok,
|
||||||
|
results
|
||||||
|
|> Enum.filter(&match?({{:song, _ref}, _}, &1))
|
||||||
|
|> Enum.map(fn {{:song, ref}, song} ->
|
||||||
|
consome_file.(ref, fn tmp_path -> store_mp3(song, tmp_path) end)
|
||||||
|
{ref, song}
|
||||||
|
end)
|
||||||
|
|> Enum.into(%{})}
|
||||||
|
|
||||||
|
{:error, _failed_op, _failed_val, _changes} ->
|
||||||
|
{:error, :invalid}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_file_name(name) do
|
||||||
|
case Regex.split(~r/[-–]/, Path.rootname(name), parts: 2) do
|
||||||
|
[title] -> %{title: String.trim(title), artist: nil}
|
||||||
|
[title, artist] -> %{title: String.trim(title), artist: String.trim(artist)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def create_genre(attrs \\ %{}) do
|
def create_genre(attrs \\ %{}) do
|
||||||
%Genre{}
|
%Genre{}
|
||||||
|> Genre.changeset(attrs)
|
|> Genre.changeset(attrs)
|
||||||
|
|
|
@ -9,6 +9,8 @@ defmodule LiveBeats.MediaLibrary.Song do
|
||||||
field :date_released, :naive_datetime
|
field :date_released, :naive_datetime
|
||||||
field :duration, :integer
|
field :duration, :integer
|
||||||
field :title, :string
|
field :title, :string
|
||||||
|
field :mp3_path, :string
|
||||||
|
field :mp3_filename, :string
|
||||||
belongs_to :user, LiveBeats.Accounts.User
|
belongs_to :user, LiveBeats.Accounts.User
|
||||||
belongs_to :genre, LiveBeats.MediaLibrary.Genre
|
belongs_to :genre, LiveBeats.MediaLibrary.Genre
|
||||||
|
|
||||||
|
@ -18,7 +20,19 @@ defmodule LiveBeats.MediaLibrary.Song do
|
||||||
@doc false
|
@doc false
|
||||||
def changeset(song, attrs) do
|
def changeset(song, attrs) do
|
||||||
song
|
song
|
||||||
|> cast(attrs, [:album_artist, :artist, :duration, :title, :date_recorded, :date_released])
|
|> cast(attrs, [:album_artist, :artist, :title, :date_recorded, :date_released])
|
||||||
|> validate_required([:artist, :duration, :title])
|
|> validate_required([:artist, :title])
|
||||||
|
end
|
||||||
|
|
||||||
|
def put_mp3_path(%Ecto.Changeset{} = changeset) do
|
||||||
|
if changeset.valid? do
|
||||||
|
filename = Ecto.UUID.generate() <> ".mp3"
|
||||||
|
|
||||||
|
changeset
|
||||||
|
|> Ecto.Changeset.put_change(:mp3_filename, filename)
|
||||||
|
|> Ecto.Changeset.put_change(:mp3_path, "priv/uploads/songs/#{filename}")
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -120,7 +120,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
|
||||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||||
<div
|
<div
|
||||||
id={"#{@id}-content"}
|
id={"#{@id}-content"}
|
||||||
class={"#{if @show, do: "fade-in-scale", else: "hidden"} sticky 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-xl sm:w-full sm:p-6"}
|
class={"#{if @show, do: "fade-in-scale", else: "hidden"} sticky 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 lg:ml-48 sm:max-w-2xl sm:w-full sm:p-6"}
|
||||||
phx-window-keydown={hide_modal(@id)} phx-key="escape"
|
phx-window-keydown={hide_modal(@id)} phx-key="escape"
|
||||||
phx-click-away={hide_modal(@id)}
|
phx-click-away={hide_modal(@id)}
|
||||||
>
|
>
|
||||||
|
|
|
@ -17,7 +17,7 @@ defmodule LiveBeatsWeb.SongLive.Index do
|
||||||
<%= if @live_action in [:new, :edit] do %>
|
<%= if @live_action in [:new, :edit] do %>
|
||||||
<.modal show id="add-songs" return_to={Routes.song_index_path(@socket, :index)}>
|
<.modal show id="add-songs" return_to={Routes.song_index_path(@socket, :index)}>
|
||||||
<.live_component
|
<.live_component
|
||||||
module={LiveBeatsWeb.SongLive.FormComponent}
|
module={LiveBeatsWeb.SongLive.UploadFormComponent}
|
||||||
title={@page_title}
|
title={@page_title}
|
||||||
id={@song.id || :new}
|
id={@song.id || :new}
|
||||||
action={@live_action}
|
action={@live_action}
|
||||||
|
|
38
lib/live_beats_web/live/song_live/song_entry_component.ex
Normal file
38
lib/live_beats_web/live/song_live/song_entry_component.ex
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
defmodule LiveBeatsWeb.SongLive.SongEntryComponent do
|
||||||
|
use LiveBeatsWeb, :live_component
|
||||||
|
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start sm:border-t sm:border-gray-200 sm:pt-2">
|
||||||
|
<div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600">
|
||||||
|
<label for="name" class="block text-xs font-medium text-gray-900">Title</label>
|
||||||
|
<input type="text" name={"songs[#{@ref}][title]"} value={@title}
|
||||||
|
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/>
|
||||||
|
<%= error_tag(@errors, :title, "songs[#{@ref}][title]") %>
|
||||||
|
</div>
|
||||||
|
<div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600">
|
||||||
|
<label for="name" class="block text-xs font-medium text-gray-900">Artist</label>
|
||||||
|
<input type="text" name={"songs[#{@ref}][artist]"} value={@artist}
|
||||||
|
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/>
|
||||||
|
<%= error_tag(@errors, :artist, "songs[#{@ref}][artist]") %>
|
||||||
|
</div>
|
||||||
|
<div style={"width: #{@progress}%;"} class="col-span-full bg-purple-500 dark:bg-purple-400 h-1.5 w-0 p-0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def update(%{progress: progress}, socket) do
|
||||||
|
{:ok, assign(socket, progress: progress)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update(%{changeset: changeset, id: id}, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(ref: id)
|
||||||
|
|> assign(:errors, changeset.errors)
|
||||||
|
|> assign(title: Ecto.Changeset.get_field(changeset, :title))
|
||||||
|
|> assign(artist: Ecto.Changeset.get_field(changeset, :artist))
|
||||||
|
|> assign_new(:progress, fn -> 0 end)}
|
||||||
|
end
|
||||||
|
end
|
107
lib/live_beats_web/live/song_live/upload_form_component.ex
Normal file
107
lib/live_beats_web/live/song_live/upload_form_component.ex
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
|
||||||
|
use LiveBeatsWeb, :live_component
|
||||||
|
|
||||||
|
alias LiveBeats.{MediaLibrary, ID3}
|
||||||
|
alias LiveBeatsWeb.SongLive.SongEntryComponent
|
||||||
|
|
||||||
|
@max_songs 10
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def update(%{song: song} = assigns, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(assigns)
|
||||||
|
|> assign(changesets: %{})
|
||||||
|
|> allow_upload(:mp3,
|
||||||
|
song_id: song.id,
|
||||||
|
auto_upload: true,
|
||||||
|
progress: &handle_progress/3,
|
||||||
|
accept: ~w(.mp3),
|
||||||
|
max_entries: @max_songs,
|
||||||
|
max_file_size: 20_000_000
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"_target" => ["mp3"]}, socket) do
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("validate", %{"songs" => songs_params, "_target" => ["songs", _, _]}, socket) do
|
||||||
|
new_socket =
|
||||||
|
Enum.reduce(songs_params, socket, fn {ref, song_params}, acc ->
|
||||||
|
new_changeset =
|
||||||
|
acc
|
||||||
|
|> get_changeset(ref)
|
||||||
|
|> Ecto.Changeset.apply_changes()
|
||||||
|
|> MediaLibrary.change_song(song_params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
update_changeset(acc, new_changeset, ref)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:noreply, new_socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp consume_entry(socket, ref, store_func) when is_function(store_func) do
|
||||||
|
{entries, []} = uploaded_entries(socket, :mp3)
|
||||||
|
entry = Enum.find(entries, fn entry -> entry.ref == ref end)
|
||||||
|
consume_uploaded_entry(socket, entry, fn meta -> store_func.(meta.path) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"songs" => song_params}, socket) do
|
||||||
|
changesets = socket.assigns.changesets
|
||||||
|
|
||||||
|
case MediaLibrary.import_songs(changesets, &consume_entry(socket, &1, &2)) do
|
||||||
|
{:ok, songs} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "#{map_size(songs)} song(s) uploaded")
|
||||||
|
|> push_redirect(to: Routes.song_index_path(socket, :index))}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:noreply, put_flash(socket, :error, "There were problems uploading your songs")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_changeset(socket, entry_ref) do
|
||||||
|
case Enum.find(socket.assigns.changesets, fn {ref, _changeset} -> ref === entry_ref end) do
|
||||||
|
{^entry_ref, changeset} -> changeset
|
||||||
|
nil -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_new_changeset(socket, entry) do
|
||||||
|
if get_changeset(socket, entry.ref) do
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
if Enum.count(socket.assigns.changesets) > @max_songs do
|
||||||
|
raise RuntimeError, "file upload limited exceeded"
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs = MediaLibrary.parse_file_name(entry.client_name)
|
||||||
|
changeset = MediaLibrary.change_song(%MediaLibrary.Song{}, attrs)
|
||||||
|
|
||||||
|
update_changeset(socket, changeset, entry.ref)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp update_changeset(socket, %Ecto.Changeset{} = changeset, entry_ref) do
|
||||||
|
update(socket, :changesets, &Map.put(&1, entry_ref, changeset))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_progress(:mp3, entry, socket) do
|
||||||
|
send_update(SongEntryComponent, id: entry.ref, progress: entry.progress)
|
||||||
|
{:noreply, put_new_changeset(socket, entry)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp put_tmp_mp3(changeset, path) do
|
||||||
|
{:ok, tmp_path} = Plug.Upload.random_file("tmp_mp3")
|
||||||
|
File.cp!(path, tmp_path)
|
||||||
|
Ecto.Changeset.put_change(changeset, :tmp_mp3_path, tmp_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp file_error(%{kind: :too_large} = assigns), do: ~H|larger than 10MB|
|
||||||
|
defp file_error(%{kind: :not_accepted} = assigns), do: ~H|not a valid MP3 file|
|
||||||
|
defp file_error(%{kind: :too_many_files} = assigns), do: ~H|too many files|
|
||||||
|
end
|
|
@ -0,0 +1,81 @@
|
||||||
|
<div>
|
||||||
|
<h2><%= @title %></h2>
|
||||||
|
|
||||||
|
<.form
|
||||||
|
let={f}
|
||||||
|
for={:songs}
|
||||||
|
id="song-form"
|
||||||
|
class="space-y-8"
|
||||||
|
phx-target={@myself}
|
||||||
|
phx-change="validate"
|
||||||
|
phx-submit="save">
|
||||||
|
|
||||||
|
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
|
||||||
|
<div class="space-y-2 sm:space-y-2">
|
||||||
|
<%= for {ref, changeset} <- @changesets do %>
|
||||||
|
<.live_component id={ref} module={SongEntryComponent} changeset={changeset} />
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- upload -->
|
||||||
|
<div class="sm:grid sm:border-t sm:border-gray-200 sm:pt-5">
|
||||||
|
<div class="mt-1 sm:mt-0" phx-drop-target={@uploads.mp3.ref}>
|
||||||
|
<%= if Enum.any?(@uploads.mp3.errors) do %>
|
||||||
|
<div class="rounded-md bg-red-50 p-4 mb-2">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-red-400" x-description="Heroicon name: solid/x-circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">
|
||||||
|
Oops!
|
||||||
|
</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<ul role="list" class="list-disc pl-5 space-y-1">
|
||||||
|
<%= for {_ref, error} <- @uploads.mp3.errors do %>
|
||||||
|
<li><.file_error kind={error} /></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="max-w-lg flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
|
||||||
|
<div class="space-y-1 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
<div class="flex text-sm text-gray-600">
|
||||||
|
<label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
|
||||||
|
<span phx-click={JS.dispatch("click", to: "##{@uploads.mp3.ref}")}>Upload files</span>
|
||||||
|
<%= live_file_input @uploads.mp3, class: "sr-only" %>
|
||||||
|
</label>
|
||||||
|
<p class="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
MP3s up to 20MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- /upload -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-5">
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="button" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</div>
|
|
@ -65,7 +65,6 @@
|
||||||
<div>
|
<div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500"
|
class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500"
|
||||||
id="options-menu-button"
|
|
||||||
phx-click={show_dropdown("#account-dropdown-mobile")}>
|
phx-click={show_dropdown("#account-dropdown-mobile")}>
|
||||||
<span class="flex w-full justify-between items-center">
|
<span class="flex w-full justify-between items-center">
|
||||||
<span class="flex min-w-0 items-center justify-between space-x-3">
|
<span class="flex min-w-0 items-center justify-between space-x-3">
|
||||||
|
@ -89,16 +88,16 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="account-dropdown-mobile" phx-click-away={hide_dropdown("#account-dropdown-mobile")} class="hidden z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none" role="menu">
|
<div id="account-dropdown-mobile" phx-click-away={hide_dropdown("#account-dropdown-mobile")} class="hidden z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200 focus:outline-none" role="menu">
|
||||||
<div class="py-1" role="none">
|
<div class="py-1" role="none">
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-0">View profile</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">View profile</a>
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-1">Settings</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Settings</a>
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-2">Notifications</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Notifications</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-1" role="none">
|
<div class="py-1" role="none">
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-3">Get desktop app</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Get desktop app</a>
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-4">Support</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Support</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-1" role="none">
|
<div class="py-1" role="none">
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-5">Logout</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -159,7 +158,6 @@
|
||||||
<div>
|
<div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500"
|
class="group w-full bg-gray-100 rounded-md px-3.5 py-2 text-sm text-left font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-purple-500"
|
||||||
id="options-menu-button"
|
|
||||||
phx-click={show_dropdown("#account-dropdown")}>
|
phx-click={show_dropdown("#account-dropdown")}>
|
||||||
<span class="flex w-full justify-between items-center">
|
<span class="flex w-full justify-between items-center">
|
||||||
<span class="flex min-w-0 items-center justify-between space-x-3">
|
<span class="flex min-w-0 items-center justify-between space-x-3">
|
||||||
|
@ -183,13 +181,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="account-dropdown" phx-click-away={hide_dropdown("#account-dropdown")} class="hidden z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200" role="menu" aria-expanded="true">
|
<div id="account-dropdown" phx-click-away={hide_dropdown("#account-dropdown")} class="hidden z-10 mx-3 origin-top absolute right-0 left-0 mt-1 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-200" role="menu" aria-expanded="true">
|
||||||
<div class="py-1" role="none">
|
<div class="py-1" role="none">
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-0">View profile</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">View profile</a>
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-1">Settings</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Settings</a>
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-2">Notifications</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Notifications</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-1" role="none">
|
<div class="py-1" role="none">
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-3">Get desktop app</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Get desktop app</a>
|
||||||
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem" id="options-menu-item-4">Support</a>
|
<a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" role="menuitem">Support</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-1" role="none">
|
<div class="py-1" role="none">
|
||||||
<%= link "Logout", to: Routes.o_auth_callback_path(@conn, :sign_out), method: :delete, role: "menuitem", id: "options-menu-item-5", class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
|
<%= link "Logout", to: Routes.o_auth_callback_path(@conn, :sign_out), method: :delete, role: "menuitem", id: "options-menu-item-5", class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
|
||||||
|
|
|
@ -9,14 +9,20 @@ defmodule LiveBeatsWeb.ErrorHelpers do
|
||||||
Generates tag for inlined form input errors.
|
Generates tag for inlined form input errors.
|
||||||
"""
|
"""
|
||||||
def error_tag(form, field) do
|
def error_tag(form, field) do
|
||||||
Enum.map(Keyword.get_values(form.errors, field), fn error ->
|
error_tag(form.errors, field, input_name(form, field))
|
||||||
|
end
|
||||||
|
|
||||||
|
def error_tag(errors, field, input_name) do
|
||||||
|
Enum.map(Keyword.get_values(errors, field), fn error ->
|
||||||
content_tag(:div, translate_error(error),
|
content_tag(:div, translate_error(error),
|
||||||
class: "invalid-feedback mt-0 text-sm text-red-600 text-right",
|
class: "invalid-feedback mt-0 text-sm text-red-600 text-right",
|
||||||
phx_feedback_for: input_name(form, field)
|
phx_feedback_for: input_name
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Translates an error message using gettext.
|
Translates an error message using gettext.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -7,6 +7,8 @@ defmodule LiveBeats.Repo.Migrations.CreateSongs do
|
||||||
add :artist, :string
|
add :artist, :string
|
||||||
add :duration, :integer
|
add :duration, :integer
|
||||||
add :title, :string
|
add :title, :string
|
||||||
|
add :mp3_path, :string
|
||||||
|
add :mp3_filename, :string
|
||||||
add :date_recorded, :naive_datetime
|
add :date_recorded, :naive_datetime
|
||||||
add :date_released, :naive_datetime
|
add :date_released, :naive_datetime
|
||||||
add :user_id, references(:users, on_delete: :nothing)
|
add :user_id, references(:users, on_delete: :nothing)
|
||||||
|
|
|
@ -14,11 +14,11 @@ for title <- ~w(Chill Pop Hip-hop Electronic) do
|
||||||
{:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title})
|
{:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title})
|
||||||
end
|
end
|
||||||
|
|
||||||
for i <- 1..20 do
|
# for i <- 1..20 do
|
||||||
{:ok, _} =
|
# {:ok, _} =
|
||||||
LiveBeats.MediaLibrary.create_song(%{
|
# LiveBeats.MediaLibrary.create_song(%{
|
||||||
artist: "Bonobo",
|
# artist: "Bonobo",
|
||||||
title: "Black Sands #{i}",
|
# title: "Black Sands #{i}",
|
||||||
duration: 180_000
|
# duration: 180_000
|
||||||
})
|
# })
|
||||||
end
|
# end
|
||||||
|
|
Loading…
Reference in a new issue