文化祭の特設サイトを作ったお話 ～技術スタックを添えて～

# はじめに
この記事は、「[長野高専アドベントカレンダー2024 1日目](https://qiita.com/advent-calendar/2024/nnct)」の記事になります。
~~なんで主催者が1日目やらないかって...? テスト期間と丸被りで書いている余裕がなかったからです。~~ (物理の長い範囲に絶望しながら現実逃避をした結果1日目に書くことになりました)
そんな冗談はさておき、今回は文化祭の特設サイトを作る機会があったのでその際の技術スタックであったり、どんなことをしたのかを書いていこうと思います。
ちなみにですが、今年のトップページはこんな感じでした。[^1]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/117ff433-74f2-29c8-a0d4-be9d1734efe3.png)

# :man: whoami
みなさんこんにちは! :wave: [くりすた](https://x.com/Cr1sta_dev)と申します。長野工業高等専門学校(通称: 長野高専)というところで学生をやっています。昨年の文化祭では、Vue.jsでクラス企画の特設ページを作ったり、ゲーム(Unity)側のスコアをウェブページに反映させるシステムをExpressで作っていました。[^2]

:::note warn
本記事の内容は、長野工業高等専門学校に非公式で公開しているものです。
本記事に関する問い合わせはご遠慮ください。
:::

# :pencil2: きっかけ
今まで、工嶺祭(長野高専の文化祭)のサイトは先輩が一人で作っていたのですが、その先輩が今年卒業しまして、今年度は誰が担当するという話になりました。そこで私自身が所属している情報技術研究部という部活でその代わりを担う形で「デジタル部門」が発足、今年のWebサイトを委託されました。
情報技術研究部ってどんな部活？と感じるかもしれませんが、情報系が大好きな高専生が集まって技術を共有したり、「高専プログラミングコンテスト(通称: 高専プロコン)」というロボコンのプログラミングverみたいな大会に参加している部活です。

https://jo-ken.info

# :package: 技術スタック
今回用いた技術スタックです。
|   |   |
|:-:|:-:|
|  言語 | JavaScript / TypeScript  |
|  フレームワーク | React v18 / Next.js v14  |
| スタイル、UI | Tailwind CSS / headless UI |
| アニメーション | Framer Motion |
| アイコン | Lucide Icon / MUI Icon |
| コード管理 | Git / Github |
| デザイン管理 | Storybook |

今回はチームで開発することが多かったので、コード管理は欠かせませんでした。ただ、締め切り間近になると、ブランチ作成→プルリクを送るのが面倒になってきたため、**`main`に直接コミットしてコンフリクトを起こす**[^3]ということもやってました。(後悔してます)
本当は、今までやっていたVue等で作ろうかと思っていたのですが、新しい技術を学ぶチャンス！と思いReactを採用し、勉強しました。あとの後輩はもうReactやNext.jsを触っていたのでそのコードを見たりもしました。

# :map:  サイトマップ
サイトマップはこんな感じです。ツリー形式になります。
- トップページ (/)
    - 交通アクセスページ (/access)
    - クラス企画ページ (/class-events)
    - 学科企画ページ (/department-events)
    - タイムテーブルページ (/timetable)
    - ランタンイベントページ (/local-events)
    - 屋台ページ(/stall-events)

といった感じです。

# :checkered_flag: 担当箇所
ここから先は技術メインの話になります。私が担当した場所は、タイムテーブルの設計・デザインでした。といっても、タイムテーブルにどんなを情報表示したいかによって、実装する内容も変わってきます。今回は、シンプルに「各イベントの情報を時間軸で表示する」ようにしました。
時間軸で表示するといっても、どうやって実装しようかということで一通り探してみたのですが見つかりません。そこで、様々な文化祭のサイトのタイムテーブルを探したり、driddleのようなサイトをいくつか探しました。
そこで見つけたのが、「[松ヶ崎祭](https://matsufes.info/)」さんのサイトでした。見たところ、時間軸と横軸を`flex`要素で横に並べ、`absolute`要素で、上にデータを配置しているようでした。
これが簡単な予感がしたのでこれを採用することにしました。
## Part1
まずは試作品を作成してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/0ac1932a-bc90-cc2e-059c-e397302491ad.png)
いい感じですね。この時点では、親となる`index.tsx`に`TimeTableEvents`を継承した変数にデータを直接ぶっこむという脳筋設計です。(この後ちゃんと`types.ts`として専用のファイルを作りました)
```ts:Timetable/index.tsx
export interface TimeTableEvents {
    title: string;
    description: string;
    start: {
        hour: number;
        minute: number;
    };
    end: {
        hour: number;
        minute: number;
    };
}

export interface TimeTableEventsByVenue {
    id: number;
    name: string;
    events: TimeTableEvents[];
}

export interface TimeTableEventsByDate {
    id: number;
    name: string;
    eventsByVenue: TimeTableEventsByVenue[];
}
```
`TimeTableEventsByDate` ← `TimeTableEventsByVenue` ← `TimeTableEvents` という感じになっています。
## Part1-1
カード部分のレンダリング、レスポンシブ対応についてここで軽く触れておきます。
カードの位置に関しては、開始時間と各時間軸の間隔から動的に算出し、表示しています。後半になると、レスポンシブ対応でスマホの時とパソコンの時で高さが変更されるということがあったため、`useEffect`で画面幅を監視させ、変更された瞬間にモバイル用・パソコン用の高さを切り替えるようにしました。
画面幅については次のコードで判定を行い、`boolean`で返すようにしています。
```ts:Timetable/index.tsx
  const [isSmallScreen, setIsSmallScreen] = useState(false);

  // 画面幅に基づいて、md以下かどうかを判定
  useEffect(() => {
    const checkScreenSize = () => {
      setIsSmallScreen(window.innerWidth < 768);
    };

    checkScreenSize();
    window.addEventListener("resize", checkScreenSize);

    return () => window.removeEventListener("resize", checkScreenSize);
  }, []);
```

## Part2
試作品が完成したので、ここに複数のイベントを表示できるようにします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/feebafab-0dc7-a7c5-f2e1-7b43ba43834c.png)
リストボックスでの日付切替も問題なく動作します。

