Forum dạng SPA với Laravel và Vue.js – Phần 5: Nâng cấp giao diện Fontend

Chúng ta đã trải qua được phần lớn chặng đường trong xây dựng ứng dụng Forum dạng đơn trang sử dụng hai framework là Laravel cho phần backend và Vue.js cho phần fontend. Trong phần tiếp theo này, chúng ta sẽ tiếp tục nâng cấp giao diện người dùng bằng cách thêm vào một số tính năng hữu ích.

1. Phân trang nội dung

Nếu sử dụng các view trong Laravel, việc phân trang là hết sức đơn giản với Laravel Pagination, khi sử dụng Vue.js, công việc phân trang có phức tạp hơn đôi chút. Nhưng không vấn đề gì, bạn cũng sẽ nhanh chóng nắm bắt được cách thức làm sau khi thực hiện phần này thôi.

Trong trang danh sách các chủ đề liên quan đến một danh mục, nếu số lượng chủ đề tăng lên (thực tế là tăng lên rất nhanh), chúng ta cần thực hiện phân trang. Chúng ta sẽ thực hiện thay đổi phương thức topics() trong CategoryController. Đây chính là phương thức để thao tác với dữ liệu cho API endpoint http://spa-forum.dev/categories/{id}/topics. Xem lại routers/api.php:

Route::get('categories', 'CategoryController@index');
Route::get('categories/{id}/topics', 'CategoryController@topics');
    public function topics($categoryId)
    {
        $topics = Category::findOrFail($categoryId)
            ->topics()
            ->orderBy('created_at', 'desc')
            ->paginate(3);
        return $topics;
    }

Chúng ta sẽ tạo ra một Component phân trang, xem thêm Xây dựng Component phân trang trong Laravel và Vue.js. Tạo file Pagination.vue trong resources/assets/js/components:

<template>
    <nav>
        <ul class="pagination">
            <li v-if="pagination.current_page > 1">
                <a href="#" aria-label="Previous" v-on:click.prevent="changePage(pagination.current_page - 1)">
                    <span aria-hidden="true">&laquo;</span>
                </a>
            </li>
            <li v-for="page in pagesNumber" :class="{'active': page == pagination.current_page}">
                <a href="#" v-on:click.prevent="changePage(page)">{{ page }}</a>
            </li>
            <li v-if="pagination.current_page < pagination.last_page">
                <a href="#" aria-label="Next" v-on:click.prevent="changePage(pagination.current_page + 1)">
                    <span aria-hidden="true">&raquo;</span>
                </a>
            </li>
        </ul>
    </nav>
</template>
<script>
    export default{
        props: {
            pagination: {
                type: Object,
                required: true
            },
            offset: {
                type: Number,
                default: 4
            }
        },
        computed: {
            pagesNumber: function () {
                if (!this.pagination.to) {
                    return [];
                }
                var from = this.pagination.current_page - this.offset;
                if (from < 1) {
                    from = 1;
                }
                var to = from + (this.offset * 2);
                if (to >= this.pagination.last_page) {
                    to = this.pagination.last_page;
                }
                var pagesArray = [];
                for (from = 1; from <= to; from++) {
                    pagesArray.push(from);
                }
                return pagesArray;
            }
        },
        methods : {
            changePage: function (page) {
                this.pagination.current_page = page;
            }
        }
    }
</script>

Tiếp theo, trong CategoryView.vue là component thực hiện nội dung danh sách các chủ đề liên quan đến một danh mục. Thực hiện đưa đoạn mã hiển thị phân trang vào CategoryView.vue như sau:

<template>
	<div>
		<topic v-for="topic in topics" :topic="topic" :key="topic.id"></topic>
		<pagination v-bind:pagination="pagination" v-on:click.native="getTopics(pagination.current_page)" :offset="4"></pagination>
	</div>
</template>

<script>
import Topic from '../Topic.vue'
import Pagination from '../Pagination.vue'

