Appearance
Shop: Detail Section
REMARKS
This is Part 2 of the Shop implementation.
- Part 1: Displaying records with pagination (previous chapter).
- Part 2: Showing record details (tracks) in a modal when a record is clicked (this chapter).
- Part 3: Adding filtering and sorting functionality (next chapter).
In the previous chapter, we successfully displayed a paginated list of vinyl records. Now, we'll enhance the user experience by allowing them to view more details about a specific record. When a user clicks the "Show tracks" icon on a record card, a modal window will appear, displaying the tracklist for that album.
Interestingly, the track information isn't stored directly in our local records
database table. Instead, we'll fetch this data dynamically from an external source: the MusicBrainz API. Each record in our database has a mb_id
(MusicBrainz ID) attribute, which serves as a unique identifier for the album on MusicBrainz.
For example, the album Rumours by Fleetwood Mac has the mb_id
value 081ea37e-db59-4332-8cd2-ad020cb93af6
. We can use this ID to construct an API request URL like this:
https://musicbrainz.org/ws/2/release/081ea37e-db59-4332-8cd2-ad020cb93af6?inc=recordings&fmt=json
The JSON response from this URL contains detailed information about the release, including the tracklist. We will parse this response to extract the track position, title, and length, and then present this information neatly within our modal.
Triggering the Detail View
To initiate the process of showing the track details, we first need to capture the user's click action on the appropriate button and identify which record they are interested in. We'll use Livewire's wire:click
directive for this.
Pass the Record ID to Component Methods
We'll attach wire:click
handlers to both the "Show tracks" and "Add to basket" icons within our record card loop. This directive allows us to call methods directly within our Shop
Livewire component class when the corresponding element is clicked. We will pass the unique id
of the record as an argument to these methods.
For now, the addToBasket
method will simply display a JavaScript alert informing the user that the basket functionality hasn't been implemented yet. The showTracks
method will be responsible for fetching the data for the clicked record and preparing it for display.
Initially, let's set up the showTracks
method to receive the record's id
and store it in a new public property within our component class called $selectedRecord
. We'll also add the basic addToBasket
method.
- resources/views/livewire/shop.blade.php:
- Locate the
<flux:button>
elements for "Add to basket" and "Show tracks". - Add the
wire:click
directive to both buttons. - For the "Add to basket" button, set
wire:click="addToBasket({{ $record->id }})"
. - For the "Show tracks" button, set
wire:click="showTracks({{ $record->id }})"
.
- Locate the
- app/Livewire/Shop.php:
- Add a new public property:
public $selectedRecord;
. This will hold the data of the record the user clicks on. - Define a new public method:
public function showTracks($record)
. Inside this method, assign the received$record
(which is just the ID at this point) to the$selectedRecord
property:$this->selectedRecord = $record;
. - Define another new public method:
public function addToBasket($record)
. Inside this method, use Livewire's$this->js()
helper to execute JavaScript:$this->js("alert('Basket not implemented yet')");
.
- Add a new public property:
After making these changes, if you click the "Show tracks" button and inspect the Livewire state using your browser's developer tools or our <x-itf.livewire-log/>
component, you'll see that the $selectedRecord
property now holds the id
of the record you clicked. Clicking the "Add to basket" button will trigger the JavaScript alert.
php
<div class="flex space-x-2">
@if($record->stock > 0)
<flux:button
wire:click="addToBasket({{ $record->id }})"
icon="shopping-bag" tooltip="Add to basket" variant="subtle"
class="cursor-pointer border border-zinc-200 dark:border-zinc-700"/>
@else
<flux:button
icon="shopping-bag" tooltip="Out of stock" variant="subtle"
class="cursor-pointer border border-zinc-200 dark:border-zinc-700 !text-red-200 dark:!text-red-700/75"/>
@endif
<flux:button {{-- Corrected typo: flux:button --}}
wire:click="showTracks({{ $record->id }})"
icon="musical-note" tooltip="Show tracks" variant="subtle"
class="cursor-pointer border border-zinc-200 dark:border-zinc-700"/>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Using Route Model Binding for Convenience
While passing the ID works, it means that inside our showTracks
method, we would typically need to perform another database query to fetch the full Record
object using that ID (Record::find($recordId)
). Laravel and Livewire offer a more elegant solution called Route Model Binding.
By type-hinting the method parameter with the Eloquent model class (Record
), Livewire automatically queries the database and injects the corresponding model instance directly into the method. This eliminates the need for manual fetching.
- app/Livewire/Shop.php:
- Modify the signature of the
showTracks
method: changepublic function showTracks($record)
topublic function showTracks(Record $record)
. - Modify the signature of the
addToBasket
method: changepublic function addToBasket($record)
topublic function addToBasket(Record $record)
.
- Modify the signature of the
Now, when you click "Show tracks", the $record
parameter inside the showTracks
method will be the fully loaded Record
model instance, not just its ID. Inspecting the $selectedRecord
property in the debug log will confirm this – you'll see all the attributes of the selected record object.
php
class Shop extends Component
{
use WithPagination;
public $perPage = 6;
public $selectedRecord;
public function showTracks(Record $record) // Type-hint the parameter with the Record model
{
$this->selectedRecord = $record;
}
public function addToBasket(Record $record) // Type-hint the parameter here as well
{
$this->js("alert('{$record->title} - Basket not implemented yet')"); // Example: Use record data
}
public function render()
{
$records = Record::orderBy('artist')
->orderBy('title')
->paginate($this->perPage);
return view('livewire.shop', compact('records'));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Showing Tracks in a Modal
With the complete $selectedRecord
object readily available after a click, our next step is to display its details, particularly the tracks, in a pop-up modal window. We'll use the Modal component from the Flux UI library we integrated earlier.
Add the Modal Structure
First, we need to define the HTML structure for our modal within the shop.blade.php
view. This modal will initially be hidden and will only appear when triggered by our showTracks
method. Inside the modal, we'll display the record's cover image, artist, and title, followed by a table to list the tracks. We use the name
attribute on the flux:modal
component (name="tracksModal"
) so we can specifically target and control this modal from our component class. We also use the null coalescing operator (??
) and provide default values (like the placeholder cover image path or empty strings) to prevent errors when the page initially loads before any $selectedRecord
has been set.
- resources/views/livewire/shop.blade.php:
- Find the comment
{{-- Detail Modal will go here --}}
. - Add the
<flux:modal>
component below it. Give it aname="tracksModal"
and optionally set a width using Tailwind classes (e.g.,class="w-[500px]"
). - Inside the modal, add structure to display the cover (
$selectedRecord->cover ?? ...
), artist ($selectedRecord->artist ?? ''
), and title ($selectedRecord->title ?? ''
). - Add an
@isset($selectedRecord->tracks)
block. Inside this block, add the<x-ift.table>
component with<thead>
(for headers: #, Track, Length) and<tbody>
. - Inside the
<tbody>
, use an@foreach($selectedRecord->tracks as $track)
loop to iterate over the tracks (once we fetch them). Create table rows (<tr>
) and cells (<td>
) to display$track['position']
,$track['title']
, and$track['length']
.
- Find the comment
Open the Modal from the Component
Now that the modal structure exists in the view, we need to tell it to open when the showTracks
method is executed. We can do this using the Flux
facade provided by the Flux UI package. The Flux::modal('modalName')->show()
method targets the modal with the specified name and makes it visible.
- app/Livewire/Shop.php:
- Import the
Flux
facade at the top:use Flux;
. - Inside the
showTracks(Record $record)
method, after assigning the record to$this->selectedRecord
, add the line:Flux::modal('tracksModal')->show();
.
- Import the
Now, clicking the "Show tracks" button will not only load the record data into $selectedRecord
but also open the modal. Initially, the track table will be empty because we haven't fetched the tracks yet.
php
{{-- Detail Modal will go here --}}
<flux:modal name="tracksModal" class="w-[500px]">
<div class="flex items-top border-b border-zinc-300 pb-2 gap-4">
<img class="size-24"
src="{{ $selectedRecord->cover ?? asset('storage/covers/no-cover.png') }}" alt="">
<div>
<flux:heading size="lg">{{ $selectedRecord->artist ?? '' }}</flux:heading>
<flux:subheading>{{ $selectedRecord->title ?? '' }}</flux:subheading>
</div>
</div>
@isset($selectedRecord->tracks)
<x-itf.table cols="w-8, w-auto, w-24">
<thead>
<tr>
<th>#</th>
<th>Track</th>
<th>Length</th>
</tr>
</thead>
<tbody>
@foreach($selectedRecord->tracks as $track)
<tr class="border-t border-zinc-100">
<td>{{ $track['position'] }}</td>
<td>{{ $track['title'] }}</td>
<td>{{ $track['length'] }}</td>
</tr>
@endforeach
</tbody>
</x-itf.table>
@endif
</flux:modal>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Fetching Tracks from the MusicBrainz API
The core of the detail view is fetching the actual tracklist. We'll use Laravel's built-in HTTP Client, which provides a convenient wrapper around Guzzle or PHP's stream functions for making HTTP requests.
Inside the showTracks
method, before showing the modal, we will:
- Construct the API URL using the
$record->mb_id
property. - Use
Http::get($url)
to send a GET request to the MusicBrainz API endpoint. - Call
->json()
on the response object to automatically decode the JSON response into a PHP array. - Navigate through the resulting array structure to extract the list of tracks. Based on the MusicBrainz JSON structure, this is typically found under
$response['media'][0]['tracks']
. - Store this extracted
$tracks
array as a new, dynamic property on our$selectedRecord
object. We can simply assign it like:$this->selectedRecord->tracks = $tracks;
. This makes the$tracks
data available within the modal's Blade template via$selectedRecord->tracks
.
- app/Livewire/Shop.php:
- Import the HTTP facade at the top:
use Http;
. - Inside the
showTracks
method, before theFlux::modal('tracksModal')->show();
line:- Define the
$url
using the record'smb_id
. - Make the HTTP GET request and decode the JSON response:
$response = Http::get($url)->json();
. - Extract the tracks array:
$tracks = $response['media'][0]['tracks'] ?? [];
(using?? []
as a fallback in case the structure is unexpected). - Assign the extracted tracks to the selected record:
$this->selectedRecord->tracks = $tracks;
.
- Define the
- Import the HTTP facade at the top:
Now, when the modal opens, the @isset($selectedRecord->tracks)
condition in the view will be true, and the @foreach
loop will iterate over the fetched tracks, displaying their position and title. The length will still be shown as raw milliseconds.
$response['media'][0]['tracks']
contains the tracklist.
Formatting the Track Length with Carbon
The final refinement is to format the track length, which the MusicBrainz API provides in milliseconds, into a more human-readable minutes:seconds
format (e.g., 03:45
). While we could write a custom function for this conversion, Laravel ships with the powerful Carbon date and time manipulation library, which makes this task straightforward.
We'll use Carbon's createFromTimestampMs()
method to create a Carbon instance from the millisecond value, and then use the format()
method with the format string 'i:s'
(where i
represents minutes with leading zeros and s
represents seconds with leading zeros).
To apply this formatting, we need to loop through the $tracks
array after fetching it from the API but before assigning it to$this->selectedRecord->tracks
. Inside the loop, we'll overwrite the original length
value in milliseconds with the newly formatted mm:ss
string.
- app/Livewire/Shop.php:
- Import the Carbon class at the top:
use Carbon\Carbon;
. - Inside the
showTracks
method, locate the line where$tracks
is extracted ($tracks = $response['media'][0]['tracks'] ?? [];
). - After that line, but before
$this->selectedRecord->tracks = $tracks;
, insert aforeach
loop to iterate through the$tracks
array by reference (using&$track
) or by key.- Inside the loop, re-assign the
length
key:$tracks[$key]['length'] = Carbon::createFromTimestampMs($track['length'] ?? 0)->format('i:s');
.
We use?? 0
to handle cases where length might be missing or null in the API response.
- Inside the loop, re-assign the
- Import the Carbon class at the top:
With this change, the modal will now display the track lengths in the desired mm:ss
format.
php
<?php
namespace App\Livewire;
use App\Models\Record;
use Carbon\Carbon;
use Flux;
use Http;
use Livewire\Component;
use Livewire\WithPagination;
class Shop extends Component
{
use WithPagination;
public $perPage = 6;
public $selectedRecord;
public function showTracks(Record $record)
{
$this->selectedRecord = $record;
$url = "https://musicbrainz.org/ws/2/release/{$record->mb_id}?inc=recordings&fmt=json";
try {
$response = Http::timeout(10)->get($url)->json(); // Added timeout
$tracks = $response['media'][0]['tracks'] ?? []; // Get the tracks from the response, provide default empty array if not found
foreach ($tracks as $key => $track) { // Convert milliseconds to mm:ss format using Carbon
$milliseconds = $track['length'] ?? 0; // Use null coalescing for safety if length is missing
$tracks[$key]['length'] = Carbon::createFromTimestampMs($milliseconds)->format('i:s');
}
$this->selectedRecord->tracks = $tracks; // Add the tracks to $selectedRecord
} catch (\Exception $e) {
report($e); // Handle exceptions (e.g., timeout, network error, invalid JSON)
$this->selectedRecord->tracks = []; // Set tracks to empty on error
}
// Show the modal with the name attribute "tracksModal"
Flux::modal('tracksModal')->show();
}
public function addToBasket(Record $record) { /* ... */ }
public function render() { /* ... */ }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Now, when users click the "Show tracks" button, they are presented with a clean modal displaying the album details and a properly formatted tracklist fetched dynamically from MusicBrainz.