Laravel + Vue Js admin using Vuetify

Laravel can be set with and without Vue js. However, my personal experience says that using Vue, the admin can become flexible and easy to maintain when it comes to doing some complex operations like generating Product Attributes.

Plugins and Components needed in the Vue Laravel Admin Application

  1. Axios (For API Calls)
  2. Vuetify - (For UI/UX Design)
  3. Vuex - (For State Management)

Step 1: Create a View File - dashboard.blade.php in resources/views/admin folder

Add following code in it

<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="csrf-token" content="{{ csrf_token() }}"> <title>Dashboard</title> <link href="https://fonts.googleapis.com/css?family=Muli:300,400,600,700,800,900&display=swap" rel="stylesheet"> <script> const ADMIN_ROUTE = "admin"; const ADMIN_APP_ROUTE = "admin/app"; const ADMIN_API_ROUTE = "admin/api"; </script> </head> <body> <div id="dApp"> </div> <script src="{{ url('js/vApp.js') }}"></script> </body> </html>

The above code will ensure a few things such as it provides root div dApp where the application will be rendered and declare a few constant such as ADMIN_APP_ROUTE, ADMIN_API_ROUTE and ADMIN_ROUTE which can be used later in the application

Step 2: Configure webpack.mix.js (located at the root folder of a Laravel Application)

Change mix.js('resources/js/app.js', 'public/js') to mix.js('_vApp/vApp.js', 'public/js')

So now it is necessary to create _vApp folder in the root directory of the application. This is the directory where all the files, components and plugins folders will be created.

Step 3: Create vApp.js file the _vApp directory and start configuring the Vue Admin Application

import Vue from 'vue' import axios from './plugins/Axios' import vuetify from './plugins/Vuetify' import routes from './routes' import vuex from './store/index.js' //Default page or layout import layout from './pages/layout' const app = new Vue({ vuex, router: routes, axios, vuetify, el: '#dApp', render: h => h(layout) });

So considering above code, the next directories to be created under _vApp directory are plugins and store.

Step 4: Create Axios.js file in Plugins directory and add following content in it

import Vue from 'vue' import axios from 'axios' var axiosx = axios.create({ baseURL: 'http://127.0.0.1:8000/' + ADMIN_API_ROUTE }); Vue.prototype.$axiosx = axiosx;

Now, the above code will enable the use of this.$axiosx throughout the Vue application. Moreover, we have declared baseURL so that we won't need to add endpoint every time while making an API call. We will just need to provide relative path such as '/get/users' instead of http://127.0.0.1:8000/admin/api/get/users and this makes maintenance of the application much easier.

Step 5: Create Vuetify.js file in Plugins directory and add following content in it

import Vue from 'vue' import '@mdi/font/css/materialdesignicons.min.css' import Vuetify, { VApp, VCard, VCardText, VCardTitle, VCardActions, VForm, VTextField, VTextarea, VBtn, VAppBar, VAppBarNavIcon, VContainer, VNavigationDrawer, VDivider, VList, VListItem, VListItemIcon, VListItemTitle, VListItemGroup, VIcon, VListItemAvatar, VImg, VListItemContent, VListItemSubtitle, VListItemAction, VToolbar, VToolbarTitle, VMenu, VSimpleTable, VPagination, VSelect, VSwitch, VProgressLinear, VFlex, VLayout, VCheckbox, VExpansionPanels, VExpansionPanel, VExpansionPanelHeader, VExpansionPanelContent, VDialog, VAlert, VRadio, VRadioGroup, VCombobox, VAutocomplete, VTabs, VTab, VTabItem, VTabsItems, VTabsSlider, VCol, VRow, VListGroup, VSnackbar } from "vuetify/lib" Vue.use(Vuetify, { components: { VApp, VDialog, VAlert, VListGroup, VCol, VRow, VTabs, VTab, VTabItem, VTabsItems, VTabsSlider, VRadio, VAutocomplete, VCombobox, VRadioGroup, VExpansionPanels, VExpansionPanel, VExpansionPanelHeader, VExpansionPanelContent, VLayout, VFlex, VCheckbox, VCard, VCardText, VCardTitle, VCardActions, VForm, VTextField, VTextarea, VBtn, VAppBar, VAppBarNavIcon, VContainer, VNavigationDrawer, VDivider, VList, VListItem, VListItemIcon, VListItemTitle, VListItemGroup, VIcon, VListItemAvatar, VImg, VListItemContent, VListItemSubtitle, VListItemAction, VToolbar, VToolbarTitle, VMenu, VSimpleTable, VPagination, VSelect, VSwitch, VProgressLinear, VSnackbar } }) export default new Vuetify({ icons: { iconfont: 'mdi' }, theme: { dark: false, }, themes: { light: { primary: "#ed4b3f", secondary: "#b0bec5", accent: "#8c9eff", error: "#b71c1c", }, }, })

