|

CSS grid: filas con misma altura

Tener un grid con todas las filas al mismo tamaño puede ser algo difícil de conseguir. El problema viene con que CSS grid toma el tamaño de las filas según el tamaño máximo de sus celdas.

Para conseguir ello es necesario que el contenido de la celda tenga posicionamiento absoluto, y jugar con ::before para simular contenido vacío que rellene la celda:

.grid {
  margin: 0 auto;
  width: 60vw;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 5px;
  grid-auto-rows: auto;
}

img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.grid--same-height .cell {
  position: relative;
}
.grid--same-height .cell::before {
  content: "";
  display: inline-block;
  padding-bottom: 100%;
}

.grid--same-height .content {
  height: 100%;
  position: absolute;
  margin-top: -100%;
}

Teniendo en cuenta que el HTML sería:

<div id="grid" class="grid">
  <div class="cell">
    <div class="content">
      <img src="https://picsum.photos/500/500"/>
    </div>
  </div>
  ...
</div>

Para el ejemplo uso picsum.photos, un lorem ipsum para fotos que permite indicar distintos tamaños de imágenes.

Podéis ver un ejemplo en este pen

Hapi.js + Vue.js avatar

Hapi.js + Vue.js avatar

En esta nueva versión se ha añadido el poder subir el avatar, recortando la imagen usando la librería Cropper.js y para ello también se usará Vuex para guardar el avatar y que se actualice en toda la app.

Para subir la imagen se usará el siguiente método.

this.cropper
	.getCroppedCanvas( this.outputOptions )
	.toBlob( blob => {
		new ApiFectch()
			.updateAvatar( blob )
			.then( response => {
				if ( response.response ) {
					success( response.message );
					this.avatar = avatar + '?cache=' + new Date();
					this.$store.commit( `user/${ USER_SET_AVATAR }`, new User().getAvatarURL( userData.username ) + '?cache=' + ( new Date() ) );
				} else {
					error( response.message );
				}
			} );
	} );

Para manejar todo habrá que usar Vuex con módulos, por ejemplo, para el módulo user:

import { USER_SET_AVATAR, USER_SET_USERNAME } from '../mutation-types';

const state = {
	avatar: null,
	username: null,
};

const mutations = {
	/**
	 * Sets avatar
	 *
	 * @param {Object} mutationState Vuex state.
	 * @param {String} avatar Avatar string.
	 */
	[ USER_SET_AVATAR ]( mutationState, avatar ) {
		mutationState.avatar = avatar;
	},

	/**
	 * Sets username
	 *
	 * @param {Object} mutationState Vuex state.
	 * @param {String} username User name.
	 */
	[ USER_SET_USERNAME ]( mutationState, username ) {
		mutationState.username = username;
	},
};

export default {
	namespaced: true,
	state,
	mutations,
};

Aquí está el código

Hapi.js + Vue.js internacionalización (i18n)

Hapi.js + Vue.js internacionalización (i18n)

A partir de ahora no voy a explicar lo que hago, sino que compartiré lo que considere algo especial, pondré algún enlace a algún otro tutorial y por supuesto el enlace al tag de GitHub.

En esta ocasión he añadido internacionalización al proyecto. Parece una tontería, pero cuanto antes se meta, en mi opinión, mejor que mejor. Luego cambiar todos los textos para que admitan i18n es un tostón enorme.

Para añadir internacionalización usaremos el paquete vue-i18n, que añade todo lo que necesitamos. Aquí hay un tutorial bastante completo que explica cómo usarlo, y es el que yo he seguido.

Lo único que he añadido es que coja el idioma del navegador:

import( `@/lang/${ browserLang }.json` ).then( messages => {
	i18n.setLocaleMessage( browserLang, messages.default );
} );

Aquí está el código

Hapi.js + Vue.js ejemplo mínimo de frontend: formulario login

Hapi.js + Vue.js ejemplo mínimo de frontend: formulario login

Esta parte es solo frontend, aún no está configurado para que interactúe con el servidor.

Lo más destacado de este ejemplo es el uso de vue-router, paquete que permite la realización de SPA de forma sencilla. Como la aplicación será gestionada por un único fichero (index.html), es necesario configurar el servidor de webpack para que gestione las URLs que acceden a otras partes de la aplicación para que no devuelva un error 404.

Esto es fácil, tan solo hay que añadir historyApiFallback y ponerlo a true en la configuración del servidor.

devServer: {
	inline: true,
	hot: true,
	port: 9999,
	historyApiFallback: true,
},

Si usas Apache u otro servidor deberás usar una configuración distinta.

