Add first version of online stores for wishlisted records (claude)

See docs/plans/online_stores.md
This commit is contained in:
Claudio Ortolina
2025-07-15 09:42:59 +01:00
parent af4587fdbf
commit 65de5fc833
13 changed files with 1189 additions and 1 deletions
@@ -0,0 +1,76 @@
defmodule MusicLibrary.OnlineStoreTemplates do
@moduledoc """
The OnlineStoreTemplates context.
"""
import Ecto.Query, warn: false
alias MusicLibrary.OnlineStoreTemplates.OnlineStoreTemplate
alias MusicLibrary.Repo
@doc """
Returns the list of enabled online store templates ordered by name.
"""
def list_enabled_templates do
OnlineStoreTemplate
|> where([t], t.enabled == true)
|> order_by([t], asc: t.name)
|> Repo.all()
end
@doc """
Returns the list of all online store templates for management.
"""
def list_templates do
OnlineStoreTemplate
|> order_by([t], asc: t.name)
|> Repo.all()
end
@doc """
Gets a single online store template.
"""
def get_template!(id), do: Repo.get!(OnlineStoreTemplate, id)
@doc """
Creates an online store template.
"""
def create_template(attrs \\ %{}) do
%OnlineStoreTemplate{}
|> OnlineStoreTemplate.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates an online store template.
"""
def update_template(%OnlineStoreTemplate{} = template, attrs) do
template
|> OnlineStoreTemplate.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes an online store template.
"""
def delete_template(%OnlineStoreTemplate{} = template) do
Repo.delete(template)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking online store template changes.
"""
def change_template(%OnlineStoreTemplate{} = template, attrs \\ %{}) do
OnlineStoreTemplate.changeset(template, attrs)
end
@doc """
Generates a URL from a template by replacing variables with record data.
"""
def generate_url(template, record) do
artists_string = Enum.map_join(record.artists, " ", & &1.name)
template.url_template
|> String.replace("{artist}", URI.encode(artists_string))
|> String.replace("{title}", URI.encode(record.title))
end
end
@@ -0,0 +1,37 @@
defmodule MusicLibrary.OnlineStoreTemplates.OnlineStoreTemplate do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "online_store_templates" do
field :name, :string
field :description, :string
field :url_template, :string
field :enabled, :boolean, default: true
timestamps(type: :utc_datetime)
end
@doc false
def changeset(template, attrs) do
template
|> cast(attrs, [:name, :description, :url_template, :enabled])
|> validate_required([:name, :url_template])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:url_template, min: 1, max: 500)
|> validate_url_template()
end
defp validate_url_template(changeset) do
if template = get_field(changeset, :url_template) do
case URI.parse(template) do
%URI{scheme: scheme} when scheme in ["http", "https"] -> changeset
_ -> add_error(changeset, :url_template, "must be a valid HTTP or HTTPS URL")
end
else
changeset
end
end
end
@@ -84,6 +84,15 @@
/>
{gettext("Scrobble Rules")}
</.dropdown_link>
<.dropdown_link href={~p"/online-store-templates"}>
<.icon
name="hero-building-storefront"
class="h-4 w-4 mr-2"
aria-hidden="true"
data-slot="icon"
/>
{gettext("Online Store Templates")}
</.dropdown_link>
<.dropdown_link href={~p"/backup"}>
<.icon
name="hero-archive-box-arrow-down"
@@ -0,0 +1,125 @@
defmodule MusicLibraryWeb.OnlineStoreTemplateLive.FormComponent do
use MusicLibraryWeb, :live_component
alias MusicLibrary.OnlineStoreTemplates
@impl true
def render(assigns) do
~H"""
<div>
<header class="mb-6">
<h1 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{@title}
</h1>
</header>
<.simple_form
for={@form}
id="online_store_template-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input
field={@form[:name]}
type="text"
label={gettext("Template Name")}
placeholder={gettext("e.g. Amazon UK")}
/>
<.input
field={@form[:url_template]}
type="text"
label={gettext("URL Template")}
placeholder={gettext("e.g. https://www.amazon.co.uk/s?k={artist}+{title}+vinyl")}
class="font-mono"
/>
<.textarea
field={@form[:description]}
label={gettext("Description (optional)")}
placeholder={gettext("Add a description to help identify this template")}
/>
<.switch field={@form[:enabled]} label={gettext("Enable this template")} />
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">
{gettext("Available Variables")}
</h4>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p>
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">{"{artist}"}</code>
- {gettext("Artist name(s)")}
</p>
<p>
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">{"{title}"}</code>
- {gettext("Record title")}
</p>
</div>
</div>
<:actions>
<.button phx-disable-with={gettext("Saving...")}>
{gettext("Save Template")}
</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def update(%{template: template} = assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:form, fn ->
to_form(OnlineStoreTemplates.change_template(template))
end)}
end
@impl true
def handle_event("validate", %{"online_store_template" => template_params}, socket) do
changeset =
OnlineStoreTemplates.change_template(socket.assigns.template, template_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"online_store_template" => template_params}, socket) do
save_template(socket, socket.assigns.action, template_params)
end
defp save_template(socket, :edit, template_params) do
case OnlineStoreTemplates.update_template(socket.assigns.template, template_params) do
{:ok, template} ->
notify_parent({:saved, template})
{:noreply,
socket
|> put_flash(:info, gettext("Online store template updated successfully"))
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_template(socket, :new, template_params) do
case OnlineStoreTemplates.create_template(template_params) do
{:ok, template} ->
notify_parent({:saved, template})
{:noreply,
socket
|> put_flash(:info, gettext("Online store template created successfully"))
|> push_patch(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
@@ -0,0 +1,63 @@
defmodule MusicLibraryWeb.OnlineStoreTemplateLive.Index do
use MusicLibraryWeb, :live_view
alias MusicLibrary.OnlineStoreTemplates
alias MusicLibrary.OnlineStoreTemplates.OnlineStoreTemplate
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:current_section, :scrobble_rules)
|> stream(:templates, OnlineStoreTemplates.list_templates())}
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}) do
socket
|> assign(:page_title, "Edit Online Store Template")
|> assign(:template, OnlineStoreTemplates.get_template!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Online Store Template")
|> assign(:template, %OnlineStoreTemplate{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Online Store Templates")
|> assign(:template, nil)
end
@impl true
def handle_info(
{MusicLibraryWeb.OnlineStoreTemplateLive.FormComponent, {:saved, template}},
socket
) do
{:noreply, stream_insert(socket, :templates, template)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
template = OnlineStoreTemplates.get_template!(id)
{:ok, _} = OnlineStoreTemplates.delete_template(template)
{:noreply, stream_delete(socket, :templates, template)}
end
@impl true
def handle_event("toggle-enabled", %{"id" => id}, socket) do
template = OnlineStoreTemplates.get_template!(id)
{:ok, updated_template} =
OnlineStoreTemplates.update_template(template, %{enabled: !template.enabled})
{:noreply, stream_insert(socket, :templates, updated_template)}
end
end
@@ -0,0 +1,102 @@
<div class="px-4 py-6 sm:px-6 lg:px-8">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-gray-900 dark:text-white">
{gettext("Online Store Templates")}
</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
{gettext("Manage templates for generating links to online record stores.")}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<.button as="link" patch={~p"/online-store-templates/new"}>
{gettext("Add Template")}
</.button>
</div>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<ul
role="list"
class="divide-y divide-gray-200 dark:divide-gray-800"
phx-update="stream"
id="templates"
>
<li
:for={{id, template} <- @streams.templates}
id={id}
class="flex items-center justify-between gap-x-6 py-5"
>
<div class="min-w-0">
<div class="flex items-start gap-x-3">
<p class="text-sm font-semibold text-gray-900 dark:text-white">
{template.name}
</p>
<.badge :if={template.enabled} color="green">
{gettext("Enabled")}
</.badge>
<.badge :if={!template.enabled} color="yellow">
{gettext("Disabled")}
</.badge>
</div>
<div class="mt-1 flex items-center gap-x-2 text-xs text-gray-500 dark:text-gray-400">
<p class="truncate">{template.url_template}</p>
</div>
<p :if={template.description} class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{template.description}
</p>
</div>
<div class="flex flex-none items-center gap-x-4">
<.button
phx-click="toggle-enabled"
phx-value-id={template.id}
color={if template.enabled, do: "orange", else: "green"}
variant="ghost"
size="sm"
>
{if template.enabled, do: "Disable", else: "Enable"}
</.button>
<.button
as="link"
patch={~p"/online-store-templates/#{template}/edit"}
variant="ghost"
size="sm"
>
{gettext("Edit")}
</.button>
<.button
as="link"
phx-click={JS.push("delete", value: %{id: template.id})}
data-confirm="Are you sure?"
color="red"
variant="ghost"
size="sm"
>
{gettext("Delete")}
</.button>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<.modal
:if={@live_action in [:new, :edit]}
class="min-w-sm md:min-w-2xl max-w-3xl"
id="template-modal"
open
on_close={JS.patch(~p"/online-store-templates")}
>
<.live_component
module={MusicLibraryWeb.OnlineStoreTemplateLive.FormComponent}
id={@template.id || :new}
title={@page_title}
action={@live_action}
template={@template}
patch={~p"/online-store-templates"}
/>
</.modal>
@@ -10,6 +10,7 @@ defmodule MusicLibraryWeb.WishlistLive.Show do
record_colors: 1
]
alias MusicLibrary.OnlineStoreTemplates
alias MusicLibrary.Records
@impl true
@@ -29,11 +30,13 @@ defmodule MusicLibraryWeb.WishlistLive.Show do
@impl true
def handle_params(%{"id" => id}, _, socket) do
record = Records.get_record!(id)
online_store_templates = OnlineStoreTemplates.list_enabled_templates()
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action, record))
|> assign(:record, record)}
|> assign(:record, record)
|> assign(:online_store_templates, online_store_templates)}
end
@impl true
@@ -271,6 +271,36 @@
</div>
</div>
<div :if={@online_store_templates != []} class="mt-8">
<details class="px-4 text-zinc-700 hover:text-zinc-500 dark:text-zinc-400 dark:hover:text-zinc-300">
<summary class="text-sm font-medium cursor-pointer">
{gettext("Check Online Stores")}
</summary>
<div class="mt-4 space-y-2">
<div
:for={template <- @online_store_templates}
class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{template.name}
</span>
<.button
as="link"
href={OnlineStoreTemplates.generate_url(template, @record)}
target="_blank"
rel="noopener noreferrer"
variant="ghost"
size="sm"
class="ml-2"
>
<.icon name="hero-arrow-top-right-on-square" class="h-4 w-4" />
{gettext("Search")}
</.button>
</div>
</div>
</details>
</div>
<.json_viewer title={gettext("MusicBrainz data")} data={@record.musicbrainz_data} />
<.modal
+4
View File
@@ -65,6 +65,10 @@ defmodule MusicLibraryWeb.Router do
live "/scrobble-rules", ScrobbleRulesLive.Index, :index
live "/scrobble-rules/new", ScrobbleRulesLive.Index, :new
live "/scrobble-rules/:id/edit", ScrobbleRulesLive.Index, :edit
live "/online-store-templates", OnlineStoreTemplateLive.Index, :index
live "/online-store-templates/new", OnlineStoreTemplateLive.Index, :new
live "/online-store-templates/:id/edit", OnlineStoreTemplateLive.Index, :edit
end
end
end