Step 6: Create routes file (routes.js) under _vApp directory, this file handles all the routes of the application. Following content is just a base

import Vue from 'vue' import VueRouter from 'vue-router' Vue.use(VueRouter) //Components for routes import Form from './pages/Form' const routes = [ { path: '/form', component: Form } ]; const router = new VueRouter({ mode: 'history', routes: routes, base: ADMIN_APP_ROUTE }) export default router;

In above code, ADMIN_APP_ROUTE is declared in the dashboard.blade.php file (See Step 1)

Step 7: Initialise Vuex in the application

Create directory "store" under _vApp folder and create file "index.js" in it with following content

import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex); const store = new Vuex.Store({ state: { loading: false, test: 'jigesh', snackbar: { status: false, text: '', color: 'error' }, api: 'http://127.0.0.1:8000/console/api', media_dialog: false, selectedMedia: [], selectedMediaIds: [], selectMultipleMedia: true, mediaSelectionFinished: false, mediaType: 'Image' }, mutations: { setMediaType (state, type) { state.mediaType = type; }, snackbar (state, data) { state.snackbar.status = data.status; if (!data.text) { data.text = ''; } state.snackbar.text = data.text; if (data && data.status && data.status == 'success') { state.snackbar.color="success"; } }, loading (state, status) { state.loading = status; }, setSelectMultipleMedia (state, data) { state.selectMultipleMedia = data; }, mediaSelectionFinished (state, data) { state.mediaSelectionFinished = data; }, setSelectedMedia (state, array) { state.selectedMedia = array; }, setSelectedMediaIds (state, array) { state.selectedMediaIds = array; }, media_dialog (state, status) { state.media_dialog = status; }, } }); Vue.prototype.$store = store; export default store;

In above code, you can see state and mutations object is defined

Step 8: Create pages directory and define layout component

In this step create "pages" folder in _vApp directory. In this folder, all new pages will be added. For now, add layout.vue file, this is the base file where all the router-view will be rendered along with assets (style.css) will be defined and imported. As well as that, we will also manage a global SnackBar

<template> <v-app class="main-bg"> <div class="flex"> <Media></Media> <Drawer></Drawer> <div class="flex column"> <div id="_wrapper" class="pa-5"> <router-view></router-view> </div> </div> <div class="UppyProgressBar"></div> <v-snackbar v-model="snackbar.status" :timeout="timeout" :top="false" :bottom="true" > {{ snackbar.text }} <v-btn color="blue" text @click="snackbar.status = false" > Close </v-btn> </v-snackbar> </div> </v-app> </template> <script> import Vue from 'vue' import Header from '../components/Header.vue' import Drawer from '../components/Drawer.vue' import Media from '../components/MediaDialog.vue' import File from '../components/File.vue' import Textarea from '../components/RichTextarea.vue' import Geo from '../components/GeoComplete.vue' Vue.component('Drawer', Drawer) Vue.component('Header', Header) Vue.component('Media', Media) Vue.component('File', File) Vue.component('Textarea', Textarea) Vue.component('Geo', Geo) export default { data () { return { loading: true, timeout: 6000, media: Media, snackbar: { text: this.$store.state.snackbar.text, status: this.$store.state.snackbar.status }, media_dialog: this.$store.state.media_dialog } }, watch: { media_dialog (val) { if (!val) { this.$store.commit('media_dialog', false); } }, '$store.state.media_dialog' (val) { if (val) { this.media_dialog = val; } }, '$store.state.snackbar.text' (val) { this.snackbar.text = val; }, '$store.state.snackbar.status' (val) { if (val) { this.snackbar.status = true; } }, 'snackbar.status' (val) { if (!val) { this.$store.commit('snackbar', { status: false }); } } } } </script> <style lang="scss"> @import url('../assets/style.scss') </style>

