NFCなキーボードカードを作って運用してみる。
こんにちは。Next.jsアンチのキーボードオタクです。
今回は天下一キーボードわいわい会 Vol.7に参加した際に使用したキーボードカードと、そのカードに書き込むためのWebサイトを作成したので、紹介しようと思います。
なに作ったの?
Webサイトと、それに連動するNFCカードです!
キーボードカードってなに?
自作キーボードや、カスタムキーボードのイベントではよく、自分のキーボードを紹介するためのカードを記述することが多々あります。
こんなのとか、
こんなの
from TaehaTypes post
自作キーボードやカスタムキーボードは十人十色なので、どのようなキーボードなのかを示す情報が必要です。
なんか面白いネタはないものか
特別な理由はないです。 面白いか否か。
毎回手書きしてというのもよいのですが、それだとなにか面白みに欠けます。
どうせお高いキーボードを持ち込むわけで、そこでなにか自分の特徴を出せるようなものはないかと考えながら、"keyboard card"だとか、"keyboard meetup card"を調べながら、r/MechKeyを探索していると、とあるredditを見つけました。
NFCカードをキーボードカードとともにスリーブに入れて、それを見てある程度の概要を把握してもらって、もう少し詳しい情報はカードを読み取った先のWebサイトで見てもらうというもののようです。
現地でキーボードを見てもらうときの以下のようなデメリットがイイ感じに消えているのではないでしょうか?
- 視覚情報が多すぎると、見るだけだと大変
- 記述する側もできれば小さいカードに大量の情報は書きたくない
- かといってWebサイトへ誘導するのにQRコードは手間(カメラの起動は地味に面倒)
- A4用紙は場所を取るし、印刷が面倒
ほかには?
いくつか理由はありますが、自分は追加でこんな理由で採用しました。
- キーボードの情報を一元管理できるDBを作る予定があった
- キーボードを売却するときなどに、ついでに沿えてあげると特別感がある
- キーボードのキャリーバッグに入れておける
だいたいこの辺が理由です。
ということで、カードを作りました。
あとはAmazonでNFCカードを購入して、スリーブに入れてあげれば完成です。
コンビニでシール紙を印刷できるサービスもあるので、これで作って貼ったり、NFCカード自体を印刷サービスに投げる(1枚1000円前後~)でもよいでしょう。
キーボードの情報を一元管理したい。
キーボードの情報というのは、ある程度一元管理ができます。
大まかな情報は
- キーボード(ケースやプレート)の情報
- キースイッチの情報
- そのほか
くらいの情報で済むことが多いです。
要求
- 手軽に扱えるDB(あるいはデータソース)
- 固定のフォーマット(表示、データともに)
- 型安全
- 素早く構築
- 特定のフレームワークに依存しすぎない
といった形で、最終的にAstro + jsonが無難かなと感じました。
CMSは後々採用するかもしれません。
Why Astro?
なぜAstroを選んだかはそこまで深く考えていません。
- Next.jsを使いたくない
- Remixでもよいが、SSGができない
- そもそもReactを使いたくない
- SvelteKit SSG?
そんな感じで脳内の動きをしていました。
1. Next.jsを使いたくない
割とそのままです。
Web標準と逆行していたり、Vercelの実質的なベンダーロックインがあったり。
最近はとくに、個人的にあまりいい心象がないです。
2.Remixでもよいが、SSGができない。
Static Assetsに対応したことで、Cloudflare WorkersでRemixを快適にDeployできるようになったので、Remixを使おうと思いましたが、SSR/SPAをそもそも別に求めていないなと感じました。
3. そもそもReact(あるいはそれらをベースとしたフレームワーク)を使いたくない
ここ最近、Svelte/SvelteKitをよく触っていたこともあり、React自体から離れていました。
Reactを批判するわけではないですし、むしろ素晴らしJavascriptライブラリだと思っています。
が、個人的には、useState()だらけになるエディタを見て苦痛になっていました。
あとは.jsx/.tsxから離れてみていろいろと視野を広げたいなと思っていました。
4. SvelteKit SSG?
A website is needed. A web application is not needed.
とは言ったものの、SvelteKit SSGも全然ありだと思います。
Svelte 5もリリースされたので、またどこかでしっかりと触りたいですね。
そのほか
コンテンツコレクションが刺さった。が結構大きいです。
最初はmarkdownでだいたいのフレームワークに対応できるようにしていましたが、一元管理したいのと、あまりブログとかとして運用するつもりがなかったので、markdownをやめたいなと思ってました。
最終的にはコンテンツコレクションにjsonが使えることを知ったので、これにしました。
microCMSとかで引っ張ってくるとかでもよかったのですが、あまり外部に依存したくなかったので、当面はこのままの予定です。
実装
DB
{
"keyboard": "TGR x Linworks Dolice",
"draft": false,
"brand": "TGR x Linworks",
"plate": "Aluminium",
"mount": "Sandwitch mount",
"keycaps": "Keykobo WoB",
"switches": {
"name": "Cherry MX Butter Browns",
"attributes": {
"weight": "63.6g",
"type": "Tactile",
"lube": "None",
"film": "None"
}
},
"youtubeId": "QLTN0BT2oQs",
"thumbnailImage": "DSC00988.jpg",
"other": "The WASD keycaps are using those of the G81-1800HAU.",
"owner": "L4Ph",
"tags": ["TGR", "Linworks"]
}
こんな感じのjsonで管理していて、それぞれ必要な分だけ表示しています。
import { glob } from "astro/loaders";
import { defineCollection, z } from "astro:content";
const keyboardCollection = defineCollection({
loader: glob({ pattern: "**/*.json", base: "./src/data/keyboard" }),
schema: z.object({
keyboard: z.string(),
draft: z.boolean(),
brand: z.string(),
plate: z.string(),
mount: z.string(),
keycaps: z.string(),
switches: z.object({
name: z.string(),
attributes: z.object({
weight: z.string(),
type: z.string(),
lube: z.string(),
film: z.string(),
}),
}),
youtubeId: z.string().optional(),
thumbnailImage: z.string(),
other: z.string(),
owner: z.string(),
tags: z.array(z.string()),
}),
});
export const collections = {
keyboard: keyboardCollection,
};
上記のような感じにContent Collections APIで管理しています。
そのうち必要になったらjsonを置く場所を変えて、エンドポイント変えれば動くようにしています。
カードコンポーネント
ほとんどdaisyUIのCardコンポーネントを再利用しています。
少し違うところといえばImage Componentを使っているところくらいです。
もともとSvelteで書いていたのですが、とくに理由もなかったので、Astroに移し替えました。
Svelteをやめた理由は、純粋に@astrojs/svelte
のSvelte 5サポートが怪しかったからです。
使っていた感じではそこまで問題なかったのですが、警告が邪魔だったのと、そのうちコード増えてきて$rune
が使えないの出てくると面倒だと思って、正式サポートまで待っています。
(なぜかpeerDependenciesが"svelte": "^4.0.0 || ^5.0.0-next.190"
で止まっている。)
---
import { Image } from 'astro:assets';
import getImagePath from "../utils/get_image_path"
type Props = {
id: string;
keyboard: string;
description: string;
owner: string;
thumbnailImage: string;
tags: string[]
}
const {id, keyboard, description, owner, thumbnailImage, tags }:Props = Astro.props;
---
<div class="card bg-base-100 w-96 h-96 shadow-xl mx-8 my-4">
<figure class="h-52">
<a href={`keyboard/${id}`}>
<Image src={getImagePath(`${id}`, `${thumbnailImage}`)} alt={`${keyboard} by ${owner}`} format="avif" transition:name={`keyboard-${id}`} />
</a>
</figure>
<div class="card-body">
<h2 class="card-title">
<a href={`keyboard/${id}`}>{keyboard}</a>
<div class="badge badge-primary">{owner}</div>
</h2>
<p class="line-clamp-2">{description}</p>
<div class="card-actions justify-end">
{tags.map((tag, index) => (
<div class="badge badge-outline" data-key={index}>{tag}</div>
))}
</div>
</div>
</div>
このコンポーネントをindex.astroでglobしたjson分表示しているだけです。
お手軽でいいですね。
キーボードのページ
src/pages/keyboard/[...id].astro
としています。
---
import { getCollection } from 'astro:content';
import KeyboardPageLayout from '../../layouts/KeyboardPageLayout.astro';
import { YouTube } from 'astro-embed';
import { Image } from 'astro:assets';
import getImagePath from "../../utils/get_image_path"
export async function getStaticPaths() {
const keyboardEntries = await getCollection('keyboard');
const paths = await Promise.all(
keyboardEntries.map(async (entry) => {
return {
params: { id: entry.id },
props: { entry },
};
})
);
return paths;
}
const { entry } = Astro.props;
---
<KeyboardPageLayout title={`${ entry.data.keyboard } by ${ entry.data.owner }`} >
<article class="prose w-full">
<Image
src={getImagePath(`${entry.id}`, `${entry.data.thumbnailImage}`)}
alt={`${entry.data.keyboard} by ${entry.data.owner}`}
format='avif'
transition:name={`keyboard-${entry.id}`}
/>
<p>{ entry.data.description }</p>
<h2>YouTube</h2>
{entry.data.youtubeId ? <YouTube id={ entry.data.youtubeId } posterQuality="max" /> : <YouTube id="hDzCwbLuTO4" posterQuality="max" />}
<h2>Keyboard</h2>
<h3>{entry.data.keyboard}</h3>
<ul>
<li>Brand: {entry.data.brand}</li>
<li>Keycaps: {entry.data.keycaps}</li>
<li>Mount: {entry.data.mount}</li>
</ul>
<h2>Switch</h2>
<h3>{entry.data.switches.name}</h3>
<ul>
<li>Type: {entry.data.switches.attributes.type}</li>
<li>Weight: {entry.data.switches.attributes.weight}</li>
<li>Lube: {entry.data.switches.attributes.lube}</li>
<li>Film: {entry.data.switches.attributes.film}</li>
</ul>
</article>
</KeyboardPageLayout>
こちらもとくに何も考えずに、class="prose"
を充てて、記述しています。
あくまでDBをそのまま表示するような使い方をしたいので、markdownみたいな自由な記述ではなく、固定したレイアウトにjsonから取得した中身を表示しています。
Youtubeコンポーネントはastro-embedのYoutubeコンポーネントを使用しています。
よしなにやってくれるので、オススメです。
YoutubeのIDが設定されていない場合は、下記の動画を表示しています。
布教したいのと、自分がTyping soundの動画投稿をサボっている戒めのために設定しています。
Glarsesを見て『ください』(戒律)
Navbar
ほとんどdaisyUIのから変更していません。
この辺のレイアウトまで加味してスタイル充てられているのは、爆速でWebサイト作成したいときには本当に便利だと思います。
The most popular component library for Tailwind CSS
というだけはある。
Deploy
GitHub Actionsなり、手元のwranglerなりでastro build
してからCloudflare Pagesにdeployしています。
SSGなので、GitHub Pagesだろうが、Vercelだろうが、Netlifyだろうがなんでもいいのですが、画像がそれなりのサイズあるので、コンテンツのキャッシュを考えるとCloudflare Pagesにしています。
そのうちR2に画像上げるかもしれないというのもあります。
いまのところは特になにも困っていません。
実際に運用してみてどうだったか
天キーに持ち込んで運用してみました。
pros、consあったので、纏めてみます。
pros
- 耐久性が高い
- 統一感がある
- 使いまわしが効く
- 割と面白がって見てもらえる
cons
- NFCという単語が、一般の人には馴染みがない
- QRコードが一周回って視覚的に分かりやすい
- ISO/IEC 7810(今回採用したNFCカードなどの共通規格)だと文字が小さい
だいたいメリットもデメリットもそれなりという感じでしょうか?
今回はほとんど告知もしていないので、ちゃんとNFCがなにかを分かるものを用意すれば対策出来る気がします。
今回、紙に印刷して、それをスリーブに入れました。
が、紙に印刷すると想像してた色よりもだいぶ薄く出てくるので、もう少しコントラスト高めてもよかったかなと思っています。
でも、GMK Noel、好きなんですよね...(カラーコード公開されていたので、それを流用しています。)
次回に繋げられるとよいですね。
最後に
グダグダ書きましたが、総合的に見ると、いい経験になったかなと思います。
- キーボードの管理のモチベも上がる
- Inkscapeの使い方を覚えた
- 楽しんでもらえた
- 久しぶりにカッターなどで、図画工作みたいなことができた
KeebCardの元ネタや、フィルムなどのロゴを提供していただいた、Omar AlKhalili氏に感謝を。
これが無ければ正直カードを作るモチベがだいぶ減っていたと思うので、とても助かりました。
キーボードのデータの提供に協力していただいた友人にも感謝します。
OSSとして
後々、このWebサイトのテンプレートと、(許可が得ることができれば)カードのジェネレータのWebサイトなどを作成しようと思っています。
キーボードのイベント自体が増えてきたので、こういう形で貢献できればと思っています。
ロゴなどのライセンスの問題があるので、時間はかかるかもしれませんが、GitHubのテンプレートとして公開できるよう、頑張ってみます。
(どちらかというとコミットメッセージのお掃除に時間がかかりそうですが...)
What kind of keyboard did you write it on?
で書きました!
早速この場で活用できて嬉しいです!
Bye!👋
SNS
GitHub
X
Youtube
Discussion