Web Technologies

021-RhinoFab|Step4_Material UIでレイアウト調整ヘッダーとフッターを作成

現時点のディレクトリ構成とコード

ディレクトリ構成

/src  
 ├── components          # UIコンポーネント(再利用可能なパーツ)  
 │      ├── AuthButton.js   # ログイン・ログアウトボタン  
 │      ├── AccountMenu.js  # アカウントメニュー(削除ボタン含む)  
 │      └── ...  
 │  
 ├── pages               # 各ページごとのコンポーネント  
 │      ├── Home.js         # ログイン前のホーム画面  
 │      ├── Login.js        # ログイン画面  
 │      ├── Signup.js       # アカウント作成画面  
 │      ├── Dashboard.js    # ログイン後のホーム画面  
 │      ├── MyPage.js       # マイページ  
 │      ├── NotFound.js       #  404エラーページ
 │      ├── Policy.js       # プライバシーポリシー
 │      ├── HowTo.js       # 使い方ページ
 │      ├── Terms.js       #  利用規約
 │      └── ...  
 │  
 ├── utils               # ユーティリティ関数  
 │      ├── firebase.js      # Firebase初期設定  
 │      ├── auth.js          # 認証関連処理(ログイン、削除)  
 │      └── ...  
 │  
 ├── templates           # UI関連  
 │      ├── Header.js        # ヘッダー  
 │      ├── Footer.js        # フッター  
 │      ├── Layout.js        # レイアウト関連  
 │      └── ...   
 │  
 ├── App.js               # アプリのエントリーポイント&ルーティング関連  
 ├── index.js             # ルートレンダリング   
 └── ...  

画面遷移一覧

ページ名パス説明遷移元・遷移先
ホーム/ログイン前のトップページログイン・アカウント作成へ
ログイン/loginメールとパスワードでログインホーム ⇄ ダッシュボード
アカウント作成/signupアカウント新規作成フォームホーム ⇄ ダッシュボード
ダッシュボード/dashboardログイン後のメイン画面ログイン後に遷移
マイページ/mypageアカウント情報の管理ページダッシュボード ⇄ マイページ
404ページ*(存在しないパス)存在しないページのエラーページ不正なURLで遷移
  • 未ログイン時の流れ
    • //login/dashboard
    • //signup/dashboard
  • ログイン後の流れ
    • /dashboard/mypage
  • アカウント削除後
    • /dashboard/(ログアウト後にトップへ)

components:UIコンポーネント(再利用可能なパーツ)

AuthButton.js:ログイン・ログアウトボタン

import React, { useEffect, useState } from "react";
import { Button, CircularProgress } from "@mui/material";
import { auth } from "../utils/firebase";
import { GoogleAuthProvider, signInWithPopup, signOut, onAuthStateChanged } from "firebase/auth";
import { useNavigate } from "react-router-dom";

const AuthButton = () => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true); // ローディング状態を追加
  const navigate = useNavigate();

  useEffect(() => {
    // 認証状態を監視
    const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
      setUser(currentUser);
      setLoading(false);  // ローディング完了
    });

    // クリーンアップ関数
    return () => unsubscribe();
  }, []);

  const handleLogin = async () => {
    const provider = new GoogleAuthProvider();
    try {
      await signInWithPopup(auth, provider);
      console.log("ログイン成功");
      navigate("/dashboard");
    } catch (error) {
      console.error("ログイン失敗", error);
    }
  };

  const handleLogout = async () => {
    try {
      await signOut(auth);
      console.log("ログアウト成功");
      navigate("/");
    } catch (error) {
      console.error("ログアウト失敗", error);
    }
  };

  // ローディング中はスピナー表示
  if (loading) {
    return <CircularProgress />;
  }

  return (
    <div>
      {user ? (
        <Button variant="contained" color="secondary" onClick={handleLogout}>
          ログアウト
        </Button>
      ) : (
        <Button variant="contained" color="primary" onClick={handleLogin}>
          Googleでログイン
        </Button>
      )}
    </div>
  );
};

export default AuthButton;

AccountMenu.js:アカウントメニュー(削除ボタン含む)

import React, { useState } from "react";
import { Box, Avatar, Menu, MenuItem, ListItemIcon, Divider, IconButton, Tooltip } from "@mui/material";
import { Logout, Settings, PersonAdd } from "@mui/icons-material";
import { useAuth } from "../utils/auth";
import { useNavigate } from "react-router-dom";