The other custom components can be created under components directory (_vApp/components) as I did Header, Drawer, Media, File, TextArea, and Geo.

This keeps flow of this Vue application really organised

Since now, in 8 steps process, we have configured Vue application with Vue Router, Vuex, Vuetify, Axios which is a core part of any Vue application.

In upcoming process, we will render Header, Drawer Menu etc.

Step 9: Create Header.vue file under components directory, this will be the header part of the Laravel Vue JS admin dashboard

<template> <v-app-bar color="#fff" flat light height="64px" max-height="64px" id="_mainAppBar" > <div class="flex align-center"> <v-toolbar-title v-text="heading"></v-toolbar-title> <div class="ml-5" v-if="caption"> <small class="caption">(Showing {{ caption }})</small> </div> </div> <div class="flex-grow-1"></div> <div id="_metas_other" class="flex align-center flex-end"> <slot> <v-btn icon> <v-icon>mdi-heart</v-icon> </v-btn> <v-btn icon> <v-icon>mdi-magnify</v-icon> </v-btn> </slot> </div> <v-menu left bottom > <template v-slot:activator="{ on }"> <v-btn icon v-on="on"> <v-icon>mdi-dots-vertical</v-icon> </v-btn> </template> <v-list> <v-list-item v-for="n in 5" :key="n" @click="() => {}" > <v-list-item-title>Option {{ n }}</v-list-item-title> </v-list-item> </v-list> </v-menu> <v-progress-linear :active="$store.state.loading" :indeterminate="$store.state.loading" absolute bottom color="info" ></v-progress-linear> </v-app-bar> </template> <script> export default { props: { heading: {}, caption: {} }, data () { return { } } } </script>

Step 9: Create Drawer.vue file under components directory

<template> <v-navigation-drawer expand-on-hover height="auto" permanent id="_mainDrawer" > <template v-slot:prepend> <v-list class="py-0" id="_logo"> <v-list-item link two-line > <v-list-item-content> <v-list-item-title class="title _900">AdLara</v-list-item-title> </v-list-item-content> </v-list-item> </v-list> </template> <v-divider></v-divider> <v-list nav dense id="_sideList" > <v-list-item-group color="primary"> <v-list-item link color="red" to="/dashboard"> <v-list-item-icon> <v-icon>mdi-view-dashboard</v-icon> </v-list-item-icon> <v-list-item-title>Dashboard</v-list-item-title> </v-list-item> <v-list-item link to="/table"> <v-list-item-icon> <v-icon>mdi-table-large</v-icon> </v-list-item-icon> <v-list-item-title>Table</v-list-item-title> </v-list-item> <v-list-item link to="/form"> <v-list-item-icon> <v-icon>mdi-star</v-icon> </v-list-item-icon> <v-list-item-title>Starred</v-list-item-title> </v-list-item> <v-list-item link to="/component/list"> <v-list-item-icon> <v-icon>mdi-iframe-braces-outline</v-icon> </v-list-item-icon> <v-list-item-title>Component</v-list-item-title> </v-list-item> </v-list-item-group> </v-list> </v-navigation-drawer> </template>

Step 10: Create MediaDialog.vue file in components directory

