Improve relationship UI handling (#42)

* Added tabbed view; improved relationships for media profile

* Adds new maybe_limit method for queries

* Improves UI for media items, associated tasks
This commit is contained in:
Kieran 2024-02-29 18:35:51 -08:00 committed by GitHub
parent 7809a25f2d
commit be8bcf0eb2
16 changed files with 292 additions and 127 deletions

View file

@ -41,12 +41,14 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...]. Returns [%MediaItem{}, ...].
""" """
def list_pending_media_items_for(%Source{} = source) do def list_pending_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil)
media_profile = Repo.preload(source, :media_profile).media_profile media_profile = Repo.preload(source, :media_profile).media_profile
MediaItem MediaItem
|> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath)) |> where([mi], mi.source_id == ^source.id and is_nil(mi.media_filepath))
|> where(^build_format_clauses(media_profile)) |> where(^build_format_clauses(media_profile))
|> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end
@ -55,9 +57,12 @@ defmodule Pinchflat.Media do
Returns [%MediaItem{}, ...]. Returns [%MediaItem{}, ...].
""" """
def list_downloaded_media_items_for(%Source{} = source) do def list_downloaded_media_items_for(%Source{} = source, opts \\ []) do
limit = Keyword.get(opts, :limit, nil)
MediaItem MediaItem
|> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath)) |> where([mi], mi.source_id == ^source.id and not is_nil(mi.media_filepath))
|> Repo.maybe_limit(limit)
|> Repo.all() |> Repo.all()
end end

View file

@ -7,6 +7,7 @@ defmodule Pinchflat.Sources.Source do
import Ecto.Changeset import Ecto.Changeset
import Pinchflat.Utils.ChangesetUtils import Pinchflat.Utils.ChangesetUtils
alias Pinchflat.Tasks.Task
alias Pinchflat.Media.MediaItem alias Pinchflat.Media.MediaItem
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
@ -47,6 +48,7 @@ defmodule Pinchflat.Sources.Source do
belongs_to :media_profile, MediaProfile belongs_to :media_profile, MediaProfile
has_many :tasks, Task
has_many :media_items, MediaItem, foreign_key: :source_id has_many :media_items, MediaItem, foreign_key: :source_id
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)

View file

@ -3,6 +3,8 @@ defmodule Pinchflat.Repo do
otp_app: :pinchflat, otp_app: :pinchflat,
adapter: Ecto.Adapters.SQLite3 adapter: Ecto.Adapters.SQLite3
import Ecto.Query, warn: false
@doc """ @doc """
It's not immediately obvious if an Oban job qualifies as unique, so this method It's not immediately obvious if an Oban job qualifies as unique, so this method
attempts creating a job and checks for the `conflict?` field in the returned job. attempts creating a job and checks for the `conflict?` field in the returned job.
@ -16,4 +18,13 @@ defmodule Pinchflat.Repo do
err -> err err -> err
end end
end end
@doc """
Applies a limit to a query if provided, otherwise returns the query as-is.
Returns %Ecto.Query{}.
"""
def maybe_limit(query, limit) do
if limit, do: limit(query, ^limit), else: query
end
end end

View file

@ -88,6 +88,7 @@ defmodule PinchflatWeb do
# Core UI components and translation # Core UI components and translation
import PinchflatWeb.Gettext import PinchflatWeb.Gettext
import PinchflatWeb.CoreComponents import PinchflatWeb.CoreComponents
import PinchflatWeb.CustomComponents.TabComponents
import PinchflatWeb.CustomComponents.TextComponents import PinchflatWeb.CustomComponents.TextComponents
import PinchflatWeb.CustomComponents.TableComponents import PinchflatWeb.CustomComponents.TableComponents
import PinchflatWeb.CustomComponents.ButtonComponents import PinchflatWeb.CustomComponents.ButtonComponents

View file

@ -587,6 +587,7 @@ defmodule PinchflatWeb.CoreComponents do
attrs = attrs =
Enum.filter(assigns.map, fn Enum.filter(assigns.map, fn
{_, %{__struct__: _}} -> false {_, %{__struct__: _}} -> false
{_, [%{__meta__: _} | _]} -> false
_ -> true _ -> true
end) end)

View file

