• Czas czytania ~14 min
  • 28.07.2023

Wczoraj zespół Laravel wydał Laravel Folio - potężny router stronicowy zaprojektowany w celu uproszczenia routingu w aplikacjach Laravel. Dzisiaj wydali Volt - elegancko wykonane funkcjonalne API dla Livewire, umożliwiające współistnienie logiki PHP komponentu i szablonów Blade w tym samym pliku przy zmniejszonej liczbie wzorców.

Chociaż mogą być używane osobno, myślę, że używanie ich razem jest nowym, niezwykle produktywnym sposobem tworzenia aplikacji Laravel.

W tym artykule nauczę Cię, jak zbudować prostą aplikację, która wyświetla odcinki podcastu Laravel News i pozwala użytkownikom je odtwarzać, za pomocą odtwarzacza, który może płynnie kontynuować odtwarzanie na wszystkich załadunkach stron.

Konfiguracja Livewire, Volt i Folio Aby rozpocząć, musimy utworzyć nową aplikację Laravel i zainstalować Livewire, Volt, Folio

i Sushi (aby utworzyć fałszywe dane).

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

Livewire v3, Volt i Folio są nadal w wersji beta. Powinny być dość stabilne, ale używaj ich na własne ryzyko.

Po wymaganiu pakietów musimy uruchomić php artisan volt:install i php artisan folio:install. Spowoduje to rusztowanie niektórych folderów i dostawców usług, których potrzebują Volt i Folio.

Model Episode W przypadku danych atrapy stworzę model sushi. Sushi to pakiet napisany przez Caleba Pozio, który pozwala tworzyć modele Eloquent, które wysyłają zapytania do swoich danych z tablicy zapisanej bezpośrednio w pliku modelu. Działa to znakomicie, gdy tworzysz przykładowe aplikacje lub masz dane, które nie muszą się często zmieniać.

Utwórz model, a następnie usuń cechę HasFactory i zastąp ją cechąSushi. Dodałem szczegóły 4 najnowszych odcinków Laravel News Podcast jako dane dla tego przykładu.

Nie będę wchodził w szczegóły, jak to wszystko działa, ponieważ nie o to chodzi w artykule, i prawdopodobnie użyjesz prawdziwego modelu eloquent, jeśli miałbyś zbudować własny odtwarzacz podcastów.

<?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',
        ],
        // ...
    ];
}

Widok

układu Będziemy potrzebować pliku układu, aby załadować Tailwind, dodać logo i dodać podstawowe stylizacje. Ponieważ Livewire i Alpine automatycznie wstrzykują teraz swoje skrypty i style, nie musimy nawet ładować ich w układzie! Utworzymy układ jako anonimowy komponent Blade w .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>

Strona

z listą odcinków Po pierwsze, potrzebujemy strony, aby wyświetlić wszystkie odcinki podcastu.

Korzystając z Folio, możemy łatwo utworzyć nową stronę w resources/views/pages katalogu, a Laravel automatycznie utworzy trasę dla tej strony. Chcemy, aby nasza trasa była /episodes, abyśmy mogli biecphp artisan make:folio episodes/index. Spowoduje to utworzenie pustego widoku w formacie resources/views/pages/episodes/index.blade.php.

Na tej stronie wstawimy komponent układu, a następnie zapętlimy wszystkie odcinki podcastu. Volt zapewnia funkcje namespaced dla większości funkcji Livewire. Tutaj otworzymy zwykłe <?php ?> tagi otwierania i zamykania. Wewnątrz nich użyjemy computed funkcji do utworzenia zmiennej$episodes, która uruchamia zapytanie w celu uzyskania wszystkich modeli odcinków ($episodes = computed(fn () => Episode::get());). Możemy uzyskać dostęp do obliczonej właściwości w szablonie za pomocą $this->episodes.

Stworzyłem $formatDuration również zmienną, która jest funkcją formatowania właściwości każdego odcinka duration_in_seconds do czytelnego formatu. Możemy wywołać tę funkcję w szablonie za pomocą $this->formatDuration($episode->duration_in_seconds).

Musimy również owinąć dynamiczną funkcjonalność na stronie w dyrektywie@volt, aby zarejestrować ją jako "anonimowy komponent Livewire" na stronie 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>

Odtwarzacz

odcinków Stamtąd musimy dodać trochę interaktywności. Chcę dodać odtwarzacz odcinków, abyśmy mogli słuchać odcinków z listy odcinków. Może to być zwykły komponent Blade, który renderujemy w pliku układu.

<!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>

