用React和Firebase建立一个Google Classroom的克隆系统

1,145 阅读12分钟

现在,整个世界正面临着一场大流行,学校和学院等教育机构正在转向完全的在线教学体验。互联网上有许多协助教师的服务,如谷歌课堂、微软团队和Zoom。

现在,似乎每天都有新的服务出现,声称可以改善教学过程。这就是为什么我认为知道如何制作你自己的谷歌课堂克隆是有用的!本教程将教你如何制作谷歌课堂克隆。

本教程将教你如何使用React和Firebase,这样你就可以把所有的碎片放在一起,做出一个漂亮的应用程序。

你需要什么?

  • 一个代码编辑器。我推荐Visual Studio Code,因为它有一个集成的终端。
  • 安装NodeJS,因为我们使用的是React
  • 一个谷歌账户来使用Firebase
  • 对React有一定的了解--我不建议初学者使用这个教程。

创建一个React应用程序

现在,让我们开始进行这个有趣的构建工作吧要创建一个React应用程序,在一个安全的文件夹中打开终端,并输入以下命令;npm将为你做其他的工作。

npx create-react-app app-name

记住用你想给构建的实际名称替换app-name 。在我的例子中,我把它命名为google-classroom-clone

一旦应用程序安装完毕,在该目录下打开Visual Studio Code。然后,打开集成终端(Windows中为Ctrl+J,MacOS中为Cmd+J),输入以下命令来启动该应用程序。

npm start

如果你看到下面的屏幕,你已经成功创建了你的React应用程序。

Screenshot of blank React app

现在,让我们对文件进行快速清理。你可以从项目中删除以下内容;我们不需要它们。

  • logo.svg
  • setupTests.svg
  • App.test.js

继续打开App.js ,删除顶部的logo.svg 导入,以及文件中div 标签下的所有内容。你的文件现在应该看起来像这样。

import "./App.css";
function App() {
  return <div className="app"></div>;
}
export default App;

删除App.css 的内容,因为我们不需要React给我们的默认样式。

接下来,在终端键入以下内容,以安装将帮助我们完成整个项目的依赖。

npm install firebase moment react-firebase-hooks recoil @material-ui/core @material-ui/icons react-router-dom

firebase 帮助我们与Firebase服务轻松互动,moment 帮助我们在项目中处理日期。因为我们使用的是基于函数的React组件,react-firebase-hooks 提供了各种钩子,告知我们用户的状态。

recoil 是一个和Redux一样不复杂的状态管理库,所以我决定我们可以使用这个,并保持简单。

@material-ui/core@material-ui/icons 为我们提供了各种预建的组件和SVG图标。最后,react-router-dom 帮助我们处理路由。

设置Firebase

我们将使用Firebase作为后台,所以请准备好你的Google账户信息。现在,进入Firebase控制台并登录。现在,点击添加项目,你应该看到以下屏幕。

Screenshot of Firebase create project screen

输入一个项目名称。在我的例子中,我将使用google-classroom-clone-article

现在会提示你是否要为你的项目启用谷歌分析。尽管我们并不真正需要Google Analytics,但保持它的启用也没有坏处。当被要求选择一个账户时,记得选择Firebase的默认账户。点击创建项目

现在Firebase会给这个项目分配资源。一旦完成,按Continue进入你的项目仪表板。现在,让我们在Firebase仪表板上设置一些东西。

启用认证

在侧边栏中,点击认证,你会看到下面的屏幕。

Screenshot of Firebase Authentication screen

点击 "开始"。这将为你启用认证模块,你应该看到各种可用的认证选项。

Screenshot of Firebase authentication sign-in providers list

在这里,我们将使用谷歌认证,所以点击谷歌,按启用,填写所需的细节,并点击保存

你已经成功地在你的Firebase项目中设置了Google认证。

启用Cloud Firestore

Firebase的Cloud Firestore是一个非关系型数据库,就像MongoDB一样。要启用Cloud Firestore,在侧边栏点击Firestore数据库

Screenshot of Cloud Firestore homepage

点击创建数据库,你会被提示以下模式。

Screenshot of create database page in Cloud Firestore

记住要在测试模式下启动Firestore数据库。这是因为我们不想担心生产环境和安全规则,以便更专注于开发方面的事情。然而,你可以在完成项目后把它改为生产模式。

点击下一步,选择一个数据库位置,然后按创建。这将最终初始化你的Cloud Firestore数据库。

现在让我们来复制我们的Firebase配置。点击侧边栏上的齿轮图标,进入项目设置。向下滚动,你会看到这个部分。

Screenshot of Firebase project settings that says "there are no apps in your project"

点击第三个图标**(</>),它代表一个网络应用。为该应用程序提供一个名称,然后点击注册应用程序**。忽略其他每一步,我们将手动完成它。

现在,返回项目设置,并复制配置。它应该看起来像这样。

Screenshot of SDK setup and configuration screen

将一个React应用与Firebase连接起来

