フロントエンド側 React import階層/リンク文字列/一覧・地図・詳細

まだまだまだパクリ29発目!!そろそろ勢い衰えてきた。

パクリ元

内容

前回の続きを。

  • PCでのメニューは常時表示
  • メニュー内のサブメニュー表示
  • ログイン/ログアウト
  • 最適なフォルダ構成
  • 外部API呼び出し
  • 共通レイアウト・メニューの作り方をなんとかしたい
  • ダイアログ切り離し
  • import 階層    ←←← 今回ココから。
  • リンクの文字列をなんとかしたい
  • APIのURL
  • 一覧表示
  • 詳細表示
  • 地図表示(一覧)
  • 検索
  • loading

import 階層

import のディレクトリ階層に関して ../ が本当に嫌なのでパクリ元1つ目を参考に書き換える。

tsconfig.json
{
  "compilerOptions": {
    ...
    "baseUrl": "./",
    "paths": {
      "@/src/*": [ "./src/*" ]
    }
  }
}
vite.config.ts
export default defineConfig({
  plugins: [
    ...
  ],
  resolve: {
    alias: {
      "@/src": "/src"
    },
  },
});

あとはソース全体で ../ がなくなるように修正。package.json の箇所だけ ../ が現れることになってしまったが。

リンクの文字列をなんとかしたい

パクリ元2つ目を参考にリンク文字列をオブジェクト形式で作成する。ライブラリはとりあえず使用しない。

routes/routing.ts
export const Routing =  {
    Login: {
        path: "/login",
        pageName: "ログイン"
    },
  
    Todo: {
        path: "/todo",
        pageName: "TODO"
    },
}
routes/Router.tsx
・・・
import { Routing } from "@/src/routes/routing";
・・・
      <Navigate to={Routing.Login.path} />
・・・
        <Route path={Routing.Login.path} element={<Login />} />
        <Route element={<PrivateRoutes />}>
          <Route path="/" element={<Layout />}>
            <Route path={Routing.Todo.path} element={<Todo />} />
features/login/login-form/components/Login.tsx
・・・
import { Routing } from "@/src/routes/routing";
・・・
        navigate(Routing.Todo.path);

APIのURL

API の URL も直書きだったのでパクリ元3つ目を参考に修正。直下に .env.development ファイル作成。