## Part3
メインページのレイアウト・スタイルが確定したのでそれに合わせて変更していきます。
今回のテーマは、「Infinite」。ポスターイメージも宇宙をイメージして作られているのでそれに合うようにカードに半透明をいれたりしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/18f3ff67-589e-f9e2-38b6-669b3320d1e9.png)

## Part4
次に、現在時刻を反映したバーを表示させます。
バーの設計が一番大変でした。時間軸は、9:00～17:30までで、時間についてはday.jsから現在時刻を取得、開始時間と比較してバーの高さを算出するため、この範囲を超えると、バーがとんでもない位置に行ってしまったりします。そのため、現在時刻で条件分けをする必要があるのですが最終的にはこうなりました。
```tsx:Timetable/index.tsx
      {Number(dayHour) >= 9 &&
        !(
          Number(dayHour) > 17 ||
          (Number(dayHour) == 17 && Number(dayMinute) > 30)
        ) && (
          <div
            className='relative border border-red-500 flex-grow w-full'
            style={{ top: `${startTop}rem` }}>
            <div className='arrow' />
            <div className='arrow mirror' />
          </div>
        )}

      {Number(dayHour) >= 0 && Number(dayHour) < 9 && (
        <div
          className='relative border border-red-500 flex-grow w-full'
          style={{ top: `${isSmallScreen ? 2.7 : 2.8}rem` }}>
          <div className='arrow' />
          <div className='arrow mirror' />
        </div>
      )}

      {(Number(dayHour) > 17 ||
        (Number(dayHour) == 17 && Number(dayMinute) > 30)) &&
        Number(dayHour) <= 23 &&
        Number(dayMinute) <= 59 && (
          <div
            className='relative border border-red-500 flex-grow w-full'
            style={{ top: `${isSmallScreen ? 125.95 : 130.3}rem` }}>
            <div className='arrow' />
            <div className='arrow mirror' />
          </div>
        )}
```
0時～8時59分までは、9:00の位置で固定しておきます。9:00～17:30は計算した高さをそのまま用います。そして、17:30以降は、17:30の位置で固定させるというものです。
こうして、バーの処理部分が完成です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/2e07e744-cc39-a415-489b-b04b3d6ecdac.png)

## Part5
今までの画像を見てお気づきの方もいると思いますが、項目が多い場合は横スクロールになります。が、Part4にある画像をパソコンで見るとしましょう。横スクロールが難しいことに気づくと思います。そこで、急遽ボタンで横スクロールを実装することにしました。
ボタン部分は次のように実装しています。
```tsx:Timetable/index.tsx
const timetablesRef = useRef<HTMLDivElement | null>(null);

const scrollBefore = () => {
    const element = timetablesRef.current;
    if (element) {
      const scrollAmount = isSmallScreen ? 460 : 420;
      const newPosition = Math.max(element.scrollLeft - scrollAmount, 0);
      element.scrollTo({
        left: newPosition,
        behavior: "smooth",
      });
      setScrollPosition(newPosition);
    }
};

const scrollAfter = () => {
    const element = timetablesRef.current;
    if (element) {
      const scrollAmount = isSmallScreen ? 460 : 420;
      const newPosition = Math.min(
        element.scrollLeft + scrollAmount,
        maxScrollPosition
      );
      element.scrollTo({
        left: newPosition,
        behavior: "smooth",
      });
      setScrollPosition(newPosition);
    }
};
...
return(
    <div className='flex flex-row-reverse gap-4 mb-4'>
        <button
          className='flex justify-center items-center size-12 rounded-full bg-gray-500/40 disabled:bg-gray-500/10 disabled:cursor-not-allowed'
          onClick={scrollAfter}
          disabled={scrollPosition >= maxScrollPosition}>
          <ChevronRight className='text-white' />
        </button>
        <button
          className='flex justify-center items-center size-12 rounded-full bg-gray-500/40 disabled:bg-gray-500/10 disabled:cursor-not-allowed'
          onClick={scrollBefore}
          disabled={scrollPosition <= 0}>
          <ChevronLeft className='text-white' />
        </button>
    </div>
);
```
完成したものがこちらです。
![scroll_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/7246b95c-900a-44e8-f9fa-40734c76ad71.gif)