现在一切都设置好了,我们终于可以进入编码的有趣部分了在你的React应用中,创建一个名为firebase.js 的新文件,并使用这个语句导入firebase 包。

import firebase from "firebase";

现在粘贴配置。我们需要初始化应用程序,以便我们的React应用程序能够与Firebase通信。要做到这一点,使用下面的代码。

const app = firebase.initializeApp(firebaseConfig);
const auth = app.auth();
const db = app.firestore();

在上面的代码中,我们正在使用initializeApp() 函数启动与Firebase的连接。然后,我们从我们的app 中提取authfirestore 模块,并将它们存储在不同的变量中。

现在让我们设置几个函数来帮助我们的应用程序。

const googleProvider = new firebase.auth.GoogleAuthProvider();

// Sign in and check or create account in firestore
const signInWithGoogle = async () => {
  try {
    const response = await auth.signInWithPopup(googleProvider);
    console.log(response.user);
    const user = response.user;
    console.log(`User ID - ${user.uid}`);
    const querySnapshot = await db
      .collection("users")
      .where("uid", "==", user.uid)
      .get();
    if (querySnapshot.docs.length === 0) {
      // create a new user
      await db.collection("users").add({
        uid: user.uid,
        enrolledClassrooms: [],
      });
    }
  } catch (err) {
    alert(err.message);
  }
};

const logout = () => {
  auth.signOut();
};

在上面的代码中,我们正在获取Firebase提供的GoogleAuthProvider ,以帮助我们进行谷歌认证。如果在认证中出现任何错误,用户将自动被转移到catch 块,错误将被显示在屏幕上。

我们正在使用Firebase的signInWithPopup() 函数,并传入Google提供商,以便告诉Firebase我们想通过外部提供商来登录。在这种情况下,它是谷歌。

接下来,我们要检查我们的Firestore数据库,看认证的用户是否存在于我们的数据库中。如果不存在,我们就在数据库中创建一个新条目,这样我们就认为这个用户已经注册了_。_

Firebase在处理本地存储方面是非常聪明的。认证将在页面重新加载时持续存在,Firebase在引擎盖下处理它。所以一旦用户被认证,我们不需要再做任何事情。

现在,我们创建一个logout() 函数。

最后,导出这些模块,以便我们可以在整个应用程序中使用它们。

export { app, auth, db, signInWithGoogle, logout };

现在让我们继续配置路由器。

配置React Router

我们需要配置我们的React应用程序,使其能够处理多个屏幕的多个路由。react-router-dom ,帮助我们实现这一点。

转到App.js ,从这个包中导入必要的组件。

import { BrowserRouter as Router, Route, Switch } from "react-router-dom";

现在,在空的<div> ,添加以下配置。

<Router>
  <Switch>
    <Route exact path="/">
      Hello
    </Route>
  </Switch>
</Router>

你可能之前已经使用过React Router,因为几乎所有的多页面项目都会用到它。但如果你不了解它,不用担心,让我们分解上面的代码,看看这里发生了什么。

你所有的代码都必须被封闭在<Router> 。这有助于包保持对页面和组件的跟踪。

<Switch> 告诉路由器,这个部分需要在页面变化时进行更改。所以,有些组件应该只在用户处于某个页面时才会显示。在我们的例子中,我们正在切换屏幕,因为这就是用户通常做的事情。

当用户在path 中指定的路线上时,被包围在<Route> 下的组件会被呈现。

