Reactアプリ100本ノックを実践する 〜06 Profile〜

# はじめに
こちらの記事は、@Sicut_studyさんがアップしている【Reactアプリ100本ノック】シリーズに相乗りし、アウトプットを行うための記事になります。
- 実装ルールや成果物の達成条件は元記事に従うものとします。
- 元記事との差別点として、具体的に自分がどんな実装を行ったのか（と必要に応じて解説）を記載します。

@Sicut_studyさんのノック100本についていき、Reactを100日間学ぶのが目標です。

今回の元記事はこちら

https://qiita.com/Sicut_study/items/ccfc451360d3f22bde63

# 前回の記事

https://qiita.com/jinto/items/3946598841861714c13d

# 問題
プロフィールカードを生成できるサービスを作成する

## ルール
**元記事より引用**
- 主要なライブラリやフレームワークはReactである必要がありますが、その他のツールやライブラリ（例: Redux, Next.js, Styled Componentsなど）を組み合わせて使用することは自由
- TypeScriptを利用する
- 要件をみたせばデザインなどは自由

## 達成条件
**元記事より引用**
- ユーザーが名前、誕生日、電話番号を入力し、プロフィール写真をアップロードできる
- アップロードされた画像がプレビューとして表示される
- ユーザーが生成したプロフィールカードをPNG形式でダウンロードできる

# 実装

本記事では以下の方針で実装していきます。
- 各UIパーツをコンポーネント化する
- ダウンロード機能の実装には`html2canvas`と`jspdf`を活用する

コードは以下のようになりました。

<details><summary>src/components/Label.tsx</summary>

```typescript:src/components/Label.tsx
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";

interface LabelProps {
  htmlFor: string;
  children: React.ReactNode;
}

const labelStyle = css`
  margin-bottom: 5px;
  font-weight: bold;
`;

const Label = ({ htmlFor, children }: LabelProps) => {
  return (
    <label css={labelStyle} htmlFor={htmlFor}>
      {children}
    </label>
  );
};

export default Label;
```

</details>

<details><summary>src/components/InputField.tsx</summary>

```typescript:src/components/InputField.tsx
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";
import Label from "./Label";

interface InputFieldProps {
  label: string;
  type: string;
  name: string;
  value: string;
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

const inputStyle = css`
  width: 100%;
  padding: 8px 12px;
  margin-bottom: 10px;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box;

  &:focus {
    border-color: #007bff;
    outline: none;
  }
`;

const InputField = ({
  label,
  type,
  name,
  value,
  onChange,
}: InputFieldProps) => {
  return (
    <div>
      <Label htmlFor={name}>{label}</Label>
      <input
        css={inputStyle}
        id={name}
        type={type}
        name={name}
        value={value}
        onChange={onChange}
      />
    </div>
  );
};

export default InputField;
```

</details>

<details><summary>src/components/ImageUpload.tsx</summary>

```typescript:src/components/ImageUpload.tsx
/** @jsxImportSource @emotion/react */
import { useState } from "react";
import { css } from "@emotion/react";
import Label from "./Label";

interface ImageUploadProps {
  label: string;
  onImageChange: (file: File | null) => void;
}

const containerStyle = css`
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  margin-bottom: 20px;
`;

const ImageUpload = ({ label, onImageChange }: ImageUploadProps) => {
  const [previewUrl, setPreviewUrl] = useState<string>("");

  const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    if (e.target.files && e.target.files[0]) {
      const file = e.target.files[0];
      setPreviewUrl(URL.createObjectURL(file));
      onImageChange(file);
    } else {
      onImageChange(null);
    }
  };

  return (
    <div css={containerStyle}>
      <Label htmlFor="image-upload">{label}</Label>
      <input
        type="file"
        id="image-upload"
        onChange={handleImageChange}
        accept="image/*"
      />
    </div>
  );
};

export default ImageUpload;
```

</details>

<details><summary>src/components/ProfileCard.tsx</summary>

```typescript:src/components/ProfileCard.tsx
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";

interface ProfileCardProps {
  name: string;
  birthday: string;
  phoneNumber: string;
  photo: string;
}

const cardStyle = css`
  padding: 20px;
  width: 100%;
`;

const photoContainerStyle = css`
  display: flex;
  justify-content: center;
  width: 100%;
`;

const photoStyle = css`
  width: 250px;
  height: 250px;
  border-radius: 50%;
  object-fit: cover;