## Part6
ここまで、様々な実装をしてきましたが、まだ足りません。現在、どの企画が行われているかどうかを確認する手段はありません。そこで、開催中のイベントを表示する機能も実装しました。
コード量が長いので、Gistにしました。処理内容としては、現在の日付と時間を取得し、現在開催中のイベントとその次のイベントを取得します。開催中のイベントがあればそれを、なければ準備中として次のイベントが呼び出されます。また、時間外のものについては、終了という扱いにしました。

https://gist.github.com/Crysta1221/1017cf6396160e47d64e1d613bbc63c6

開催中:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/eef2768b-0b9a-6db1-105a-73a92053205c.png)
終了:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/7cfd21a6-43d6-834c-fb58-fe20c7995457.png)

# :computer: 開発の流れ
## 夏休み前(~8月)
高専の夏休みは、8月からです。テストも終わったしようやくプロジェクトが始まる...!と言いたいところですが、ポスターデータや各種データが届かずあまり活動できませんでした。ただ、Reactのリファレンスは結構読みました。Reactをやる前まではVueをやっていたのですが、書き方が少し変わっただけで自然に納得できました。
## 夏休み中
2週間ほどのインターンに行っていたため、正味ここも何もできていません。あと、まだデータが届きませんでした。多分誰かが「何やってんだお前～～！」と叫ぶことでしょう。
## 9月
ようやくもろもろのデータが届き、デザインも固まってきました。といっても、デザインやframer motionによるアニメーションは後輩が作ったものなので感謝しかないです。:pray:
## 10月
多忙の日々を送りながらひたすら書いていました。ちなみにですが、プログラムのバグを授業中に見つけて授業中にその訂正をしたりしていました。~~(ばれなきゃ犯罪じゃないんですよ...)~~
タイムテーブルのほとんどはここで実装しました。ただ、完全にわからないところはGithub Copilot君にも頼りながら書きました。

# 🥚こだわりポイントなど
## ローディング編
最初アクセスする際に、ローディングアニメーションが出ていますが、これはテーマが「Infinite」に沿って無限(∞)の形をしたアニメーションを入れたいということで実装されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/1fcf94ad-d882-d8d0-6369-de5254d84e81.png)
実装は、こちらのライブラリを用いました。

https://mhnpd.github.io/react-loader-spinner/docs/components/infinity-spin

## うーんどこが変なのかなぁ:thinking:
タイムテーブルのページをデベロッパーツールで開いてもらうとわかるのですが、おわかりいただけましたか...?
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/20a63bce-7aa7-37d3-0812-0ce4c63faafc.png)
テストコードが残ったままですね！時間や日付等のログが毎秒表示されます。これ、何気に`useEffect`で2回発火させずに1回発火するように工夫しています。軽量化を思ってそうしたのですが、多分あまり意味がないような気もします。
ここを見るだけでもいかに脳筋かがわかると思います。(というかこれ以外の方法を私は存じ上げません)
## コミットの数
総コミット数は... **138 Commit** でした！
チーム開発だったのでまぁこのぐらいはどうしても行くと思っていましたが、ちょっとした修正のたびにプルリクないしコミットを入れていたので個人的には多かったような気もします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2467860/58a5be53-a35c-8ce0-ef1e-99f854a4a012.png)

# :book: 感想
率直な感想としては、なんとかものにできてほっとしました。ただ、文化祭一週間前になっても公開できず、いろんな方から公開が遅いということを聞いたので、来年はなんとかしたいです。
タイムテーブルの部分は、個人的にはいろいろ実装できて満足でした。`useEffect`を勉強するいい機会になりました。

長くなりましたが、ここら辺でおしまいにします！Happy Coding! :wave:

[^1]: 同じドメインを使いまわす関係上、来年の夏ごろには新しいものになり、見れなくなります。
[^2]: https://qiita.com/Crysta1221/items/627ae9b51f662b52946b 
[^3]: 最新のコミットを反映しないと起こる、いわば重複が発生している状況です。