现在,如果你注意到,你可以在屏幕上看到Hello 。但如果你把URL改为类似 [http://localhost:3000/test](http://localhost:3000/test),你会看到Hello 不再出现。这就是React Router的力量。

创建一个新的组件

让我们来制作一个新的组件。我强烈建议在VS Code中安装ES7 React Snippets扩展。它将帮助你非常容易地制作React组件。

创建一个名为screens 的新文件夹,并制作两个文件,分别称为Home.jsHome.css 。转到Home.js ,开始输入rfce ,然后按回车键。一个全新的React组件将被制作出来。通过在顶部包括这个声明来导入CSS文件。

import "./Home.css";

我们将一直使用这种方法来创建组件。让我们回到App.js ,把主页添加到我们的主页路线中。不要忘记像这样导入组件,否则你会面临错误。

import Home from "./components/Home";

你在App.js 中的JSX应该是这样的。

<Router>
  <Switch>
    <Route exact path="/">
      <Home />
    </Route>
  </Switch>
</Router>

现在去Home.js ,添加以下布局。

<div className="home">
  <div className="home__container">
    <img
      src="https://upload.wikimedia.org/wikipedia/commons/5/59/Google_Classroom_Logo.png"
      alt="Google Classroom Image"
      className="home__image"
    />
    <button className="home__login">
      Login with Google
    </button>
  </div>
</div>

我们在本教程中不会关注样式,所以在Home.css 文件中使用以下CSS。

.home {
  height: 100vh;
  width: 100vw;
  display: flex;
  align-items: center;
  justify-content: center;
}
.home__container {
  background-color: #f5f5f5;
  box-shadow: 0px 0px 2px -1px black;
  display: flex;
  flex-direction: column;
  padding: 30px;
}
.home__login {
  margin-top: 30px;
  padding: 20px;
  background-color: #2980b9;
  font-size: 18px;
  color: white;
  border: none;
  text-transform: uppercase;
  border-radius: 10px;
}

保存后,你应该看到一个像这样的屏幕。

Screenshot of example app with Google Classroom logo and text that says "login using google"

实现谷歌登录功能

我们已经在firebase.js 中制作了处理认证的函数,现在我们可以实现它。

在 "用谷歌登录 "按钮的onClick 方法中添加signInWithGoogle 函数。不要忘记导入signInWithGoogle 函数。现在你的JSX应该是这样的。

<div className="home">
  <div className="home__container">
    <img
      src="https://upload.wikimedia.org/wikipedia/commons/5/59/Google_Classroom_Logo.png"
      alt="Google Classroom Image"
      className="home__image"
    />
    <button className="home__login" onClick={signInWithGoogle}>
      Login with Google
    </button>
  </div>
</div>

当用户登录时,我们要用React Router和Firebase钩子把他们重定向到仪表板。Firebase钩子总是监控用户的认证状态是否有变化,所以我们可以使用这些数据来检查用户是否已经登录。让我们像这样导入Firebase和路由器钩子。

import { useAuthState } from "react-firebase-hooks/auth";
import { useHistory } from "react-router-dom";

然后,在你的组件中,添加以下几行,以便使用钩子。

const [user, loading, error] = useAuthState(auth);
const history = useHistory();

第一行给我们提供用户的状态。所以,如果用户处于loading 状态,loading 是真的。如果用户没有登录,user 将是未定义的,或者没有用户数据。如果有任何错误,它将被存储在error

现在,history 让我们可以在用户没有点击链接的情况下,通过我们的代码路由用户。我们可以使用history.push()history.replace() 等方法来管理路由。

最后,让我们做一个useEffect() 钩子,一旦用户通过了验证,就会重定向用户。

useEffect(() => {
  if (loading) return;
  if (user) history.push("/dashboard");
}, [loading, user]);

上面的代码在用户状态改变时检查用户是否已经登录。如果是,他就会被重定向到/dashboard 路由。我之所以使用useEffect() ,是因为我可以在认证状态更新时检查用户的状态。所以,如果一个已经登录的用户访问主页,他将立即被重定向到仪表板,而不显示登录屏幕。

现在,如果你试图用你的谷歌账户登录,你会看到一个空白的屏幕,因为我们还没有一个仪表板。但在创建仪表板之前,我们将创建一个导航条,这在我们的大多数屏幕中都是常见的。

创建一个导航条

src 目录下创建一个新的文件夹,命名为components ,然后创建一个新的组件,命名为Navbar ,同时创建JSCSS 文件。像这样把导航条放在我们的/dashboard 路线中,App.js

<Route exact path="/dashboard">
  <Navbar />
</Route>

现在,如果你登录了,导航条组件就被放置了。让我们来添加基本的布局。首先,添加Firebase钩子,因为我们将需要它们来获取用户数据。

const [user, loading, error] = useAuthState(auth);

你的文件应该看起来像这样。

import { Avatar, IconButton, MenuItem, Menu } from "@material-ui/core";
import { Add, Apps, Menu as MenuIcon } from "@material-ui/icons";
import React, { useState } from "react";
import { useAuthState } from "react-firebase-hooks/auth";
import { auth, logout } from "../firebase";
import "./Navbar.css";
function Navbar() {
  const [user, loading, error] = useAuthState(auth);
  const [anchorEl, setAnchorEl] = useState(null);

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

  return (
    <>
      <CreateClass />
      <JoinClass />
      <nav className="navbar">
        <div className="navbar__left">
          <IconButton>
            <MenuIcon />
          </IconButton>
          <img
            src="https://1000logos.net/wp-content/uploads/2021/05/Google-logo.png"
            alt="Google Logo"
            className="navbar__logo"
          />{" "}
          <span>Classroom</span>
        </div>
        <div className="navbar__right">
          <IconButton
            aria-controls="simple-menu"
            aria-haspopup="true"
            onClick={handleClick}
          >
            <Add />
          </IconButton>
          <IconButton>
            <Apps />
          </IconButton>
          <IconButton onClick={logout}>
            <Avatar src={user?.photoURL} />
          </IconButton>
          <Menu
            id="simple-menu"
            anchorEl={anchorEl}
            keepMounted
            open={Boolean(anchorEl)}
            onClose={handleClose}
          >
            <MenuItem>
              Create Class
            </MenuItem>
            <MenuItem>
              Join Class
            </MenuItem>
          </Menu>
        </div>
      </nav>
    </>
  );
}
export default Navbar;

将以下样式添加到Navbar.css

.navbar {
  width: 100vw;
  height: 65px;
  border-bottom: 1px solid #dcdcdc;
  display: flex;
  justify-content: space-between;
  padding: 0 20px;
  align-items: center;
}
.navbar__left {
  display: flex;
  align-items: center;
}
.navbar__left img {
  margin-right: 20px;
  margin-left: 20px;
}
.navbar__left span {
  font-size: 20px;
}
.navbar__right {
  display: flex;
  align-items: center;
}
.navbar__logo {
  height: 30px;
  width: auto;
}

我们使用了Material UI组件以及我自己的造型,所以你的导航条应该是这样的。

Screenshot of basic Material UI navbar in Google Classroom

如果你点击**+**图标,你应该看到弹出一个菜单。

Screenshot of a menu that has "create class" and "join class" options

现在,点击任何一个选项都不会做任何事情。让我们制作新的组件,作为创建和加入一个类的模版。为此,我们需要状态管理来确定模态是打开还是关闭。

我们有很多组件,所以recoil 状态管理库被用来提升数据,供每个组件访问。在src 中创建一个名为utils 的新文件夹,并创建一个名为atoms.js 的新文件。这个文件应该看起来像下面这样。

import { atom } from "recoil";
const joinDialogAtom = atom({
  key: "joinDialogAtom",
  default: false,
});
const createDialogAtom = atom({
  key: "createDialogAtom",
  default: false,
});
export { createDialogAtom, joinDialogAtom };

原子只是用来存储你的全局数据的空间。在这里,我们做了两个全局原子,表示 "加入 "或 "创建 "模式是否打开。默认情况下,它们总是false

现在让我们着手创建一个类。

创建一个类

components 文件夹中创建一个新的组件,名为CreateClass 。我们不需要一个CSS文件,因为我们使用的是Material UI组件。

import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
  TextField,
} from "@material-ui/core";
import React, { useState } from "react";
import { useAuthState } from "react-firebase-hooks/auth";
import { useRecoilState } from "recoil";
import { auth, db } from "../firebase";
import { createDialogAtom } from "../utils/atoms";
function CreateClass() {
  const [user, loading, error] = useAuthState(auth);
  const [open, setOpen] = useRecoilState(createDialogAtom);
  const [className, setClassName] = useState("");
  const handleClose = () => {
    setOpen(false);
  };

  return (
    <div>
      <Dialog
        open={open}
        onClose={handleClose}
        aria-labelledby="form-dialog-title"
      >
        <DialogTitle id="form-dialog-title">Create class</DialogTitle>
        <DialogContent>
          <DialogContentText>
            Enter the name of class and we will create a classroom for you!
          </DialogContentText>
          <TextField
            autoFocus
            margin="dense"
            label="Class Name"
            type="text"
            fullWidth
            value={className}
            onChange={(e) => setClassName(e.target.value)}
          />
        </DialogContent>
        <DialogActions>
          <Button onClick={handleClose} color="primary">
            Cancel
          </Button>
          <Button onClick={handleClose} color="primary">
            Create
          </Button>
        </DialogActions>
      </Dialog>
    </div>
  );
}
export default CreateClass;

