CUI Portfolioを作ったはなし
はじめに
この記事はLOCAL Students Advent Calendar 2021と鈴鹿高専 Advent Calendar 2021の記事です.
鈴鹿高専アドベントカレンダーに関しては, 鈴鹿高専に関わる方(鈴鹿高専の学生と関わる方という解釈)で記事を書かせてもらうことになりました. (takumaくんから許可は頂いています)
後期末テストや創造工学で過去に例を見ない忙しさだったということで同じ記事を登録しているのを許してください.
はじめに(1)
ある日
とのツイートをしたところ,
とTLのガチプロの方からアドバイスを頂いたのと, やはりコマンドの機能の実装などなどが楽しそう!!と思ったので作ることにしました.
というわけでできたのがこれ
当初, Vue/Nuxtを使おうと思っていたのですが, このツイートの後のサマーインターンの期間でReactを触りまくった結果, React最高!!となったのでNext.js + TypeScript + tailwindcssで実装しています.
しくみ
コマンドの入力
inputでユーザーからcommand
を取得し押されたKeyがEnterだった際に, command
をhandler
に渡して, command
に応じてコンポーネントを返すというのが, ざっくりとした仕組みです.
{/* 省略 */}
const Terminal: FC = () => {
const [command, setCommand] = useState('');
const [replies, setReplies] = useState([]);
const [logs, setLogs] = useState([]);
const [currentDir, setCurrentDir] = useState(GENSHI_PATH);
const [isFormatted, setIsFormatted] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCommand(e.target.value);
};
const handleOnEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
const res = handler(
command,
currentDir,
setCurrentDir,
isFormatted,
setIsFormatted
);
setReplies([...replies, res]);
setLogs([...logs, { command: command, dir: currentDir }]);
setCommand('');
if (res === 'clear') {
setReplies([]);
setLogs([]);
}
}
};
{/* 省略 */}
return (
{/* 省略 */}
<div className='bg-ubuntu-terminal opacity-90 h-3/6 shadow-2xl rounded-b-xl pl-3 overflow-auto text-xl'>
{logs.map((log: { command: string; dir: string }, idx: number) => (
<div key={idx}>
<span className='tracking-tight font-ubuntu_terminal'>
<span className='text-ubuntu-terminal-text'>
genshi@addtobasic
</span>
<span className='text-white'>:</span>
<span className='text-blue-500'>~</span>
<Directory dir={log.dir} />
<span className='text-white'> $ </span>
</span>
<span className='font-ubuntu_terminal text-white'>
{log.command}
</span>
{replies[idx]}
</div>
))}
{/* 省略 */}
<input
className='bg-transparent focus-within:outline-none w-1/2 font-ubuntu_terminal text-white'
id='command-area'
type='text'
autoComplete='off'
value={command}
onChange={handleChange}
onKeyPress={handleOnEnter}
onKeyDown={handleOnTab}
/>
<div id='bottom' style={{ float: 'left' }} />
</div>
);
};
入力されたコマンドはlog
に保存しておいて
という感じになっています.
コマンド
コマンドの結果を返すhandlerは入力されたcommandが ls
やls aaaa
など, スペースが入っていたり雑なオプションが入力されている状態でcommnad not foundが出たらかなしいのでtrim()
やreplace()
などで上手く抽出する必要があります.
const handler = (
inputCommand: string,
currentDir: string,
setCurrentDir: (currentDir: string) => void,
isFormatted: boolean,
setIsFormatted: (isFormatted: boolean) => void
) => {
// 入力から前後のスペースを削除
const command = inputCommand.trim();
if (command === '') {
return '';
}
ls
ディレクトリによってdirItem
を更新しLsコンポーネントに渡しています.
コマンドは基本オプションも考慮する必要があるので以下のように'ls aa'なども満たすようなif文にします.
let dirItem: string[] = LS_GENSHI_ITEM;
// 省略
// ls
else if (command === 'ls' || command.startsWith('ls ')) {
if (currentDir === GENSHI_PATH) {
dirItem = LS_GENSHI_ITEM;
} else if (currentDir === HOME_PATH) {
dirItem = LS_HOME_ITEM;
} else if (currentDir === PRODUCTS_PATH) {
dirItem = LS_PRODUCTS_ITEM;
} else if (currentDir === CONTACTS_PATH) {
dirItem = LS_CONTACTS_ITEM;
} else if (currentDir === WHITE_PATH) {
dirItem = LS_WHITE_ITEM;
}
return <Ls dirItem={dirItem} />;
}
// 省略
Lsコンポーネントは.
の有無でファイルかフォルダを判断し色を変えています.
import { FC } from 'react';
type Props = {
dirItem: string[];
};
const Ls: FC<Props> = ({ dirItem }) => (
<p className='font-ubuntu_terminal'>
{/* ファイルは白 */}
{dirItem
.filter((fileName) => fileName.includes('.'))
.map((item) => (
<span className='pr-4 text-white inline-block' key={item}>
{item}
</span>
))}
{/* フォルダは青 */}
{dirItem
.filter((fileName) => !fileName.includes('.'))
.map((item) => (
<span className='pr-4 text-blue-500 inline-block' key={item}>
{item}
</span>
))}
</p>
);
export default Ls;
cd
こちらもifの暴力です.
pathをcommandの'cd '以降に続く文字列を抽出したものとしてcurrentDir
とpath
がそれぞれ要件を満たしていたらsetCurrentDir
を実行しています.
elseにて現在のdirItem
にpath
がある場合はNotDirコンポーネントで, bash: cd: {fileName}: not a directory
を,
ない場合はNoFileOrDirコンポーネントでbash: {command}: {fileOrDir}: No such file or directory
を返すなどと細部にまでこだわっています.
// cd
else if (command === 'cd') {
setCurrentDir(GENSHI_PATH);
} else if (command.startsWith('cd ')) {
// pathの抽出と/の削除
let path = command.replace('cd ', '').replace(/\/$/, '');
if (currentDir === GENSHI_PATH && path === 'products') {
dirItem = LS_PRODUCTS_ITEM;
setCurrentDir(GENSHI_PATH + '/' + path);
} else if (currentDir === GENSHI_PATH && path === 'contacts') {
dirItem = LS_CONTACTS_ITEM;
setCurrentDir(GENSHI_PATH + '/' + path);
} else if (currentDir === GENSHI_PATH && path === 'white') {
dirItem = LS_WHITE_ITEM;
setCurrentDir(GENSHI_PATH + '/' + path);
} else if (currentDir === HOME_PATH && path === 'genshi') {
dirItem = LS_GENSHI_ITEM;
setCurrentDir(GENSHI_PATH);
} else if (path === '..' || path === '../') {
if (currentDir === GENSHI_PATH) {
setCurrentDir(HOME_PATH);
} else if (currentDir === PRODUCTS_PATH) {
setCurrentDir(GENSHI_PATH);
} else if (currentDir === CONTACTS_PATH) {
setCurrentDir(GENSHI_PATH);
} else if (currentDir === WHITE_PATH) {
setCurrentDir(GENSHI_PATH);
}
} else {
return dirItem.includes(path) ? (
<NotDir fileName={path} />
) : (
<NoFileOrDir command={'cd'} fileOrDir={path} />
);
}
}
cat
catFile
に入力されたファイル名を抽出しCatコンポーネントに渡しています.
// cat
else if (command === 'cat') {
// なにも実行しない
} else if (command.startsWith('cat ')) {
let catFile = command.replace('cat ', '').replace(/\/$/, '');
return (
<Cat
dirItem={dirItem}
catFile={catFile}
currentDir={currentDir}
isFormatted={isFormatted}
/>
);
}
catで表示するコンポーネントはurlがある場合とない場合でaタグで囲むかどうかを分けています.
// 省略
const Cat: FC<Props> = ({ dirItem, catFile, currentDir, isFormatted }) => {
// 省略
else if (dirItem.includes(catFile)) {
// urlがないならhover時にunderlineをつけない
return CAT_FILE_CONTENTS[catFile].url === undefined ? (
<UbuntuText>{CAT_FILE_CONTENTS[catFile].content}</UbuntuText>
) : (
<a href={CAT_FILE_CONTENTS[catFile].url} target='_blank' rel='noreferrer'>
<p className='font-ubuntu_terminal text-white hover:underline'>
{CAT_FILE_CONTENTS[catFile].content}
</p>
</a>
);
} else {
return <NoFileOrDir command={'cat'} fileOrDir={catFile} />;
}
};
whoami, pwd, clear, date, command not found
とくにこれといった説明は無いです.
コマンドに応じて, それぞれのコンポーネントを返します.
clearは'clear'を返しhandleOnEnterの中でresが'clear'だったらrepliesとlogを空にしています.
// whoami
else if (command === 'whoami') {
return <Whoami />;
}
// pwd
else if (command === 'pwd') {
return <Pwd currentDir={currentDir} />;
}
// clear
else if (command === 'clear') {
return 'clear';
}
// date
else if (command === 'date') {
const dateStr = getDateStr();
return <DateNow dateStr={dateStr} />;
}
// command not found
else {
return <NotFound command={command} />;
}
タブでの入力補完
cdコマンド, catコマンドの補完ともにifの暴力です.
あまり見せたいものではないのでこちらのhandleOnTabを見てください(投げやり).
...........プルリク待ってます.
十字キーでのコマンド履歴
handleOnTab
はOnKeyDownイベントで呼ばれるのでTabだったら入力補完, 十字キーだったらコマンド履歴という形になっています.
logStateNumを↑なら-1, ↓なら+1でsetCommandの引数のlog.commandを操作しています.
// コマンド履歴の見せられないコード
//省略
else if (e.key === 'ArrowUp') {
e.preventDefault();
if (logs[0] !== undefined) {
if (logsStateNum > 0) {
logsStateNum -= 1;
if (logs[logsStateNum] !== undefined) {
setCommand(String(logs[logsStateNum].command));
}
}
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (logs[0] !== undefined) {
if (logsNum - logsStateNum - 1 > 0) {
logsStateNum += 1;
if (logs[logsStateNum] !== undefined) {
setCommand(String(logs[logsStateNum].command));
}
} else {
setCommand('');
}
}
}
Ubuntuのターミナルっぽいデザイン
やるだけ.
CSSと戦え. 僕は勝ったぞ.
おわりに
インターン以外でReactでまとも[1]なものを作ったのはこれが初めてでしたが, そこそこ形になりました. なによりコマンドをどんどん実装していくのはとても楽しかったです.
後半説明が雑になっていたのはこういうことなので許してください.
GitHubで公開しているのでStarをつけてくれると僕がすごく喜びます. おねがいします.
明日はLOCAL Students Advent Calendar 2021では我らがはいばらさんの😉
鈴鹿高専 Advent Calendar 2021ではtommy-pcmさんのロボット的な何かについて書きますです.
-
諸説あり ↩︎
Discussion