• Время чтения ~8 мин
  • 28.07.2023

Вчера команда Laravel выпустила Laravel Folio - мощный страничный маршрутизатор, предназначенный для упрощения маршрутизации в приложениях Laravel. Сегодня они выпустили Volt - элегантно созданный функциональный API для Livewire, позволяющий логике PHP компонента и шаблонам Blade сосуществовать в одном файле с уменьшенным шаблоном.

Хотя их можно использовать по отдельности, я думаю, что их совместное использование — это новый, невероятно продуктивный способ создания приложений Laravel.

В этой статье я научу вас, как создать простое приложение, которое перечисляет эпизоды подкаста Laravel News и позволяет пользователям воспроизводить их, с помощью проигрывателя, который может беспрепятственно продолжать воспроизведение при загрузке страницы.

Настройка Livewire, Volt и Folio Для начала нам нужно создать новое приложение Laravel и установить Livewire, Volt, Folio

и Sushi (чтобы сделать некоторые фиктивные данные).

laravel new
composer require livewire/livewire:^3.0@beta livewire/volt:^1.0@beta laravel/folio:^1.0@beta calebporzio/sushi

Livewire v3, Volt и Folio все еще находятся в стадии бета-тестирования. Они должны быть довольно стабильными, но используйте их на свой страх и риск.

После того, как нам потребуются пакеты, нам нужно запустить php artisan volt:install и php artisan folio:install. Это приведет к удалению некоторых папок и поставщиков услуг, необходимых Volt и Folio.

Модель Episode

Для фиктивных данных я собираюсь создать модель суши. Sushi — это пакет, написанный Калебом Поцио, который позволяет создавать модели Eloquent, которые запрашивают свои данные из массива, записанного непосредственно в файле модели. Это отлично работает, когда вы создаете примеры приложений или у вас есть данные, которые не нужно менять очень часто.

Создайте модель, затем удалите HasFactory признак и замените его чертойSushi. Я добавил подробную информацию о 4 последних эпизодах подкаста Laravel News в качестве данных для этого примера.

Я не буду вдаваться в подробности о том, как все это работает, так как это не является целью статьи, и вы, вероятно, будете использовать настоящую модель Eloquent, если создадите свой собственный проигрыватель подкастов.

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Sushi\Sushi;

class Episode extends Model
{
    use Sushi;

    protected $casts = [
        'released_at' => 'datetime',
    ];
    protected $rows = [
        [
            'number' => 195,
            'title' => 'Queries, GPT, and sinking downloads',
            'notes' => '...',
            'audio' => 'https://media.transistor.fm/c28ad926/93e5fe7d.mp3',
            'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
            'duration_in_seconds' => 2579,
            'released_at' => '2023-07-06 10:00:00',
        ],
        [
            'number' => 194,
            'title' => 'Squeezing lemons, punching cards, and bellowing forges',
            'notes' => '...',
            'audio' => 'https://media.transistor.fm/6d2d53fe/f70d9278.mp3',
            'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
            'duration_in_seconds' => 2219,
            'released_at' => '2023-06-21 10:00:00',
        ],
        [
            'number' => 193,
            'title' => 'Precognition, faking Stripe, and debugging Blade',
            'notes' => '...',
            'audio' => 'https://media.transistor.fm/d434305e/975fbb28.mp3',
            'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
            'duration_in_seconds' => 2146,
            'released_at' => '2023-06-06 10:00:00',
        ],
        [
            'number' => 192,
            'title' => 'High octane, sleepy code, and Aaron Francis',
            'notes' => '...',
            'audio' => 'https://media.transistor.fm/b5f81577/c58c90c8.mp3',
            'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
            'duration_in_seconds' => 1865,
            'released_at' => '2023-05-24 10:00:00',
        ],
        // ...
    ];
}

Вид

макета Нам понадобится файл макета, чтобы загрузить Tailwind, добавить логотип и добавить некоторые базовые стили. Поскольку Livewire и Alpine теперь автоматически вставляют свои скрипты и стили, нам даже не нужно загружать их в макет! Мы создадим макет как анонимный компонент Blade в resources/views/components/layout.blade.php.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Laravel News Podcast Player</title>
        <script src="https://cdn.tailwindcss.com?plugins=typography"></script>
    </head>
    <body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
        <div class="mx-auto max-w-2xl px-6 py-24">
            <a
                href="/episodes"
                class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
            >
                <img
                    src="/images/logo.svg"
                    alt="Laravel News"
                    class="mx-auto w-12"
                />
                <span>Laravel News Podcast</span>
            </a>

            <div class="py-10">{{ $slot }}</div>
        </div>
    </body>