在这里,我们正在导入我们的Recoil原子并检索其中的数据,在我们的例子中,这是一个布尔值,告诉我们的模态是否被打开。

我们还将与文本框同步一个状态,这样我们就可以随时从文本框中获取数据。

open <Dialog> 上的道具来自于状态,所以如果打开的状态被设置为 ,模态就会出现。true

我们有两个按钮,通过将我们的打开状态设置为false ,就可以关闭模态。

现在,让我们创建一个函数,它将通过联系我们的Cloud Firestore数据库来创建一个类。

const createClass = async () => {
  try {
    const newClass = await db.collection("classes").add({
      creatorUid: user.uid,
      name: className,
      creatorName: user.displayName,
      creatorPhoto: user.photoURL,
      posts: [],
    });
    const userRef = await db
      .collection("users")
      .where("uid", "==", user.uid)
      .get();
    const docId = userRef.docs[0].id;
    const userData = userRef.docs[0].data();
    let userClasses = userData.enrolledClassrooms;
    userClasses.push({
      id: newClass.id,
      name: className,
      creatorName: user.displayName,
      creatorPhoto: user.photoURL,
    });
    const docRef = await db.collection("users").doc(docId);
    await docRef.update({
      enrolledClassrooms: userClasses,
    });
    handleClose();
    alert("Classroom created successfully!");
  } catch (err) {
    alert(`Cannot create class - ${err.message}`);
  }
};

在这个函数中,我们使用一个try-catch块,这样我们可以处理在联系Firebase时捕获的任何错误。

我们正在用从文本框状态和Firebase钩子得到的数据在集合中创建一个新条目classes ,然后通过使用用户ID在数据库中获取用户的数据。

我们还将类的ID添加到我们用户的enrolledClasses 数组中,并更新我们数据库中的用户数据。

现在在创建按钮的onClick ,插入这个函数。你的JS 文件应该看起来像这样。

