Build a blog using Vue, Laravel and Canvas
Tags: vue, typescript, laravel, tutorial
Published: 2023-10-16 14:36:05

TL;DR: How to quickly create a blog using the Vue Inertia Laravel Tailwind stack and Austin Todd's Canvas tool.

&nbsp;
&nbsp;

A sample Laravel project can be found on this [Github Repository](https://github.com/capsulescodes/articles).

&nbsp;
&nbsp;

A quick note on the languages and frameworks used: Laravel handles the server-side, Vue handles the client-side, and Inertia bridges the gap between them. For the site's visual aspect, we're using Tailwind CSS. Let's assume you already have a ready-to-use Laravel Inertia Vue Tailwind template.

&nbsp;
&nbsp;

[Canvas](https://trycanvas.app/) is a powerful tool for Laravel applications that streamlines the writing, editing, and customization of your content with a range of publishing tools. It's an incredible all-in-one solution for creating and publishing articles, just like the one you're reading.

&nbsp;
&nbsp;

![capsules-blog-001.png](https://capsules.codes/storage/canvas/images/OBXOt9BjB6K9VHb78r4tnJS88M9I4MnUXqsV1Kpx.png)

&nbsp;
&nbsp;

Installation of [Canvas](https://github.com/austintoddj/canvas) via the terminal:

&nbsp;

```bash
composer require austintoddj/canvas
```

&nbsp;
&nbsp;

Creating a `blog` database via the terminal.

&nbsp;

```bash
mysql -u <username> -p <password> -e "CREATE DATABASE blog"
```

&nbsp;
&nbsp;

Configuring database-related data in the `.env` file.

&nbsp;

`.env`

```php

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog
DB_USERNAME=
DB_PASSWORD=
```

&nbsp;
&nbsp;

Publishing resources, the main configuration file, Canvas migrations, and creating a `symlink` to the `storage` directory.

&nbsp;

```bash
php artisan canvas:install

php artisan storage:link
```

&nbsp;
&nbsp;

Canvas is now accessible at the following address : `http://your.domain.name/canvas` The credentials generated during the execution of `php artisan canvas:install` can be used to log in through the login form.

&nbsp;
&nbsp;

It is now possible to manually add an article through the Canvas tool. However, a seeder has been provided beforehand.

&nbsp;

`database/seeders/DatabaseSeeder.php`

```php
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use Canvas\Models\Post;
use Canvas\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Str;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $user = User::first();

        for( $amount = 1; $amount <= 6; $amount++ )
        {
            $post = new Post();

            $post->id = Str::uuid();
            $post->title = fake()->sentence();
            $post->slug = Str::snake( $post->title, '-' );
            $post->summary = fake()->paragraph();
            $post->body = '<p>' . fake()->paragraph( 2 ) . '</p><br><br><p>' . fake()->paragraph( 8 ) . '</p><br><br><p>' . fake()->paragraph( 6 ) . '</p>';
            $post->published_at = Carbon::now()->addSeconds( $amount );
            $post->featured_image = "/storage/canvas/images/capsules-blog-00{$amount}.jpg";
            $post->user()->associate( $user );

            $post->save();
        }
    }
}
```

&nbsp;
&nbsp;

Six images have been previously placed in the `/canvas/images` directory. Their file paths are specified in the `featured_image` property. You can find them in the [Github Repository](https://github.com/capsulescodes/articles) mentioned earlier.

&nbsp;
&nbsp;

The `body` property requires HTML code.

&nbsp;
&nbsp;

Seconds have been added to the `published_at` property to ensure that the articles do not have the same publication date.

&nbsp;
&nbsp;

The seeder can be executed to create six articles using the following command.

&nbsp;

```bash
php artisan db:seed
```

&nbsp;
&nbsp;

The articles are ready to be organized and read. It's now time to display them. To start, the creation of a `PostController` is required. Initially, the `index` method will list article summaries on the main page.

&nbsp;

`App/Http/Controllers/PostController.php`

```php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Response;
use Canvas\Models\Post;
use Inertia\Inertia;
use App\Http\Resources\PostResource;

class PostController extends Controller
{
    public function index( Request $request ) : Response
    {
        $posts = Post::published()->orderBy( 'published_at', 'desc' )->get();

        return Inertia::render( 'Posts/Index', [ 'posts' => PostResource::collection( $posts )->toArray( $request ) ] );
    }
}
```

&nbsp;
&nbsp;

It is necessary to call the `toArray` method of the collection to bypass the default setup of a pagination JSON object.

&nbsp;
&nbsp;

We select the published articles rather than the drafts and sort them based on the most recent publication date. These data are then injected into a `PostResource`.

&nbsp;

`App/Resources/PostResource.php`

```php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Str;

class PostResource extends JsonResource
{
    public function toArray( Request $request ) : array
    {
        return [
            'title' => $this->title,
            'slug' => $this->slug,
            'summary' => $this->summary,
            'image' => $this->featured_image,
            'body' => $this->body,
            'time' => round( Str::wordCount( $this->body ) / 200 ),
            'date' => $this->published_at->format( 'd M Y' )
        ];
    }
}
```

&nbsp;
&nbsp;

A small feature has been added to the resource, namely the `time` concept, which represents the reading time of the article. This time is calculated by taking the number of words in the article's `body` and dividing it by an arbitrary number, which is 200. This calculation is based on the assumption that an average reader reads 200 words per minute.

&nbsp;
&nbsp;

You can now create the main route in the web.php file, which will then call the `index` method of the `PostController`.

&nbsp;

`routes/web.php`

```php
use App\Http\Controllers\PostController;

Route::get( '/', [ PostController::class, 'index' ] )->name( 'posts.index' );
```

&nbsp;
&nbsp;

Now it's up to the client and a Vue component to format this list of articles in `Index.vue`.

&nbsp;

`resources/js/pages/Posts/Index.vue`

```vue
<script setup lang="ts">

import { Link } from '@inertiajs/vue3';

const props = defineProps( { posts : { type : Array, default : [] } } );

</script>

<template>

    <div class="mx-auto w-full min-h-screen max-w-screen-lg flex text-primary-black">

        <div class="my-16 mx-8 grid grid-cols-3 gap-x-8 gap-y-20">

            <article class="p-4 flex flex-col items-start first:col-span-3 justify-between rounded-xl bg-primary-white bg-opacity-75 hover:opacity-90 transition-opacity duration-300 ease-in-out" v-for=" ( post, index ) in props.posts " v-bind:key="post.slug">

                <div class="h-full flex flex-col" v-bind:class=" { 'grid grid-cols-3 gap-x-8' : index == 0 } ">

                    <Link v-bind:class=" { 'col-span-2 h-full' : index == 0 } " v-bind:href="`/${post.slug}`">

                        <img class="w-full aspect-video object-cover rounded-xl dark-image-mode" v-bind:class=" { 'h-full' : index == 0 } " v-bind:src="post.image">

                    </Link>

                    <div class="grow flex flex-col items-stretch justify-between">

                        <div v-bind:class=" { 'mt-4' : index != 0 } ">

                            <div v-bind:class=" { 'flex flex-col-reverse' : index == 0 } ">

                                <div class="mt-4 flex items-center space-x-4 text-xs">

                                    <p class="py-1" v-text="post.date" />

                                </div>

                                <Link v-bind:href="`/${post.slug}`"><h3 class="mt-4 text-lg font-bold" v-bind:class=" { 'text-2xl' : index == 0 }" v-text="post.title" /></Link>

                            </div>

                            <p class="mt-8 text-sm leading-6" v-bind:class=" index == 0 ? 'line-clamp-6' : 'line-clamp-3' " v-text="post.summary" />

                        </div>

                        <div class="mt-8 flex" v-bind:class=" index == 0 ? 'justify-start' : 'justify-end' ">

                            <Link class="px-4 py-2 rounded-md text-xs border" v-bind:href="`/${post.slug}`"><span v-text="'Read more'" /></Link>

                        </div>

                    </div>

                </div>

            </article>

        </div>

    </div>

</template>
```

&nbsp;
&nbsp;

The first article receives special treatment, and each element of the object is assigned a different Tailwind class based on `index == 0`.

&nbsp;
&nbsp;

![capsules-blog-002.png](https://capsules.codes/storage/canvas/images/EtviDnfWC2taikkkNKrmwqPnV4angBvafHnfCZvZ.png)

&nbsp;
&nbsp;

Next comes the article reading page, requiring the creation of a new route.

&nbsp;

`routes/web.php`

```php
Route::get( '/{post:slug}', [ PostController::class, 'show' ] )->name( 'posts.show' );
```

&nbsp;
&nbsp;

The `{post:slug}` directive allows using the article's slug in the URL instead of its default id.

&nbsp;
&nbsp;

Therefore, the implementation of the `show` method can be carried out in the `PostController`.

&nbsp;

`App/Http/Controlles/PostController.php`

```php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Inertia\Response;
use Canvas\Models\Post;
use Inertia\Inertia;
use App\Http\Resources\PostResource;
use Illuminate\Support\Facades\Event;
use Canvas\Events\PostViewed;

class PostController extends Controller
{
    public function index( Request $request ) : Response
    {
        $posts = Post::published()->orderBy( 'published_at', 'desc' )->get();

        return Inertia::render( 'Posts/Index', [ 'posts' => PostResource::collection( $posts )->toArray( $request ) ] );
    }

    public function show( Post $post ) : Response
    {
        Event::dispatch( new PostViewed( $post ) );

        return Inertia::render( 'Post/Show', [ 'post' => new PostResource( $post ) ] );
    }
}
```

&nbsp;
&nbsp;

The Canvas tool has a dashboard that is fueled by a series of events, such as the one added in the `show` method, which counts the number of times an article has been viewed using the event dispatch `Event::dispatch( new PostViewed( $post ) )`.

&nbsp;
&nbsp;

![capsules-blog-003.png](https://capsules.codes/storage/canvas/images/wng4Nd9yjlK5RunGtzh0Zdxo6B3HBjt3f7MNrQjm.png)

&nbsp;
&nbsp;

The final step is to create the layout for the `Show.vue` page.

&nbsp;

`resources/js/pages/Posts/Show.vue`

```vue
<script setup lang="ts">

import { Link } from '@inertiajs/vue3';

const props = defineProps( { post : { type : Object, required : true } } );

</script>

<template>

    <div class="mx-auto w-full min-h-screen max-w-screen-lg flex text-primary-black">

        <div class="m-8 sm:m-16">

            <Link class="m-8 flex items-center" v-bind:href="'/'">

                <p class="text-sm leading-6 truncate" v-text="`< Back to blog`" />

            </Link>

            <div class="mb-16 p-8 rounded-xl bg-primary-white">

                <div class="mb-16 flex items-stretch">

                    <img class="w-24 object-cover rounded-xl" v-bind:src="props.post.image">

                    <h1 class="ml-4 py-2 w-2/3 text-5xl font-bold" v-text="props.post.title" />

                </div>

                <div class="mt-4 flex items-center">

                    <p class="text-xs" v-text="`${props.post.time} min - ${props.post.date}`" />

                    <hr class="grow ml-4 border-solid">

                </div>

                <div class="mt-8 text-2xl font-extralight tracking-wide article" v-html="props.post.body" />

            </div>

        </div>

    </div>

</template>
```

&nbsp;
&nbsp;

Adding a back button appears suitable to return to the `Posts/Index` page.

&nbsp;
&nbsp;

![capsules-blog-004.png](https://capsules.codes/storage/canvas/images/gNyl1YUreOg8g9ze1PCeh7e5wutce5d9PeFgRV94.png)

&nbsp;
&nbsp;

Everything is customizable thanks to the work of [Todd Austin](https://twitter.com/austintoddj) and his [Canvas](https://trycanvas.app/) tool, which allowed us to create this blog in less time than it took to write this article.

&nbsp;
&nbsp;

Glad this helped.

&nbsp;
&nbsp;

Find out more on [Capsules](https://capsules.codes) or [X](https://x.com/capsulescodes)