export default {

	components: { Topic, Pagination },

	data () {
		return {
			topics: [],
            counter: 0,
            pagination: {
                total: 0,
                per_page: 2,
                from: 1,
                to: 0,
                current_page: 1
            },
            offset: 4
		}
	},
	mounted() {
        this.getTopics(this.pagination.current_page);
    },
    methods: {
        getTopics (page) {
            axios.get('/api/categories/' + this.$route.params.categoryId + '/topics?page='+page)
                .then((response) => {
                    this.topics = response.data.data.data
                    this.pagination = response.data.pagination
                })
        }
    }
}
</script>

Kết quả như sau:

Danh sách chủ đề có phân trang trong SPA Forum

2. Breadcrumb

Breadcrumb là tập hợp các đường dẫn URL được phân cấp giúp người dùng có thể đi đến các đường dẫn cấp cha của đường dẫn hiện tại một cách nhanh chóng. Breadcrumb tạo cho người dùng sự thuận tiện trong sử dụng và nó cũng là một tiêu chí ảnh hưởng đến SEO (Search Engine Optimization) tức là ảnh hưởng đến thứ hạng trên các bộ máy tìm kiếm.

OK, chúng ta cùng xây dựng nào. Chúng ta sẽ xây dựng thêm phần API để trả về dữ liệu Breadcrumb. Trong Model Topic thêm trường breadcrumb vào thuộc tính $visible và $append để mỗi khi lấy dữ liệu từ API sẽ có thêm dữ liệu về breadcrumd. Phương thức getBreadcrumbAttribute() sẽ thực hiện xây dựng dữ liệu cho trường mới này:

protected $visible = ['id', 'title', 'body', 'views', 'time', 'category_id', 'breadcrumb'];
protected $appends = ['time', 'breadcrumb'];

// …

public function getBreadcrumbAttribute()
{
    $category = $this->category;

    return [
        [ 'title' => 'Home', 'link' => '/' ],
        [ 'title' => $category->name, 'link' => "/category/$category->id"],
        [ 'title' => $this->title, 'link' => "/topic/$this->id"]
    ];
}

Bạn hãy xem lại thử API http://spa-forum.dev/api/categories chúng ta đã thấy xuất hiện thêm trường breadcrumb. Công việc tiếp theo, chúng ta sẽ đưa breadcrumb vào hiển thị ở giao diện. Do trong nội dung nào của Forum cũng cần có breadcrumb nên chúng ta sẽ đưa nó vào master view App.vue ngay dưới phần header.

<template>
	<div class="container">
		<h1>
			SPA-FORUM <small> với Laravel + Vue.js</small>
		</h1>
		<breadcrumb :breadcrumb="breadcrumb"></breadcrumb>
		<router-view></router-view>
	</div>
</template>
<script>
import Breadcrumb from './Breadcrumb.vue';
export default {
	components: { Breadcrumb },
	data () {
		return {
			breadcrumb: []
		}
	}
}
</script>

Chúng ta cũng tạo ra component Breadcrumb.vue trong resources/assets/js/components:

<template>
	<ol class="breadcrumb">
		<li class="Breadcrumb-item" v-for="item in breadcrumb" v-bind:class="{ 'active': currentPage(item.link) }">
			<router-link :to="{ path: item.link }" v-text="item.title"></router-link>
		</li>
	</ol>
</template>
<script>
	export default {
		props: ['breadcrumb'],
		methods: {
			currentPage (link) {
				return this.$route.path.split('?')[0] == link;
			}
		}
	}
</script>

Trong component này không có gì đặc biệt, chúng ta chỉ thực hiện hiển thị các đường dẫn, đường dẫn nào trùng với đường dẫn hiện hành sẽ thêm class=”active” để nó mờ đi không click vào được. Tiếp theo, một phần rất quan trọng, component breadcrumb được đưa vào master view App.vue, vậy bằng cách nào chúng ta có thể cập nhật dữ liệu cho nó. Để làm việc này, chúng ta có hai tùy chọn:

  • Một là thiết lập giá trị trực tiếp cho this.$root.breadcrumb = breadcrumb
  • Hai là tạo ra một sự kiện có chứa các giá trị cần truyền.

Chúng ta sẽ sử dụng phương thức emit() để truyền dữ liệu từ component con về component cha, Xem thêm Cách truyền dữ liệu giữa các component trong Vue.