@ -0,0 +1,37 @@
defmodule PinchflatWeb.CustomComponents.TabComponents do
@moduledoc false
use Phoenix.Component
@doc """
Takes a list of tabs and renders them in a tabbed layout.
"""
slot :tab, required: true do
attr :title, :string, required: true
end
def tabbed_layout(assigns) do
~H"""
<div
x-data="{ openTab: 0, activeClasses: 'text-meta-5 border-meta-5', inactiveClasses: 'border-transparent' }"
class="w-full"
>
<div class="mb-6 flex flex-wrap gap-5 border-b border-strokedark sm:gap-10">
<a
:for={{tab, idx} <- Enum.with_index(@tab)}
href="#"
@click.prevent={"openTab = #{idx}"}
x-bind:class={"openTab === #{idx} ? activeClasses : inactiveClasses"}
class="border-b-2 py-4 text-sm font-medium hover:text-meta-5 md:text-base"
>
<span class="text-xl"><%= tab.title %></span>
</a>
</div>
<div>
<div :for={{tab, idx} <- Enum.with_index(@tab)} x-show={"openTab === #{idx}"} class="font-medium leading-relaxed">
<%= render_slot(tab) %>
</div>
</div>
</div>
"""
end
end

View file

@ -23,7 +23,7 @@ defmodule PinchflatWeb.CustomComponents.TextComponents do
attr :href, :string, required: true attr :href, :string, required: true
slot :inner_block slot :inner_block
def reference_link(assigns) do def inline_link(assigns) do
~H""" ~H"""
<.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300"> <.link href={@href} target="_blank" class="text-blue-500 hover:text-blue-300">
<%= render_slot(@inner_block) %> <%= render_slot(@inner_block) %>

View file

@ -1,10 +1,14 @@
defmodule PinchflatWeb.MediaItems.MediaItemController do defmodule PinchflatWeb.MediaItems.MediaItemController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
alias Pinchflat.Repo
alias Pinchflat.Media alias Pinchflat.Media
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
media_item = Media.get_media_item!(id) media_item =
id
|> Media.get_media_item!()
|> Repo.preload([:source, tasks: [:job]])
render(conn, :show, media_item: media_item) render(conn, :show, media_item: media_item)
end end

View file

@ -8,23 +8,51 @@
</h2> </h2>
</div> </div>
</div> </div>
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1"> <div class="rounded-sm border border-stroke bg-white py-5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10 dark:text-white"> <.tabbed_layout>
<h3 class="font-bold text-xl">Attributes</h3> <:tab title="Attributes">
<.list_items_from_map map={Map.from_struct(@media_item)} /> <div class="flex flex-col gap-10 dark:text-white">
</div> <h3 class="font-bold text-xl">Attributes</h3>
<section>
<strong>Source:</strong>
<.inline_link href={~p"/sources/#{@media_item.source_id}"}>
<%= @media_item.source.friendly_name %>
</.inline_link>
</section>
<section class="flex justify-center my-10"> <.list_items_from_map map={Map.from_struct(@media_item)} />
<.link </div>
href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
method="delete" <section class="flex justify-center my-10">
data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone." <.link
> href={~p"/sources/#{@media_item.source_id}/media/#{@media_item}?delete_files=true"}
<.button color="bg-meta-1" rounding="rounded-full"> method="delete"
Delete Files data-confirm="Are you sure you want to delete this record and all associated files on disk? This cannot be undone."
</.button> >
</.link> <.button color="bg-meta-1" rounding="rounded-full">
</section> Delete Files
</.button>
</.link>
</section>
</:tab>
<:tab title="Tasks">
<%= if match?([_|_], @media_item.tasks) do %>
<.table rows={@media_item.tasks} table_class="text-black dark:text-white">
<:col :let={task} label="Worker">
<%= task.job.worker %>
</:col>
<:col :let={task} label="State">
<%= task.job.state %>
</:col>
<:col :let={task} label="Scheduled At">
<%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
</:col>
</.table>
<% else %>
<p class="text-black dark:text-white">Nothing Here!</p>
<% end %>
</:tab>
</.tabbed_layout>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
defmodule PinchflatWeb.MediaProfiles.MediaProfileController do defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
use PinchflatWeb, :controller use PinchflatWeb, :controller
alias Pinchflat.Repo
alias Pinchflat.Profiles alias Pinchflat.Profiles
alias Pinchflat.Profiles.MediaProfile alias Pinchflat.Profiles.MediaProfile
@ -39,7 +40,11 @@ defmodule PinchflatWeb.MediaProfiles.MediaProfileController do
end end
def show(conn, %{"id" => id}) do def show(conn, %{"id" => id}) do
media_profile = Profiles.get_media_profile!(id) media_profile =
id
|> Profiles.get_media_profile!()
|> Repo.preload(:sources)
render(conn, :show, media_profile: media_profile) render(conn, :show, media_profile: media_profile)
end end

