Add first version of online stores for wishlisted records (claude)
See docs/plans/online_stores.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user