Lógicamente habrá que instalar el paquete vue-router.

Vale, ya está todo instalado, ahora solo hace falta configurar vue-router para que acepte distintas URLs y que muestre distintos controladores según sea el caso.

Para ello creamos un fichero router.js que posteriormente añadiremos a nuestra instancia de Vue:

Es fácil de entender, importamos los distintos controladores y configuramos las rutas (‘/‘ y ‘/login‘), a las que les asignaremos el controlador correspondiente.

Para indicar a Vue que vamos a usar vue-router, debemos importarlo en la instancia de la aplicación:

import App from './components/App.vue';
import router from './router';

Vue.use( VueRouter );

new Vue( {
	el: '#app',
	router,
	components: {
		App,
	},
	render: ( c ) => c( 'app' ),
} );

El siguiente paso es modificar el controlador principal de la aplicación (App.vue) para que muestre la cabecera (que tendrá su propio controlador) y la vista principal de vue-router (<router-view>):

<template>
	<div>
		<v-header />
		<router-view />
	</div>
</template>

<script>
import header from '@/js/components/layout/Header';

export default {
	name: 'App',
	components: {
		'v-header': header,
	},
};
</script>

Como no soy diseñador, pues usaré Buefy (basado en Bulma) y Material Design icons (no sé por qué le tengo algo de manía a FontAwesome).

Existe un paquete especial para usar Material Design en vue (vue-material-design-icons), que para funcionar con Buefy necesitará usar la fuente de letras de Material Design (@mdi/fonts). Instalamos todo y ya estará todo listo para empezar a diseñar nuestra página.

La cabecera (<v-header>) mostrará el logo, el menú principal y otro secundario para loguearse. No explicaré ni las clases Bulma (que yo casi ni conozco) y cómo se muestra el menú al clickar en el burger icon, ya que estos tutoriales son para llevar yo un diario de cómo desarrollar una app web con Hapi.js y Vue.js.

El controlador de la cabecera nos quedará así:

<template>
	<header>
		<nav
			class="navbar"
			role="navigation"
			aria-label="main navigation"
		>
			<div class="navbar-brand">
				<a
					class="navbar-item"
					href="https://sentidoweb.com"
				>
					<img src="/assets/images/logo.svg">
				</a>

				<a
					role="button"
					class="navbar-burger burger"
					aria-label="menu"
					aria-expanded="false"
					:class="{ 'is-active' : showNav }"
					@click="showNav = !showNav"
				>
					<span aria-hidden="true" />
					<span aria-hidden="true" />
					<span aria-hidden="true" />
				</a>
			</div>

			<div :class="[ { 'is-active' : showNav }, 'navbar-menu' ]">
				<div class="navbar-start">
					<router-link class="navbar-item" to='/'>Home</router-link>
				</div>

				<div class="navbar-end">
					<div class="navbar-item">
						<div class="buttons">
							<router-link class="button is-light" to='/login'>Log in</router-link>
						</div>
					</div>
				</div>
			</div>
		</nav>
	</header>
</template>

<script>
export default {
	data() {
		return {
			showNav: false,
		};
	},
};
</script>

Tan solo mencionar cómo vue-router gestiona la navegación, para ello hace uso de <router-link>:

<router-link class="navbar-item" to='/'>Home</router-link>

El resto del código es simplemente la página principal y el formulario “tonto”.

Hapi.js + Vue.js inicializar el frontend
|

Hapi.js + Vue.js inicializar el frontend

El backend ya está algo configurado, por lo que voy a empezar a configurar el frontend.

Instalaré varias librerías:

  • Vue.js
  • Webpack: configurado para que funcione con HMR
  • Eslint: para que no haya errores Javascript
  • Stylelint: lo mismo para CSS
  • Buefy: una librería que combina Bulma y Vue
  • Sass loader

En vez de ir instalando una a una, usando el siguiente package.json y ejecutando npm i, lo tendremos todo instalado.

