Blog app: tags

Published on Sunday 01 May 2016. Tagged as CSSPHP.

I've added a tagging system to my blog app: each post can be tagged in multiple categories. Browsing through a list of tagged items is also possible.

Preliminary changes

Since a tag system allows for a post to be tagged in multiple categories, I need an additional table in MySql to store the tag relations:

  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `postid` bigint(20) NOT NULL,
  `name` varchar(40) NOT NULL,
  PRIMARY KEY (`id`)

To be able to resolve the tag elements as fast as possible, I decided to store the tag information a text field 'tags' in the 'posts' table as well. This allows me to resolve the tag links without joining the 'posts' table with the 'tags' one when displaying the post's info:

ALTER TABLE `posts` ADD `tags` TEXT NOT NULL AFTER `description`;

Since each post will now be accessible with multiple URLs, I have to add the <link rel="canonical" tag to the HTML head in the ViewPage->process() method:

case 'meta':
	out( '<title>'.$app['title'].($this->data['request'][0]=='index'?'':' | '.$title ).'</title>' );
	out( '<meta name="description" value="'.( isset( $content['description'] )?$content['description']:$app['desc'] ).'">' );
	if( $this->data['type']=='post' ) out( '<link rel="canonical" href="'.ROOT.'/post/'.$this->data['content']['slug'].'">' );

Similar to the search page, 'tags' is a special page that has to be declared in the menu in content/config.php:

	'index'=>array(		'link'=>'/', 			'label'=>'<svg><use xlink:href="{{path}}/inc/icons.svg#home-icon"/></svg>Home'),
	'tags'=>array(		'link'=>'/tags', 		'label'=>'Tags' ),
	'search'=>array(	'link'=>'/search', 		'label'=>'Search'),
	'about'=>array(		'link'=>'/page/about', 	'label'=>'About')

The controller changes

First the method to display the list of tags with their post counts:

function getTags(){
	$this->extendData( array( 
	) );

Then the method to display the list of posts tagged with a specific tag, included the pagination:

function getTag(){
	if($this->request[2]=='post') return $this->getTaggedPost( $tag );
	$obj=$this->model->getTagged( $tag, $this->request[2] );
	$this->extendData( array( 
		'title'=>'Posts tagged with "'.$tags[$tag]['name'].'"', 
	) );			

And finally the method to display a single post, including the special navigation while in 'tagged' mode:

function getTaggedPost( $tag ){
	$item=$this->model->getPost( $slug );
	if( !$item ) return $this->get404();
	$prevNext=$this->model->getPrevNextTagged( $item['publishdate'], $tag );
	$this->extendData( array( 
	) );

The model changes

The method to retrieve the list of tags with their post count:

function getTags(){
	$r=$this->db->query('SELECT LOWER(name) AS tag, name, count(*) AS count FROM tags GROUP BY tag ORDER BY name');
	return $r->fetchAll( PDO::FETCH_GROUP | PDO::FETCH_UNIQUE );

The method to retrieve the list of posts tagged with a specific tag and its pagination:

function getTagged( $tag, $thisPage, $perPage=10 ){
	$r=$this->db->prepare( 'SELECT COUNT(*) FROM tags WHERE LOWER(name)=:tag' );
	$r->execute( array( ':tag'=>$tag ) );
	$pagination=$this->getPagination( $thisPage, $perPage, $count );
	$r=$this->db->prepare( 'SELECT slug, subject, publishdate, description, tags FROM tags a, posts b WHERE LOWER(name)=:tag AND ORDER BY publishdate DESC'.$pagination['queryAdd'] );
	$r->execute( array( ':tag'=>$tag ) );
	return array( 'list'=>$r->fetchAll( PDO::FETCH_ASSOC ), 'pagination'=>$pagination );	

And finally the previous and next links when in 'tagged with...' mode:

function getPrevNextTagged( $date, $tag ){
	$prevNext=array( 'prev'=>null, 'next'=>null );
	foreach( $prevNext as $key=>$value ){
		$r=$this->db->prepare('SELECT slug,subject FROM tags a, posts b WHERE LOWER(name)=:tag AND AND publishdate'.($key=='prev'?'<':'>').':date ORDER BY publishdate '.($key=='prev'?'DESC':'').' LIMIT 1');
		$r->execute( array( ':tag'=>$tag, ':date'=>$date ) );
		$prevNext[ $key ]=$r->fetch(PDO::FETCH_ASSOC);
	return $prevNext;

The view changes

To render the list of tags and their post count:

function renderTags(){
	extract( $this->data );
	if( sizeof( $content )==0 ) return out('<p>No tags found.</p>');
	out( '<ul class="tags">' );
	foreach( $content as $key=>$item ){
		extract( $item );
		$num=$count.' post'.( (int) $count==1?'':'s' );
		out("<li><a href=\"$link\">$name - $num</a></li>");
	out( '</ul>' );

The list of posts tagged with a specific tag, including the navigation:

function renderTagged(){
	extract( $this->data );
	if( sizeof( $content )==0 ) return out('<p>No items found.</p>');
	out( '<ul class="list">' );
	foreach( $content as $item ) $this->renderItem( $item, $link );	
	out( '</ul>' );
	$this->renderPagination( $pagination, '/tag/'.$tag.'/' );

And finally, both in the rendering of the lists as in the rendering of the individual post, the tags are resolved using the 'tags' field rather than using the separate 'tags' table to gain performance. This is done by calling the ViewPage->getTagLinks( $tag )method:

function getTagLinks( $val ){
	$tags=array_filter( explode( ', ', $val ) );
	if( sizeof( $tags )==0 ) return '';
	return 'Tagged as '.implode(', ', array_map( function( $val ){ 
		return '<a href="'.ROOT.'/tag/'.strtolower($val).'">'.$val.'</a>'; 
	}, $tags ) );

The CSS changes

I've added a few lines of CSS to display the list of tags and their post counts:

/* tags */
.tags a{display:block;font-size:1.4em;color:#455a64;text-decoration:none;line-height:60px;padding:0 16px;border-bottom:solid 1px #b6b6b6}
.tags a:hover{background:#f0f0f0}

And a line to enlarge the tap target of the tag links on each post's info line:

.info a{display:inline-block;padding:10px;margin:0 10px;border:solid 1px #b6b6b6;border-radius:10px;color:inherit;text-decoration:none}

Additional updates had to be made to the admin part: making sure the tags can be edited and are updated correctly. The admin part of this blog app extends each class to add CRUD functions. But more on this in future posts...

The Blog app project on GitHub