Appearance
Admin: covers (part 1)
PODCAST
- In this chapter, we can edit the covers of a particular record
- Delete the cover
- Upload a new cover
- Reset the cover by downloading it from the MusicBrainz API
- In the next chapter, we list all the redundant covers in our storage and delete them
WARNING
Even though you can perfectly separate the two functionalities on two different pages, we will keep them on the same page to demonstrate how route-parameters works and how to use those parameters in the Livewire component
Preparation
Create a Covers component
- Create a new Livewire component with the terminal command
php artisan livewire:make Admin/Covers
- app/Livewire/Admin/Covers.php (the component class)
- resources/views/livewire/admin/covers.blade.php (the component view)
- Open the component class and change the layout to
layouts.vinylshop
php
<?php
namespace App\Livewire\Admin;
use Livewire\Attributes\Layout;
use Livewire\Component;
class Covers extends Component
{
#[Layout('layouts.vinylshop', ['title' => 'Manage album covers', 'description' => 'Manage album covers',])]
public function render()
{
return view('livewire.admin.covers');
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Add a new route
- Add a new get-route for the Covers class to the routes/web.php file
- Update the navigation menu in resources/views/components/layouts/nav.blade.php
php
Route::middleware(['auth', Admin::class, ActiveUser::class])->prefix('admin')->name('admin.')->group(function () {
Route::redirect('/', '/admin/records');
Route::get('genres', Genres::class)->name('genres');
Route::get('records', Records::class)->name('records');
Route::get('users/basic', UsersBasic::class)->name('users.basic');
Route::get('users/advanced', UsersAdvanced::class)->name('users.advanced');
Route::get('users/expert', UsersExpert::class)->name('users.expert');
Route::get('covers/{id?}', Covers::class)->name('covers');
});
...
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
ROUTE-PARAMETER
- In our route, we have a variable
id
that acts as a route-parameter{id?}
(see: route-parameters) - The
?
means that the parameter is optional - This means that the route can be called with or without a parameter, e.g.:
http://vinyl_shop.test/admin/covers/12
(with parameter)http://vinyl_shop.test/admin/covers
(without parameter)
- You can name the parameter whatever you want, but keep in mind that you have to use the same name in the Livewire component otherwise the parameter will not be passed
Basic scaffolding for the view
- Open the resources/views/livewire/admin/covers.blade.php file
- Replace the content of file with the following code:
php
<div>
@if($record)
<h2>Edit cover</h2>
<div
x-data
class="size-60 flex flex-col gap-4">
<img class="border border-gray-300 object-cover rounded shadow-lg"
src="/storage/covers/no-cover.png"
alt="">
<div class="border border-gray-500 flex text-center [&>a]:flex-1 [&>a]:bg-gray-300 [&>a]:p-2 [&>a]:transition">
<a href="#"
class="hover:text-white hover:bg-sky-800 border-r border-gray-500">EDIT</a>
<a href="#"
class="hover:text-white hover:bg-green-800 border-r border-gray-500">RESET</a>
<a href="#"
class="hover:text-white hover:bg-red-800">DELETE</a>
</div>
</div>
{{-- Upload cover modal --}}
<x-dialog-modal wire:model.live="showModal">
<x-slot name="title">
<h2 class="text-lg font-bold">Edit cover</h2>
</x-slot>
<x-slot name="content">
<div class="flex flex-col gap-4">
<div class="flex gap-4">
<img class="size-36 border border-gray-300 object-cover" id="coverPreview"
src="/storage/covers/no-cover.png"
alt="">
<div class="flex-1 py-2">
<p class="text-lg font-bold">...</p>
<p class="text-sm">....</p>
<input type="file" id="cover"
wire:model.live="newCover"
wire:loading.attr="disabled"
wire:target="newCover"
accept="image/*"
class="mt-4 file:border-0 file:text-white file:bg-sky-800 file:p-2 file:rounded file:cursor-pointer">
<x-input-error for="newCover" class="mt-2"/>
<p class="hidden w-full italic text-sky-700 pt-4" wire:loading wire:target="newCover">
Processing image...
</p>
</div>
</div>
</div>
</x-slot>
<x-slot name="footer">
<x-secondary-button wire:click="$toggle('showModal')" wire:loading.attr="disabled">
Cancel
</x-secondary-button>
@if($newCover)
<x-button class="ml-2"
wire:loading.attr="disabled">Save
</x-button>
@endif
</x-slot>
</x-dialog-modal>
@else
<h2>Redundant covers</h2>
@endif
</div>
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
TIPS
- Don't worry about deleting some covers that belong to a record. You can always get them back from the URL http://vinyl_shop.test/admin/download_covers (See: chapter Record covers)
- Refresh the database with the command
php artisan migrate:fresh --seed
- (Optional) add one or more records without a cover to the database, e.g
mb_id
:a36348b3-1950-49fe-b895-49f586afc895
(Daan - Le Franc Belge)ff6284f3-d5f3-4a13-b839-d64a468aa430
(Lidia Lunch - Queen of Siam)58dcd354-a89a-48ea-9e6e-e258cb23e11d
(Ramones - End of the Century)794c6bf2-3241-416f-9b8f-24e2d84a1c4b
(The Stooges - Fun House)
Add a link on the records page
- Open the file
resources/views/livewire/admin/records.blade.php
- Line 8: change the width of the last column from
w-24
tow-28
- Find the last
td
tag with the buttons and add a new button with thex-phosphor-image
icon- Line 14: change the gird from 2 columns
grid-cols-2
to 3 columnsgrid-cols-3
- Line 20 - 23: add a link to the
Covers
component with themb_id
of the record (route('admin.covers', $record->id)
)
- Line 14: change the gird from 2 columns
php
<table class="text-center w-full border border-gray-300">
<colgroup>
<col class="w-14">
<col class="w-20">
<col class="w-20">
<col class="w-14">
<col class="w-max">
<col class="w-32">
</colgroup>
<thead>...</thead>
<tbody>
...
<td>
<div class="border border-gray-300 rounded-md overflow-hidden m-2 grid grid-cols-3 h-10">
<button
wire:click="editRecord({{ $record->id }})"
class="text-gray-400 hover:text-sky-100 hover:bg-sky-500 transition border-r border-gray-300">
<x-phosphor-pencil-line-duotone class="inline-block w-5 h-5"/>
</button>
<a href="{{ route('admin.covers', $record->id) }}"
class="text-gray-400 hover:text-lime-100 hover:bg-lime-500 transition border-r border-gray-300 py-2">
<x-phosphor-image class="inline-block w-5 h-5"/>
</a>
<button
wire:click="modalDelete({{ $record->id }})"
class="text-gray-400 hover:text-red-100 hover:bg-red-500 transition">
<x-phosphor-trash-duotone class="inline-block w-5 h-5"/>
</button>
</div>
</td>
</tbody>
</table>
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
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
Get the selected record
- The
mount()
method of theCovers
component has a parameter$id
with a default value ofnull
- If the parameter is not
null
, themount()
method will get the record with the given id from the database - If the parameter is
null
, themount()
method don't do anything for now (see part 2)
- If the parameter is not
php
public function mount($id = null)
{
if ($id) {
// get the selected record if id is not null
$this->record = Record::findOrFail($id);
} else {
// get all the redundant covers from the disk
}
}
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
mount()
vs render()
- The
mount()
method is one of the lifecycle hooks of a Livewire component - The
mount()
method called when the component is initialized and will be executed before therender()
method - In the
mount()
method acts as a constructor, and you can use it to initialize some default values for your properties
find()
vs findOrFail()
- To select one single record from the database, you can use the
findOrFail()
method or thefind()
method - If you try to select a record that doesn't exist:
- the
findOrFail()
method will throw a 404 exception - the
find()
method will returnnull
- the
- Go to the URL http://vinyl_shop.test/admin/covers/999 and look at different results with
find()
andfindOrFail()
Upload a new cover
Open the modal
- Line 7: replace the dummy cover
/storage/covers/no-cover.png
with the cover of the selected record$record->cover
- Line 12: add a
wire:click
attribute to theEDIT
link and set theshowModal
property totrue
Theprevent
modifier will prevent the default behaviour of the anchor tag (i.e. redirecting to the URL in thehref
attribute) - Line 30: replace the dummy cover in the modal with the cover of the selected record
- Line 33 - 34: add the record title and the artist to the modal
- Line 50: the
wire:click
attribute will call thesaveCover()
method and store the new cover to the disk
(The save button will only be visible in the next step when thenewCover
property contains a value)
php
@if($record)
<h2>Edit cover</h2>
<div
x-data
class="w-60 h-60 flex flex-col gap-4">
<img class="border border-gray-300 object-cover rounded shadow-lg"
src="{{ $record->cover }}"
alt="">
<div
class="border border-gray-500 flex text-center [&>a]:flex-1 [&>a]:bg-gray-300 [&>a]:p-2 [&>a]:transition">
<a href="#"
wire:click.prevent="$set('showModal', true)"
class="hover:text-white hover:bg-sky-800 border-r border-gray-500">EDIT</a>
<a href="#"
class="hover:text-white hover:bg-green-800 border-r border-gray-500">RESET</a>
<a href="#"
class="hover:text-white hover:bg-red-800">DELETE</a>
</div>
</div>
{{-- Upload cover modal --}}
<x-dialog-modal wire:model.live="showModal">
<x-slot name="title">
<h2 class="text-lg font-bold">Edit cover</h2>
</x-slot>
<x-slot name="content">
<div class="flex flex-col gap-4">
<div class="flex gap-4">
<img class="size-36 border border-gray-300 object-cover" id="coverPreview"
src="{{ $record->cover }}"
alt="">
<div class="flex-1 py-2">
<p class="text-lg font-bold">{{ $record->title }}</p>
<p class="text-sm">{{ $record->artist }}</p>
<input type="file" id="cover" ...>
<x-input-error for="newCover" class="mt-2"/>
<p class="hidden w-full italic text-sky-700 pt-4" wire:loading wire:target="newCover">
Processing image...
</p>
</div>
</div>
</div>
</x-slot>
<x-slot name="footer">
<x-secondary-button wire:click="$toggle('showModal')" wire:loading.attr="disabled">
Cancel
</x-secondary-button>
@if($newCover)
<x-button class="ml-2"
wire:click="saveCover()"
wire:loading.attr="disabled">Save
</x-button>
@endif
</x-slot>
</x-dialog-modal>
@else
<h1 class="text-3xl mb-4">Redundant covers</h1>
@endif
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
47
48
49
50
51
52
53
54
55
56
57
58
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
47
48
49
50
51
52
53
54
55
56
57
58
Upload a new cover
- Livewire makes it super easy to upload files (not only images) and even shows a preview of the file
- First add the
WithFileUploads
trait to theCovers
component - Now you can use the
wire:model
attribute to bind thenewCover
property to theinput
element with thetype="file"
attribute - The uploaded file has a random name and will be stored in the
storage/app/livewire-temp
directory - You can now access the temporary file with
$newCover->temporaryUrl()
- Line 12: add the
WithFileUploads
trait to theCovers
component
php
<?php
namespace App\Livewire\Admin;
use App\Models\Record;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
class Covers extends Component
{
use WithFileUploads;
public $showModal = false;
public $record = null;
public $newCover;
public $redundantCovers = [];
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
WARNING
- Every time you upload a new file, a new temporary file will be created and stored in the
storage/app/livewire-temp
directory - In the screenshot below you can see that I temporarily uploaded 4 files, and they are all stored on the server, in the
storage/app/livewire-temp
directory - Livewire will automatically delete the temporary files after 24 hours, but you can also delete them manually
Save the cover
- The SAVE button will only be visible if the
newCover
property contains a value
- When the SAVE button is clicked, the
saveCover()
method will be called and:- Line 19: add validation rules for the
newCover
property - Line 29: validate the
newCover
property - Line 30: create a new
Image
object from thenewCover
property, resize it to 250x250 pixels and encode it to ajpg
file with a quality of 75% - Line 31: call the
saveToDisk()
method to save the cover to the disk - Line 33 - 36: delete all temporary files from the
livewire-tmp
directory (optional)
- Line 19: add validation rules for the
- The
saveToDisk()
method will;- Line 51: set the path where the cover will be saved
- Line 52: save the cover to the disk
- Line 53: reset the
newCover
andshowModal
properties
php
<?php
namespace App\Livewire\Admin;
use App\Models\Record;
use Intervention\Image\Laravel\Facades\Image;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Livewire\WithFileUploads;
use Storage;
class Covers extends Component
{
use WithFileUploads;
public $showModal = false;
public $record = null;
#[Validate('required|image|mimes:jpg,jpeg,png,webp|max:1024')]
public $newCover;
public $redundantCovers = [];
// open modal to upload a new cover
public function openModal() { ... }
// get the uploaded cover and save it to the disk
public function saveCover()
{
$this->validateOnly('newCover');
$cover = Image::read($this->newCover->path())->cover(250, 250)->toJpeg(75);
$this->saveToDisk($cover);
// delete all temporary files from the livewire-tmp directory (optional)
$files = Storage::disk('local')->files('livewire-tmp');
foreach ($files as $file) {
Storage::disk('local')->delete($file);
}
}
// try to get the original cover from the coverartarchive.org
public function getOriginalCover() { ... }
// ask for confirmation to delete the cover
public function deleteCover() { ... }
// delete the cover from the disk
public function deleteConfirmed() { ... }
// save the cover to the disk
public function saveToDisk($cover)
{
$coverName = 'covers/' . $this->record->mb_id . '.jpg';
Storage::disk('public')->put($coverName, $cover, 'public');
$this->reset('newCover', 'showModal');
}
public function mount($id = null) { ... }
#[Layout('layouts.vinylshop', ['title' => 'Manage album covers', 'description' => 'Manage album covers',])]
public function render()
{
return view('livewire.admin.covers');
}
}
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
IMPORTANT
- If the old cover is still displayed, you can try to clear the browser cache with the
CTRL
+SHIFT
+R
key
Avoid browser caching
- When you upload a new cover, the browser will cache the image and probably won't show the new cover because it thinks it's already in his cache (the name of the file is not changed, only the content)
- To avoid this, you can add a random string to the end of the image url, eg: the current timestamp
- Update the
src
attribute of theimg
tag in thelivewire/admin/covers.blade.php
file- from:
$record->cover
- to:
$record->cover['url'] . '?' . time()
- from:
php
<div
x-data
class="w-60 h-60 flex flex-col gap-4">
<img class="border border-gray-300 object-cover rounded shadow-lg"
src="{{ $record->cover . '?v=' . time() }}"
alt="">
<div
class="border border-gray-500 flex text-center [&>a]:flex-1 [&>a]:bg-gray-300 [&>a]:p-2 [&>a]:transition">
<a href="#"
wire:click.prevent="$set('showModal', true)"
class="hover:text-white hover:bg-sky-800 border-r border-gray-500">EDIT</a>
<a href="#"
class="hover:text-white hover:bg-green-800 border-r border-gray-500">RESET</a>
<a href="#"
class="hover:text-white hover:bg-red-800">DELETE</a>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Delete the cover
- When the DELETE button is clicked, the
deleteCover()
method will be called after a confirmation message
- Line 12: call the
deleteCover()
method when the DELETE button is clicked
php
<div
wire:loading.remove
wire:target="getOriginalCover"
class="border border-gray-500 flex text-center [&>a]:flex-1 [&>a]:bg-gray-300 [&>a]:p-2 [&>a]:transition">
<a href="#"
wire:click.prevent="$set('showModal', true)"
class="hover:text-white hover:bg-sky-800 border-r border-gray-500">EDIT</a>
<a href="#"
wire:click.prevent="getOriginalCover()"
class="hover:text-white hover:bg-green-800 border-r border-gray-500">RESET</a>
<a href="#"
wire:click="deleteCover()"
class="hover:text-white hover:bg-red-800">DELETE</a>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
Reset the cover
- When the RESET button is clicked, the
getOriginalCover()
method will be called - This method will try to get the original cover from the coverartarchive.org
- Line 13: add the
getOriginalCover()
method to the RESET button
php
<div
x-data
class="w-60 h-60 flex flex-col gap-4">
<img class="border border-gray-300 object-cover rounded shadow-lg"
src="{{ $record->cover . '?v=' . time()}}"
alt="">
<div
class="border border-gray-500 flex text-center [&>a]:flex-1 [&>a]:bg-gray-300 [&>a]:p-2 [&>a]:transition">
<a href="#"
wire:click.prevent="$set('showModal', true)"
class="hover:text-white hover:bg-sky-800 border-r border-gray-500">EDIT</a>
<a href="#"
wire:click.prevent="getOriginalCover()"
class="hover:text-white hover:bg-green-800 border-r border-gray-500">RESET</a>
<a href="#"
class="hover:text-white hover:bg-red-800">DELETE</a>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Add a preloading text
- Fetching data from an external API can take a while, so it's a good idea to show a preloading text
- Line 9 - 10: hide the buttons while the
getOriginalCover()
method is running - Line 24 - 28 add a preloading text (the text is only visible while the
getOriginalCover()
method is running)
php
<h2>Edit cover</h2>
<div
x-data
class="w-60 h-60 flex flex-col gap-4">
<img class="border border-gray-300 object-cover rounded shadow-lg"
src="{{ $record->cover . '?v=' . time()}}"
alt="">
<div
wire:loading.remove
wire:target="getOriginalCover"
class="border border-gray-500 flex text-center [&>a]:flex-1 [&>a]:bg-gray-300 [&>a]:p-2 [&>a]:transition">
<a href="#"
wire:click.prevent="$set('showModal', true)"
class="hover:text-white hover:bg-sky-800 border-r border-gray-500">EDIT</a>
<a href="#"
wire:click.prevent="getOriginalCover()"
class="hover:text-white hover:bg-green-800 border-r border-gray-500">RESET</a>
<a href="#"
class="hover:text-white hover:bg-red-800">DELETE</a>
</div>
</div>
{{-- Preloading text --}}
<div wire:loading wire:target="getOriginalCover" class="hidden">
<x-tmk.preloader class="bg-slate-600 text-white italic">
Try to fetch original cover from the Cover Art Archive website
</x-tmk.preloader>
</div>
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
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