{
  "name": "hapi-frontend",
  "version": "1.0.0",
  "description": "Hapi.js frontend",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "webpack-dev-server --mode development --config webpack.config.dev.js",
    "build": "webpack --mode production --config webpack.config.production.js"
  },
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.5.4",
    "babel-eslint": "^10.0.2",
    "babel-loader": "^8.0.6",
    "css-loader": "^3.0.0",
    "eslint": "^6.0.1",
    "eslint-plugin-html": "^6.0.0",
    "eslint-plugin-vue": "^5.2.3",
    "mini-css-extract-plugin": "^0.7.0",
    "node-sass": "^4.12.0",
    "sass-loader": "^7.1.0",
    "stylelint": "^10.1.0",
    "stylelint-config-standard": "^18.3.0",
    "stylelint-webpack-plugin": "^0.10.5",
    "vue-hot-reload-api": "^2.3.3",
    "vue-html-loader": "^1.2.4",
    "vue-loader": "^15.7.0",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.10",
    "webpack": "^4.35.3",
    "webpack-cli": "^3.3.5",
    "webpack-dev-server": "^3.7.2",
    "webpack-merge": "^4.2.1"
  },
  "dependencies": {
    "buefy": "^0.7.10",
    "vue": "^2.6.10"
  }
}

Como se puede ver, existen dos scripts dentro de npm: build que compila el js y extrae los CSS, y dev, que arranca el servidor de webpack habilitando HMR (🎶 ¡ya no puedo vivir sin él! 🎶).

Ambas configuraciones de webpack usan un script en común (webpack.config.common.js):

const webpack = require( 'webpack' );
const path = require( 'path' );
// Carga los ficheros .vue
const VueLoaderPlugin = require( 'vue-loader/lib/plugin' );
// Configura stylelint
const StyleLintPlugin = require( 'stylelint-webpack-plugin' );

// Para obtener un path para los alias
function resolve( dir ) {
	return path.join( __dirname, '.', dir );
}

module.exports = {
	mode: 'production',
	// Fichero inicial del proyecto
	entry: './js/main.js',
	// Fichero final para incluir
	output: {
		filename: 'js/main.js',
		publicPath: '/dist/',
	},
	module: {
		// Reglas para los ficheros
		rules: [
			{
				test: /\.js$/,
				exclude: /node_modules/,
				loader: 'babel-loader',
			},
			{
				test: /\.vue$/,
				loader: 'vue-loader',
			},
			{
				test: /\.css$/,
				use: [
					'css-loader',
					'sass-loader',
				],
			},
		],
	},
	plugins: [
		new webpack.HotModuleReplacementPlugin(),
		new VueLoaderPlugin(),
		new StyleLintPlugin( {
			files: [ '**/*.{vue,htm,html,css,sss,less,scss,sass}' ],
		} ),
	],
	resolve: {
		extensions: [ '.js', '.vue', '.json' ],
		alias: {
			'@': resolve( '' ),
		},
	},
};

El frontend se gestiona desde el fichero main.js, que inicializará Vue y añadirá el componente principal:

import Vue from 'vue';
import Buefy from 'buefy';
import 'buefy/dist/buefy.css';

import App from './components/App.vue';

import '@/assets/scss/main.scss';

Vue.use( Buefy );

new Vue( {
	el: '#app',
	components: {
		App,
	},
	render: ( c ) => c( 'app' ),
} );

// accept replacement modules
if ( module.hot ) {
	module.hot.accept();
}

Y ya por último el componente App.vue, que muestra simplemente un poco de HTML

<template>
	<header class="hero">
		<div class="hero-head">
			<h1>{{ title }}</h1>
		</div>
	</header>
</template>

<script>
export default {
	data() {
		return {
			title: "Demo site",
		};
	},
};
</script>

<style lang="scss" scoped>
	div {

		h1 {
			color: #fff;
		}
	}
</style>

Bueno, ha sido un resumen rápido, pero bajándote el código seguro que lo entiendes fácil

Hapi.js + Vue.js modelos mejorados

Hapi.js + Vue.js modelos mejorados

En este caso voy a explicar cómo añadir una ruta en el backend para gestionar usuarios.

Antes de nada vamos a instalar tres paquetes:

  • @hapi/joi para validar los datos de entrada
  • @hapi/boom para mostrar errores HTTP de forma sencilla
  • bcrypt para encriptar la contraseña
npm i @hapi/joi
npm i @hapi/boom
npm i bcrypt

Tendremos que añadir la ruta al manifest de glee

const manifest = {
	server: {
		port: Config.get( '/server/port' ),
	},
	register: {
		plugins: [
			{
				plugin: './api/home',
			},
			{
				plugin: './api/user',
			},
			{
				plugin: './plugins/db',
				options: Config.get( '/db' ),
			},
		],
	},
};

Ahora solo falta crear el controlador para las rutas de usuarios, dos en este caso:

  • GET /user/[user] para recuperar un usuario
  • PUT /user para crear un nuevo usuario

Lógicamente aún no hay nada de autenticación, por lo que cualquiera puede crear un usuario realizando una llamada PUT a la URL indicando userName, email y password.