Tiếp theo, chúng ta bắt đầu áp dụng vào các trang danh mục, chúng ta sẽ cập nhật dữ liệu cho component breadcrumb vào lúc nào? Câu trả lời là bất kỳ khi nào mảng dữ liệu các chủ đề được cập nhật, do vậy chúng ta sẽ thêm một watcher vào CategoryView để thực hiện giám sát mảng dữ liệu topics.

	watch: {
		topics: function() {
			let breadcrumb = this.topics[0].breadcrumbs
    		        breadcrumb.pop()
			this.$emit('update-breadcrumb', breadcrumb)
		}
	},

Khi đó, trên <router-view> của App.vue chúng ta lắng nghe sự kiện update-breadcrumb và gọi đến phương thức updateBreadcrumb() để cập nhật dữ liệu cho breadcrumb.

<template>
	<div class="container">
		<h1>
			SPA-FORUM <small> với Laravel + Vue.js</small>
		</h1>
		<breadcrumb :breadcrumb="breadcrumb"></breadcrumb>
		<router-view @update-breadcrumb="updateBreadcrumb"></router-view>
	</div>
</template>
<script>
import Breadcrumb from './Breadcrumb.vue';
export default {
	components: { Breadcrumb },
	data () {
		return {
			breadcrumb: []
		}
	},
	methods: {
		updateBreadcrumb (breadcrumb) {
			this.breadcrumb = breadcrumb
		}
	}
}
</script>

Kiểm tra lại trang danh sách chủ đề trong một topics chúng ta thấy breadcrumb đã được hiển thị.

Hiển thị breadcrumb trong SPA Forum

Tương tự với trang danh sách danh mục, chúng ta thay đổi HomeView.vue như sau:

<template>
	<div>
		<category v-for="category in categories" :category="category" :key="category.id"></category>
	</div>
</template>
<script>
	import Category from '../Category.vue';
	export default {
		
		components: { Category },

		data () {
			return {
				categories: []
			}
		},

		mounted () {
			axios.get('http://spa-forum.dev/api/categories').then((response) => {
		        this.categories = response.data
		    })
			let breadcrumb = [{
				name: 'Categories',
				link: '/'
			}]
			this.$emit('update-breadcrumb', breadcrumb)
		}
	}
</script>

Ở đây, chúng ta thiết lập giá trị tĩnh cho breadcrumb luôn.

3. Hiển thị dấu hiệu tải dữ liệu

Không giống như các ứng dụng truyền thống, trong ứng dụng đơn trang, chúng ta không tải lại đầy đủ cả trang mà chỉ tải lại những phần nào cần thay đổi, do đó có thể sẽ thấy một trang nội dung trắng khi nó đang tải dữ liệu. Để tránh việc này, chúng ta sẽ hiển thị những dấu hiệu cho người dùng thấy là dữ liệu đang được tải.

Chúng ta sẽ sử dụng NProgress một thư viện có sẵn để thực hiện.

Trước tiên chúng ta cần cài đặt NProgress với câu lệnh npm install nproress –save. Tiếp theo, NProgress có style riêng viết thuần CSS ở node_modules/nprogress/nprogress.css, do đó cần cấu hình lại webpack.mix.js để Laravel Mix biết và bundle cả sass và css của nprogress.

mix.js('resources/assets/js/app.js', 'public/js')
   .sass('resources/assets/sass/app.scss', 'public/css/app.css')
   .styles(['public/css/app.css' ,'node_modules/nprogress/nprogress.css'], 'public/css/app.css');

Với NProgress bạn chỉ cần quan tâm đến hai phương thức là:

  • NProgress.start() đặt vào nơi bắt đầu tải dữ liệu.
  • NProgress.done() đặt vào nơi kết thúc tải dữ liệu.

Ok, với NProgress thì như vậy là quá đơn giản, nhưng một câu hỏi đặt ra là chúng ta biết đặt các phương thức của NProgress vào đâu trong ứng dụng. Chúng ta cần start khi bắt đầu request và stop khi đã nhận được response. Và cần biết tới một khái niệm HTTP Interceptor.

HTTP Interceptor là gì? Đơn giản nó cho phép chúng ta thực hiện các công việc cần thiết trước khi gửi request đi và sau khi response đã được nhận. Axios cũng hỗ trợ HTTP Interceptor, thực hiện import vào app.js như sau:

