Qiitaのタグから記事を表示する小さなSPA


タグをクリックすると、Qiita API v2 `/api/v2/tags/:tag_id/items` で記事を取得して表示する小さなSPA。[Craft-UIKit](https://github.com/craftkit/craft-uikit) による簡単な実装例です。

## A Pen

<p class="codepen" data-height="265" data-theme-id="0" data-default-tab="html,result" data-user="raykitajima" data-slug-hash="XLVmNm" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="qiita_small">
  <span>See the Pen <a href="https://codepen.io/raykitajima/pen/XLVmNm/">
  qiita_small</a> by Ray Kitajima (<a href="https://codepen.io/raykitajima">@raykitajima</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>


※ 未認証のAPIコール数の上限に直ぐに到達する気がするので下のコードをコピペでローカルで試してみて下さい。
※ Qiita上で browser back-forward も機能するんですね。よくできてる。
※ 0.5x にすると全体がみえる感じ

## クラスとページのレイアウト

![components.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/34821/9429789e-b50a-fff5-51ac-36a938b30be6.jpeg)

クラスとページレイアウトの関連図。

## レイアウトコード

```PageController.js 
viewDidLoad(callback){
	this.appendView(new Header());
	this.tags = new Tags({delegate:this});
	this.items = new Items({delegate:this});
	let container = new Container();
	container.loadView();
	container.appendView(this.tags);
	container.appendView(this.items);
	this.appendView(container);
	this.appendView(new Footer());
}
``` 
モバイルアプリ風のレイアウトコード。


## クラスの概略
 
| クラス | 役割 |
|:-----------|:------------|
| PageController | RootViewController / ヒストリーの管理 |
| Header | ヘッダー(装飾) |
| Container | TagsとItemsを保持するレイアウト用ラッパー |
| Tags | タグのリスト / 一覧はハードコード<br>クリックで `Items` にタグ付けされた記事一覧を表示 |
| Items | タグに関連した記事を表示するスペース |
| Footer | フッター(装飾) |


## 中身を触ってみる

PageController は RootViewController なので Context から取得できます。
例えば console から手動でタグをセレクトするのはこんな感じ。

```JavaScript 
window.Craft.Core.Context.getRootViewController().selectTag('Python')
``` 

※ CodePenでは動かないですのでローカルでお試し下さい

## コード

```index.html 
<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="utf-8">
	<title>Qiita small</title>
	<script src="https://unpkg.com/@craftkit/craft-uikit/dist/craft-uikit.min.js"></script>
	
	<script>
		var default_tags = [{"followers_count":49144,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/65275b3d3423ddf27cec968034cca8850e4ca97b/medium.jpg?1490807160","id":"JavaScript","items_count":22217},{"followers_count":41399,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/bcef5ec765c6eb84e5ec3300ce1ff16850bb3e14/medium.jpg?1512821048","id":"Python","items_count":22130},{"followers_count":27643,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/0337fbcbff62fb8fa5d0b8be5c3b47d1115d91fc/medium.jpg?1418548649","id":"Ruby","items_count":18847},{"followers_count":30315,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/1420ca7652bf7c28bdab51be6f5bcd4640df27b1/medium.jpg?1478446216","id":"PHP","items_count":13911},{"followers_count":16569,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/6ae023831e95ee12e287c04656a8d128e6834bce/medium.jpg?1489110713","id":"Rails","items_count":12673},{"followers_count":23188,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/f8da947d9c7f46fd4b061b2df9538615f2a587b6/medium.jpg?1498197309","id":"iOS","items_count":12514},{"followers_count":29890,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/f577d33e0031b68387fcf0168098b070101a48ab/medium.jpg?1475471164","id":"Android","items_count":11001},{"followers_count":5051,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/e4c5b2934424684d712ca939b6d82a4da7722844/medium.jpg?1479386754","id":"AWS","items_count":10679},{"followers_count":32090,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/1bfaf60121121d7dec866c83d4c4453347ec93e2/medium.jpg?1436171387","id":"Java","items_count":10426},{"followers_count":6172,"icon_url":"https://s3-ap-northeast-1.amazonaws.com/qiita-tag-image/8924010780db484a83145542a3e49c6c2084ecb7/medium.jpg?1401738498","id":"Swift","items_count":10344}];

		var App = {
			didBootApplication : function(){
				let rootViewController = new PageController();
				Craft.Core.Context.setRootViewController(rootViewController);
				rootViewController.bringup();
			}
		};
		var Util = {
			escape : function(str){
				return str.replace(/</g,'&lt;').replace(/>/g,'&gt;');
			}
		};
		window.onload = function(){
			Craft.Core.Defaults.ALLOW_COMPONENT_SHORTCUT = true;
			Craft.Core.Bootstrap.boot(App);
		};
		
		class PageController extends Craft.UI.DefaultRootViewController {
			constructor(){
				super();
				this.tags = '';
				this.items = '';
			}
			viewDidLoad(callback){
				this.appendView(new Header());
				this.tags = new Tags({delegate:this});
				this.items = new Items({delegate:this});
				let container = new Container();
				container.loadView();
				container.appendView(this.tags);
				container.appendView(this.items);
				this.appendView(container);
				this.appendView(new Footer());
			}
			resolveRoutingRequest(path,event){
				if( !path ){ path = ''; }
				let match = path.match(/(\w*)/);
				let tag = match[1];
				if( tag ){ this.selectTag(tag,event); }
			}
			selectTag(tag,event){
				if( !event ){
					// the argument `event` is a popstate event object
					// you should update history if it is not passed.
					this.pushState({state:{tag:tag},path:'/#/'+tag});
				}
				document.title = "Tag: "+tag;
				this.items.selectTag(tag);
			}
			style(componentId){
				return `
					* { box-sizing:border-box; margin:0; padding:0; }
					.root { display:flex; flex-direction:column; width:75%; margin-left:auto; margin-right:auto; }
				`;
			}
			template(componentId){
				return `<div id="root" class="root"></div>`;
			}
		}
		class Container extends Craft.UI.DefaultViewController {
			style(componentId){
				return `
					.container { display:flex; flex-direction:row; margin-top:15px; width:100%; }
				`;
			}
			template(componentId){
				return `<div class="container"></div>`;
			}
		}
		class Header extends Craft.UI.View {
			style(componentId){
				return `
					.header{ margin:0px; color:#54c524; border-width:0px 0px 1px 0px; border-style:solid; }
				`;
			}
			template(componentId){
				return `<h1 class="header">Qiita small</h1>`;
			}
		}
		class Footer extends Craft.UI.View {
			style(componentId){
				return `
					.footer { border-style:solid; border-width:1px 0px 0px 0px; border-color:#54c524; margin-top:20px; color:#54c524; }
				`;
			}
			template(componentId){
				return `<div class="footer">This is Craft-UIKit example app.</div>`;
			}
		}
		class Items extends Craft.UI.View {
			constructor(options){
				super();
				this.delegate = options.delegate;
				this.items = ''; // current items
				this.itemsCache = {};
			}
			selectTag(tag){
				if( this.itemsCache[tag] ){
					this.items = this.itemsCache[tag];
					this.renderView();
				}else{
					this.fetchItems(tag, () => { 
						this.items = this.itemsCache[tag];
						this.renderView();
					});
				}
			}
			fetchItems(tag,callback){
				fetch(`https://qiita.com/api/v2/tags/${tag}/items?page=1&per_page=10`)
					.then( res => { return res.json(); } )
					.then( items => { this.itemsCache[tag]=items; if(callback){ callback(); } });
			}
			style(componentId){
				return `
					a { text-decoration: none; }
					:host { width:70%; }
					.root { display:flex; flex-direction:column; overflow:scroll; }
					.item { 
						display:flex; flex-direction:row; 
						line-height:28px; font-size:16px; cursor:pointer; margin-left:20px;
						animation:show 0.1s ease-in 0s 1 normal both; }
					.item:hover { background-color:#eee; }
					@keyframes show {
						0% { margin-left:20px; opacity:0; }
						100% { margin-left:0px; opacity:1; }
					}
				`;
			}
			template(componentId){
				if( !this.items ){ this.items = []; }
				return `
					<div id="root" class="root">
						${ this.items.map( (item, idx) => `
							<div class="item" style="animation-delay:${idx*30}ms">
								<div style="margin-right:10px;">&#x1F4C4;</div>
								<div><a href="${item.url}" title="${item.url}" target="_new">${Util.escape(item.title)}</a></div>
							</div>
						`).join('')}
					</div>
				`;
			}
		}
		class Tags extends Craft.UI.View {
			constructor(options){
				super();
				this.tags = '';
				this.delegate = options.delegate;
			}
			viewWillAppear(callback){
				this.tags = default_tags;
				if(callback){ callback(); }
			}
			viewDidAppear(callback){
				this.renderView();
				if(callback){ callback(); }
			}
			style(componentId){
				return `
					:host { all: initial; width:25%; } /* block cascading. font is colored by default black */
					.root { display:flex; flex-direction:column; width:100%; height:100%; }
					.tag { width:100%; line-height:32px; font-size:18px; cursor:pointer; }
					.tag:hover { background-color:#eee; }
				`;
			}
			template(componentId){
				if( !this.tags ){ this.tags = []; }
				return `
					<div id="root" class="root">
						${ this.tags.map( tag => `
							<div class="tag" onclick="${componentId}.delegate.selectTag('${tag.id}');">
							&#x1F3F7; ${tag.id}
							</div>
						`).join('')}
					</div>
				`;
			}
		};
	</script>
</head>
<body id="CraftRoot">
</body>
</html>
```