Para comprobar la validez de los datos introducidos, usaremos joi. Usando las opciones de la ruta, indicaremos las reglas que deberá cumplir cada parámetro introducido. Así, para recuperar un usuario, se comprobará que user sea string, alfanumérico y que tenga una longitud de 3 a 20 caracteres:

validate: {
	params: {
		user: Joi.string().alphanum().min( 3 ).max( 20 ),
	},
},

Así de fácil.

En el caso de comprobar los datos de entrada de la llamada PUT, en vez de usar params, usaremos payload:

validate: {
	payload: {
		userName: Joi.string().alphanum().min( 3 ).max( 20 ).required(),
		email: Joi.string().email().required(),
		password: Joi.string().min( 8 ).required(),
	},
},

Por último mostrar el código para crear un nuevo usuario. Primero se comprueba si existe un usuario con ese nickname o email. Si es así, se devuelve error usando boom, si no, se genera la contraseña encriptada (aquí no me he molestado mucho en ello, ya lo haré más adelante), y se crea el usuario usando el método create de moongose:

/**
 * Route handler
 *
 * @param {object} request
 * @param {object} h Hapi object
 * @returns {object}
 */
handler: async( request, h ) => { // eslint-disable-line
	try {
		// TODO: Add role
		const user = await User
			.findOne( {
				$or: [
					{ userName: request.payload.username },
					{ email: request.payload.email },
				],
			} )
			.exec();
		if ( user ) {
			return Boom.badData( 'User exists' );
		}
		const password = await bcrypt.hash( request.payload.password, Config.get( '/hash/PASSWORD_HASH' ) );
		const userData = Object.assign( {}, request.payload, { password } );
		const newUser = await User.create( userData );

		return newUser ?
			{
				response: true,
				message: 'User created',
				userId: newUser.id,
			} :
			Boom.boomify( {
				response: false,
				message: 'There was an error during user creation',
			}, { statusCode: 400 } );
	} catch ( error ) {
		return Boom.badImplementation( 'Error', { error } );
	}
},

Como último apunte, he modificado la configuración para que admita ficheros .env con los datos necesarios.

Como siempre te puedes bajar el código aquí

Hapi.js + Vue.js accediendo a mongodb

Hapi.js + Vue.js accediendo a mongodb

Como ya dije, esta aplicación estará basada en Mongodb, y usaremos mongoose como ODM.

El primer paso es instalar mongoose:

npm i mongoose

Una vez instalado crearemos un controlador que nos permita usar la BD en toda la aplicación Hapi.js. Para ello haremos uso de los decorate del servidor. Los decorations permite extender objectos ofrecidos por Hapi.js, en nuestro caso server y request. Usando un plugin nos conectaremos a mongodb usando mongoose y añadiremos ese objecto con los decorate.

Creamos el fichero /plugins/db.js con el siguiente código:

/**
 * DB controller
 *
 * It uses Mongoose and "stores" it in the server and the request using `decorate`
 */
const mongoose = require( 'mongoose' );

exports.plugin = {
	name: 'db',
	register: async function( server, options ) {
		mongoose.connect( options.url, { useNewUrlParser: true } );
		const db = mongoose.connection;
		// eslint-disable-next-line
		db.on( 'error', console.error.bind( console, 'connection error:' ) );
		db.once( 'open', function() {
			server.decorate( 'server', 'db', mongoose );
			server.decorate( 'request', 'db', mongoose );
			// eslint-disable-next-line
			console.log( 'DB connected' );
		} );
	},
};

Para configurar la conectividad a mongodb tendremos que añadir los datos a la /config/index.js

const config = {
	server: {
		port: 3001,
	},
	website: {
		name: `WP Desk`,
	},
	db: {
		url: 'mongodb://localhost/wpdesk',
	},
};

Y en el manifiest usado por glue, tendremos que añadir el nuevo plugin y las nuevas opciones de conexión:

const manifest = {
	server: {
		port: Config.get( '/server/port' ),
	},
	register: {
		plugins: [
			{
				plugin: './api/home',
			},
			{
				plugin: './plugins/db',
				options: Config.get( '/db' ),
			},
		],
	},
};

Ya tenemos casi todo configurado, ahora vamos a empezar con un ejemplo creando un esquema de moongose que nos permite acceder a colecciones de mongodb.

Lo más común es tener una colección de usuarios, que tendrá los siguientes campos:

  • userName: de tipo String,
  • firstName: de tipo String,
  • lastName: de tipo String,
  • email: de tipo String,
  • role: que referencia a otro elemento de otra colección,
  • isEnabled: de tipo Boolean,
  • password: de tipo String,
  • resetPassword: un objeto representado por:
    • hash: de tipo String,
    • active: de tipo Boolean,

