Nextron과 함께하는 익명의 실시간 질문(채팅) 앱
Anonymous Realtime Question(Chat) App with Nextron
우리는 모여서 회사에서 한 달에 한 번 CEO가 우리에게 연설을합니다. '시민회관'.
연설이 끝나면 CEO는 우리가 질문할 때까지 기다립니다. 그는 그것이 직원들과 소통할 수 있는 좋은 방법이며 회사에 도움이 될 것이라고 말했다. 하지만 방안에 가득찬 동료들 앞에서 질문을 하기란 쉽지 않다. 나는 내 질문이 그에게 말해도 괜찮은지 생각해야 했다. 나는 회사에서 얼간이가 되고 싶지 않다.
회의가 끝나면 아이디어가 떠오른다. "모바일에서 질문을 할 수 있는데 그가 누가 질문하는지 알아낼 수 없다면 어떨까요?"그는 우리에게서 정직한 질문을 받을 것이고 우리는 익명으로 자유롭게 질문합니다.
실제로 유용한지는 모르겠지만 이 아이디어를 구현하기로 결정했습니다.
내 프로젝트에서
nextron
를 사용할 것입니다. nextron
는 electron
를 next
와 함께 쉽게 사용할 수 있도록 도와줍니다. create-react-app
와 같습니다.프로젝트 파일 구조(src)
웹 클라이언트 및 IPC
nextron
는 내가 즉시 typescript
및 antd
로 프로젝트를 설정할 수 있도록 변수 예제를 제공합니다.하나의 페이지와 두 개의 구성 요소가 있습니다.
페이지
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;
비즈니스 논리는 페이지 구성 요소에 있습니다.
Question
는 nickname
의 첫 글자에 따라 색상을 생성합니다.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.extraResources
에 package.json
옵션을 추가해야 했기 때문에 서버 소스 파일을 추가 리소스로 추가했습니다.패키지.json
...
"build": {
"extraResources": "main/api"
},
...
유형 및 상수
electron
및 next
는 유형 및 상수를 공유합니다.상수.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
Reference
이 문제에 관하여(Nextron과 함께하는 익명의 실시간 질문(채팅) 앱), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://dev.to/lico/anonymous-realtime-questionchat-app-with-nextron-3fc1텍스트를 자유롭게 공유하거나 복사할 수 있습니다.하지만 이 문서의 URL은 참조 URL로 남겨 두십시오.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)