import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
  TextField,
} from "@material-ui/core";
import React, { useState } from "react";
import { useAuthState } from "react-firebase-hooks/auth";
import { useRecoilState } from "recoil";
import { auth, db } from "../firebase";
import { createDialogAtom } from "../utils/atoms";
function CreateClass() {
  const [user, loading, error] = useAuthState(auth);
  const [open, setOpen] = useRecoilState(createDialogAtom);
  const [className, setClassName] = useState("");
  const handleClose = () => {
    setOpen(false);
  };
  const createClass = async () => {
    try {
      const newClass = await db.collection("classes").add({
        creatorUid: user.uid,
        name: className,
        creatorName: user.displayName,
        creatorPhoto: user.photoURL,
        posts: [],
      });
      // add to current user's class list
      const userRef = await db
        .collection("users")
        .where("uid", "==", user.uid)
        .get();
      const docId = userRef.docs[0].id;
      const userData = userRef.docs[0].data();
      let userClasses = userData.enrolledClassrooms;
      userClasses.push({
        id: newClass.id,
        name: className,
        creatorName: user.displayName,
        creatorPhoto: user.photoURL,
      });
      const docRef = await db.collection("users").doc(docId);
      await docRef.update({
        enrolledClassrooms: userClasses,
      });
      handleClose();
      alert("Classroom created successfully!");
    } catch (err) {
      alert(`Cannot create class - ${err.message}`);
    }
  };
  return (
    <div>
      <Dialog
        open={open}
        onClose={handleClose}
        aria-labelledby="form-dialog-title"
      >
        <DialogTitle id="form-dialog-title">Create class</DialogTitle>
        <DialogContent>
          <DialogContentText>
            Enter the name of class and we will create a classroom for you!
          </DialogContentText>
          <TextField
            autoFocus
            margin="dense"
            label="Class Name"
            type="text"
            fullWidth
            value={className}
            onChange={(e) => setClassName(e.target.value)}
          />
        </DialogContent>
        <DialogActions>
          <Button onClick={handleClose} color="primary">
            Cancel
          </Button>
          <Button onClick={createClass} color="primary">
            Create
          </Button>
        </DialogActions>
      </Dialog>
    </div>
  );
}
export default CreateClass;

加入一个班级

加入一个类的基本概念与创建一个类非常相似。下面是JoinClass.js ,应该是这样的。

import {
  Button,
  Dialog,
  DialogActions,
  DialogContent,
  DialogContentText,
  DialogTitle,
  TextField,
} from "@material-ui/core";
import React, { useState } from "react";
import { useAuthState } from "react-firebase-hooks/auth";
import { useRecoilState } from "recoil";
import { auth, db } from "../firebase";
import { joinDialogAtom } from "../utils/atoms";
function JoinClass() {
  const [open, setOpen] = useRecoilState(joinDialogAtom);
  const [user, loading, error] = useAuthState(auth);
  const [classId, setClassId] = useState("");
  const handleClose = () => {
    setOpen(false);
  };
  const joinClass = async () => {
    try {
      // check if class exists
      const classRef = await db.collection("classes").doc(classId).get();
      if (!classRef.exists) {
        return alert(`Class doesn't exist, please provide correct ID`);
      }
      const classData = await classRef.data();
      // add class to user
      const userRef = await db.collection("users").where("uid", "==", user.uid);
      const userData = await (await userRef.get()).docs[0].data();
      let tempClassrooms = userData.enrolledClassrooms;
      tempClassrooms.push({
        creatorName: classData.creatorName,
        creatorPhoto: classData.creatorPhoto,
        id: classId,
        name: classData.name,
      });
      await (
        await userRef.get()
      ).docs[0].ref.update({
        enrolledClassrooms: tempClassrooms,
      });
      // alert done
      alert(`Enrolled in ${classData.name} successfully!`);
      handleClose();
    } catch (err) {
      console.error(err);
      alert(err.message);
    }
  };
  return (
    <div className="joinClass">
      <Dialog
        open={open}
        onClose={handleClose}
        aria-labelledby="form-dialog-title"
      >
        <DialogTitle id="form-dialog-title">Join class</DialogTitle>
        <DialogContent>
          <DialogContentText>
            Enter ID of the class to join the classroom
          </DialogContentText>
          <TextField
            autoFocus
            margin="dense"
            label="Class Name"
            type="text"
            fullWidth
            value={classId}
            onChange={(e) => setClassId(e.target.value)}
          />
        </DialogContent>
        <DialogActions>
          <Button onClick={handleClose} color="primary">
            Cancel
          </Button>
          <Button onClick={joinClass} color="primary">
            Join
          </Button>
        </DialogActions>
      </Dialog>
    </div>
  );
}
export default JoinClass;

这里的区别是我们使用了另一个原子,它检查 "加入类 "模式是否被打开,以及该类是否存在。如果是,我们就把它添加到用户的enrolledClasses 数组中,并在Firestore中更新用户。这是如此简单

