Twill / Vue 3 block party

Search for a command to run...

No comments yet. Be the first to comment.
Our attempt to build a CMS using Laravel and Twill for the back-end and Inertia with Vite, Vue 3 Composition API, TypeScript and Tailwind for the front-end.
Updated version for Laravel 10 / Twill 3 on Apr 13, 2023 We are not regular writers and even less in english, so please accept our apologies in advance! (and we encourage your feedback) After some years building Laravel based applications and using ...
Now that we can create pages, it's time to leverage Twill's powerful content management features and construct reusable blocks. In this article, we will focus on creating a basic Title Twill Block (with a translatable Twill Text Input in a Common nam...

You are probably already familiar with the work of Spatie and its contributors in the Laravel and Open Source ecosystem. For many years, we have been using their Backup package (https://spatie.be/docs/laravel-backup) to back up our applications (data...

If a Twill module uses Revisions, you can benefit from an awesome Twill feature: preview your changes before publishing and compare old revisions. By default, it uses Blade views, so working with Inertia needs some adjustments. The principle For now,...

In this article, we will see the creation and customization of a Twill singleton module to provide a Homepage for our application. Singleton module creation In Twill terminology, a Singleton Module is globally like a classic module except that it man...

Now that we have set up all the tools to create blocks on both Twill and Vue sides, let's create some generic blocks.
Each block will be created in the Common namespace and will be manually added as an allowed block in the controller of our PageContent Module, and declared and called in our Module Inertia Page.
For example, for a Codivores block:
/app/Http/Controllers/Admin/PageContentController.php
BlockEditor::make()
->withoutSeparator()
->blocks([
'common-codivores',
])
/resources/views/Pages/Page/Content.vue
// <script>
const BlockCommonCodivores = defineAsyncComponent(() => import('@Block/Common/Codivores.vue'))
// </script>
// <template>
<div
v-for="(block, index) in item.blocks"
:key="index"
>
...
<BlockCommonCodivores
v-else-if="block.type == 'common-codivores'"
:block="block"
></BlockCommonCodivores>
</div>
// </template>
Let's start with simple things; it is entirely possible to create a block without manageable content (this can also be the useful for blocks that will load asynchronously their content through Fetch API).
/app/View/Components/Twill/Blocks/Common/Separator.php
<?php
namespace App\View\Components\Twill\Blocks\Common;
use A17\Twill\Services\Forms\Form;
class Separator extends Base
{
public function getForm(): Form
{
return Form::make();
}
public static function getBlockTitle(): string
{
return __('Separator');
}
public static function getBlockIcon(): string
{
return 'more-dots';
}
}


/resources/views/Components/Theme/Block/Common/Separator.vue
<script setup lang="ts">
defineOptions({
name: 'BlockCommonSeparator',
})
</script>
<template>
<div class="mx-auto my-4 max-w-6xl">
<hr class="w-full border-t border-gray-800" />
</div>
</template>

It's now time to enable HTML content in our blocks using Twill's WYSIWYG field.
To achieve this, we will enhance our BlockComponent class with new methods:
fieldWysiwygTypeDefault(): we will use the quill (https://quilljs.com) editor instead of default tiptap (https://tiptap.dev)
fieldWysiwygToolbarOptionsDefault(): our default toolbar with options that we decide to allow (it is also possible to add colorization with a list of hexadecimal codes)
fieldWysiwygToolbarOptionsLight(): light toolbar that can be useful for certain content types
/app/View/Components/Twill/Blocks/Base/BlockComponent.php
...
public static function fieldWysiwygTypeDefault(): string
{
return 'quill';
}
public static function fieldWysiwygToolbarOptionsDefault(): array
{
return [
['header' => [2, 3, 4, false]],
'bold',
'italic',
'underline',
'strike',
['list' => 'bullet'],
['list' => 'ordered'],
'link',
'clean',
// ['color' => ["#747E8C", "#54D52B", "#3BA618", "#00B2D2", "#FBC000"]],
];
}
public static function fieldWysiwygToolbarOptionsLight(): array
{
return [
'bold',
'italic',
'clean',
];
}
...
/app/View/Components/Twill/Blocks/Common/Text.php
<?php
namespace App\View\Components\Twill\Blocks\Common;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Wysiwyg;
class Text extends Base
{
public function getForm(): Form
{
return Form::make([
Wysiwyg::make()
->name('content')
->label(__('Content'))
->type(self::fieldWysiwygTypeDefault())
->toolbarOptions(self::fieldWysiwygToolbarOptionsDefault())
->allowSource()
->translatable(),
]);
}
public static function getBlockTitle(): string
{
return __('Text');
}
public static function getBlockIcon(): string
{
return 'text';
}
}


/resources/views/Components/Theme/Block/Common/Text.vue
<script setup lang="ts">
defineOptions({
name: 'BlockCommonText',
})
interface Props {
block: Model.Block & PropsBlock
}
type PropsBlock = {
content: {
content?: string | null
}
}
defineProps<Props>()
</script>
<template>
<div
v-if="block.content?.content"
v-html="block.content.content"
class="text-gray-700 text-justify [&>p]:pb-4"
></div>
</template>

Now, let's focus on buttons. In addition to a translatable URL and label, we will enable the selection of a type for standard (default) or CTA button.
A small Vue-side nuance: we will use the Inertia Link component for internal links and an HTML <a> tag with target="_blank" for external links.
/app/View/Components/Twill/Blocks/Common/Button.php
<?php
namespace App\View\Components\Twill\Blocks\Common;
use A17\Twill\Services\Forms\Columns;
use A17\Twill\Services\Forms\Fields\Input;
use A17\Twill\Services\Forms\Fields\Select;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Option;
use A17\Twill\Services\Forms\Options;
class Button extends Base
{
public function getForm(): Form
{
return Form::make([
Columns::make()
->left([
Input::make()
->name('label')
->label(__('Label'))
->translatable()
->required()
])
->right([
Input::make()
->name('url')
->label(__('URL'))
->translatable()
->required()
]),
Select::make()
->name('type')
->label(__('Type'))
->options(
Options::make([
Option::make('std', __('Standard')),
Option::make('cta', __('Call To Action')),
])
)
->default('std')
]);
}
public static function getBlockTitle(): string
{
return __('Button');
}
public static function getBlockIcon(): string
{
return 'revision-single';
}
}


/resources/views/Components/Theme/Block/Common/Button.vue
<script setup lang="ts">
import { Link } from '@inertiajs/vue3'
defineOptions({
name: 'BlockCommonButton',
})
interface Props {
block: Model.Block & PropsBlock
}
type PropsBlock = {
content: {
label?: string | null
url?: string | null
type?: string | null
}
}
const props = defineProps<Props>()
const classes = computed(() =>
(props.block?.content?.type == 'cta'
? 'bg-teal-600 hover:bg-teal-800 uppercase'
: 'bg-gray-500 hover:bg-gray-700'
).concat(' rounded px-5 py-3 font-bold text-white')
)
</script>
<template>
<div
v-if="block.content?.url && block.content?.label"
class="flex justify-center my-2"
>
<template v-if="block.content.url?.startsWith('http')">
<a
:href="block.content.url"
target="_blank"
>
<button :class="classes">
{{ block.content.label }}
</button>
</a>
</template>
<template v-else>
<Link :href="block.content.url">
<button :class="classes">
{{ block.content.label }}
</button>
</Link>
</template>
</div>
</template>

Another type of content that will likely be used in a project is images. Twill handles this exceptionally well with medias
To streamline our workflow for media management, we will make some adaptations:
Enhancing our computeBlock() method to handle medias and include only necessary information (src, height, width, alt, caption and video attributes)
Creating a Picture Vue component: naturally, you can customize the integration as you prefer or according to your project needs, and there are also solutions like TwicPics or Imgix that not only offer CDN functionalities but also could display cropped images at the correct ratio automatically
Enriching our TypeScript types
In this example, we will use Twill's standard roles, namely desktop (16/9), tablet (4/3), and mobile (1). Again, feel free to adapt these according to your preferences, depending on the project, the Module where it is used, ...
/app/Models/Base/Model.php
class Model extends TwillModel
{
private function computeBlock($block, string $locale, string $fallbackLocale = null): array
{
// Handle medias.
if (isset($block->medias) && count($block->medias) > 0) {
$medias = [];
$roles = $block
->medias
->unique('pivot.role')
->pluck('pivot.role');
foreach ($roles as $role) {
$images = $block->imagesAsArraysWithCrops($role);
$medias[$role] = (is_array($images) && count($images) > 1) ? $images : reset($images);
}
$block->medias = $medias;
}
...
return $block->only(Arr::collapse(
[
[
'editor_name',
'position',
'type',
'content',
],
(count($block->medias) > 0) ? ['medias'] : [],
($block->childs && count($block->childs) > 0) ? ['childs'] : []
]
));
}
}
/resources/views/Components/Theme/UI/Picture.vue
@@ -0,0 +1,121 @@
<script setup lang="ts">
defineOptions({
name: 'ThemeUiPicture',
inheritAttrs: false,
})
interface Props {
media?: Model.Media | null
mediaList?: Model.MediaWithRoles | null
sizes?: number | object | null
lazy?: boolean
caption?: boolean
}
const props = withDefaults(defineProps<Props>(), {
media: null,
mediaList: null,
lazy: false,
caption: false,
})
const extensionList = ['webp', 'jpg']
const sizes =
props.sizes && typeof props.sizes == 'number'
? {
mobile: props.sizes,
tablet: props.sizes,
desktop: props.sizes,
default: props.sizes,
}
: Object.assign(
{
mobile: 768,
tablet: 1024,
desktop: 1536,
default: 1536,
},
props.sizes
)
const figure = computed(() => {
if (props.mediaList !== null && props.mediaList?.mobile && props.mediaList?.tablet && props.mediaList?.desktop) {
return {
sources: [
{
crop: 'mobile',
object: props.mediaList.mobile,
size: sizes.mobile,
media: '(max-width: 768px)',
},
{
crop: 'tablet',
object: props.mediaList.tablet,
size: sizes.tablet,
media: '(max-width: 1024px)',
},
{
crop: 'desktop',
object: props.mediaList.desktop,
size: sizes.desktop,
media: '(min-width: 1025px)',
},
],
caption:
props.caption && props.mediaList.desktop?.caption && props.mediaList.desktop.caption !== ''
? props.mediaList.desktop.caption
: null,
}
} else if (props.media) {
return {
sources: [
{
crop: 'default',
object: props.media,
size: sizes.default,
media: '(min-width: 1px)',
},
],
caption: props.caption && props.media?.caption && props.media.caption !== '' ? props.media.caption : null,
}
}
return null
})
</script>
<template>
<figure class="w-full h-auto">
<template v-if="figure && figure.sources">
<picture>
<template
v-for="(media, index) in figure.sources"
:key="index"
>
<template v-if="media.object && media.object.src">
<source
v-for="imageFormat in extensionList"
:srcset="`${media.object.src}&fm=webp&w=${media.size}`"
:media="media.media"
:type="`image/${imageFormat}`"
/>
<img
v-if="index == figure.sources.length - 1"
:src="`${media.object.src}&fm=webp&w=${media.size}`"
:alt="media.object?.alt"
v-bind="$attrs"
:loading="lazy ? 'lazy' : 'eager'"
class=""
/>
</template>
</template>
</picture>
<template v-if="caption && figure.caption !== null">
<figcaption class="w-full text-xs text-center py-1">
{{ figure.caption }}
</figcaption>
</template>
</template>
</figure>
</template>
/resources/js/types/models.d.ts
declare namespace Model {
export type Page = {
...
medias: {} | null
}
export type Media = {
alt?: string
caption?: string
height: number
src?: string
video?: string
width: number
}
export type MediaWithRoles = {
default?: Media
desktop?: Media
mobile?: Media
tablet?: Media
}
}
/app/View/Components/Twill/Blocks/Common/Image.php
<?php
namespace App\View\Components\Twill\Blocks\Common;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Medias;
class Image extends Base
{
public function getForm(): Form
{
return Form::make([
Medias::make()
->name('common_image')
->label(__('Image'))
->max(1)
]);
}
public static function getCrops(): array
{
return [
'common_image' => [
'desktop' => [
[
'name' => 'desktop',
'ratio' => 16 / 9,
'minValues' => [
'width' => 1200,
'height' => 675,
],
],
],
'tablet' => [
[
'name' => 'tablet',
'ratio' => 4 / 3,
],
],
'mobile' => [
[
'name' => 'mobile',
'ratio' => 1,
],
],
],
];
}
public static function getBlockTitle(): string
{
return __('Image');
}
public static function getBlockIcon(): string
{
return 'image';
}
}


Then you can edit the crop for each role:



/resources/views/Components/Theme/Block/Common/Image.vue
<script setup lang="ts">
import Picture from '@Theme/UI/Picture.vue'
defineOptions({
name: 'BlockCommonImage',
})
interface Props {
block: Model.Block & PropsBlock
}
type PropsBlock = {
medias: {
common_image?: Model.MediaWithRoles
}
}
defineProps<Props>()
</script>
<template>
<div
v-if="block?.medias?.common_image"
class="my-4"
>
<Picture
:mediaList="block.medias.common_image"
class="w-full"
></Picture>
</div>
</template>


A more permissive version of the Image Block, with a single role and an unrestricted free ratio for unconstrained image display.
/app/View/Components/Twill/Blocks/Common/ImageUnconstrained.php
<?php
namespace App\View\Components\Twill\Blocks\Common;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Medias;
class ImageUnconstrained extends Base
{
public function getForm(): Form
{
return Form::make([
Medias::make()
->name('common_unconstrained_image')
->label(__('Image'))
->max(1)
]);
}
public static function getCrops(): array
{
return [
'common_unconstrained_image' => [
'default' => [
[
'name' => 'default',
'ratio' => 'free',
],
],
],
];
}
public static function getBlockTitle(): string
{
return __('Unconstrained image');
}
public static function getBlockIcon(): string
{
return 'image';
}
}
/resources/views/Components/Theme/Block/Common/ImageUnconstrained.vue
<script setup lang="ts">
import Picture from '@Theme/UI/Picture.vue'
defineOptions({
name: 'BlockCommonImageUnconstrained',
})
interface Props {
block: Model.Block & PropsBlock
}
type PropsBlock = {
medias: {
common_unconstrained_image?: Model.MediaWithRoles
}
}
defineProps<Props>()
</script>
<template>
<div
v-if="block?.medias?.common_unconstrained_image?.default"
class="my-4"
>
<Picture
:media="block.medias.common_unconstrained_image.default"
class="w-full"
></Picture>
</div>
</template>
Just for fun, a component with conditional title, subtitle and content, that leverages our previous Text component for the content field.
/app/View/Components/Twill/Blocks/Common/Paragraph.php
<?php
namespace App\View\Components\Twill\Blocks\Common;
use A17\Twill\Services\Forms\Fields\Input;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Wysiwyg;
class Paragraph extends Base
{
public function getForm(): Form
{
return Form::make([
Input::make()
->name('title')
->label(__('Title'))
->translatable(),
Input::make()
->name('subtitle')
->label(__('Subtitle'))
->translatable(),
Wysiwyg::make()
->name('content')
->label(__('Content'))
->type(self::fieldWysiwygTypeDefault())
->toolbarOptions(self::fieldWysiwygToolbarOptionsDefault())
->allowSource()
->translatable(),
]);
}
public static function getBlockTitleField(): ?string
{
return 'title';
}
public static function getBlockTitle(): string
{
return __('Paragraph');
}
public static function getBlockIcon(): string
{
return 'content-editor';
}
}


/resources/views/Components/Theme/Block/Common/Paragraph.vue
<script setup lang="ts">
import Text from './Text.vue'
defineOptions({
name: 'BlockCommonParagraph',
})
interface Props {
block: Model.Block & PropsBlock
}
type PropsBlock = {
content: {
title?: string | null
subtitle?: string | null
content?: string | null
}
}
defineProps<Props>()
</script>
<template>
<div
v-if="block?.content?.title || block?.content?.subtitle || block?.content?.content"
class="my-4"
>
<h2
v-if="block?.content?.title"
v-html="block.content.title"
class="text-2xl font-bold mb-1"
></h2>
<h2
v-if="block?.content?.subtitle"
v-html="block.content.subtitle"
class="text-xl font-semibold mb-1"
></h2>
<Text
v-if="block?.content?.content"
:block="block"
></Text>
</div>
</template>

An example to show the use of the Twill InlineRepeater, enabling the easy creation of dynamic content lists.
For the demonstration, we will create a pricing table.
/app/View/Components/Twill/Blocks/Sandbox/PricingTable.php
<?php
namespace App\View\Components\Twill\Blocks\Sandbox;
use A17\Twill\Services\Forms\Columns;
use A17\Twill\Services\Forms\Fields\Input;
use A17\Twill\Services\Forms\Form;
use A17\Twill\Services\Forms\Fields\Wysiwyg;
use A17\Twill\Services\Forms\InlineRepeater;
class PricingTable extends Base
{
public function getForm(): Form
{
return Form::make([
Input::make()
->name('title')
->label(__('Title'))
->translatable(),
InlineRepeater::make()
->name('pricing')
->label(__('Plan'))
->triggerText(__('Add a Plan'))
->fields([
Columns::make()
->left([
Input::make()
->name('name')
->label(__('Name'))
->translatable()
->required(),
])
->right([
Input::make()
->name('price')
->label(__('Price'))
->required()
->type('number')
->min(0),
]),
Wysiwyg::make()
->name('description')
->label(__('Description'))
->type(self::fieldWysiwygTypeDefault())
->toolbarOptions(self::fieldWysiwygToolbarOptionsDefault())
->allowSource()
->translatable(),
])
]);
}
public static function getBlockTitle(): string
{
return __('Pricing Table');
}
public static function getBlockIcon(): string
{
return 'b-checklist';
}
}


/resources/views/Components/Theme/Block/Sandbox/PricingTable.vue
<script setup lang="ts">
defineOptions({
name: 'BlockSandboxPricingTable',
})
interface Props {
block: Model.Block & PropsBlock
}
type PropsBlock = {
content: {
title?: string | null
subtitle?: string | null
content?: string | null
}
childs: PropsChildBlock[]
}
type PropsChildBlock = {
content: {
name?: string | null
price?: string | null
description?: string | null
}
}
defineProps<Props>()
/**
* Tailwind dynamic classes.
* opacity-60
* opacity-80
* opacity-100
*/
</script>
<template>
<div
v-if="block?.childs && Array.isArray(block.childs) && block.childs.length > 0"
class="w-full bg-blue pt-8"
>
<h1
v-if="block.content?.title"
v-html="block.content.title"
class="text-center text-4xl font-semibold text-gray-900 mb-8"
></h1>
<div class="flex justify-center">
<template
v-for="(child, index) in block.childs"
:key="index"
>
<div
v-if="child.content?.name && child.content?.price"
class="w-full px-3"
>
<div
class="bg-teal-500 rounded-lg"
:class="`opacity-${(3 + index) * 20}`"
>
<div class="py-3 block text-3xl text-center font-semibold">
{{ child.content.name }}
</div>
<h2 class="mb-6 font-bold text-center">
<span class="text-4xl">${{ child.content.price }}</span>
<span class="text-gray-600"> / month </span>
</h2>
<p
v-if="child.content?.description"
v-html="child.content.description"
class="border-t border-stroke py-6 mx-6"
></p>
</div>
</div>
</template>
</div>
</div>
</template>

We'll do our best to provide source code of the serie on GitHub