View file

@ -10,9 +10,9 @@
</li> </li>
<li> <li>
<code class="text-sm">yt-dlp</code>-style <code class="text-sm">yt-dlp</code>-style
<.reference_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template"> <.inline_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template">
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" /> <.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" />
</.reference_link>: </.inline_link>:
<.inline_code>/%(channel)s/%(duration>%H-%M-%S)s-%(id)s.%(ext)s</.inline_code> <.inline_code>/%(channel)s/%(duration>%H-%M-%S)s-%(id)s.%(ext)s</.inline_code>
</li> </li>
<li> <li>
@ -50,9 +50,9 @@
<p> <p>
Any single-word <code class="text-sm">yt-dlp</code> Any single-word <code class="text-sm">yt-dlp</code>
option option
<.reference_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template"> <.inline_link href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#output-template">
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" /> <.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" />
</.reference_link> </.inline_link>
can be used with the curly braced liquid-style syntax. can be used with the curly braced liquid-style syntax.
This is just a list of the most common options as well as some custom aliases This is just a list of the most common options as well as some custom aliases
</p> </p>

View file

@ -18,31 +18,50 @@
</div> </div>
<div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1"> <div class="rounded-sm border border-stroke bg-white px-5 pb-2.5 pt-6 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5 xl:pb-1">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10 dark:text-white"> <.tabbed_layout>
<h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3> <:tab title="Attributes">
<.list_items_from_map map={Map.from_struct(@media_profile)} /> <div class="flex flex-col gap-10 text-white">
</div> <h3 class="font-bold text-xl">Attributes for "<%= @media_profile.name %>"</h3>
<.list_items_from_map map={Map.from_struct(@media_profile)} />
</div>
<section class="flex flex-col md:flex-row items-center md:justify-around my-10"> <section class="flex flex-col md:flex-row items-center md:justify-around my-10">
<.link <.link
href={~p"/media_profiles/#{@media_profile}"} href={~p"/media_profiles/#{@media_profile}"}
method="delete" method="delete"
data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone." data-confirm="Are you sure you want to delete this profile and all its sources (leaving files in place)? This cannot be undone."
> >
<.button color="bg-meta-1" rounding="rounded-full"> <.button color="bg-meta-1" rounding="rounded-full">
Delete Profile and its Sources Delete Profile and its Sources
</.button> </.button>
</.link> </.link>
<.link <.link
href={~p"/media_profiles/#{@media_profile}?delete_files=true"} href={~p"/media_profiles/#{@media_profile}?delete_files=true"}
method="delete" method="delete"
data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone." data-confirm="Are you sure you want to delete this profile, all its sources, and its files on disk? This cannot be undone."
class="mt-5 md:mt-0" class="mt-5 md:mt-0"
> >
<.button color="bg-meta-1" rounding="rounded-full"> <.button color="bg-meta-1" rounding="rounded-full">
Delete Profile, Sources, and Files Delete Profile, Sources, and Files
</.button> </.button>
</.link> </.link>
</section> </section>
</:tab>
<:tab title="Sources">
<.table rows={@media_profile.sources} table_class="text-black dark:text-white">
<:col :let={source} label="Name">
<%= source.friendly_name || source.collection_name %>
</:col>
<:col :let={source} label="Type"><%= source.collection_type %></:col>
<:col :let={source} label="Should Download?">
<.icon name={if source.download_media, do: "hero-check", else: "hero-x-mark"} />
</:col>
<:col :let={source} label="" class="flex place-content-evenly">
<.icon_link href={~p"/sources/#{source.id}"} icon="hero-eye" class="mx-1" />
<.icon_link href={~p"/sources/#{source.id}/edit"} icon="hero-pencil-square" class="mx-1" />
</:col>
</.table>
</:tab>
</.tabbed_layout>
</div> </div>
</div> </div>

View file

