Nextron과 함께하는 익명의 실시간 질문(채팅) 앱

Anonymous Realtime Question(Chat) App with Nextron



우리는 모여서 회사에서 한 달에 한 번 CEO가 우리에게 연설을합니다. '시민회관'.

연설이 끝나면 CEO는 우리가 질문할 때까지 기다립니다. 그는 그것이 직원들과 소통할 수 있는 좋은 방법이며 회사에 도움이 될 것이라고 말했다. 하지만 방안에 가득찬 동료들 앞에서 질문을 하기란 쉽지 않다. 나는 내 질문이 그에게 말해도 괜찮은지 생각해야 했다. 나는 회사에서 얼간이가 되고 싶지 않다.

회의가 끝나면 아이디어가 떠오른다. "모바일에서 질문을 할 수 있는데 그가 누가 질문하는지 알아낼 수 없다면 어떨까요?"그는 우리에게서 정직한 질문을 받을 것이고 우리는 익명으로 자유롭게 질문합니다.
실제로 유용한지는 모르겠지만 이 아이디어를 구현하기로 결정했습니다.

내 프로젝트에서 nextron를 사용할 것입니다. nextronelectronnext와 함께 쉽게 사용할 수 있도록 도와줍니다. create-react-app 와 같습니다.

프로젝트 파일 구조(src)


  • 메인
  • API
  • 스키마
  • 질문.ts

  • 질문.ts
  • 서버.ts
  • 도우미(기본값)
  • background.ts(기본값 및 편집됨)

  • 렌더러
  • 부품
  • 헤더.tsx
  • 질문.tsx
  • 페이지
  • _app.tsx(기본값)
  • _document.tsx
  • index.css(기본값 및 편집됨)
  • main.tsx(기본값 및 편집됨)

  • 주식
  • 상수.ts
  • types.ts


  • 웹 클라이언트 및 IPC


    nextron는 내가 즉시 typescriptantd로 프로젝트를 설정할 수 있도록 변수 예제를 제공합니다.
    하나의 페이지와 두 개의 구성 요소가 있습니다.

    페이지

    import React, { useEffect, useRef, useState } from "react";
    import { ipcRenderer } from "electron";
    import styled from "@emotion/styled";
    import Head from "next/head";
    
    import {
      SEND_QUESTION,
      TOGGLE_EVENT_REQ,
      TOGGLE_EVENT_RES,
    } from "../../shares/constants";
    import { Question as TQuestion } from "../../shares/types";
    import Header from "../components/Header";
    import Question from "../components/Question";
    
    const Container = styled.div`
      height: 100%;
    `;
    
    const Content = styled.div`
      background-color: #ecf0f1;
      height: calc(100% - 64px);
    `;
    
    const QuestionContainer = styled.div`
      box-sizing: border-box;
    
      padding: 24px;
      margin-top: auto;
      max-height: 100%;
      overflow: auto;
    `;
    
    function Main() {
      const [working, setWorking] = useState(false);
      const [port, setPort] = useState("");
      const [serverOn, setServerOn] = useState(false);
      const [questions, setQuestions] = useState<TQuestion[]>([]);
      const questionContainerRef = useRef<HTMLDivElement>(null);
    
      const handleServerToggle = () => {
        setPort("");
        ipcRenderer.send(TOGGLE_EVENT_REQ, !serverOn);
        setWorking(true);
      };
    
      const scrollToBottom = () => {
        if (!questionContainerRef.current) return;
    
        questionContainerRef.current.scrollTo({
          top: questionContainerRef.current.scrollHeight,
          behavior: "smooth",
        });
      };
    
      useEffect(() => {
        ipcRenderer.on(
          TOGGLE_EVENT_RES,
          (_, { result, port }: { result: boolean; port?: string }) => {
            if (!result) return;
            if (port) setPort(port);
    
            setServerOn((prev) => !prev);
            setWorking(false);
          }
        );
    
        ipcRenderer.on(SEND_QUESTION, (_, question: TQuestion) => {
          setQuestions((prevQuestions) => prevQuestions.concat(question));
        });
      }, []);
    
      useEffect(() => {
        scrollToBottom();
      }, [questions]);
    
      return (
        <Container>
          <Head>
            <title>Anonymous Question</title>
          </Head>
          <Header
            port={port}
            serverOn={serverOn}
            onServerToggle={handleServerToggle}
            serverOnDisabled={working}
          />
          <Content>
            <QuestionContainer ref={questionContainerRef}>
              {questions.map((q, qIdx) => (
                <Question key={qIdx} {...q} />
              ))}
            </QuestionContainer>
          </Content>
        </Container>
      );
    }
    
    export default Main;
    
    


    두 가지 구성 요소

    import { Avatar, Typography } from "antd";
    import styled from "@emotion/styled";
    
    interface QuestionProps {
      nickname: string;
      question: string;
    }
    
    let nextRandomColorIdx = 0;
    const randomColors = [
      "#f56a00",
      "#e17055",
      "#0984e3",
      "#6c5ce7",
      "#fdcb6e",
      "#00b894",
    ];
    
    const nicknameColors: { [key: string]: string } = {};
    
    const getNicknameColor = (nickname: string) => {
      if (nicknameColors[nickname]) return nicknameColors[nickname];
    
      nicknameColors[nickname] = randomColors[nextRandomColorIdx];
      nextRandomColorIdx = (nextRandomColorIdx + 1) % randomColors.length;
    
      return nicknameColors[nickname];
    };
    
    const Container = styled.div`
      &:hover {
        transform: scale(1.05);
      }
    
      padding: 8px;
      border-bottom: 1px solid #ccc;
      transition: all 0.2s;
      display: flex;
      align-items: center;
      column-gap: 8px;
    
      > *:first-of-type {
        min-width: 48px;
      }
    `;
    
    const Question = ({ nickname, question }: QuestionProps) => {
      return (
        <Container>
          <Avatar
            size={48}
            style={{ backgroundColor: getNicknameColor(nickname), marginRight: 8 }}
          >
            {nickname}
          </Avatar>
          <Typography.Text>{question}</Typography.Text>
        </Container>
      );
    };
    export default Question;
    



    import { Switch, Typography, Layout } from "antd";
    
    export interface HeaderProps {
      serverOn?: boolean;
      onServerToggle?: VoidFunction;
      serverOnDisabled?: boolean;
      port: string;
    }
    
    const Header = ({
      serverOn,
      onServerToggle,
      serverOnDisabled,
      port,
    }: HeaderProps) => {
      return (
        <Layout.Header style={{ display: "flex", alignItems: "center" }}>
          <Typography.Text style={{ color: "white", marginRight: 12 }}>
            (:{port}) Server Status:
          </Typography.Text>
          <Switch
            checkedChildren="ON"
            unCheckedChildren="OFF"
            disabled={serverOnDisabled}
            checked={serverOn}
            onChange={onServerToggle}
          />
        </Layout.Header>
      );
    };
    
    export default Header;
    


    비즈니스 논리는 페이지 구성 요소에 있습니다.Questionnickname의 첫 글자에 따라 색상을 생성합니다.Header 에는 API 서버와 서버의 포트를 켜거나 끄는 스위치가 있습니다.

    그리고 폰트 설정입니다.

    _document.tsx

    import { Html, Head, Main, NextScript } from "next/document";
    
    export default function Document() {
      return (
        <Html>
          <Head>
            <link rel="preconnect" href="https://fonts.googleapis.com" />
            <link
              rel="preconnect"
              href="https://fonts.gstatic.com"
              crossOrigin="true"
            />
            <link
              href="https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
              rel="stylesheet"
            ></link>
          </Head>
          <body>
            <Main />
            <NextScript />
          </body>
        </Html>
      );
    }
    


    index.css

    * {
      font-family: "Noto Sans", sans-serif;
    }
    
    #__next {
      height: 100%;
    }
    



    API 서버 및 IPC



    하나의 API가 있었지만 테스트 목적으로 swagger를 설정했습니다.

    서버.ts

    import { ipcMain } from "electron";
    import path from "path";
    import { networkInterfaces } from "os";
    import express, { Express } from "express";
    import { default as dotenv } from "dotenv";
    import cors from "cors";
    import swaggerUi from "swagger-ui-express";
    import swaggerJsdoc from "swagger-jsdoc";
    
    import { ServerInfo } from "../../shares/types";
    import { TOGGLE_EVENT_REQ, TOGGLE_EVENT_RES } from "../../shares/constants";
    import questionApi from "./questions";
    
    const isProd: boolean = process.env.NODE_ENV === "production";
    let serverInfo: ServerInfo | undefined;
    
    dotenv.config();
    
    const nets = networkInterfaces();
    const addressList: string[] = [];
    
    for (const value of Object.values(nets)) {
      for (const net of value) {
        if (net.family === "IPv4" && !net.internal) {
          addressList.push(net.address);
          break;
        }
      }
    }
    
    /** Swagger */
    const addSwaggerToApp = (app: Express, port: string) => {
      const options = {
        definition: {
          openapi: "3.0.0",
          info: {
            title: "Anonymous Question API with Swagger",
            version: "0.0.1",
            description: "Anonymous Question API Server",
            license: {
              name: "MIT",
              url: "https://spdx.org/licenses/MIT.html",
            },
            contact: {
              name: "lico",
              url: "https://www.linkedin.com/in/seongkuk-han-49022419b/",
              email: "[email protected]",
            },
          },
          servers: addressList.map((address) => ({
            url: `http://${address}:${port}`,
          })),
        },
        apis: isProd ? [
          path.join(process.resourcesPath, "main/api/questions.ts"),
          path.join(process.resourcesPath, "main/api/schemas/*.ts"),
        ]: ["./main/api/questions.ts", "./main/api/schemas/*.ts"],
      };
    
      const specs = swaggerJsdoc(options);
    
      app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(specs));
    };
    
    /** Events */
    
    ipcMain.on(TOGGLE_EVENT_REQ, async (event, on) => {
      if (on && serverInfo !== undefined) {
        event.reply(TOGGLE_EVENT_RES, {
          result: false,
          message: `It's already on.`,
        });
        return;
      } else if (!on && serverInfo === undefined) {
        event.reply(TOGGLE_EVENT_RES, {
          result: true,
          message: `The server isn't running.`,
        });
        return;
      }
    
      let port: string | undefined;
    
      try {
        if (on) {
          port = await startServer();
        } else {
          await stopServer();
        }
    
        event.reply(TOGGLE_EVENT_RES, { result: true, message: "Succeed.", port });
      } catch (e) {
        console.error(e);
        event.reply(TOGGLE_EVENT_RES, {
          result: false,
          message: `Something went wrong.`,
        });
      }
    });
    
    /** Server */
    
    const configureServer = (app: Express) => {
      app.use(express.json());
      app.use(cors());
    
      app.use("/api", questionApi);
    };
    
    export const startServer = (): Promise<string> => {
      return new Promise((resolve, reject) => {
        const app = express();
        const port = process.env.SERVER_PORT;
    
        configureServer(app);
    
        const server = app
          .listen(undefined, () => {
            const port = (server.address() as { port: number }).port.toString();
            console.log(`Server has been started on ${port}.`);
            addSwaggerToApp(app, port);
            resolve(port);
          })
          .on("error", (err) => {
            reject(err);
          });
    
        serverInfo = {
          app,
          port,
          server,
        };
      });
    };
    
    export const stopServer = (): Promise<void> => {
      return new Promise((resolve, reject) => {
        try {
          if (!serverInfo) throw new Error("There is no server information.");
    
          serverInfo.server.close(() => {
            console.log("Server has been stopped.");
            serverInfo = undefined;
            resolve();
          });
        } catch (e) {
          console.error(e);
          reject(e);
        }
      });
    };
    


    질문.ts

    import express, { Request } from "express";
    import { Question } from "../../shares/types";
    import { sendQuestionMessage } from "./ipc";
    
    const router = express.Router();
    
    interface QuestionParams extends Request {
      body: Question;
    }
    
    /**
     * @swagger
     * tags:
     *  name: Questions
     *  description: API to manager questions.
     *
     * @swagger
     * /api/questions:
     *  post:
     *    summary: Creates a new question
     *    tags: [Questions]
     *    requestBody:
     *      required: true
     *      content:
     *        application/json:
     *          schema:
     *            $ref:  '#/components/schemas/Question'
     *    responses:
     *      "200":
     *        description: Succeed to request a question
     *        content:
     *          application/json:
     *            schema:
     *              $ref: '#/components/schemas/Question'
     */
    router.post("/questions", (req: QuestionParams, res) => {
      if (!req.body.nickname && !req.body.question) {
        return res.status(400).send("Bad Request");
      }
    
      const question = req.body.question.trim();
      const nickname = req.body.nickname.trim();
    
      if (nickname.length >= 2) {
        return res
          .status(400)
          .send("Length of the nickname must be less than or equal to 2.");
      } else if (question.length >= 100) {
        return res
          .status(400)
          .send("Length of the quesztion must be less than or equal to 100.");
      }
    
      sendQuestionMessage(nickname, question);
    
      return res.json({
        question,
        nickname,
      });
    });
    
    export default router;
    


    스키마/question.ts

    /**
     * @swagger
     *  components:
     *    schemas:
     *      Question:
     *        type: object
     *        required:
     *          - nickname
     *          - question
     *        properties:
     *          nickname:
     *            type: string;
     *            minLength: 1
     *            maxLength: 1
     *          question:
     *            type: string;
     *            minLength: 1
     *            maxLength: 100
     *        example:
     *          nickname: S
     *          question: What is your name?
     */
    
    export {};
    


    ipc.ts

    import { webContents } from "electron";
    import { SEND_QUESTION } from "../../shares/constants";
    
    export const sendQuestionMessage = (nickname: string, question: string) => {
      const contents = webContents.getAllWebContents();
    
      for (const content of contents) {
        content.send(SEND_QUESTION, { nickname, question });
      }
    };
    

    swagger 빌드 후 API 문서를 표시하지 않았습니다. 프로덕션 앱이 소스 파일에 액세스할 수 없기 때문입니다. 이를 위해 build.extraResourcespackage.json 옵션을 추가해야 했기 때문에 서버 소스 파일을 추가 리소스로 추가했습니다.

    패키지.json

      ...
      "build": {
        "extraResources": "main/api"
      },
      ...
    



    유형 및 상수


    electronnext는 유형 및 상수를 공유합니다.

    상수.ts

    export const TOGGLE_EVENT_REQ = "server:toggle-req";
    export const TOGGLE_EVENT_RES = "server:toggle-res";
    
    export const SEND_QUESTION = "SEND_QUESTION";
    


    types.ts

    import { Express } from "express";
    import { Server } from "http";
    
    export interface ServerInfo {
      port: string;
      app: Express;
      server: Server;
    }
    
    export interface Question {
      nickname: string;
      question: string;
    }
    



    결과





    프로그램을 실행하면 첫 화면입니다.
    서버를 켜려면 버튼을 전환해야 합니다.



    63261은 서버의 포트입니다. http://localhost:63261/api-docs에서 swagger를 볼 수 있습니다.


    swagger에 질문을 했고 앱에서 볼 수 있습니다.





    나는 또한 test client을 만들었습니다.








    결론



    그것이 완전하지 않더라도 시도해 볼 가치가 있었다. '내 생각이 현실이 될 수 있다'는 생각을 하게 되었어요.

    어쨌든 누군가에게 도움이 되길 바랍니다.

    행복한 코딩!

    Github



  • anonymous-question
  • anonymous-question-test-client
  • 좋은 웹페이지 즐겨찾기