const AccountMenu = () => {
  const [anchorEl, setAnchorEl] = useState(null);
  const open = Boolean(anchorEl);
  const { user, logout, deleteAccount } = useAuth();
  const navigate = useNavigate();

  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };

  const handleClose = () => {
    setAnchorEl(null);
  };

  const handleLogout = async () => {
    try {
      await logout();
      navigate("/");  // ログアウト後にホーム画面へ遷移
    } catch (error) {
      console.error("ログアウトに失敗しました:", error.message);
    }
  };

  const handleDelete = async () => {
    if (window.confirm("本当にアカウントを削除しますか?")) {
      try {
        await deleteAccount();
        navigate("/");  // 削除後にホーム画面へ遷移
      } catch (error) {
        console.error("アカウント削除に失敗しました:", error.message);
      }
    }
  };

  return (
    <React.Fragment>
      <Box sx={{ display: "flex", alignItems: "center", textAlign: "center" }}>
        <Tooltip title="アカウント設定">
          <IconButton
            onClick={handleClick}
            size="small"
            sx={{ ml: 2 }}
            aria-controls={open ? "account-menu" : undefined}
            aria-haspopup="true"
            aria-expanded={open ? "true" : undefined}
          >
            <Avatar sx={{ width: 32, height: 32 }}>
              {user?.displayName?.charAt(0) || "U"}
            </Avatar>
          </IconButton>
        </Tooltip>
      </Box>

      <Menu
        anchorEl={anchorEl}
        id="account-menu"
        open={open}
        onClose={handleClose}
        onClick={handleClose}
        slotProps={{
          paper: {
            elevation: 0,
            sx: {
              overflow: "visible",
              filter: "drop-shadow(0px 2px 8px rgba(0,0,0,0.32))",
              mt: 1.5,
              "& .MuiAvatar-root": {
                width: 32,
                height: 32,
                ml: -0.5,
                mr: 1,
              },
              "&::before": {
                content: '""',
                display: "block",
                position: "absolute",
                top: 0,
                right: 14,
                width: 10,
                height: 10,
                bgcolor: "background.paper",
                transform: "translateY(-50%) rotate(45deg)",
                zIndex: 0,
              },
            },
          },
        }}
        transformOrigin={{ horizontal: "right", vertical: "top" }}
        anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
      >
        <MenuItem onClick={handleClose}>
          <Avatar /> マイページ
        </MenuItem>
        <Divider />
        <MenuItem onClick={handleClose}>
          <ListItemIcon>
            <Settings fontSize="small" />
          </ListItemIcon>
          設定
        </MenuItem>
        <MenuItem onClick={handleLogout}>
          <ListItemIcon>
            <Logout fontSize="small" />
          </ListItemIcon>
          ログアウト
        </MenuItem>
        <MenuItem onClick={handleDelete}>
          🗑️ アカウントを削除
        </MenuItem>
      </Menu>
    </React.Fragment>
  );
};

export default AccountMenu;

pages:各ページごとのコンポーネント

Home.js

import React from "react";
import { Container, Typography, Button, Box } from "@mui/material";
import { useNavigate } from "react-router-dom";

const Home = () => {
  const navigate = useNavigate();

  try {
    return (
      <Container sx={{ mt: 5, textAlign: "center" }}>
        <Typography variant="h3" gutterBottom>
          RhinoFabへようこそ!
        </Typography>
        <Typography variant="h5" color="text.secondary" sx={{ mb: 4 }}>
          3Dモデルを管理・共有できるプラットフォーム
        </Typography>

        <Box sx={{ display: "flex", justifyContent: "center", gap: 3, mt: 3 }}>
          <Button
            variant="contained"
            color="primary"
            onClick={() => navigate("/login")}
            sx={{ px: 5, py: 1.5, fontSize: "1.2rem" }}
          >
            ログイン
          </Button>

          <Button
            variant="outlined"
            color="secondary"
            onClick={() => navigate("/signup")}
            sx={{ px: 5, py: 1.5, fontSize: "1.2rem" }}
          >
            アカウント作成
          </Button>
        </Box>
      </Container>
    );
  } catch (error) {
    console.error("Home.jsでエラーが発生:", error);
    return <div>エラーが発生しました: {error.message}</div>;
  }
};

export default Home;

Login.js

import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../utils/auth";
import {
  Container,
  Typography,
  TextField,
  Button,
  Box,
  Alert,
} from "@mui/material";