</html>

Страница

со списком эпизодов Во-первых, нам нужна страница для отображения всех выпусков подкаста.

Используя Folio, мы можем легко создать новую страницу в каталогеresources/views/pages, и Laravel автоматически создаст маршрут для этой страницы. Мы хотим, чтобы наш маршрут был/episodes, чтобы мы могли бежатьphp artisan make:folio episodes/index. Это создаст пустое представление в resources/views/pages/episodes/index.blade.php.

На этой странице мы вставим компонент макета, а затем прокрутим все эпизоды подкаста. Volt предоставляет функции пространства имен для большинства функций Livewire. Здесь мы откроем обычные <?php ?> теги открытия и закрытия. Внутри них мы будем использовать computed функцию для создания переменной$episodes, которая выполняет запрос, чтобы получить все модели эпизодов ($episodes = computed(fn () => Episode::get());). Мы можем получить доступ к вычисляемому свойству в шаблоне с помощью $this->episodes.

Я также создал переменную$formatDuration, которая является функцией для форматирования свойства каждого эпизода duration_in_seconds в читаемый формат. Мы можем вызвать эту функцию в шаблоне с помощью $this->formatDuration($episode->duration_in_seconds).

Нам также нужно обернуть динамическую функциональность на странице в директиву@volt, чтобы зарегистрировать ее как «анонимный компонент Livewire» на странице Folio.

<?php

use App\Models\Episode;
use Illuminate\Support\Stringable;
use function Livewire\Volt\computed;
use function Livewire\Volt\state;

$episodes = computed(fn () => Episode::get());

$formatDuration = function ($seconds) { ...
    return str(date('G\h i\m s\s', $seconds))
        ->trim('0h ')
        ->explode(' ')
        ->mapInto(Stringable::class)
        ->each->ltrim('0')
        ->join(' ');
}; 
?>

<x-layout>
    @volt
        <div class="rounded-xl border border-gray-200 bg-white shadow">
            <ul class="divide-y divide-gray-100">
                @foreach ($this->episodes as $episode)
                    <li
                        wire:key="{{ $episode->number }}"
                        class="flex flex-col items-start gap-x-6 gap-y-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
                    >
                        <div>
                            <h2>
                                No. {{ $episode->number }} - {{ $episode->title }}
                            </h2>
                            <div
                                class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500"
                            >
                                <p>
                                    Released:
                                    {{ $episode->released_at->format('M j, Y') }}
                                </p>
                                &middot;
                                <p>
                                    Duration:
                                    {{ $this->formatDuration($episode->duration_in_seconds) }}
                                </p>
                            </div>
                        </div>
                        <button
                            type="button"
                            class="flex shrink-0 items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"
                        >
                            <img
                                src="/images/play.svg"
                                alt="Play"
                                class="h-8 w-8 transition hover:opacity-60"
                            />
                            <span>Play</span>
                        </button>
                    </li>
                @endforeach
            </ul>
        </div>
    @endvolt
</x-layout>

Игрок

эпизода Оттуда нам нужно добавить некоторую интерактивность. Я хочу добавить проигрыватель эпизодов, чтобы мы могли прослушивать эпизоды из списка эпизодов. Это может быть обычный компонент Blade, который мы визуализируем в файле макета.

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Laravel News Podcast Player</title>
        <script src="https://cdn.tailwindcss.com?plugins=typography"></script>
    </head>
    <body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
        <div class="mx-auto max-w-2xl px-6 py-24">
            <a
                href="/episodes"
                class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
            >
                <img
                    src="/images/logo.svg"
                    alt="Laravel News"
                    class="mx-auto w-12"
                />
                <span>Laravel News Podcast</span>
            </a>

            <div class="py-10">{{ $slot }}</div>

            <x-episode-player />
        </div>
    </body>
</html>

