🛠️

CUI Portfolioを作ったはなし

2021/12/21に公開

はじめに

この記事はLOCAL Students Advent Calendar 2021鈴鹿高専 Advent Calendar 2021の記事です.
鈴鹿高専アドベントカレンダーに関しては, 鈴鹿高専に関わる方(鈴鹿高専の学生と関わる方という解釈)で記事を書かせてもらうことになりました. (takumaくんから許可は頂いています)

後期末テストや創造工学で過去に例を見ない忙しさだったということで同じ記事を登録しているのを許してください.

はじめに(1)

ある日
https://twitter.com/addtobasic/status/1399043373797838848

とのツイートをしたところ,

https://twitter.com/uzimaru0000/status/1399044118265823237

とTLのガチプロの方からアドバイスを頂いたのと, やはりコマンドの機能の実装などなどが楽しそう!!と思ったので作ることにしました.

というわけでできたのがこれ

https://twitter.com/addtobasic/status/1448594018045095939?s=20

当初, Vue/Nuxtを使おうと思っていたのですが, このツイートの後のサマーインターンの期間でReactを触りまくった結果, React最高!!となったのでNext.js + TypeScript + tailwindcssで実装しています.

しくみ

コマンドの入力

inputでユーザーからcommandを取得し押されたKeyがEnterだった際に, commandhandlerに渡して, commandに応じてコンポーネントを返すというのが, ざっくりとした仕組みです.

Terminal.tsx
{/* 省略 */}
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コンポーネントは.の有無でファイルかフォルダを判断し色を変えています.

Ls.tsx
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 '以降に続く文字列を抽出したものとしてcurrentDirpathがそれぞれ要件を満たしていたらsetCurrentDirを実行しています.

elseにて現在のdirItempathがある場合は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タグで囲むかどうかを分けています.

Cat.tsx
// 省略
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をつけてくれると僕がすごく喜びます. おねがいします.
https://github.com/addtobasic/addtobasic.github.io

明日はLOCAL Students Advent Calendar 2021では我らがはいばらさんの😉

鈴鹿高専 Advent Calendar 2021ではtommy-pcmさんのロボット的な何かについて書きますです.

脚注
  1. 諸説あり ↩︎

Discussion