const Login = () => {
  const navigate = useNavigate();
  const { login, signInWithGoogle } = useAuth();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  // メールとパスワードでログイン
  const handleLogin = async (e) => {
    e.preventDefault();
    try {
      await login(email, password);
      navigate("/dashboard");
    } catch (error) {
      setError("ログインに失敗しました。メールアドレスとパスワードを確認してください。");
    }
  };

  // Googleログイン
  const handleGoogleLogin = async () => {
    try {
      await signInWithGoogle();
      navigate("/dashboard");
    } catch (error) {
      setError("Googleログインに失敗しました。");
    }
  };

  return (
    <Container maxWidth="sm" sx={{ mt: 8 }}>
      <Typography variant="h4" align="center" gutterBottom>
        ログイン
      </Typography>
      
      {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}

      <Box component="form" onSubmit={handleLogin} sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
        <TextField
          label="メールアドレス"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          fullWidth
        />
        <TextField
          label="パスワード"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          fullWidth
        />
        <Button type="submit" variant="contained" color="primary" fullWidth>
          ログイン
        </Button>
      </Box>

      <Box sx={{ mt: 2, display: "flex", justifyContent: "center" }}>
        <Button
          variant="outlined"
          color="secondary"
          onClick={handleGoogleLogin}
          fullWidth
        >
          Googleでログイン
        </Button>
      </Box>
    </Container>
  );
};

export default Login;

Signup.js

import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../utils/auth";  // 🔥 修正済み
import { TextField, Button, Container, Typography, Box, Alert, Modal } from "@mui/material";