.env.development
VITE_BASE_URL="http://localhost:33000"
infrastructures/api/userApi.ts
・・・
return await fetchWithErrorHandling<User>(`${import.meta.env.VITE_BASE_URL}/auth/`, {
・・・

メニュー再度修正

傍目にはどうでもいいとこだけど一度やり始めたところなので再度修正。今の作り方だと Todo のレイアウトが更新されないと Todo のメニューが読み込まれないので他のページを作ったらメニュー自体がなくなってしまう。なので、Layout には親メニューだけ追加しておいて、押された瞬間にサブメニューを追加する形に修正。

components/logics/addSubItems.ts
export const addSubItems = (menuItems: MenuItem, targetMenuItemId: string, subMenuItems: MenuItem)=> {
  console.log(menuItems, targetMenuItemId, subMenuItems);
  let newMenuItems: MenuItem = {};
  Object.keys(menuItems).forEach((menuItemId: string, idx) => {
    if (menuItemId === targetMenuItemId) {
      newMenuItems[menuItemId] = {...menuItems[menuItemId], subitems:subMenuItems};
    } else {
      newMenuItems[menuItemId] = menuItems[menuItemId];
    }
  });
  return newMenuItems;
};
components/composite/Layout.tsx
・・・
  const handleToggleMenu = (openMenuId: string) => {
    setOperateMenu((operateMenu) => ({...operateMenu, openMenuId: openMenuId, openMenu: !operateMenu.openMenu}));
  };
  useEffect(() => {
    let items: MenuItem = {};
    items['task'] = { text: 'タスク', icon: 'task', iconsx: null, onClick:  () => handleToggleMenu('task'), subitems: {} };
    items['share'] = { text: 'このアプリを共有', icon: 'share', iconsx: null, onClick: handleToggleQR, subitems: {} };
    items['logout'] = { text: 'ログアウト', icon: 'logout', iconsx: null, onClick: () => { resetAccountState(); navigate("/login");}, subitems: {} };

    setMenuItems(items);
  },[]) 
・・・
features/todo/todo-list/components/Todo.tsx
・・・
import { addSubItems } from "@/src/components/logics/addSubItems";
・・・
-  const handleToggleMenu = (openMenuId: string) => {
-    setOperateMenu((operateMenu) => ({...operateMenu, openMenuId: openMenuId, openMenu: !operateMenu.openMenu}));
-  };
  useEffect(() => {
    let subitems: MenuItem = {};
    subitems['list-all'] = { text: 'すべてのタスク', icon: 'subject', iconsx: null, onClick: () => handleSort('all', 'list-all'), subitems: {} };
    subitems['list-unchecked'] = { text: '現在のタスク', icon: 'radio_button_unchecked', iconsx: { color: lightBlue[500] }, onClick: () => handleSort('unchecked', 'list-unchecked'), subitems: {} };
    subitems['list-checked'] = { text: '完了したタスク', icon: 'check_circle_outline', iconsx: { color: pink.A200 }, onClick: () => handleSort('checked', 'list-checked'), subitems: {} };
    subitems['list-removed'] = { text: 'ごみ箱', icon: 'delete', iconsx: null, onClick: () => handleSort('removed', 'list-removed'), subitems: {} };

    setMenuItems((menuItems) => addSubItems(menuItems, 'task', subitems));
  }, [todos]);
・・・

一覧表示

業務システムは表形式の一覧が多いのでパクリ元4つ目でライブラリを確認。ササっとできそうなのでパクリ元5つ目のやつを。大昔 w2ui というライブラリに結構悩まされてたなぁ(遠い目

~/vite-project$ npm install @mui/x-data-grid

ちょっと古いけど2020年のJ1サッカースタジアムのデータがあり(パクリ元5つ目)、緯度経度もあったのでそれを使う。

testdb/db.json
・・・
    "stadium": [
        {"id": 1,"team_name":"セレッソ大阪","stadium_name":"キンチョウスタジアム","latitude":34.61551,"longitude":135.516486,"capacity":17892},
        {"id": 2,"team_name":"ガンバ大阪","stadium_name":"パナソニックスタジアム吹田","latitude":34.80322,"longitude":135.537876,"capacity":39694},
        {"id": 3,"team_name":"名古屋グランパス","stadium_name":"パロマ瑞穂スタジアム","latitude":35.122093,"longitude":136.94382,"capacity":20223},
        {"id": 4,"team_name":"名古屋グランパス","stadium_name":"豊田スタジアム","latitude":35.084868,"longitude":137.17092,"capacity":41255},
        {"id": 5,"team_name":"ジュビロ磐田","stadium_name":"ヤマハスタジアム_磐田","latitude":34.725423,"longitude":137.875104,"capacity":15165},
        {"id": 6,"team_name":"ジュビロ磐田","stadium_name":"エコパスタジアム","latitude":34.743759,"longitude":137.970753,"capacity":51697},
        {"id": 7,"team_name":"清水エスパルス","stadium_name":"IAIスタジアム日本平","latitude":34.98492,"longitude":138.4813,"capacity":20248},
        {"id": 8,"team_name":"湘南ベルマーレ","stadium_name":"ShonanBMWスタジアム平塚","latitude":35.343807,"longitude":139.341169,"capacity":15732},
        {"id": 9,"team_name":"横浜Fマリノス","stadium_name":"日産スタジアム","latitude":35.510138,"longitude":139.606383,"capacity":72081},
        {"id": 10,"team_name":"横浜Fマリノス","stadium_name":"ニッパツ三ツ沢球技場","latitude":35.4694,"longitude":139.603808,"capacity":15440},
        {"id": 11,"team_name":"横浜FC","stadium_name":"ニッパツ三ツ沢球技場","latitude":35.4694,"longitude":139.603808,"capacity":15440},
        {"id": 12,"team_name":"川崎フロンターレ","stadium_name":"等々力陸上競技場","latitude":35.585982,"longitude":139.652776,"capacity":26827},
        {"id": 13,"team_name":"FC東京","stadium_name":"味の素スタジアム","latitude":35.664531,"longitude":139.527151,"capacity":48999},
        {"id": 14,"team_name":"浦和レッズ","stadium_name":"埼玉スタジアム2002","latitude":35.90334,"longitude":139.717608,"capacity":62010},
        {"id": 15,"team_name":"鹿島アントラーズ","stadium_name":"県立カシマサッカースタジアム","latitude":35.992205,"longitude":140.640398,"capacity":37496},
        {"id": 16,"team_name":"ベガルタ仙台","stadium_name":"ユアテックスタジアム仙台","latitude":38.319352,"longitude":140.881803,"capacity":19694},
        {"id": 17,"team_name":"北海道コンサドーレ札幌","stadium_name":"札幌ドーム","latitude":43.015254,"longitude":141.410027,"capacity":39856},
        {"id": 18,"team_name":"柏レイソル","stadium_name":"三協フロンテア柏スタジアム","latitude":35.849661,"longitude":139.975053,"capacity":15109}
    ]
}
features/stadium/stadium-list/types/Stadium.d.ts
declare type Stadium = {
    id: number;
    team_name: string;
    stadium_name: string;
    latitude: number;
    longitude: number;
    capacity: number;
};
infrastructures/api/stadiumApi.ts
import { fetchWithErrorHandling } from "./_base";

export const getStadiums = async () => {
  return await fetchWithErrorHandling<Stadium[]>(`${import.meta.env.VITE_BASE_URL}/stadium/`, {
    method: 'GET',
  });
};
features/stadium/stadium-list/components/Stadium.tsx
import { useEffect, useState } from 'react';
import { useRecoilState } from "recoil";
import { getStadiums } from "@/src/infrastructures/api/stadiumApi";
import { messageDialogState } from "@/src/stores/messageDialog/messageDialogState";

import {
  GridColDef,
  DataGrid,
  GridToolbar,
} from "@mui/x-data-grid";
import { jaJP } from '@mui/x-data-grid/locales';

export const Stadium = () => {
  const [records, setRecords] = useState([{"id": 0} as Stadium]);
  const [messageDialog, setMessageDialog] = useRecoilState<MessageDialog>(messageDialogState);

  const cols: GridColDef[] = [
    { field: 'id', headerName: 'ID', flex: 1 },
    { field: 'team_name', headerName: 'チーム名', flex: 3 },
    { field: 'stadium_name', headerName: 'スタジアム名', flex: 4 },
    { field: 'latitude', headerName: '緯度', flex: 4 },
    { field: 'longitude', headerName: '経度', flex: 4 },
    { field: 'capacity', headerName: '収容人数', flex: 2 },
  ];

  useEffect(() => {
    const get = async () => {
      try {
        const stadiums: Stadium[] = await getStadiums();
        setRecords(stadiums);
      } catch {
        setMessageDialog({
          dialogOpen: true,
          dialogTitle: 'エラー',
          dialogMessages: ['データ取得に失敗しました。'],
          okCaption: 'OK',
          cancelCaption: '',
          isOkAutoFocus: true,
          onOkFunc: () => {setMessageDialog({...messageDialog, dialogOpen: false})},
          onCancelFunc: () => {setMessageDialog({...messageDialog, dialogOpen: false})},
        });
      }
    }
    get();  
  }, []);

  return (
    <DataGrid
      rows={records}
      columns={cols}
      slots={{ toolbar: GridToolbar }}
      sx={{ overflowY: 'scroll', pb: 0}}
      onCellClick={(event) => {
        console.log(`id: ${event.row.id}をClick`);
      }}
      localeText={jaJP.components.MuiDataGrid.defaultProps.localeText}
      />
  );
 };
routes/routing.ts
・・・
    Stadium: {
        path: "/stadium",
        pageName: "Stadium"
    },
}
routes/Router.tsx
・・・
          <Route path="/" element={<Layout />}>
            <Route path={Routing.Stadium.path} element={<Stadium />} />
            <Route path={Routing.Todo.path} element={<Todo />} />
          </Route>
・・・

Layout には一枚 div をかませることにしてそこに画面サイズでメニュー常時表示制御を移す。

components/composite/Layout.tsx
・・・
import { createTheme, ThemeProvider, styled } from '@mui/material/styles';
import { Routing } from "@/src/routes/routing";
import { headerHeight } from '@/src/components/composite/Header';
・・・
const Container = styled('div')({
  margin: 0,
  maxWidth: '100%',
  top: headerHeight,
  height: `90vh`,
  overflowY: 'auto',
  fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif',
});
・・・
  useEffect(() => {
    let items: MenuItem = {};
    items['stadium'] = { text: 'スタジアム', icon: 'stadium', iconsx: null, onClick:  () => {navigate(Routing.Stadium.path); handleToggleMenu('stadium')}, subitems: {} };
    items['task'] = { text: 'タスク', icon: 'task', iconsx: null, onClick:  () => {navigate(Routing.Todo.path); handleToggleMenu('task')}, subitems: {} };
・・・
      <Container sx={{pl: {xs: 0, sm: '250px'}}}>
        <Outlet />
      </Container>
・・・
features/todo/todo-list/components/TodoItem.tsx
・・・
-const Container = styled('div')({
-  margin: '0 auto',
-  maxWidth: '640px',
-  fontFamily: '-apple-system, BlinkMacSystemFont, Roboto, sans-serif',
-});
・・・
  return (
    <>
      {filteredTodos.map((todo) => {
・・・
      })}
    </>
  );

この DataGrid なぜか右端にもう一個スクロールバー残っちゃうけどもう疲れたのでどうでもいい。ヘッダに「すべてのタスク」が残っているのもどうでもいい。早く終えるぞー

ここまでくるといろいろ気になってくることがあるわね、ファイル名やコンポーネント名の大文字小文字とか、DB 側の型とコンポーネント側の型両方作るべきかとか、

詳細表示

機能的には同じなのでリンクではなく、サイドバー表示みたいなので対応する。一覧でクリックした id を元に複数のデータを取ってきて表示するイメージ。パクリ元7つ目を参考にまずは Google Map 表示ができるように。

~/vite-project$ npm install @vis.gl/react-google-maps

一覧から選択した ID を使用していろんなデータを取得するところだがとりあえず同じデータで絞るように。

infrastructures/api/stadiumApi.ts
・・・
export const getStadiums = async (word: string = "") => {
  if (word === "") {
    return await fetchWithErrorHandling<Stadium[]>(`${import.meta.env.VITE_BASE_URL}/stadium/`, {
      method: 'GET',
    });
  } else {
    return await fetchWithErrorHandling<Stadium[]>(`${import.meta.env.VITE_BASE_URL}/stadium/?q=` + word, {
      method: 'GET',
    });
  }
};

環境ファイルに GoogleMap API Key を入れる。

.env.development
・・・
VITE_GOOGLE_MAPS_API_KEY=*****

詳細用コンポーネントを作成。

features/stadium/stadium-detail/types/StadiumDetailStatus.d.ts
declare type StadiumDetailStatus = {
    isOpen: boolean;
    stadiumName: string;
};
features/stadium/stadium-detail/components/StadiumDetail.tsx
import { useEffect, useState } from 'react';
import { getStadiums } from "@/src/infrastructures/api/stadiumApi";
import Drawer from '@mui/material/Drawer';

import {
  Card,
  CardContent,
  CardHeader,
} from "@mui/material";
import {APIProvider, Map, Marker} from '@vis.gl/react-google-maps';

type Props = {
  stadiumDetailStatus: StadiumDetailStatus;
  onToggleDrawer: (stadiumName: string) => void;
};

export const StadiumDetail = (props: Props) => {
  const [stadiums, setStadiums] = useState([{"id": 0, team_name: "", stadium_name: "", latitude: 0, longitude: 0, capacity: 0} as Stadium]);

  useEffect(() => {
    const get = async () => {
      try {
        const stadiums: Stadium[] = await getStadiums(props.stadiumDetailStatus.stadiumName);
        setStadiums(stadiums);
      } catch {
        console.log("error")
      }
    }
    get();  
  }, [props]);

  return (
    <Drawer
    variant="temporary"
    open={props.stadiumDetailStatus.isOpen}
    onClose={props.onToggleDrawer}
    anchor='right'
    ModalProps={{
      keepMounted: true,
    }}
    PaperProps={{
      sx: { width: "60%", maxWidth: '450px' },
    }}
  >
      <Card style={{textAlign: 'center', width: '100%', height: '100%'}}>
        <CardHeader title={stadiums[0]?stadiums[0].stadium_name:""} />
        <CardContent>
          <APIProvider apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}>
            <Map
              style={{margin: '0 auto', width: '100%', height: '15vh'}}
              defaultCenter={{lat: stadiums[0]?stadiums[0].latitude:0, lng: stadiums[0]?stadiums[0].longitude:0}}
              center={{lat: stadiums[0]?stadiums[0].latitude:0, lng: stadiums[0]?stadiums[0].longitude:0}}
              defaultZoom={12}
              gestureHandling={'greedy'}
              disableDefaultUI={true}
            />
            <Marker
              position={{lat: stadiums[0]?stadiums[0].latitude:0, lng: stadiums[0]?stadiums[0].longitude:0}}
              clickable={true}
              title={stadiums[0]?stadiums[0].stadium_name:""}
            />
          </APIProvider>
          {stadiums.map((stadium) => {
            return <p>{stadium.team_name}</p>;
          })}
          <p> </p>
          <p>{stadiums[0]?stadiums[0].capacity:0} 人収容</p>
          </CardContent>
      </Card>
    </Drawer>
  );
}

一覧からクリックしたら詳細を表示するようにする。

features/stadium/stadium-list/components/Stadium.tsx
・・・
import { StadiumDetail } from "@/src/features/stadium/stadium-detail/components/StadiumDetail";
・・・
  const [stadiumDetailStatus, setStadiumDetailStatus] = useState({} as StadiumDetailStatus);

  const handleToggleDrawer = (_stadiumName: string) => {
    setStadiumDetailStatus((stadiumDetailStatus) => ({isOpen: !stadiumDetailStatus.isOpen, stadiumName: _stadiumName}));
  };
・・・
  return (
    <>
      <StadiumDetail
        stadiumDetailStatus={stadiumDetailStatus}
        onToggleDrawer={handleToggleDrawer}
      />
      <DataGrid
        rows={records}
        columns={cols}
        slots={{ toolbar: GridToolbar }}
        sx={{ overflowY: 'scroll', pb: 0}}
        onCellClick={(event) => {
          handleToggleDrawer(records[event.row.id - 1].stadium_name);
        }}
        localeText={jaJP.components.MuiDataGrid.defaultProps.localeText}
      />
    </>
  );
};

モーダル表示でいいかはさておき完了。

地図表示(一覧)

テーブルによる一覧表示でなく、地図による一覧表示からの詳細表示、がよくある気がするのでやってみる。動かした後の地図の中心を基点に300km四方のエリアのデータを取得するような関数作成。

infrastructures/api/stadiumApi.ts
・・・
export const getStadiumsByLatLon = async (lat: number, lon: number, km: number/*指定緯度経度を中心にした正方形の一辺*/) => {
  const min_lat = lat - (km/110/2);// 緯度1度=110km
  const max_lat = lat + (km/110/2);// 緯度1度=110km
  const min_lon = lon - (km/55.802/2);// 経度1度=55.802km(経度60度)
  const max_lon = lon + (km/55.802/2);// 経度1度=55.802km(経度60度)
    return await fetchWithErrorHandling<Stadium[]>(
      `${import.meta.env.VITE_BASE_URL}/stadium/?latitude_gte=${min_lat}&latitude_lte=${max_lat}&longitude_gte=${min_lon}&longitude_lte=${max_lon}
      `,
      {
        method: 'GET',
      }
    );
};

インストールした地図ライブラリどこまでラッピングされているかよく分からず、例題のソースを見ながら適当に作成する。

features/stadium/stadium-list/components/StadiumMap.tsx
import { useEffect, useState, useCallback } from 'react';
import { useRecoilState } from "recoil";
import { getStadiumsByLatLon } from "@/src/infrastructures/api/stadiumApi";
import { StadiumDetail } from "@/src/features/stadium/stadium-detail/components/StadiumDetail";

import {APIProvider, Map, Marker, MapEvent} from '@vis.gl/react-google-maps';
import { menuItemsState } from "@/src/stores/menuItem/menuItemsState";
import { addSubItems } from "@/src/components/logics/addSubItems";
import { useNavigate } from "react-router-dom";
import { Routing } from '@/src/routes/routing';


export const StadiumMap = () => {
  const [records, setRecords] = useState([{"id": 0, team_name: "", stadium_name: "", latitude: 0, longitude: 0, capacity: 0} as Stadium]);
  const [stadiumDetailStatus, setStadiumDetailStatus] = useState({} as StadiumDetailStatus);
  const [latLon, setLatLon] = useState({"lat": 0, "lon": 0});
  const [menuItems, setMenuItems] = useRecoilState<MenuItem>(menuItemsState)
  const navigate = useNavigate();

  const handleToggleDrawer = (_stadiumName: string) => {
    setStadiumDetailStatus((stadiumDetailStatus) => ({isOpen: !stadiumDetailStatus.isOpen, stadiumName: _stadiumName}));
  };

  const handleIdle = useCallback((ev: MapEvent) => {
    setLatLon({"lat": ev.map.getCenter()?.toJSON().lat??0, "lon": ev.map.getCenter()?.toJSON().lng??0})
  }, []);

  useEffect(() => {
    const get = async () => {
      try {
        const stadiums: Stadium[] = await getStadiumsByLatLon(latLon.lat, latLon.lon, 300);
        setRecords(stadiums);
      } catch {
        console.log("error")
      }
    }
    get();  
  }, [latLon]);

  useEffect(() => {
    let subitems: MenuItem = {};
    subitems['list-map'] = { text: '地図表示', icon: 'map', iconsx: null, onClick: () => navigate(Routing.StadiumMap.path), subitems: {} };
    subitems['list-table'] = { text: '一覧表示', icon: 'table', iconsx: null, onClick: () => navigate(Routing.Stadium.path), subitems: {} };
    
    setMenuItems((menuItems) => addSubItems(menuItems, 'stadium', subitems));
  }, [latLon]);

  return (
    <>
      <StadiumDetail
        stadiumDetailStatus={stadiumDetailStatus}
        onToggleDrawer={handleToggleDrawer}
      />
      <APIProvider apiKey={import.meta.env.VITE_GOOGLE_MAPS_API_KEY}>
      <Map id="m1"
          style={{margin: '0 auto', width: '100%', height: '90vh'}}
          defaultCenter={{lat: 35.4694, lng: 139.603808}}
          defaultZoom={10}
          gestureHandling={'greedy'}
          disableDefaultUI={true}
          onIdle={handleIdle}
        >
        {records.map((record) => {
          return (
            <Marker
              position={{lat: record.latitude??0, lng: record.longitude??0}}
              clickable={true}
              onClick={() => handleToggleDrawer(record.stadium_name)}
              title={record.stadium_name}
            />
          );
        })}
        </Map>
      </APIProvider>
    </>
  );
 };

ルーティング系を整えてスタジアムメニュー押下時の遷移を地図表示とする。

routes/routing.ts
・・・
    StadiumMap: {
        path: "/stadiumMap",
        pageName: "StadiumMap"
    },
}
routes/Router.tsx
・・・
          <Route path="/" element={<Layout />}>
            <Route path={Routing.StadiumMap.path} element={<StadiumMap />} />
            <Route path={Routing.Stadium.path} element={<Stadium />} />
            <Route path={Routing.Todo.path} element={<Todo />} />
          </Route>