@ -54,10 +54,10 @@ defmodule PinchflatWeb.Sources.SourceController do
source = source =
id id
|> Sources.get_source!() |> Sources.get_source!()
|> Repo.preload(:media_profile) |> Repo.preload([:media_profile, tasks: [:job]])
pending_media = Media.list_pending_media_items_for(source) pending_media = Media.list_pending_media_items_for(source, limit: 100)
downloaded_media = Media.list_downloaded_media_items_for(source) downloaded_media = Media.list_downloaded_media_items_for(source, limit: 100)
render(conn, :show, source: source, pending_media: pending_media, downloaded_media: downloaded_media) render(conn, :show, source: source, pending_media: pending_media, downloaded_media: downloaded_media)
end end

View file

@ -18,83 +18,89 @@
</div> </div>
<div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5"> <div class="rounded-sm border border-stroke bg-white px-5 py-5 shadow-default dark:border-strokedark dark:bg-boxdark sm:px-7.5">
<div class="max-w-full overflow-x-auto"> <div class="max-w-full overflow-x-auto">
<div class="flex flex-col gap-10 dark:text-white"> <.tabbed_layout>
<h3 class="font-bold text-xl">Attributes for "<%= @source.friendly_name %>"</h3> <:tab title="Attributes">
<div class="flex flex-col gap-10 text-white">
<h3 class="font-bold text-lg">Attributes</h3>
<section>
<strong>Media Profile:</strong>
<.inline_link href={~p"/media_profiles/#{@source.media_profile_id}"}>
<%= @source.media_profile.name %>
</.inline_link>
</section>
<h3 class="font-bold text-lg">Relationships</h3> <.list_items_from_map map={Map.from_struct(@source)} />
<.list> </div>
<:item title="media_profile">
<section class="flex flex-col md:flex-row items-center md:justify-around mt-10">
<.link <.link
navigate={~p"/media_profiles/#{@source.media_profile_id}"} href={~p"/sources/#{@source}"}
class="hover:text-secondary duration-200 ease-in-out" method="delete"
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone."
> >
<%= @source.media_profile.name %> <.button color="bg-meta-1" rounding="rounded-full">
Delete Source
</.button>
</.link> </.link>
</:item> <.link
</.list> href={~p"/sources/#{@source}?delete_files=true"}
method="delete"
<h3 class="font-bold text-lg">Attributes</h3> data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone."
<.list_items_from_map map={Map.from_struct(@source)} /> class="mt-5 md:mt-0"
>
<h3 class="font-bold text-xl">Downloaded Media</h3> <.button color="bg-meta-1" rounding="rounded-full">
<%= if match?([_|_], @downloaded_media) do %> Delete Source and Files
<.table rows={@downloaded_media} table_class="text-black dark:text-white"> </.button>
<:col :let={media_item} label="Title"> </.link>
<%= StringUtils.truncate(media_item.title, 50) %> </section>
</:col> </:tab>
<:col :let={media_item} label="" class="flex place-content-evenly"> <:tab title="Pending Media">
<.link <%= if match?([_|_], @pending_media) do %>
navigate={~p"/sources/#{@source.id}/media/#{media_item.id}"} <h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4>
class="hover:text-secondary duration-200 ease-in-out mx-0.5" <.table rows={@pending_media} table_class="text-black dark:text-white">
> <:col :let={media_item} label="Title">
<.icon name="hero-eye" /> <%= StringUtils.truncate(media_item.title, 50) %>
</.link> </:col>
</:col> <:col :let={media_item} label="" class="flex place-content-evenly">
</.table> <.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
<% else %> </:col>
<p class="text-black dark:text-white">Nothing Here!</p> </.table>
<% end %> <% else %>
<p class="text-black dark:text-white">Nothing Here!</p>
<h3 class="font-bold text-xl">Pending Media</h3> <% end %>
<%= if match?([_|_], @pending_media) do %> </:tab>
<.table rows={@pending_media} table_class="text-black dark:text-white"> <:tab title="Downloaded Media">
<:col :let={media_item} label="Title"> <%= if match?([_|_], @downloaded_media) do %>
<%= StringUtils.truncate(media_item.title, 50) %> <h4 class="text-white text-lg mb-6">Shows a maximum of 100 media items</h4>
</:col> <.table rows={@downloaded_media} table_class="text-black dark:text-white">
<:col :let={media_item} label="" class="flex place-content-evenly"> <:col :let={media_item} label="Title">
<.link <%= StringUtils.truncate(media_item.title, 50) %>
navigate={~p"/sources/#{@source.id}/media/#{media_item.id}"} </:col>
class="hover:text-secondary duration-200 ease-in-out mx-0.5" <:col :let={media_item} label="" class="flex place-content-evenly">
> <.icon_link href={~p"/sources/#{@source.id}/media/#{media_item.id}"} icon="hero-eye" />
<.icon name="hero-eye" /> </:col>
</.link> </.table>
</:col> <% else %>
</.table> <p class="text-black dark:text-white">Nothing Here!</p>
<% else %> <% end %>
<p class="text-black dark:text-white">Nothing Here!</p> </:tab>
<% end %> <:tab title="Tasks">
</div> <%= if match?([_|_], @source.tasks) do %>
<.table rows={@source.tasks} table_class="text-black dark:text-white">
<section class="flex flex-col md:flex-row items-center md:justify-around mt-10"> <:col :let={task} label="Worker">
<.link <%= task.job.worker %>
href={~p"/sources/#{@source}"} </:col>
method="delete" <:col :let={task} label="State">
data-confirm="Are you sure you want to delete this source (leaving files in place)? This cannot be undone." <%= task.job.state %>
> </:col>
<.button color="bg-meta-1" rounding="rounded-full"> <:col :let={task} label="Scheduled At">
Delete Source <%= Calendar.strftime(task.job.scheduled_at, "%y-%m-%d %I:%M:%S %p %Z") %>
</.button> </:col>
</.link> </.table>
<.link <% else %>
href={~p"/sources/#{@source}?delete_files=true"} <p class="text-black dark:text-white">Nothing Here!</p>
method="delete" <% end %>
data-confirm="Are you sure you want to delete this source and it's files on disk? This cannot be undone." </:tab>
class="mt-5 md:mt-0" </.tabbed_layout>
>
<.button color="bg-meta-1" rounding="rounded-full">
Delete Source and Files
</.button>
</.link>
</section>
</div> </div>
</div> </div>