Możemy utworzyć ten komponent, dodając resources/views/components/episode-player.blade.php plik. Wewnątrz komponentu dodamy <audio> element z kodem alpejskim do przechowywania aktywnego odcinka oraz funkcję, która aktualizuje aktywny odcinek i uruchamia dźwięk. Pokażemy graczowi tylko wtedy, gdy aktywny odcinek jest ustawiony, a do opakowania dodamy ładne przejście zanikania.

<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>

Jeśli ponownie załadujemy stronę, nie zobaczymy żadnych zmian. To dlatego, że nie dodaliśmy sposobu odtwarzania odcinków. Będziemy używać zdarzeń do komunikowania się z naszymi komponentami Livewire do gracza. Najpierw w odtwarzaczu dodamyx-on:play-episode.window="play($event.detail)", aby nasłuchiwać play-episode zdarzenia w oknie, a następnie wywołać play funkcję.

<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>

Następnie na stronie dodamy słuchacza kliknięć na episodes/index przyciskach odtwarzania dla każdego odcinka. Przyciski wyślą play-episode zdarzenie, które zostanie odebrane przez gracza odcinka i tam obsłużone.

<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>

Strona

szczegółów odcinka Następnie chcę dodać stronę szczegółów odcinka, aby wyświetlić notatki z każdego odcinka i inne szczegóły.

Folio ma kilka całkiem fajnych konwencji wiązania modelu trasy w nazwach plików. Aby utworzyć równoważną trasę dla /episodes/{episode:id}, utwórz stronę w formacie resources/views/pages/episodes/[Episode].blade.php. Aby użyć parametru route innego niż klucz podstawowy, można użyć składni [Model:some_other_key].blade.php w nazwie pliku. Chcę użyć numeru odcinka w adresie URL, więc utworzymy plik pod adresem resources/views/pages/episodes/[Episode:number].blade.php.

Folio automatycznie odpyta modele odcinków o numer, który przekazujemy w adresie URL i udostępni jako zmienną $episode w naszym <?php ?> kodzie. Następnie możemy przekonwertować to na właściwość Livewire za pomocą funkcji Voltastate.

Na tej stronie umieścimy również przycisk odtwarzania, aby użytkownicy mogli odtwarzać odcinek, wyświetlając jego szczegóły.

<?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>

Teraz musimy połączyć się ze stroną szczegółów ze strony indeksu. Wróćmy na episodes/index stronę, zawińmy każdy odcinek <h2> w znacznik kotwicy.

@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

Tryb

SPA Jesteśmy prawie na miejscu. Aplikacja wygląda całkiem nieźle i działa dobrze, ale jest jeden problem. Jeśli użytkownik słucha odcinka i przejdzie na inną stronę, odtwarzacz odcinków utraci aktywny stan odcinka i zniknie.

Na szczęście Livewire ma teraz dyrektywę wire:navigate @persist, która pomoże w rozwiązaniu tych problemów!

W naszym pliku układu zawińmy logo i odtwarzacz odcinków w @persist bloki. Livewire wykryje to i pominie ponowne renderowanie tych bloków, gdy zmienimy strony.

<!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>

Na koniec musimy dodać wire:navigate atrybut do wszystkich linków za pośrednictwem aplikacji. Na przykład:

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

Gdy używasz atrybutuwire:navigate, za kulisami Livewire pobierze zawartość nowej strony za pomocą AJAX, a następnie magicznie zamieni zawartość w przeglądarce bez ponownego ładowania całej strony. To sprawia, że ładowanie strony jest niesamowicie szybkie i umożliwia działanie funkcji takich jak persist! Włącza funkcje, które wcześniej można było osiągnąć tylko poprzez zbudowanie SPA.

Wniosek:

To była naprawdę fajna aplikacja demonstracyjna do zbudowania podczas nauki Volt i Folio. Przesłałem tutaj aplikację demonstracyjną i @bosunski stworzyłem phpsandbox, jeśli chcesz zobaczyć pełny kod źródłowy lub wypróbować go sam!

Co myślisz? Czy Livewire v3 + Volt + Folio to teraz najprostszy stos do tworzenia aplikacji Laravel? Myślę, że jest to naprawdę fajne i może wydawać się bardziej znajome dla ludzi, którzy są przyzwyczajeni do tworzenia aplikacji w frameworkach JavaScript, takich jak Next.js i Nuxt.js. Miło jest również mieć cały kod strony kolokowany - stylizację (przez Tailwind), JS (przez Alpine) i kod backendu w jednym pliku. Wyślij mi swoje przemyślenia na Twitterze!

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

O

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...

O autorze CrazyBoy49z
WORK EXPERIENCE
Kontakt
Ukraine, Lutsk
+380979856297