Мы можем создать этот компонент, resources/views/components/episode-player.blade.php добавив файл. Внутри компонента мы добавим <audio> элемент с некоторым альпийским кодом для хранения активного эпизода и функцию, которая обновляет активный эпизод и запускает звук. Мы покажем игроку только в том случае, если установлен активный эпизод, и добавим в оболочку приятный переход затухания.

<div
    x-data="{
        activeEpisode: null,
        play(episode) {
            this.activeEpisode = episode

            this.$nextTick(() => {
                this.$refs.audio.play()
            })
        },
    }"
    x-show="activeEpisode"
    x-transition.opacity.duration.500ms
    class="fixed inset-x-0 bottom-0 w-full border-t border-gray-200 bg-white"
    style="display: none"
>
    <div class="mx-auto max-w-xl p-6">
        <h3
            x-text="`Playing: No. ${activeEpisode?.number} - ${activeEpisode?.title}`"
            class="text-center text-sm font-medium text-gray-600"
        ></h3>
        <audio
            x-ref="audio"
            class="mx-auto mt-3"
            :src="activeEpisode?.audio"
            controls
        ></audio>
    </div>
</div>

Если мы перезагрузим страницу, мы не увидим никаких изменений. Это потому, что мы не добавили способ воспроизведения эпизодов. Мы будем использовать события для связи между нашими компонентами Livewire и игроком. Сначала в плеере добавим x-on:play-episode.window="play($event.detail)" прослушивать play-episode событие на окно, затем вызовем play функцию.

<div
    x-data="{
        activeEpisode: null,
        play(episode) {
            this.activeEpisode = episode

            this.$nextTick(() => {
                this.$refs.audio.play()
            })
        },
    }"
    x-on:play-episode.window="play($event.detail)"
    ...
>
    <!-- ... -->
</div>

Затем, вернувшись на episodes/index страницу, мы добавим клик-слушатель на кнопки воспроизведения для каждого эпизода. Кнопки отправят play-episode событие, которое будет получено игроком эпизода и обработано там.

<button
    x-data
    x-on:click="$dispatch('play-episode', @js($episode))"
    ...
>
    <img
        src="/images/play.svg"
        alt="Play"
        class="h-8 w-8 transition hover:opacity-60"
    />
    <span>Play</span>
</button>

Страница

сведений об эпизоде Затем я хотел бы добавить страницу сведений об эпизоде, чтобы отобразить заметки о шоу и другие детали каждого эпизода.

В Folio есть несколько довольно интересных соглашений для привязки модели маршрута в именах файлов. Чтобы создать эквивалентный маршрут для /episodes/{episode:id}, создайте страницу по адресу resources/views/pages/episodes/[Episode].blade.php. Чтобы использовать параметр маршрута, отличный от первичного ключа, можно использовать синтаксис [Model:some_other_key].blade.php имени файла. Я хочу использовать номер эпизода в URL-адресе, поэтому мы создадим файл в resources/views/pages/episodes/[Episode:number].blade.php.

Folio автоматически запрашивает модели эпизодов для эпизода с номером, который мы передаем в URL-адресе, и делает его доступным в качестве переменной $episode в нашем <?php ?> коде. Затем мы можем преобразовать это в свойство Livewire, используя функцию Voltstate.

Мы также включим кнопку воспроизведения на этой странице, чтобы пользователи могли воспроизводить эпизод, просматривая его подробности.

<?php
use Illuminate\Support\Stringable;
use function Livewire\Volt\state;

state(['episode' => fn () => $episode]);

$formatDuration = function ($seconds) { ...
    return str(date('G\h i\m s\s', $seconds))
        ->trim('0h ')
        ->explode(' ')
        ->mapInto(Stringable::class)
        ->each->ltrim('0')
        ->join(' ');
}; 
?>