现在,我们需要将导航条中的所有内容连接起来,并设置onClick 的功能。下面是你的Navbar.js 文件应该是这样的。

import { Avatar, IconButton, MenuItem, Menu } from "@material-ui/core";
import { Add, Apps, Menu as MenuIcon } from "@material-ui/icons";
import React, { useState } from "react";
import { useAuthState } from "react-firebase-hooks/auth";
import { useRecoilState } from "recoil";
import { auth, logout } from "../firebase";
import { createDialogAtom, joinDialogAtom } from "../utils/atoms";
import CreateClass from "./CreateClass";
import JoinClass from "./JoinClass";
import "./Navbar.css";
function Navbar() {
  const [user, loading, error] = useAuthState(auth);
  const [anchorEl, setAnchorEl] = useState(null);
  const [createOpened, setCreateOpened] = useRecoilState(createDialogAtom);
  const [joinOpened, setJoinOpened] = useRecoilState(joinDialogAtom);
  const handleClick = (event) => {
    setAnchorEl(event.currentTarget);
  };
  const handleClose = () => {
    setAnchorEl(null);
  };
  return (
    <>
      <CreateClass />
      <JoinClass />
      <nav className="navbar">
        <div className="navbar__left">
          <IconButton>
            <MenuIcon />
          </IconButton>
          <img
            src="https://1000logos.net/wp-content/uploads/2021/05/Google-logo.png"
            alt="Google Logo"
            className="navbar__logo"
          />{" "}
          <span>Classroom</span>
        </div>
        <div className="navbar__right">
          <IconButton
            aria-controls="simple-menu"
            aria-haspopup="true"
            onClick={handleClick}
          >
            <Add />
          </IconButton>
          <IconButton>
            <Apps />
          </IconButton>
          <IconButton onClick={logout}>
            <Avatar src={user?.photoURL} />
          </IconButton>
          <Menu
            id="simple-menu"
            anchorEl={anchorEl}
            keepMounted
            open={Boolean(anchorEl)}
            onClose={handleClose}
          >
            <MenuItem
              onClick={() => {
                setCreateOpened(true);
                handleClose();
              }}
            >
              Create Class
            </MenuItem>
            <MenuItem
              onClick={() => {
                setJoinOpened(true);
                handleClose();
              }}
            >
              Join Class
            </MenuItem>
          </Menu>
        </div>
      </nav>
    </>
  );
}
export default Navbar;

创建仪表板

现在让我们开始制作仪表板。在screens 文件夹中创建一个新的组件,命名为Dashboard ,记住要为它建立JSCSS 文件。下面是Dashboard.css 的风格设计。

.dashboard__404 {
  display: flex;
  height: 100vh;
  width: 100vw;
  align-items: center;
  justify-content: center;
  font-size: 20px;
}
.dashboard__classContainer {
  display: flex;
  padding: 30px;
  flex-wrap: wrap;
  width: 100vw;
}

现在,让我们先做一个组件,用来显示各个类。它没有什么特别之处,只是用样式来渲染数据。在components ,创建一个名为ClassCard 的新组件,并复制这个布局。

import { IconButton } from "@material-ui/core";
import { AssignmentIndOutlined, FolderOpenOutlined } from "@material-ui/icons";
import React from "react";
import { useHistory } from "react-router-dom";
import "./ClassCard.css";
function ClassCard({ name, creatorName, creatorPhoto, id, style }) {
  const history = useHistory();
  const goToClass = () => {
    history.push(`/class/${id}`);
  };
  return (
    <div className="classCard" style={style} onClick={goToClass}>
      <div className="classCard__upper">
        <div className="classCard__className">{name}</div>
        <div className="classCard__creatorName">{creatorName}</div>
        <img src={creatorPhoto} className="classCard__creatorPhoto" />
      </div>
      <div className="classCard__middle"></div>
      <div className="classCard__lower">
        <IconButton>
          <FolderOpenOutlined />
        </IconButton>
        <IconButton>
          <AssignmentIndOutlined />
        </IconButton>
      </div>
    </div>
  );
}
export default ClassCard;

在这里,我们只是接收道具并将其渲染出来。有一点需要注意的是,当用户按下卡片组件时,她会被重定向到类屏幕。

这里是ClassCard.css

.classCard__upper {
  background-color: #008d7d;
  height: 90px;
  position: relative;
  color: white;
  padding: 10px;
  border-bottom: 1px solid #dcdcdc;
}
.classCard {
  width: 300px;
  border: 1px solid #dcdcdc;
  border-radius: 5px;
  overflow: hidden;
  cursor: pointer;
}
.classCard__middle {
  height: 190px;
  border-bottom: 1px solid #dcdcdc;
}
.classCard__creatorPhoto {
  position: absolute;
  right: 5px;
  border-radius: 9999px;
}
.classCard__className {
  font-weight: 600;
  font-size: 30px;
}
.classCard__creatorName {
  position: absolute;
  bottom: 12px;
  font-size: 15px;
}
.classCard__lower {
  display: flex;
  flex-direction: row-reverse;
}

