diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6a2bda --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# Chat +> Chat - это анонимный асинхронный webchat + +## Стек +- Frontend: + - [TypeScript](https://www.typescriptlang.org) + - [React 19](https://react.dev) + - [Next.js](https://nextjs.org) 15 (App Router) + - [Tailwind CSS](https://tailwindcss.com/) + - [Yup](https://github.com/jquense/yup) + - [SWR](https://swr.vercel.app/ru) +- Backend: + - [TypeScript](https://www.typescriptlang.org) + - [Nest](https://nestjs.com) 11 + - [TypeORM](https://typeorm.io) + - [Swagger UI](https://swagger.io) Express +- Database: [PostgreSQL](https://www.postgresql.org) + +## Возможности +- Анонимный асинхронный чат на основе WebSockets +- Цветовое выделение разных пользователей +- Адаптивная верстка. Корректное отображение на мобильных устройствах, планшетах, ноутбуках, десктопах +- SSR ([Server-Side Rendering](https://nextjs.org/docs/pages/building-your-application/rendering/server-side-rendering)) +- Валидация данных с помощью [Yup](https://github.com/jquense/yup) +- Структура проекта в соответствии с [Feature-Sliced Design](https://feature-sliced.design) +- Цветовая схема [Gruvbox](https://github.com/morhetz/gruvbox). Возможность переключения тёмной и светлой темы +- Запуск с помощью Docker контейнеров (используется Docker Compose) + +## Скриншоты +|![](./screenshots/dark.png)|![](./screenshots/light.png)| +|-|-| + + +## Запуск +### Development +Database: + + docker compose -f 'docker-compose.yml' up -d --build + +Backend: + + cd ./backend + npm install + npm run start:dev + +Frontend: + + cd ./frontend + npm install + npm run dev + +### Production + + docker compose -f 'docker-compose-all.yml' up -d --build diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..cdba149 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +# Use the official Node.js image as the base image +FROM node:20 + +# Set the working directory inside the container +WORKDIR /usr/src/app + +# Copy package.json and package-lock.json to the working directory +COPY package*.json ./ + +# Install the application dependencies +RUN npm install + +# Copy the rest of the application files +COPY . . + +# Build the NestJS application +RUN npm run build + +# Expose the application port +# EXPOSE 3000 + +# Command to run the application +CMD ["node", "dist/main"] \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts index 8d7584b..da5eddb 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -7,7 +7,6 @@ import AppModule from '@/modules/app'; async function bootstrap() { const app = await NestFactory.create(AppModule); - app.useGlobalPipes(new ValidationPipe()); app.useWebSocketAdapter(new WsAdapter(app)); @@ -22,4 +21,4 @@ async function bootstrap() { await app.listen(process.env.PORT ?? 8000); } -bootstrap(); +void bootstrap(); diff --git a/backend/src/modules/app/app.module.ts b/backend/src/modules/app/app.module.ts index d174ca9..a030f14 100644 --- a/backend/src/modules/app/app.module.ts +++ b/backend/src/modules/app/app.module.ts @@ -14,7 +14,6 @@ import MessageModule from '@/modules/message'; load: [config, databaseConfig], }), TypeOrmModule.forRoot(databaseConfig()), - MessageModule, ], }) diff --git a/backend/src/shared/gateways/message/message.gateway.ts b/backend/src/shared/gateways/message/message.gateway.ts index d6d1f46..0f8800f 100644 --- a/backend/src/shared/gateways/message/message.gateway.ts +++ b/backend/src/shared/gateways/message/message.gateway.ts @@ -2,16 +2,11 @@ import Message from '@/entities/message'; import { OnGatewayConnection, WebSocketGateway } from '@nestjs/websockets'; import { Socket } from 'socket.io'; -@WebSocketGateway(8002) +@WebSocketGateway(8002, { transports: ['websocket'] }) export class MessageGateway implements OnGatewayConnection { private clients: Socket[] = []; - handleConnection(client: Socket) { - this.clients.push(client); - } - - sendMessage = (message: Message) => { - console.log(message, this.clients.length); + handleConnection = (client: Socket) => this.clients.push(client); + sendMessage = (message: Message) => this.clients.forEach((client) => client.send(JSON.stringify(message))); - }; } diff --git a/backend/src/shared/services/message/message.service.ts b/backend/src/shared/services/message/message.service.ts index e341863..47f15af 100644 --- a/backend/src/shared/services/message/message.service.ts +++ b/backend/src/shared/services/message/message.service.ts @@ -10,7 +10,7 @@ export class MessageService { private messageRepository: Repository, ) {} - getTopOfHistory = () => + getTopOfHistory = (): Promise => this.messageRepository .createQueryBuilder('message') .orderBy('message.id', 'DESC') diff --git a/docker-compose-all.yml b/docker-compose-all.yml index 633e57f..c767157 100644 --- a/docker-compose-all.yml +++ b/docker-compose-all.yml @@ -1,6 +1,6 @@ services: postgres: - container_name: postgres_container + container_name: postgres image: postgres environment: POSTGRES_USER: postgres @@ -12,10 +12,11 @@ services: # ports: # - "5432:5432" networks: - - postgres + - network restart: unless-stopped backend: + container_name: backend build: ./backend environment: DATABASE_HOST: postgres @@ -23,27 +24,25 @@ services: DATABASE_USER: postgres DATABASE_PASSWORD: pass2postgres DATABASE_DATABASE: chat - WEBSOCKETS_PORT: 8001 - # ports: - # - "5000:5000" + ports: + - "8002:8002" + networks: + - network depends_on: - postgres frontend: + container_name: frontend build: ./frontend - environment: - NEXT_PUBLIC_BASE_PROTOCOL: http - NEXT_PUBLIC_BASE_DOMAIN: backend - NEXT_PUBLIC_BASE_PORT: 3000 - NEXT_PUBLIC_WS_URL: ws://backend:8002/ - NEXT_PUBLIC_API_PATTERN: / ports: - "3000:3000" + networks: + - network depends_on: - backend networks: - postgres: + network: driver: bridge volumes: diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..95a89bd --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,11 @@ +BACKEND_API_PROTOCOL=http +BACKEND_API_DOMAIN=backend +BACKEND_API_PORT=8000 + +NEXT_PUBLIC_BASE_PROTOCOL=http +NEXT_PUBLIC_BASE_DOMAIN=localhost +NEXT_PUBLIC_BASE_PORT=3000 + +NEXT_PUBLIC_WS_URL=ws://localhost:8002/ + +NEXT_PUBLIC_API_PATTERN=/api/ \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..557bd5c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,42 @@ +# Stage 0: Get base system +FROM node:20-alpine AS base + + +# Stage 1: Instal dependencies +FROM base AS dependencies +WORKDIR /application +COPY package*.json ./ +RUN npm ci + + +# Stage 2: Build +FROM base AS builder +WORKDIR /application +COPY --from=dependencies /application/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED 1 +RUN npm run build + + +# Stage 3: Start +FROM base AS runner +WORKDIR /application +ENV NODE_ENV production +ENV NEXT_TELEMETRY_DISABLED 1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /application/public ./public + +RUN mkdir .next +RUN chown nextjs:nodejs .next +COPY --from=builder --chown=nextjs:nodejs /application/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /application/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 +ENV PORT 3000 + +CMD HOSTNAME="0.0.0.0" node server.js diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e71ba0b..4186ff0 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -9,16 +9,17 @@ const nextConfig = { }, ]; }, - images: { - remotePatterns: [ - { - protocol: process.env.NEXT_PUBLIC_BASE_PROTOCOL, - hostname: process.env.NEXT_PUBLIC_BASE_DOMAIN, - port: process.env.NEXT_PUBLIC_BASE_PORT, - }, - ], - }, + // images: { + // remotePatterns: [ + // { + // protocol: process.env.NEXT_PUBLIC_BASE_PROTOCOL, + // hostname: process.env.NEXT_PUBLIC_BASE_DOMAIN, + // port: process.env.NEXT_PUBLIC_BASE_PORT, + // }, + // ], + // }, devIndicators: false, + output: "standalone", }; export default nextConfig; diff --git a/frontend/src/features/messagesList/message.tsx b/frontend/src/features/messagesList/message.tsx index 42a6125..28d3cdb 100644 --- a/frontend/src/features/messagesList/message.tsx +++ b/frontend/src/features/messagesList/message.tsx @@ -20,7 +20,7 @@ export const Message = ({ ? "borderBottomRightRadius" : "borderBottomLeftRadius"]: 0, }} - className={`max-w-20 p-2`} + className={`p-2`} > {message.text} diff --git a/frontend/src/features/messagesList/messagesList.tsx b/frontend/src/features/messagesList/messagesList.tsx index 47768dc..8307e7a 100644 --- a/frontend/src/features/messagesList/messagesList.tsx +++ b/frontend/src/features/messagesList/messagesList.tsx @@ -8,11 +8,13 @@ import { Message } from "./message"; export const MessagesList = () => { const { data: users, mutate: mutateUsers } = useSWR("users"); - const { data: messages, mutate: mutateMessages } = useSWR( - "messages", - MessageService.getTopOfHistory, - { revalidateOnFocus: false } - ); + const { + data: messages, + mutate: mutateMessages, + isLoading: messagesLoading, + } = useSWR("messages", MessageService.getTopOfHistory, { + revalidateOnFocus: false, + }); const { data: me } = useSWR("me"); const [websocket, updateWebSocket] = useState(); const bottomRef = useRef(null); @@ -20,8 +22,18 @@ export const MessagesList = () => { const getUserColor = (userUUID: IUser["uuid"]) => users?.find((user) => user.uuid === userUUID)?.color; + const updateMessageHandler = (websocket?: WebSocket) => { + if (websocket) + websocket.onmessage = (event) => { + mutateMessages([...(messages ?? []), JSON.parse(event.data)], { + revalidate: false, + }); + }; + }; + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + updateMessageHandler(websocket); const newUsers: IUser[] = []; messages?.forEach((message) => { if ( @@ -35,20 +47,18 @@ export const MessagesList = () => { }, [messages]); useEffect(() => { - if (!websocket && messages && process.env.NEXT_PUBLIC_WS_URL) { + if (!websocket && !messagesLoading && process.env.NEXT_PUBLIC_WS_URL) { const websocket = new WebSocket(process.env.NEXT_PUBLIC_WS_URL); - websocket.onmessage = (event) => { - mutateMessages([...(messages ?? []), JSON.parse(event.data)], { - revalidate: false, - }); - }; - websocket.onclose = function () { - setTimeout(() => updateWebSocket(undefined), 1000); + updateMessageHandler(websocket); + websocket.onclose = () => { + setTimeout(() => { + updateWebSocket(undefined); + }, 1000); }; updateWebSocket(websocket); } return () => websocket?.close(); - }, [websocket, messages]); + }, [websocket, messagesLoading]); return (
diff --git a/screenshots/dark.png b/screenshots/dark.png new file mode 100644 index 0000000..802397c Binary files /dev/null and b/screenshots/dark.png differ diff --git a/screenshots/light.png b/screenshots/light.png new file mode 100644 index 0000000..ed8ef90 Binary files /dev/null and b/screenshots/light.png differ