También crearemos un método estático que devuelva todos los elementos de la colección users para realizar pruebas:

/**
 * User model based on Mongoose
 */
const mongoose = require( 'mongoose' );
const Schema = mongoose.Schema;

// Mongoose schema
const userSchema = new mongoose.Schema( {
	userName: String,
	firstName: String,
	lastName: String,
	email: String,
	role: Schema.Types.ObjectId,
	isEnabled: Boolean,
	password: String,
	resetPassword: {
		hash: String,
		active: Boolean,
	},
} );

/**
 * User static model findAll
 *
 * @returns {array}
 */
userSchema.static( 'findAll', async function() {
	const result = await new Promise( ( resolve, reject ) => {
		this.model( 'User' ).find( {} ).exec( ( error, data ) => {
			if ( error ) {
				reject( error );
			}
			resolve( data );
		} );
	} );
	return result;
} );

const User = mongoose.model( 'User', userSchema );

module.exports = User;

Ya está todo, ahora solo modificamos el handler de la ruta home.js para mostrar los valores de findAll:

/**
 * Route handler
 *
 * @param {object} request
 * @param {object} h Hapi object
 * @returns {object}
 */
handler: async( request, h ) => { // eslint-disable-line
	try {
		const result = await User.findAll();
		return result;
	} catch ( error ) {
		return { error: 500 };
	}
},

Puedes bajarte el código aquí

Hapi.js + Vue.js empezando con las routes

Hapi.js + Vue.js empezando con las routes

Empezamos a mostrar contenido, hasta ahora solo mostraba un error 404 cuando se accedía a la URL del servidor.

Ahora vamos a añadir una route simple que muestre un objeto JSON cuando se acceda a la home. Recordad que la parte del servidor solo va a devolver respuestas JSON que tratará el frontend.

Usaremos plugins para tratar todas las rutas que incluiremos en glue. En este ejemplo tan solo añadiremos un route al server que devuelva un mensaje:

const Config = require( '../config' );

const websiteName = Config.get( '/website/name' );

const register = function( server, serverOptions ) { // eslint-disable-line
	server.route( {
		method: 'GET',
		path: '/',
		options: {
			tags: [ 'api', 'home' ],
			description: 'Server home',
			notes: 'Server home',
			auth: false,
		},
		handler: function( request, h ) { // eslint-disable-line
			return {
				message: `Welcome to ${ websiteName }`,
			};
		},
	} );
};

module.exports = {
	name: 'api-home',
	dependencies: [],
	register,
};

Ahora solo falta añadir el plugin en el manifest.js

const manifest = {
	server: {
		port: Config.get( '/server/port' ),
	},
	register: {
		plugins: [
			{
				plugin: './api/home',
			},
		],
	},
};

Puedes bajarte el código aquí

Traduce tu plugin para Gutenberg

Traducir los plugins desde WordPress es bastante fácil, tan solo tienes que ejecutar este comando para generar los ficheros .pot.

wp i18n make-pot .

Una vez generados los ficheros de traducción, yo uso PoEdit, el siguiente paso es generar los ficheros JSON, ya que Gutenberg es lo que usa.

Para ello utilizaremos la herramienta po2json, que podrás instalar ejecutando:

npm i -g po2json

Cuando se haya instalado tendremos que generar el fichero json ejecutando desde el directorio donde están los ficheros de traducción:

po2json mi-plugin-es_ES.po mi-plugin-es_ES-mi-plugin-handler.json -f jed

Ahora viene la parte más complicada, y digo complicada porque sinceramente he tenido que mirar el código porque algo me fallaba cuando seguía lo que decía la documentación.

El nombre del fichero JSON tiene el siguiente formato [dominio de traducción]-[idioma]-[handler del fichero].json, total nada.

  • Dominio será el que usemos para traducir, en el ejemplo sería mi-plugin:
__( 'Hola que tal', 'mi-plugin' );
  • Idioma es el código del idioma, en este caso es_ES
  • Y por último el handler del fichero es el primer parámetro que usamos en wp_enqueue_script

Lo podemos ver todo en un ejemplo final:

wp_enqueue_script(
	'mi-plugin-handler', // El handler mencionado anteriormente
	$blocks_script, // Nuestro path
	[
		'wp-i18n', // De referenciar al menos a wp-i18n
	],
);

wp_set_script_translations( 'mi-plugin-handler, 'mi-plugin', plugin_dir_path( __FILE__ ) . 'languages' );