・・・
components/composite/Layout.tsx
・・・
    items['stadium'] = { text: 'スタジアム', icon: 'stadium', iconsx: null, onClick:  () => {navigate(Routing.StadiumMap.path); handleToggleMenu('stadium')}, subitems: {} };
・・・

地図表示→詳細表示完了。

ほっとひと安心。と思ったら以下コンソースに出てた。

As of February 21st, 2024, google.maps.Marker is deprecated. Please use google.maps.marker.AdvancedMarkerElement instead. At this time, google.maps.Marker is not scheduled to be discontinued, but google.maps.marker.AdvancedMarkerElement is recommended over google.maps.Marker. While google.maps.Marker will continue to receive bug fixes for any major regressions, existing bugs in google.maps.Marker will not be addressed. At least 12 months notice will be given before support is discontinued. Please see・・・

この世界は変化が早い。パクリ学習だと気づかないことが多い。

検索 & loading

検索はとりあえずいい。loading も Suspense というのを調べたが地図だと地図だけは最初に表示しとかなきゃいけないとかあるのでとりあえずいいこととした。

雑感

は~疲れた。忘れないように忘れないように早く早くと思ってたけど仕事もありまあ大変。一応 フロントエンドもなんとなく終えたということで一つなんか作ってみようと思う。終わりまで作ることで細かい所でつまずいたりするのが大事だからね