diff --git a/docs/plans/online_stores.md b/docs/plans/online_stores.md
new file mode 100644
index 00000000..a9019c59
--- /dev/null
+++ b/docs/plans/online_stores.md
@@ -0,0 +1,546 @@
+# Online Store Quick Check System - Implementation Plan
+
+## Overview
+
+This plan outlines the implementation of a system to quickly check for the presence of wishlisted records on configurable online record stores. The system will add an expandable section to record details pages with links to online stores, generated from user-configurable templates.
+
+## Requirements Analysis
+
+### Core Features
+
+- **Expandable section on record details**: Collapsed by default, shows links to online stores
+- **Configurable store templates**: User-managed templates stored in database
+- **Template variables**: Support for record artist and title as search parameters
+- **User management interface**: Dedicated section for managing store templates
+
+### Example Use Case
+
+Create a template for Amazon UK search: `https://www.amazon.co.uk/s?k={artist}+{title}+vinyl`
+When viewing a record by "Pink Floyd" with title "The Wall", generate: `https://www.amazon.co.uk/s?k=Pink+Floyd+The+Wall+vinyl`
+
+## Database Schema Design
+
+### Online Store Templates Table
+
+Following the application's established patterns:
+
+```elixir
+# Migration: create_online_store_templates.exs
+defmodule MusicLibrary.Repo.Migrations.CreateOnlineStoreTemplates do
+ use Ecto.Migration
+
+ def change do
+ create table(:online_store_templates, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :description, :text
+ add :url_template, :text, null: false
+ add :enabled, :boolean, default: true, null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:online_store_templates, [:enabled])
+ end
+end
+```
+
+### Schema Module
+
+```elixir
+# lib/music_library/online_store_templates/online_store_template.ex
+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
+```
+
+### Context Module
+
+```elixir
+# lib/music_library/online_store_templates.ex
+defmodule MusicLibrary.OnlineStoreTemplates do
+ @moduledoc """
+ The OnlineStoreTemplates context.
+ """
+
+ import Ecto.Query, warn: false
+ alias MusicLibrary.Repo
+ alias MusicLibrary.OnlineStoreTemplates.OnlineStoreTemplate
+
+ @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 = record.artists |> Enum.map(& &1.name) |> Enum.join(" ")
+
+ template.url_template
+ |> String.replace("{artist}", URI.encode(artists_string))
+ |> String.replace("{title}", URI.encode(record.title))
+ end
+end
+```
+
+## User Interface Design
+
+### Management Interface
+
+Following the Scrobble Rules pattern for consistency:
+
+#### Route Addition
+
+```elixir
+# lib/music_library_web/router.ex
+# Add to the authenticated scope:
+live "/online-store-templates", OnlineStoreTemplateLive.Index, :index
+live "/online-store-templates/new", OnlineStoreTemplateLive.Index, :new
+live "/online-store-templates/:id/edit", OnlineStoreTemplateLive.Index, :edit
+```
+
+#### Navigation Integration
+
+```elixir
+# lib/music_library_web/components/layouts/app.html.heex
+# Add to the "More" dropdown menu:
+<.dropdown_link href={~p"/online-store-templates"}>
+ Online Store Templates
+
+```
+
+#### LiveView Index Page
+
+```elixir
+# lib/music_library_web/live/online_store_template_live/index.ex
+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, stream(socket, :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
+```
+
+#### Template Design
+
+```heex
+
+
+
+
+
+ Online Store Templates
+
+
+ Manage templates for generating links to online record stores.
+
+
+
+ <.button patch={~p"/online-store-templates/new"}>
+ Add Template
+
+
+
+
+
+
+
+
+ -
+
+
+
+ <%= template.name %>
+
+ <.badge :if={template.enabled} color="success" size="sm">
+ Enabled
+
+ <.badge :if={!template.enabled} color="warning" size="sm">
+ Disabled
+
+
+
+
<%= template.url_template %>
+
+
+ <%= template.description %>
+
+
+
+ <.button
+ phx-click="toggle-enabled"
+ phx-value-id={template.id}
+ color={if template.enabled, do: "warning", else: "success"}
+ variant="ghost"
+ size="sm"
+ >
+ <%= if template.enabled, do: "Disable", else: "Enable" %>
+
+ <.button
+ patch={~p"/online-store-templates/#{template}/edit"}
+ variant="ghost"
+ size="sm"
+ >
+ Edit
+
+ <.button
+ phx-click={JS.push("delete", value: %{id: template.id})}
+ data-confirm="Are you sure?"
+ color="danger"
+ variant="ghost"
+ size="sm"
+ >
+ Delete
+
+
+
+
+
+
+
+
+
+<.modal :if={@live_action in [:new, :edit]} id="template-modal" show on_cancel={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"}
+ />
+
+```
+
+### Record Details Integration
+
+Add to the wishlist show page template:
+
+```heex
+
+
+
+
+
+
+ Check Online Stores
+
+
+
+
+ <%= template.name %>
+
+ <.button
+ 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" />
+ Search
+
+
+
+
+
+```
+
+### LiveView Updates
+
+Update the wishlist show LiveView to load templates:
+
+```elixir
+# lib/music_library_web/live/wishlist_live/show.ex
+# Add to the mount function:
+online_store_templates = MusicLibrary.OnlineStoreTemplates.list_enabled_templates()
+
+socket =
+ socket
+ |> assign(:record, record)
+ |> assign(:online_store_templates, online_store_templates)
+ |> assign(:page_title, record.title)
+```
+
+## Implementation Steps
+
+### Phase 1: Database Foundation
+
+1. Create migration for `online_store_templates` table
+2. Create schema module with validation
+3. Create context module with CRUD operations
+4. Run migration and test basic functionality
+
+### Phase 2: Management Interface
+
+1. Create LiveView modules for template management
+2. Create form component for template editing
+3. Add routes to router
+4. Add navigation link to main layout
+5. Style templates using existing design patterns
+
+### Phase 3: Record Details Integration
+
+1. Update wishlist show LiveView to load templates
+2. Add expandable section to show template
+3. Implement URL generation logic
+4. Test template variable replacement
+
+### Phase 4: Testing & Refinement
+
+1. Add comprehensive tests for context functions
+2. Add LiveView tests for management interface
+3. Test URL generation with various record data
+4. Validate template functionality end-to-end
+
+## Security Considerations
+
+### URL Template Validation
+
+- Validate URL templates contain only HTTP/HTTPS schemes
+- Sanitize user input in templates
+- Encode record data properly when generating URLs
+
+### Access Control
+
+- Templates management requires authentication
+- Template visibility follows existing permission patterns
+
+## Testing Strategy
+
+### Unit Tests
+
+- Context module functions (CRUD operations)
+- URL generation with various record data
+- Template validation logic
+
+### Integration Tests
+
+- LiveView template management workflow
+- Template display on record details pages
+- URL generation end-to-end
+
+### Example Test Cases
+
+```elixir
+defmodule MusicLibrary.OnlineStoreTemplatesTest do
+ use MusicLibrary.DataCase
+
+ alias MusicLibrary.OnlineStoreTemplates
+
+ describe "generate_url/2" do
+ test "replaces artist and title variables correctly" do
+ template = %OnlineStoreTemplate{
+ url_template: "https://example.com/search?q={artist}+{title}"
+ }
+
+ record = %Record{
+ title: "Dark Side of the Moon",
+ artists: [%Artist{name: "Pink Floyd"}]
+ }
+
+ assert OnlineStoreTemplates.generate_url(template, record) ==
+ "https://example.com/search?q=Pink+Floyd+Dark+Side+of+the+Moon"
+ end
+ end
+end
+```
+
+## Database Migration Commands
+
+```bash
+# Create migration
+mix ecto.gen.migration create_online_store_templates
+
+# Run migration
+mix ecto.migrate
+
+# Rollback if needed
+mix ecto.rollback
+```
+
+## Default Template Examples
+
+Consider seeding the database with common store templates:
+
+```elixir
+# priv/repo/seeds.exs additions
+alias MusicLibrary.OnlineStoreTemplates
+
+templates = [
+ %{
+ name: "Amazon UK",
+ description: "Search Amazon UK for vinyl records",
+ url_template: "https://www.amazon.co.uk/s?k={artist}+{title}+vinyl&i=popular"
+ },
+ %{
+ name: "Discogs",
+ description: "Search Discogs marketplace",
+ url_template: "https://www.discogs.com/search/?q={artist}+{title}&type=all"
+ },
+ %{
+ name: "eBay",
+ description: "Search eBay for records",
+ url_template: "https://www.ebay.co.uk/sch/i.html?_nkw={artist}+{title}+vinyl"
+ }
+]
+
+Enum.each(templates, &OnlineStoreTemplates.create_template/1)
+```
+
+## Conclusion
+
+This implementation provides a clean, maintainable system for checking online stores that:
+
+- Follows existing application patterns and conventions
+- Provides a user-friendly management interface
+- Integrates seamlessly with the existing record details pages
+- Supports extensibility for future enhancements
+- Maintains security and validation standards
+
+The system is designed to be conservative, focusing on core functionality while following the established UI and database patterns throughout the application.
+
diff --git a/lib/music_library/online_store_templates.ex b/lib/music_library/online_store_templates.ex
new file mode 100644
index 00000000..2fdc0d10
--- /dev/null
+++ b/lib/music_library/online_store_templates.ex
@@ -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
diff --git a/lib/music_library/online_store_templates/online_store_template.ex b/lib/music_library/online_store_templates/online_store_template.ex
new file mode 100644
index 00000000..45864fe6
--- /dev/null
+++ b/lib/music_library/online_store_templates/online_store_template.ex
@@ -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
diff --git a/lib/music_library_web/components/layouts/app.html.heex b/lib/music_library_web/components/layouts/app.html.heex
index 9e7e7589..adfc8bc6 100644
--- a/lib/music_library_web/components/layouts/app.html.heex
+++ b/lib/music_library_web/components/layouts/app.html.heex
@@ -84,6 +84,15 @@
/>
{gettext("Scrobble Rules")}
+ <.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 href={~p"/backup"}>
<.icon
name="hero-archive-box-arrow-down"
diff --git a/lib/music_library_web/live/online_store_template_live/form_component.ex b/lib/music_library_web/live/online_store_template_live/form_component.ex
new file mode 100644
index 00000000..e9d39ac7
--- /dev/null
+++ b/lib/music_library_web/live/online_store_template_live/form_component.ex
@@ -0,0 +1,125 @@
+defmodule MusicLibraryWeb.OnlineStoreTemplateLive.FormComponent do
+ use MusicLibraryWeb, :live_component
+
+ alias MusicLibrary.OnlineStoreTemplates
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+ <.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")} />
+
+
+
+ {gettext("Available Variables")}
+
+
+
+ {"{artist}"}
+ - {gettext("Artist name(s)")}
+
+
+ {"{title}"}
+ - {gettext("Record title")}
+
+
+
+
+ <:actions>
+ <.button phx-disable-with={gettext("Saving...")}>
+ {gettext("Save Template")}
+
+
+
+
+ """
+ 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
diff --git a/lib/music_library_web/live/online_store_template_live/index.ex b/lib/music_library_web/live/online_store_template_live/index.ex
new file mode 100644
index 00000000..4ae777db
--- /dev/null
+++ b/lib/music_library_web/live/online_store_template_live/index.ex
@@ -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
diff --git a/lib/music_library_web/live/online_store_template_live/index.html.heex b/lib/music_library_web/live/online_store_template_live/index.html.heex
new file mode 100644
index 00000000..fbb4aa13
--- /dev/null
+++ b/lib/music_library_web/live/online_store_template_live/index.html.heex
@@ -0,0 +1,102 @@
+
+
+
+
+ {gettext("Online Store Templates")}
+
+
+ {gettext("Manage templates for generating links to online record stores.")}
+
+
+
+ <.button as="link" patch={~p"/online-store-templates/new"}>
+ {gettext("Add Template")}
+
+
+
+
+
+
+
+
+ -
+
+
+
+ {template.name}
+
+ <.badge :if={template.enabled} color="green">
+ {gettext("Enabled")}
+
+ <.badge :if={!template.enabled} color="yellow">
+ {gettext("Disabled")}
+
+
+
+
{template.url_template}
+
+
+ {template.description}
+
+
+
+ <.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
+ as="link"
+ patch={~p"/online-store-templates/#{template}/edit"}
+ variant="ghost"
+ size="sm"
+ >
+ {gettext("Edit")}
+
+ <.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")}
+
+
+
+
+
+
+
+
+
+<.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"}
+ />
+
diff --git a/lib/music_library_web/live/wishlist_live/show.ex b/lib/music_library_web/live/wishlist_live/show.ex
index c70c9fa9..90702dca 100644
--- a/lib/music_library_web/live/wishlist_live/show.ex
+++ b/lib/music_library_web/live/wishlist_live/show.ex
@@ -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
diff --git a/lib/music_library_web/live/wishlist_live/show.html.heex b/lib/music_library_web/live/wishlist_live/show.html.heex
index abb25eb2..30da7991 100644
--- a/lib/music_library_web/live/wishlist_live/show.html.heex
+++ b/lib/music_library_web/live/wishlist_live/show.html.heex
@@ -271,6 +271,36 @@
+
+
+
+ {gettext("Check Online Stores")}
+
+
+
+
+ {template.name}
+
+ <.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")}
+
+
+
+
+
+
<.json_viewer title={gettext("MusicBrainz data")} data={@record.musicbrainz_data} />
<.modal
diff --git a/lib/music_library_web/router.ex b/lib/music_library_web/router.ex
index 31c8be44..81923151 100644
--- a/lib/music_library_web/router.ex
+++ b/lib/music_library_web/router.ex
@@ -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
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 786b2808..5f5a1d4d 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -34,6 +34,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/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
#, elixir-autogen, elixir-format
@@ -45,6 +46,7 @@ msgstr ""
#: lib/music_library_web/live/artist_live/show.html.heex
#: 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/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.ex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -163,12 +165,14 @@ msgstr ""
#: lib/music_library_web/components/form_component.ex
#: lib/music_library_web/live/artist_live/form_component.ex
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
#: lib/music_library_web/components/record_components.ex
+#: lib/music_library_web/live/wishlist_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Search"
msgstr ""
@@ -989,6 +993,7 @@ msgstr ""
msgid "Artist Name"
msgstr ""
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Description (optional)"
@@ -1096,11 +1101,13 @@ msgstr ""
msgid "Enable rule"
msgstr ""
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.ex
#, elixir-autogen, elixir-format
msgid "Disabled"
msgstr ""
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.ex
#, elixir-autogen, elixir-format
msgid "Enabled"
@@ -1177,3 +1184,84 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Wishlisted release"
msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Add a description to help identify this template"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Artist name(s)"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Available Variables"
+msgstr ""
+
+#: lib/music_library_web/live/wishlist_live/show.html.heex
+#, elixir-autogen, elixir-format
+msgid "Check Online Stores"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Enable this template"
+msgstr ""
+
+#: lib/music_library_web/components/layouts/app.html.heex
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Online Store Templates"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Online store template created successfully"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Online store template updated successfully"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Record title"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Save Template"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Template Name"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "URL Template"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "e.g. Amazon UK"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "e.g. https://www.amazon.co.uk/s?k={artist}+{title}+vinyl"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Add Template"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Manage templates for generating links to online record stores."
+msgstr ""
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index b2de0895..1bb903f8 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -34,6 +34,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/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.html.heex
#, elixir-autogen, elixir-format
@@ -45,6 +46,7 @@ msgstr ""
#: lib/music_library_web/live/artist_live/show.html.heex
#: 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/scrobble_rules_live/index.html.heex
#: lib/music_library_web/live/wishlist_live/show.ex
#: lib/music_library_web/live/wishlist_live/show.html.heex
@@ -163,12 +165,14 @@ msgstr ""
#: lib/music_library_web/components/form_component.ex
#: lib/music_library_web/live/artist_live/form_component.ex
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Saving..."
msgstr ""
#: lib/music_library_web/components/record_components.ex
+#: lib/music_library_web/live/wishlist_live/show.html.heex
#, elixir-autogen, elixir-format
msgid "Search"
msgstr ""
@@ -989,6 +993,7 @@ msgstr ""
msgid "Artist Name"
msgstr ""
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
#: lib/music_library_web/live/scrobble_rules_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Description (optional)"
@@ -1096,11 +1101,13 @@ msgstr ""
msgid "Enable rule"
msgstr ""
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Disabled"
msgstr ""
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
#: lib/music_library_web/live/scrobble_rules_live/index.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Enabled"
@@ -1177,3 +1184,84 @@ msgstr ""
#, elixir-autogen, elixir-format, fuzzy
msgid "Wishlisted release"
msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Add a description to help identify this template"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Artist name(s)"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Available Variables"
+msgstr ""
+
+#: lib/music_library_web/live/wishlist_live/show.html.heex
+#, elixir-autogen, elixir-format
+msgid "Check Online Stores"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Enable this template"
+msgstr ""
+
+#: lib/music_library_web/components/layouts/app.html.heex
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Online Store Templates"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Online store template created successfully"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Online store template updated successfully"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Record title"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Save Template"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "Template Name"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "URL Template"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "e.g. Amazon UK"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/form_component.ex
+#, elixir-autogen, elixir-format
+msgid "e.g. https://www.amazon.co.uk/s?k={artist}+{title}+vinyl"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#, elixir-autogen, elixir-format, fuzzy
+msgid "Add Template"
+msgstr ""
+
+#: lib/music_library_web/live/online_store_template_live/index.html.heex
+#, elixir-autogen, elixir-format
+msgid "Manage templates for generating links to online record stores."
+msgstr ""
diff --git a/priv/repo/migrations/20250715082338_create_online_store_templates.exs b/priv/repo/migrations/20250715082338_create_online_store_templates.exs
new file mode 100644
index 00000000..ddbbf0f9
--- /dev/null
+++ b/priv/repo/migrations/20250715082338_create_online_store_templates.exs
@@ -0,0 +1,17 @@
+defmodule MusicLibrary.Repo.Migrations.CreateOnlineStoreTemplates do
+ use Ecto.Migration
+
+ def change do
+ create table(:online_store_templates, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :description, :text
+ add :url_template, :text, null: false
+ add :enabled, :boolean, default: true, null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:online_store_templates, [:enabled])
+ end
+end