现在,让我们把Dashboard 组件纳入我们的App.js 文件。

<Router>
  <Switch>
    <Route exact path="/">
      <Home />
    </Route>
    <Route exact path="/dashboard">
      <Navbar />
      <Dashboard />
    </Route>
  </Switch>
</Router>

现在打开Dashboard.js ,做一个函数来获取用户的所有课程。

const fetchClasses = async () => {
  try {
    await db
      .collection("users")
      .where("uid", "==", user.uid)
      .onSnapshot((snapshot) => {
        setClasses(snapshot?.docs[0]?.data()?.enrolledClassrooms);
      });
  } catch (error) {
    console.error(error.message);
  }
};

这段代码与我们从Firestore获取数据时相似。我们设置了一个快照监听器,这样,每当Firestore数据库中的数据更新时,这些变化就会反映在这里。

现在我们有了类的状态,我们可以很容易地把它们渲染出来。这是你的Dashboard.js 文件应该是这样的。

import React, { useEffect } from "react";
import "./Dashboard.css";
import { useAuthState } from "react-firebase-hooks/auth";
import { auth, db } from "../firebase";
import { useHistory } from "react-router-dom";
import { useState } from "react";
import ClassCard from "../components/ClassCard";
function Dashboard() {
  const [user, loading, error] = useAuthState(auth);
  const [classes, setClasses] = useState([]);
  const history = useHistory();
  const fetchClasses = async () => {
    try {
      await db
        .collection("users")
        .where("uid", "==", user.uid)
        .onSnapshot((snapshot) => {
          setClasses(snapshot?.docs[0]?.data()?.enrolledClassrooms);
        });
    } catch (error) {
      console.error(error.message);
    }
  };
  useEffect(() => {
    if (loading) return;
    if (!user) history.replace("/");
  }, [user, loading]);
  useEffect(() => {
    if (loading) return;
    fetchClasses();
  }, [user, loading]);
  return (
    <div className="dashboard">
      {classes?.length === 0 ? (
        <div className="dashboard__404">
          No classes found! Join or create one!
        </div>
      ) : (
        <div className="dashboard__classContainer">
          {classes.map((individualClass) => (
            <ClassCard
              creatorName={individualClass.creatorName}
              creatorPhoto={individualClass.creatorPhoto}
              name={individualClass.name}
              id={individualClass.id}
              style={{ marginRight: 30, marginBottom: 30 }}
            />
          ))}
        </div>
      )}
    </div>
  );
}
export default Dashboard;

现在,如果你创建一些类,你应该看到它们在页面上出现。

Screenshot of three test classes within Google Classroom

祝贺你!我们的仪表板已经准备好了。现在我们需要制作一个班级屏幕,在这个屏幕上将显示每个班级的所有公告。

创建班级屏幕

首先,让我们创建一个组件,它将帮助我们创建班级屏幕。我们需要显示一个班级的公告,所以我们制作一个Announcement ,这个组件将接收道具并渲染出数据。

将以下内容复制到你的Announcement.js 文件中。

import { IconButton } from "@material-ui/core";
import { Menu, MoreVert } from "@material-ui/icons";
import React from "react";
import "./Announcement.css";
function Announcement({ image, name, date, content, authorId }) {
  return (
    <div className="announcement">
      <div className="announcement__informationContainer">
        <div className="announcement__infoSection">
          <div className="announcement__imageContainer">
            <img src={image} alt="Profile photo" />
          </div>
          <div className="announcement__nameAndDate">
            <div className="announcement__name">{name}</div>
            <div className="announcement__date">{date}</div>
          </div>
        </div>
        <div className="announcement__infoSection">
          <IconButton>
            <MoreVert />
          </IconButton>
        </div>
      </div>
      <div className="announcement__content">{content}</div>
    </div>
  );
}
export default Announcement;

这里没有发生什么,只是基本的布局。这里是Announcement.css