<template> <div> <v-dialog id="_media" width="100%" max-width="1300px" v-model="media_dialog" > <v-card max-height="800" min-height="700" color="#f9f9f9" tile id="_mediaCard" :loading="loading" > <v-tabs v-model="active" color="info" left id="_mediaTabs" > <v-tab>Media</v-tab> <v-tab>Upload</v-tab> <v-tab-item> <v-container fluid class="pa-0" id="_mainContainer"> <div id="innerWrap"> <div :class="'_image ' + getClass(m)" v-for="(m, i) in media" :key="i" @click="chosen(m)" > <img v-if="m.url && m.type == 'image'" :src="m.url" /> <span v-if="m.url && (m.format == 'application/pdf' || m.format == 'pdf')" class="_pdf" > <v-icon>mdi-file-pdf-box</v-icon> {{ m.name }} </span> <video v-if="m.url && m.type == 'video' && m.format != 'embeded'" > <source :src="m.url" /> </video> <div v-html="m.name" v-if="m.url && m.type == 'video' && m.format == 'embeded'"></div> <div class="_layer"></div> </div> </div> </v-container> </v-tab-item> <v-tab-item eager > <v-container fluid> <div id="imageUploader"></div> </v-container> </v-tab-item> </v-tabs> <div id="_mediaFooter" v-if="active == 0" > <div class="flex space-between"> <div class="flex"> <div class="_paginate"> <v-select dense solo v-model="paginate" v-if="mediaData && mediaData.last_page && mediaData.last_page > 1" :items="getPaginate(mediaData.last_page)" hide-details > </v-select> </div> <div class="_type ml-5"> <v-select solo v-model="type" :items="types" hide-details > </v-select> </div> </div> <v-btn color="info" @click="finishMediaSelection" > Finish </v-btn> </div> </div> </v-card> </v-dialog> </div> </template> <script> import Uppy from '@uppy/core' import Dashboard from '@uppy/dashboard' import XHRUpload from '@uppy/xhr-upload' import axios from 'axios' import '@uppy/core/dist/style.min.css' import '@uppy/dashboard/dist/style.min.css' export default { data () { return { paginate: 1, url: null, loading: true, mediaData: [], media: [], active: 0, selected: this.$store.state.selectedMedia, selectedIds: this.$store.state.selectedMediaIds, media_dialog: this.$store.state.media_dialog, dashboard_initialised: false, types: ['Image', 'Video', 'PDF'], type: this.$store.state.mediaType, media_fetched: false, selectionFinished: false } }, methods: { getPaginate (end) { var start = 1; return new Array(end - start).fill().map((d, i) => i + start); }, finishMediaSelection() { this.selectionFinished = true; this.$store.commit('mediaSelectionFinished', true); this.selected = []; this.getClass(); this.media_dialog = false; setTimeout(() => { /* | Keep selection open for the next time | Fixed bug for not able to select media after the first selection */ this.selectionFinished = false; }, 100); }, chosen (media) { var selected = this.selected; var index = this.selectedIds.indexOf(media.id); var multiple = this.$store.state.selectMultipleMedia; // If image exists in the array -> Remove it if (index > -1) { //this removes matched index this.selected.splice(index, 1); this.getClass(media); return; } // If the selection is not multiple (Means single) then it resets this.selected array so that previously selected object (media) will be flushed if (!multiple && selected && selected.length) { this.selected = []; } //Finally adding media to the array and adding class selected this.selected.push(media); this.getClass(media); }, getClass (media) { if (media) { var selected = this.selected; for (var key in selected) { if (selected && selected[key]) { if (media.id == selected[key]['id']) { return 'selected'; } } } } return ''; }, getAllMedia (page = 1) { this.loading = true; this.$axiosx.get('/media?page=' + page + '&type=' + this.type.toLowerCase()) .then((res) => { this.loading = false; this.media = res.data.data; this.mediaData = res.data; }); }, initUppy () { var url = this.$store.state.api + '/media/upload'; Uppy().use(Dashboard, { target: "#imageUploader", id: 'Dashboard', trigger: '#uppy-select-files', inline: true, width: '100%', height: '100vh' }).use(XHRUpload, { endpoint: url, getResponseData: ((responseText, response) => { var res = JSON.parse(responseText); if (res.status == 'error') { this.$store.commit('snackbar', { status: true, message: res.message }); } this.getAllMedia(); this.active = 0; this.paginate = 1; return { url: res.url } }) }) } }, watch: { selected (list) { // If this.selected has a length then it will update this.selectedIds if (list && list.length) { //Before pushing arrays into ids, resetting this.selectedIds to remove any duplication this.selectedIds = []; //Finally mapping list to this.selectedIds list.map((l) => { this.selectedIds.push(l.id); }); } else { //If list has no length then resetting this.selectedIds array this.selectedIds = []; } //If selection finshed button is triggered then it should not update the change since this.selected & this.selectedIds would be resetted if (!this.selectionFinished) { this.$store.commit('setSelectedMedia', this.selected); this.$store.commit('setSelectedMediaIds', this.selectedIds); } }, type (val) { this.getAllMedia(); }, '$store.state.mediaType' (type) { this.type = type; }, paginate (number) { this.getAllMedia(number); }, media_dialog (val) { if (val) { // If media is not already fetched if (!this.media_fetched) { this.getAllMedia(); } this.selected = this.$store.state.selectedMedia; this.selectedIds = this.$store.state.selectedMediaIds; //if media dialog is initialised and dashboard (uppy) is not initialised then initialise it var t = this; if (!this.dashboard_initialised) { //Setting timeout as the function need to be awaited for the element to be rendered setTimeout(function () { t.initUppy(); }, 100); this.dashboard_initialised = true; } } if (!val) { this.$store.commit('media_dialog', false); } }, '$store.state.media_dialog' (val) { if (val) { this.media_dialog = val; } }, } } </script> <style> a.uppy-Dashboard-poweredBy { display: none; } #myUploader { position: relative; background-color: #fafafa; max-width: 100%; max-height: 100%; min-height: 450px; outline: none; border: 1px solid #eaeaea; border-radius: 5px; } div#innerWrap { display: flex; flex-flow: row wrap; background: transparent; padding: 0; min-height: 560px; overflow: auto; } ._image { display: flex; flex-flow: column; align-items: center; justify-content: center; margin: 15px; cursor: pointer; position: relative; border: 1px solid #ccc; width: calc(12.5% - 30px); overflow: hidden; height: 100px; } ._addTrigger._image { border: 1px solid #ccc; } ._image img { height: 100%; width: 100%; object-fit: contain; object-position: center; } ._addTrigger > ._inner { width: 48px; } ._addTrigger i { font-size: 48px; color: #999; } ._cover { position: absolute !important; bottom: 10px; } .bg-gray { background: #fafafa; } body .uppy-Dashboard-inner { border-radius: 0; max-height: 550px; } button._delete.v-btn.v-btn--floating.v-btn--small { position: absolute; top: -15px; right: -15px; visibility: hidden; opacity: 0; transition: .3s; } ._image:hover button._delete.v-btn.v-btn--floating.v-btn--small { visibility: visible; opacity: 1; } @media(max-width: 768px) { ._image { width: calc(25% - 30px); margin: 15px 0; } } @media (max-width: 640px) { ._image { width: 100%; } } #_media .v-dialog { border-radius: 0; } #_mainContainer { background: #f9f9f9; border-top: 1px solid #f0f0f0; } #_mediaCard.v-card--loading { overflow: visible; } ._image.selected { outline: 2px solid #3398f3; border: 1px solid transparent; } ._pdf { text-align: center; text-decoration: none; display: flex; flex-flow: column; height: 100%; font-size: 13px; } ._pdf .v-icon.v-icon { font-size: 80px; color: #f64545; background: #fff; height: 80%; } ._image video { max-width: 100%; } ._paginate { max-width: 80px; } ._layer { position: absolute; z-index: 0; background: #000; opacity: 0.2; height: 100%; width: 100%; } </style>

Step 11: Create File.vue file in components directory

<template> <div class="mb-5 mt-5"> <v-btn large color="#444" tile outlined @click="mediaDialog" :block="block" > <v-icon left>mdi-cloud-upload-outline</v-icon> {{ text }} </v-btn> <div class="_fileZone flex row-wrap" v-if="mediaList && mediaList.length" > <div :class="'_zone ' + cls" v-for="(m, i) in mediaList" :key="i" > <input v-if="name" type="hidden" :name="getName()" :value="m.id"> <a v-if="m.type == 'application' && (m.format == 'application/pdf' || m.format == 'pdf')" :href="m.url" class="_pdf" target="_blank" > <v-icon>mdi-file-pdf-box</v-icon> {{ m.name }} </a> <img v-if="m.type == 'image'" :src="m.url" :alt="m.name" > <video controls v-if="m.url && m.type == 'video' && m.format != 'embeded'" > <source :src="m.url" /> </video> <div v-html="m.name" v-if="m.url && m.type == 'video' && m.format == 'embeded'"> </div> </div> </div> </div> </template> <script> import MediaDialog from './MediaDialog' export default { props: { multiple: { default: true }, text: { default: 'Select Media' }, name: { }, block: { default: false }, cls: { default: '' }, type: { default: 'Image' }, value: { default: function () { return ''; } } }, data () { return { active: false, mediaList: [], mediaListIds: [] } }, mounted () { this.iterateMediaList(); }, methods: { iterateMediaList (list = null) { if (!list) { list = this.value; } if (list) { if (!this.multiple) { var mediaList = new Array(); mediaList.push(list); } else { var mediaList = list; } this.mediaList = mediaList; } }, getName () { if (this.multiple) { return this.name + '[]'; } return this.name; }, mediaDialog () { this.active = true; this.$store.commit('setSelectMultipleMedia', this.multiple); this.$store.commit('setSelectedMedia', this.mediaList); this.$store.commit('setSelectedMediaIds', this.mediaListIds); this.$store.commit('setMediaType', this.type); this.$store.commit('media_dialog', true); } }, watch: { '$store.state.mediaSelectionFinished' (state) { if (state && this.active) { this.mediaList = this.$store.state.selectedMedia; this.mediaListIds = this.$store.state.selectedMediaIds; this.active = false; this.$store.commit('mediaSelectionFinished', false); } }, value (list) { this.iterateMediaList(list); }, mediaList (list) { } } } </script> <style lang="scss"> ._fileZone ._zone { padding: 10px; border: 1px solid #ccc; margin: 7.5px; display: flex; height: 200px; align-items: center; overflow: hidden; width: calc(20% - 15px); } ._fileZone ._zone img { max-width: 100%; margin: auto; display: block; height: 100%; } ._fileZone { margin: 10px -7.5px; overflow: auto; display: flex; align-items: center; max-width: 200%; } a._pdf { width: 100%; color: #444 !important; } ._zone video, ._zone iframe { max-width: 100%; } ._fileZone ._zone._block { width: 100%; } </style>

Step 12: Create RichTextarea.vue file in components directory

<template> <div class="mb-5 mt-5"> <div class="v-label theme--light mb-3" v-text="label"></div> <textarea id="editor" > </textarea> </div> </template> <script> export default { props: { label: { default: 'Content' } }, mounted () { } } </script>

Step 13: Create GeoComplete.vue file in components directory

<template> <div> <vuetify-google-autocomplete :id="id" append-icon="mdi-map-marker" :disabled="false" :placeholder="placeholder" :label="label" v-model="location" v-on:placechanged="getAddressData" :country="country" > </vuetify-google-autocomplete> <input type="hidden" name="street1" v-if="street1" :value="street1"> <input type="hidden" name="city" v-if="city" :value="city"> <input type="hidden" name="state" v-if="state" :value="state"> <input type="hidden" name="zip" v-if="zip" :value="zip"> <input type="hidden" name="lat" v-if="lat" :value="lat"> <input type="hidden" name="lng" v-if="lng" :value="lng"> </div> </template> <script> import Vue from 'vue' import VuetifyGoogleAutocomplete from 'vuetify-google-autocomplete' Vue.use(VuetifyGoogleAutocomplete, { apiKey: 'YOUR_API_KEY' }); export default { props: { obj: {}, id: { default: 'map' }, placeholder: { default: 'Start typing' }, label: { default: 'Location' }, name: { default: 'location' }, value: { default: function () { return []; } } }, data () { return { street1: null, city: null, state: null, zip: null, lat: null, lng: null, lng: null, country: ['us'], location: '' } }, watch: { 'value' : function (val) { if (val) { console.log(val); this.location = val.location; this.street1 = val.street; this.city = val.city; this.state = val.state; this.zip = val.zip; this.lat = val.lat; this.lng = val.lng; } } }, methods: { getAddressData (addressData, placeResultData, id) { if (addressData) { // this.location = placeResultData.formatted_address; this.street1 = addressData.name; this.city = addressData.locality; this.state = addressData.administrative_area_level_1; this.zip = addressData.postal_code; this.lat = addressData.latitude; this.lng = addressData.longitude; addressData.location = this.location; this.$emit('geo', addressData); } } } } </script>

Make sure you get your Google Autocomplete API Key following this instructions https://developers.google.com/places/web-service/get-api-key

In the next tutorial, I will explain more about Laravel routes to have this Laravel Vue Admin working 100%. Stay Tuned

Newsletter

Make sure to subscribe to my newsletter and be the first to know about my new post.

Subscribe on Youtube
FOR UPDATES

I post tutorials about various technologies on the youtube channel

Subscribe Now
Newsletter

Make sure to subscribe to my newsletter and be the first to know about my new post.

© 2020