`;

const textStyle = css`
  text-align: center;
  margin: 10px 0;
`;

const ProfileCard  = ({
  name,
  birthday,
  phoneNumber,
  photo,
}: ProfileCardProps) => {
  return (
    <div id="profile-card" css={cardStyle}>
      <div css={photoContainerStyle}>
        {photo && <img src={photo} alt="Profile" css={photoStyle} />}
      </div>
      <h3 css={textStyle}>{name}</h3>
      <p css={textStyle}>{birthday}</p>
      <p css={textStyle}>{phoneNumber}</p>
    </div>
  );
};

export default ProfileCard;
```

</details>

<details><summary>src/App.tsx</summary>

```typescript:src/App.tsx
/** @jsxImportSource @emotion/react */
import { useState } from "react";
import { css } from "@emotion/react";
import InputField from "./components/InputField";
import ImageUpload from "./components/ImageUpload";
import ProfileCard from "./components/ProfileCard";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";

interface UserProfile {
  name: string;
  birthday: string;
  phoneNumber: string;
  photo: string;
}

const appContainerStyle = css`
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding: 20px;
  max-width: 900px;
  margin: 0 auto;
`;

const formContainerStyle = css`
  width: 35%;
  margin-right: 20px;
`;

const cardContainerStyle = css`
  width: 45%;
  height: 400px;
  display: flex;
  justify-content: center;
  align-items: center;
  border: 1px solid #ddd;
  border-radius: 8px;
  overflow: hidden;
`;

const downloadButtonStyle = css`
  background-color: #4caf50;
  color: white;
  padding: 10px 20px;
  text-align: center;
  text-decoration: none;
  display: inline-block;
  font-size: 16px;
  cursor: pointer;
  border: none;
  border-radius: 4px;
  width: 100%;
`;

const App = () => {
  const [userProfile, setUserProfile] = useState<UserProfile>({
    name: "",
    birthday: "",
    phoneNumber: "",
    photo: "",
  });

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUserProfile({ ...userProfile, [e.target.name]: e.target.value });
  };

  const handleImageChange = (file: File | null) => {
    if (file) {
      setUserProfile({ ...userProfile, photo: URL.createObjectURL(file) });
    } else {
      setUserProfile({ ...userProfile, photo: "" });
    }
  };

  const handleDownload = async () => {
    const card = document.getElementById("profile-card");
    if (card) {
      const canvas = await html2canvas(card);
      const imgData = canvas.toDataURL("image/png");
      const pdf = new jsPDF();

      const imgWidth = 210;
      const imgHeight = (canvas.height * imgWidth) / canvas.width;

      pdf.addImage(imgData, "PNG", 0, 0, imgWidth, imgHeight);
      pdf.save("profile-card.pdf");
    }
  };

  return (
    <div css={appContainerStyle}>
      <div css={formContainerStyle}>
        <InputField
          label="名前"
          type="text"
          name="name"
          value={userProfile.name}
          onChange={handleInputChange}
        />
        <InputField
          label="誕生日"
          type="date"
          name="birthday"
          value={userProfile.birthday}
          onChange={handleInputChange}
        />
        <InputField
          label="電話番号"
          type="text"
          name="phoneNumber"
          value={userProfile.phoneNumber}
          onChange={handleInputChange}
        />
        <ImageUpload
          label="プロフィール写真"
          onImageChange={handleImageChange}
        />
        <button css={downloadButtonStyle} onClick={handleDownload}>
          ダウンロード
        </button>
      </div>
      <div css={cardContainerStyle}>
        <ProfileCard
          name={userProfile.name}
          birthday={userProfile.birthday}
          phoneNumber={userProfile.phoneNumber}
          photo={userProfile.photo}
        />
      </div>
    </div>
  );
};

export default App;
```

</details>

# 完成
完成形は以下の通りです。
ダウンロードもしっかり動きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267522/c17e871e-cab6-0be1-4f14-c5d771719d5e.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/267522/f8f64d69-8562-3c4c-dfec-194767625a16.png)

# 最後に
回を重ねて、徐々に実装に時間がかかるようになってきました。次回からはUIライブラリの導入も検討してみようかなと思います。

引き続き、100回完走を目指して継続していきます。
応援してくれる方はぜひフォローいただけると嬉しいです。
いいね、ストックもお待ちしております。

ではまた。

# 次回の記事

https://qiita.com/jinto/items/9f32136b9ede3943bea8