.announcement {
  width: 100%;
  padding: 25px;
  border-radius: 10px;
  border: 1px solid #adadad;
  margin-bottom: 20px;
}
.announcement__informationContainer {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.announcement__infoSection {
  display: flex;
  align-items: center;
}
.announcement__nameAndDate {
  margin-left: 10px;
}
.announcement__name {
  font-weight: 600;
}
.announcement__date {
  color: #424242;
  font-size: 14px;
  margin-top: 2px;
}
.announcement__imageContainer > img {
  height: 50px;
  width: 50px;
  border-radius: 9999px;
}
.announcement__content {
  margin-top: 15px;
}

现在,让我们来创建类屏幕。在screens 文件夹中创建一个名为Class 的新组件。让我们把它包括在我们的App.js

<Router>
  <Switch>
    <Route exact path="/">
      <Home />
    </Route>
    <Route exact path="/dashboard">
      <Navbar />
      <Dashboard />
    </Route>
    <Route exact path="/class/:id">
      <Navbar />
      <Dashboard />
    </Route>
  </Switch>
</Router>

这里需要注意的一点是:id ,这是我们通过URL发送的查询参数。我们可以在我们的类屏幕中访问这个id ,感谢React Router。这里是Class.css 的内容。

.class {
  width: 55%;
  margin: auto;
}
.class__nameBox {
  width: 100%;
  background-color: #0a9689;
  color: white;
  height: 350px;
  margin-top: 30px;
  border-radius: 10px;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  padding: 30px;
  font-weight: bold;
  font-size: 43px;
}
.class__announce {
  display: flex;
  align-items: center;
  width: 100%;
  padding: 20px;
  margin-bottom: 25px;
  box-shadow: 0px 1px 6px -2px black;
  justify-content: space-between;
  border-radius: 15px;
  margin-top: 20px;
}
.class__announce > img {
  height: 50px;
  width: 50px;
  border-radius: 9999px;
}
.class__announce > input {
  border: none;
  padding: 15px 20px;
  width: 100%;
  margin-left: 20px;
  margin-right: 20px;
  font-size: 17px;
  outline: none;
}

现在让我们关注一下Class.js 。同样,这与我们之前对组件所做的是一样的。

import { IconButton } from "@material-ui/core";
import { SendOutlined } from "@material-ui/icons";
import moment from "moment";
import React from "react";
import { useEffect } from "react";
import { useState } from "react";
import { useAuthState } from "react-firebase-hooks/auth";
import { useHistory, useParams } from "react-router-dom";
import Announcement from "../components/Announcement";
import { auth, db } from "../firebase";
import "./Class.css";
function Class() {
  const [classData, setClassData] = useState({});
  const [announcementContent, setAnnouncementContent] = useState("");
  const [posts, setPosts] = useState([]);
  const [user, loading, error] = useAuthState(auth);
  const { id } = useParams();
  const history = useHistory();
  useEffect(() => {
    // reverse the array
    let reversedArray = classData?.posts?.reverse();
    setPosts(reversedArray);
  }, [classData]);
  const createPost = async () => {
    try {
      const myClassRef = await db.collection("classes").doc(id).get();
      const myClassData = await myClassRef.data();
      console.log(myClassData);
      let tempPosts = myClassData.posts;
      tempPosts.push({
        authorId: user.uid,
        content: announcementContent,
        date: moment().format("MMM Do YY"),
        image: user.photoURL,
        name: user.displayName,
      });
      myClassRef.ref.update({
        posts: tempPosts,
      });
    } catch (error) {
      console.error(error);
      alert(`There was an error posting the announcement, please try again!`);
    }
  };
  useEffect(() => {
    db.collection("classes")
      .doc(id)
      .onSnapshot((snapshot) => {
        const data = snapshot.data();
        if (!data) history.replace("/");
        console.log(data);
        setClassData(data);
      });
  }, []);
  useEffect(() => {
    if (loading) return;
    if (!user) history.replace("/");
  }, [loading, user]);
  return (
    <div className="class">
      <div className="class__nameBox">
        <div className="class__name">{classData?.name}</div>
      </div>
      <div className="class__announce">
        <img src={user?.photoURL} alt="My image" />
        <input
          type="text"
          value={announcementContent}
          onChange={(e) => setAnnouncementContent(e.target.value)}
          placeholder="Announce something to your class"
        />
        <IconButton onClick={createPost}>
          <SendOutlined />
        </IconButton>
      </div>
      {posts?.map((post) => (
        <Announcement
          authorId={post.authorId}
          content={post.content}
          date={post.date}
          image={post.image}
          name={post.name}
        />
      ))}
    </div>
  );
}
export default Class;

这里有很多事情要做,让我们把它分解一下。

我们正在useEffect() 钩子中设置一个快照监听器,这样我们就可以从数据库中获得现有的帖子。然后,我们反转数组,将其保存在另一个状态。反转帖子将给我们最新的帖子在上面。

我们正在根据posts ,渲染Announcement 组件。一旦一个人创建了一个公告,帖子数组就会从数据库中获取,一个新的条目就会被添加,数据就会在数据库中更新。

因为我们有一个快照监听器,每当我们创建一个帖子,它就会自动在屏幕上更新。

类的ID在URL栏中。其他用户可以使用这个ID来加入这个班级。

如果一切设置正确,在添加一些帖子后,你应该看到类似这样的东西。

Screenshot of test announcements in Google Classroom

祝贺你!接下来是什么?

你已经成功地使用React和Firebase制作了一个Google Classroom的克隆!现在你可以玩玩代码了。现在你可以玩玩代码了--尝试新的东西,比如编辑帖子,或者添加评论和附件。

我还建议你做不同的克隆。像这样的练习会帮助你在更深的层次上理解这些流行的应用程序是如何工作的。

如果你需要这个克隆的代码,请查看我的GitHub仓库,我已经把所有的代码都推送到那里。如果你想增加更多的功能,你可以提出拉动请求。

The postBuild a Google Classroom clone with React and Firebaseappeared first onLogRocket Blog.