const Signup = () => {
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState(null);
  const navigate = useNavigate();
  const { createUser } = useAuth();  // ✅ 修正:createUserが取得できる
  const [open, setOpen] = useState(false);  // モーダル制御

  const handleSignup = async (e) => {
    e.preventDefault();
    setError(null);

    if (!username || !email || !password) {
      setError("すべてのフィールドを入力してください。");
      return;
    }

    try {
      await createUser(username, email, password);  // 🔥 修正:createUserを呼び出し
      navigate("/dashboard");  
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <Container maxWidth="sm">
      <Box sx={{ mt: 8, p: 4, boxShadow: 3, borderRadius: 2 }}>
        <Typography variant="h4" align="center" gutterBottom>
          アカウント作成
        </Typography>

        {error && <Alert severity="error">{error}</Alert>}

        <form onSubmit={handleSignup}>
          <TextField
            label="ユーザーネーム"
            placeholder="例: sekkeiya"
            fullWidth
            margin="normal"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
          />

          <TextField
            label="メールアドレス"
            type="email"
            placeholder="例: example@example.com"
            fullWidth
            margin="normal"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
          />

          <TextField
            label="パスワード"
            type="password"
            placeholder="6文字以上"
            fullWidth
            margin="normal"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
          />

          <Button
            type="submit"
            variant="contained"
            color="primary"
            fullWidth
            sx={{ mt: 2 }}
          >
            アカウント作成
          </Button>
        </form>

        <Box mt={2} textAlign="center">
          <Button onClick={() => navigate("/login")} variant="text">
            ログインはこちら
          </Button>
        </Box>

        {/* ✅ モーダル */}
        <Modal open={open} onClose={() => setOpen(false)}>
            <Box sx={{ 
            position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
            bgcolor: "white", boxShadow: 24, p: 4, borderRadius: 2 
            }}>
            <Typography variant="h5">ようこそ!</Typography>
            <Typography>アカウントが作成されました!</Typography>
            </Box>
        </Modal>
      </Box>
    </Container>
  );
};

export default Signup;

Dashboard.js

import React from "react";
import { Box, Typography } from "@mui/material";

const Dashboard = () => {
  return (
    <div>
      {/* メインコンテンツ */}
      <Box p={3}>
        <Typography variant="h4">ようこそ!</Typography>
        <Typography variant="body1">
          ここにダッシュボードのコンテンツが表示されます。
        </Typography>
      </Box>
    </div>
  );
};

export default Dashboard;

Mypage.js

import React, { useState } from "react";
import { Box, Button, Typography, Modal } from "@mui/material";
import { useAuth } from "../utils/auth";

const MyPage = () => {
  const { logout } = useAuth();
  const [open, setOpen] = useState(false);

  const handleDelete = async () => {
    try {
      await logout();
      setOpen(true);
    } catch (error) {
      console.error("アカウント削除失敗:", error.message);
    }
  };

  return (
    <Box sx={{ p: 4 }}>
      <Typography variant="h4">マイページ</Typography>

      <Button 
        onClick={handleDelete}
        variant="contained"
        color="error"
        sx={{ mt: 3 }}
      >
        アカウント削除
      </Button>

      <Modal open={open} onClose={() => setOpen(false)}>
        <Box sx={{ 
          position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
          bgcolor: "white", boxShadow: 24, p: 4, borderRadius: 2 
        }}>
          <Typography variant="h5">アカウントが削除されました</Typography>
        </Box>
      </Modal>
    </Box>
  );
};

export default MyPage;

NotFound.js

import React from "react";

const NotFound = () => {
  return (
    <div>
      <h1>404 - ページが見つかりません</h1>
    </div>
  );
};

export default NotFound;

Policy.js

import React from "react";

const Policy = () => {
  return (
    <div>
      <h1>Privacy Policy</h1>
      <p>ここにプライバシーポリシーを記載します。</p>
    </div>
  );
};

export default Policy;

HowTo.js

import React from "react";

const HowTo = () => {
  return (
    <div>
      <h1>How to Use RhinoFab</h1>
      <p>ここにアプリの使い方を記載します。</p>
    </div>
  );
};

export default HowTo;

Terms.js

import React from "react";

const Terms = () => {
  return (
    <div>
      <h1>Terms of Service</h1>
      <p>ここに利用規約を記載します。</p>
    </div>
  );
};

export default Terms;

templates

Header.js

import React from "react";
import { AppBar, Toolbar, Typography, Box } from "@mui/material";
import { useAuth } from "../utils/auth";
import { useNavigate } from "react-router-dom";
import AccountMenu from "../components/AccountMenu";

const Header = () => {
  const { user } = useAuth();
  const navigate = useNavigate();

  // ✅ ロゴクリックでログイン状態に応じた遷移
  const handleLogoClick = () => {
    navigate(user ? "/dashboard" : "/");
  };

  return (
    <AppBar position="static" color="primary">
      <Toolbar>
        {/* ✅ ロゴ部分 */}
        <Typography
          variant="h6"
          sx={{ flexGrow: 1, cursor: "pointer" }}
          onClick={handleLogoClick}
        >
          RhinoFab
        </Typography>

        {/* ✅ ユーザーメニュー(ログイン時のみ表示) */}
        {user && (
          <Box>
            <AccountMenu />
          </Box>
        )}
      </Toolbar>
    </AppBar>
  );
};

export default Header;

Footer.js

import React from "react";
import { Box, Typography } from "@mui/material";

const Footer = () => {
  return (
    <Box
      component="footer"
      sx={{
        width: "100%",
        height: "64px",
        backgroundColor: "#f5f5f5",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        position: "fixed",
        bottom: 0,
        left: 0,
        zIndex: 1000, // 他の要素より前面に表示
        boxShadow: "0 -2px 4px rgba(0,0,0,0.1)",
      }}
    >
      <Typography variant="body2" color="textSecondary">
        © 2025 RhinoFab
      </Typography>
    </Box>
  );
};

export default Footer;

Layout.js

import React from "react";
import Header from "./Header";
import Footer from "../templates/Footer";
import { Box, CircularProgress } from "@mui/material";
import { useAuth } from "../utils/auth";

const Layout = ({ children }) => {
  const { loading } = useAuth();

  if (loading) {
    return (
      <Box
        sx={{
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          height: "100vh",
        }}
      >
        <CircularProgress />
      </Box>
    );
  }

  return (
    <>
      {/* ✅ Headerを追加 */}
      <Header />

      <Box
        component="main"
        sx={{
          minHeight: "calc(100vh - 128px)", // AppbarとFooterの高さ分を引く
          paddingBottom: "64px",
        }}
      >
        {children}
      </Box>

      <Footer />
    </>
  );
};

export default Layout;

utils:ユーティリティ関数

firebase.js:Firebase初期設定

import { initializeApp } from "firebase/app";
import { 
  getAuth, 
  GoogleAuthProvider, 
  signInWithPopup, 
  signOut, 
  createUserWithEmailAndPassword 
} from "firebase/auth";
import { 
  getFirestore, 
  doc, 
  getDoc, 
  setDoc 
} from "firebase/firestore";

// Firebase設定情報
const firebaseConfig = {
    apiKey: "AIzaSyC_iO9W5qCxJRJJdJkqhpYc14G6RrHq-wI",
    authDomain: "rhinofab-463be.firebaseapp.com",
    projectId: "rhinofab-463be",
    storageBucket: "rhinofab-463be.firebasestorage.app",
    messagingSenderId: "324255802841",
    appId: "1:324255802841:web:ebd4ebcf7e9571b3aa25c8",
    measurementId: "G-VV3Y0SLWF8"
  };

  const app = initializeApp(firebaseConfig);
  const auth = getAuth(app);
  const provider = new GoogleAuthProvider();
  const db = getFirestore(app);
  
  export { 
    auth, 
    provider, 
    db, 
    signInWithPopup, 
    signOut, 
    doc, 
    getDoc, 
    setDoc,
    createUserWithEmailAndPassword
  };

auth.js:認証関連処理(ログイン、削除)

import React, { createContext, useContext, useState, useEffect } from "react";
import { auth, provider, db } from "./firebase";  
import {
  signInWithEmailAndPassword,
  signInWithPopup,
  onAuthStateChanged,
  signOut,
  deleteUser,
  createUserWithEmailAndPassword,
} from "firebase/auth";
import { doc, setDoc, getDoc, deleteDoc } from "firebase/firestore";  // 🔥 Firestore操作を追加

const AuthContext = createContext();

export const useAuth = () => useContext(AuthContext);

export const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
      setUser(currentUser);
    });
    return () => unsubscribe();
  }, []);

  // メールとパスワードでログイン
  const login = (email, password) => {
    return signInWithEmailAndPassword(auth, email, password);
  };

  // Firestoreにユーザーデータを保存する
  const saveUserToFirestore = async (user) => {
    if (!user) return;

    const userRef = doc(db, "users", user.uid);
    const docSnap = await getDoc(userRef);

    if (!docSnap.exists()) {
      await setDoc(userRef, {
        uid: user.uid,
        displayName: user.displayName || "No Name",
        email: user.email,
        createdAt: new Date().toISOString(),
      });
      console.log("Firestoreにユーザーを追加しました!");
    } else {
      console.log("既にFirestoreに存在しています!");
    }
  };

  // Googleログイン時にFirestoreにデータを保存
  const signInWithGoogle = async () => {
    try {
      const result = await signInWithPopup(auth, provider);
      const user = result.user;
      await saveUserToFirestore(user);
    } catch (error) {
      console.error("Googleログインに失敗しました:", error.message);
      throw error;
    }
  };

    // アカウント作成関数
    const createUser = async (username, email, password) => {
        try {
        const result = await createUserWithEmailAndPassword(auth, email, password);
        const newUser = result.user;
    
        // Firestoreにユーザー情報を保存
        const userRef = doc(db, "users", newUser.uid);
        await setDoc(userRef, {
            uid: newUser.uid,
            username: username,
            email: newUser.email,
            createdAt: new Date().toISOString(),
        });
    
        console.log("アカウント作成成功:", newUser);
        return newUser;
        } catch (error) {
        console.error("アカウント作成に失敗しました:", error.message);
        throw error;
        }
    };

    // ログアウト関数
    const logout = async () => {
        await signOut(auth);
    };

  // ✅ アカウント削除機能(FirestoreとFirebase Authentication両方から削除)
  const deleteAccount = async () => {
    const currentUser = auth.currentUser;

    if (currentUser) {
      try {
        // Firestoreのユーザーデータを削除
        const userRef = doc(db, "users", currentUser.uid);
        await deleteDoc(userRef);

        // Firebase Authenticationからユーザーを削除
        await deleteUser(currentUser);

        setUser(null);
        console.log("アカウントが削除されました。");
      } catch (error) {
        console.error("アカウント削除に失敗しました:", error.message);
        throw error;
      }
    } else {
      console.error("ユーザーが存在しません。");
    }
  };

  return (
    <AuthContext.Provider
      value={{ user, createUser, login, signInWithGoogle, logout, deleteAccount }}
    >
      {children}
    </AuthContext.Provider>
  );
};

src

App.js:アプリのエントリーポイント&ルーティング関連

import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Signup from "./pages/Signup";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Layout from "./templates/Layout";
import { useAuth } from "./utils/auth";

const App = () => {
  const { user } = useAuth();

  return (
    <>
      <Layout>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/signup" element={<Signup />} />
          <Route path="/login" element={<Login />} />
          <Route path="/dashboard" element={user ? <Dashboard /> : <Login />} />
        </Routes>
      </Layout>
    </>
  );
};

export default App;

index.js:ルートレンダリング

import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from "./utils/auth";   // ✅ AuthProviderをインポート
import App from "./App";
import "./index.css";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <AuthProvider>  {/* ✅ AuthProviderでラップ */}
        <App />
      </AuthProvider>
    </BrowserRouter>
  </React.StrictMode>
);

-Web Technologies