diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..be174b2b8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "[yaml]": { + "editor.formatOnSave": false + } +} \ No newline at end of file diff --git a/docs/isupipe.yaml b/docs/isupipe.yaml index 93f099a4d..9ba4f5ec8 100644 --- a/docs/isupipe.yaml +++ b/docs/isupipe.yaml @@ -11,7 +11,7 @@ paths: parameters: [] get: summary: "" - operationid: get-tag + operationId: get-tag responses: "200": $ref: "#/components/responses/GetTag" @@ -73,6 +73,17 @@ paths: description: ユーザ登録 requestBody: $ref: "#/components/requestBodies/PostUser" + /user/me: + get: + summary: + operationId: get-user-me + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/User" "/users/{username}": parameters: - schema: @@ -177,7 +188,7 @@ paths: responses: "200": $ref: "#/components/responses/GetLivestream" - operationId: "get-livestream-:livestreamid" + operationId: "get-livestream-_livestreamid" description: ライブストリーム視聴画面の情報取得 "/livestream/{livestreamid}/moderate": parameters: @@ -218,7 +229,7 @@ paths: get: summary: Your GET endpoint tags: [] - operationId: "get-livestream-:livestreamid-livecomment" + operationId: "get-livestream-_livestreamid-livecomment" description: 当該ライブストリームのライブコメント取得 responses: "200": @@ -303,7 +314,7 @@ paths: get: summary: Your GET endpoint tags: [] - operationId: "get-livestream-:livestreamid-reaction" + operationId: "get-livestream-_livestreamid-reaction" description: 当該ライブストリームのリアクション取得 responses: "200": @@ -358,7 +369,9 @@ paths: responses: "200": $ref: "#/components/responses/GetLivestreamStatistics" - operationId: "get-livestream-:livestreamid-statistics" + "404": + description: Not Found + operationId: "get-livestream-_livestreamid-statistics" description: ライブストリームの統計情報取得 /livestream/reservation: post: @@ -707,12 +720,16 @@ components: end_at: 0 responses: GetTag: + description: Example response content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/Tag" + type: object + properties: + tags: + type: array + items: + $ref: "#/components/schemas/Tag" GetUser: description: Example response content: @@ -744,7 +761,7 @@ components: content: application/json: schema: - $ref: "#/components/schemas/LivestreamView" + $ref: "#/components/schemas/Livestream" GetLivestreamStatistics: description: Example response content: diff --git a/frontend/.eslintrc.yml b/frontend/.eslintrc.yml new file mode 100644 index 000000000..47b7118fd --- /dev/null +++ b/frontend/.eslintrc.yml @@ -0,0 +1,42 @@ +env: + browser: true + es6: true +extends: + - 'eslint:recommended' + - 'plugin:react/recommended' + - 'plugin:@typescript-eslint/eslint-recommended' + - 'plugin:import/recommended' + - 'plugin:import/errors' + - 'plugin:import/warnings' +globals: + Atomics: readonly + SharedArrayBuffer: readonly +parser: '@typescript-eslint/parser' +parserOptions: + ecmaFeatures: + jsx: true + ecmaVersion: 2018 + sourceType: module +plugins: + - react + - '@typescript-eslint' + - 'react-hooks' +rules: + import/order: # https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/order.md + - 'warn' + - alphabetize: { order: asc, caseInsensitive: true } + newlines-between: never + pathGroups: + - { pattern: '~/**', group: 'parent' } + - { pattern: 'react', group: 'parent', position: 'before' } + import/no-unresolved: 0 + 'no-unused-vars': 'off' + '@typescript-eslint/no-unused-vars': ['error'] + '@typescript-eslint/explicit-module-boundary-types': ['error'] + 'react/jsx-curly-brace-presence': 'error' + 'react-hooks/rules-of-hooks': 'error' + 'arrow-body-style': ['error', 'as-needed'] + 'dot-notation': 'error' +settings: + react: + version: 'detect' diff --git a/frontend/.gitattributes b/frontend/.gitattributes new file mode 100644 index 000000000..8964ebdbc --- /dev/null +++ b/frontend/.gitattributes @@ -0,0 +1,4 @@ +.yarn/* linguist-generated=true +yarn.lock linguist-generated=true +src/api/apiClient.ts linguist-generated=true +src/api/types.ts linguist-generated=true diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..6ba436721 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +/.yarn/cache +/.yarn/install-state.gz diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 000000000..882759c9e --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,3 @@ +.yarn +node_modules +/dist/ diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 000000000..544138be4 --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 000000000..b8de2bdd0 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": true + }, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "files.insertFinalNewline": true +} diff --git a/frontend/.yarnrc.yml b/frontend/.yarnrc.yml new file mode 100644 index 000000000..3186f3f07 --- /dev/null +++ b/frontend/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..b0b9dc02d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + ISUCON13 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..d571fd8ad --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,64 @@ +{ + "name": "vite-project", + "private": true, + "version": "0.0.0", + "packageManager": "yarn@3.2.2", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint --ext .jsx,.js,.tsx,.ts src/", + "format": "prettier --write --ignore-unknown .", + "format:ci": "prettier --check --ignore-unknown .", + "test": "vitest --config ./vitest.config.ts", + "test:ci": "yarn test run", + "generate-api-client": "ts-node -P scripts/openapi/tsconfig.build.json scripts/openapi/generate-api-client.ts" + }, + "dependencies": { + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@hookform/resolvers": "^3.3.1", + "@mui/joy": "^5.0.0-beta.11", + "@react-stately/toast": "^3.0.0-beta.1", + "d3-format": "^3.1.0", + "date-fns": "^2.30.0", + "dayjs": "^1.11.9", + "emoji-mart": "^5.5.2", + "events": "^3.3.0", + "focus-visible": "^5.2.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.46.1", + "react-hooks-global-state": "^2.1.0", + "react-icons": "^4.11.0", + "react-router-dom": "^6.15.0", + "swr": "^2.2.2", + "zod": "^3.22.2" + }, + "devDependencies": { + "@himenon/openapi-typescript-code-generator": "^0.27.1", + "@types/d3-format": "^3.0.1", + "@types/react": "^18.2.21", + "@types/react-dom": "^18.2.7", + "@types/react-router-dom": "^5.3.3", + "@typescript-eslint/eslint-plugin": "^6.7.0", + "@typescript-eslint/parser": "^6.7.0", + "@vitejs/plugin-react": "^4.0.4", + "cross-env": "^7.0.3", + "eslint": "^8.49.0", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "prettier": "^3.0.3", + "ts-node": "^10.9.1", + "typescript": "^5.2.2", + "vite": "^4.4.9", + "vite-plugin-pages": "^0.31.0", + "vitest": "^0.34.4" + }, + "readme": "ERROR: No README data found!", + "_id": "vite-project@0.0.0" +} diff --git a/frontend/scripts/openapi/generate-api-client.ts b/frontend/scripts/openapi/generate-api-client.ts new file mode 100644 index 000000000..8d13a34f2 --- /dev/null +++ b/frontend/scripts/openapi/generate-api-client.ts @@ -0,0 +1,37 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { CodeGenerator } from '@himenon/openapi-typescript-code-generator'; +import * as Templates from '@himenon/openapi-typescript-code-generator/templates'; + +const main = () => { + const codeGenerator = new CodeGenerator( + path.join(__dirname, '../../../docs/isupipe.yaml'), + ); + + const apiClientGeneratorTemplate = { + generator: Templates.ClassApiClient.generator, + option: {}, + }; + + const typeDefCode = codeGenerator.generateTypeDefinition(); + const apiClientCode = codeGenerator.generateCode([ + { + generator: () => [ + `import { Schemas, RequestBodies, Responses } from "./types";`, + ], + }, + codeGenerator.getAdditionalTypeDefinitionCustomCodeGenerator(), + apiClientGeneratorTemplate, + ]); + + fs.writeFileSync(__dirname + '/../../src/api/types.ts', typeDefCode, { + encoding: 'utf-8', + }); + fs.writeFileSync(__dirname + '/../../src/api/apiClient.ts', apiClientCode, { + encoding: 'utf-8', + }); + + console.log('Generate API Client'); +}; + +main(); diff --git a/frontend/scripts/openapi/package.json b/frontend/scripts/openapi/package.json new file mode 100644 index 000000000..09b5c1ff2 --- /dev/null +++ b/frontend/scripts/openapi/package.json @@ -0,0 +1,6 @@ +{ + "name": "openapi", + "private": true, + "version": "0.0.0", + "packageManager": "yarn@3.2.2" +} diff --git a/frontend/scripts/openapi/tsconfig.build.json b/frontend/scripts/openapi/tsconfig.build.json new file mode 100644 index 000000000..91c532b8a --- /dev/null +++ b/frontend/scripts/openapi/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "types": ["node"], + "declaration": false, + "noEmit": false + }, + "include": ["./*"] +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 000000000..f616b2572 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,44 @@ +import { CssVarsProvider } from '@mui/joy/styles'; +import React, { Suspense } from 'react'; +import { useRoutes } from 'react-router-dom'; +import { SWRConfig } from 'swr'; +import { HTTPError } from './api/client'; +import { Layout } from './components/layout/Layout'; +import { LoginModal } from './components/layout/loginmodal'; +import routes from '~react-pages'; +import 'focus-visible/dist/focus-visible'; + +export function App(): React.ReactElement { + const routeContent = useRoutes(routes); + const [isOpenReLoginModal, setIsOpenReLoginModal] = React.useState(false); + + return ( + + { + if (error instanceof HTTPError) { + switch (error.response.status) { + case 401: + case 403: + setIsOpenReLoginModal(true); + break; + } + } + }, + }} + > + + setIsOpenReLoginModal(false)} + /> + Loading...

}>{routeContent}
+
+
+
+ ); +} diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts new file mode 100644 index 000000000..a9a005923 --- /dev/null +++ b/frontend/src/api/apiClient.ts @@ -0,0 +1,741 @@ +// +// Generated by @himenon/openapi-typescript-code-generator v0.27.1 +// +// OpenApi : 3.1.0 +// +// + +import { Schemas, RequestBodies, Responses } from './types'; +export type Response$get$tag$Status$200 = Responses.GetTag.Content; +export type RequestBody$post$login = RequestBodies.Login.Content; +export interface Response$get$users$Status$200 { + 'application/json': Schemas.User[]; +} +export type RequestBody$post$user = RequestBodies.PostUser.Content; +export interface Response$post$user$Status$201 { + 'application/json': Schemas.User; +} +export interface Response$get$user$me$Status$200 { + 'application/json': Schemas.User; +} +export interface Parameter$get$users$username { + username: string; + /** セッションID */ + SESSIONID?: string; +} +export type Response$get$users$username$Status$200 = Responses.GetUser.Content; +export interface Parameter$get$theme { + /** セッションID */ + SESSIONID?: string; +} +export type Response$get$theme$Status$200 = Responses.GetUserTheme.Content; +export interface Parameter$get$users$statistics { + username: string; + /** セッションID */ + SESSIONID?: string; +} +export type Response$get$users$statistics$Status$200 = + Responses.GetUserStatistics.Content; +export interface Parameter$get$livestream { + /** 検索に使用するタグの名前 */ + tag?: string; +} +export type Response$get$livestream$Status$200 = + Responses.GetLivestreams.Content; +export interface Parameter$get$livestream$_livestreamid { + livestreamid: string; +} +export type Response$get$livestream$_livestreamid$Status$200 = + Responses.GetLivestream.Content; +export interface Parameter$post$livestream$livestreamid$moderate { + livestreamid: string; +} +export type RequestBody$post$livestream$livestreamid$moderate = + RequestBodies.PostLivestreamModerate.Content; +export interface Response$post$livestream$livestreamid$moderate$Status$201 { + 'application/json': { + word_id?: number; + }; +} +export interface Parameter$get$livestream$_livestreamid$livecomment { + livestreamid: string; +} +export interface Response$get$livestream$_livestreamid$livecomment$Status$200 { + 'application/json': Schemas.Livecomment[]; +} +export interface Parameter$post$livestream$livestreamid$livecomment { + livestreamid: string; + /** application/json */ + 'Content-Type'?: string; +} +export type RequestBody$post$livestream$livestreamid$livecomment = + RequestBodies.PostLivecomment.Content; +export interface Response$post$livestream$livestreamid$livecomment$Status$201 { + 'application/json': Schemas.Livecomment; +} +export interface Parameter$post$livestream$livestreamid$enter { + livestreamid: string; +} +export interface Parameter$delete$livestream$livestreamid$enter { + livestreamid: string; +} +export interface Parameter$get$livestream$_livestreamid$reaction { + livestreamid: string; +} +export interface Response$get$livestream$_livestreamid$reaction$Status$200 { + 'application/json': Schemas.Reaction[]; +} +export interface Parameter$post$livestream$livestreamid$reaction { + livestreamid: string; + /** application/json */ + 'Content-Type'?: string; +} +export type RequestBody$post$livestream$livestreamid$reaction = + RequestBodies.PostReaction.Content; +export interface Response$post$livestream$livestreamid$reaction$Status$201 { + 'application/json': Schemas.Reaction; +} +export interface Parameter$get$livestream$_livestreamid$statistics { + livestreamid: string; +} +export type Response$get$livestream$_livestreamid$statistics$Status$200 = + Responses.GetLivestreamStatistics.Content; +export type RequestBody$post$livestream$reservation = + RequestBodies.ReserveLivestream.Content; +export interface Response$post$livestream$reservation$Status$201 { + 'application/json': Schemas.Livestream; +} +export interface Parameter$get$livecomment$livecommentid$reports { + livestreamid: string; +} +export interface Response$get$livecomment$livecommentid$reports$Status$200 { + 'application/json': Schemas.LivecommentReport[]; +} +export interface Parameter$post$livecomment$livecommentid$report { + livecommentid: string; + livestreamid: string; +} +export interface Response$post$livecomment$livecommentid$report$Status$201 { + 'application/json': Schemas.LivecommentReport; +} +export type ResponseContentType$get$tag = keyof Response$get$tag$Status$200; +export type RequestContentType$post$login = keyof RequestBody$post$login; +export interface Params$post$login { + requestBody: RequestBody$post$login['application/json']; +} +export type ResponseContentType$get$users = keyof Response$get$users$Status$200; +export type RequestContentType$post$user = keyof RequestBody$post$user; +export type ResponseContentType$post$user = keyof Response$post$user$Status$201; +export interface Params$post$user { + requestBody: RequestBody$post$user['application/json']; +} +export type ResponseContentType$get$user$me = + keyof Response$get$user$me$Status$200; +export type ResponseContentType$get$users$username = + keyof Response$get$users$username$Status$200; +export interface Params$get$users$username { + parameter: Parameter$get$users$username; +} +export type ResponseContentType$get$theme = keyof Response$get$theme$Status$200; +export interface Params$get$theme { + parameter: Parameter$get$theme; +} +export type ResponseContentType$get$users$statistics = + keyof Response$get$users$statistics$Status$200; +export interface Params$get$users$statistics { + parameter: Parameter$get$users$statistics; +} +export type ResponseContentType$get$livestream = + keyof Response$get$livestream$Status$200; +export interface Params$get$livestream { + parameter: Parameter$get$livestream; +} +export type ResponseContentType$get$livestream$_livestreamid = + keyof Response$get$livestream$_livestreamid$Status$200; +export interface Params$get$livestream$_livestreamid { + parameter: Parameter$get$livestream$_livestreamid; +} +export type RequestContentType$post$livestream$livestreamid$moderate = + keyof RequestBody$post$livestream$livestreamid$moderate; +export type ResponseContentType$post$livestream$livestreamid$moderate = + keyof Response$post$livestream$livestreamid$moderate$Status$201; +export interface Params$post$livestream$livestreamid$moderate { + parameter: Parameter$post$livestream$livestreamid$moderate; + requestBody: RequestBody$post$livestream$livestreamid$moderate['application/json']; +} +export type ResponseContentType$get$livestream$_livestreamid$livecomment = + keyof Response$get$livestream$_livestreamid$livecomment$Status$200; +export interface Params$get$livestream$_livestreamid$livecomment { + parameter: Parameter$get$livestream$_livestreamid$livecomment; +} +export type RequestContentType$post$livestream$livestreamid$livecomment = + keyof RequestBody$post$livestream$livestreamid$livecomment; +export type ResponseContentType$post$livestream$livestreamid$livecomment = + keyof Response$post$livestream$livestreamid$livecomment$Status$201; +export interface Params$post$livestream$livestreamid$livecomment { + parameter: Parameter$post$livestream$livestreamid$livecomment; + requestBody: RequestBody$post$livestream$livestreamid$livecomment['application/json']; +} +export interface Params$post$livestream$livestreamid$enter { + parameter: Parameter$post$livestream$livestreamid$enter; +} +export interface Params$delete$livestream$livestreamid$enter { + parameter: Parameter$delete$livestream$livestreamid$enter; +} +export type ResponseContentType$get$livestream$_livestreamid$reaction = + keyof Response$get$livestream$_livestreamid$reaction$Status$200; +export interface Params$get$livestream$_livestreamid$reaction { + parameter: Parameter$get$livestream$_livestreamid$reaction; +} +export type RequestContentType$post$livestream$livestreamid$reaction = + keyof RequestBody$post$livestream$livestreamid$reaction; +export type ResponseContentType$post$livestream$livestreamid$reaction = + keyof Response$post$livestream$livestreamid$reaction$Status$201; +export interface Params$post$livestream$livestreamid$reaction { + parameter: Parameter$post$livestream$livestreamid$reaction; + requestBody: RequestBody$post$livestream$livestreamid$reaction['application/json']; +} +export type ResponseContentType$get$livestream$_livestreamid$statistics = + keyof Response$get$livestream$_livestreamid$statistics$Status$200; +export interface Params$get$livestream$_livestreamid$statistics { + parameter: Parameter$get$livestream$_livestreamid$statistics; +} +export type RequestContentType$post$livestream$reservation = + keyof RequestBody$post$livestream$reservation; +export type ResponseContentType$post$livestream$reservation = + keyof Response$post$livestream$reservation$Status$201; +export interface Params$post$livestream$reservation { + requestBody: RequestBody$post$livestream$reservation['application/json']; +} +export type ResponseContentType$get$livecomment$livecommentid$reports = + keyof Response$get$livecomment$livecommentid$reports$Status$200; +export interface Params$get$livecomment$livecommentid$reports { + parameter: Parameter$get$livecomment$livecommentid$reports; +} +export type ResponseContentType$post$livecomment$livecommentid$report = + keyof Response$post$livecomment$livecommentid$report$Status$201; +export interface Params$post$livecomment$livecommentid$report { + parameter: Parameter$post$livecomment$livecommentid$report; +} +export type HttpMethod = + | 'GET' + | 'PUT' + | 'POST' + | 'DELETE' + | 'OPTIONS' + | 'HEAD' + | 'PATCH' + | 'TRACE'; +export interface ObjectLike { + [key: string]: any; +} +export interface QueryParameter { + value: any; + style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject'; + explode: boolean; +} +export interface QueryParameters { + [key: string]: QueryParameter; +} +export type SuccessResponses = + | Response$get$tag$Status$200 + | Response$get$users$Status$200 + | Response$post$user$Status$201 + | Response$get$user$me$Status$200 + | Response$get$users$username$Status$200 + | Response$get$theme$Status$200 + | Response$get$users$statistics$Status$200 + | Response$get$livestream$Status$200 + | Response$get$livestream$_livestreamid$Status$200 + | Response$post$livestream$livestreamid$moderate$Status$201 + | Response$get$livestream$_livestreamid$livecomment$Status$200 + | Response$post$livestream$livestreamid$livecomment$Status$201 + | Response$get$livestream$_livestreamid$reaction$Status$200 + | Response$post$livestream$livestreamid$reaction$Status$201 + | Response$get$livestream$_livestreamid$statistics$Status$200 + | Response$post$livestream$reservation$Status$201 + | Response$get$livecomment$livecommentid$reports$Status$200 + | Response$post$livecomment$livecommentid$report$Status$201; +export namespace ErrorResponse { + export type get$tag = void; + export type post$login = void; + export type get$users = void; + export type post$user = void; + export type get$user$me = void; + export type get$users$username = void; + export type get$theme = void; + export type get$users$statistics = void; + export type get$livestream = void; + export type get$livestream$_livestreamid = void; + export type post$livestream$livestreamid$moderate = void; + export type get$livestream$_livestreamid$livecomment = void; + export type post$livestream$livestreamid$livecomment = void; + export type post$livestream$livestreamid$enter = void; + export type delete$livestream$livestreamid$enter = void; + export type get$livestream$_livestreamid$reaction = void; + export type post$livestream$livestreamid$reaction = void; + export type get$livestream$_livestreamid$statistics = void; + export type post$livestream$reservation = void; + export type get$livecomment$livecommentid$reports = void; + export type post$livecomment$livecommentid$report = void; +} +export interface Encoding { + readonly contentType?: string; + headers?: Record; + readonly style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject'; + readonly explode?: boolean; + readonly allowReserved?: boolean; +} +export interface RequestArgs { + readonly httpMethod: HttpMethod; + readonly url: string; + headers: ObjectLike | any; + requestBody?: ObjectLike | any; + requestBodyEncoding?: Record; + queryParameters?: QueryParameters | undefined; +} +export interface ApiClient { + request: ( + requestArgs: RequestArgs, + options?: RequestOption, + ) => Promise; +} +export class Client { + private baseUrl: string; + constructor( + private apiClient: ApiClient, + baseUrl: string, + ) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + /** サービスで提供されているタグの一覧取得 */ + public async get$tag( + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/tag`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** ログイン */ + public async post$login( + params: Params$post$login, + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/login`; + const headers = { + 'Content-Type': 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'POST', + url, + headers, + requestBody: params.requestBody, + }, + option, + ); + } + public async get$users( + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/user`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** + * Create New User + * ユーザ登録 + */ + public async post$user( + params: Params$post$user, + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/user`; + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'POST', + url, + headers, + requestBody: params.requestBody, + }, + option, + ); + } + public async get$user$me( + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/user/me`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** ユーザプロフィール取得 */ + public async get$users$username( + params: Params$get$users$username, + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/users/${params.parameter.username}`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** 配信者のテーマ取得 */ + public async get$theme( + params: Params$get$theme, + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/theme`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** ユーザの配信に関する統計情報取得 */ + public async get$users$statistics( + params: Params$get$users$statistics, + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/users/${params.parameter.username}/statistics`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** + * Your GET endpoint + * ライブストリームの情報取得エンドポイント + */ + public async get$livestream( + params: Params$get$livestream, + option?: RequestOption, + ): Promise { + const url = this.baseUrl + `/livestream`; + const headers = { + Accept: 'application/json', + }; + const queryParameters: QueryParameters = { + tag: { value: params.parameter.tag, explode: false }, + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + queryParameters: queryParameters, + }, + option, + ); + } + /** + * Your GET endpoint + * ライブストリーム視聴画面の情報取得 + */ + public async get$livestream$_livestreamid( + params: Params$get$livestream$_livestreamid, + option?: RequestOption, + ): Promise< + Response$get$livestream$_livestreamid$Status$200['application/json'] + > { + const url = this.baseUrl + `/livestream/${params.parameter.livestreamid}`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** 配信者がNGワードを登録するエンドポイント */ + public async post$livestream$livestreamid$moderate( + params: Params$post$livestream$livestreamid$moderate, + option?: RequestOption, + ): Promise< + Response$post$livestream$livestreamid$moderate$Status$201['application/json'] + > { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/moderate`; + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'POST', + url, + headers, + requestBody: params.requestBody, + }, + option, + ); + } + /** + * Your GET endpoint + * 当該ライブストリームのライブコメント取得 + */ + public async get$livestream$_livestreamid$livecomment( + params: Params$get$livestream$_livestreamid$livecomment, + option?: RequestOption, + ): Promise< + Response$get$livestream$_livestreamid$livecomment$Status$200['application/json'] + > { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/livecomment`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** ライブストリームに対するライブコメント投稿 */ + public async post$livestream$livestreamid$livecomment( + params: Params$post$livestream$livestreamid$livecomment, + option?: RequestOption, + ): Promise< + Response$post$livestream$livestreamid$livecomment$Status$201['application/json'] + > { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/livecomment`; + const headers = { + 'Content-Type': params.parameter['Content-Type'], + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'POST', + url, + headers, + requestBody: params.requestBody, + }, + option, + ); + } + /** 配信の視聴開始 */ + public async post$livestream$livestreamid$enter( + params: Params$post$livestream$livestreamid$enter, + option?: RequestOption, + ): Promise { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/enter`; + const headers = {}; + return this.apiClient.request( + { + httpMethod: 'POST', + url, + headers, + }, + option, + ); + } + /** 配信の視聴終了 */ + public async delete$livestream$livestreamid$enter( + params: Params$delete$livestream$livestreamid$enter, + option?: RequestOption, + ): Promise { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/enter`; + const headers = {}; + return this.apiClient.request( + { + httpMethod: 'DELETE', + url, + headers, + }, + option, + ); + } + /** + * Your GET endpoint + * 当該ライブストリームのリアクション取得 + */ + public async get$livestream$_livestreamid$reaction( + params: Params$get$livestream$_livestreamid$reaction, + option?: RequestOption, + ): Promise< + Response$get$livestream$_livestreamid$reaction$Status$200['application/json'] + > { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/reaction`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + /** リアクション投稿 */ + public async post$livestream$livestreamid$reaction( + params: Params$post$livestream$livestreamid$reaction, + option?: RequestOption, + ): Promise< + Response$post$livestream$livestreamid$reaction$Status$201['application/json'] + > { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/reaction`; + const headers = { + 'Content-Type': params.parameter['Content-Type'], + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'POST', + url, + headers, + requestBody: params.requestBody, + }, + option, + ); + } + /** + * Your GET endpoint + * ライブストリームの統計情報取得 + */ + public async get$livestream$_livestreamid$statistics( + params: Params$get$livestream$_livestreamid$statistics, + option?: RequestOption, + ): Promise< + Response$get$livestream$_livestreamid$statistics$Status$200['application/json'] + > { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/statistics`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + public async post$livestream$reservation( + params: Params$post$livestream$reservation, + option?: RequestOption, + ): Promise< + Response$post$livestream$reservation$Status$201['application/json'] + > { + const url = this.baseUrl + `/livestream/reservation`; + const headers = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'POST', + url, + headers, + requestBody: params.requestBody, + }, + option, + ); + } + public async get$livecomment$livecommentid$reports( + params: Params$get$livecomment$livecommentid$reports, + option?: RequestOption, + ): Promise< + Response$get$livecomment$livecommentid$reports$Status$200['application/json'] + > { + const url = + this.baseUrl + `/livestream/${params.parameter.livestreamid}/report`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'GET', + url, + headers, + }, + option, + ); + } + public async post$livecomment$livecommentid$report( + params: Params$post$livecomment$livecommentid$report, + option?: RequestOption, + ): Promise< + Response$post$livecomment$livecommentid$report$Status$201['application/json'] + > { + const url = + this.baseUrl + + `/livestream/${params.parameter.livestreamid}/livecomment/${params.parameter.livecommentid}/report`; + const headers = { + Accept: 'application/json', + }; + return this.apiClient.request( + { + httpMethod: 'POST', + url, + headers, + }, + option, + ); + } +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 000000000..827d148fc --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,77 @@ +import { + ApiClient as ApiClientGenerated, + Client, + QueryParameters, + RequestArgs, +} from '~/api/apiClient'; + +export class HTTPError extends Error { + constructor(public response: Response) { + super(`HTTP Error: ${response.status} ${response.statusText}`); + + if ((Error as any).captureStackTrace) { + (Error as any).captureStackTrace(this, this.constructor); + } + + Object.setPrototypeOf(this, new.target.prototype); + } +} + +export interface RequestOption { + timeout?: number; +} + +export function decodeParams( + params: QueryParameters | undefined, +): string | undefined { + if (!params) { + return; + } + + const param = new URLSearchParams(); + for (const key in params) { + const value = params[key].value; + if (value === undefined || value === null) { + continue; // ignore undefined/null field + } + param.set(key, value); + } + return param.toString(); +} + +const apiClientImpl: ApiClientGenerated = { + request: async (requestArgs: RequestArgs): Promise => { + const query = decodeParams(requestArgs.queryParameters); + const origin = apiOrigin(window.location.origin); + const requestUrl = `${origin}${ + query ? requestArgs.url + '?' + query : requestArgs.url + }`; + const response = await fetch(requestUrl, { + body: JSON.stringify(requestArgs.requestBody), + headers: { + ...requestArgs.headers, + }, + method: requestArgs.httpMethod, + }); + + if (!response.ok) { + throw new HTTPError(response); + } + + if (response.headers.get('Content-Type')?.includes('application/json')) { + return await response.json(); + } else { + return response.text(); + } + }, +}; + +export function apiOrigin(origin: string): string { + if (origin.includes('localhost')) { + return origin; + } + return origin; +} + +export const apiClient = new Client(apiClientImpl, '/api/'); +export type ApiClient = typeof apiClient; diff --git a/frontend/src/api/hooks.tsx b/frontend/src/api/hooks.tsx new file mode 100644 index 000000000..c1883bb92 --- /dev/null +++ b/frontend/src/api/hooks.tsx @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import useSWR, { type SWRConfiguration } from 'swr'; +import { Parameter$get$livestream } from './apiClient'; +import { HTTPError, apiClient } from './client'; + +export function useUserMe(config?: SWRConfiguration) { + return useSWR( + `/user/me`, + async () => { + try { + return await apiClient.get$user$me({}); + } catch (e) { + if (e instanceof HTTPError) { + switch (e.response.status) { + case 403: + return null; + case 401: + return null; + } + } + throw e; + } + }, + config, + ); +} + +export function useLiveStreams(params: Parameter$get$livestream) { + return useSWR(`/livestream?${encodeParam(params)}`, () => + apiClient.get$livestream({ + parameter: params, + }), + ); +} + +export function useLiveStream(id: string | null) { + return useSWR(id && `/livestream/${id}/`, () => + apiClient.get$livestream$_livestreamid({ + parameter: { + livestreamid: id ?? '', + }, + }), + ); +} + +export function useLiveStreamComment(id: string | null) { + return useSWR(id && `/livestream/${id}/livecomment`, () => + apiClient.get$livestream$_livestreamid$livecomment({ + parameter: { + livestreamid: id ?? '', + }, + }), + ); +} + +export function useLiveStreamReaction(id: string | null) { + return useSWR(id && `/livestream/${id}/reaction`, () => + apiClient.get$livestream$_livestreamid$reaction({ + parameter: { + livestreamid: id ?? '', + }, + }), + ); +} + +export function useTags() { + return useSWR('/tags', () => apiClient.get$tag()); +} + +function encodeParam(params: Object): string { + const p = Object.entries(params); + p.sort(([key1], [key2]) => key1.localeCompare(key2)); + return p.map(([key, value]) => `${key}=${value}`).join('&'); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 000000000..cf6f220b0 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,178 @@ +// +// Generated by @himenon/openapi-typescript-code-generator v0.27.1 +// +// OpenApi : 3.1.0 +// +// + +export namespace Schemas { + export type Theme = any; + export type Tag = any; + export type Reaction = any; + export interface User { + /** Unique identifier for the given user. */ + id: number; + name: string; + display_name: string; + description: string; + created_at?: number; + updated_at?: number; + is_popular: boolean; + theme: Schemas.Theme; + } + export interface Livestream { + id?: number; + owner?: Schemas.User; + tags?: Schemas.Tag[]; + title?: string; + description?: string; + playlist_url?: string; + thumbnail_url?: string; + start_at?: number; + end_at?: number; + created_at?: number; + updated_at?: number; + } + /** 上位チャットの投稿 */ + export interface Livecomment { + id?: number; + user?: Schemas.User; + livestream?: Schemas.Livestream; + comment?: string; + tip?: number; + created_at?: number; + updated_at?: number; + } + export interface LivestreamStatistics { + most_tip_ranking?: { + tip_rank?: number; + total_tip?: number; + }[]; + most_posted_reaction_ranking?: { + reaction_rank?: number; + total_reaction?: number; + emoji_name?: string; + }[]; + } + export interface UserStatistics { + tip_rank_by_livestream?: { + [key: string]: string; + }; + } + export interface LivecommentReport { + id?: number; + reporter?: Schemas.User; + livecomment?: Schemas.Livecomment; + created_at?: number; + updated_at?: number; + } +} +export namespace Responses { + /** Example response */ + export namespace GetTag { + export interface Content { + 'application/json': { + tags?: Schemas.Tag[]; + }; + } + } + /** Example response */ + export namespace GetUser { + export interface Content { + 'application/json': Schemas.User; + } + } + /** Example response */ + export namespace GetUserTheme { + export interface Content { + 'application/json': Schemas.Theme; + } + } + /** Example response */ + export namespace GetUserStatistics { + export interface Content { + 'application/json': Schemas.UserStatistics; + } + } + /** Example response */ + export namespace GetLivestreams { + export interface Content { + 'application/json': Schemas.Livestream[]; + } + } + /** Example response */ + export namespace GetLivestream { + export interface Content { + 'application/json': Schemas.Livestream; + } + } + /** Example response */ + export namespace GetLivestreamStatistics { + export interface Content { + 'application/json': Schemas.LivestreamStatistics; + } + } + /** Example response */ + export namespace GetLivecomments { + export interface Content { + 'application/json': Schemas.Livecomment[]; + } + } +} +export namespace RequestBodies { + export namespace PostLivestreamModerate { + export interface Content { + 'application/json': { + ng_word?: string; + }; + } + } + export namespace PostUser { + export interface Content { + 'application/json': { + name?: string; + display_name?: string; + description?: string; + password?: string; + theme?: { + dark_mode?: boolean; + }; + }; + } + } + export namespace Login { + export interface Content { + 'application/json': { + username?: string; + password?: string; + }; + } + } + export namespace PostReaction { + export interface Content { + 'application/json': { + emoji_name?: string; + }; + } + } + export namespace PostLivecomment { + export interface Content { + 'application/json': { + comment?: string; + tip?: number; + }; + } + } + export namespace ReserveLivestream { + export interface Content { + 'application/json': { + tags?: number[]; + title?: string; + description?: string; + collaborators?: number[]; + start_at?: number; + end_at?: number; + }; + } + } +} diff --git a/frontend/src/components/console/newlive.tsx b/frontend/src/components/console/newlive.tsx new file mode 100644 index 000000000..3b510821e --- /dev/null +++ b/frontend/src/components/console/newlive.tsx @@ -0,0 +1,54 @@ +import Button from '@mui/joy/Button'; +import DialogContent from '@mui/joy/DialogContent'; +import DialogTitle from '@mui/joy/DialogTitle'; +import FormControl from '@mui/joy/FormControl'; +import FormLabel from '@mui/joy/FormLabel'; +import Input from '@mui/joy/Input'; +import Modal from '@mui/joy/Modal'; +import ModalDialog from '@mui/joy/ModalDialog'; +import Stack from '@mui/joy/Stack'; +import Textarea from '@mui/joy/Textarea'; +import React from 'react'; + +export interface NewLiveDialogProps { + isOpen: boolean; + onClose: () => void; +} +export function NewLiveDialog(props: NewLiveDialogProps): React.ReactElement { + return ( + <> + + + 予約配信の作成 + 配信内容を入力してください。 +
) => { + event.preventDefault(); + props.onClose(); + }} + > + + + タイトル + + + + 説明文 +