<x-layout>
    @volt
        <div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow">
            <div class="p-6">
                <div class="flex items-center justify-between gap-8">
                    <div>
                        <h2 class="text-xl font-medium">
                            No. {{ $episode->number }} -
                            {{ $episode->title }}
                        </h2>
                        <div
                            class="mt-1 flex items-center gap-3 text-sm text-gray-500"
                        >
                            <p>
                                Released:
                                {{ $episode->released_at->format('M j, Y') }}
                            </p>
                            &middot;
                            <p>
                                Duration:
                                {{ $this->formatDuration($episode->duration_in_seconds) }}
                            </p>
                        </div>
                    </div>

                    <button
                        x-on:click="$dispatch('play-episode', @js($episode))"
                        type="button"
                        class="flex items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"
                    >
                        <img
                            src="/images/play.svg"
                            alt="Play"
                            class="h-8 w-8 transition hover:opacity-60"
                        />
                        <span>Play</span>
                    </button>
                </div>
                <div class="prose prose-sm mt-4">
                    {!! $episode->notes !!}
                </div>
            </div>
            <div class="bg-gray-50 px-6 py-4">
                <a
                    href="/episodes"
                    class="text-sm font-medium text-gray-600"
                >
                    &larr; Back to episodes
                </a>
            </div>
        </div>
    @endvolt
</x-layout>

Теперь нам нужно перейти на страницу сведений с индексной страницы. Вернемся на episodes/index страницу, давайте обернем каждый эпизод <h2> в тег привязки.

@foreach ($this->episodes as $episode)
    <li
        wire:key="{{ $episode->number }}"
        class="flex flex-col items-start gap-x-6 gap-y-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
    >
        <div>
            <a
                href="/episodes/{{ $episode->number }}"
                class="transition hover:text-[#FF2D20]"
            >
                <h2>
                    No. {{ $episode->number }} -
                    {{ $episode->title }}
                </h2>
            </a>
        </div>
        {{-- ... --}}
    </li>
@endforeach

SPA-режим

Мы почти у цели. Приложение выглядит довольно хорошо и хорошо функционирует, но есть одна проблема. Если пользователь прослушивает эпизод и переходит на другую страницу, проигрыватель эпизода теряет свое активное состояние эпизода и исчезает.

К счастью, у Livewire есть директива wire:navigate @persist, чтобы помочь с этими проблемами сейчас!

В нашем файле макета давайте обернем логотип и проигрыватель эпизодов в @persist блоки. Livewire обнаружит это и пропустит повторный рендеринг этих блоков при смене страниц.

<!DOCTYPE html>
<html lang="en">
    ...
    <body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
        <div class="mx-auto max-w-2xl px-6 py-24">
            @persist('logo')
                <a
                    href="/episodes"
                    class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
                >
                    <img
                        src="/images/logo.svg"
                        alt="Laravel News"
                        class="mx-auto w-12"
                    />
                    <span>Laravel News Podcast</span>
                </a>
            @endpersist

            <div class="py-10">{{ $slot }}</div>

            @persist('player')
                <x-episode-player />
            @endpersist
        </div>
    </body>
</html>

Наконец, нам нужно добавить wire:navigate атрибут ко всем ссылкам через приложение. Например:

<a
    href="/episodes/{{ $episode->number }}"
    class="transition hover:text-[#FF2D20]"
    wire:navigate
>
    <h2>
        No. {{ $episode->number }} -
        {{ $episode->title }}
    </h2>
</a>

Когда вы используете wire:navigate атрибут за кулисами, Livewire извлечет содержимое новой страницы с помощью AJAX, а затем волшебным образом заменит содержимое в вашем браузере без полной перезагрузки страницы. Это делает загрузку страниц невероятно быстрой и позволяет работать таким функциям, как сохранение! Он позволяет использовать функции, которые раньше можно было реализовать, только создав SPA.

Заключение

Это было действительно забавное демонстрационное приложение, которое можно было создать во время изучения Volt и Folio. Я загрузил сюда демонстрационное приложение и @bosunski создал песочницу php, если вы хотите увидеть полный исходный код или попробовать сами!

Как ты думаешь? Является ли Livewire v3 + Volt + Folio простейшим стеком для создания приложений Laravel сейчас? Я думаю, что это действительно круто и может показаться более знакомым людям, которые привыкли создавать приложения на фреймворках JavaScript, таких как Next.js и Nuxt.js. Также приятно иметь весь ваш код для страницы в одном файле - стили (через Tailwind), JS (через Alpine) и бэкенд-код. Присылайте мне свои мысли в Twitter!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Об авторе CrazyBoy49z
WORK EXPERIENCE
Контакты
Ukraine, Lutsk
+380979856297