require('./bootstrap')

import Vue from 'vue'
import App from './components/App'
import router from './routes'
import NProgress from 'nprogress'
import {fromNow} from './filters/timeFilter';
import {largeNumber} from './filters/largeNumber'

axios.interceptors.request.use(function (config) {
	NProgress.start();
	return config;
}, function (error) {
	return Promise.reject(error);
});

axios.interceptors.response.use(function (response) {
    NProgress.done();
    return response;
}, function (error) {
    return Promise.reject(error);
});

Vue.filter('fromNow', fromNow);
Vue.filter('largeNumber', largeNumber);

var vm = new Vue({
    el: '#app',
    router,
    render: h => h(App)
})

Ok, chạy thử thôi, kết quả vào các trang đã thấy có NProgress hiển thị tình trạng tải dữ liệu.

4. Cài đặt theme bootstrap cho SPA Forum

Từ đầu đến giờ, chúng ta đã sử dụng Bootstrap cũng như các style có sẵn trong Laravel, trông cũng không quá tệ nhưng có rất nhiều theme Bootstrap miễn phí, chúng ta sẽ sử dụng Bootswatch để style lại cho ứng dụng Forum này nhé. Chúng ta sẽ sử dụng theme Cosmos được cung cấp bởi Bootswatch.

Bước 1: Chọn Download -> bootstrap.css hoặc tải CSS về từ đây. Copy nội dung này vào file resources/assets/css/bootstrap.css

Bước 2: Thay đổi nội dung file resources/assets/sass/app.scss thành:

@import '../css/bootstrap.css';
@import '../css/nprogress.css';

Như vậy SPA Forum đã dùng theme Cosmos, tiếp theo chúng ta thiết kế lại phần header trang, đưa thêm Đăng nhập và Đăng xuất vào, chỉnh sửa App.Vue như sau:

<template>
	<div class="container">
		<nav class="navbar navbar-default">
  			<div class="container-fluid">
				<div class="navbar-header">
					<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1" aria-expanded="false">
						<span class="sr-only">Toggle navigation</span>
						<span class="icon-bar"></span>
						<span class="icon-bar"></span>
						<span class="icon-bar"></span>
					</button>
					<router-link to="/" class="navbar-brand">SPA FORUM <small>Laravel + Vue.js</small></router-link>
				</div>
    			<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
					<ul class="nav navbar-nav navbar-right">
						<li><router-link :to="{ name: 'Login' }">Đăng nhập</router-link></li>
						<li><router-link :to="{ name: 'Register' }">Đăng ký</router-link></li>
					</ul>
    			</div>
  			</div>
		</nav>
		<breadcrumb :breadcrumb="breadcrumb"></breadcrumb>
		<router-view @update-breadcrumb="updateBreadcrumb"></router-view>
	</div>
</template>

<script>
import Breadcrumb from './Breadcrumb.vue';
export default {
	components: { Breadcrumb },
	data () {
		return {
			breadcrumb: []
		}
	},
	methods: {
		updateBreadcrumb (breadcrumb) {
			this.breadcrumb = breadcrumb
		}
	}
}
</script>

Xem lại giao diện xem thế nào:

Giao diện SPA Forum đã cài đặt Cosmos từ Bootswatch

Hehe, nhìn trong khá hơn nhiều nhỉ.

5. Phần cuối là gì?

Như vậy, chúng ta cũng đã trải qua được khá nhiều phần trong xây dựng ứng dụng Forum dạng SPA bằng Laravel và Vue.js. Cũng đến lúc phải làm một phần chốt lại những gì chúng ta đã học được và triển khai trong thời gian qua. Các tính năng của Forum đã được hình thành tương đối, giờ chỉ còn một vấn đề là xác thực người dùng, và nó cũng sẽ là phần cuối cùng của loạt bài viết này. Đón xem bạn nhé!

One thought on “Forum dạng SPA với Laravel và Vue.js – Phần 5: Nâng cấp giao diện Fontend

  1. Vũ Đức Hồng

    - Edit

    Reply

    import {fromNow} from ‘./filters/timeFilter’;
    import {largeNumber} from ‘./filters/largeNumber’

    ———-
    cái này ở đâu vậy ad

Add Comment