From 419b093101343722879c3757bbb839e87211f3b8 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Thu, 5 Feb 2026 11:46:52 +0000 Subject: [PATCH] First pass at record sets --- lib/music_library/record_sets.ex | 144 +++++++++++++ lib/music_library/record_sets/record_set.ex | 24 +++ .../record_sets/record_set_item.ex | 25 +++ .../components/layouts/app.html.heex | 13 ++ .../live/record_set_live/form.ex | 95 ++++++++ .../live/record_set_live/index.ex | 184 ++++++++++++++++ .../live/record_set_live/index.html.heex | 202 ++++++++++++++++++ .../live/record_set_live/record_picker.ex | 143 +++++++++++++ lib/music_library_web/router.ex | 5 + priv/gettext/default.pot | 110 ++++++++++ priv/gettext/en/LC_MESSAGES/default.po | 110 ++++++++++ .../20260205113052_create_record_sets.exs | 13 ++ ...20260205113055_create_record_set_items.exs | 21 ++ 13 files changed, 1089 insertions(+) create mode 100644 lib/music_library/record_sets.ex create mode 100644 lib/music_library/record_sets/record_set.ex create mode 100644 lib/music_library/record_sets/record_set_item.ex create mode 100644 lib/music_library_web/live/record_set_live/form.ex create mode 100644 lib/music_library_web/live/record_set_live/index.ex create mode 100644 lib/music_library_web/live/record_set_live/index.html.heex create mode 100644 lib/music_library_web/live/record_set_live/record_picker.ex create mode 100644 priv/repo/migrations/20260205113052_create_record_sets.exs create mode 100644 priv/repo/migrations/20260205113055_create_record_set_items.exs diff --git a/lib/music_library/record_sets.ex b/lib/music_library/record_sets.ex new file mode 100644 index 00000000..2ce987de --- /dev/null +++ b/lib/music_library/record_sets.ex @@ -0,0 +1,144 @@ +defmodule MusicLibrary.RecordSets do + import Ecto.Query, warn: false + + alias MusicLibrary.RecordSets.{RecordSet, RecordSetItem} + alias MusicLibrary.Repo + + def list_record_sets(opts \\ []) do + offset = Keyword.get(opts, :offset, 0) + limit = Keyword.get(opts, :limit, 20) + + from(rs in RecordSet, + order_by: [desc: rs.updated_at], + offset: ^offset, + limit: ^limit, + preload: [items: :record] + ) + |> Repo.all() + end + + def count_record_sets do + Repo.aggregate(RecordSet, :count) + end + + def get_record_set!(id) do + RecordSet + |> Repo.get!(id) + |> Repo.preload(items: :record) + end + + def create_record_set(attrs) do + %RecordSet{} + |> RecordSet.changeset(attrs) + |> Repo.insert() + |> case do + {:ok, record_set} -> {:ok, Repo.preload(record_set, items: :record)} + error -> error + end + end + + def update_record_set(%RecordSet{} = record_set, attrs) do + record_set + |> RecordSet.changeset(attrs) + |> Repo.update() + |> case do + {:ok, record_set} -> {:ok, Repo.preload(record_set, [items: :record], force: true)} + error -> error + end + end + + def delete_record_set(%RecordSet{} = record_set) do + Repo.delete(record_set) + end + + def change_record_set(%RecordSet{} = record_set, attrs \\ %{}) do + RecordSet.changeset(record_set, attrs) + end + + def add_record_to_set(%RecordSet{} = record_set, record_id) do + next_position = + from(i in RecordSetItem, + where: i.record_set_id == ^record_set.id, + select: coalesce(max(i.position), -1) + ) + |> Repo.one!() + |> Kernel.+(1) + + %RecordSetItem{} + |> RecordSetItem.changeset(%{position: next_position}) + |> Ecto.Changeset.put_change(:record_set_id, record_set.id) + |> Ecto.Changeset.put_change(:record_id, record_id) + |> Ecto.Changeset.unique_constraint([:record_set_id, :record_id]) + |> Repo.insert() + |> case do + {:ok, _item} -> {:ok, get_record_set!(record_set.id)} + error -> error + end + end + + def remove_record_from_set(%RecordSet{} = record_set, record_id) do + item = + from(i in RecordSetItem, + where: i.record_set_id == ^record_set.id and i.record_id == ^record_id + ) + |> Repo.one!() + + Repo.delete(item) + + recompact_positions(record_set.id) + + {:ok, get_record_set!(record_set.id)} + end + + def move_record_in_set(%RecordSet{} = record_set, record_id, direction) + when direction in [:up, :down] do + items = + from(i in RecordSetItem, + where: i.record_set_id == ^record_set.id, + order_by: [asc: i.position] + ) + |> Repo.all() + + index = Enum.find_index(items, fn i -> i.record_id == record_id end) + + swap_index = + case direction do + :up -> index - 1 + :down -> index + 1 + end + + if index && swap_index >= 0 && swap_index < length(items) do + item_a = Enum.at(items, index) + item_b = Enum.at(items, swap_index) + + Repo.transaction(fn -> + item_a + |> Ecto.Changeset.change(position: item_b.position) + |> Repo.update!() + + item_b + |> Ecto.Changeset.change(position: item_a.position) + |> Repo.update!() + end) + end + + {:ok, get_record_set!(record_set.id)} + end + + defp recompact_positions(record_set_id) do + items = + from(i in RecordSetItem, + where: i.record_set_id == ^record_set_id, + order_by: [asc: i.position] + ) + |> Repo.all() + + Enum.with_index(items, fn item, index -> + if item.position != index do + item + |> Ecto.Changeset.change(position: index) + |> Repo.update!() + end + end) + end +end diff --git a/lib/music_library/record_sets/record_set.ex b/lib/music_library/record_sets/record_set.ex new file mode 100644 index 00000000..9f9d41f7 --- /dev/null +++ b/lib/music_library/record_sets/record_set.ex @@ -0,0 +1,24 @@ +defmodule MusicLibrary.RecordSets.RecordSet do + use Ecto.Schema + + import Ecto.Changeset + + alias MusicLibrary.RecordSets.RecordSetItem + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "record_sets" do + field :name, :string + field :description, :string + + has_many :items, RecordSetItem, preload_order: [asc: :position] + + timestamps(type: :utc_datetime) + end + + def changeset(record_set, attrs) do + record_set + |> cast(attrs, [:name, :description]) + |> validate_required([:name]) + end +end diff --git a/lib/music_library/record_sets/record_set_item.ex b/lib/music_library/record_sets/record_set_item.ex new file mode 100644 index 00000000..9dbb3ebf --- /dev/null +++ b/lib/music_library/record_sets/record_set_item.ex @@ -0,0 +1,25 @@ +defmodule MusicLibrary.RecordSets.RecordSetItem do + use Ecto.Schema + + import Ecto.Changeset + + alias MusicLibrary.Records.Record + alias MusicLibrary.RecordSets.RecordSet + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "record_set_items" do + field :position, :integer + + belongs_to :record_set, RecordSet + belongs_to :record, Record + + timestamps(type: :utc_datetime) + end + + def changeset(record_set_item, attrs) do + record_set_item + |> cast(attrs, [:position]) + |> validate_required([:position]) + end +end diff --git a/lib/music_library_web/components/layouts/app.html.heex b/lib/music_library_web/components/layouts/app.html.heex index 8ddebdb1..3837e669 100644 --- a/lib/music_library_web/components/layouts/app.html.heex +++ b/lib/music_library_web/components/layouts/app.html.heex @@ -35,6 +35,19 @@ /> {gettext("Wishlist")} + <.nav_link + route={~p"/record-sets"} + section={:record_sets} + current_section={@current_section} + > + <.icon + name="hero-rectangle-stack" + class="max-sm:hidden h-4 w-4 mr-2" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Sets")} +
diff --git a/lib/music_library_web/live/record_set_live/form.ex b/lib/music_library_web/live/record_set_live/form.ex new file mode 100644 index 00000000..2dc38940 --- /dev/null +++ b/lib/music_library_web/live/record_set_live/form.ex @@ -0,0 +1,95 @@ +defmodule MusicLibraryWeb.RecordSetLive.Form do + use MusicLibraryWeb, :live_component + + alias MusicLibrary.RecordSets + + @impl true + def render(assigns) do + ~H""" +
+
+

+ {@title} +

+
+ + <.simple_form + for={@form} + id="record-set-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input + field={@form[:name]} + type="text" + label={gettext("Name")} + placeholder={gettext("e.g. Favorites, Road Trip, Sunday Morning")} + /> + + <.textarea + field={@form[:description]} + label={gettext("Description (optional)")} + placeholder={gettext("What is this set about?")} + /> + + <:actions> + <.button phx-disable-with={gettext("Saving...")}> + {gettext("Save Set")} + + + +
+ """ + end + + @impl true + def update(%{record_set: record_set} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(RecordSets.change_record_set(record_set)) + end)} + end + + @impl true + def handle_event("validate", %{"record_set" => record_set_params}, socket) do + changeset = + RecordSets.change_record_set(socket.assigns.record_set, record_set_params) + + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"record_set" => record_set_params}, socket) do + save_record_set(socket, socket.assigns.action, record_set_params) + end + + defp save_record_set(socket, :edit, record_set_params) do + case RecordSets.update_record_set(socket.assigns.record_set, record_set_params) do + {:ok, record_set} -> + notify_parent({:updated, record_set}) + put_toast!(:info, gettext("Record set updated successfully")) + + {:noreply, push_patch(socket, to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_record_set(socket, :new, record_set_params) do + case RecordSets.create_record_set(record_set_params) do + {:ok, record_set} -> + notify_parent({:created, record_set}) + put_toast!(:info, gettext("Record set created successfully")) + + {:noreply, push_patch(socket, to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) +end diff --git a/lib/music_library_web/live/record_set_live/index.ex b/lib/music_library_web/live/record_set_live/index.ex new file mode 100644 index 00000000..5733eaa4 --- /dev/null +++ b/lib/music_library_web/live/record_set_live/index.ex @@ -0,0 +1,184 @@ +defmodule MusicLibraryWeb.RecordSetLive.Index do + use MusicLibraryWeb, :live_view + + import MusicLibraryWeb.Components.Pagination + + alias MusicLibrary.RecordSets + alias MusicLibrary.RecordSets.RecordSet + + @default_list_params %{ + page: 1, + page_size: 20, + query: "", + order: :updated_at + } + + @impl true + def mount(_params, _session, socket) do + {:ok, assign(socket, :current_section, :record_sets)} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id} = params) do + socket + |> apply_fallback_index(params) + |> assign(:page_title, gettext("Edit Set")) + |> assign(:record_set, RecordSets.get_record_set!(id)) + end + + defp apply_action(socket, :new, params) do + socket + |> apply_fallback_index(params) + |> assign(:page_title, gettext("New Set")) + |> assign(:record_set, %RecordSet{}) + end + + defp apply_action(socket, :add_record, %{"id" => id} = params) do + socket + |> apply_fallback_index(params) + |> assign(:page_title, gettext("Add Record")) + |> assign(:record_set, RecordSets.get_record_set!(id)) + end + + defp apply_action(socket, :index, params) do + total_sets = RecordSets.count_record_sets() + + list_params = + @default_list_params + |> merge_pagination(params, total_sets) + + load_and_assign_sets(socket, list_params) + end + + defp apply_fallback_index(socket, params) do + if get_in(socket.assigns, [:streams, :record_sets]) == nil do + socket + |> apply_action(:index, params) + else + socket + end + end + + defp merge_pagination(params, url_params, total_records) do + page = parse_page(url_params["page"]) + page_size = parse_page_size(url_params["page_size"]) + + params + |> Map.put(:page, page) + |> Map.put(:page_size, page_size) + |> Map.put(:total_records, total_records) + end + + defp parse_page(nil), do: 1 + + defp parse_page(page) when is_binary(page) do + case Integer.parse(page) do + {num, ""} when num > 0 -> num + _ -> 1 + end + end + + defp parse_page(_), do: 1 + + defp parse_page_size(nil), do: 20 + + defp parse_page_size(page_size) when is_binary(page_size) do + case Integer.parse(page_size) do + {num, ""} when num in [20, 50, 100] -> num + _ -> 20 + end + end + + defp parse_page_size(_), do: 20 + + defp load_and_assign_sets(socket, list_params) do + offset = page_to_offset(list_params.page, list_params.page_size) + + sets = + RecordSets.list_record_sets( + offset: offset, + limit: list_params.page_size + ) + + list_params_with_total = + Map.put(list_params, :total_entries, list_params.total_records) + + socket + |> assign(:list_params, list_params_with_total) + |> assign(:page_title, gettext("Record Sets")) + |> assign(:record_set, nil) + |> stream(:record_sets, sets, reset: true) + end + + def back_path(list_params) do + qs = + list_params + |> Map.take([:page, :page_size]) + + ~p"/record-sets?#{qs}" + end + + @impl true + def handle_info( + {MusicLibraryWeb.RecordSetLive.Form, {:created, record_set}}, + socket + ) do + {:noreply, + socket + |> stream_insert(:record_sets, record_set, at: 0) + |> load_and_assign_sets(socket.assigns.list_params)} + end + + def handle_info( + {MusicLibraryWeb.RecordSetLive.Form, {:updated, record_set}}, + socket + ) do + {:noreply, stream_insert(socket, :record_sets, record_set)} + end + + def handle_info( + {MusicLibraryWeb.RecordSetLive.RecordPicker, {:added, record_set}}, + socket + ) do + {:noreply, + socket + |> stream_insert(:record_sets, record_set) + |> push_patch(to: back_path(socket.assigns.list_params))} + end + + @impl true + def handle_event("delete_set", %{"id" => id}, socket) do + record_set = RecordSets.get_record_set!(id) + {:ok, _} = RecordSets.delete_record_set(record_set) + + {:noreply, + socket + |> stream_delete(:record_sets, record_set) + |> load_and_assign_sets(socket.assigns.list_params)} + end + + def handle_event("remove_record", %{"set-id" => set_id, "record-id" => record_id}, socket) do + record_set = RecordSets.get_record_set!(set_id) + {:ok, updated_set} = RecordSets.remove_record_from_set(record_set, record_id) + + {:noreply, stream_insert(socket, :record_sets, updated_set)} + end + + def handle_event("move_up", %{"set-id" => set_id, "record-id" => record_id}, socket) do + record_set = RecordSets.get_record_set!(set_id) + {:ok, updated_set} = RecordSets.move_record_in_set(record_set, record_id, :up) + + {:noreply, stream_insert(socket, :record_sets, updated_set)} + end + + def handle_event("move_down", %{"set-id" => set_id, "record-id" => record_id}, socket) do + record_set = RecordSets.get_record_set!(set_id) + {:ok, updated_set} = RecordSets.move_record_in_set(record_set, record_id, :down) + + {:noreply, stream_insert(socket, :record_sets, updated_set)} + end +end diff --git a/lib/music_library_web/live/record_set_live/index.html.heex b/lib/music_library_web/live/record_set_live/index.html.heex new file mode 100644 index 00000000..bebeb12a --- /dev/null +++ b/lib/music_library_web/live/record_set_live/index.html.heex @@ -0,0 +1,202 @@ + +
+
+
+

+ {gettext("Record Sets")} +

+
+
+ <.button + variant="solid" + size="sm" + patch={~p"/record-sets/new"} + > + <.icon name="hero-plus" class="icon" aria-hidden="true" data-slot="icon" /> + {gettext("New Set")} + +
+
+
+ +
+
    + + +
  • +
    +
    +

    + {record_set.name} +

    +

    + {record_set.description} +

    +
    +
    + + {ngettext("1 record", "%{count} records", length(record_set.items))} + + <.dropdown id={"set-actions-#{record_set.id}"} placement="bottom-end"> + <:toggle> + <.button variant="ghost"> + {gettext("Actions")} + <.icon + name="hero-ellipsis-vertical" + class="h-5 w-5 text-zinc-500 dark:text-zinc-400 cursor-pointer" + aria-hidden="true" + data-slot="icon" + /> + + + <.dropdown_link + id={"set-actions-#{record_set.id}-edit"} + patch={ + ~p"/record-sets/#{record_set}/edit?#{Map.take(@list_params, [:page, :page_size])}" + } + > + {gettext("Edit")} + + <.separator /> + <.dropdown_button + phx-click="delete_set" + phx-value-id={record_set.id} + data-confirm={gettext("Are you sure?")} + class={[ + "text-red-900! hover:bg-red-50! dark:text-red-500! dark:hover:bg-red-900/30! dark:hover:text-red-600!" + ]} + > + {gettext("Delete")} + + +
    +
    + +
    +
    + <.link navigate={~p"/collection/#{item.record}"}> + + +
    + <.dropdown + id={"item-actions-#{record_set.id}-#{item.record.id}"} + placement="bottom-end" + > + <:toggle> + + <.icon + name="hero-ellipsis-vertical" + class="h-4 w-4 text-white" + aria-hidden="true" + data-slot="icon" + /> + + + <.dropdown_button + :if={item.position > 0} + phx-click="move_up" + phx-value-set-id={record_set.id} + phx-value-record-id={item.record.id} + > + {gettext("Move left")} + + <.dropdown_button + :if={item.position < length(record_set.items) - 1} + phx-click="move_down" + phx-value-set-id={record_set.id} + phx-value-record-id={item.record.id} + > + {gettext("Move right")} + + <.separator /> + <.dropdown_button + phx-click="remove_record" + phx-value-set-id={record_set.id} + phx-value-record-id={item.record.id} + data-confirm={gettext("Remove this record from the set?")} + class={[ + "text-red-900! hover:bg-red-50! dark:text-red-500! dark:hover:bg-red-900/30! dark:hover:text-red-600!" + ]} + > + {gettext("Remove")} + + +
    +

    + {item.record.title} +

    +
    + + <.link + patch={~p"/record-sets/#{record_set}/add-record"} + class={[ + "flex-none w-24 sm:w-32 aspect-square", + "border-2 border-dashed border-zinc-300 dark:border-zinc-600", + "rounded-lg flex items-center justify-center", + "hover:border-zinc-400 dark:hover:border-zinc-500", + "hover:bg-zinc-50 dark:hover:bg-zinc-800", + "transition-colors cursor-pointer" + ]} + > + <.icon + name="hero-plus" + class="h-8 w-8 text-zinc-400 dark:text-zinc-500" + aria-hidden="true" + data-slot="icon" + /> + +
    +
  • +
+ + <.pagination id={:bottom_pagination} pagination_params={@list_params} /> +
+ + <.structured_modal + :if={@live_action in [:new, :edit]} + id="record-set-modal" + on_close={JS.patch(back_path(@list_params))} + > + <.live_component + module={MusicLibraryWeb.RecordSetLive.Form} + id={@record_set.id || :new} + title={@page_title} + action={@live_action} + record_set={@record_set} + patch={back_path(@list_params)} + /> + + + <.structured_modal + :if={@live_action == :add_record} + id="record-picker-modal" + on_close={JS.patch(back_path(@list_params))} + > + <.live_component + module={MusicLibraryWeb.RecordSetLive.RecordPicker} + id={"record-picker-#{@record_set.id}"} + title={@page_title} + record_set={@record_set} + patch={back_path(@list_params)} + /> + +
diff --git a/lib/music_library_web/live/record_set_live/record_picker.ex b/lib/music_library_web/live/record_set_live/record_picker.ex new file mode 100644 index 00000000..02c95891 --- /dev/null +++ b/lib/music_library_web/live/record_set_live/record_picker.ex @@ -0,0 +1,143 @@ +defmodule MusicLibraryWeb.RecordSetLive.RecordPicker do + use MusicLibraryWeb, :live_component + + alias MusicLibrary.Collection + alias MusicLibrary.Records.Record + + @impl true + def render(assigns) do + ~H""" +
+
+

+ {@title} +

+

+ {gettext("Search your collection to add a record to this set.")} +

+
+
+ <.input + type="search" + size="sm" + id={:query} + name={:query} + value={@query} + placeholder={gettext("Search")} + phx-debounce="500" + autocomplete="off" + autofocus + /> +
+ +
    +
  • + {gettext("No records found")} +
  • +
  • +
    + +
    +
    +

    + {record.title} +

    +

    + {Record.artist_names(record)} +

    +
    +
    + <.icon + name="hero-plus-circle" + class="h-5 w-5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300" + aria-hidden="true" + data-slot="icon" + /> +
    +
  • +
+
+ """ + end + + @impl true + def update(assigns, socket) do + existing_record_ids = + MapSet.new(assigns.record_set.items, fn item -> item.record.id end) + + {:ok, + socket + |> assign(assigns) + |> assign(:query, "") + |> assign(:results, []) + |> assign(:existing_record_ids, existing_record_ids)} + end + + @impl true + def handle_event("search", %{"query" => query}, socket) do + results = + if String.trim(query) == "" do + [] + else + query + |> Collection.search_records(limit: 20) + |> Enum.reject(fn record -> + MapSet.member?(socket.assigns.existing_record_ids, record.id) + end) + end + + {:noreply, + socket + |> assign(:query, query) + |> assign(:results, results)} + end + + def handle_event("add_record", %{"record-id" => record_id}, socket) do + record_set = socket.assigns.record_set + + case MusicLibrary.RecordSets.add_record_to_set(record_set, record_id) do + {:ok, updated_set} -> + existing_record_ids = MapSet.put(socket.assigns.existing_record_ids, record_id) + + results = + Enum.reject(socket.assigns.results, fn record -> record.id == record_id end) + + notify_parent({:added, updated_set}) + put_toast!(:info, gettext("Record added to set")) + + {:noreply, + socket + |> assign(:record_set, updated_set) + |> assign(:existing_record_ids, existing_record_ids) + |> assign(:results, results)} + + {:error, _changeset} -> + put_toast!(:error, gettext("Could not add record to set")) + {:noreply, socket} + end + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) +end diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex index b5f6f56a..2915a7d8 100644 --- a/lib/music_library_web/router.ex +++ b/lib/music_library_web/router.ex @@ -65,6 +65,11 @@ defmodule MusicLibraryWeb.Router do live "/artists/:musicbrainz_id/import", ArtistLive.Show, :import live "/artists/:musicbrainz_id/edit", ArtistLive.Show, :edit + live "/record-sets", RecordSetLive.Index, :index + live "/record-sets/new", RecordSetLive.Index, :new + live "/record-sets/:id/edit", RecordSetLive.Index, :edit + live "/record-sets/:id/add-record", RecordSetLive.Index, :add_record + live "/scrobble-rules", ScrobbleRulesLive.Index, :index live "/scrobble-rules/new", ScrobbleRulesLive.Index, :new live "/scrobble-rules/:id/edit", ScrobbleRulesLive.Index, :edit diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 2e7a7908..37800756 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -14,6 +14,7 @@ msgstr "" #: lib/music_library_web/components/record_components.ex #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex +#: lib/music_library_web/live/record_set_live/index.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -38,6 +39,7 @@ msgstr "" #: lib/music_library_web/components/record_components.ex #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex +#: lib/music_library_web/live/record_set_live/index.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -52,6 +54,7 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.ex #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex +#: lib/music_library_web/live/record_set_live/index.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.ex @@ -175,6 +178,7 @@ msgstr "" #: lib/music_library_web/components/record_form.ex #: lib/music_library_web/live/artist_live/form.ex #: lib/music_library_web/live/online_store_template_live/form.ex +#: lib/music_library_web/live/record_set_live/form.ex #: lib/music_library_web/live/scrobble_rules_live/form.ex #: lib/music_library_web/live/scrobbled_tracks_live/form.ex #, elixir-autogen, elixir-format @@ -182,6 +186,7 @@ msgid "Saving..." msgstr "" #: lib/music_library_web/components/core_components.ex +#: lib/music_library_web/live/record_set_live/record_picker.ex #, elixir-autogen, elixir-format msgid "Search" msgstr "" @@ -390,6 +395,7 @@ msgid "On Tour" msgstr "" #: lib/music_library_web/components/record_components.ex +#: lib/music_library_web/live/record_set_live/index.html.heex #, elixir-autogen, elixir-format msgid "1 record" msgid_plural "%{count} records" @@ -899,6 +905,7 @@ msgstr "" #: lib/music_library_web/live/artist_live/show.html.heex #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex +#: lib/music_library_web/live/record_set_live/index.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -977,6 +984,7 @@ msgid "Artist Name" msgstr "" #: lib/music_library_web/live/online_store_template_live/form.ex +#: lib/music_library_web/live/record_set_live/form.ex #: lib/music_library_web/live/scrobble_rules_live/form.ex #, elixir-autogen, elixir-format msgid "Description (optional)" @@ -1687,3 +1695,105 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Regenerate embeddings" msgstr "" + +#: lib/music_library_web/live/record_set_live/index.ex +#, elixir-autogen, elixir-format +msgid "Add Record" +msgstr "" + +#: lib/music_library_web/live/record_set_live/record_picker.ex +#, elixir-autogen, elixir-format +msgid "Could not add record to set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.ex +#, elixir-autogen, elixir-format +msgid "Edit Set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Move left" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Move right" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "Name" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.ex +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "New Set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No record sets yet" +msgstr "" + +#: lib/music_library_web/live/record_set_live/record_picker.ex +#, elixir-autogen, elixir-format +msgid "No records found" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.ex +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Record Sets" +msgstr "" + +#: lib/music_library_web/live/record_set_live/record_picker.ex +#, elixir-autogen, elixir-format +msgid "Record added to set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "Record set created successfully" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "Record set updated successfully" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Remove" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Remove this record from the set?" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "Save Set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/record_picker.ex +#, elixir-autogen, elixir-format +msgid "Search your collection to add a record to this set." +msgstr "" + +#: lib/music_library_web/components/layouts/app.html.heex +#, elixir-autogen, elixir-format +msgid "Sets" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "What is this set about?" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "e.g. Favorites, Road Trip, Sunday Morning" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 3428a541..fc82743e 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -14,6 +14,7 @@ msgstr "" #: lib/music_library_web/components/record_components.ex #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex +#: lib/music_library_web/live/record_set_live/index.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -38,6 +39,7 @@ msgstr "" #: lib/music_library_web/components/record_components.ex #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex +#: lib/music_library_web/live/record_set_live/index.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -52,6 +54,7 @@ msgstr "" #: lib/music_library_web/live/collection_live/show.ex #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex +#: lib/music_library_web/live/record_set_live/index.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.ex @@ -175,6 +178,7 @@ msgstr "" #: lib/music_library_web/components/record_form.ex #: lib/music_library_web/live/artist_live/form.ex #: lib/music_library_web/live/online_store_template_live/form.ex +#: lib/music_library_web/live/record_set_live/form.ex #: lib/music_library_web/live/scrobble_rules_live/form.ex #: lib/music_library_web/live/scrobbled_tracks_live/form.ex #, elixir-autogen, elixir-format @@ -182,6 +186,7 @@ msgid "Saving..." msgstr "" #: lib/music_library_web/components/core_components.ex +#: lib/music_library_web/live/record_set_live/record_picker.ex #, elixir-autogen, elixir-format msgid "Search" msgstr "" @@ -390,6 +395,7 @@ msgid "On Tour" msgstr "" #: lib/music_library_web/components/record_components.ex +#: lib/music_library_web/live/record_set_live/index.html.heex #, elixir-autogen, elixir-format msgid "1 record" msgid_plural "%{count} records" @@ -899,6 +905,7 @@ msgstr "" #: lib/music_library_web/live/artist_live/show.html.heex #: lib/music_library_web/live/collection_live/show.html.heex #: lib/music_library_web/live/online_store_template_live/index.html.heex +#: lib/music_library_web/live/record_set_live/index.html.heex #: lib/music_library_web/live/scrobble_rules_live/index.html.heex #: lib/music_library_web/live/scrobbled_tracks_live/index.html.heex #: lib/music_library_web/live/wishlist_live/show.html.heex @@ -977,6 +984,7 @@ msgid "Artist Name" msgstr "" #: lib/music_library_web/live/online_store_template_live/form.ex +#: lib/music_library_web/live/record_set_live/form.ex #: lib/music_library_web/live/scrobble_rules_live/form.ex #, elixir-autogen, elixir-format msgid "Description (optional)" @@ -1687,3 +1695,105 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Regenerate embeddings" msgstr "" + +#: lib/music_library_web/live/record_set_live/index.ex +#, elixir-autogen, elixir-format +msgid "Add Record" +msgstr "" + +#: lib/music_library_web/live/record_set_live/record_picker.ex +#, elixir-autogen, elixir-format +msgid "Could not add record to set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Edit Set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Move left" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Move right" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "Name" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.ex +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "New Set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "No record sets yet" +msgstr "" + +#: lib/music_library_web/live/record_set_live/record_picker.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "No records found" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.ex +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Record Sets" +msgstr "" + +#: lib/music_library_web/live/record_set_live/record_picker.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Record added to set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Record set created successfully" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Record set updated successfully" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Remove" +msgstr "" + +#: lib/music_library_web/live/record_set_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Remove this record from the set?" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Save Set" +msgstr "" + +#: lib/music_library_web/live/record_set_live/record_picker.ex +#, elixir-autogen, elixir-format +msgid "Search your collection to add a record to this set." +msgstr "" + +#: lib/music_library_web/components/layouts/app.html.heex +#, elixir-autogen, elixir-format +msgid "Sets" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "What is this set about?" +msgstr "" + +#: lib/music_library_web/live/record_set_live/form.ex +#, elixir-autogen, elixir-format +msgid "e.g. Favorites, Road Trip, Sunday Morning" +msgstr "" diff --git a/priv/repo/migrations/20260205113052_create_record_sets.exs b/priv/repo/migrations/20260205113052_create_record_sets.exs new file mode 100644 index 00000000..4276ae40 --- /dev/null +++ b/priv/repo/migrations/20260205113052_create_record_sets.exs @@ -0,0 +1,13 @@ +defmodule MusicLibrary.Repo.Migrations.CreateRecordSets do + use Ecto.Migration + + def change do + create table(:record_sets, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string, null: false + add :description, :text + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/migrations/20260205113055_create_record_set_items.exs b/priv/repo/migrations/20260205113055_create_record_set_items.exs new file mode 100644 index 00000000..8a1a5573 --- /dev/null +++ b/priv/repo/migrations/20260205113055_create_record_set_items.exs @@ -0,0 +1,21 @@ +defmodule MusicLibrary.Repo.Migrations.CreateRecordSetItems do + use Ecto.Migration + + def change do + create table(:record_set_items, primary_key: false) do + add :id, :binary_id, primary_key: true + add :position, :integer, null: false + + add :record_set_id, references(:record_sets, type: :binary_id, on_delete: :delete_all), + null: false + + add :record_id, references(:records, type: :binary_id, on_delete: :delete_all), null: false + + timestamps(type: :utc_datetime) + end + + create index(:record_set_items, [:record_set_id]) + create unique_index(:record_set_items, [:record_set_id, :record_id]) + create index(:record_set_items, [:record_set_id, :position]) + end +end