├── Dockerfile ├── README.md ├── api ├── .dockerignore ├── .gitignore ├── Dockerfile.dev ├── requirements.txt └── src │ └── main.py ├── client └── web │ ├── .dockerignore │ ├── .gitignore │ ├── .prettierrc.json │ ├── Dockerfile.dev │ ├── babel.config.js │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── components │ │ ├── App.tsx │ │ ├── Button.tsx │ │ ├── Chat.tsx │ │ ├── Header.tsx │ │ ├── Main.tsx │ │ ├── Provider.tsx │ │ └── editor │ │ │ ├── Base.tsx │ │ │ ├── CodeEditor.tsx │ │ │ └── TextEditor.tsx │ ├── index.tsx │ ├── pages │ │ ├── chat.tsx │ │ └── index.tsx │ ├── scripts │ │ ├── base62.ts │ │ ├── code-run.ts │ │ ├── promise.ts │ │ ├── room-api.ts │ │ ├── room-mates.ts │ │ ├── utils.ts │ │ ├── wandbox.ts │ │ └── websocket │ │ │ ├── connection.ts │ │ │ ├── hooks.ts │ │ │ ├── transformer.ts │ │ │ ├── types.ts │ │ │ └── validator.ts │ ├── styles │ │ └── global.ts │ └── template.html │ ├── tsconfig.json │ └── webpack.config.js ├── docker-compose.dev.yml └── docker-compose.prod.yml /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-bookworm AS builder 2 | 3 | WORKDIR /workspace 4 | 5 | COPY ./client/web/package.json . 6 | 7 | RUN npm i 8 | 9 | COPY ./client/web . 10 | 11 | RUN npm run build 12 | 13 | 14 | FROM node:lts-bookworm AS modules 15 | 16 | WORKDIR /workspace 17 | 18 | COPY ./client/web/package.json ./ 19 | COPY ./client/web/package-lock.json ./ 20 | 21 | RUN npm i --production 22 | 23 | FROM python:3.12 AS runner 24 | 25 | WORKDIR /app 26 | 27 | COPY --from=builder /workspace/build ./build 28 | COPY --from=modules /workspace/node_modules ./node_modules 29 | 30 | COPY ./api/requirements.txt . 31 | RUN pip3 install -r requirements.txt 32 | 33 | COPY api/src ./src 34 | 35 | ENV ENV=production 36 | 37 | RUN apt update && apt upgrade -y 38 | 39 | CMD uvicorn src.main:app --host 0.0.0.0 --port $PORT 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 起動する 2 | ``` 3 | docker-compose -f docker-compose.[dev/prod].yml up 4 | ``` 5 | 6 | # デプロイする 7 | ``` 8 | heroku container:push web 9 | heroku container:release web 10 | ``` -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | -------------------------------------------------------------------------------- /api/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | 3 | WORKDIR /workspace 4 | COPY requirements.txt . 5 | RUN pip install -r requirements.txt 6 | 7 | CMD ["uvicorn", "src.main:app", "--reload", "--host", "0.0.0.0", "--port", "3000"] 8 | -------------------------------------------------------------------------------- /api/requirements.txt: -------------------------------------------------------------------------------- 1 | uvicorn[standard] 2 | fastapi 3 | jinja2 -------------------------------------------------------------------------------- /api/src/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Depends, WebSocket, WebSocketDisconnect, HTTPException, Request 2 | from starlette.middleware.cors import CORSMiddleware 3 | from fastapi.staticfiles import StaticFiles 4 | from fastapi.templating import Jinja2Templates 5 | from typing import List 6 | import json 7 | import binascii 8 | import uuid 9 | from pydantic import BaseModel 10 | from pathlib import Path 11 | from typing import Union 12 | import os 13 | 14 | app = FastAPI() 15 | 16 | # CORSを回避するための設定 17 | app.add_middleware( 18 | CORSMiddleware, 19 | allow_origins=["*"], 20 | allow_credentials=True, 21 | allow_methods=["*"], 22 | allow_headers=["*"] 23 | ) 24 | 25 | class ConnectionManager: 26 | def __init__(self): 27 | self.active_connections: List[WebSocket] = [] 28 | self.full_text = "" 29 | 30 | def __len__(self): 31 | return len(self.active_connections) 32 | 33 | def set_full_text(self, text: str): 34 | self.full_text = text 35 | 36 | async def connect(self, websocket: WebSocket, name): 37 | await websocket.accept() 38 | self.active_connections.append(websocket) 39 | await self.broadcast(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": self.get_key(websocket), "type": "connect", "data": self.full_text})) 40 | 41 | def disconnect(self, websocket: WebSocket): 42 | self.active_connections.remove(websocket) 43 | 44 | async def send_personal_message(self, message: str, websocket: WebSocket): 45 | await websocket.send_text(message) 46 | 47 | async def broadcast(self, message: str): 48 | for connection in self.active_connections: 49 | await connection.send_text(message) 50 | 51 | async def broadcast_except_me(self, message: str, websocket: WebSocket): 52 | for connection in self.active_connections: 53 | if connection != websocket: 54 | await connection.send_text(message) 55 | 56 | def get_key(self, websocket: WebSocket): 57 | return websocket.headers.get('sec-websocket-key') 58 | 59 | managers = {} 60 | is_public = {} 61 | name = {} 62 | 63 | class CreateRoom(BaseModel): 64 | name: str 65 | is_public: bool 66 | 67 | class RoomInfo(BaseModel): 68 | name: str 69 | is_open: bool 70 | connections: int 71 | room_id: str 72 | 73 | @app.post("/api/create_room", response_model=str) 74 | async def create_room(room: CreateRoom): 75 | clean_keys = [] 76 | for room_id in managers: 77 | if len(managers[room_id]) == 0: 78 | clean_keys.append(room_id) 79 | for key in clean_keys: 80 | managers.pop(key) 81 | is_public.pop(key) 82 | name.pop(key) 83 | room_id = str(uuid.uuid4()) 84 | managers[room_id] = ConnectionManager() 85 | is_public[room_id] = room.is_public 86 | name[room_id] = room.name 87 | return room_id 88 | 89 | @app.get("/api/room_info/{room_id}", response_model=RoomInfo) 90 | async def room_info(room_id: str): 91 | try: 92 | return RoomInfo(name=name[room_id], is_open=is_public[room_id], room_id=room_id, connections=len(managers[room_id])) 93 | except KeyError: 94 | raise HTTPException(404, "Room not found") 95 | 96 | @app.get("/api/room_list", response_model=List[RoomInfo]) 97 | async def room_list(): 98 | return list(filter(lambda x: x.is_open is True, [RoomInfo(name=name[room_id], is_open=is_public[room_id], room_id=room_id, connections=len(managers[room_id])) for room_id in managers])) 99 | 100 | @app.websocket("/api/ws/{room_id}") 101 | async def websocket_endpoint(websocket: WebSocket, room_id: str, name: str = "Anonymous"): 102 | print(room_id) 103 | manager = None 104 | try: 105 | manager = managers[room_id] 106 | except KeyError: 107 | raise HTTPException(404, "Room not found") 108 | 109 | await manager.connect(websocket, name) 110 | try: 111 | while True: 112 | data = await websocket.receive_text() 113 | data_json = json.loads(data) 114 | 115 | if data_json["type"] == "chat": 116 | await manager.broadcast(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "chat", "data": data_json["data"]})) 117 | 118 | elif data_json["type"] == "cursormove": 119 | await manager.broadcast_except_me(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "cursormove", "data": data_json["data"]}), websocket) 120 | 121 | elif data_json["type"] == "selection": 122 | await manager.broadcast_except_me(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "selection", "data": data_json["data"]}), websocket) 123 | 124 | elif data_json["type"] == "edit": 125 | manager.set_full_text(data_json["data"]["full_text"]) 126 | await manager.broadcast_except_me(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "edit", "data": data_json["data"]}), websocket) 127 | 128 | except WebSocketDisconnect: 129 | manager.disconnect(websocket) 130 | await manager.broadcast(json.dumps({"name": binascii.unhexlify(name).decode('utf-8'), "key": manager.get_key(websocket), "type": "disconnect", "data": ""})) 131 | 132 | def serve_react_app(app: FastAPI, build_dir: Union[Path, str]) -> FastAPI: 133 | 134 | if isinstance(build_dir, str): 135 | build_dir = Path(build_dir) 136 | 137 | app.mount( 138 | "/static/", 139 | StaticFiles(directory=build_dir / "static/"), 140 | name="React App static files", 141 | ) 142 | templates = Jinja2Templates(directory=build_dir.as_posix()) 143 | 144 | @app.get("/{full_path:path}") 145 | async def serve_react_app(request: Request, full_path: str): 146 | return templates.TemplateResponse("index.html", {"request": request}) 147 | 148 | return app 149 | 150 | if os.environ.get("ENV") == "production": 151 | serve_react_app(app, "./build") -------------------------------------------------------------------------------- /client/web/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | Dockerfile* 3 | 4 | -------------------------------------------------------------------------------- /client/web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build 3 | -------------------------------------------------------------------------------- /client/web/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "jsxSingleQuote": true, 5 | "arrowParens": "avoid" 6 | } 7 | -------------------------------------------------------------------------------- /client/web/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:lts-bookworm AS modules 2 | 3 | WORKDIR /workspace 4 | 5 | COPY ./package.json . 6 | 7 | RUN npm i 8 | 9 | FROM node:lts-bookworm 10 | 11 | WORKDIR /workspace 12 | 13 | COPY "*.config.js" "tsconfig.json" "package.json" ./ 14 | COPY --from=modules /workspace/node_modules ./node_modules 15 | 16 | ENTRYPOINT ["npm", "run"] 17 | CMD ["dev"] 18 | 19 | -------------------------------------------------------------------------------- /client/web/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: 'last 2 chrome versions and last 2 firefox versions', 7 | modules: false, 8 | useBuiltIns: 'usage', 9 | corejs: 3, 10 | }, 11 | ], 12 | '@babel/preset-typescript', 13 | ], 14 | plugins: [['babel-plugin-styled-components', { pure: true }]], 15 | }; 16 | -------------------------------------------------------------------------------- /client/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "webpack-dev-server --mode development", 6 | "build": "webpack --mode production", 7 | "lint": "prettier --write ." 8 | }, 9 | "devDependencies": { 10 | "@babel/core": "^7.17.5", 11 | "@babel/preset-env": "^7.16.11", 12 | "@babel/preset-typescript": "^7.16.7", 13 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.4", 14 | "@tanstack/react-location": "^3.6.1", 15 | "@types/react": "^17.0.39", 16 | "@types/react-dom": "^17.0.11", 17 | "@types/styled-components": "^5.1.23", 18 | "babel-loader": "^8.2.3", 19 | "babel-plugin-styled-components": "^2.0.6", 20 | "copy-webpack-plugin": "^10.2.4", 21 | "core-js": "^3.21.1", 22 | "css-loader": "^6.6.0", 23 | "flexlayout-react": "^0.6.8", 24 | "html-webpack-plugin": "^5.5.0", 25 | "monaco-editor": "^0.32.1", 26 | "monaco-editor-webpack-plugin": "^7.0.1", 27 | "prettier": "^2.5.1", 28 | "react": "^17.0.2", 29 | "react-dom": "^17.0.2", 30 | "react-refresh-typescript": "2.0.3", 31 | "style-loader": "^3.3.1", 32 | "styled-components": "^5.3.3", 33 | "ts-loader": "^9.2.6", 34 | "typescript": "4.5.5", 35 | "webpack": "^5.69.1", 36 | "webpack-cli": "^4.9.2", 37 | "webpack-dev-server": "^4.7.4" 38 | }, 39 | "dependencies": { 40 | "cors": "^2.8.5", 41 | "express": "^4.17.3", 42 | "http-proxy-middleware": "^2.0.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client/web/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, MakeGenerics, Router, ReactLocation } from '@tanstack/react-location'; 2 | import { Welcome } from '~/pages/index'; 3 | import { ChatPage } from '~/pages/chat'; 4 | import { getRoomInfo, RoomInfo } from '~/scripts/room-api'; 5 | import { GlobalStyle } from '~/styles/global'; 6 | import { Providers } from './Provider'; 7 | 8 | export type LocationGenerics = MakeGenerics<{ 9 | LoaderData: { 10 | roomInfo: RoomInfo; 11 | }; 12 | }>; 13 | 14 | const location = new ReactLocation(); 15 | 16 | const routes: Route[] = [ 17 | { 18 | path: '/', 19 | element: , 20 | }, 21 | { 22 | path: 'chat', 23 | children: [ 24 | { 25 | path: ':roomId', 26 | element: , 27 | loader: async ({ params: { roomId } }) => ({ roomInfo: await getRoomInfo(roomId!) }), 28 | }, 29 | ], 30 | }, 31 | ]; 32 | 33 | const App = () => { 34 | return ( 35 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /client/web/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | export const blueButtonCss = css` 4 | border: none; 5 | border-radius: 0.25em; 6 | padding-inline: 0.5em; 7 | font-size: 1em; 8 | line-height: 2; 9 | background-color: #0d6efd; 10 | color: white; 11 | `; 12 | 13 | export const BlueButton = styled.button` 14 | ${blueButtonCss} 15 | `; 16 | 17 | export const planeButtonCss = css` 18 | border: none; 19 | padding-inline: 0.5em; 20 | font-size: 1em; 21 | line-height: 2; 22 | color: inherit; 23 | background-color: inherit; 24 | `; 25 | 26 | export const PlaneButton = styled.button` 27 | ${planeButtonCss} 28 | `; 29 | -------------------------------------------------------------------------------- /client/web/src/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useCallback, useEffect, useRef, useState, VFC } from 'react'; 2 | import styled from 'styled-components'; 3 | import { suffix } from '~/scripts/room-mates'; 4 | import { WebsocketConnectionManager } from '~/scripts/websocket/connection'; 5 | import { useChat } from '~/scripts/websocket/hooks'; 6 | import { BlueButton } from './Button'; 7 | 8 | const Chat = styled>(({ connection, ...rest }) => { 9 | const ulRef = useRef(null); 10 | const messages = useChat(connection); 11 | 12 | useEffect(() => { 13 | ulRef.current?.scroll({ top: 0, behavior: 'smooth' }); 14 | }, [messages]); 15 | 16 | const send = useCallback( 17 | (content: string) => { 18 | connection.sendMessage({ type: 'chat', data: content }); 19 | }, 20 | [connection] 21 | ); 22 | 23 | return ( 24 |
25 | 26 | {messages.length === 0 ? ( 27 |

no messages

28 | ) : ( 29 |
    30 | {messages.map(m => ( 31 |
  • 32 |

    {m.content}

    33 | {m.type !== 'chat' ?

    info

    :

    {m.name}

    } 34 |
  • 35 | ))} 36 |
37 | )} 38 |
39 | ); 40 | })` 41 | display: flex; 42 | flex-direction: column; 43 | height: 100%; 44 | > p { 45 | font-size: 0.9em; 46 | text-align: center; 47 | color: #777; 48 | } 49 | > ul { 50 | list-style: none; 51 | padding: 0; 52 | > li { 53 | border-block-start: 1px solid #ccc; 54 | :last-child { 55 | border-block-end: 1px solid #ccc; 56 | } 57 | padding-inline: 0.5em; 58 | > p { 59 | font-size: 1.25em; 60 | } 61 | } 62 | } 63 | > *:last-child { 64 | flex-grow: 1; 65 | overflow-y: auto; 66 | } 67 | `; 68 | 69 | const ChatInput = styled void }>>(({ send, ...rest }) => { 70 | const [message, setMessage] = useState(''); 71 | const onclick = (ev: MouseEvent) => { 72 | ev.preventDefault(); 73 | send(message); 74 | setMessage(''); 75 | }; 76 | return ( 77 |
78 | setMessage(ev.target.value)} /> 79 | 80 | 81 | 送信 82 | 83 | 84 | ); 85 | })` 86 | display: flex; 87 | padding: 0.5em; 88 | 89 | > input { 90 | flex-grow: 1; 91 | font-size: 1em; 92 | border: 1px solid #ccc; 93 | padding-inline: 0.5em; 94 | } 95 | > span { 96 | padding-inline-start: 0.5em; 97 | } 98 | > button { 99 | flex-shrink: 0; 100 | } 101 | `; 102 | 103 | export default Chat; 104 | -------------------------------------------------------------------------------- /client/web/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Link } from '@tanstack/react-location'; 4 | 5 | export const Header = styled(({ children, ...rest }) => { 6 | return ( 7 |
8 |

9 | CodingChat 10 |

11 |
{children}
12 |
13 | ); 14 | })` 15 | display: flex; 16 | padding: 0.5em; 17 | color: white; 18 | background-color: #212529; 19 | > h1 { 20 | font-size: 1.25em; 21 | line-height: 2; 22 | flex-grow: 1; 23 | > a { 24 | color: inherit; 25 | text-decoration: none; 26 | } 27 | } 28 | > div { 29 | display: flex; 30 | align-items: center; 31 | flex-shrink: 0; 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /client/web/src/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import { Layout, Model, TabNode } from 'flexlayout-react'; 2 | import 'flexlayout-react/style/light.css'; 3 | import { FC, useCallback, useLayoutEffect, useState, VFC } from 'react'; 4 | import { useTextEditor, useReadonlyTextEditor } from '~/components/editor/TextEditor'; 5 | import { Loadable } from '~/scripts/promise'; 6 | import { useCodeEditor } from '~/components/editor/CodeEditor'; 7 | import { Header } from '~/components/Header'; 8 | import { run } from '~/scripts/code-run'; 9 | import { Link } from '@tanstack/react-location'; 10 | import ChatView from './Chat'; 11 | import styled from 'styled-components'; 12 | import { PlaneButton, planeButtonCss } from './Button'; 13 | import { WebsocketConnectionManager } from '~/scripts/websocket/connection'; 14 | 15 | const flexLayoutModel = Model.fromJson({ 16 | global: {}, 17 | borders: [], 18 | layout: { 19 | type: 'row', 20 | children: [ 21 | { 22 | type: 'row', 23 | weight: 80, 24 | children: [ 25 | { 26 | type: 'row', 27 | weight: 80, 28 | children: [ 29 | { 30 | type: 'tabset', 31 | children: [ 32 | { 33 | type: 'tab', 34 | enableClose: false, 35 | name: 'CodeEditor', 36 | component: 'code-editor', 37 | }, 38 | ], 39 | }, 40 | ], 41 | }, 42 | { 43 | type: 'row', 44 | weight: 20, 45 | children: [ 46 | { 47 | type: 'tabset', 48 | children: [ 49 | { 50 | type: 'tab', 51 | name: 'Log', 52 | component: 'log-editor', 53 | enableClose: false, 54 | }, 55 | ], 56 | }, 57 | { 58 | type: 'tabset', 59 | children: [ 60 | { 61 | type: 'tab', 62 | name: 'Input', 63 | component: 'input-editor', 64 | enableClose: false, 65 | }, 66 | ], 67 | }, 68 | { 69 | type: 'tabset', 70 | children: [ 71 | { 72 | type: 'tab', 73 | name: 'Output', 74 | component: 'output-editor', 75 | enableClose: false, 76 | }, 77 | ], 78 | }, 79 | ], 80 | }, 81 | ], 82 | }, 83 | { 84 | type: 'row', 85 | weight: 20, 86 | children: [ 87 | { 88 | type: 'tabset', 89 | children: [ 90 | { 91 | type: 'tab', 92 | name: 'Chat', 93 | component: 'chat', 94 | enableClose: false, 95 | }, 96 | ], 97 | }, 98 | ], 99 | }, 100 | ], 101 | }, 102 | }); 103 | type FlexLayoutComponentName = 'chat' | 'log-editor' | 'input-editor' | 'output-editor' | 'code-editor'; 104 | 105 | export const Main = styled }>>(({ wsConnection, ...rest }) => { 106 | const conn = wsConnection.get(); 107 | useLayoutEffect(() => { 108 | return () => conn.close(); 109 | }, []) 110 | 111 | const [language, languageSelect] = useLanguageSelect(); 112 | const [CodeEditor, getCode] = useCodeEditor(conn, language); 113 | const [LogView, setLog] = useReadonlyTextEditor(); 114 | const [StdinEditor, , getStdin] = useTextEditor(); 115 | const [StdoutView, setStdout] = useReadonlyTextEditor(); 116 | 117 | const execute = useCallback(async () => { 118 | const code = getCode(); 119 | const stdin = getStdin(); 120 | if (code == null) throw Error('no code editor instance'); 121 | if (stdin == null) throw Error('no input editor instance'); 122 | const [stdout, log] = await run(code, stdin, language === 'cpp' ? 'C++' : 'Python'); 123 | setLog(log); 124 | setStdout(stdout ?? ''); 125 | }, [language]); 126 | 127 | const factory = useCallback( 128 | (node: TabNode) => { 129 | const component = node.getComponent() as FlexLayoutComponentName; 130 | switch (component) { 131 | case 'code-editor': 132 | return ; 133 | case 'log-editor': 134 | return ; 135 | case 'input-editor': 136 | return ; 137 | case 'output-editor': 138 | return ; 139 | case 'chat': 140 | return ; 141 | default: 142 | return unresolved component; 143 | } 144 | }, 145 | [language] 146 | ); 147 | 148 | return ( 149 |
150 |
151 | {languageSelect} 152 | 153 | 154 |
155 | 156 |
157 | ); 158 | })` 159 | display: flex; 160 | flex-direction: column; 161 | width: 100vw; 162 | height: 100vh; 163 | overflow: hide; 164 | 165 | > *:last-child { 166 | position: relative; 167 | flex-grow: 1; 168 | } 169 | `; 170 | 171 | const useLanguageSelect = () => { 172 | const [language, setLanguage] = useState<'cpp' | 'python'>('cpp'); 173 | 174 | const selectElement = ( 175 | setLanguage(ev.target.value as any)}> 176 | 177 | 178 | 179 | ); 180 | return [language, selectElement] as const; 181 | }; 182 | 183 | const LanguageSelect = styled.select` 184 | border-radius: 0.25em; 185 | padding: 0.5em; 186 | margin-inline-end: 0.5em; 187 | font-size: 1em; 188 | `; 189 | 190 | const ExecuteButton: FC<{ execute: () => Promise }> = ({ execute }) => { 191 | const [executing, setExecuting] = useState(false); 192 | const onClick = () => { 193 | setExecuting(true); 194 | execute().finally(() => { 195 | setExecuting(false); 196 | }); 197 | }; 198 | 199 | return ( 200 | 201 | {executing ? '実行中' : '▶実行'} 202 | 203 | ); 204 | }; 205 | 206 | const ExitButton = styled(props => ( 207 | 208 | 退室 209 | 210 | ))` 211 | text-decoration: none; 212 | ${planeButtonCss} 213 | `; 214 | -------------------------------------------------------------------------------- /client/web/src/components/Provider.tsx: -------------------------------------------------------------------------------- 1 | import { FC, StrictMode } from 'react'; 2 | 3 | export const Providers: FC = ({ children }) => {children}; 4 | -------------------------------------------------------------------------------- /client/web/src/components/editor/Base.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | import { editor } from 'monaco-editor'; 3 | import styled from 'styled-components'; 4 | 5 | export const useMonacoEditor = ( 6 | options: editor.IStandaloneEditorConstructionOptions, 7 | effects: () => void = () => {} 8 | ) => { 9 | const containerRef = useRef(null); 10 | const instanceRef = useRef(); 11 | 12 | const Editor = useCallback(() => { 13 | useEffect(() => { 14 | if (containerRef.current == null) return; 15 | 16 | const container = containerRef.current; 17 | const instance = editor.create(container, options); 18 | instanceRef.current = instance; 19 | // on_mount(editor); 20 | 21 | const observer = new ResizeObserver(entries => { 22 | instance.layout(); // checking if this works 23 | // for (const entry of entries) { 24 | // editor.layout(entry.contentRect); 25 | // } 26 | }); 27 | 28 | observer.observe(container); 29 | return () => { 30 | instance.dispose(); 31 | observer.unobserve(container); 32 | }; 33 | }, []); 34 | effects(); 35 | return ; 36 | }, []); 37 | 38 | return [Editor, instanceRef] as const; 39 | }; 40 | 41 | const EditorContainer = styled.div` 42 | height: 100%; 43 | `; 44 | -------------------------------------------------------------------------------- /client/web/src/components/editor/CodeEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useMonacoEditor } from './Base'; 2 | import { Range, editor, IPosition, IRange } from 'monaco-editor'; 3 | import { useCallback, useEffect, useRef } from 'react'; 4 | import { cursorSuffix, selectionSuffix, suffix } from '~/scripts/room-mates'; 5 | import { WebsocketConnectionManager } from '~/scripts/websocket/connection'; 6 | import { readStream } from '~/scripts/utils'; 7 | import { pickAndTransformEditorCommand } from '~/scripts/websocket/transformer'; 8 | import { ConnectionKey } from '~/scripts/websocket/types'; 9 | 10 | type Cursor = { 11 | uid: ConnectionKey; 12 | name: string; 13 | position: IPosition; 14 | }; 15 | type Selection = { 16 | uid: ConnectionKey; 17 | name: string; 18 | range: IRange; 19 | }; 20 | 21 | const defaultValue = ['function x() {', '\tconsole.log("Hello world!");', '}'].join('\n'); 22 | 23 | export const useCodeEditor = (conn: WebsocketConnectionManager, language: string) => { 24 | const compositioning = useRef(false); 25 | 26 | const effects = useCallback(() => { 27 | useEffect(() => { 28 | const instance = instanceRef.current; 29 | if (instance == null) { 30 | console.warn('the instance of the code editor has not yet been created'); 31 | return; 32 | } 33 | 34 | const cleaners = [ 35 | instance.onDidChangeModelContent(({ changes }) => { 36 | if (!compositioning.current) 37 | conn.sendMessage({ 38 | type: 'edit', 39 | data: { 40 | changes, 41 | timestamp: Date.now(), 42 | full_text: instance.getValue(), 43 | }, 44 | }); 45 | }).dispose, 46 | instance.onDidChangeCursorPosition(({ position }) => { 47 | conn.sendMessage({ type: 'cursormove', data: position }); 48 | }).dispose, 49 | instance.onDidChangeCursorSelection(({ selection }) => { 50 | conn.sendMessage({ type: 'selection', data: selection }); 51 | }).dispose, 52 | ]; 53 | 54 | return () => cleaners.forEach(f => f()); 55 | }, []); 56 | 57 | useEffect(() => { 58 | const instance = instanceRef.current; 59 | if (instance == null) { 60 | console.warn('the instance of the code editor has not yet been created'); 61 | return; 62 | } 63 | 64 | let cursors: Cursor[] = []; 65 | let selections: Selection[] = []; 66 | let first = true; 67 | 68 | readStream(conn.getMessageStream().getReader(), message => { 69 | const command = pickAndTransformEditorCommand(message); 70 | if (command == null) return; 71 | 72 | switch (command.type) { 73 | case 'cursormove': { 74 | const { uid, name, position } = command; 75 | cursors = cursors.filter(v => v.uid !== uid).concat({ uid, name, position }); 76 | setCursorDecorations(instance, cursors); 77 | break; 78 | } 79 | case 'selection': { 80 | const { uid, name, range } = command; 81 | selections = selections.filter(v => v.uid !== uid).concat({ uid, name, range }); 82 | setSelectionDecrations(instance, selections); 83 | break; 84 | } 85 | case 'edit': { 86 | const { changes } = command; 87 | compositioning.current = true; 88 | instance.executeEdits('coding-chat', changes); 89 | compositioning.current = false; 90 | break; 91 | } 92 | case 'onconnect': { 93 | if (first) { 94 | const { fullText } = command; 95 | compositioning.current = true; 96 | instance.setValue(fullText); 97 | compositioning.current = false; 98 | first = false; 99 | } else { 100 | const position = instance.getPosition(); 101 | const selection = instance.getSelection(); 102 | if (position != null) conn.sendMessage({ type: 'cursormove', data: position }); 103 | if (selection != null) conn.sendMessage({ type: 'selection', data: selection }); 104 | } 105 | break; 106 | } 107 | case 'clean': { 108 | const { uid } = command; 109 | cursors = cursors.filter(v => v.uid !== uid); 110 | selections = selections.filter(v => v.uid !== uid); 111 | setCursorDecorations(instance, cursors); 112 | setSelectionDecrations(instance, selections); 113 | break; 114 | } 115 | } 116 | }); 117 | }, []); 118 | }, []); 119 | 120 | const [element, instanceRef] = useMonacoEditor( 121 | { 122 | value: defaultValue, 123 | language: language, 124 | }, 125 | effects 126 | ); 127 | 128 | useEffect(() => { 129 | const instance = instanceRef.current; 130 | if (instance != null) editor.setModelLanguage(instance.getModel()!, language); 131 | }, [language]); 132 | 133 | const getCode = useCallback(() => instanceRef.current?.getValue(), []); 134 | 135 | return [element, getCode] as const; 136 | }; 137 | 138 | const setCursorDecorations = (instance: editor.IStandaloneCodeEditor, cursors: Cursor[]) => { 139 | const range = new Range(0, 0, (instance.getModel()?.getLineCount() ?? 0) + 1, 0); 140 | const oldCursorDecorations = (instance.getDecorationsInRange(range) ?? []) 141 | .filter(decoration => decoration.options.className?.startsWith(cursorSuffix)) 142 | .map(decoration => decoration.id); 143 | const newCursorDecorations = cursors.map(({ uid, name, position: { lineNumber, column } }) => ({ 144 | range: new Range(lineNumber, column, lineNumber, column), 145 | options: { 146 | className: [cursorSuffix, suffix + '-' + uid, suffix + '-' + uid + '-bg'].join(' '), 147 | stickiness: 1, 148 | hoverMessage: { value: name }, 149 | }, 150 | })); 151 | instance.deltaDecorations(oldCursorDecorations, newCursorDecorations); 152 | }; 153 | 154 | const setSelectionDecrations = (instance: editor.IStandaloneCodeEditor, selections: Selection[]) => { 155 | const range = new Range(0, 0, (instance.getModel()?.getLineCount() ?? 0) + 1, 0); 156 | const oldSelectionDecorations = (instance.getDecorationsInRange(range) ?? []) 157 | .filter(decoration => decoration.options.className?.startsWith(selectionSuffix)) 158 | .map(v => v.id); 159 | const newSelectionDecorations = selections.map(({ uid, name, range }) => ({ 160 | range, 161 | options: { 162 | className: [selectionSuffix, suffix + '-' + uid, suffix + '-' + uid + '-bg'].join(' '), 163 | hoverMessage: { value: name }, 164 | }, 165 | })); 166 | instance.deltaDecorations(oldSelectionDecorations, newSelectionDecorations); 167 | }; 168 | -------------------------------------------------------------------------------- /client/web/src/components/editor/TextEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useMonacoEditor } from './Base'; 3 | 4 | export const useTextEditor = () => { 5 | const [element, instanceRef] = useMonacoEditor({ 6 | value: '', 7 | language: 'plaintext', 8 | minimap: { enabled: false }, 9 | lineNumbers: 'off', 10 | }); 11 | 12 | const setValue = useCallback((value: string) => { 13 | instanceRef.current?.setValue(value); 14 | }, []); 15 | const getValue = useCallback(() => instanceRef.current?.getValue(), []); 16 | 17 | return [element, setValue, getValue] as const; 18 | }; 19 | 20 | export const useReadonlyTextEditor = () => { 21 | const [element, instanceRef] = useMonacoEditor({ 22 | value: '', 23 | language: 'plaintext', 24 | minimap: { enabled: false }, 25 | lineNumbers: 'off', 26 | readOnly: true, 27 | }); 28 | 29 | const setValue = useCallback((value: string) => { 30 | instanceRef.current?.setValue(value); 31 | }, []); 32 | 33 | return [element, setValue] as const; 34 | }; 35 | -------------------------------------------------------------------------------- /client/web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from 'react-dom'; 2 | import App from './components/App'; 3 | 4 | const root = document.getElementById('root')!; 5 | root.innerHTML = ''; 6 | 7 | render(, root); 8 | -------------------------------------------------------------------------------- /client/web/src/pages/chat.tsx: -------------------------------------------------------------------------------- 1 | import { VFC, useState, useMemo, Suspense, MouseEvent } from 'react'; 2 | import { Main } from '~/components/Main'; 3 | import { useLoad } from '~/scripts/promise'; 4 | import { open } from '~/scripts/websocket/connection'; 5 | import { Link, useMatch, useLocation } from '@tanstack/react-location'; 6 | import { LocationGenerics } from '~/components/App'; 7 | import { RoomInfo } from '~/scripts/room-api'; 8 | import styled from 'styled-components'; 9 | import { BlueButton } from '~/components/Button'; 10 | 11 | const tickets = new Map(); 12 | const getTickets = (pageKey: string | undefined) => { 13 | if (!tickets.has(pageKey)) tickets.set(pageKey, Symbol()); 14 | return tickets.get(pageKey) as symbol; 15 | }; 16 | 17 | export const ChatPage: VFC = () => { 18 | const location = useLocation(); 19 | const { 20 | data: { roomInfo }, 21 | } = useMatch(); 22 | 23 | const [userName, setUserName] = useState(); 24 | const chatConnection = useMemo(() => { 25 | if (userName != null && roomInfo != null) 26 | return useLoad(getTickets(location.current.key), () => open(roomInfo.id, userName)); 27 | }, [userName]); 28 | 29 | return chatConnection == null ? ( 30 | 31 | ) : ( 32 | connecting

}> 33 |
34 | 35 | ); 36 | }; 37 | 38 | const Entrance = styled void }>>( 39 | ({ roomInfo, set, ...rest }) => { 40 | const [userName, setUserName] = useState(''); 41 | const onClick = (ev: MouseEvent) => { 42 | ev.preventDefault(); 43 | set(userName); 44 | }; 45 | 46 | return roomInfo == null ? ( 47 |
48 |

部屋が存在しません

49 |
50 | 戻る 51 |
52 |
53 | ) : ( 54 |
55 |

部屋名: {roomInfo.name}

56 |
57 | setUserName(ev.target.value)} placeholder='名前' /> 58 | 59 | 60 | OK 61 | 62 | 63 |
64 | ); 65 | } 66 | )` 67 | width: 100vw; 68 | height: 100vh; 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-direction: column; 73 | 74 | > h2 { 75 | font-size: 1.25em; 76 | } 77 | > form { 78 | display: flex; 79 | align-items: center; 80 | margin-inline: auto; 81 | padding: 0.5em; 82 | max-width: 30em; 83 | > input { 84 | border: 1px solid #ccc; 85 | padding-inline: 0.5rem; 86 | font-size: 1em; 87 | line-height: 2; 88 | flex-grow: 1; 89 | } 90 | > span { 91 | padding: 0.25em; 92 | } 93 | } 94 | `; 95 | -------------------------------------------------------------------------------- /client/web/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { VFC, Suspense, useState, MouseEvent } from 'react'; 2 | import { Link, useLocation, useNavigate } from '@tanstack/react-location'; 3 | import { Header } from '~/components/Header'; 4 | import { Loadable, useLoad } from '~/scripts/promise'; 5 | import { RoomList, getRoomList, createRoom } from '~/scripts/room-api'; 6 | import styled from 'styled-components'; 7 | import { BlueButton, blueButtonCss } from '~/components/Button'; 8 | 9 | const tickets = new Map(); 10 | const getTickets = (pageKey: string | undefined) => { 11 | if (!tickets.has(pageKey)) tickets.set(pageKey, Symbol()); 12 | return tickets.get(pageKey) as symbol; 13 | }; 14 | 15 | export const Welcome = styled(props => { 16 | const location = useLocation(); 17 | const roomList = useLoad(getTickets(location.current.key), getRoomList); 18 | return ( 19 |
20 |
21 |
22 |
23 | CodingChatはオンラインでのペアプログラミングを支援するサービスです。 24 |
25 | 誰でも無料でオンラインでコードを共同編集し実行することができます。 26 |
27 |
28 | 29 |

公開部屋一覧

30 |
31 | 32 |
33 |
34 |
35 | 36 |
37 |
38 | ); 39 | })` 40 | > div:last-child { 41 | padding-block: 2em; 42 | text-align: center; 43 | > * { 44 | padding: 2em 0.5em; 45 | } 46 | > section:nth-child(2) > h2 { 47 | font-size: 1.25em; 48 | + div { 49 | margin-inline: auto; 50 | max-width: 30em; 51 | } 52 | } 53 | } 54 | `; 55 | 56 | const Rooms = styled }>>(({ roomList, ...rest }) => { 57 | const list = roomList.get(); 58 | return list.length === 0 ? ( 59 |

no rooms

60 | ) : ( 61 |
    62 | {list.map(({ name, id, participantsNumber }) => ( 63 |
  • 64 |
    65 |

    {name}

    66 | {participantsNumber}人接続中 67 |
    68 | 入室 69 |
  • 70 | ))} 71 |
72 | ); 73 | })` 74 | padding: 0; 75 | list-style: none; 76 | > li { 77 | display: flex; 78 | align-items: center; 79 | border: 1px solid #ccc; 80 | border-bottom: 0; 81 | :last-child { 82 | border-bottom: 1px solid #ccc; 83 | } 84 | padding: 0 1rem; 85 | > div { 86 | padding: 0.5em 0; 87 | text-align: start; 88 | flex-grow: 1; 89 | > h3 { 90 | font-weight: bold; 91 | } 92 | } 93 | > a { 94 | display: block; 95 | ${blueButtonCss} 96 | text-decoration: none; 97 | } 98 | } 99 | `; 100 | 101 | const NewRoom = styled(props => { 102 | const [name, setName] = useState(''); 103 | const [isPublic, setIsPublic] = useState(true); 104 | const navigate = useNavigate(); 105 | 106 | const create = (ev: MouseEvent) => { 107 | createRoom(name, isPublic).then(id => navigate({ to: '/chat/' + id })); 108 | ev.preventDefault(); 109 | }; 110 | 111 | return ( 112 |
113 |

部屋を新規作成

114 |
115 | setName(ev.target.value)} placeholder='部屋名' /> 116 | 120 | 作成 121 |
122 | ※新しい部屋の作成時に、誰も入っていない部屋は自動的に削除されます。 123 |
124 | ); 125 | })` 126 | > h2 { 127 | font-size: 1.25em; 128 | } 129 | > form { 130 | display: flex; 131 | align-items: center; 132 | margin-inline: auto; 133 | max-width: 30em; 134 | > input[type='text'] { 135 | border: 1px solid #ccc; 136 | padding-inline: 0.5rem; 137 | font-size: 1em; 138 | line-height: 2; 139 | flex-grow: 1; 140 | } 141 | > label { 142 | margin-inline: 0.5em; 143 | } 144 | } 145 | > span { 146 | font-size: 0.9em; 147 | line-height: 2; 148 | } 149 | `; 150 | -------------------------------------------------------------------------------- /client/web/src/scripts/base62.ts: -------------------------------------------------------------------------------- 1 | const RADIX = 62; 2 | 3 | export const encode = (data: Uint8Array): string => { 4 | const n = Math.ceil(data.length * 1.3435902316563355); // 1.3435902316633555 = log 256 / log 62 5 | const res: string[] = []; 6 | 7 | for (let i = 0; i < n; ++i) { 8 | const sur = data.reduce((p, c) => ((p << 8) + c) % RADIX, 0); 9 | res.push('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'[sur]!); 10 | minus(data, sur); 11 | div(data, RADIX); 12 | } 13 | 14 | return res.reverse().join(''); 15 | }; 16 | 17 | const minus = (arr: Uint8Array, b: number) => { 18 | for (let i = arr.length - 1; b > 0 && i >= 0; --i) { 19 | const n = arr[i]! - b; 20 | if (n < 0) { 21 | const a = (n % 256) + 256; 22 | arr[i] = a; 23 | b = (a - n) / 256; 24 | } else { 25 | arr[i] = n % 256; 26 | b = 0; 27 | } 28 | } 29 | }; 30 | 31 | const div = (arr: Uint8Array, b: number) => { 32 | let sur = 0; 33 | arr.forEach((v, i) => { 34 | sur = (sur << 8) + v; 35 | arr[i] = (sur / b) | 0; 36 | sur %= b; 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /client/web/src/scripts/code-run.ts: -------------------------------------------------------------------------------- 1 | import { request } from './wandbox'; 2 | 3 | type Language = 'C++' | 'Python'; 4 | 5 | export const run = (code: string, stdin: string, language: Language) => 6 | request({ 7 | compiler: language === 'C++' ? 'gcc-head' : 'cpython-3.9.3', 8 | code, 9 | stdin, 10 | }) 11 | .then(({ compiler_output, compiler_error, program_output, program_error }) => { 12 | let log = [compiler_output, compiler_error, program_error].filter(v => v != null).join('\n'); 13 | return [program_output ?? '', log] as const; 14 | }) 15 | .catch(err => { 16 | return ['', 'could not run the code:\n' + err.message] as const; 17 | }); 18 | -------------------------------------------------------------------------------- /client/web/src/scripts/promise.ts: -------------------------------------------------------------------------------- 1 | export interface Loadable { 2 | get(): T; 3 | } 4 | 5 | type SyncStatus = 6 | | { 7 | type: 'pending'; 8 | promise: Promise; 9 | } 10 | | { 11 | type: 'fulfilled'; 12 | value: T; 13 | } 14 | | { 15 | type: 'rejected'; 16 | error: unknown; 17 | }; 18 | export class Sync implements Loadable { 19 | protected status: SyncStatus; 20 | 21 | constructor(promise: Promise) { 22 | this.status = { type: 'pending', promise }; 23 | promise.then( 24 | value => { 25 | this.status = { type: 'fulfilled', value }; 26 | return value; 27 | }, 28 | error => { 29 | this.status = { type: 'rejected', error }; 30 | throw error; 31 | } 32 | ); 33 | } 34 | 35 | get(): T { 36 | switch (this.status.type) { 37 | case 'fulfilled': 38 | return this.status.value; 39 | case 'pending': 40 | throw this.status.promise; 41 | case 'rejected': 42 | throw this.status.error; 43 | } 44 | } 45 | } 46 | export const sync = (promise: Promise) => new Sync(promise); 47 | 48 | const syncs = new Map>(); 49 | export const useLoad = (key: symbol, loader: () => Promise) => { 50 | if (!syncs.has(key)) syncs.set(key, sync(loader())); 51 | 52 | return syncs.get(key) as Loadable; 53 | }; 54 | -------------------------------------------------------------------------------- /client/web/src/scripts/room-api.ts: -------------------------------------------------------------------------------- 1 | import { ResponseError } from './utils'; 2 | 3 | const endpoint = 'http://' + window.location.host + '/api'; 4 | 5 | export type RoomInfo = { 6 | id: string; 7 | name: string; 8 | isPublic: boolean; 9 | participantsNumber: number; 10 | }; 11 | type RoomInfoResponse = { 12 | name: string; 13 | is_open: boolean; 14 | connections: number; 15 | room_id: string; 16 | }; 17 | export const getRoomInfo = (roomId: string) => 18 | fetch(endpoint + '/room_info/' + roomId).then(async res => { 19 | if (!res.ok) throw new ResponseError(res); 20 | 21 | const { name, is_open, connections, room_id }: RoomInfoResponse = await res.json(); 22 | return { 23 | id: room_id, 24 | name, 25 | isPublic: is_open, 26 | participantsNumber: connections, 27 | }; 28 | }); 29 | 30 | export type RoomList = { 31 | name: string; 32 | id: string; 33 | participantsNumber: number; 34 | }[]; 35 | type RoomListResponse = { 36 | name: string; 37 | room_id: string; 38 | connections: number; 39 | }[]; 40 | export const getRoomList = () => 41 | fetch(endpoint + '/room_list').then(async res => { 42 | if (!res.ok) throw new ResponseError(res); 43 | 44 | const info = (await res.json()) as RoomListResponse; 45 | return info.map(({ name, room_id, connections }) => ({ 46 | name, 47 | id: room_id, 48 | participantsNumber: connections, 49 | })); 50 | }); 51 | 52 | export const createRoom = (name: string, isPublic: boolean) => 53 | fetch(endpoint + '/create_room', { 54 | method: 'POST', 55 | body: JSON.stringify({ name, is_public: isPublic }), 56 | headers: { 'Content-Type': 'application/json' }, 57 | }).then(async res => { 58 | if (!res.ok) throw new ResponseError(res); 59 | return (await res.json()) as string; 60 | }); 61 | -------------------------------------------------------------------------------- /client/web/src/scripts/room-mates.ts: -------------------------------------------------------------------------------- 1 | import { getRandomColor } from './utils'; 2 | 3 | export class Room { 4 | private roomMates: Map; 5 | private styleElement: HTMLStyleElement; 6 | 7 | constructor() { 8 | this.roomMates = new Map(); 9 | this.styleElement = document.createElement('style'); 10 | document.head.append(this.styleElement); 11 | } 12 | 13 | dispose() { 14 | document.head.removeChild(this.styleElement); 15 | } 16 | 17 | joined(uid: string, name: string) { 18 | if (!this.roomMates.has(uid)) { 19 | this.roomMates.set(uid, name); 20 | this.reflect(); 21 | } 22 | } 23 | left(uid: string) { 24 | return this.roomMates.delete(uid); 25 | } 26 | 27 | private getStyles() { 28 | const csss: string[] = [ 29 | ` 30 | .${selectionSuffix} { 31 | opacity: .5; 32 | position: relative; 33 | margin-inline: 2px; 34 | pointer-events: none; 35 | } 36 | .${cursorSuffix} { 37 | width: 2px !important; 38 | position: relative; 39 | z-index: 1; 40 | animation-name: ${cursorSuffix}-blink-animation; 41 | animation-direction: alternate; 42 | animation-duration: .5s; 43 | animation-delay: 2s; 44 | animation-iteration-count: infinite; 45 | animation-timing-function: steps(2, jump-end); 46 | } 47 | @keyframes ${cursorSuffix}-blink-animation { 48 | from { opacity: 1; } 49 | to { opacity: 0; } 50 | } 51 | .${cursorSuffix}::after { 52 | position: relative; 53 | top: -5px; 54 | right: -5px; 55 | 56 | font-size: calc(1em - 4px); 57 | pointer-events: none; 58 | white-space: nowrap; 59 | padding: 1px; 60 | z-index: 3; 61 | color: white; 62 | animation: 1s ease-in 1s 1 normal forwards running ${cursorSuffix}-fade-animation; 63 | } 64 | @keyframes ${cursorSuffix}-fade-animation { 65 | from { opacity: 1; } 66 | to { opacity: 0; } 67 | } 68 | `, 69 | ]; 70 | for (const uid of this.roomMates.keys()) { 71 | const color = getRandomColor(uid); 72 | csss.push(` 73 | .${cursorSuffix}.${suffix}-${uid}:hover { 74 | animation: unset; 75 | } 76 | .${cursorSuffix}.${suffix}-${uid}:hover::after { 77 | animation: unset; 78 | } 79 | .${cursorSuffix}.${suffix}-${uid}::after { 80 | content: "${this.roomMates.get(uid)!}"; 81 | background-color: ${color}; 82 | } 83 | .${suffix}-${uid}-bg { 84 | background-color: ${color}; 85 | } 86 | .${suffix}-${uid}-color { 87 | color: ${color}; 88 | } 89 | `); 90 | } 91 | return csss.join(''); 92 | } 93 | 94 | reflect() { 95 | requestIdleCallback(() => { 96 | this.styleElement.innerHTML = this.getStyles(); 97 | }); 98 | } 99 | } 100 | 101 | export const suffix = '__codingchat'; 102 | export const cursorSuffix = suffix + '-cursor'; 103 | export const selectionSuffix = suffix + '-selection'; 104 | -------------------------------------------------------------------------------- /client/web/src/scripts/utils.ts: -------------------------------------------------------------------------------- 1 | import { encode as base62Encode } from './base62'; 2 | 3 | export const cyrb53 = (str: string, seed = 0) => { 4 | let h1 = 0xdeadbeef ^ seed, 5 | h2 = 0x41c6ce57 ^ seed; 6 | for (let i = 0, ch; i < str.length; i++) { 7 | ch = str.charCodeAt(i); 8 | h1 = Math.imul(h1 ^ ch, 2654435761); 9 | h2 = Math.imul(h2 ^ ch, 1597334677); 10 | } 11 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); 12 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); 13 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 14 | }; 15 | 16 | export const getRandomColor = (input: string) => { 17 | const h = cyrb53(input) % 360; 18 | return `hsl(${h}, 80%, 60%)`; 19 | }; 20 | 21 | const encoder = new TextEncoder(); 22 | export const str2hex = (str: string) => 23 | encoder 24 | .encode(str) 25 | .reduce((p, c) => (p.push(c.toString(16).padStart(2, '0')), p), []) 26 | .join(''); 27 | export const base64ToBase62 = (str: string) => base62Encode(encoder.encode(atob(str))); 28 | 29 | export class ResponseError extends Error { 30 | constructor(readonly response: Response) { 31 | super('status code: ' + response.status.toString().padStart(3, '0') + '\nresponse: ' + response.text()); 32 | } 33 | } 34 | 35 | export const readStream = ( 36 | reader: ReadableStreamDefaultReader, 37 | processor: (chunk: T) => void | Promise 38 | ) => { 39 | const handler = async (result: ReadableStreamDefaultReadResult): Promise => { 40 | if (result.done) return; 41 | 42 | await processor(result.value); 43 | return reader.read().then(handler); 44 | }; 45 | return reader.read().then(handler); 46 | }; 47 | -------------------------------------------------------------------------------- /client/web/src/scripts/wandbox.ts: -------------------------------------------------------------------------------- 1 | import { ResponseError } from './utils'; 2 | 3 | const compilerInfo = [ 4 | { lang: 'C', name: 'gcc-head-c' }, 5 | { lang: 'C', name: 'gcc-11.1.0-c' }, 6 | { lang: 'C', name: 'gcc-10.2.0-c' }, 7 | { lang: 'C', name: 'gcc-9.3.0-c' }, 8 | { lang: 'C', name: 'gcc-8.4.0-c' }, 9 | { lang: 'C', name: 'gcc-7.5.0-c' }, 10 | { lang: 'C', name: 'gcc-6.5.0-c' }, 11 | { lang: 'C', name: 'gcc-5.5.0-c' }, 12 | { lang: 'C', name: 'gcc-4.9.4-c' }, 13 | { lang: 'CPP', name: 'gcc-head-pp' }, 14 | { lang: 'C++', name: 'gcc-head' }, 15 | { lang: 'C++', name: 'gcc-11.1.0' }, 16 | { lang: 'C++', name: 'gcc-10.2.0' }, 17 | { lang: 'C++', name: 'gcc-9.3.0' }, 18 | { lang: 'C++', name: 'gcc-8.4.0' }, 19 | { lang: 'C++', name: 'gcc-7.5.0' }, 20 | { lang: 'C++', name: 'gcc-6.5.0' }, 21 | { lang: 'C++', name: 'gcc-5.5.0' }, 22 | { lang: 'C++', name: 'gcc-4.9.4' }, 23 | { lang: 'C', name: 'clang-head-c' }, 24 | { lang: 'C', name: 'clang-13.0.0-c' }, 25 | { lang: 'C', name: 'clang-12.0.1-c' }, 26 | { lang: 'C', name: 'clang-11.1.0-c' }, 27 | { lang: 'C', name: 'clang-10.0.1-c' }, 28 | { lang: 'C', name: 'clang-9.0.1-c' }, 29 | { lang: 'C', name: 'clang-8.0.1-c' }, 30 | { lang: 'C', name: 'clang-7.1.0-c' }, 31 | { lang: 'CPP', name: 'clang-head-pp' }, 32 | { lang: 'C++', name: 'clang-head' }, 33 | { lang: 'C++', name: 'clang-13.0.0' }, 34 | { lang: 'C++', name: 'clang-12.0.1' }, 35 | { lang: 'C++', name: 'clang-11.1.0' }, 36 | { lang: 'C++', name: 'clang-10.0.1' }, 37 | { lang: 'C++', name: 'clang-9.0.1' }, 38 | { lang: 'C++', name: 'clang-8.0.1' }, 39 | { lang: 'C++', name: 'clang-7.1.0' }, 40 | { lang: 'C#', name: 'mono-6.12.0.122' }, 41 | { lang: 'C#', name: 'mono-5.20.1.34' }, 42 | { lang: 'Erlang', name: 'erlang-23.3.1' }, 43 | { lang: 'Erlang', name: 'erlang-22.3.4.16' }, 44 | { lang: 'Erlang', name: 'erlang-21.3.8.22' }, 45 | { lang: 'Elixir', name: 'elixir-1.11.4' }, 46 | { lang: 'Elixir', name: 'elixir-1.10.4' }, 47 | { lang: 'Haskell', name: 'ghc-9.0.1' }, 48 | { lang: 'Haskell', name: 'ghc-8.10.4' }, 49 | { lang: 'Haskell', name: 'ghc-8.8.4' }, 50 | { lang: 'D', name: 'dmd-2.096.0' }, 51 | { lang: 'D', name: 'ldc-1.25.1' }, 52 | { lang: 'D', name: 'ldc-1.24.0' }, 53 | { lang: 'D', name: 'ldc-1.23.0' }, 54 | { lang: 'Java', name: 'openjdk-jdk-15.0.3+2' }, 55 | { lang: 'Java', name: 'openjdk-jdk-14.0.2+12' }, 56 | { lang: 'Rust', name: 'rust-1.51.0' }, 57 | { lang: 'Rust', name: 'rust-1.50.0' }, 58 | { lang: 'Python', name: 'cpython-3.9.3' }, 59 | { lang: 'Python', name: 'cpython-3.8.9' }, 60 | { lang: 'Python', name: 'cpython-3.7.10' }, 61 | { lang: 'Python', name: 'cpython-3.6.12' }, 62 | { lang: 'Python', name: 'cpython-2.7.18' }, 63 | { lang: 'Ruby', name: 'ruby-3.1.0' }, 64 | { lang: 'Ruby', name: 'ruby-3.0.1' }, 65 | { lang: 'Ruby', name: 'ruby-2.7.3' }, 66 | { lang: 'Ruby', name: 'mruby-3.0.0' }, 67 | { lang: 'Ruby', name: 'mruby-2.1.2' }, 68 | { lang: 'Ruby', name: 'mruby-1.4.1' }, 69 | { lang: 'Scala', name: 'scala-2.13.5' }, 70 | { lang: 'Scala', name: 'scala-2.12.13' }, 71 | { lang: 'Groovy', name: 'groovy-3.0.8' }, 72 | { lang: 'Groovy', name: 'groovy-2.5.14' }, 73 | { lang: 'JavaScript', name: 'nodejs-14.16.1' }, 74 | { lang: 'JavaScript', name: 'nodejs-12.22.1' }, 75 | { lang: 'JavaScript', name: 'nodejs-10.24.1' }, 76 | { lang: 'JavaScript', name: 'spidermonkey-88.0.0' }, 77 | { lang: 'Swift', name: 'swift-5.3.3' }, 78 | { lang: 'Perl', name: 'perl-5.34.0' }, 79 | { lang: 'Perl', name: 'perl-5.33.8' }, 80 | { lang: 'Perl', name: 'perl-5.32.1' }, 81 | { lang: 'Perl', name: 'perl-5.30.3' }, 82 | { lang: 'PHP', name: 'php-8.0.3' }, 83 | { lang: 'PHP', name: 'php-7.4.16' }, 84 | { lang: 'PHP', name: 'php-5.6.40' }, 85 | { lang: 'Lua', name: 'lua-5.4.3' }, 86 | { lang: 'Lua', name: 'lua-5.3.6' }, 87 | { lang: 'Lua', name: 'lua-5.2.4' }, 88 | { lang: 'Lua', name: 'luajit-2.0.5' }, 89 | { lang: 'Lua', name: 'luajit-2.0.4' }, 90 | { lang: 'Lua', name: 'luajit-2.0.3' }, 91 | { lang: 'SQL', name: 'sqlite-3.35.5' }, 92 | { lang: 'Pascal', name: 'fpc-3.2.0' }, 93 | { lang: 'Pascal', name: 'fpc-3.0.4' }, 94 | { lang: 'Pascal', name: 'fpc-2.6.4' }, 95 | { lang: 'Lisp', name: 'clisp-2.49' }, 96 | { lang: 'Lazy K', name: 'lazyk' }, 97 | { lang: 'Vim script', name: 'vim-8.2.2811' }, 98 | { lang: 'Vim script', name: 'vim-8.1.2424' }, 99 | { lang: 'Python', name: 'pypy-3.7-v7.3.4' }, 100 | { lang: 'Python', name: 'pypy-2.7-v7.3.4' }, 101 | { lang: 'OCaml', name: 'ocaml-4.12.0' }, 102 | { lang: 'OCaml', name: 'ocaml-4.11.2' }, 103 | { lang: 'OCaml', name: 'ocaml-4.10.2' }, 104 | { lang: 'Go', name: 'go-1.16.3' }, 105 | { lang: 'Go', name: 'go-1.15.11' }, 106 | { lang: 'Go', name: 'go-1.14.15' }, 107 | { lang: 'Lisp', name: 'sbcl-2.1.3' }, 108 | { lang: 'Lisp', name: 'sbcl-1.5.9' }, 109 | { lang: 'Bash script', name: 'bash' }, 110 | { lang: 'Pony', name: 'pony-0.39.1' }, 111 | { lang: 'Pony', name: 'pony-0.38.3' }, 112 | { lang: 'Crystal', name: 'crystal-1.0.0' }, 113 | { lang: 'Crystal', name: 'crystal-0.36.1' }, 114 | { lang: 'Nim', name: 'nim-1.6.0' }, 115 | { lang: 'Nim', name: 'nim-1.4.8' }, 116 | { lang: 'Nim', name: 'nim-1.4.6' }, 117 | { lang: 'Nim', name: 'nim-1.2.8' }, 118 | { lang: 'Nim', name: 'nim-1.0.10' }, 119 | { lang: 'OpenSSL', name: 'openssl-1.1.1k' }, 120 | { lang: 'OpenSSL', name: 'openssl-1.0.2u' }, 121 | { lang: 'OpenSSL', name: 'openssl-0.9.8zh' }, 122 | { lang: 'C#', name: 'dotnetcore-5.0.201' }, 123 | { lang: 'C#', name: 'dotnetcore-3.1.407' }, 124 | { lang: 'C#', name: 'dotnetcore-2.1.814' }, 125 | { lang: 'R', name: 'r-4.0.5' }, 126 | { lang: 'R', name: 'r-3.6.3' }, 127 | { lang: 'TypeScript', name: 'typescript-4.2.4 nodejs 14.16.1' }, 128 | { lang: 'TypeScript', name: 'typescript-3.9.9 nodejs 14.16.1' }, 129 | { lang: 'Julia', name: 'julia-1.6.1' }, 130 | { lang: 'Julia', name: 'julia-1.0.5' }, 131 | ] as const; 132 | type CompilerInfo = typeof compilerInfo; 133 | 134 | type Language = CompilerInfo[number]['lang']; 135 | type CompilerName = CompilerInfo[number] extends infer Info 136 | ? Info extends { lang: L; name: string } 137 | ? Info['name'] 138 | : never 139 | : never; 140 | 141 | export const getCompilerNameListOf = (language: L) => 142 | compilerInfo.filter(({ lang }) => lang === language).map(({ name }) => name) as CompilerName[]; 143 | 144 | type CompileRequest = { 145 | compiler: CompilerName; 146 | code: string; 147 | stdin: string; 148 | }; 149 | 150 | type CompileResponse = { 151 | status: string; 152 | signal?: string; 153 | compiler_output?: string; 154 | compiler_error?: string; 155 | compiler_message?: string; 156 | program_output?: string; 157 | program_error?: string; 158 | program_message?: string; 159 | }; 160 | 161 | const endpoint = 'https://wandbox.org/api/compile.json'; 162 | export const request = (request: CompileRequest) => 163 | fetch(endpoint, { method: 'post', body: JSON.stringify(request) }).then(response => { 164 | if (response.ok) { 165 | return response.json() as Promise; 166 | } 167 | throw new ResponseError(response); 168 | }); 169 | -------------------------------------------------------------------------------- /client/web/src/scripts/websocket/connection.ts: -------------------------------------------------------------------------------- 1 | import { Room } from '../room-mates'; 2 | import { str2hex } from '../utils'; 3 | import { strToInboundMessage } from './transformer'; 4 | import { InboundMessage, OutboundMessage } from './types'; 5 | 6 | export class WebsocketConnectionManager { 7 | private room: Room; 8 | private destructors: Set<() => void>; 9 | private messageStream: ReadableStream; 10 | 11 | constructor(private socket: WebSocket) { 12 | this.room = new Room(); 13 | this.destructors = new Set(); 14 | socket.addEventListener('close', () => { 15 | this.destructors.forEach(f => f()); 16 | }); 17 | 18 | this.messageStream = new ReadableStream({ 19 | start: controller => { 20 | socket.addEventListener('message', ({ data, timeStamp }: MessageEvent) => { 21 | if ('string' === typeof data) { 22 | const message = strToInboundMessage(data); 23 | if (message != null) { 24 | if (message.type === 'disconnect') this.room.left(message.key); 25 | else this.room.joined(message.key, message.name); 26 | 27 | controller.enqueue(Object.assign(message, { timestamp: timeStamp })); 28 | } 29 | } 30 | }); 31 | socket.addEventListener('close', () => { 32 | controller.close(); 33 | }); 34 | }, 35 | }); 36 | } 37 | 38 | sendMessage(message: OutboundMessage) { 39 | this.socket.send(JSON.stringify(message)); 40 | } 41 | close() { 42 | this.socket.close(); 43 | } 44 | getMessageStream() { 45 | const [s1, s2] = this.messageStream.tee(); 46 | this.messageStream = s1; 47 | return s2; 48 | } 49 | } 50 | 51 | const endpoint = 'ws://' + window.location.host + '/api/ws/'; 52 | 53 | export const open = (roomId: string, name: string) => 54 | new Promise((resolve, reject) => { 55 | const sock = new WebSocket(endpoint + roomId + '?name=' + str2hex(name)); 56 | const conn = new WebsocketConnectionManager(sock); 57 | sock.addEventListener('open', () => resolve(conn)); 58 | sock.addEventListener('error', () => reject('cannot open websocket connection')); 59 | }); 60 | -------------------------------------------------------------------------------- /client/web/src/scripts/websocket/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { readStream } from '../utils'; 3 | import { WebsocketConnectionManager } from './connection'; 4 | import { pickAndTransformInboundChat } from './transformer'; 5 | import { ChatMessage } from './types'; 6 | 7 | export const useChat = (conn: WebsocketConnectionManager) => { 8 | const [messages, setMessages] = useState([]); 9 | 10 | useEffect(() => { 11 | readStream(conn.getMessageStream().getReader(), message => { 12 | const chat = pickAndTransformInboundChat(message); 13 | 14 | if (chat != null) setMessages(messages => [chat, ...messages]); 15 | }); 16 | }, []); 17 | 18 | return messages; 19 | }; 20 | -------------------------------------------------------------------------------- /client/web/src/scripts/websocket/transformer.ts: -------------------------------------------------------------------------------- 1 | import { base64ToBase62 } from '../utils'; 2 | import { ChatMessage, EditorCommand, InboundMessage } from './types'; 3 | import { inboundMessageValidator } from './validator'; 4 | 5 | export const strToInboundMessage = (str: string) => { 6 | const data = JSON.parse(str) as unknown; 7 | 8 | if (!inboundMessageValidator(data)) return undefined; 9 | data.key = base64ToBase62(data.key); 10 | 11 | return data; 12 | }; 13 | 14 | export const pickAndTransformInboundChat = ( 15 | message: InboundMessage & { timestamp: number } 16 | ): ChatMessage | undefined => { 17 | switch (message.type) { 18 | case 'chat': { 19 | const { data, timestamp, name, key } = message; 20 | return { 21 | type: 'chat', 22 | content: data, 23 | date: new Date(timestamp), 24 | uid: key, 25 | name, 26 | }; 27 | } 28 | case 'connect': { 29 | const { timestamp, name } = message; 30 | return { 31 | type: 'info', 32 | content: name + 'が接続しました', 33 | date: new Date(timestamp), 34 | }; 35 | } 36 | case 'disconnect': { 37 | const { timestamp, name } = message; 38 | return { 39 | type: 'info', 40 | content: name + 'が切断しました', 41 | date: new Date(timestamp), 42 | }; 43 | } 44 | } 45 | }; 46 | 47 | export const pickAndTransformEditorCommand = (message: InboundMessage): EditorCommand | undefined => { 48 | switch (message.type) { 49 | case 'connect': { 50 | return { 51 | type: 'onconnect', 52 | fullText: message.data, 53 | }; 54 | } 55 | case 'disconnect': { 56 | return { 57 | type: 'clean', 58 | uid: message.key, 59 | } 60 | } 61 | case 'edit': { 62 | return { 63 | type: 'edit', 64 | changes: message.data.changes, 65 | }; 66 | } 67 | case 'cursormove': { 68 | const { data, key, name } = message; 69 | return { 70 | type: 'cursormove', 71 | position: data, 72 | uid: key, 73 | name, 74 | }; 75 | } 76 | case 'selection': { 77 | const { data, key, name } = message; 78 | return { 79 | type: 'selection', 80 | range: data, 81 | uid: key, 82 | name, 83 | }; 84 | } 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /client/web/src/scripts/websocket/types.ts: -------------------------------------------------------------------------------- 1 | import type { IPosition, IRange, editor } from 'monaco-editor'; 2 | 3 | export type FullText = string; 4 | export type ConnectionKey = string; 5 | 6 | export type OutboundEditorCommand = 7 | | { 8 | type: 'cursormove'; 9 | data: IPosition; 10 | } 11 | | { 12 | type: 'selection'; 13 | data: IRange; 14 | } 15 | | { 16 | type: 'edit'; 17 | data: { 18 | changes: editor.IModelContentChange[]; 19 | timestamp: number; 20 | full_text: FullText; 21 | }; 22 | }; 23 | export type OutboundChat = { 24 | type: 'chat'; 25 | data: string; 26 | }; 27 | export type OutboundMessage = OutboundEditorCommand | OutboundChat; 28 | 29 | export type InboundEditorCommand = OutboundEditorCommand & { key: ConnectionKey; name: string }; 30 | export type InboundChat = OutboundChat & { key: ConnectionKey; name: string }; 31 | export type InboundConnectionInfo = { key: ConnectionKey; name: string } & ( 32 | | { 33 | type: 'connect'; 34 | data: FullText; 35 | } 36 | | { 37 | type: 'disconnect'; 38 | data: ''; 39 | } 40 | ); 41 | export type InboundMessage = InboundEditorCommand | InboundChat | InboundConnectionInfo; 42 | 43 | export type EditorCommand = 44 | | { 45 | type: 'onconnect'; 46 | fullText: string; 47 | } 48 | | { 49 | type: 'edit'; 50 | changes: editor.IModelContentChange[]; 51 | } 52 | | { 53 | type: 'cursormove'; 54 | position: IPosition; 55 | uid: ConnectionKey; 56 | name: string; 57 | } 58 | | { 59 | type: 'selection'; 60 | range: IRange; 61 | uid: ConnectionKey; 62 | name: string; 63 | } 64 | | { 65 | type: 'clean'; 66 | uid: ConnectionKey; 67 | }; 68 | 69 | export type ChatMessage = 70 | | { 71 | type: 'chat'; 72 | content: string; 73 | date: Date; 74 | uid: ConnectionKey; 75 | name: string; 76 | } 77 | | { 78 | type: 'info'; 79 | content: string; 80 | date: Date; 81 | }; 82 | -------------------------------------------------------------------------------- /client/web/src/scripts/websocket/validator.ts: -------------------------------------------------------------------------------- 1 | import { editor, IPosition, IRange } from 'monaco-editor'; 2 | import { FullText, InboundMessage } from './types'; 3 | 4 | export const inboundMessageValidator = (data: unknown): data is InboundMessage => { 5 | if (data == null) return false; 6 | const obj = data as Record; 7 | return ( 8 | false || 9 | obj.type === 'disconnect' || 10 | (obj.type === 'connect' && isString(obj.data)) || 11 | (obj.type === 'chat' && isString(obj.data)) || 12 | (obj.type === 'edit' && editChangesValidator(obj.data)) || 13 | (obj.type === 'cursormove' && positionValidator(obj.data)) || 14 | (obj.type === 'selection' && rangeValidator(obj.data)) 15 | ); 16 | }; 17 | 18 | const isNumber = (v: unknown): v is number => 'number' === typeof v; 19 | const isString = (v: unknown): v is string => 'string' === typeof v; 20 | 21 | const positionValidator = (data: unknown): data is IPosition => { 22 | if (data == null) return false; 23 | const obj = data as Record; 24 | return isNumber(obj.lineNumber) && isNumber(obj.column); 25 | }; 26 | 27 | const rangeValidator = (data: unknown): data is IRange => { 28 | if (data == null) return false; 29 | const obj = data as Record; 30 | return ( 31 | true && 32 | isNumber(obj.startLineNumber) && 33 | isNumber(obj.endLineNumber) && 34 | isNumber(obj.startColumn) && 35 | isNumber(obj.endColumn) 36 | ); 37 | }; 38 | 39 | const editChangesValidator = ( 40 | data: unknown 41 | ): data is { 42 | changes: editor.IModelContentChange[]; 43 | timestamp: number; 44 | full_text: FullText; 45 | } => { 46 | if (data == null) return false; 47 | const obj = data as Record; 48 | return ( 49 | true && 50 | isNumber(obj.timestamp) && 51 | isString(obj.full_text) && 52 | obj.changes instanceof Array && 53 | obj.changes.every(modelContentChangeValidator) 54 | ); 55 | }; 56 | 57 | const modelContentChangeValidator = (data: unknown): data is editor.IModelContentChange => { 58 | if (data == null) return false; 59 | const obj = data as Record; 60 | return ( 61 | true && rangeValidator(obj.range) && isNumber(obj.rangeOffset) && isNumber(obj.rangeLength) && isString(obj.text) 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /client/web/src/styles/global.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | export const GlobalStyle = createGlobalStyle` 4 | * { 5 | margin: 0; 6 | } 7 | h1,h2,h3,h4,h5,h6 { 8 | font-weight: normal; 9 | font-size: 1em; 10 | } 11 | input { 12 | min-width: 0; 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /client/web/src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CodingChat 7 | 8 | 9 |
10 | 11 |

Only latest Firefox or Chrome supported

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /client/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "react-jsx", 7 | "lib": ["dom", "esnext"], 8 | "noUncheckedIndexedAccess": true, 9 | "moduleResolution": "node", 10 | "baseUrl": ".", 11 | "paths": { 12 | "~/*": ["./src/*"] 13 | }, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const MonacoEditorWebpackPlugin = require('monaco-editor-webpack-plugin'); 4 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 5 | const ReactRefreshTypeScript = require('react-refresh-typescript'); 6 | 7 | module.exports = (env, argv) => { 8 | const isDev = argv.mode !== 'production'; 9 | 10 | return { 11 | mode: argv.mode, 12 | devtool: isDev && 'eval', 13 | entry: { 14 | app: path.resolve(__dirname, './src/index.tsx'), 15 | }, 16 | resolve: { 17 | extensions: ['.ts', '.tsx', '.js'], 18 | alias: { 19 | '~': path.resolve(__dirname, './src'), 20 | }, 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.tsx?$/, 26 | use: [ 27 | { loader: 'babel-loader' }, 28 | { 29 | loader: 'ts-loader', 30 | options: { 31 | getCustomTransformers: () => (isDev ? { before: [ReactRefreshTypeScript()] } : void 0), 32 | compilerOptions: { jsx: 'react-jsx' + (isDev ? 'dev' : '') }, 33 | }, 34 | }, 35 | ], 36 | }, 37 | { 38 | test: /\.css$/, 39 | use: ['style-loader', 'css-loader'], 40 | }, 41 | ], 42 | }, 43 | optimization: { 44 | splitChunks: { 45 | cacheGroups: { 46 | vendor: { 47 | test: /node_modules/, 48 | name: 'vendor', 49 | chunks: 'initial', 50 | enforce: true, 51 | }, 52 | }, 53 | }, 54 | }, 55 | output: { 56 | path: path.resolve(__dirname, './build/'), 57 | filename: isDev ? 'static/[id].js' : 'static/[contenthash].js', 58 | publicPath: '/', 59 | clean: true, 60 | }, 61 | plugins: [ 62 | new HtmlWebpackPlugin({ 63 | template: path.resolve(__dirname, './src/template.html'), 64 | }), 65 | new MonacoEditorWebpackPlugin({ 66 | filename: 'static/[name].worker.js', 67 | publicPath: '/', 68 | languages: ['cpp', 'python'], 69 | }), 70 | isDev && new ReactRefreshWebpackPlugin(), 71 | ].filter(v => !!v), 72 | devServer: { 73 | proxy: { 74 | '/api': { 75 | target: 'http://' + (process.env.API_HOST ?? 'localhost:3000'), 76 | ws: true, 77 | }, 78 | }, 79 | static: { 80 | directory: path.resolve(__dirname, './build/static'), 81 | }, 82 | historyApiFallback: true, 83 | port: process.env.PORT ?? 8080, 84 | }, 85 | }; 86 | }; 87 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | web-client: 5 | build: 6 | context: ./client/web 7 | dockerfile: Dockerfile.dev 8 | ports: 9 | - 8080:8080 10 | volumes: 11 | - ./client/web/src:/workspace/src 12 | environment: 13 | - PORT=8080 14 | - API_HOST=api:3000 15 | 16 | api: 17 | build: 18 | context: ./api 19 | dockerfile: Dockerfile.dev 20 | volumes: 21 | - ./api/src:/workspace/src 22 | environment: 23 | - PORT=3000 24 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | web: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - 8080:8080 10 | environment: 11 | - PORT=8080 --------------------------------------------------------------------------------