View file

@ -64,6 +64,14 @@ defmodule Pinchflat.MediaTest do
assert Media.list_pending_media_items_for(source) == [] assert Media.list_pending_media_items_for(source) == []
end end
test "optionally accepts a limit" do
source = source_fixture()
media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
assert Media.list_pending_media_items_for(source, limit: 1) == [media_item]
assert Media.list_pending_media_items_for(source, limit: 0) == []
end
end end
describe "list_pending_media_items_for/1 when testing shorts" do describe "list_pending_media_items_for/1 when testing shorts" do
@ -196,6 +204,15 @@ defmodule Pinchflat.MediaTest do
assert Media.list_downloaded_media_items_for(source) == [media_item] assert Media.list_downloaded_media_items_for(source) == [media_item]
end end
test "optionally accepts a limit" do
source = source_fixture()
_media_item = media_item_fixture(%{source_id: source.id, media_filepath: nil})
media_item = media_item_fixture(%{source_id: source.id, media_filepath: "/video/#{Faker.File.file_name(:video)}"})
assert Media.list_downloaded_media_items_for(source, limit: 1) == [media_item]
assert Media.list_downloaded_media_items_for(source, limit: 0) == []
end
end end
describe "search/1" do describe "search/1" do

View file

@ -1,6 +1,9 @@
defmodule Pinchflat.RepoTest do defmodule Pinchflat.RepoTest do
use Pinchflat.DataCase use Pinchflat.DataCase
import Pinchflat.ProfilesFixtures
alias Pinchflat.Repo
alias Pinchflat.Profiles.MediaProfile
alias Pinchflat.JobFixtures.TestJobWorker alias Pinchflat.JobFixtures.TestJobWorker
describe "insert_unique_job/1" do describe "insert_unique_job/1" do
@ -23,4 +26,30 @@ defmodule Pinchflat.RepoTest do
assert {:error, _} = Pinchflat.Repo.insert_unique_job(%Ecto.Changeset{}) assert {:error, _} = Pinchflat.Repo.insert_unique_job(%Ecto.Changeset{})
end end
end end
describe "maybe_limit/2" do
test "applies a limit if provided" do
media_profile_fixture()
media_profile_fixture()
result =
MediaProfile
|> Repo.maybe_limit(1)
|> Repo.aggregate(:count, :id)
assert result == 1
end
test "does not apply a limit if not provided" do
media_profile_fixture()
media_profile_fixture()
result =
MediaProfile
|> Repo.maybe_limit(nil)
|> Repo.aggregate(:count, :id)
assert result == 2
end
end
end end