diff options
245 files changed, 8531 insertions, 7448 deletions
diff --git a/FrontEnd/.editorconfig b/FrontEnd/.editorconfig index 779719e0..7747f7a7 100644 --- a/FrontEnd/.editorconfig +++ b/FrontEnd/.editorconfig @@ -1,14 +1,16 @@ -root = true
-end_of_line = lf
-
-[*.ts]
-tab_width = 2
-
-[*.tsx]
-tab_width = 2
-
-[*.css]
-tab_width = 2
-
-[*.sass]
-tab_width = 2
+root = true +end_of_line = lf +indent_style = space + +[*.ts] +indent_size = 2 + +[*.tsx] +indent_size = 2 + +[*.css] +indent_size = 2 + +[*.sass] +indent_size = 2 + diff --git a/FrontEnd/.eslintignore b/FrontEnd/.eslintignore index 6fc7bee6..19d1b61c 100644 --- a/FrontEnd/.eslintignore +++ b/FrontEnd/.eslintignore @@ -1 +1,2 @@ dist
+src/migrating
diff --git a/FrontEnd/.eslintrc.cjs b/FrontEnd/.eslintrc.js index a9cd8e03..6fcccd3e 100644 --- a/FrontEnd/.eslintrc.cjs +++ b/FrontEnd/.eslintrc.js @@ -13,7 +13,7 @@ module.exports = { plugins: ["@typescript-eslint", "prettier", "react", "react-hooks"],
parser: "@typescript-eslint/parser",
parserOptions: {
- project: true,
+ project: ["./tsconfig.json", "tools/tsconfig.json"],
tsconfigRootDir: __dirname
},
settings: {
diff --git a/FrontEnd/cspell.json b/FrontEnd/cspell.json index a2e150c9..1d9c118a 100644 --- a/FrontEnd/cspell.json +++ b/FrontEnd/cspell.json @@ -7,6 +7,7 @@ "words": [ "languagedetector", "popperjs", - "signalr" + "signalr", + "webp" ] } diff --git a/FrontEnd/package.json b/FrontEnd/package.json index 7f438f0e..4a94d319 100644 --- a/FrontEnd/package.json +++ b/FrontEnd/package.json @@ -1,11 +1,9 @@ { "name": "timeline", "version": "0.4.0", - "private": true, - "type": "module", "source": "index.html", "scripts": { - "start": "parcel --port 5678", + "start": "parcel --port 5678 --hmr-port 6789", "build": "tsc && parcel build", "type-check": "tsc", "lint": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx", @@ -14,57 +12,56 @@ "check:fix": "pnpm run type-check && pnpm run lint:fix" }, "dependencies": { - "@microsoft/signalr": "^7.0.7", - "@popperjs/core": "^2.11.8", - "axios": "^1.4.0", - "bootstrap": "^5.3.0", - "bootstrap-icons": "^1.10.5", + "@floating-ui/react-dom": "^2.0.2", + "@microsoft/signalr": "^7.0.11", + "axios": "^1.5.0", + "bootstrap": "^5.3.2", + "bootstrap-icons": "^1.11.1", "classnames": "^2.3.2", "color": "^4.2.3", - "core-js": "^3.31.1", - "i18next": "^23.2.8", + "core-js": "^3.32.2", + "i18next": "^23.5.1", "i18next-browser-languagedetector": "^7.1.0", - "js-base64": "^3.7.5", "lodash": "^4.17.21", - "marked": "^5.1.1", + "marked": "^9.0.3", "moment": "^2.29.4", "react": "^18.2.0", - "react-color": "^2.19.3", "react-dom": "^18.2.0", - "react-i18next": "^13.0.1", + "react-i18next": "^13.2.2", "react-popper": "^2.3.0", "react-responsive": "^9.0.2", - "react-router-dom": "^6.14.1", + "react-router-dom": "^6.16.0", "react-transition-group": "^4.4.5", - "regenerator-runtime": "^0.13.11", "rxjs": "^7.8.1", "xregexp": "^5.1.1" }, "devDependencies": { "@parcel/packager-raw-url": "2.9.3", "@parcel/transformer-webmanifest": "2.9.3", - "@tsconfig/vite-react": "^2.0.0", - "@types/color": "^3.0.3", - "@types/lodash": "^4.14.195", - "@types/marked": "^5.0.0", - "@types/node": "^20.4.1", + "@tsconfig/node20": "^20.1.2", + "@types/color": "^3.0.4", + "@types/lodash": "^4.14.198", + "@types/marked": "^5.0.1", + "@types/node": "^20.6.3", "@types/parcel-env": "^0.0.1", - "@types/react": "^18.2.14", + "@types/react": "^18.2.22", "@types/react-color": "^3.0.6", - "@types/react-dom": "^18.2.6", + "@types/react-dom": "^18.2.7", "@types/react-responsive": "^8.0.5", "@types/react-transition-group": "^4.4.6", - "@typescript-eslint/eslint-plugin": "^5.61.0", - "@typescript-eslint/parser": "^5.61.0", - "buffer": "^6.0.0", - "eslint": "^8.44.0", - "eslint-config-prettier": "^8.8.0", - "eslint-plugin-prettier": "^4.2.1", - "eslint-plugin-react": "^7.32.2", + "@typescript-eslint/eslint-plugin": "^6.7.2", + "@typescript-eslint/parser": "^6.7.2", + "buffer": "^6.0.3", + "eslint": "^8.49.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", - "parcel": "latest", - "prettier": "^3.0.0", + "parcel": "^2.9.3", + "prettier": "^3.0.3", "process": "^0.11.10", - "typescript": "^5.1.6" + "ts-node": "^10.9.1", + "typescript": "^5.2.2", + "typescript-language-server": "^3.3.2" } }
\ No newline at end of file diff --git a/FrontEnd/pnpm-lock.yaml b/FrontEnd/pnpm-lock.yaml index d4a2962c..7084e82c 100644 --- a/FrontEnd/pnpm-lock.yaml +++ b/FrontEnd/pnpm-lock.yaml @@ -5,21 +5,21 @@ settings: excludeLinksFromLockfile: false dependencies: + '@floating-ui/react-dom': + specifier: ^2.0.2 + version: 2.0.2(react-dom@18.2.0)(react@18.2.0) '@microsoft/signalr': - specifier: ^7.0.7 - version: 7.0.7 - '@popperjs/core': - specifier: ^2.11.8 - version: 2.11.8 + specifier: ^7.0.11 + version: 7.0.11 axios: - specifier: ^1.4.0 - version: 1.4.0 + specifier: ^1.5.0 + version: 1.5.0 bootstrap: - specifier: ^5.3.0 - version: 5.3.0(@popperjs/core@2.11.8) + specifier: ^5.3.2 + version: 5.3.2(@popperjs/core@2.11.8) bootstrap-icons: - specifier: ^1.10.5 - version: 1.10.5 + specifier: ^1.11.1 + version: 1.11.1 classnames: specifier: ^2.3.2 version: 2.3.2 @@ -27,38 +27,32 @@ dependencies: specifier: ^4.2.3 version: 4.2.3 core-js: - specifier: ^3.31.1 - version: 3.31.1 + specifier: ^3.32.2 + version: 3.32.2 i18next: - specifier: ^23.2.8 - version: 23.2.8 + specifier: ^23.5.1 + version: 23.5.1 i18next-browser-languagedetector: specifier: ^7.1.0 version: 7.1.0 - js-base64: - specifier: ^3.7.5 - version: 3.7.5 lodash: specifier: ^4.17.21 version: 4.17.21 marked: - specifier: ^5.1.1 - version: 5.1.1 + specifier: ^9.0.3 + version: 9.0.3 moment: specifier: ^2.29.4 version: 2.29.4 react: specifier: ^18.2.0 version: 18.2.0 - react-color: - specifier: ^2.19.3 - version: 2.19.3(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) react-i18next: - specifier: ^13.0.1 - version: 13.0.1(i18next@23.2.8)(react-dom@18.2.0)(react@18.2.0) + specifier: ^13.2.2 + version: 13.2.2(i18next@23.5.1)(react-dom@18.2.0)(react@18.2.0) react-popper: specifier: ^2.3.0 version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0)(react@18.2.0) @@ -66,14 +60,11 @@ dependencies: specifier: ^9.0.2 version: 9.0.2(react@18.2.0) react-router-dom: - specifier: ^6.14.1 - version: 6.14.1(react-dom@18.2.0)(react@18.2.0) + specifier: ^6.16.0 + version: 6.16.0(react-dom@18.2.0)(react@18.2.0) react-transition-group: specifier: ^4.4.5 version: 4.4.5(react-dom@18.2.0)(react@18.2.0) - regenerator-runtime: - specifier: ^0.13.11 - version: 0.13.11 rxjs: specifier: ^7.8.1 version: 7.8.1 @@ -88,33 +79,33 @@ devDependencies: '@parcel/transformer-webmanifest': specifier: 2.9.3 version: 2.9.3(@parcel/core@2.9.3) - '@tsconfig/vite-react': - specifier: ^2.0.0 - version: 2.0.0 + '@tsconfig/node20': + specifier: ^20.1.2 + version: 20.1.2 '@types/color': - specifier: ^3.0.3 - version: 3.0.3 + specifier: ^3.0.4 + version: 3.0.4 '@types/lodash': - specifier: ^4.14.195 - version: 4.14.195 + specifier: ^4.14.198 + version: 4.14.198 '@types/marked': - specifier: ^5.0.0 - version: 5.0.0 + specifier: ^5.0.1 + version: 5.0.1 '@types/node': - specifier: ^20.4.1 - version: 20.4.1 + specifier: ^20.6.3 + version: 20.6.3 '@types/parcel-env': specifier: ^0.0.1 version: 0.0.1 '@types/react': - specifier: ^18.2.14 - version: 18.2.14 + specifier: ^18.2.22 + version: 18.2.22 '@types/react-color': specifier: ^3.0.6 version: 3.0.6 '@types/react-dom': - specifier: ^18.2.6 - version: 18.2.6 + specifier: ^18.2.7 + version: 18.2.7 '@types/react-responsive': specifier: ^8.0.5 version: 8.0.5 @@ -122,41 +113,47 @@ devDependencies: specifier: ^4.4.6 version: 4.4.6 '@typescript-eslint/eslint-plugin': - specifier: ^5.61.0 - version: 5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@5.1.6) + specifier: ^6.7.2 + version: 6.7.2(@typescript-eslint/parser@6.7.2)(eslint@8.49.0)(typescript@5.2.2) '@typescript-eslint/parser': - specifier: ^5.61.0 - version: 5.61.0(eslint@8.44.0)(typescript@5.1.6) + specifier: ^6.7.2 + version: 6.7.2(eslint@8.49.0)(typescript@5.2.2) buffer: - specifier: ^6.0.0 + specifier: ^6.0.3 version: 6.0.3 eslint: - specifier: ^8.44.0 - version: 8.44.0 + specifier: ^8.49.0 + version: 8.49.0 eslint-config-prettier: - specifier: ^8.8.0 - version: 8.8.0(eslint@8.44.0) + specifier: ^9.0.0 + version: 9.0.0(eslint@8.49.0) eslint-plugin-prettier: - specifier: ^4.2.1 - version: 4.2.1(eslint-config-prettier@8.8.0)(eslint@8.44.0)(prettier@3.0.0) + specifier: ^5.0.0 + version: 5.0.0(eslint-config-prettier@9.0.0)(eslint@8.49.0)(prettier@3.0.3) eslint-plugin-react: - specifier: ^7.32.2 - version: 7.32.2(eslint@8.44.0) + specifier: ^7.33.2 + version: 7.33.2(eslint@8.49.0) eslint-plugin-react-hooks: specifier: ^4.6.0 - version: 4.6.0(eslint@8.44.0) + version: 4.6.0(eslint@8.49.0) parcel: - specifier: latest - version: 2.9.3 + specifier: ^2.9.3 + version: 2.9.3(typescript@5.2.2) prettier: - specifier: ^3.0.0 - version: 3.0.0 + specifier: ^3.0.3 + version: 3.0.3 process: specifier: ^0.11.10 version: 0.11.10 + ts-node: + specifier: ^10.9.1 + version: 10.9.1(@types/node@20.6.3)(typescript@5.2.2) typescript: - specifier: ^5.1.6 - version: 5.1.6 + specifier: ^5.2.2 + version: 5.2.2 + typescript-language-server: + specifier: ^3.3.2 + version: 3.3.2 packages: @@ -165,65 +162,73 @@ packages: engines: {node: '>=0.10.0'} dev: true - /@babel/code-frame@7.22.5: - resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==} + /@babel/code-frame@7.22.13: + resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/highlight': 7.22.5 + '@babel/highlight': 7.22.20 + chalk: 2.4.2 dev: true - /@babel/helper-validator-identifier@7.22.5: - resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} + /@babel/helper-validator-identifier@7.22.20: + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} dev: true - /@babel/highlight@7.22.5: - resolution: {integrity: sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==} + /@babel/highlight@7.22.20: + resolution: {integrity: sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-validator-identifier': 7.22.5 + '@babel/helper-validator-identifier': 7.22.20 chalk: 2.4.2 js-tokens: 4.0.0 dev: true - /@babel/runtime-corejs3@7.22.6: - resolution: {integrity: sha512-M+37LLIRBTEVjktoJjbw4KVhupF0U/3PYUCbBwgAd9k17hoKhRu1n935QiG7Tuxv0LJOMrb2vuKEeYUlv0iyiw==} + /@babel/runtime-corejs3@7.22.15: + resolution: {integrity: sha512-SAj8oKi8UogVi6eXQXKNPu8qZ78Yzy7zawrlTr0M+IuW/g8Qe9gVDhGcF9h1S69OyACpYoLxEzpjs1M15sI5wQ==} engines: {node: '>=6.9.0'} dependencies: - core-js-pure: 3.31.1 - regenerator-runtime: 0.13.11 + core-js-pure: 3.32.2 + regenerator-runtime: 0.14.0 dev: false - /@babel/runtime@7.22.6: - resolution: {integrity: sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==} + /@babel/runtime@7.22.15: + resolution: {integrity: sha512-T0O+aa+4w0u06iNmapipJXMV4HoUir03hpx3/YqXXhu9xim3w+dVphjFWl1OH8NbZHw5Lbm9k45drDkgq2VNNA==} engines: {node: '>=6.9.0'} dependencies: - regenerator-runtime: 0.13.11 + regenerator-runtime: 0.14.0 dev: false - /@eslint-community/eslint-utils@4.4.0(eslint@8.44.0): + /@cspotcode/source-map-support@0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.49.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - eslint: 8.44.0 - eslint-visitor-keys: 3.4.1 + eslint: 8.49.0 + eslint-visitor-keys: 3.4.3 dev: true - /@eslint-community/regexpp@4.5.1: - resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==} + /@eslint-community/regexpp@4.8.1: + resolution: {integrity: sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} dev: true - /@eslint/eslintrc@2.1.0: - resolution: {integrity: sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==} + /@eslint/eslintrc@2.1.2: + resolution: {integrity: sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 debug: 4.3.4 - espree: 9.6.0 - globals: 13.20.0 + espree: 9.6.1 + globals: 13.21.0 ignore: 5.2.4 import-fresh: 3.3.0 js-yaml: 4.1.0 @@ -233,13 +238,41 @@ packages: - supports-color dev: true - /@eslint/js@8.44.0: - resolution: {integrity: sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==} + /@eslint/js@8.49.0: + resolution: {integrity: sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@humanwhocodes/config-array@0.11.10: - resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} + /@floating-ui/core@1.5.0: + resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==} + dependencies: + '@floating-ui/utils': 0.1.4 + dev: false + + /@floating-ui/dom@1.5.3: + resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==} + dependencies: + '@floating-ui/core': 1.5.0 + '@floating-ui/utils': 0.1.4 + dev: false + + /@floating-ui/react-dom@2.0.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.5.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@floating-ui/utils@0.1.4: + resolution: {integrity: sha512-qprfWkn82Iw821mcKofJ5Pk9wgioHicxcQMxx+5zt5GSKoqdWvgG5AxVmpmUUjzTLPVSH5auBrhI93Deayn/DA==} + dev: false + + /@humanwhocodes/config-array@0.11.11: + resolution: {integrity: sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==} engines: {node: '>=10.10.0'} dependencies: '@humanwhocodes/object-schema': 1.2.1 @@ -258,13 +291,21 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true - /@icons/material@0.2.4(react@18.2.0): - resolution: {integrity: sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==} - peerDependencies: - react: '*' + /@jridgewell/resolve-uri@3.1.1: + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + dev: true + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + dev: true + + /@jridgewell/trace-mapping@0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} dependencies: - react: 18.2.0 - dev: false + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true /@lezer/common@0.15.12: resolution: {integrity: sha512-edfwCxNLnzq5pBA/yaIhwJ3U3Kz8VAUOTRg0hhxaizaI1N+qxV7EXDv/kLCkLeq2RzSFvxexlaj5Mzfn2kY0Ig==} @@ -324,13 +365,13 @@ packages: dev: true optional: true - /@microsoft/signalr@7.0.7: - resolution: {integrity: sha512-RMWZLTxnjWPSaS9PYZxXAttql2JDM/+IsSJk0nACFhpLjnSw8UWfvUxOv/QjZSqLxhuksXxzBJ/91xUP6Y7Nvg==} + /@microsoft/signalr@7.0.11: + resolution: {integrity: sha512-//6ipnYKhHf2MJgM+MQSlgB5L/pcYeZ+v4w6YAr4epRM1iSDQ6WjUkCVX2ZMxcY06XGlLzggs3Z9ZIcL9ws9KQ==} dependencies: abort-controller: 3.0.0 eventsource: 2.0.2 fetch-cookie: 2.1.0 - node-fetch: 2.6.12 + node-fetch: 2.7.0 ws: 7.5.9 transitivePeerDependencies: - bufferutil @@ -459,7 +500,7 @@ packages: - '@parcel/core' dev: true - /@parcel/config-default@2.9.3(@parcel/core@2.9.3): + /@parcel/config-default@2.9.3(@parcel/core@2.9.3)(typescript@5.2.2): resolution: {integrity: sha512-tqN5tF7QnVABDZAu76co5E6N8mA9n8bxiWdK4xYyINYFIEHgX172oRTqXTnhEMjlMrdmASxvnGlbaPBaVnrCTw==} peerDependencies: '@parcel/core': ^2.9.3 @@ -469,7 +510,7 @@ packages: '@parcel/core': 2.9.3 '@parcel/namer-default': 2.9.3(@parcel/core@2.9.3) '@parcel/optimizer-css': 2.9.3(@parcel/core@2.9.3) - '@parcel/optimizer-htmlnano': 2.9.3(@parcel/core@2.9.3) + '@parcel/optimizer-htmlnano': 2.9.3(@parcel/core@2.9.3)(typescript@5.2.2) '@parcel/optimizer-image': 2.9.3(@parcel/core@2.9.3) '@parcel/optimizer-svgo': 2.9.3(@parcel/core@2.9.3) '@parcel/optimizer-swc': 2.9.3(@parcel/core@2.9.3) @@ -503,6 +544,7 @@ packages: - relateurl - srcset - terser + - typescript - uncss dev: true @@ -527,12 +569,12 @@ packages: '@parcel/workers': 2.9.3(@parcel/core@2.9.3) abortcontroller-polyfill: 1.7.5 base-x: 3.0.9 - browserslist: 4.21.9 + browserslist: 4.21.10 clone: 2.1.2 dotenv: 7.0.0 dotenv-expand: 5.1.0 json5: 2.2.3 - msgpackr: 1.9.5 + msgpackr: 1.9.9 nullthrows: 1.1.1 semver: 7.5.4 dev: true @@ -565,7 +607,7 @@ packages: '@parcel/fs-search': 2.9.3 '@parcel/types': 2.9.3(@parcel/core@2.9.3) '@parcel/utils': 2.9.3 - '@parcel/watcher': 2.2.0 + '@parcel/watcher': 2.3.0 '@parcel/workers': 2.9.3(@parcel/core@2.9.3) dev: true @@ -631,19 +673,19 @@ packages: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 - browserslist: 4.21.9 - lightningcss: 1.21.5 + browserslist: 4.21.10 + lightningcss: 1.22.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' dev: true - /@parcel/optimizer-htmlnano@2.9.3(@parcel/core@2.9.3): + /@parcel/optimizer-htmlnano@2.9.3(@parcel/core@2.9.3)(typescript@5.2.2): resolution: {integrity: sha512-9g/KBck3c6DokmJfvJ5zpHFBiCSolaGrcsTGx8C3YPdCTVTI9P1TDCwUxvAr4LjpcIRSa82wlLCI+nF6sSgxKA==} engines: {node: '>= 12.0.0', parcel: ^2.9.3} dependencies: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) - htmlnano: 2.0.4(svgo@2.8.0) + htmlnano: 2.0.4(svgo@2.8.0)(typescript@5.2.2) nullthrows: 1.1.1 posthtml: 0.16.6 svgo: 2.8.0 @@ -655,6 +697,7 @@ packages: - relateurl - srcset - terser + - typescript - uncss dev: true @@ -691,7 +734,7 @@ packages: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 - '@swc/core': 1.3.68 + '@swc/core': 1.3.86 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' @@ -750,7 +793,7 @@ packages: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 - globals: 13.20.0 + globals: 13.21.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' @@ -910,7 +953,7 @@ packages: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 - browserslist: 4.21.9 + browserslist: 4.21.10 json5: 2.2.3 nullthrows: 1.1.1 semver: 7.5.4 @@ -926,8 +969,8 @@ packages: '@parcel/plugin': 2.9.3(@parcel/core@2.9.3) '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 - browserslist: 4.21.9 - lightningcss: 1.21.5 + browserslist: 4.21.10 + lightningcss: 1.22.0 nullthrows: 1.1.1 transitivePeerDependencies: - '@parcel/core' @@ -975,8 +1018,8 @@ packages: '@parcel/source-map': 2.1.1 '@parcel/utils': 2.9.3 '@parcel/workers': 2.9.3(@parcel/core@2.9.3) - '@swc/helpers': 0.5.1 - browserslist: 4.21.9 + '@swc/helpers': 0.5.2 + browserslist: 4.21.10 nullthrows: 1.1.1 regenerator-runtime: 0.13.11 semver: 7.5.4 @@ -1099,8 +1142,8 @@ packages: nullthrows: 1.1.1 dev: true - /@parcel/watcher-android-arm64@2.2.0: - resolution: {integrity: sha512-nU2wh00CTQT9rr1TIKTjdQ9lAGYpmz6XuKw0nAwAN+S2A5YiD55BK1u+E5WMCT8YOIDe/n6gaj4o/Bi9294SSQ==} + /@parcel/watcher-android-arm64@2.3.0: + resolution: {integrity: sha512-f4o9eA3dgk0XRT3XhB0UWpWpLnKgrh1IwNJKJ7UJek7eTYccQ8LR7XUWFKqw6aEq5KUNlCcGvSzKqSX/vtWVVA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] @@ -1108,8 +1151,8 @@ packages: dev: true optional: true - /@parcel/watcher-darwin-arm64@2.2.0: - resolution: {integrity: sha512-cJl0UZDcodciy3TDMomoK/Huxpjlkkim3SyMgWzjovHGOZKNce9guLz2dzuFwfObBFCjfznbFMIvAZ5syXotYw==} + /@parcel/watcher-darwin-arm64@2.3.0: + resolution: {integrity: sha512-mKY+oijI4ahBMc/GygVGvEdOq0L4DxhYgwQqYAz/7yPzuGi79oXrZG52WdpGA1wLBPrYb0T8uBaGFo7I6rvSKw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] @@ -1117,8 +1160,8 @@ packages: dev: true optional: true - /@parcel/watcher-darwin-x64@2.2.0: - resolution: {integrity: sha512-QI77zxaGrCV1StKcoRYfsUfmUmvPMPfQrubkBBy5XujV2fwaLgZivQOTQMBgp5K2+E19u1ufpspKXAPqSzpbyg==} + /@parcel/watcher-darwin-x64@2.3.0: + resolution: {integrity: sha512-20oBj8LcEOnLE3mgpy6zuOq8AplPu9NcSSSfyVKgfOhNAc4eF4ob3ldj0xWjGGbOF7Dcy1Tvm6ytvgdjlfUeow==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] @@ -1126,8 +1169,17 @@ packages: dev: true optional: true - /@parcel/watcher-linux-arm-glibc@2.2.0: - resolution: {integrity: sha512-I2GPBcAXazPzabCmfsa3HRRW+MGlqxYd8g8RIueJU+a4o5nyNZDz0CR1cu0INT0QSQXEZV7w6UE8Hz9CF8u3Pg==} + /@parcel/watcher-freebsd-x64@2.3.0: + resolution: {integrity: sha512-7LftKlaHunueAEiojhCn+Ef2CTXWsLgTl4hq0pkhkTBFI3ssj2bJXmH2L67mKpiAD5dz66JYk4zS66qzdnIOgw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@parcel/watcher-linux-arm-glibc@2.3.0: + resolution: {integrity: sha512-1apPw5cD2xBv1XIHPUlq0cO6iAaEUQ3BcY0ysSyD9Kuyw4MoWm1DV+W9mneWI+1g6OeP6dhikiFE6BlU+AToTQ==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] @@ -1135,8 +1187,8 @@ packages: dev: true optional: true - /@parcel/watcher-linux-arm64-glibc@2.2.0: - resolution: {integrity: sha512-St5mlfp+2lS9AmgixUqfwJa/DwVmTCJxC1HcOubUTz6YFOKIlkHCeUa1Bxi4E/tR/HSez8+heXHL8HQkJ4Bd8g==} + /@parcel/watcher-linux-arm64-glibc@2.3.0: + resolution: {integrity: sha512-mQ0gBSQEiq1k/MMkgcSB0Ic47UORZBmWoAWlMrTW6nbAGoLZP+h7AtUM7H3oDu34TBFFvjy4JCGP43JlylkTQA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] @@ -1144,8 +1196,8 @@ packages: dev: true optional: true - /@parcel/watcher-linux-arm64-musl@2.2.0: - resolution: {integrity: sha512-jS+qfhhoOBVWwMLP65MaG8xdInMK30pPW8wqTCg2AAuVJh5xepMbzkhHJ4zURqHiyY3EiIRuYu4ONJKCxt8iqA==} + /@parcel/watcher-linux-arm64-musl@2.3.0: + resolution: {integrity: sha512-LXZAExpepJew0Gp8ZkJ+xDZaTQjLHv48h0p0Vw2VMFQ8A+RKrAvpFuPVCVwKJCr5SE+zvaG+Etg56qXvTDIedw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] @@ -1153,8 +1205,8 @@ packages: dev: true optional: true - /@parcel/watcher-linux-x64-glibc@2.2.0: - resolution: {integrity: sha512-xJvJ7R2wJdi47WZBFS691RDOWvP1j/IAs3EXaWVhDI8FFITbWrWaln7KoNcR0Y3T+ZwimFY/cfb0PNht1q895g==} + /@parcel/watcher-linux-x64-glibc@2.3.0: + resolution: {integrity: sha512-P7Wo91lKSeSgMTtG7CnBS6WrA5otr1K7shhSjKHNePVmfBHDoAOHYRXgUmhiNfbcGk0uMCHVcdbfxtuiZCHVow==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] @@ -1162,8 +1214,8 @@ packages: dev: true optional: true - /@parcel/watcher-linux-x64-musl@2.2.0: - resolution: {integrity: sha512-D+NMpgr23a+RI5mu8ZPKWy7AqjBOkURFDgP5iIXXEf/K3hm0jJ3ogzi0Ed2237B/CdYREimCgXyeiAlE/FtwyA==} + /@parcel/watcher-linux-x64-musl@2.3.0: + resolution: {integrity: sha512-+kiRE1JIq8QdxzwoYY+wzBs9YbJ34guBweTK8nlzLKimn5EQ2b2FSC+tAOpq302BuIMjyuUGvBiUhEcLIGMQ5g==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] @@ -1171,8 +1223,8 @@ packages: dev: true optional: true - /@parcel/watcher-win32-arm64@2.2.0: - resolution: {integrity: sha512-z225cPn3aygJsyVUOWwfyW+fY0Tvk7N3XCOl66qUPFxpbuXeZuiuuJemmtm8vxyqa3Ur7peU/qJxrpC64aeI7Q==} + /@parcel/watcher-win32-arm64@2.3.0: + resolution: {integrity: sha512-35gXCnaz1AqIXpG42evcoP2+sNL62gZTMZne3IackM+6QlfMcJLy3DrjuL6Iks7Czpd3j4xRBzez3ADCj1l7Aw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] @@ -1180,8 +1232,17 @@ packages: dev: true optional: true - /@parcel/watcher-win32-x64@2.2.0: - resolution: {integrity: sha512-JqGW0RJ61BkKx+yYzIURt9s53P7xMVbv0uxYPzAXLBINGaFmkIKSuUPyBVfy8TMbvp93lvF4SPBNDzVRJfvgOw==} + /@parcel/watcher-win32-ia32@2.3.0: + resolution: {integrity: sha512-FJS/IBQHhRpZ6PiCjFt1UAcPr0YmCLHRbTc00IBTrelEjlmmgIVLeOx4MSXzx2HFEy5Jo5YdhGpxCuqCyDJ5ow==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@parcel/watcher-win32-x64@2.3.0: + resolution: {integrity: sha512-dLx+0XRdMnVI62kU3wbXvbIRhLck4aE28bIGKbRGS7BJNt54IIj9+c/Dkqb+7DJEbHUZAX1bwaoM8PqVlHJmCA==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] @@ -1189,8 +1250,8 @@ packages: dev: true optional: true - /@parcel/watcher@2.2.0: - resolution: {integrity: sha512-71S4TF+IMyAn24PK4KSkdKtqJDR3zRzb0HE3yXpacItqTM7XfF2f5q9NEGLEVl0dAaBAGfNwDCjH120y25F6Tg==} + /@parcel/watcher@2.3.0: + resolution: {integrity: sha512-pW7QaFiL11O0BphO+bq3MgqeX/INAk9jgBldVDYjlQPO4VddoZnF22TcF9onMhnLVHuNqBJeRf+Fj7eezi/+rQ==} engines: {node: '>= 10.0.0'} dependencies: detect-libc: 1.0.3 @@ -1198,16 +1259,18 @@ packages: micromatch: 4.0.5 node-addon-api: 7.0.0 optionalDependencies: - '@parcel/watcher-android-arm64': 2.2.0 - '@parcel/watcher-darwin-arm64': 2.2.0 - '@parcel/watcher-darwin-x64': 2.2.0 - '@parcel/watcher-linux-arm-glibc': 2.2.0 - '@parcel/watcher-linux-arm64-glibc': 2.2.0 - '@parcel/watcher-linux-arm64-musl': 2.2.0 - '@parcel/watcher-linux-x64-glibc': 2.2.0 - '@parcel/watcher-linux-x64-musl': 2.2.0 - '@parcel/watcher-win32-arm64': 2.2.0 - '@parcel/watcher-win32-x64': 2.2.0 + '@parcel/watcher-android-arm64': 2.3.0 + '@parcel/watcher-darwin-arm64': 2.3.0 + '@parcel/watcher-darwin-x64': 2.3.0 + '@parcel/watcher-freebsd-x64': 2.3.0 + '@parcel/watcher-linux-arm-glibc': 2.3.0 + '@parcel/watcher-linux-arm64-glibc': 2.3.0 + '@parcel/watcher-linux-arm64-musl': 2.3.0 + '@parcel/watcher-linux-x64-glibc': 2.3.0 + '@parcel/watcher-linux-x64-musl': 2.3.0 + '@parcel/watcher-win32-arm64': 2.3.0 + '@parcel/watcher-win32-ia32': 2.3.0 + '@parcel/watcher-win32-x64': 2.3.0 dev: true /@parcel/workers@2.9.3(@parcel/core@2.9.3): @@ -1225,17 +1288,29 @@ packages: nullthrows: 1.1.1 dev: true + /@pkgr/utils@2.4.2: + resolution: {integrity: sha512-POgTXhjrTfbTV63DiFXav4lBHiICLKKwDeaKn9Nphwj7WH6m0hMMCaJkMyRWjgtPFyRKRVoMXXjczsTQRDEhYw==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + dependencies: + cross-spawn: 7.0.3 + fast-glob: 3.3.1 + is-glob: 4.0.3 + open: 9.1.0 + picocolors: 1.0.0 + tslib: 2.6.2 + dev: true + /@popperjs/core@2.11.8: resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} dev: false - /@remix-run/router@1.7.1: - resolution: {integrity: sha512-bgVQM4ZJ2u2CM8k1ey70o1ePFXsEzYVZoWghh6WjM8p59jQ7HxzbHW4SbnWFG7V9ig9chLawQxDTZ3xzOF8MkQ==} - engines: {node: '>=14'} + /@remix-run/router@1.9.0: + resolution: {integrity: sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==} + engines: {node: '>=14.0.0'} dev: false - /@swc/core-darwin-arm64@1.3.68: - resolution: {integrity: sha512-Z5pNxeuP2NxpOHTzDQkJs0wAPLnTlglZnR3WjObijwvdwT/kw1Y5EPDKM/BVSIeG40SPMkDLBbI0aj0qyXzrBA==} + /@swc/core-darwin-arm64@1.3.86: + resolution: {integrity: sha512-hMvSDms0sJJHNtRa3Vhmr9StWN1vmikbf5VE0IZUYGnF1/JZTkXU1h6CdNUY4Hr6i7uCZjH6BEhxFHX1JtKV4w==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] @@ -1243,8 +1318,8 @@ packages: dev: true optional: true - /@swc/core-darwin-x64@1.3.68: - resolution: {integrity: sha512-ZHl42g6yXhfX4PzAQ0BNvBXpt/OcbAHfubWRN6eXELK3fiNnxL7QBW1if7iizlq6iA+Mj1pwHyyUit1pz0+fgA==} + /@swc/core-darwin-x64@1.3.86: + resolution: {integrity: sha512-Jro6HVH4uSOBM7tTDaQNKLNc8BJV7n+SO+Ft2HAZINyeKJS/8MfEYneG7Vmqg18gv00c6dz9AOCcyz+BR7BFkQ==} engines: {node: '>=10'} cpu: [x64] os: [darwin] @@ -1252,8 +1327,8 @@ packages: dev: true optional: true - /@swc/core-linux-arm-gnueabihf@1.3.68: - resolution: {integrity: sha512-Mk8f6KCOQ2CNAR4PtWajIjS6XKSSR7ZYDOCf1GXRxhS3qEyQH7V8elWvqWYqHcT4foO60NUmxA/NOM/dQrdO1A==} + /@swc/core-linux-arm-gnueabihf@1.3.86: + resolution: {integrity: sha512-wYB9m0pzXJVSzedXSl4JwS3gKtvcPinpe9MbkddezpqL7OjyDP6pHHW9qIucsfgCrtMtbPC2nqulXLPtAAyIjw==} engines: {node: '>=10'} cpu: [arm] os: [linux] @@ -1261,8 +1336,8 @@ packages: dev: true optional: true - /@swc/core-linux-arm64-gnu@1.3.68: - resolution: {integrity: sha512-RhBllggh9t9sIxaRgRcGrVaS7fDk6KsIqR6b9+dwU5OyDr4ZyHWw1ZaH/1/HAebuXYhNBjoNUiRtca6lKRIPgQ==} + /@swc/core-linux-arm64-gnu@1.3.86: + resolution: {integrity: sha512-fR44IyK5cdCaO8cC++IEH0Jn03tWnunJnjzA99LxlE5TRInSIOvFm+g5OSUQZDAvEXmQ38sd31LO2HOoDS1Edw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] @@ -1270,8 +1345,8 @@ packages: dev: true optional: true - /@swc/core-linux-arm64-musl@1.3.68: - resolution: {integrity: sha512-8K3zjU+tFgn6yGDEeD343gkKaHU9dhz77NiVkI1VzwRaT/Ag5pwl5eMQ1yStm8koNFzn3zq6rGjHfI5g2yI5Wg==} + /@swc/core-linux-arm64-musl@1.3.86: + resolution: {integrity: sha512-EUPfdbK4dUk/nkX3Vmv/47XH+DqHOa9JI0CTthvJ8/ZXei1MKDUsUc+tI1zMQX2uCuSkSWsEIEpCmA0tMwFhtw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] @@ -1279,8 +1354,8 @@ packages: dev: true optional: true - /@swc/core-linux-x64-gnu@1.3.68: - resolution: {integrity: sha512-4xAnvsBOyeTL0AB8GWlRKDM/hsysJ5jr5qvdKKI3rZfJgnnxl/xSX6TJKPsJ8gygfUJ3BmfCbmUmEyeDZ3YPvA==} + /@swc/core-linux-x64-gnu@1.3.86: + resolution: {integrity: sha512-snVZZWv8XgNVaKrTxtO3rUN+BbbB6I8Fqwe8zM/DWGJ096J13r89doQ48x5ZyO+bW4D48eZIWP5pdfSW7oBE3w==} engines: {node: '>=10'} cpu: [x64] os: [linux] @@ -1288,8 +1363,8 @@ packages: dev: true optional: true - /@swc/core-linux-x64-musl@1.3.68: - resolution: {integrity: sha512-RCpaBo1fcpy1EFdjF+I7N4lfzOaHXVV0iMw/ABM+0PD6tp3V/9pxsguaZyeAHyEiUlDA6PZ4TfXv5zfnXEgW4Q==} + /@swc/core-linux-x64-musl@1.3.86: + resolution: {integrity: sha512-PnnksUJymEJkdnbV2orOSOSB441UqsxYbJge9zbr5UTRXUfWO3eFRV0iTBegjTlOQGbW6yN+YRSDkenTbmCI6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] @@ -1297,8 +1372,8 @@ packages: dev: true optional: true - /@swc/core-win32-arm64-msvc@1.3.68: - resolution: {integrity: sha512-v2WZvXrSslYEpY1nqpItyamL4DyaJinmOkXvM8Bc1LLKU5rGuvmBdjUYg/5Y+o0AUynuiWubpgHNOkBWiCvfqw==} + /@swc/core-win32-arm64-msvc@1.3.86: + resolution: {integrity: sha512-XlGEGyHwLndm08VvgeAPGj40L+Hx575MQC+2fsyB1uSNUN+uf7fvke+wc7k50a92CaQe/8foLyIR5faayozEJA==} engines: {node: '>=10'} cpu: [arm64] os: [win32] @@ -1306,8 +1381,8 @@ packages: dev: true optional: true - /@swc/core-win32-ia32-msvc@1.3.68: - resolution: {integrity: sha512-HH5NJrIdzkJs+1xxprie0qSCMBeL9yeEhcC1yZTzYv8bwmabOUSdtKIqS55iYP/2hLWn9CTbvKPmLOIhCopW3Q==} + /@swc/core-win32-ia32-msvc@1.3.86: + resolution: {integrity: sha512-U1BhZa1x9yn+wZGTQmt1cYR79a0FzW/wL6Jas1Pn0bykKLxdRU4mCeZt2P+T3buLm8jr8LpPWiCrbvr658PzwA==} engines: {node: '>=10'} cpu: [ia32] os: [win32] @@ -1315,8 +1390,8 @@ packages: dev: true optional: true - /@swc/core-win32-x64-msvc@1.3.68: - resolution: {integrity: sha512-9HZVtLQUgK8r/yXQdwe0VBexbIcrY6+fBROhs7AAPWdewpaUeLkwQEJk6TbYr9CQuHw26FFGg6SjwAiqXF+kgQ==} + /@swc/core-win32-x64-msvc@1.3.86: + resolution: {integrity: sha512-wRoQUajqpE3wITHhZVj/6BPu/QwHriFHLHuJA+9y6PeGtUtTmntL42aBKXIFhfL767dYFtohyNg1uZ9eqbGyGQ==} engines: {node: '>=10'} cpu: [x64] os: [win32] @@ -1324,8 +1399,8 @@ packages: dev: true optional: true - /@swc/core@1.3.68: - resolution: {integrity: sha512-njGQuJO+Wy06dEayt70cf0c/KI3HGjm4iW9LLViVLBuYNzJ4SSdNfzejludzufu6im+dsDJ0i3QjgWhAIcVHMQ==} + /@swc/core@1.3.86: + resolution: {integrity: sha512-bEXUtm37bcmJ3q+geG7Zy4rJNUzpxalXQUrrqX1ZoGj3HRtzdeVZ0L/um3fG2j16qe61t8TX/OIZ2G6j6dkG/w==} engines: {node: '>=10'} requiresBuild: true peerDependencies: @@ -1333,23 +1408,29 @@ packages: peerDependenciesMeta: '@swc/helpers': optional: true + dependencies: + '@swc/types': 0.1.5 optionalDependencies: - '@swc/core-darwin-arm64': 1.3.68 - '@swc/core-darwin-x64': 1.3.68 - '@swc/core-linux-arm-gnueabihf': 1.3.68 - '@swc/core-linux-arm64-gnu': 1.3.68 - '@swc/core-linux-arm64-musl': 1.3.68 - '@swc/core-linux-x64-gnu': 1.3.68 - '@swc/core-linux-x64-musl': 1.3.68 - '@swc/core-win32-arm64-msvc': 1.3.68 - '@swc/core-win32-ia32-msvc': 1.3.68 - '@swc/core-win32-x64-msvc': 1.3.68 + '@swc/core-darwin-arm64': 1.3.86 + '@swc/core-darwin-x64': 1.3.86 + '@swc/core-linux-arm-gnueabihf': 1.3.86 + '@swc/core-linux-arm64-gnu': 1.3.86 + '@swc/core-linux-arm64-musl': 1.3.86 + '@swc/core-linux-x64-gnu': 1.3.86 + '@swc/core-linux-x64-musl': 1.3.86 + '@swc/core-win32-arm64-msvc': 1.3.86 + '@swc/core-win32-ia32-msvc': 1.3.86 + '@swc/core-win32-x64-msvc': 1.3.86 dev: true - /@swc/helpers@0.5.1: - resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==} + /@swc/helpers@0.5.2: + resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} dependencies: - tslib: 2.6.0 + tslib: 2.6.2 + dev: true + + /@swc/types@0.1.5: + resolution: {integrity: sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==} dev: true /@trysound/sax@0.2.0: @@ -1357,12 +1438,28 @@ packages: engines: {node: '>=10.13.0'} dev: true - /@tsconfig/vite-react@2.0.0: - resolution: {integrity: sha512-erT+k9yzjRYnqRn6Fmvz+Y8+AtE+/YE954frGGwwit2ifsoWzRzYaOTlGj9/z0xJyYiaKNnNiFhid312QdC4rw==} + /@tsconfig/node10@1.0.9: + resolution: {integrity: sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==} + dev: true + + /@tsconfig/node12@1.0.11: + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + dev: true + + /@tsconfig/node14@1.0.3: + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + dev: true + + /@tsconfig/node16@1.0.4: + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + dev: true + + /@tsconfig/node20@20.1.2: + resolution: {integrity: sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==} dev: true - /@types/color-convert@2.0.0: - resolution: {integrity: sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==} + /@types/color-convert@2.0.1: + resolution: {integrity: sha512-GwXanrvq/tBHJtudbl1lSy9Ybt7KS9+rA+YY3bcuIIM+d6jSHUr+5yjO83gtiRpuaPiBccwFjSnAK2qSrIPA7w==} dependencies: '@types/color-name': 1.1.1 dev: true @@ -1371,65 +1468,65 @@ packages: resolution: {integrity: sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==} dev: true - /@types/color@3.0.3: - resolution: {integrity: sha512-X//qzJ3d3Zj82J9sC/C18ZY5f43utPbAJ6PhYt/M7uG6etcF6MRpKdN880KBy43B0BMzSfeT96MzrsNjFI3GbA==} + /@types/color@3.0.4: + resolution: {integrity: sha512-OpisS4bqJJwbkkQRrMvURf3DOxBoAg9mysHYI7WgrWpSYHqHGKYBULHdz4ih77SILcLDo/zyHGFyfIl9yb8NZQ==} dependencies: - '@types/color-convert': 2.0.0 + '@types/color-convert': 2.0.1 dev: true - /@types/json-schema@7.0.12: - resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} + /@types/json-schema@7.0.13: + resolution: {integrity: sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==} dev: true - /@types/lodash@4.14.195: - resolution: {integrity: sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==} + /@types/lodash@4.14.198: + resolution: {integrity: sha512-trNJ/vtMZYMLhfN45uLq4ShQSw0/S7xCTLLVM+WM1rmFpba/VS42jVUgaO3w/NOLiWR/09lnYk0yMaA/atdIsg==} dev: true - /@types/marked@5.0.0: - resolution: {integrity: sha512-YcZe50jhltsCq7rc9MNZC/4QB/OnA2Pd6hrOSTOFajtabN+38slqgDDCeE/0F83SjkKBQcsZUj7VLWR0H5cKRA==} + /@types/marked@5.0.1: + resolution: {integrity: sha512-Y3pAUzHKh605fN6fvASsz5FDSWbZcs/65Q6xYRmnIP9ZIYz27T4IOmXfH9gWJV1dpi7f1e7z7nBGUTx/a0ptpA==} dev: true - /@types/node@20.4.1: - resolution: {integrity: sha512-JIzsAvJeA/5iY6Y/OxZbv1lUcc8dNSE77lb2gnBH+/PJ3lFR1Ccvgwl5JWnHAkNHcRsT0TbpVOsiMKZ1F/yyJg==} + /@types/node@20.6.3: + resolution: {integrity: sha512-HksnYH4Ljr4VQgEy2lTStbCKv/P590tmPe5HqOnv9Gprffgv5WXAY+Y5Gqniu0GGqeTCUdBnzC3QSrzPkBkAMA==} dev: true /@types/parcel-env@0.0.1: resolution: {integrity: sha512-8WmdiJ1uEBcW6AOWzQH7i0141ZXZr7B03YfTpguUDrTHXJHwYU9eEOckBRCZzYGrzb4pdoyBlaIMiTee04uqPQ==} dev: true - /@types/prop-types@15.7.5: - resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} + /@types/prop-types@15.7.6: + resolution: {integrity: sha512-RK/kBbYOQQHLYj9Z95eh7S6t7gq4Ojt/NT8HTk8bWVhA5DaF+5SMnxHKkP4gPNN3wAZkKP+VjAf0ebtYzf+fxg==} dev: true /@types/react-color@3.0.6: resolution: {integrity: sha512-OzPIO5AyRmLA7PlOyISlgabpYUa3En74LP8mTMa0veCA719SvYQov4WLMsHvCgXP+L+KI9yGhYnqZafVGG0P4w==} dependencies: - '@types/react': 18.2.14 + '@types/react': 18.2.22 '@types/reactcss': 1.2.6 dev: true - /@types/react-dom@18.2.6: - resolution: {integrity: sha512-2et4PDvg6PVCyS7fuTc4gPoksV58bW0RwSxWKcPRcHZf0PRUGq03TKcD/rUHe3azfV6/5/biUBJw+HhCQjaP0A==} + /@types/react-dom@18.2.7: + resolution: {integrity: sha512-GRaAEriuT4zp9N4p1i8BDBYmEyfo+xQ3yHjJU4eiK5NDa1RmUZG+unZABUTK4/Ox/M+GaHwb6Ow8rUITrtjszA==} dependencies: - '@types/react': 18.2.14 + '@types/react': 18.2.22 dev: true /@types/react-responsive@8.0.5: resolution: {integrity: sha512-k3gQJgI87oP5IrVZe//3LKJFnAeFaqqWmmtl5eoYL2H3HqFcIhUaE30kRK1CsW3DHdojZxcVj4ZNc2ClsEu2PA==} dependencies: - '@types/react': 18.2.14 + '@types/react': 18.2.22 dev: true /@types/react-transition-group@4.4.6: resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==} dependencies: - '@types/react': 18.2.14 + '@types/react': 18.2.22 dev: true - /@types/react@18.2.14: - resolution: {integrity: sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==} + /@types/react@18.2.22: + resolution: {integrity: sha512-60fLTOLqzarLED2O3UQImc/lsNRgG0jE/a1mPW9KjMemY0LMITWEsbS4VvZ4p6rorEHd5YKxxmMKSDK505GHpA==} dependencies: - '@types/prop-types': 15.7.5 + '@types/prop-types': 15.7.6 '@types/scheduler': 0.16.3 csstype: 3.1.2 dev: true @@ -1437,145 +1534,146 @@ packages: /@types/reactcss@1.2.6: resolution: {integrity: sha512-qaIzpCuXNWomGR1Xq8SCFTtF4v8V27Y6f+b9+bzHiv087MylI/nTCqqdChNeWS7tslgROmYB7yeiruWX7WnqNg==} dependencies: - '@types/react': 18.2.14 + '@types/react': 18.2.22 dev: true /@types/scheduler@0.16.3: resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} dev: true - /@types/semver@7.5.0: - resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} + /@types/semver@7.5.2: + resolution: {integrity: sha512-7aqorHYgdNO4DM36stTiGO3DvKoex9TQRwsJU6vMaFGyqpBA1MNZkz+PG3gaNUPpTAOYhT1WR7M1JyA3fbS9Cw==} dev: true - /@typescript-eslint/eslint-plugin@5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@5.1.6): - resolution: {integrity: sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/eslint-plugin@6.7.2(@typescript-eslint/parser@6.7.2)(eslint@8.49.0)(typescript@5.2.2): + resolution: {integrity: sha512-ooaHxlmSgZTM6CHYAFRlifqh1OAr3PAQEwi7lhYhaegbnXrnh7CDcHmc3+ihhbQC7H0i4JF0psI5ehzkF6Yl6Q==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - '@typescript-eslint/parser': ^5.0.0 - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@eslint-community/regexpp': 4.5.1 - '@typescript-eslint/parser': 5.61.0(eslint@8.44.0)(typescript@5.1.6) - '@typescript-eslint/scope-manager': 5.61.0 - '@typescript-eslint/type-utils': 5.61.0(eslint@8.44.0)(typescript@5.1.6) - '@typescript-eslint/utils': 5.61.0(eslint@8.44.0)(typescript@5.1.6) + '@eslint-community/regexpp': 4.8.1 + '@typescript-eslint/parser': 6.7.2(eslint@8.49.0)(typescript@5.2.2) + '@typescript-eslint/scope-manager': 6.7.2 + '@typescript-eslint/type-utils': 6.7.2(eslint@8.49.0)(typescript@5.2.2) + '@typescript-eslint/utils': 6.7.2(eslint@8.49.0)(typescript@5.2.2) + '@typescript-eslint/visitor-keys': 6.7.2 debug: 4.3.4 - eslint: 8.44.0 + eslint: 8.49.0 graphemer: 1.4.0 ignore: 5.2.4 - natural-compare-lite: 1.4.0 + natural-compare: 1.4.0 semver: 7.5.4 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + ts-api-utils: 1.0.3(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@5.61.0(eslint@8.44.0)(typescript@5.1.6): - resolution: {integrity: sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/parser@6.7.2(eslint@8.49.0)(typescript@5.2.2): + resolution: {integrity: sha512-KA3E4ox0ws+SPyxQf9iSI25R6b4Ne78ORhNHeVKrPQnoYsb9UhieoiRoJgrzgEeKGOXhcY1i8YtOeCHHTDa6Fw==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 5.61.0 - '@typescript-eslint/types': 5.61.0 - '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.1.6) + '@typescript-eslint/scope-manager': 6.7.2 + '@typescript-eslint/types': 6.7.2 + '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.2.2) + '@typescript-eslint/visitor-keys': 6.7.2 debug: 4.3.4 - eslint: 8.44.0 - typescript: 5.1.6 + eslint: 8.49.0 + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/scope-manager@5.61.0: - resolution: {integrity: sha512-W8VoMjoSg7f7nqAROEmTt6LoBpn81AegP7uKhhW5KzYlehs8VV0ZW0fIDVbcZRcaP3aPSW+JZFua+ysQN+m/Nw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/scope-manager@6.7.2: + resolution: {integrity: sha512-bgi6plgyZjEqapr7u2mhxGR6E8WCzKNUFWNh6fkpVe9+yzRZeYtDTbsIBzKbcxI+r1qVWt6VIoMSNZ4r2A+6Yw==} + engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 5.61.0 - '@typescript-eslint/visitor-keys': 5.61.0 + '@typescript-eslint/types': 6.7.2 + '@typescript-eslint/visitor-keys': 6.7.2 dev: true - /@typescript-eslint/type-utils@5.61.0(eslint@8.44.0)(typescript@5.1.6): - resolution: {integrity: sha512-kk8u//r+oVK2Aj3ph/26XdH0pbAkC2RiSjUYhKD+PExemG4XSjpGFeyZ/QM8lBOa7O8aGOU+/yEbMJgQv/DnCg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/type-utils@6.7.2(eslint@8.49.0)(typescript@5.2.2): + resolution: {integrity: sha512-36F4fOYIROYRl0qj95dYKx6kybddLtsbmPIYNK0OBeXv2j9L5nZ17j9jmfy+bIDHKQgn2EZX+cofsqi8NPATBQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: '*' + eslint: ^7.0.0 || ^8.0.0 typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.1.6) - '@typescript-eslint/utils': 5.61.0(eslint@8.44.0)(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.2.2) + '@typescript-eslint/utils': 6.7.2(eslint@8.49.0)(typescript@5.2.2) debug: 4.3.4 - eslint: 8.44.0 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + eslint: 8.49.0 + ts-api-utils: 1.0.3(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/types@5.61.0: - resolution: {integrity: sha512-ldyueo58KjngXpzloHUog/h9REmHl59G1b3a5Sng1GfBo14BkS3ZbMEb3693gnP1k//97lh7bKsp6/V/0v1veQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/types@6.7.2: + resolution: {integrity: sha512-flJYwMYgnUNDAN9/GAI3l8+wTmvTYdv64fcH8aoJK76Y+1FCZ08RtI5zDerM/FYT5DMkAc+19E4aLmd5KqdFyg==} + engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@5.61.0(typescript@5.1.6): - resolution: {integrity: sha512-Fud90PxONnnLZ36oR5ClJBLTLfU4pIWBmnvGwTbEa2cXIqj70AEDEmOmpkFComjBZ/037ueKrOdHuYmSFVD7Rw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/typescript-estree@6.7.2(typescript@5.2.2): + resolution: {integrity: sha512-kiJKVMLkoSciGyFU0TOY0fRxnp9qq1AzVOHNeN1+B9erKFCJ4Z8WdjAkKQPP+b1pWStGFqezMLltxO+308dJTQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' peerDependenciesMeta: typescript: optional: true dependencies: - '@typescript-eslint/types': 5.61.0 - '@typescript-eslint/visitor-keys': 5.61.0 + '@typescript-eslint/types': 6.7.2 + '@typescript-eslint/visitor-keys': 6.7.2 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + ts-api-utils: 1.0.3(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@5.61.0(eslint@8.44.0)(typescript@5.1.6): - resolution: {integrity: sha512-mV6O+6VgQmVE6+xzlA91xifndPW9ElFW8vbSF0xCT/czPXVhwDewKila1jOyRwa9AE19zKnrr7Cg5S3pJVrTWQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/utils@6.7.2(eslint@8.49.0)(typescript@5.2.2): + resolution: {integrity: sha512-ZCcBJug/TS6fXRTsoTkgnsvyWSiXwMNiPzBUani7hDidBdj1779qwM1FIAmpH4lvlOZNF3EScsxxuGifjpLSWQ==} + engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.44.0) - '@types/json-schema': 7.0.12 - '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 5.61.0 - '@typescript-eslint/types': 5.61.0 - '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.1.6) - eslint: 8.44.0 - eslint-scope: 5.1.1 + eslint: ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.49.0) + '@types/json-schema': 7.0.13 + '@types/semver': 7.5.2 + '@typescript-eslint/scope-manager': 6.7.2 + '@typescript-eslint/types': 6.7.2 + '@typescript-eslint/typescript-estree': 6.7.2(typescript@5.2.2) + eslint: 8.49.0 semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript dev: true - /@typescript-eslint/visitor-keys@5.61.0: - resolution: {integrity: sha512-50XQ5VdbWrX06mQXhy93WywSFZZGsv3EOjq+lqp6WC2t+j3mb6A9xYVdrRxafvK88vg9k9u+CT4l6D8PEatjKg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@typescript-eslint/visitor-keys@6.7.2: + resolution: {integrity: sha512-uVw9VIMFBUTz8rIeaUT3fFe8xIUx8r4ywAdlQv1ifH+6acn/XF8Y6rwJ7XNmkNMDrTW+7+vxFFPIF40nJCVsMQ==} + engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 5.61.0 - eslint-visitor-keys: 3.4.1 + '@typescript-eslint/types': 6.7.2 + eslint-visitor-keys: 3.4.3 dev: true /abort-controller@3.0.0: @@ -1597,6 +1695,11 @@ packages: acorn: 8.10.0 dev: true + /acorn-walk@8.2.0: + resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} + engines: {node: '>=0.4.0'} + dev: true + /acorn@8.10.0: resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} engines: {node: '>=0.4.0'} @@ -1631,6 +1734,10 @@ packages: color-convert: 2.0.1 dev: true + /arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + dev: true + /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true @@ -1642,13 +1749,13 @@ packages: is-array-buffer: 3.0.2 dev: true - /array-includes@3.1.6: - resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} + /array-includes@3.1.7: + resolution: {integrity: sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 get-intrinsic: 1.2.1 is-string: 1.0.7 dev: true @@ -1658,36 +1765,55 @@ packages: engines: {node: '>=8'} dev: true - /array.prototype.flat@1.3.1: - resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==} + /array.prototype.flat@1.3.2: + resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 es-shim-unscopables: 1.0.0 dev: true - /array.prototype.flatmap@1.3.1: - resolution: {integrity: sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==} + /array.prototype.flatmap@1.3.2: + resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 es-shim-unscopables: 1.0.0 dev: true - /array.prototype.tosorted@1.1.1: - resolution: {integrity: sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==} + /array.prototype.tosorted@1.1.2: + resolution: {integrity: sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 es-shim-unscopables: 1.0.0 get-intrinsic: 1.2.1 dev: true + /arraybuffer.prototype.slice@1.0.2: + resolution: {integrity: sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.0 + call-bind: 1.0.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 + get-intrinsic: 1.2.1 + is-array-buffer: 3.0.2 + is-shared-array-buffer: 1.0.2 + dev: true + + /asynciterator.prototype@1.0.0: + resolution: {integrity: sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==} + dependencies: + has-symbols: 1.0.3 + dev: true + /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false @@ -1697,10 +1823,10 @@ packages: engines: {node: '>= 0.4'} dev: true - /axios@1.4.0: - resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} + /axios@1.5.0: + resolution: {integrity: sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==} dependencies: - follow-redirects: 1.15.2 + follow-redirects: 1.15.3 form-data: 4.0.0 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -1721,22 +1847,34 @@ packages: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: true + /big-integer@1.6.51: + resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} + engines: {node: '>=0.6'} + dev: true + /boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} dev: true - /bootstrap-icons@1.10.5: - resolution: {integrity: sha512-oSX26F37V7QV7NCE53PPEL45d7EGXmBgHG3pDpZvcRaKVzWMqIRL9wcqJUyEha1esFtM3NJzvmxFXDxjJYD0jQ==} + /bootstrap-icons@1.11.1: + resolution: {integrity: sha512-F0DDp7nKUX+x/QtpfRZ+XHFya60ng9nfdpdS59vDDfs4Uhuxp7zym/QavMsu/xx51txkoM9eVmpE7D08N35blw==} dev: false - /bootstrap@5.3.0(@popperjs/core@2.11.8): - resolution: {integrity: sha512-UnBV3E3v4STVNQdms6jSGO2CvOkjUMdDAVR2V5N4uCMdaIkaQjbcEAMqRimDHIs4uqBYzDAKCQwCB+97tJgHQw==} + /bootstrap@5.3.2(@popperjs/core@2.11.8): + resolution: {integrity: sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==} peerDependencies: - '@popperjs/core': ^2.11.7 + '@popperjs/core': ^2.11.8 dependencies: '@popperjs/core': 2.11.8 dev: false + /bplist-parser@0.2.0: + resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} + engines: {node: '>= 5.10.0'} + dependencies: + big-integer: 1.6.51 + dev: true + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -1751,15 +1889,15 @@ packages: fill-range: 7.0.1 dev: true - /browserslist@4.21.9: - resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==} + /browserslist@4.21.10: + resolution: {integrity: sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001513 - electron-to-chromium: 1.4.454 + caniuse-lite: 1.0.30001538 + electron-to-chromium: 1.4.525 node-releases: 2.0.13 - update-browserslist-db: 1.0.11(browserslist@4.21.9) + update-browserslist-db: 1.0.11(browserslist@4.21.10) dev: true /buffer@6.0.3: @@ -1769,6 +1907,13 @@ packages: ieee754: 1.2.1 dev: true + /bundle-name@3.0.0: + resolution: {integrity: sha512-PKA4BeSvBpQKQ8iPOGCSiell+N8P+Tf1DlwqmYhpe2gAhKPHn8EYOxVT+ShuGmhg8lN8XiSlS80yiExKXrURlw==} + engines: {node: '>=12'} + dependencies: + run-applescript: 5.0.0 + dev: true + /call-bind@1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: @@ -1781,8 +1926,8 @@ packages: engines: {node: '>=6'} dev: true - /caniuse-lite@1.0.30001513: - resolution: {integrity: sha512-pnjGJo7SOOjAGytZZ203Em95MRM8Cr6jhCXNF/FAXTpCTRTECnqQWLpiTRqrFtdYcth8hf4WECUpkezuYsMVww==} + /caniuse-lite@1.0.30001538: + resolution: {integrity: sha512-HWJnhnID+0YMtGlzcp3T9drmBJUVDchPJ08tpUGFLs9CYlwWPH2uLgpHn8fND5pCgXVtnGS3H4QR9XLMHVNkHw==} dev: true /chalk@2.4.2: @@ -1866,24 +2011,34 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true - /core-js-pure@3.31.1: - resolution: {integrity: sha512-w+C62kvWti0EPs4KPMCMVv9DriHSXfQOCQ94bGGBiEW5rrbtt/Rz8n5Krhfw9cpFyzXBjf3DB3QnPdEzGDY4Fw==} + /core-js-pure@3.32.2: + resolution: {integrity: sha512-Y2rxThOuNywTjnX/PgA5vWM6CZ9QB9sz9oGeCixV8MqXZO70z/5SHzf9EeBrEBK0PN36DnEBBu9O/aGWzKuMZQ==} requiresBuild: true dev: false - /core-js@3.31.1: - resolution: {integrity: sha512-2sKLtfq1eFST7l7v62zaqXacPc7uG8ZAya8ogijLhTtaKNcpzpB4TMoTw2Si+8GYKRwFPMMtUT0263QFWFfqyQ==} + /core-js@3.32.2: + resolution: {integrity: sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==} requiresBuild: true dev: false - /cosmiconfig@8.2.0: - resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==} + /cosmiconfig@8.3.6(typescript@5.2.2): + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true dependencies: import-fresh: 3.3.0 js-yaml: 4.1.0 parse-json: 5.2.0 path-type: 4.0.0 + typescript: 5.2.2 + dev: true + + /create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} dev: true /cross-spawn@7.0.3: @@ -1948,10 +2103,43 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true - /define-properties@1.2.0: - resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} + /default-browser-id@3.0.0: + resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==} + engines: {node: '>=12'} + dependencies: + bplist-parser: 0.2.0 + untildify: 4.0.0 + dev: true + + /default-browser@4.0.0: + resolution: {integrity: sha512-wX5pXO1+BrhMkSbROFsyxUm0i/cJEScyNhA4PPxc41ICuv05ZZB/MX28s8aZx6xjmatvebIapF6hLEKEcpneUA==} + engines: {node: '>=14.16'} + dependencies: + bundle-name: 3.0.0 + default-browser-id: 3.0.0 + execa: 7.2.0 + titleize: 3.0.0 + dev: true + + /define-data-property@1.1.0: + resolution: {integrity: sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.1 + gopd: 1.0.1 + has-property-descriptors: 1.0.0 + dev: true + + /define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + dev: true + + /define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} dependencies: + define-data-property: 1.1.0 has-property-descriptors: 1.0.0 object-keys: 1.1.1 dev: true @@ -1967,6 +2155,11 @@ packages: hasBin: true dev: true + /diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + dev: true + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1991,7 +2184,7 @@ packages: /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.22.15 csstype: 3.1.2 dev: false @@ -2031,8 +2224,8 @@ packages: engines: {node: '>=6'} dev: true - /electron-to-chromium@1.4.454: - resolution: {integrity: sha512-pmf1rbAStw8UEQ0sr2cdJtWl48ZMuPD9Sto8HVQOq9vx9j2WgDEN6lYoaqFvqEHYOmGA9oRGn7LqWI9ta0YugQ==} + /electron-to-chromium@1.4.525: + resolution: {integrity: sha512-GIZ620hDK4YmIqAWkscG4W6RwY6gOx1y5J6f4JUQwctiJrqH2oxZYU4mXHi35oV32tr630UcepBzSBGJ/WYcZA==} dev: true /entities@2.2.0: @@ -2050,16 +2243,17 @@ packages: is-arrayish: 0.2.1 dev: true - /es-abstract@1.21.2: - resolution: {integrity: sha512-y/B5POM2iBnIxCiernH1G7rC9qQoM77lLIMQLuob0zhp8C56Po81+2Nj0WFKnd0pNReDTnkYryc+zhOzpEIROg==} + /es-abstract@1.22.2: + resolution: {integrity: sha512-YoxfFcDmhjOgWPWsV13+2RNjq1F6UQnfs+8TftwNqtzlmFzEXvlUwdrNrYeaizfjQzRMxkZ6ElWMOJIFKdVqwA==} engines: {node: '>= 0.4'} dependencies: array-buffer-byte-length: 1.0.0 + arraybuffer.prototype.slice: 1.0.2 available-typed-arrays: 1.0.5 call-bind: 1.0.2 es-set-tostringtag: 2.0.1 es-to-primitive: 1.2.1 - function.prototype.name: 1.1.5 + function.prototype.name: 1.1.6 get-intrinsic: 1.2.1 get-symbol-description: 1.0.0 globalthis: 1.0.3 @@ -2075,19 +2269,42 @@ packages: is-regex: 1.1.4 is-shared-array-buffer: 1.0.2 is-string: 1.0.7 - is-typed-array: 1.1.10 + is-typed-array: 1.1.12 is-weakref: 1.0.2 object-inspect: 1.12.3 object-keys: 1.1.1 object.assign: 4.1.4 - regexp.prototype.flags: 1.5.0 + regexp.prototype.flags: 1.5.1 + safe-array-concat: 1.0.1 safe-regex-test: 1.0.0 - string.prototype.trim: 1.2.7 - string.prototype.trimend: 1.0.6 - string.prototype.trimstart: 1.0.6 + string.prototype.trim: 1.2.8 + string.prototype.trimend: 1.0.7 + string.prototype.trimstart: 1.0.7 + typed-array-buffer: 1.0.0 + typed-array-byte-length: 1.0.0 + typed-array-byte-offset: 1.0.0 typed-array-length: 1.0.4 unbox-primitive: 1.0.2 - which-typed-array: 1.1.9 + which-typed-array: 1.1.11 + dev: true + + /es-iterator-helpers@1.0.15: + resolution: {integrity: sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==} + dependencies: + asynciterator.prototype: 1.0.0 + call-bind: 1.0.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 + es-set-tostringtag: 2.0.1 + function-bind: 1.1.1 + get-intrinsic: 1.2.1 + globalthis: 1.0.3 + has-property-descriptors: 1.0.0 + has-proto: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.5 + iterator.prototype: 1.1.2 + safe-array-concat: 1.0.1 dev: true /es-set-tostringtag@2.0.1: @@ -2129,96 +2346,93 @@ packages: engines: {node: '>=10'} dev: true - /eslint-config-prettier@8.8.0(eslint@8.44.0): - resolution: {integrity: sha512-wLbQiFre3tdGgpDv67NQKnJuTlcUVYHas3k+DZCc2U2BadthoEY4B7hLPvAxaqdyOGCzuLfii2fqGph10va7oA==} + /eslint-config-prettier@9.0.0(eslint@8.49.0): + resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==} hasBin: true peerDependencies: eslint: '>=7.0.0' dependencies: - eslint: 8.44.0 + eslint: 8.49.0 dev: true - /eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.8.0)(eslint@8.44.0)(prettier@3.0.0): - resolution: {integrity: sha512-f/0rXLXUt0oFYs8ra4w49wYZBG5GKZpAYsJSm6rnYL5uVDjd+zowwMwVZHnAjf4edNrKpCDYfXDgmRE/Ak7QyQ==} - engines: {node: '>=12.0.0'} + /eslint-plugin-prettier@5.0.0(eslint-config-prettier@9.0.0)(eslint@8.49.0)(prettier@3.0.3): + resolution: {integrity: sha512-AgaZCVuYDXHUGxj/ZGu1u8H8CYgDY3iG6w5kUFw4AzMVXzB7VvbKgYR4nATIN+OvUrghMbiDLeimVjVY5ilq3w==} + engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - eslint: '>=7.28.0' + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' eslint-config-prettier: '*' - prettier: '>=2.0.0' + prettier: '>=3.0.0' peerDependenciesMeta: + '@types/eslint': + optional: true eslint-config-prettier: optional: true dependencies: - eslint: 8.44.0 - eslint-config-prettier: 8.8.0(eslint@8.44.0) - prettier: 3.0.0 + eslint: 8.49.0 + eslint-config-prettier: 9.0.0(eslint@8.49.0) + prettier: 3.0.3 prettier-linter-helpers: 1.0.0 + synckit: 0.8.5 dev: true - /eslint-plugin-react-hooks@4.6.0(eslint@8.44.0): + /eslint-plugin-react-hooks@4.6.0(eslint@8.49.0): resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} engines: {node: '>=10'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 dependencies: - eslint: 8.44.0 + eslint: 8.49.0 dev: true - /eslint-plugin-react@7.32.2(eslint@8.44.0): - resolution: {integrity: sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==} + /eslint-plugin-react@7.33.2(eslint@8.49.0): + resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==} engines: {node: '>=4'} peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 dependencies: - array-includes: 3.1.6 - array.prototype.flatmap: 1.3.1 - array.prototype.tosorted: 1.1.1 + array-includes: 3.1.7 + array.prototype.flatmap: 1.3.2 + array.prototype.tosorted: 1.1.2 doctrine: 2.1.0 - eslint: 8.44.0 + es-iterator-helpers: 1.0.15 + eslint: 8.49.0 estraverse: 5.3.0 - jsx-ast-utils: 3.3.4 + jsx-ast-utils: 3.3.5 minimatch: 3.1.2 - object.entries: 1.1.6 - object.fromentries: 2.0.6 - object.hasown: 1.1.2 - object.values: 1.1.6 + object.entries: 1.1.7 + object.fromentries: 2.0.7 + object.hasown: 1.1.3 + object.values: 1.1.7 prop-types: 15.8.1 resolve: 2.0.0-next.4 - semver: 6.3.0 - string.prototype.matchall: 4.0.8 - dev: true - - /eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 + semver: 6.3.1 + string.prototype.matchall: 4.0.10 dev: true - /eslint-scope@7.2.0: - resolution: {integrity: sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==} + /eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 dev: true - /eslint-visitor-keys@3.4.1: - resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} + /eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint@8.44.0: - resolution: {integrity: sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==} + /eslint@8.49.0: + resolution: {integrity: sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.44.0) - '@eslint-community/regexpp': 4.5.1 - '@eslint/eslintrc': 2.1.0 - '@eslint/js': 8.44.0 - '@humanwhocodes/config-array': 0.11.10 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.49.0) + '@eslint-community/regexpp': 4.8.1 + '@eslint/eslintrc': 2.1.2 + '@eslint/js': 8.49.0 + '@humanwhocodes/config-array': 0.11.11 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 ajv: 6.12.6 @@ -2227,19 +2441,18 @@ packages: debug: 4.3.4 doctrine: 3.0.0 escape-string-regexp: 4.0.0 - eslint-scope: 7.2.0 - eslint-visitor-keys: 3.4.1 - espree: 9.6.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 esquery: 1.5.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 6.0.1 find-up: 5.0.0 glob-parent: 6.0.2 - globals: 13.20.0 + globals: 13.21.0 graphemer: 1.4.0 ignore: 5.2.4 - import-fresh: 3.3.0 imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 @@ -2251,19 +2464,18 @@ packages: natural-compare: 1.4.0 optionator: 0.9.3 strip-ansi: 6.0.1 - strip-json-comments: 3.1.1 text-table: 0.2.0 transitivePeerDependencies: - supports-color dev: true - /espree@9.6.0: - resolution: {integrity: sha512-1FH/IiruXZ84tpUlm0aCUEwMl2Ho5ilqVh0VvQXw+byAz/4SAciyHLlfmL5WYqsvD38oymdUwBss0LtK8m4s/A==} + /espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: acorn: 8.10.0 acorn-jsx: 5.3.2(acorn@8.10.0) - eslint-visitor-keys: 3.4.1 + eslint-visitor-keys: 3.4.3 dev: true /esquery@1.5.0: @@ -2280,11 +2492,6 @@ packages: estraverse: 5.3.0 dev: true - /estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - dev: true - /estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -2305,6 +2512,36 @@ packages: engines: {node: '>=12.0.0'} dev: false + /execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + dev: true + + /execa@7.2.0: + resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} + engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 4.3.1 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.1.0 + onetime: 6.0.0 + signal-exit: 3.0.7 + strip-final-newline: 3.0.0 + dev: true + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -2313,8 +2550,8 @@ packages: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} dev: true - /fast-glob@3.3.0: - resolution: {integrity: sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==} + /fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2349,7 +2586,7 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} dependencies: - flat-cache: 3.0.4 + flat-cache: 3.1.0 dev: true /fill-range@7.0.1: @@ -2367,20 +2604,21 @@ packages: path-exists: 4.0.0 dev: true - /flat-cache@3.0.4: - resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} - engines: {node: ^10.12.0 || >=12.0.0} + /flat-cache@3.1.0: + resolution: {integrity: sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==} + engines: {node: '>=12.0.0'} dependencies: - flatted: 3.2.7 + flatted: 3.2.9 + keyv: 4.5.3 rimraf: 3.0.2 dev: true - /flatted@3.2.7: - resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + /flatted@3.2.9: + resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} dev: true - /follow-redirects@1.15.2: - resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} + /follow-redirects@1.15.3: + resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -2412,13 +2650,13 @@ packages: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} dev: true - /function.prototype.name@1.1.5: - resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} + /function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 functions-have-names: 1.2.3 dev: true @@ -2440,6 +2678,11 @@ packages: engines: {node: '>=6'} dev: true + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + dev: true + /get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} engines: {node: '>= 0.4'} @@ -2473,8 +2716,8 @@ packages: path-is-absolute: 1.0.1 dev: true - /globals@13.20.0: - resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} + /globals@13.21.0: + resolution: {integrity: sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==} engines: {node: '>=8'} dependencies: type-fest: 0.20.2 @@ -2484,7 +2727,7 @@ packages: resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} engines: {node: '>= 0.4'} dependencies: - define-properties: 1.2.0 + define-properties: 1.2.1 dev: true /globby@11.1.0: @@ -2493,7 +2736,7 @@ packages: dependencies: array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.0 + fast-glob: 3.3.1 ignore: 5.2.4 merge2: 1.4.1 slash: 3.0.0 @@ -2559,7 +2802,7 @@ packages: void-elements: 3.1.0 dev: false - /htmlnano@2.0.4(svgo@2.8.0): + /htmlnano@2.0.4(svgo@2.8.0)(typescript@5.2.2): resolution: {integrity: sha512-WGCkyGFwjKW1GeCBsPYacMvaMnZtFJ0zIRnC2NCddkA+IOEhTqskXrS7lep+3yYZw/nQ3dW1UAX4yA/GJyR8BA==} peerDependencies: cssnano: ^6.0.0 @@ -2588,10 +2831,12 @@ packages: uncss: optional: true dependencies: - cosmiconfig: 8.2.0 + cosmiconfig: 8.3.6(typescript@5.2.2) posthtml: 0.16.6 svgo: 2.8.0 timsort: 0.3.0 + transitivePeerDependencies: + - typescript dev: true /htmlparser2@7.2.0: @@ -2603,6 +2848,16 @@ packages: entities: 3.0.1 dev: true + /human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + dev: true + + /human-signals@4.3.1: + resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} + engines: {node: '>=14.18.0'} + dev: true + /hyphenate-style-name@1.0.4: resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==} dev: false @@ -2610,13 +2865,13 @@ packages: /i18next-browser-languagedetector@7.1.0: resolution: {integrity: sha512-cr2k7u1XJJ4HTOjM9GyOMtbOA47RtUoWRAtt52z43r3AoMs2StYKyjS3URPhzHaf+mn10hY9dZWamga5WPQjhA==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.22.15 dev: false - /i18next@23.2.8: - resolution: {integrity: sha512-wU0pMlJ91ZbB89i77G3YQ11/pBQrzgWpxJYl7HFyA9aU9v3aHMI/oBKQmAJNURr0A8cLG4EHjgSMK8IqQTp4PQ==} + /i18next@23.5.1: + resolution: {integrity: sha512-JelYzcaCoFDaa+Ysbfz2JsGAKkrHiMG6S61+HLBUEIPaF40WMwW9hCPymlQGrP+wWawKxKPuSuD71WZscCsWHg==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.22.15 dev: false /ieee754@1.2.1: @@ -2666,7 +2921,7 @@ packages: dependencies: call-bind: 1.0.2 get-intrinsic: 1.2.1 - is-typed-array: 1.1.10 + is-typed-array: 1.1.12 dev: true /is-arrayish@0.2.1: @@ -2677,6 +2932,13 @@ packages: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} dev: false + /is-async-function@2.0.0: + resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: true + /is-bigint@1.0.4: resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} dependencies: @@ -2696,8 +2958,8 @@ packages: engines: {node: '>= 0.4'} dev: true - /is-core-module@2.12.1: - resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} + /is-core-module@2.13.0: + resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==} dependencies: has: 1.0.3 dev: true @@ -2709,11 +2971,36 @@ packages: has-tostringtag: 1.0.0 dev: true + /is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + dev: true + + /is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + dev: true + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} dev: true + /is-finalizationregistry@1.0.2: + resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + dependencies: + call-bind: 1.0.2 + dev: true + + /is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: true + /is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2721,10 +3008,22 @@ packages: is-extglob: 2.1.1 dev: true + /is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + dependencies: + is-docker: 3.0.0 + dev: true + /is-json@2.0.1: resolution: {integrity: sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==} dev: true + /is-map@2.0.2: + resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} + dev: true + /is-negative-zero@2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} @@ -2755,12 +3054,26 @@ packages: has-tostringtag: 1.0.0 dev: true + /is-set@2.0.2: + resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} + dev: true + /is-shared-array-buffer@1.0.2: resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} dependencies: call-bind: 1.0.2 dev: true + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + dev: true + + /is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + /is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} @@ -2775,15 +3088,15 @@ packages: has-symbols: 1.0.3 dev: true - /is-typed-array@1.1.10: - resolution: {integrity: sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==} + /is-typed-array@1.1.12: + resolution: {integrity: sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==} engines: {node: '>= 0.4'} dependencies: - available-typed-arrays: 1.0.5 - call-bind: 1.0.2 - for-each: 0.3.3 - gopd: 1.0.1 - has-tostringtag: 1.0.0 + which-typed-array: 1.1.11 + dev: true + + /is-weakmap@2.0.1: + resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} dev: true /is-weakref@1.0.2: @@ -2792,13 +3105,37 @@ packages: call-bind: 1.0.2 dev: true + /is-weakset@2.0.2: + resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + dev: true + + /is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + dev: true + + /isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + dev: true + /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true - /js-base64@3.7.5: - resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} - dev: false + /iterator.prototype@1.1.2: + resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + dependencies: + define-properties: 1.2.1 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + reflect.getprototypeof: 1.0.4 + set-function-name: 2.0.1 + dev: true /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2810,6 +3147,10 @@ packages: argparse: 2.0.1 dev: true + /json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + dev: true + /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true @@ -2828,14 +3169,20 @@ packages: hasBin: true dev: true - /jsx-ast-utils@3.3.4: - resolution: {integrity: sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==} + /jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} dependencies: - array-includes: 3.1.6 - array.prototype.flat: 1.3.1 + array-includes: 3.1.7 + array.prototype.flat: 1.3.2 object.assign: 4.1.4 - object.values: 1.1.6 + object.values: 1.1.7 + dev: true + + /keyv@4.5.3: + resolution: {integrity: sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==} + dependencies: + json-buffer: 3.0.1 dev: true /levn@0.4.1: @@ -2846,8 +3193,8 @@ packages: type-check: 0.4.0 dev: true - /lightningcss-darwin-arm64@1.21.5: - resolution: {integrity: sha512-z05hyLX85WY0UfhkFUOrWEFqD69lpVAmgl3aDzMKlIZJGygbhbegqb4PV8qfUrKKNBauut/qVNPKZglhTaDDxA==} + /lightningcss-darwin-arm64@1.22.0: + resolution: {integrity: sha512-aH2be3nNny+It5YEVm8tBSSdRlBVWQV8m2oJ7dESiYRzyY/E/bQUe2xlw5caaMuhlM9aoTMtOH25yzMhir0qPg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] @@ -2855,8 +3202,8 @@ packages: dev: true optional: true - /lightningcss-darwin-x64@1.21.5: - resolution: {integrity: sha512-MSJhmej/U9MrdPxDk7+FWhO8+UqVoZUHG4VvKT5RQ4RJtqtANTiWiI97LvoVNMtdMnHaKs1Pkji6wHUFxjJsHQ==} + /lightningcss-darwin-x64@1.22.0: + resolution: {integrity: sha512-9KHRFA0Y6mNxRHeoQMp0YaI0R0O2kOgUlYPRjuasU4d+pI8NRhVn9bt0yX9VPs5ibWX1RbDViSPtGJvYYrfVAQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] @@ -2864,8 +3211,17 @@ packages: dev: true optional: true - /lightningcss-linux-arm-gnueabihf@1.21.5: - resolution: {integrity: sha512-xN6+5/JsMrbZHL1lPl+MiNJ3Xza12ueBKPepiyDCFQzlhFRTj7D0LG+cfNTzPBTO8KcYQynLpl1iBB8LGp3Xtw==} + /lightningcss-freebsd-x64@1.22.0: + resolution: {integrity: sha512-xaYL3xperGwD85rQioDb52ozF3NAJb+9wrge3jD9lxGffplu0Mn35rXMptB8Uc2N9Mw1i3Bvl7+z1evlqVl7ww==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm-gnueabihf@1.22.0: + resolution: {integrity: sha512-epQGvXIjOuxrZpMpMnRjK54ZqzhiHhCPLtHvw2fb6NeK2kK9YtF0wqmeTBiQ1AkbWfnnXGTstYaFNiadNK+StQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] @@ -2873,8 +3229,8 @@ packages: dev: true optional: true - /lightningcss-linux-arm64-gnu@1.21.5: - resolution: {integrity: sha512-KfzFNhC4XTbmG3ma/xcTs/IhCwieW89XALIusKmnV0N618ZDXEB0XjWOYQRCXeK9mfqPdbTBpurEHV/XZtkniQ==} + /lightningcss-linux-arm64-gnu@1.22.0: + resolution: {integrity: sha512-AArGtKSY4DGTA8xP8SDyNyKtpsUl1Rzq6FW4JomeyUQ4nBrR71uPChksTpj3gmWuGhZeRKLeCUI1DBid/zhChg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] @@ -2882,8 +3238,8 @@ packages: dev: true optional: true - /lightningcss-linux-arm64-musl@1.21.5: - resolution: {integrity: sha512-bc0GytQO5Mn9QM6szaZ+31fQHNdidgpM1sSCwzPItz8hg3wOvKl8039rU0veMJV3ZgC9z0ypNRceLrSHeRHmXw==} + /lightningcss-linux-arm64-musl@1.22.0: + resolution: {integrity: sha512-RRraNgP8hnBPhInTTUdlFm+z16C/ghbxBG51Sw00hd7HUyKmEUKRozyc5od+/N6pOrX/bIh5vIbtMXIxsos0lg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] @@ -2891,8 +3247,8 @@ packages: dev: true optional: true - /lightningcss-linux-x64-gnu@1.21.5: - resolution: {integrity: sha512-JwMbgypPQgc2kW2av3OwzZ8cbrEuIiDiXPJdXRE6aVxu67yHauJawQLqJKTGUhiAhy6iLDG8Wg0a3/ziL+m+Kw==} + /lightningcss-linux-x64-gnu@1.22.0: + resolution: {integrity: sha512-grdrhYGRi2KrR+bsXJVI0myRADqyA7ekprGxiuK5QRNkv7kj3Yq1fERDNyzZvjisHwKUi29sYMClscbtl+/Zpw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] @@ -2900,8 +3256,8 @@ packages: dev: true optional: true - /lightningcss-linux-x64-musl@1.21.5: - resolution: {integrity: sha512-Ib8b6IQ/OR/VrPU6YBgy4T3QnuHY7DUa95O+nz+cwrTkMSN6fuHcTcIaz4t8TJ6HI5pl3uxUOZjmtls2pyQWow==} + /lightningcss-linux-x64-musl@1.22.0: + resolution: {integrity: sha512-t5f90X+iQUtIyR56oXIHMBUyQFX/zwmPt72E6Dane3P8KNGlkijTg2I75XVQS860gNoEFzV7Mm5ArRRA7u5CAQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] @@ -2909,8 +3265,8 @@ packages: dev: true optional: true - /lightningcss-win32-x64-msvc@1.21.5: - resolution: {integrity: sha512-A8cSi8lUpBeVmoF+DqqW7cd0FemDbCuKr490IXdjyeI+KL8adpSKUs8tcqO0OXPh1EoDqK7JNkD/dELmd4Iz5g==} + /lightningcss-win32-x64-msvc@1.22.0: + resolution: {integrity: sha512-64HTDtOOZE9PUCZJiZZQpyqXBbdby1lnztBccnqh+NtbKxjnGzP92R2ngcgeuqMPecMNqNWxgoWgTGpC+yN5Sw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] @@ -2918,20 +3274,21 @@ packages: dev: true optional: true - /lightningcss@1.21.5: - resolution: {integrity: sha512-/pEUPeih2EwIx9n4T82aOG6CInN83tl/mWlw6B5gWLf36UplQi1L+5p3FUHsdt4fXVfOkkh9KIaM3owoq7ss8A==} + /lightningcss@1.22.0: + resolution: {integrity: sha512-+z0qvwRVzs4XGRXelnWRNwqsXUx8k3bSkbP8vD42kYKSk3z9OM2P3e/gagT7ei/gwh8DTS80LZOFZV6lm8Z8Fg==} engines: {node: '>= 12.0.0'} dependencies: detect-libc: 1.0.3 optionalDependencies: - lightningcss-darwin-arm64: 1.21.5 - lightningcss-darwin-x64: 1.21.5 - lightningcss-linux-arm-gnueabihf: 1.21.5 - lightningcss-linux-arm64-gnu: 1.21.5 - lightningcss-linux-arm64-musl: 1.21.5 - lightningcss-linux-x64-gnu: 1.21.5 - lightningcss-linux-x64-musl: 1.21.5 - lightningcss-win32-x64-msvc: 1.21.5 + lightningcss-darwin-arm64: 1.22.0 + lightningcss-darwin-x64: 1.22.0 + lightningcss-freebsd-x64: 1.22.0 + lightningcss-linux-arm-gnueabihf: 1.22.0 + lightningcss-linux-arm64-gnu: 1.22.0 + lightningcss-linux-arm64-musl: 1.22.0 + lightningcss-linux-x64-gnu: 1.22.0 + lightningcss-linux-x64-musl: 1.22.0 + lightningcss-win32-x64-msvc: 1.22.0 dev: true /lines-and-columns@1.2.4: @@ -2964,10 +3321,6 @@ packages: p-locate: 5.0.0 dev: true - /lodash-es@4.17.21: - resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} - dev: false - /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true @@ -2989,9 +3342,13 @@ packages: yallist: 4.0.0 dev: true - /marked@5.1.1: - resolution: {integrity: sha512-bTmmGdEINWmOMDjnPWDxGPQ4qkDLeYorpYbEtFOXzOruTwUE671q4Guiuchn4N8h/v6NGd7916kXsm3Iz4iUSg==} - engines: {node: '>= 18'} + /make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + dev: true + + /marked@9.0.3: + resolution: {integrity: sha512-pI/k4nzBG1PEq1J3XFEHxVvjicfjl8rgaMaqclouGSMPhk7Q3Ejb2ZRxx/ZQOcQ1909HzVoWCFYq6oLgtL4BpQ==} + engines: {node: '>= 16'} hasBin: true dev: false @@ -3001,14 +3358,14 @@ packages: css-mediaquery: 0.1.2 dev: false - /material-colors@1.2.6: - resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} - dev: false - /mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} dev: true + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3034,6 +3391,16 @@ packages: mime-db: 1.52.0 dev: false + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + + /mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + dev: true + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -3070,16 +3437,12 @@ packages: msgpackr-extract: 3.0.2 dev: true - /msgpackr@1.9.5: - resolution: {integrity: sha512-/IJ3cFSN6Ci3eG2wLhbFEL6GT63yEaoN/R5My2QkV6zro+OJaVRLPlwvxY7EtHYSmDlQpk8stvOQTL2qJFkDRg==} + /msgpackr@1.9.9: + resolution: {integrity: sha512-sbn6mioS2w0lq1O6PpGtsv6Gy8roWM+o3o4Sqjd6DudrL/nOugY+KyJUimoWzHnf9OkO0T6broHFnYE/R05t9A==} optionalDependencies: msgpackr-extract: 3.0.2 dev: true - /natural-compare-lite@1.4.0: - resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} - dev: true - /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -3092,8 +3455,8 @@ packages: resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} dev: true - /node-fetch@2.6.12: - resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==} + /node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} peerDependencies: encoding: ^0.1.0 @@ -3112,6 +3475,7 @@ packages: /node-gyp-build-optional-packages@5.0.7: resolution: {integrity: sha512-YlCCc6Wffkx0kHkmam79GKvDQ6x+QZkMjFGrIMxgFNILFvGSbCp2fCBC55pGTT9gVaz8Na5CLmxt/urtzRv36w==} hasBin: true + requiresBuild: true dev: true optional: true @@ -3119,6 +3483,20 @@ packages: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} dev: true + /npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + dev: true + + /npm-run-path@5.1.0: + resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + path-key: 4.0.0 + dev: true + /nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: @@ -3147,43 +3525,43 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 + define-properties: 1.2.1 has-symbols: 1.0.3 object-keys: 1.1.1 dev: true - /object.entries@1.1.6: - resolution: {integrity: sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==} + /object.entries@1.1.7: + resolution: {integrity: sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 dev: true - /object.fromentries@2.0.6: - resolution: {integrity: sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==} + /object.fromentries@2.0.7: + resolution: {integrity: sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 dev: true - /object.hasown@1.1.2: - resolution: {integrity: sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==} + /object.hasown@1.1.3: + resolution: {integrity: sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==} dependencies: - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 dev: true - /object.values@1.1.6: - resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==} + /object.values@1.1.7: + resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 dev: true /once@1.4.0: @@ -3192,6 +3570,30 @@ packages: wrappy: 1.0.2 dev: true + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + dev: true + + /onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + dependencies: + mimic-fn: 4.0.0 + dev: true + + /open@9.1.0: + resolution: {integrity: sha512-OS+QTnw1/4vrf+9hh1jc1jnYjzSG4ttTBB8UxOwAnInG3Uo4ssetzC1ihqaIHjLJnA5GGlRl6QlZXOTQhRBUvg==} + engines: {node: '>=14.16'} + dependencies: + default-browser: 4.0.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + is-wsl: 2.2.0 + dev: true + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -3222,7 +3624,7 @@ packages: p-limit: 3.1.0 dev: true - /parcel@2.9.3: + /parcel@2.9.3(typescript@5.2.2): resolution: {integrity: sha512-2GTVocFkwblV/TIg9AmT7TI2fO4xdWkyN8aFUEVtiVNWt96GTR3FgQyHFValfCbcj1k9Xf962Ws2hYXYUr9k1Q==} engines: {node: '>= 12.0.0'} hasBin: true @@ -3230,7 +3632,7 @@ packages: '@parcel/core': optional: true dependencies: - '@parcel/config-default': 2.9.3(@parcel/core@2.9.3) + '@parcel/config-default': 2.9.3(@parcel/core@2.9.3)(typescript@5.2.2) '@parcel/core': 2.9.3 '@parcel/diagnostic': 2.9.3 '@parcel/events': 2.9.3 @@ -3252,6 +3654,7 @@ packages: - relateurl - srcset - terser + - typescript - uncss dev: true @@ -3266,7 +3669,7 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} dependencies: - '@babel/code-frame': 7.22.5 + '@babel/code-frame': 7.22.13 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -3287,6 +3690,11 @@ packages: engines: {node: '>=8'} dev: true + /path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + dev: true + /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true @@ -3350,8 +3758,8 @@ packages: fast-diff: 1.3.0 dev: true - /prettier@3.0.0: - resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} + /prettier@3.0.3: + resolution: {integrity: sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==} engines: {node: '>=14'} hasBin: true dev: true @@ -3388,21 +3796,6 @@ packages: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true - /react-color@2.19.3(react@18.2.0): - resolution: {integrity: sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==} - peerDependencies: - react: '*' - dependencies: - '@icons/material': 0.2.4(react@18.2.0) - lodash: 4.17.21 - lodash-es: 4.17.21 - material-colors: 1.2.6 - prop-types: 15.8.1 - react: 18.2.0 - reactcss: 1.2.3(react@18.2.0) - tinycolor2: 1.6.0 - dev: false - /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -3421,8 +3814,8 @@ packages: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} dev: false - /react-i18next@13.0.1(i18next@23.2.8)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-gMO6N2GfSfuH7xlHSsZ/mZf+Py9bLm/+EDKIn5fNTuDTjcCcwmMU5UEuGCDk5mdfivbo7ySyYXBN7B9tbGUxiA==} + /react-i18next@13.2.2(i18next@23.5.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-+nFUkbRByFwnrfDcYqvzBuaeZb+nACHx+fAWN/pZMddWOCJH5hoc21+Sa/N/Lqi6ne6/9wC/qRGOoQhJa6IkEQ==} peerDependencies: i18next: '>= 23.2.3' react: '>= 16.8.0' @@ -3434,9 +3827,9 @@ packages: react-native: optional: true dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.22.15 html-parse-stringify: 3.0.1 - i18next: 23.2.8 + i18next: 23.5.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false @@ -3476,26 +3869,26 @@ packages: shallow-equal: 1.2.1 dev: false - /react-router-dom@6.14.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-ssF6M5UkQjHK70fgukCJyjlda0Dgono2QGwqGvuk7D+EDGHdacEN3Yke2LTMjkrpHuFwBfDFsEjGVXBDmL+bWw==} - engines: {node: '>=14'} + /react-router-dom@6.16.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==} + engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' dependencies: - '@remix-run/router': 1.7.1 + '@remix-run/router': 1.9.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-router: 6.14.1(react@18.2.0) + react-router: 6.16.0(react@18.2.0) dev: false - /react-router@6.14.1(react@18.2.0): - resolution: {integrity: sha512-U4PfgvG55LdvbQjg5Y9QRWyVxIdO1LlpYT7x+tMAxd9/vmiPuJhIwdxZuIQLN/9e3O4KFDHYfR9gzGeYMasW8g==} - engines: {node: '>=14'} + /react-router@6.16.0(react@18.2.0): + resolution: {integrity: sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==} + engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' dependencies: - '@remix-run/router': 1.7.1 + '@remix-run/router': 1.9.0 react: 18.2.0 dev: false @@ -3505,7 +3898,7 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.22.15 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -3520,25 +3913,33 @@ packages: loose-envify: 1.4.0 dev: false - /reactcss@1.2.3(react@18.2.0): - resolution: {integrity: sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==} - peerDependencies: - react: '*' + /reflect.getprototypeof@1.0.4: + resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==} + engines: {node: '>= 0.4'} dependencies: - lodash: 4.17.21 - react: 18.2.0 - dev: false + call-bind: 1.0.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 + get-intrinsic: 1.2.1 + globalthis: 1.0.3 + which-builtin-type: 1.1.3 + dev: true /regenerator-runtime@0.13.11: resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + dev: true + + /regenerator-runtime@0.14.0: + resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} + dev: false - /regexp.prototype.flags@1.5.0: - resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==} + /regexp.prototype.flags@1.5.1: + resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - functions-have-names: 1.2.3 + define-properties: 1.2.1 + set-function-name: 2.0.1 dev: true /requires-port@1.0.0: @@ -3554,7 +3955,7 @@ packages: resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==} hasBin: true dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 dev: true @@ -3571,6 +3972,13 @@ packages: glob: 7.2.3 dev: true + /run-applescript@5.0.0: + resolution: {integrity: sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==} + engines: {node: '>=12'} + dependencies: + execa: 5.1.1 + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -3580,9 +3988,19 @@ packages: /rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} dependencies: - tslib: 2.6.0 + tslib: 2.6.2 dev: false + /safe-array-concat@1.0.1: + resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} + engines: {node: '>=0.4'} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + isarray: 2.0.5 + dev: true + /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: true @@ -3601,8 +4019,8 @@ packages: loose-envify: 1.4.0 dev: false - /semver@6.3.0: - resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true dev: true @@ -3618,6 +4036,15 @@ packages: resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} dev: false + /set-function-name@2.0.1: + resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.0 + dev: true + /shallow-equal@1.2.1: resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==} dev: false @@ -3642,6 +4069,10 @@ packages: object-inspect: 1.12.3 dev: true + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: true + /simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} dependencies: @@ -3668,42 +4099,43 @@ packages: deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' dev: true - /string.prototype.matchall@4.0.8: - resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==} + /string.prototype.matchall@4.0.10: + resolution: {integrity: sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 get-intrinsic: 1.2.1 has-symbols: 1.0.3 internal-slot: 1.0.5 - regexp.prototype.flags: 1.5.0 + regexp.prototype.flags: 1.5.1 + set-function-name: 2.0.1 side-channel: 1.0.4 dev: true - /string.prototype.trim@1.2.7: - resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} + /string.prototype.trim@1.2.8: + resolution: {integrity: sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 dev: true - /string.prototype.trimend@1.0.6: - resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} + /string.prototype.trimend@1.0.7: + resolution: {integrity: sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 dev: true - /string.prototype.trimstart@1.0.6: - resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==} + /string.prototype.trimstart@1.0.7: + resolution: {integrity: sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.21.2 + define-properties: 1.2.1 + es-abstract: 1.22.2 dev: true /strip-ansi@6.0.1: @@ -3713,6 +4145,16 @@ packages: ansi-regex: 5.0.1 dev: true + /strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + dev: true + + /strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + dev: true + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -3751,6 +4193,14 @@ packages: stable: 0.1.8 dev: true + /synckit@0.8.5: + resolution: {integrity: sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==} + engines: {node: ^14.18.0 || >=16.0.0} + dependencies: + '@pkgr/utils': 2.4.2 + tslib: 2.6.2 + dev: true + /term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -3764,9 +4214,10 @@ packages: resolution: {integrity: sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==} dev: true - /tinycolor2@1.6.0: - resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - dev: false + /titleize@3.0.0: + resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} + engines: {node: '>=12'} + dev: true /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -3789,23 +4240,49 @@ packages: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: false - /tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + /ts-api-utils@1.0.3(typescript@5.2.2): + resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==} + engines: {node: '>=16.13.0'} + peerDependencies: + typescript: '>=4.2.0' + dependencies: + typescript: 5.2.2 dev: true - /tslib@2.6.0: - resolution: {integrity: sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==} - - /tsutils@3.21.0(typescript@5.1.6): - resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} - engines: {node: '>= 6'} + /ts-node@10.9.1(@types/node@20.6.3)(typescript@5.2.2): + resolution: {integrity: sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==} + hasBin: true peerDependencies: - typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true dependencies: - tslib: 1.14.1 - typescript: 5.1.6 + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.9 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.6.3 + acorn: 8.10.0 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.2.2 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 dev: true + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3818,16 +4295,55 @@ packages: engines: {node: '>=10'} dev: true + /typed-array-buffer@1.0.0: + resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + is-typed-array: 1.1.12 + dev: true + + /typed-array-byte-length@1.0.0: + resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + for-each: 0.3.3 + has-proto: 1.0.1 + is-typed-array: 1.1.12 + dev: true + + /typed-array-byte-offset@1.0.0: + resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + for-each: 0.3.3 + has-proto: 1.0.1 + is-typed-array: 1.1.12 + dev: true + /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: call-bind: 1.0.2 for-each: 0.3.3 - is-typed-array: 1.1.10 + is-typed-array: 1.1.12 + dev: true + + /typescript-language-server@3.3.2: + resolution: {integrity: sha512-jzun53CIkTbpAki0nP+hk5baGW+86SNNlVhyIj2ZUy45zUkCnmoetWuAtfRRQYrlIr8x4QB3ymGJPuwDQSd/ew==} + engines: {node: '>=14.17'} + hasBin: true + dependencies: + vscode-jsonrpc: 5.0.1 + vscode-languageserver-protocol: 3.17.4 dev: true - /typescript@5.1.6: - resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} + /typescript@5.2.2: + resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} hasBin: true dev: true @@ -3846,13 +4362,18 @@ packages: engines: {node: '>= 4.0.0'} dev: false - /update-browserslist-db@1.0.11(browserslist@4.21.9): + /untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + dev: true + + /update-browserslist-db@1.0.11(browserslist@4.21.10): resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' dependencies: - browserslist: 4.21.9 + browserslist: 4.21.10 escalade: 3.1.1 picocolors: 1.0.0 dev: true @@ -3875,11 +4396,36 @@ packages: engines: {node: '>= 4'} dev: true + /v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + dev: true + /void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} dev: false + /vscode-jsonrpc@5.0.1: + resolution: {integrity: sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A==} + engines: {node: '>=8.0.0 || >=10.0.0'} + dev: true + + /vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + dev: true + + /vscode-languageserver-protocol@3.17.4: + resolution: {integrity: sha512-IpaHLPft+UBWf4dOIH15YEgydTbXGz52EMU2h16SfFpYu/yOQt3pY14049mtpJu+4CBHn+hq7S67e7O0AwpRqQ==} + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.4 + dev: true + + /vscode-languageserver-types@3.17.4: + resolution: {integrity: sha512-9YXi5pA3XF2V+NUQg6g+lulNS0ncRCKASYdK3Cs7kiH9sVFXWq27prjkC/B8M/xJLRPPRSPCHVMuBTgRNFh2sQ==} + dev: true + /warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} dependencies: @@ -3911,8 +4457,35 @@ packages: is-symbol: 1.0.4 dev: true - /which-typed-array@1.1.9: - resolution: {integrity: sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==} + /which-builtin-type@1.1.3: + resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==} + engines: {node: '>= 0.4'} + dependencies: + function.prototype.name: 1.1.6 + has-tostringtag: 1.0.0 + is-async-function: 2.0.0 + is-date-object: 1.0.5 + is-finalizationregistry: 1.0.2 + is-generator-function: 1.0.10 + is-regex: 1.1.4 + is-weakref: 1.0.2 + isarray: 2.0.5 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.1 + which-typed-array: 1.1.11 + dev: true + + /which-collection@1.0.1: + resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} + dependencies: + is-map: 2.0.2 + is-set: 2.0.2 + is-weakmap: 2.0.1 + is-weakset: 2.0.2 + dev: true + + /which-typed-array@1.1.11: + resolution: {integrity: sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==} engines: {node: '>= 0.4'} dependencies: available-typed-arrays: 1.0.5 @@ -3920,7 +4493,6 @@ packages: for-each: 0.3.3 gopd: 1.0.1 has-tostringtag: 1.0.0 - is-typed-array: 1.1.10 dev: true /which@2.0.2: @@ -3951,7 +4523,7 @@ packages: /xregexp@5.1.1: resolution: {integrity: sha512-fKXeVorD+CzWvFs7VBuKTYIW63YD1e1osxwQ8caZ6o1jg6pDAbABDG54LCIq0j5cy7PjRvGIq6sef9DYPXpncg==} dependencies: - '@babel/runtime-corejs3': 7.22.6 + '@babel/runtime-corejs3': 7.22.15 dev: false /xxhash-wasm@0.4.2: @@ -3962,6 +4534,11 @@ packages: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} dev: true + /yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + dev: true + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx index cfdab229..58463d08 100644 --- a/FrontEnd/src/App.tsx +++ b/FrontEnd/src/App.tsx @@ -1,51 +1,35 @@ -import * as React from "react"; +import { Suspense } from "react"; import { BrowserRouter, Route, Routes } from "react-router-dom"; -import AppBar from "./views/common/AppBar"; -import LoadingPage from "./views/common/LoadingPage"; -import Center from "./views/center"; -import Home from "./views/home"; -import Login from "./views/login"; -import Register from "./views/register"; -import Settings from "./views/settings"; -import About from "./views/about"; -import TimelinePage from "./views/timeline"; -import Search from "./views/search"; -import Admin from "./views/admin"; -import AlertHost from "./views/common/alert/AlertHost"; - -import { useUser } from "./services/user"; - -const NoMatch: React.FC = () => { - return <div>Ah-oh, 404!</div>; -}; - -function App(): JSX.Element { - const user = useUser(); +import AppBar from "./components/AppBar"; +import NotFoundPage from "./pages/404"; +import HomePage from "./pages/home"; +import AboutPage from "./pages/about"; +import SettingPage from "./pages/setting"; +import LoginPage from "./pages/login"; +import RegisterPage from "./pages/register"; +import TimelinePage from "./pages/timeline"; +import LoadingPage from "./pages/loading"; +import { AlertHost } from "./components/alert"; +export default function App() { return ( - <React.Suspense fallback={<LoadingPage />}> + <Suspense fallback={<LoadingPage />}> <BrowserRouter> <AppBar /> <div style={{ height: 56 }} /> <Routes> - <Route index element={user == null ? <Home /> : <Center />} /> - <Route path="home" element={<Home />} /> - <Route path="center" element={<Center />} /> - <Route path="login" element={<Login />} /> - <Route path="register" element={<Register />} /> - <Route path="settings" element={<Settings />} /> - <Route path="about" element={<About />} /> - <Route path="search" element={<Search />} /> - <Route path="admin/*" element={<Admin />} /> + <Route path="login" element={<LoginPage />} /> + <Route path="register" element={<RegisterPage />} /> + <Route path="settings" element={<SettingPage />} /> + <Route path="about" element={<AboutPage />} /> <Route path=":owner" element={<TimelinePage />} /> <Route path=":owner/:timeline" element={<TimelinePage />} /> - <Route element={<NoMatch />} /> + <Route path="" element={<HomePage />} /> + <Route path="*" element={<NotFoundPage />} /> </Routes> <AlertHost /> </BrowserRouter> - </React.Suspense> + </Suspense> ); } - -export default App; diff --git a/FrontEnd/src/common.ts b/FrontEnd/src/common.ts index 965f9933..1ca796c3 100644 --- a/FrontEnd/src/common.ts +++ b/FrontEnd/src/common.ts @@ -3,8 +3,7 @@ // This error should never occur. If it does, it indicates there is some logic bug in codes. export class UiLogicError extends Error {} -export const highlightTimelineUsername = "crupest"; - export type { I18nText } from "./i18n"; +export type { I18nText as Text } from "./i18n"; export { c, convertI18nText } from "./i18n"; export { default as useC } from "./utilities/hooks/use-c"; diff --git a/FrontEnd/src/components/AppBar.css b/FrontEnd/src/components/AppBar.css new file mode 100644 index 00000000..38497478 --- /dev/null +++ b/FrontEnd/src/components/AppBar.css @@ -0,0 +1,96 @@ +.app-bar { + height: 56px; + position: fixed; + z-index: 1030; + top: 0; + left: 0; + right: 0; + background-color: var(--cru-primary-color); +} + +.app-bar { + display: flex; +} + +.app-bar > * { + background-color: var(--cru-primary-color); +} + +.app-bar .app-bar-brand { + display: flex; + align-items: center; +} + +.app-bar .app-bar-brand-icon { + height: 2em; +} + +.app-bar .app-bar-space { + flex-grow: 1; +} + +.app-bar .app-bar-user-area { + display: flex; +} + +.app-bar a { + background-color: var(--cru-primary-color); + color: var(--cru-push-button-text-color); + text-decoration: none; + display: flex; + align-items: center; + padding: 0 1em; + transition: all 0.5s; +} + +.app-bar a:hover { + background-color: var(--cru-clickable-primary-hover-color); +} + +.app-bar a:focus { + background-color: var(--cru-clickable-primary-focus-color); +} + +.app-bar a:active { + background-color: var(--cru-clickable-primary-active-color); +} + +/* the current page */ +.app-bar a.active { + background-color: var(--cru-clickable-primary-focus-color); +} + +.app-bar .app-bar-avatar img { + width: 45px; + height: 45px; + background-color: white; + border-radius: 50%; +} + +.app-bar.desktop .app-bar-link-area { + display: flex; +} + +.app-bar.mobile .app-bar-link-area { + position: absolute; + z-index: -1; + left: 0; + right: 0; + top: 100%; + translate: 0 -100%; + transition: transform 0.5s; +} + +.app-bar.mobile a { + height: 56px; +} + +.app-bar.mobile.expand .app-bar-link-area { + transform: translateY(100%); +} + +.app-bar .toggler { + font-size: 2em; + padding-right: 0.5em; +} + diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx new file mode 100644 index 00000000..d40c8105 --- /dev/null +++ b/FrontEnd/src/components/AppBar.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import classnames from "classnames"; +import { Link, NavLink } from "react-router-dom"; + +import { useUser } from "~src/services/user"; + +import { I18nText, useC } from "./common"; +import { useMobile } from "./hooks"; +import TimelineLogo from "./TimelineLogo"; +import { IconButton } from "./button"; +import UserAvatar from "./user/UserAvatar"; + +import "./AppBar.css"; + +function AppBarNavLink({ + link, + className, + label, + onClick, + children, +}: { + link: string; + className?: string; + label?: I18nText; + onClick?: () => void; + children?: React.ReactNode; +}) { + if (label != null && children != null) { + throw new Error("AppBarNavLink: label and children cannot be both set"); + } + + const c = useC(); + + return ( + <NavLink + to={link} + className={({ isActive }) => classnames(className, isActive && "active")} + onClick={onClick} + > + {children != null ? children : c(label)} + </NavLink> + ); +} + +export default function AppBar() { + const isMobile = useMobile(); + + const [isCollapse, setIsCollapse] = useState<boolean>(true); + const collapse = isMobile ? () => setIsCollapse(true) : undefined; + const toggleCollapse = () => setIsCollapse(!isCollapse); + + const user = useUser(); + const hasAdministrationPermission = user && user.hasAdministrationPermission; + + return ( + <nav + className={classnames( + isMobile ? "mobile" : "desktop", + "app-bar", + isCollapse || "expand", + )} + > + <Link to="/" className="app-bar-brand" onClick={collapse}> + <TimelineLogo className="app-bar-brand-icon" /> + Timeline + </Link> + + <div className="app-bar-link-area"> + <AppBarNavLink + link="/settings" + label="nav.settings" + onClick={collapse} + /> + <AppBarNavLink link="/about" label="nav.about" onClick={collapse} /> + {hasAdministrationPermission && ( + <AppBarNavLink + link="/admin" + label="nav.administration" + onClick={collapse} + /> + )} + </div> + + <div className="app-bar-space" /> + + <div className="app-bar-user-area"> + {user != null ? ( + <AppBarNavLink link="/" className="app-bar-avatar" onClick={collapse}> + <UserAvatar username={user.username} /> + </AppBarNavLink> + ) : ( + <AppBarNavLink link="/login" label="nav.login" onClick={collapse} /> + )} + </div> + + {isMobile && ( + <IconButton + icon="list" + color="light" + className="toggler" + onClick={toggleCollapse} + /> + )} + </nav> + ); +} diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx new file mode 100644 index 00000000..047a13b4 --- /dev/null +++ b/FrontEnd/src/components/BlobImage.tsx @@ -0,0 +1,41 @@ +import { + useState, + useEffect, + useMemo, + Ref, + ComponentPropsWithoutRef, +} from "react"; + +type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & { + imgRef?: Ref<HTMLImageElement>; + src?: Blob | string | null; + keyBySrc?: boolean; +}; + +export default function BlobImage(props: BlobImageProps) { + const { imgRef, src, keyBySrc, ...otherProps } = props; + + const [url, setUrl] = useState<string | null | undefined>(undefined); + + useEffect(() => { + if (src instanceof Blob) { + const url = URL.createObjectURL(src); + setUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setUrl(src); + } + }, [src]); + + const key = useMemo(() => { + if (keyBySrc) { + return url == null ? undefined : btoa(url); + } else { + return undefined; + } + }, [url, keyBySrc]); + + return <img key={key} ref={imgRef} {...otherProps} src={url ?? undefined} />; +} diff --git a/FrontEnd/src/components/Card.css b/FrontEnd/src/components/Card.css new file mode 100644 index 00000000..6d655eb9 --- /dev/null +++ b/FrontEnd/src/components/Card.css @@ -0,0 +1,20 @@ +.cru-card {
+ border-radius: var(--cru-card-border-radius);
+ transition: all 0.3s;
+}
+
+.cru-card-background-none {
+ background-color: transparent;
+}
+
+.cru-card-background-solid {
+ background-color: var(--cru-background-color);
+}
+
+.cru-card-background-grayscale {
+ background-color: var(--cru-container-background-color);
+}
+
+.cru-card-border-color {
+ border: 2px solid var(--cru-card-border-color);
+}
diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx new file mode 100644 index 00000000..5d3ef630 --- /dev/null +++ b/FrontEnd/src/components/Card.tsx @@ -0,0 +1,39 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import { ThemeColor } from "./common"; + +import "./Card.css"; + +interface CardProps extends ComponentPropsWithoutRef<"div"> { + containerRef?: Ref<HTMLDivElement>; + color?: ThemeColor; + border?: "color" | "none"; + background?: "color" | "solid" | "grayscale" | "none"; +} + +export default function Card({ + color, + background, + border, + className, + children, + containerRef, + ...otherProps +}: CardProps) { + return ( + <div + ref={containerRef} + className={classNames( + "cru-card", + `cru-card-${color ?? "primary"}`, + `cru-card-border-${border ?? "color"}`, + `cru-card-background-${background ?? "solid"}`, + className, + )} + {...otherProps} + > + {children} + </div> + ); +} diff --git a/FrontEnd/src/components/Icon.css b/FrontEnd/src/components/Icon.css new file mode 100644 index 00000000..3c83b0e9 --- /dev/null +++ b/FrontEnd/src/components/Icon.css @@ -0,0 +1,4 @@ +.cru-icon { + color: var(--cru-theme-color); + font-size: 1.4rem; +} diff --git a/FrontEnd/src/components/Icon.tsx b/FrontEnd/src/components/Icon.tsx new file mode 100644 index 00000000..e5cf598e --- /dev/null +++ b/FrontEnd/src/components/Icon.tsx @@ -0,0 +1,28 @@ +import { ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import { ThemeColor } from "./common"; + +import "./Icon.css"; + +interface IconButtonProps extends ComponentPropsWithoutRef<"i"> { + icon: string; + color?: ThemeColor; + size?: string | number; +} + +export default function Icon(props: IconButtonProps) { + const { icon, color, size, style, className, ...otherProps } = props; + + return ( + <i + style={size != null ? { ...style, fontSize: size } : style} + className={classNames( + `cru-theme-${color ?? "primary"}`, + `bi-${icon} cru-icon`, + className, + )} + {...otherProps} + /> + ); +} diff --git a/FrontEnd/src/views/common/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css index 2c4d0a8c..03d2038f 100644 --- a/FrontEnd/src/views/common/ImageCropper.css +++ b/FrontEnd/src/components/ImageCropper.css @@ -1,18 +1,16 @@ -.image-cropper-container {
+.cru-image-cropper-container {
position: relative;
box-sizing: border-box;
+ display: flex;
user-select: none;
}
-.image-cropper-container img {
- position: absolute;
- left: 0;
- top: 0;
+.cru-image-cropper-container img {
width: 100%;
height: 100%;
}
-.image-cropper-mask-container {
+.cru-image-cropper-mask-container {
position: absolute;
left: 0;
top: 0;
@@ -21,18 +19,16 @@ overflow: hidden;
}
-.image-cropper-mask {
+.cru-image-cropper-mask {
position: absolute;
box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
touch-action: none;
}
-.image-cropper-handler {
+.cru-image-cropper-handler {
position: absolute;
- width: 26px;
- height: 26px;
border: black solid 2px;
border-radius: 50%;
background: white;
touch-action: none;
-}
+}
\ No newline at end of file diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx new file mode 100644 index 00000000..4dfdd0cd --- /dev/null +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -0,0 +1,323 @@ +import { useState, useRef, PointerEvent } from "react"; +import classnames from "classnames"; + +import { UiLogicError, geometry } from "./common"; + +import BlobImage from "./BlobImage"; + +import "./ImageCropper.css"; + +const { Rect } = geometry; + +type Rect = geometry.Rect; +type Movement = geometry.Movement; + +export function crop( + image: HTMLImageElement, + clip: Rect, + mimeType: string, +): Promise<Blob> { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + canvas.width = clip.width; + canvas.height = clip.height; + const context = canvas.getContext("2d"); + + if (context == null) throw new Error("Failed to create context."); + + context.drawImage( + image, + clip.left, + clip.top, + clip.width, + clip.height, + 0, + 0, + clip.width, + clip.height, + ); + + canvas.toBlob((blob) => { + if (blob == null) { + reject(new Error("canvas.toBlob returns null")); + } else { + resolve(blob); + } + }, mimeType); + }); +} + +interface ImageInfo { + element: HTMLImageElement; + width: number; + height: number; + ratio: number; + landscape: boolean; + rect: Rect; +} + +export interface CropConstraint { + ratio?: number; + // minClipWidth?: number; + // minClipHeight?: number; + // maxClipWidth?: number; + // maxClipHeight?: number; +} + +function generateImageInfo(imageElement: HTMLImageElement): ImageInfo { + const { naturalWidth, naturalHeight } = imageElement; + const imageRatio = naturalHeight / naturalWidth; + + return { + element: imageElement, + width: naturalWidth, + height: naturalHeight, + ratio: imageRatio, + landscape: imageRatio < 1, + rect: new Rect(0, 0, naturalWidth, naturalHeight), + }; +} + +interface ImageCropperProps { + clip: Rect; + image: Blob | string | null; + imageElementCallback: (element: HTMLImageElement | null) => void; + onImageLoad: () => void; + onMove: (movement: Movement, originalClip: Rect) => void; + onResize: (movement: Movement, originalClip: Rect) => void; + containerClassName?: string; +} + +export function useImageCrop( + file: File | null, + options?: { + constraint?: CropConstraint; + }, +): { + clip: Rect; + setClip: (clip: Rect) => void; + canCrop: boolean; + crop: () => Promise<Blob>; + imageCropperProps: ImageCropperProps; +} { + const targetRatio = options?.constraint?.ratio; + + const [imageElement, setImageElement] = useState<HTMLImageElement | null>( + null, + ); + const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null); + const [clip, setClip] = useState<Rect>(Rect.empty); + + if (imageElement == null && imageInfo != null) { + setImageInfo(null); + setClip(Rect.empty); + } + + const canCrop = file != null && imageElement != null && imageInfo != null; + + return { + clip, + setClip, + canCrop, + crop() { + if (!canCrop) throw new UiLogicError(); + return crop(imageElement, clip, file.type); + }, + imageCropperProps: { + clip, + image: file, + imageElementCallback: setImageElement, + onMove: (movement, originalClip) => { + if (imageInfo == null) return; + const newClip = geometry.adjustRectToContainer( + originalClip.copy().move(movement), + imageInfo.rect, + "move", + { + targetRatio, + }, + ); + setClip(newClip); + }, + onResize: (movement, originalClip) => { + if (imageInfo == null) return; + const newClip = geometry.adjustRectToContainer( + originalClip.copy().expand(movement), + imageInfo.rect, + "resize", + { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" }, + ); + setClip(newClip); + }, + onImageLoad: () => { + if (imageElement == null) throw new UiLogicError(); + const image = generateImageInfo(imageElement); + setImageInfo(image); + setClip( + geometry.adjustRectToContainer(Rect.max, image.rect, "both", { + targetRatio, + }), + ); + }, + }, + }; +} + +interface PointerState { + x: number; + y: number; + pointerId: number; + originalClip: Rect; +} + +const imageCropperHandlerSize = 15; + +export function ImageCropper(props: ImageCropperProps) { + function convertClipToElement( + clip: Rect, + imageElement: HTMLImageElement, + ): Rect { + const xRatio = imageElement.clientWidth / imageElement.naturalWidth; + const yRatio = imageElement.clientHeight / imageElement.naturalHeight; + return Rect.from({ + left: xRatio * clip.left, + top: yRatio * clip.top, + width: xRatio * clip.width, + height: yRatio * clip.height, + }); + } + + function convertMovementFromElement( + move: Movement, + imageElement: HTMLImageElement, + ): Movement { + const xRatio = imageElement.naturalWidth / imageElement.clientWidth; + const yRatio = imageElement.naturalHeight / imageElement.clientHeight; + return { + x: xRatio * move.x, + y: yRatio * move.y, + }; + } + + const { + clip, + image, + imageElementCallback, + onImageLoad, + onMove, + onResize, + containerClassName, + } = props; + + const pointerStateRef = useRef<PointerState | null>(null); + const [imageElement, setImageElement] = useState<HTMLImageElement | null>( + null, + ); + + const clipInElement: Rect = + imageElement != null + ? convertClipToElement(clip, imageElement) + : Rect.empty; + + const actOnMovement = ( + e: PointerEvent, + change: (movement: Movement, originalClip: Rect) => void, + ) => { + if ( + imageElement == null || + pointerStateRef.current == null || + pointerStateRef.current.pointerId != e.pointerId + ) { + return; + } + + const { x, y, originalClip } = pointerStateRef.current; + + const movement = { + x: e.clientX - x, + y: e.clientY - y, + }; + + change(convertMovementFromElement(movement, imageElement), originalClip); + }; + + const onPointerDown = (e: PointerEvent) => { + if (imageElement == null || pointerStateRef.current != null) return; + + e.currentTarget.setPointerCapture(e.pointerId); + + pointerStateRef.current = { + x: e.clientX, + y: e.clientY, + pointerId: e.pointerId, + originalClip: clip, + }; + }; + + const onPointerUp = (e: PointerEvent) => { + if ( + pointerStateRef.current == null || + pointerStateRef.current.pointerId != e.pointerId + ) { + return; + } + + e.currentTarget.releasePointerCapture(e.pointerId); + pointerStateRef.current = null; + }; + + const onMaskPointerMove = (e: PointerEvent) => { + actOnMovement(e, onMove); + }; + + const onResizeHandlerPointerMove = (e: PointerEvent) => { + actOnMovement(e, onResize); + }; + + return ( + <div + className={classnames("cru-image-cropper-container", containerClassName)} + > + <BlobImage + imgRef={(element) => { + setImageElement(element); + imageElementCallback(element); + }} + src={image} + onLoad={onImageLoad} + /> + <div className="cru-image-cropper-mask-container"> + <div + className="cru-image-cropper-mask" + style={ + clipInElement == null + ? undefined + : { + left: clipInElement.left, + top: clipInElement.top, + width: clipInElement.width, + height: clipInElement.height, + } + } + onPointerMove={onMaskPointerMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + /> + </div> + <div + className="cru-image-cropper-handler" + style={{ + left: + clipInElement.left + clipInElement.width - imageCropperHandlerSize, + top: + clipInElement.top + clipInElement.height - imageCropperHandlerSize, + width: imageCropperHandlerSize * 2, + height: imageCropperHandlerSize * 2, + }} + onPointerMove={onResizeHandlerPointerMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + /> + </div> + ); +} diff --git a/FrontEnd/src/views/common/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx index 81ba1f67..81ba1f67 100644 --- a/FrontEnd/src/views/common/LoadFailReload.tsx +++ b/FrontEnd/src/components/LoadFailReload.tsx diff --git a/FrontEnd/src/components/Page.css b/FrontEnd/src/components/Page.css new file mode 100644 index 00000000..b22d83af --- /dev/null +++ b/FrontEnd/src/components/Page.css @@ -0,0 +1,8 @@ +.cru-page { + padding: var(--cru-page-padding); +} + +.cru-page-no-top-padding { + padding-top: 0; +} + diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx new file mode 100644 index 00000000..8c9febcc --- /dev/null +++ b/FrontEnd/src/components/Page.tsx @@ -0,0 +1,17 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import "./Page.css"; + +interface PageProps extends ComponentPropsWithoutRef<"div"> { + noTopPadding?: boolean; + pageRef?: Ref<HTMLDivElement>; +} + +export default function Page({ noTopPadding, pageRef, className, children }: PageProps) { + return ( + <div ref={pageRef} className={classNames(className, "cru-page", noTopPadding && "cru-page-no-top-padding")}> + {children} + </div> + ); +} diff --git a/FrontEnd/src/views/common/SearchInput.css b/FrontEnd/src/components/SearchInput.css index f0503016..818b2917 100644 --- a/FrontEnd/src/views/common/SearchInput.css +++ b/FrontEnd/src/components/SearchInput.css @@ -1,8 +1,8 @@ .cru-search-input {
display: flex;
- flex-wrap: wrap;
+ gap: 1em;
}
.cru-search-input-input {
width: 100%;
-}
+}
\ No newline at end of file diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx new file mode 100644 index 00000000..b1de6227 --- /dev/null +++ b/FrontEnd/src/components/SearchInput.tsx @@ -0,0 +1,50 @@ +import classNames from "classnames"; + +import { useC, Text } from "./common"; +import { LoadingButton } from "./button"; + +import "./SearchInput.css"; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onButtonClick: () => void; + loading?: boolean; + className?: string; + buttonText?: Text; +} + +export default function SearchInput({ + value, + onChange, + onButtonClick, + loading, + className, + buttonText, +}: SearchInputProps) { + const c = useC(); + + return ( + <div className={classNames("cru-search-input", className)}> + <input + type="search" + className="cru-search-input-input" + value={value} + onChange={(event) => { + const { value } = event.currentTarget; + onChange(value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + onButtonClick(); + event.preventDefault(); + } + }} + /> + + <LoadingButton loading={loading} onClick={onButtonClick}> + {c(buttonText ?? "search")} + </LoadingButton> + </div> + ); +} diff --git a/FrontEnd/src/components/Skeleton.css b/FrontEnd/src/components/Skeleton.css new file mode 100644 index 00000000..0f78d3b5 --- /dev/null +++ b/FrontEnd/src/components/Skeleton.css @@ -0,0 +1,20 @@ +.cru-skeleton {
+ padding: 0 1em;
+}
+
+.cru-skeleton-line {
+ height: 1em;
+ background-color: hsl(0 0% 90%);
+ margin: 0.7em 0;
+ border-radius: 0.2em;
+}
+
+@media (prefers-color-scheme: dark) {
+ .cru-skeleton-line {
+ background-color: hsl(0 0% 20%);
+ }
+}
+
+.cru-skeleton-line:last-child {
+ width: 50%;
+}
diff --git a/FrontEnd/src/components/Skeleton.tsx b/FrontEnd/src/components/Skeleton.tsx new file mode 100644 index 00000000..03f80df5 --- /dev/null +++ b/FrontEnd/src/components/Skeleton.tsx @@ -0,0 +1,22 @@ +import { ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import { range } from "~src/utilities"; + +import "./Skeleton.css"; + +interface SkeletonProps extends ComponentPropsWithoutRef<"div"> { + lineNumber?: number; +} + +export default function Skeleton(props: SkeletonProps) { + const { lineNumber, className, ...otherProps } = props; + + return ( + <div className={classNames(className, "cru-skeleton")} {...otherProps}> + {range(lineNumber ?? 3).map((i) => ( + <div key={i} className="cru-skeleton-line" /> + ))} + </div> + ); +} diff --git a/FrontEnd/src/views/common/Spinner.css b/FrontEnd/src/components/Spinner.css index a1de68d2..a1de68d2 100644 --- a/FrontEnd/src/views/common/Spinner.css +++ b/FrontEnd/src/components/Spinner.css diff --git a/FrontEnd/src/components/Spinner.tsx b/FrontEnd/src/components/Spinner.tsx new file mode 100644 index 00000000..50ccf0b2 --- /dev/null +++ b/FrontEnd/src/components/Spinner.tsx @@ -0,0 +1,46 @@ +import { CSSProperties, ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import "./Spinner.css"; + +const sizeMap: Record<string, string> = { + sm: "18px", + md: "30px", + lg: "42px", +}; + +function calculateSize(size: SpinnerProps["size"]) { + if (size == null) { + return "1em"; + } + if (typeof size === "number") { + return size; + } + if (size in sizeMap) { + return sizeMap[size]; + } + return size; +} + +export interface SpinnerProps extends ComponentPropsWithoutRef<"span"> { + size?: number | string; + className?: string; + style?: CSSProperties; +} + +export default function Spinner(props: SpinnerProps) { + const { size, className, style, ...otherProps } = props; + const calculatedSize = calculateSize(size); + + return ( + <span + className={classNames("cru-spinner", className)} + style={{ + width: calculatedSize, + height: calculatedSize, + ...style, + }} + {...otherProps} + /> + ); +} diff --git a/FrontEnd/src/views/common/TimelineLogo.tsx b/FrontEnd/src/components/TimelineLogo.tsx index e06ed0f5..e06ed0f5 100644 --- a/FrontEnd/src/views/common/TimelineLogo.tsx +++ b/FrontEnd/src/components/TimelineLogo.tsx diff --git a/FrontEnd/src/components/alert/AlertHost.tsx b/FrontEnd/src/components/alert/AlertHost.tsx new file mode 100644 index 00000000..59f8f27c --- /dev/null +++ b/FrontEnd/src/components/alert/AlertHost.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from "react"; +import classNames from "classnames"; + +import { ThemeColor, useC, Text } from "../common"; +import IconButton from "../button/IconButton"; + +import { alertService, AlertInfoWithId } from "./AlertService"; + +import "./alert.css"; + +interface AutoCloseAlertProps { + color: ThemeColor; + message: Text; + onDismiss?: () => void; + onIn?: () => void; + onOut?: () => void; +} + +function Alert({ + color, + message, + onDismiss, + onIn, + onOut, +}: AutoCloseAlertProps) { + const c = useC(); + + return ( + <div + className={classNames("cru-alert", `cru-theme-${color}`)} + onPointerEnter={onIn} + onPointerLeave={onOut} + > + <div className="cru-alert-message">{c(message)}</div> + <IconButton + icon="x" + color="danger" + className="cru-alert-close-button" + onClick={onDismiss} + /> + </div> + ); +} + +export default function AlertHost() { + const [alerts, setAlerts] = useState<AlertInfoWithId[]>([]); + + useEffect(() => { + const listener = (alerts: AlertInfoWithId[]) => { + setAlerts(alerts); + }; + + alertService.registerListener(listener); + + return () => { + alertService.unregisterListener(listener); + }; + }, []); + + return ( + <div className="alert-container"> + {alerts.map((alert) => { + return ( + <Alert + key={alert.id} + message={alert.message} + color={alert.color ?? "primary"} + onIn={() => { + alertService.clearDismissTimer(alert.id); + }} + onOut={() => { + alertService.resetDismissTimer(alert.id); + }} + onDismiss={() => { + alertService.dismiss(alert.id); + }} + /> + ); + })} + </div> + ); +} diff --git a/FrontEnd/src/components/alert/AlertService.ts b/FrontEnd/src/components/alert/AlertService.ts new file mode 100644 index 00000000..b9cda752 --- /dev/null +++ b/FrontEnd/src/components/alert/AlertService.ts @@ -0,0 +1,114 @@ +import { ThemeColor, Text } from "../common"; + +const defaultDismissTime = 5000; + +export interface AlertInfo { + color?: ThemeColor; + message: Text; + dismissTime?: number | "never"; +} + +export interface AlertInfoWithId extends AlertInfo { + id: number; +} + +interface AlertServiceAlert extends AlertInfoWithId { + timerId: number | null; +} + +export type AlertsListener = (alerts: AlertInfoWithId[]) => void; + +export class AlertService { + private listeners: AlertsListener[] = []; + private alerts: AlertServiceAlert[] = []; + private currentId = 1; + + getAlert(alertId?: number | null | undefined): AlertServiceAlert | null { + for (const alert of this.alerts) { + if (alert.id === alertId) return alert; + } + return null; + } + + registerListener(listener: AlertsListener): void { + this.listeners.push(listener); + listener(this.alerts); + } + + unregisterListener(listener: AlertsListener): void { + this.listeners = this.listeners.filter((l) => l !== listener); + } + + notify() { + for (const listener of this.listeners) { + listener(this.alerts); + } + } + + push(alert: AlertInfo): void { + const newAlert: AlertServiceAlert = { + ...alert, + id: this.currentId++, + timerId: null, + }; + + this.alerts = [...this.alerts, newAlert]; + this._resetDismissTimer(newAlert); + + this.notify(); + } + + private _dismiss(alert: AlertServiceAlert) { + if (alert.timerId != null) { + window.clearTimeout(alert.timerId); + } + this.alerts = this.alerts.filter((a) => a !== alert); + this.notify(); + } + + dismiss(alertId?: number | null | undefined) { + const alert = this.getAlert(alertId); + if (alert != null) { + this._dismiss(alert); + } + } + + private _clearDismissTimer(alert: AlertServiceAlert) { + if (alert.timerId != null) { + window.clearTimeout(alert.timerId); + alert.timerId = null; + } + } + + clearDismissTimer(alertId?: number | null | undefined) { + const alert = this.getAlert(alertId); + if (alert != null) { + this._clearDismissTimer(alert); + } + } + + private _resetDismissTimer( + alert: AlertServiceAlert, + dismissTime?: number | null | undefined, + ) { + this._clearDismissTimer(alert); + + const realDismissTime = + dismissTime ?? alert.dismissTime ?? defaultDismissTime; + + if (typeof realDismissTime === "number") { + alert.timerId = window.setTimeout(() => { + this._dismiss(alert); + }, realDismissTime); + } + } + + resetDismissTimer(alertId?: number | null | undefined) { + const alert = this.getAlert(alertId); + if (alert != null) { + this._resetDismissTimer(alert); + } + } +} + +export const alertService = new AlertService(); diff --git a/FrontEnd/src/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css new file mode 100644 index 00000000..948256de --- /dev/null +++ b/FrontEnd/src/components/alert/alert.css @@ -0,0 +1,21 @@ +.alert-container {
+ position: fixed;
+ z-index: 1040;
+}
+
+.cru-alert {
+ border-radius: 5px;
+ border: var(--cru-theme-color) 2px solid;
+ color: var(--cru-text-primary-color);
+ background-color: var(--cru-container-background-color);
+
+ margin: 1em;
+ padding: 0.5em 1em;
+
+ display: flex;
+ align-items: center;
+}
+
+.cru-alert-close-button {
+ margin-left: auto;
+}
\ No newline at end of file diff --git a/FrontEnd/src/components/alert/index.ts b/FrontEnd/src/components/alert/index.ts new file mode 100644 index 00000000..1be0c2ec --- /dev/null +++ b/FrontEnd/src/components/alert/index.ts @@ -0,0 +1,8 @@ +import { alertService, AlertInfo } from "./AlertService"; +import { default as AlertHost } from "./AlertHost"; + +export { alertService, AlertHost }; + +export function pushAlert(alert: AlertInfo): void { + alertService.push(alert); +} diff --git a/FrontEnd/src/components/breakpoints.ts b/FrontEnd/src/components/breakpoints.ts new file mode 100644 index 00000000..fb281610 --- /dev/null +++ b/FrontEnd/src/components/breakpoints.ts @@ -0,0 +1,3 @@ +export const breakpoints = { + sm: 576, +} as const; diff --git a/FrontEnd/src/components/button/Button.css b/FrontEnd/src/components/button/Button.css new file mode 100644 index 00000000..1da70f0e --- /dev/null +++ b/FrontEnd/src/components/button/Button.css @@ -0,0 +1,64 @@ +.cru-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.3s;
+ border-radius: 0.2em;
+ border: 1px solid;
+ cursor: pointer;
+}
+
+.cru-button:not(.outline) {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+}
+
+.cru-button:not(.outline):hover {
+ background-color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button:not(.outline):focus {
+ background-color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button:not(.outline):active {
+ background-color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button:not(.outline):disabled {
+ color: var(--cru-push-button-disabled-text-color);
+ background-color: var(--cru-push-button-disabled-color);
+ border-color: var(--cru-push-button-disabled-color);
+ cursor: auto;
+}
+
+
+.cru-button.outline {
+ color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+ background-color: transparent;
+}
+
+.cru-button.outline:hover {
+ color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button.outline:focus {
+ color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button.outline:active {
+ color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button.outline:disabled {
+ color: var(--cru-clickable-disabled-color);
+ border-color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
diff --git a/FrontEnd/src/views/common/button/Button.tsx b/FrontEnd/src/components/button/Button.tsx index be605328..30ea8c11 100644 --- a/FrontEnd/src/views/common/button/Button.tsx +++ b/FrontEnd/src/components/button/Button.tsx @@ -1,14 +1,13 @@ import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; -import { I18nText, useC } from "@/common"; -import { PaletteColorType } from "@/palette"; +import { Text, useC, ClickableColor } from "../common"; import "./Button.css"; interface ButtonProps extends ComponentPropsWithoutRef<"button"> { - color?: PaletteColorType; - text?: I18nText; + color?: ClickableColor; + text?: Text; outline?: boolean; buttonRef?: Ref<HTMLButtonElement> | null; } @@ -34,8 +33,8 @@ export default function Button(props: ButtonProps) { <button ref={buttonRef} className={classNames( - "cru-" + (color ?? "primary"), "cru-button", + `cru-clickable-${color ?? "primary"}`, outline && "outline", className, )} diff --git a/FrontEnd/src/components/button/ButtonRow.css b/FrontEnd/src/components/button/ButtonRow.css new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/FrontEnd/src/components/button/ButtonRow.css diff --git a/FrontEnd/src/components/button/ButtonRow.tsx b/FrontEnd/src/components/button/ButtonRow.tsx new file mode 100644 index 00000000..eea60cc4 --- /dev/null +++ b/FrontEnd/src/components/button/ButtonRow.tsx @@ -0,0 +1,62 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import Button from "./Button"; +import FlatButton from "./FlatButton"; +import IconButton from "./IconButton"; +import LoadingButton from "./LoadingButton"; + +import "./ButtonRow.css"; + +type ButtonRowButton = ( + | { + type: "normal"; + props: ComponentPropsWithoutRef<typeof Button>; + } + | { + type: "flat"; + props: ComponentPropsWithoutRef<typeof FlatButton>; + } + | { + type: "icon"; + props: ComponentPropsWithoutRef<typeof IconButton>; + } + | { type: "loading"; props: ComponentPropsWithoutRef<typeof LoadingButton> } +) & { key: string | number }; + +interface ButtonRowProps { + className?: string; + containerRef?: Ref<HTMLDivElement>; + buttons: ButtonRowButton[]; + buttonsClassName?: string; +} + +export default function ButtonRow({ + className, + containerRef, + buttons, + buttonsClassName, +}: ButtonRowProps) { + return ( + <div ref={containerRef} className={classNames("cru-button-row", className)}> + {buttons.map((button) => { + const { type, key, props } = button; + const newClassName = classNames(props.className, buttonsClassName); + switch (type) { + case "normal": + return <Button key={key} {...props} className={newClassName} />; + case "flat": + return <FlatButton key={key} {...props} className={newClassName} />; + case "icon": + return <IconButton key={key} {...props} className={newClassName} />; + case "loading": + return ( + <LoadingButton key={key} {...props} className={newClassName} /> + ); + default: + throw new Error(); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx new file mode 100644 index 00000000..a54425cc --- /dev/null +++ b/FrontEnd/src/components/button/ButtonRowV2.tsx @@ -0,0 +1,146 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import { Text, ClickableColor } from "../common"; + +import Button from "./Button"; +import FlatButton from "./FlatButton"; +import IconButton from "./IconButton"; +import LoadingButton from "./LoadingButton"; + +import "./ButtonRow.css"; + +type ButtonAction = "major" | "minor"; + +interface ButtonRowV2ButtonBase { + key: string | number; + action?: ButtonAction; + color?: ClickableColor; + disabled?: boolean; + onClick?: () => void; +} + +interface ButtonRowV2ButtonWithNoType extends ButtonRowV2ButtonBase { + type?: undefined | null; + text: Text; + outline?: boolean; + props?: ComponentPropsWithoutRef<typeof Button>; +} + +interface ButtonRowV2NormalButton extends ButtonRowV2ButtonBase { + type: "normal"; + text: Text; + outline?: boolean; + props?: ComponentPropsWithoutRef<typeof Button>; +} + +interface ButtonRowV2FlatButton extends ButtonRowV2ButtonBase { + type: "flat"; + text: Text; + props?: ComponentPropsWithoutRef<typeof FlatButton>; +} + +interface ButtonRowV2IconButton extends ButtonRowV2ButtonBase { + type: "icon"; + icon: string; + props?: ComponentPropsWithoutRef<typeof IconButton>; +} + +interface ButtonRowV2LoadingButton extends ButtonRowV2ButtonBase { + type: "loading"; + text: Text; + loading?: boolean; + props?: ComponentPropsWithoutRef<typeof LoadingButton>; +} + +type ButtonRowV2Button = + | ButtonRowV2ButtonWithNoType + | ButtonRowV2NormalButton + | ButtonRowV2FlatButton + | ButtonRowV2IconButton + | ButtonRowV2LoadingButton; + +interface ButtonRowV2Props { + className?: string; + containerRef?: Ref<HTMLDivElement>; + buttons: ButtonRowV2Button[]; + buttonsClassName?: string; +} + +export default function ButtonRowV2({ + className, + containerRef, + buttons, + buttonsClassName, +}: ButtonRowV2Props) { + return ( + <div ref={containerRef} className={classNames("cru-button-row", className)}> + {buttons.map((button) => { + const { key, action, color, disabled, onClick } = button; + + const realAction: ButtonAction = action ?? "minor"; + const realColor = + color ?? (realAction === "major" ? "primary" : "minor"); + + const commonProps = { key, color: realColor, disabled, onClick }; + const newClassName = classNames( + button.props?.className, + buttonsClassName, + ); + + switch (button.type) { + case null: + case undefined: + case "normal": { + const { text, outline, props } = button; + return ( + <Button + {...commonProps} + text={text} + outline={outline ?? realAction !== "major"} + {...props} + className={newClassName} + /> + ); + } + case "flat": { + const { text, props } = button; + return ( + <FlatButton + {...commonProps} + text={text} + {...props} + className={newClassName} + /> + ); + } + case "icon": { + const { icon, props } = button; + return ( + <IconButton + {...commonProps} + icon={icon} + {...props} + className={newClassName} + /> + ); + } + case "loading": { + const { text, loading, props } = button; + return ( + <LoadingButton + {...commonProps} + text={text} + loading={loading} + {...props} + className={newClassName} + /> + ); + } + default: + throw new Error(); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/components/button/FlatButton.css b/FrontEnd/src/components/button/FlatButton.css new file mode 100644 index 00000000..2050946c --- /dev/null +++ b/FrontEnd/src/components/button/FlatButton.css @@ -0,0 +1,27 @@ +.cru-flat-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.5s;
+ border-radius: 0.2em;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: 1px none;
+ color: var(--cru-clickable-normal-color);
+ cursor: pointer;
+}
+
+.cru-flat-button:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-flat-button:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-flat-button:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-flat-button:disabled {
+ color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
\ No newline at end of file diff --git a/FrontEnd/src/views/common/button/FlatButton.tsx b/FrontEnd/src/components/button/FlatButton.tsx index 49912b68..aad02e76 100644 --- a/FrontEnd/src/views/common/button/FlatButton.tsx +++ b/FrontEnd/src/components/button/FlatButton.tsx @@ -1,14 +1,13 @@ import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; -import { I18nText, useC } from "@/common"; -import { PaletteColorType } from "@/palette"; +import { Text, useC, ClickableColor } from "../common"; import "./FlatButton.css"; interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> { - color?: PaletteColorType; - text?: I18nText; + color?: ClickableColor; + text?: Text; buttonRef?: Ref<HTMLButtonElement> | null; } @@ -25,8 +24,8 @@ export default function FlatButton(props: FlatButtonProps) { <button ref={buttonRef} className={classNames( - "cru-" + (color ?? "primary"), "cru-flat-button", + `cru-clickable-${color ?? "primary"}`, className, )} {...otherProps} diff --git a/FrontEnd/src/components/button/IconButton.css b/FrontEnd/src/components/button/IconButton.css new file mode 100644 index 00000000..a3747201 --- /dev/null +++ b/FrontEnd/src/components/button/IconButton.css @@ -0,0 +1,30 @@ +.cru-icon-button { + color: var(--cru-clickable-normal-color); + font-size: 1.4rem; + background: none; + border: none; + transition: all 0.5s; + cursor: pointer; + user-select: none; +} + +.cru-icon-button:hover { + color: var(--cru-clickable-hover-color); +} + +.cru-icon-button:focus { + color: var(--cru-clickable-focus-color); +} + +.cru-icon-button:active { + color: var(--cru-clickable-active-color); +} + +.cru-flat-button:disabled { + color: var(--cru-clickable-disabled-color); + cursor: auto; +} + +.cru-icon-button.large { + font-size: 1.6rem; +} diff --git a/FrontEnd/src/views/common/button/IconButton.tsx b/FrontEnd/src/components/button/IconButton.tsx index 652a8b09..e0862167 100644 --- a/FrontEnd/src/views/common/button/IconButton.tsx +++ b/FrontEnd/src/components/button/IconButton.tsx @@ -1,14 +1,15 @@ import { ComponentPropsWithoutRef } from "react"; import classNames from "classnames"; -import { PaletteColorType } from "@/palette"; +import { ClickableColor } from "../common"; import "./IconButton.css"; interface IconButtonProps extends ComponentPropsWithoutRef<"i"> { icon: string; - color?: PaletteColorType; + color?: ClickableColor; large?: boolean; + disabled?: boolean; // TODO: Not implemented } export default function IconButton(props: IconButtonProps) { @@ -18,9 +19,9 @@ export default function IconButton(props: IconButtonProps) { <button className={classNames( "cru-icon-button", + `cru-clickable-${color ?? "grayscale"}`, large && "large", "bi-" + icon, - color ? "cru-" + color : "cru-primary", className, )} {...otherProps} diff --git a/FrontEnd/src/components/button/LoadingButton.css b/FrontEnd/src/components/button/LoadingButton.css new file mode 100644 index 00000000..23fadd3d --- /dev/null +++ b/FrontEnd/src/components/button/LoadingButton.css @@ -0,0 +1,13 @@ +.cru-loading-button { + display: flex; + align-items: center; +} + +.cru-loading-button-spinner { + margin-left: 0.5em; +} + +.cru-loading-button-loading { + color: var(--cru-clickable-normal-color) !important; + border-color: var(--cru-clickable-normal-color) !important; +}
\ No newline at end of file diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx new file mode 100644 index 00000000..9d65a2b3 --- /dev/null +++ b/FrontEnd/src/components/button/LoadingButton.tsx @@ -0,0 +1,39 @@ +import classNames from "classnames"; + +import { I18nText, ClickableColor, useC } from "../common"; +import Spinner from "../Spinner"; + +import "./LoadingButton.css"; + +interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> { + color?: ClickableColor; + text?: I18nText; + loading?: boolean; +} + +export default function LoadingButton(props: LoadingButtonProps) { + const c = useC(); + + const { color, text, loading, disabled, className, children, ...otherProps } = + props; + + if (text != null && children != null) { + console.warn("You can't set both text and children props."); + } + + return ( + <button + disabled={disabled || loading} + className={classNames( + "cru-button outline cru-loading-button", + `cru-clickable-${color ?? "primary"}`, + loading && "cru-loading-button-loading", + className, + )} + {...otherProps} + > + {text != null ? c(text) : children} + {loading && <Spinner className="cru-loading-button-spinner" />} + </button> + ); +} diff --git a/FrontEnd/src/components/button/index.tsx b/FrontEnd/src/components/button/index.tsx new file mode 100644 index 00000000..b5aa5470 --- /dev/null +++ b/FrontEnd/src/components/button/index.tsx @@ -0,0 +1,15 @@ +import Button from "./Button"; +import FlatButton from "./FlatButton"; +import IconButton from "./IconButton"; +import LoadingButton from "./LoadingButton"; +import ButtonRow from "./ButtonRow"; +import ButtonRowV2 from "./ButtonRowV2"; + +export { + Button, + FlatButton, + IconButton, + LoadingButton, + ButtonRow, + ButtonRowV2, +}; diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts new file mode 100644 index 00000000..a6c3e705 --- /dev/null +++ b/FrontEnd/src/components/common.ts @@ -0,0 +1,22 @@ +import "./index.css"; + +export type { Text, I18nText } from "~src/common"; +export { UiLogicError, c, convertI18nText, useC } from "~src/common"; + +export const themeColors = [ + "primary", + "secondary", + "danger", + "create", +] as const; + +export type ThemeColor = (typeof themeColors)[number]; + +export type ClickableColor = ThemeColor | "grayscale" | "light" | "minor"; + +export { breakpoints } from "./breakpoints"; + +export * as geometry from "~src/utilities/geometry"; + +export * as array from "~src/utilities/array" + diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 00000000..8b0a4219 --- /dev/null +++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,54 @@ +import { useC, Text, ThemeColor } from "../common"; + +import Dialog from "./Dialog"; +import DialogContainer from "./DialogContainer"; +import { useCloseDialog } from "./DialogProvider"; + +export default function ConfirmDialog({ + onConfirm, + title, + body, + color, +}: { + onConfirm: () => void; + title: Text; + body: Text; + color?: ThemeColor; + bodyColor?: ThemeColor; +}) { + const c = useC(); + + const closeDialog = useCloseDialog(); + + return ( + <Dialog color={color ?? "danger"}> + <DialogContainer + title={title} + titleColor={color ?? "danger"} + buttonsV2={[ + { + key: "cancel", + type: "normal", + action: "minor", + + text: "operationDialog.cancel", + onClick: closeDialog, + }, + { + key: "confirm", + type: "normal", + action: "major", + text: "operationDialog.confirm", + color: "danger", + onClick: () => { + onConfirm(); + closeDialog(); + }, + }, + ]} + > + <div>{c(body)}</div> + </DialogContainer> + </Dialog> + ); +} diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css new file mode 100644 index 00000000..23b663db --- /dev/null +++ b/FrontEnd/src/components/dialog/Dialog.css @@ -0,0 +1,39 @@ +.cru-dialog-overlay {
+ position: fixed;
+ z-index: 1040;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ overflow: auto;
+ padding: 20vh 1em;
+}
+
+.cru-dialog-background {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-color: var(--cru-dialog-overlay-color);
+ opacity: 0.8;
+}
+
+.cru-dialog-container {
+ max-width: 100%;
+ min-width: 30vw;
+
+ margin: 2em auto;
+
+ border: var(--cru-theme-color) 2px solid;
+ border-radius: 5px;
+ padding: 1.5em;
+ background-color: var(--cru-dialog-container-background-color);
+}
+
+@media (min-width: 576px) {
+ .cru-dialog-container {
+ max-width: 800px;
+ }
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx new file mode 100644 index 00000000..043a8eec --- /dev/null +++ b/FrontEnd/src/components/dialog/Dialog.tsx @@ -0,0 +1,55 @@ +import { ReactNode, useRef } from "react"; +import ReactDOM from "react-dom"; +import classNames from "classnames"; + +import { ThemeColor, UiLogicError } from "../common"; + +import { useCloseDialog } from "./DialogProvider"; + +import "./Dialog.css"; + +const optionalPortalElement = document.getElementById("portal"); +if (optionalPortalElement == null) { + throw new UiLogicError(); +} +const portalElement = optionalPortalElement; + +interface DialogProps { + color?: ThemeColor; + children?: ReactNode; + disableCloseOnClickOnOverlay?: boolean; +} + +export default function Dialog({ + color, + children, + disableCloseOnClickOnOverlay, +}: DialogProps) { + const closeDialog = useCloseDialog(); + + const lastPointerDownIdRef = useRef<number | null>(null); + + return ReactDOM.createPortal( + <div + className={classNames( + `cru-theme-${color ?? "primary"}`, + "cru-dialog-overlay", + )} + > + <div + className="cru-dialog-background" + onPointerDown={(e) => { + lastPointerDownIdRef.current = e.pointerId; + }} + onPointerUp={(e) => { + if (lastPointerDownIdRef.current === e.pointerId) { + if (!disableCloseOnClickOnOverlay) closeDialog(); + } + lastPointerDownIdRef.current = null; + }} + /> + <div className="cru-dialog-container">{children}</div> + </div>, + portalElement, + ); +} diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css new file mode 100644 index 00000000..f0d27a66 --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogContainer.css @@ -0,0 +1,20 @@ +.cru-dialog-container-title { + font-size: 1.2em; + font-weight: bold; + color: var(--cru-theme-color); + margin-bottom: 0.5em; +} + +.cru-dialog-container-hr { + margin: 1em 0; + border-color: var(--cru-text-minor-color); +} + +.cru-dialog-container-button-row { + display: flex; + justify-content: flex-end; +} + +.cru-dialog-container-button { + margin-left: 1em; +} diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx new file mode 100644 index 00000000..6ee4e134 --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogContainer.tsx @@ -0,0 +1,95 @@ +import { ComponentProps, Ref, ReactNode } from "react"; +import classNames from "classnames"; + +import { ThemeColor, Text, useC } from "../common"; +import { ButtonRow, ButtonRowV2 } from "../button"; + +import "./DialogContainer.css"; + +interface DialogContainerBaseProps { + className?: string; + title: Text; + titleColor?: ThemeColor; + titleClassName?: string; + titleRef?: Ref<HTMLDivElement>; + bodyContainerClassName?: string; + bodyContainerRef?: Ref<HTMLDivElement>; + buttonsClassName?: string; + buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"]; + children: ReactNode; +} + +interface DialogContainerWithButtonsProps extends DialogContainerBaseProps { + buttons: ComponentProps<typeof ButtonRow>["buttons"]; +} + +interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps { + buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"]; +} + +type DialogContainerProps = + | DialogContainerWithButtonsProps + | DialogContainerWithButtonsV2Props; + +export default function DialogContainer(props: DialogContainerProps) { + const { + className, + title, + titleColor, + titleClassName, + titleRef, + bodyContainerClassName, + bodyContainerRef, + buttonsClassName, + buttonsContainerRef, + children, + } = props; + + const c = useC(); + + return ( + <div className={classNames(className)}> + <div + ref={titleRef} + className={classNames( + `cru-dialog-container-title cru-theme-${titleColor ?? "primary"}`, + titleClassName, + )} + > + {c(title)} + </div> + <hr className="cru-dialog-container-hr" /> + <div + ref={bodyContainerRef} + className={classNames( + "cru-dialog-container-body", + bodyContainerClassName, + )} + > + {children} + </div> + <hr className="cru-dialog-container-hr" /> + {"buttons" in props ? ( + <ButtonRow + containerRef={buttonsContainerRef} + className={classNames( + "cru-dialog-container-button-row", + buttonsClassName, + )} + buttons={props.buttons} + buttonsClassName="cru-dialog-container-button" + /> + ) : ( + <ButtonRowV2 + containerRef={buttonsContainerRef} + className={classNames( + "cru-dialog-container-button-row", + buttonsClassName, + )} + buttons={props.buttonsV2} + buttonsClassName="cru-dialog-container-button" + /> + )} + </div> + ); +} diff --git a/FrontEnd/src/components/dialog/DialogProvider.tsx b/FrontEnd/src/components/dialog/DialogProvider.tsx new file mode 100644 index 00000000..bb85e4cf --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogProvider.tsx @@ -0,0 +1,95 @@ +import { useState, useContext, createContext, ReactNode } from "react"; + +import { UiLogicError } from "../common"; + +type DialogMap<D extends string> = { + [K in D]: ReactNode; +}; + +interface DialogController<D extends string> { + currentDialog: D | null; + currentDialogReactNode: ReactNode; + canSwitchDialog: boolean; + switchDialog: (newDialog: D | null) => void; + setCanSwitchDialog: (enable: boolean) => void; + closeDialog: () => void; + forceSwitchDialog: (newDialog: D | null) => void; + forceCloseDialog: () => void; +} + +export function useDialog<D extends string>( + dialogs: DialogMap<D>, + options?: { + initDialog?: D | null; + onClose?: { + [K in D]?: () => void; + }; + }, +): { + controller: DialogController<D>; + switchDialog: (newDialog: D | null) => void; + forceSwitchDialog: (newDialog: D | null) => void; + createDialogSwitch: (newDialog: D | null) => () => void; +} { + const [canSwitchDialog, setCanSwitchDialog] = useState<boolean>(true); + const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null); + + const forceSwitchDialog = (newDialog: D | null) => { + if (dialog != null) { + options?.onClose?.[dialog]?.(); + } + setDialog(newDialog); + setCanSwitchDialog(true); + }; + + const switchDialog = (newDialog: D | null) => { + if (canSwitchDialog) { + forceSwitchDialog(newDialog); + } + }; + + const controller: DialogController<D> = { + currentDialog: dialog, + currentDialogReactNode: dialog == null ? null : dialogs[dialog], + canSwitchDialog, + switchDialog, + setCanSwitchDialog, + closeDialog: () => switchDialog(null), + forceSwitchDialog, + forceCloseDialog: () => forceSwitchDialog(null), + }; + + return { + controller, + switchDialog, + forceSwitchDialog, + createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog), + }; +} + +const DialogControllerContext = createContext<DialogController<string> | null>( + null, +); + +export function useDialogController(): DialogController<string> { + const controller = useContext(DialogControllerContext); + if (controller == null) throw new UiLogicError("not in dialog provider"); + return controller; +} + +export function useCloseDialog(): () => void { + const controller = useDialogController(); + return controller.closeDialog; +} + +export function DialogProvider<D extends string>({ + controller, +}: { + controller: DialogController<D>; +}) { + return ( + <DialogControllerContext.Provider value={controller as never}> + {controller.currentDialogReactNode} + </DialogControllerContext.Provider> + ); +} diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css new file mode 100644 index 00000000..ce07c6ac --- /dev/null +++ b/FrontEnd/src/components/dialog/FullPageDialog.css @@ -0,0 +1,30 @@ +.cru-dialog-full-page {
+ position: fixed;
+ z-index: 1030;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--cru-background-color);
+ padding-top: 56px;
+}
+
+.cru-dialog-full-page-top-bar {
+ height: 56px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ background-color: var(--cru-theme-color);
+ display: flex;
+ align-items: center;
+}
+
+.cru-dialog-full-page-content-container {
+ overflow: scroll;
+}
+
+.cru-dialog-full-page-back-button {
+ margin-left: 0.5em;
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx new file mode 100644 index 00000000..575abf7f --- /dev/null +++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx @@ -0,0 +1,52 @@ +import { ReactNode } from "react"; +import { createPortal } from "react-dom"; +import classNames from "classnames"; + +import { ThemeColor, UiLogicError } from "../common"; +import { IconButton } from "../button"; + +import { useCloseDialog } from "./DialogProvider"; + +import "./FullPageDialog.css"; + +const optionalPortalElement = document.getElementById("portal"); +if (optionalPortalElement == null) { + throw new UiLogicError(); +} +const portalElement = optionalPortalElement; + +interface FullPageDialogProps { + color?: ThemeColor; + contentContainerClassName?: string; + children: ReactNode; +} + +export default function FullPageDialog({ + color, + children, + contentContainerClassName, +}: FullPageDialogProps) { + const closeDialog = useCloseDialog(); + + return createPortal( + <div className={`cru-dialog-full-page cru-theme-${color ?? "primary"}`}> + <div className="cru-dialog-full-page-top-bar"> + <IconButton + icon="arrow-left" + color="light" + className="cru-dialog-full-page-back-button" + onClick={closeDialog} + /> + </div> + <div + className={classNames( + "cru-dialog-full-page-content-container", + contentContainerClassName, + )} + > + {children} + </div> + </div>, + portalElement, + ); +} diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css new file mode 100644 index 00000000..28f73c9d --- /dev/null +++ b/FrontEnd/src/components/dialog/OperationDialog.css @@ -0,0 +1,4 @@ +.cru-operation-dialog-input-group {
+ display: block;
+ margin: 0.5em 0;
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx new file mode 100644 index 00000000..6ca4d0a0 --- /dev/null +++ b/FrontEnd/src/components/dialog/OperationDialog.tsx @@ -0,0 +1,221 @@ +import { useState, ReactNode, ComponentProps } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; +import { + useInputs, + InputGroup, + Initializer as InputInitializer, + InputConfirmValueDict, +} from "../input"; +import { ButtonRowV2 } from "../button"; +import Dialog from "./Dialog"; +import DialogContainer from "./DialogContainer"; +import { useDialogController } from "./DialogProvider"; + +import "./OperationDialog.css"; + +interface OperationDialogPromptProps { + message?: Text; + customMessage?: Text; + customMessageNode?: ReactNode; + className?: string; +} + +function OperationDialogPrompt(props: OperationDialogPromptProps) { + const { message, customMessage, customMessageNode, className } = props; + + const c = useC(); + + return ( + <div className={classNames(className, "cru-operation-dialog-prompt")}> + {message && <p>{c(message)}</p>} + {customMessageNode ?? (customMessage != null ? c(customMessage) : null)} + </div> + ); +} + +export interface OperationDialogProps<TData> { + color?: ThemeColor; + inputColor?: ThemeColor; + title: Text; + inputPrompt?: Text; + inputPromptNode?: ReactNode; + successPrompt?: (data: TData) => Text; + successPromptNode?: (data: TData) => ReactNode; + failurePrompt?: (error: unknown) => Text; + failurePromptNode?: (error: unknown) => ReactNode; + + inputs: InputInitializer; + + onProcess: (inputs: InputConfirmValueDict) => Promise<TData>; + onSuccessAndClose?: (data: TData) => void; +} + +function OperationDialog<TData>(props: OperationDialogProps<TData>) { + const { + color, + inputColor, + title, + inputPrompt, + inputPromptNode, + successPrompt, + successPromptNode, + failurePrompt, + failurePromptNode, + inputs, + onProcess, + onSuccessAndClose, + } = props; + + if (process.env.NODE_ENV === "development") { + if (inputPrompt && inputPromptNode) { + console.log("InputPrompt and inputPromptNode are both set."); + } + if (successPrompt && successPromptNode) { + console.log("SuccessPrompt and successPromptNode are both set."); + } + if (failurePrompt && failurePromptNode) { + console.log("FailurePrompt and failurePromptNode are both set."); + } + } + + type Step = + | { type: "input" } + | { type: "process" } + | { + type: "success"; + data: TData; + } + | { + type: "failure"; + data: unknown; + }; + + const dialogController = useDialogController(); + + const [step, setStep] = useState<Step>({ type: "input" }); + + const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } = + useInputs({ + init: inputs, + }); + + function close() { + if (step.type !== "process") { + dialogController.closeDialog(); + if (step.type === "success" && onSuccessAndClose) { + onSuccessAndClose?.(step.data); + } + } else { + console.log("Attempt to close modal dialog when processing."); + } + } + + function onConfirm() { + const result = confirm(); + if (result.type === "ok") { + setStep({ type: "process" }); + dialogController.setCanSwitchDialog(false); + setAllDisabled(true); + onProcess(result.values) + .then( + (d) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + }, + ) + .finally(() => { + dialogController.setCanSwitchDialog(true); + }); + } + } + + let body: ReactNode; + let buttons: ComponentProps<typeof ButtonRowV2>["buttons"]; + + if (step.type === "input" || step.type === "process") { + const isProcessing = step.type === "process"; + + body = ( + <div> + <OperationDialogPrompt + customMessage={inputPrompt} + customMessageNode={inputPromptNode} + /> + <InputGroup + containerClassName="cru-operation-dialog-input-group" + color={inputColor ?? "primary"} + {...inputGroupProps} + /> + </div> + ); + buttons = [ + { + key: "cancel", + text: "operationDialog.cancel", + onClick: close, + disabled: isProcessing, + }, + { + key: "confirm", + type: "loading", + action: "major", + text: "operationDialog.confirm", + color, + loading: isProcessing, + disabled: hasErrorAndDirty, + onClick: onConfirm, + }, + ]; + } else { + const result = step; + + const promptProps: OperationDialogPromptProps = + result.type === "success" + ? { + message: "operationDialog.success", + customMessage: successPrompt?.(result.data), + customMessageNode: successPromptNode?.(result.data), + } + : { + message: "operationDialog.error", + customMessage: failurePrompt?.(result.data), + customMessageNode: failurePromptNode?.(result.data), + }; + body = ( + <div> + <OperationDialogPrompt {...promptProps} /> + </div> + ); + + buttons = [ + { + key: "ok", + type: "normal", + action: "major", + color: "create", + text: "operationDialog.ok", + onClick: close, + }, + ]; + } + + return ( + <Dialog color={color}> + <DialogContainer title={title} titleColor={color} buttonsV2={buttons}> + {body} + </DialogContainer> + </Dialog> + ); +} + +export default OperationDialog; diff --git a/FrontEnd/src/components/dialog/index.tsx b/FrontEnd/src/components/dialog/index.tsx new file mode 100644 index 00000000..9ca06de2 --- /dev/null +++ b/FrontEnd/src/components/dialog/index.tsx @@ -0,0 +1,12 @@ +export { default as Dialog } from "./Dialog"; +export { default as FullPageDialog } from "./FullPageDialog"; +export { default as OperationDialog } from "./OperationDialog"; +export { default as ConfirmDialog } from "./ConfirmDialog"; +export { default as DialogContainer } from "./DialogContainer"; + +export { + useDialog, + useDialogController, + useCloseDialog, + DialogProvider, +} from "./DialogProvider"; diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts new file mode 100644 index 00000000..98ce729e --- /dev/null +++ b/FrontEnd/src/components/hooks/index.ts @@ -0,0 +1,5 @@ +export { useMobile } from "./responsive"; +export { default as useClickOutside } from "./useClickOutside"; +export { default as useScrollToBottom } from "./useScrollToBottom"; +export { default as useWindowLeave } from "./useWindowLeave"; +export { default as useAutoUnsubscribePromise } from "./useAutoUnsubscribePromise"; diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts new file mode 100644 index 00000000..42c134ef --- /dev/null +++ b/FrontEnd/src/components/hooks/responsive.ts @@ -0,0 +1,7 @@ +import { useMediaQuery } from "react-responsive"; + +import { breakpoints } from "../breakpoints"; + +export function useMobile(onChange?: (mobile: boolean) => void): boolean { + return useMediaQuery({ maxWidth: breakpoints.sm }, undefined, onChange); +} diff --git a/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts new file mode 100644 index 00000000..01c5a1db --- /dev/null +++ b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts @@ -0,0 +1,24 @@ +import { useEffect, DependencyList } from "react"; + +export default function useAutoUnsubscribePromise<T>( + promiseGenerator: () => Promise<T> | null | undefined, + resultHandler: (data: T) => void, + dependencies?: DependencyList | undefined, +) { + useEffect(() => { + let subscribe = true; + const promise = promiseGenerator(); + if (promise) { + void promise.then((data) => { + if (subscribe) { + resultHandler(data); + } + }); + + return () => { + subscribe = false; + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [promiseGenerator, resultHandler, ...(dependencies ?? [])]); +} diff --git a/FrontEnd/src/utilities/hooks/useClickOutside.ts b/FrontEnd/src/components/hooks/useClickOutside.ts index 6dcbf7b3..828ce7e3 100644 --- a/FrontEnd/src/utilities/hooks/useClickOutside.ts +++ b/FrontEnd/src/components/hooks/useClickOutside.ts @@ -3,7 +3,7 @@ import { useRef, useEffect } from "react"; export default function useClickOutside( element: HTMLElement | null | undefined, onClickOutside: () => void, - nextTick?: boolean + nextTick?: boolean, ): void { const onClickOutsideRef = useRef<() => void>(onClickOutside); diff --git a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts b/FrontEnd/src/components/hooks/useScrollToBottom.ts index 216746f4..79fcda16 100644 --- a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts +++ b/FrontEnd/src/components/hooks/useScrollToBottom.ts @@ -1,6 +1,5 @@ import { useRef, useEffect } from "react"; -import { fromEvent } from "rxjs"; -import { filter, throttleTime } from "rxjs/operators"; +import { fromEvent, filter, throttleTime } from "rxjs"; function useScrollToBottom( handler: () => void, @@ -8,7 +7,7 @@ function useScrollToBottom( option = { maxOffset: 5, throttle: 1000, - } + }, ): void { const handlerRef = useRef<(() => void) | null>(null); @@ -26,9 +25,9 @@ function useScrollToBottom( filter( () => window.scrollY >= - document.body.scrollHeight - window.innerHeight - option.maxOffset + document.body.scrollHeight - window.innerHeight - option.maxOffset, ), - throttleTime(option.throttle) + throttleTime(option.throttle), ) .subscribe(() => { if (enable) { diff --git a/FrontEnd/src/components/hooks/useWindowLeave.ts b/FrontEnd/src/components/hooks/useWindowLeave.ts new file mode 100644 index 00000000..ecd999d4 --- /dev/null +++ b/FrontEnd/src/components/hooks/useWindowLeave.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; + +import { useC, Text } from "../common"; + +export default function useWindowLeave( + allow: boolean, + message: Text = "timeline.confirmLeave", +) { + const c = useC(); + + useEffect(() => { + if (!allow) { + window.onbeforeunload = () => { + return c(message); + }; + + return () => { + window.onbeforeunload = null; + }; + } + }, [c, allow, message]); +} diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css new file mode 100644 index 00000000..83b48318 --- /dev/null +++ b/FrontEnd/src/components/index.css @@ -0,0 +1,49 @@ +@import "./theme.css";
+
+* {
+ box-sizing: border-box;
+ margin-inline: 0;
+ margin-block: 0;
+}
+
+body {
+ font-family: var(--cru-default-font-family);
+ background: var(--cru-body-background-color);
+ color: var(--cru-text-major-color);
+ line-height: 1.2;
+}
+
+textarea {
+ transition: border-color 0.3s;
+ border-color: var(--cru-text-minor-color);
+ background: var(--cru-background-color);
+}
+
+textarea:hover {
+ border-color: var(--cru-clickable-primary-hover-color);
+}
+
+textarea:focus {
+ border-color: var(--cru-clickable-primary-normal-color);
+}
+
+.alert-container {
+ position: fixed;
+ z-index: 1070;
+}
+
+@media (min-width: 576px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ left: 0;
+ text-align: center;
+ }
+}
diff --git a/FrontEnd/src/components/input/InputGroup.css b/FrontEnd/src/components/input/InputGroup.css new file mode 100644 index 00000000..7e905b1e --- /dev/null +++ b/FrontEnd/src/components/input/InputGroup.css @@ -0,0 +1,54 @@ +.cru-input-group { + display: block; +} + +.cru-input-container { + margin: 0.4em 0; +} + +.cru-input-label { + display: block; + color: var(--cru-clickable-normal-color); + font-size: 0.9em; + margin-bottom: 0.3em; +} + +.cru-input-label-inline { + margin-inline-start: 0.5em; +} + +.cru-input-type-text input { + appearance: none; + display: block; + border: 1px solid; + /* color: var(--cru-surface-on-color); */ + /* background-color: var(--cru-surface-color); */ + margin: 0; + font-size: 1em; + padding: 0.2em; +} + +.cru-input-type-text input:hover { + border-color: var(--cru-clickable-hover-color); +} + +.cru-input-type-text input:focus { + border-color: var(--cru-clickable-focus-color); +} + +.cru-input-type-text input:disabled { + border-color: var(--cru-clickable-disabled-color); +} + +.cru-input-error { + display: block; + font-size: 0.8em; + color: var(--cru-danger-color); + margin-top: 0.4em; +} + +.cru-input-helper { + display: block; + font-size: 0.8em; + color: var(--cru-primary-color); +}
\ No newline at end of file diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx new file mode 100644 index 00000000..47a43b38 --- /dev/null +++ b/FrontEnd/src/components/input/InputGroup.tsx @@ -0,0 +1,463 @@ +/** + * Some notes for InputGroup: + * This is one of the most complicated components in this project. + * Probably because the feature is complex and involved user inputs. + * + * I hope it contains following features: + * - Input features + * - Supports a wide range of input types. + * - Validator to validate user inputs. + * - Can set initial values. + * - Dirty, aka, has user touched this input. + * - Developer friendly + * - Easy to use APIs. + * - Type check as much as possible. + * - UI + * - Configurable appearance. + * - Can display helper and error messages. + * - Easy to extend, like new input types. + * + * So here is some design decisions: + * Inputs are identified by its _key_. + * `InputGroup` component takes care of only UI and no logic. + * `useInputs` hook takes care of logic and generate props for `InputGroup`. + */ + +import { useState, Ref, useId } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; + +import "./InputGroup.css"; + +export interface InputBase { + key: string; + label: Text; + helper?: Text; + disabled?: boolean; + error?: Text; +} + +export interface TextInput extends InputBase { + type: "text"; + value: string; + password?: boolean; +} + +export interface BoolInput extends InputBase { + type: "bool"; + value: boolean; +} + +export interface SelectInputOption { + value: string; + label: Text; + icon?: string; +} + +export interface SelectInput extends InputBase { + type: "select"; + value: string; + options: SelectInputOption[]; +} + +export type Input = TextInput | BoolInput | SelectInput; + +export type InputValue = Input["value"]; + +export type InputValueDict = Record<string, InputValue>; +export type InputErrorDict = Record<string, Text>; +export type InputDisabledDict = Record<string, boolean>; +export type InputDirtyDict = Record<string, boolean>; +// use never so you don't have to cast everywhere +export type InputConfirmValueDict = Record<string, never>; + +export type GeneralInputErrorDict = { + [key: string]: Text | null | undefined; +}; + +type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">; + +export type InputInfo = { + [I in Input as I["type"]]: MakeInputInfo<I>; +}[Input["type"]]; + +export type Validator = ( + values: InputValueDict, + errors: GeneralInputErrorDict, + inputs: InputInfo[], +) => void; + +export type InputScheme = { + inputs: InputInfo[]; + validator?: Validator; +}; + +export type InputData = { + values: InputValueDict; + errors: InputErrorDict; + disabled: InputDisabledDict; + dirties: InputDirtyDict; +}; + +export type State = { + scheme: InputScheme; + data: InputData; +}; + +export type DataInitialization = { + values?: InputValueDict; + errors?: GeneralInputErrorDict; + disabled?: InputDisabledDict; + dirties?: InputDirtyDict; +}; + +export type Initialization = { + scheme: InputScheme; + dataInit?: DataInitialization; +}; + +export type GeneralInitialization = Initialization | InputScheme | InputInfo[]; + +export type Initializer = GeneralInitialization | (() => GeneralInitialization); + +export interface InputGroupProps { + color?: ThemeColor; + containerClassName?: string; + containerRef?: Ref<HTMLDivElement>; + + inputs: Input[]; + onChange: (index: number, value: Input["value"]) => void; +} + +function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> { + const result = { ...o }; + for (const key of Object.keys(result)) { + if (result[key] == null) { + delete result[key]; + } + } + return result as never; +} + +export type ConfirmResult = + | { + type: "ok"; + values: InputConfirmValueDict; + } + | { + type: "error"; + errors: InputErrorDict; + }; + +function validate( + validator: Validator | null | undefined, + values: InputValueDict, + inputs: InputInfo[], +): InputErrorDict { + const errors: GeneralInputErrorDict = {}; + validator?.(values, errors, inputs); + return cleanObject(errors); +} + +export function useInputs(options: { init: Initializer }): { + inputGroupProps: InputGroupProps; + hasError: boolean; + hasErrorAndDirty: boolean; + confirm: () => ConfirmResult; + setAllDisabled: (disabled: boolean) => void; +} { + function initializeValue( + input: InputInfo, + value?: InputValue | null, + ): InputValue { + if (input.type === "text") { + return value ?? ""; + } else if (input.type === "bool") { + return value ?? false; + } else if (input.type === "select") { + return value ?? input.options[0].value; + } + throw new Error("Unknown input type"); + } + + function initialize(generalInitialization: GeneralInitialization): State { + const initialization: Initialization = Array.isArray(generalInitialization) + ? { scheme: { inputs: generalInitialization } } + : "scheme" in generalInitialization + ? generalInitialization + : { scheme: generalInitialization }; + + const { scheme, dataInit } = initialization; + const { inputs, validator } = scheme; + const keys = inputs.map((input) => input.key); + + if (process.env.NODE_ENV === "development") { + const checkKeys = (dict: Record<string, unknown> | undefined) => { + if (dict != null) { + for (const key of Object.keys(dict)) { + if (!keys.includes(key)) { + console.warn(""); + } + } + } + }; + + checkKeys(dataInit?.values); + checkKeys(dataInit?.errors ?? {}); + checkKeys(dataInit?.disabled); + checkKeys(dataInit?.dirties); + } + + function clean<V>( + dict: Record<string, V> | null | undefined, + ): Record<string, NonNullable<V>> { + return dict != null ? cleanObject(dict) : {}; + } + + const values: InputValueDict = {}; + const disabled: InputDisabledDict = clean(dataInit?.disabled); + const dirties: InputDirtyDict = clean(dataInit?.dirties); + const isErrorSet = dataInit?.errors != null; + let errors: InputErrorDict = clean(dataInit?.errors); + + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + const { key } = input; + + values[key] = initializeValue(input, dataInit?.values?.[key]); + } + + if (isErrorSet) { + if (process.env.NODE_ENV === "development") { + console.log( + "You explicitly set errors (not undefined) in initializer, so validator won't run.", + ); + } + } else { + errors = validate(validator, values, inputs); + } + + return { + scheme, + data: { + values, + errors, + disabled, + dirties, + }, + }; + } + + const { init } = options; + const initializer = typeof init === "function" ? init : () => init; + + const [state, setState] = useState<State>(() => initialize(initializer())); + + const { scheme, data } = state; + const { validator } = scheme; + + function createAllBooleanDict(value: boolean): Record<string, boolean> { + const result: InputDirtyDict = {}; + for (const key of scheme.inputs.map((input) => input.key)) { + result[key] = value; + } + return result; + } + + const createAllDirties = () => createAllBooleanDict(true); + + const componentInputs: Input[] = []; + + for (let i = 0; i < scheme.inputs.length; i++) { + const input = scheme.inputs[i]; + const value = data.values[input.key]; + const error = data.errors[input.key]; + const disabled = data.disabled[input.key] ?? false; + const dirty = data.dirties[input.key] ?? false; + const componentInput: Input = { + ...input, + value: value as never, + disabled, + error: dirty ? error : undefined, + }; + componentInputs.push(componentInput); + } + + const hasError = Object.keys(data.errors).length > 0; + const hasDirty = Object.keys(data.dirties).some((key) => data.dirties[key]); + + return { + inputGroupProps: { + inputs: componentInputs, + onChange: (index, value) => { + const input = scheme.inputs[index]; + const { key } = input; + const newValues = { ...data.values, [key]: value }; + const newDirties = { ...data.dirties, [key]: true }; + const newErrors = validate(validator, newValues, scheme.inputs); + setState({ + scheme, + data: { + ...data, + values: newValues, + errors: newErrors, + dirties: newDirties, + }, + }); + }, + }, + hasError, + hasErrorAndDirty: hasError && hasDirty, + confirm() { + const newDirties = createAllDirties(); + const newErrors = validate(validator, data.values, scheme.inputs); + + setState({ + scheme, + data: { + ...data, + dirties: newDirties, + errors: newErrors, + }, + }); + + if (Object.keys(newErrors).length !== 0) { + return { + type: "error", + errors: newErrors, + }; + } else { + return { + type: "ok", + values: data.values as InputConfirmValueDict, + }; + } + }, + setAllDisabled(disabled: boolean) { + setState({ + scheme, + data: { + ...data, + disabled: createAllBooleanDict(disabled), + }, + }); + }, + }; +} + +export function InputGroup({ + color, + inputs, + onChange, + containerRef, + containerClassName, +}: InputGroupProps) { + const c = useC(); + + const id = useId(); + + return ( + <div + ref={containerRef} + className={classNames( + "cru-input-group", + `cru-clickable-${color ?? "primary"}`, + containerClassName, + )} + > + {inputs.map((item, index) => { + const { key, type, value, label, error, helper, disabled } = item; + + const getContainerClassName = ( + ...additionalClassNames: classNames.ArgumentArray + ) => + classNames( + `cru-input-container cru-input-type-${type}`, + error && "error", + ...additionalClassNames, + ); + + const changeValue = (value: InputValue) => { + onChange(index, value); + }; + + const inputId = `${id}-${key}`; + + if (type === "text") { + const { password } = item; + return ( + <div + key={key} + className={getContainerClassName(password && "password")} + > + {label && ( + <label className="cru-input-label" htmlFor={inputId}> + {c(label)} + </label> + )} + <input + id={inputId} + type={password ? "password" : "text"} + value={value} + onChange={(event) => { + const v = event.target.value; + changeValue(v); + }} + disabled={disabled} + /> + {error && <div className="cru-input-error">{c(error)}</div>} + {helper && <div className="cru-input-helper">{c(helper)}</div>} + </div> + ); + } else if (type === "bool") { + return ( + <div key={key} className={getContainerClassName()}> + <input + id={inputId} + type="checkbox" + checked={value} + onChange={(event) => { + const v = event.currentTarget.checked; + changeValue(v); + }} + disabled={disabled} + /> + <label className="cru-input-label-inline" htmlFor={inputId}> + {c(label)} + </label> + {error && <div className="cru-input-error">{c(error)}</div>} + {helper && <div className="cru-input-helper">{c(helper)}</div>} + </div> + ); + } else if (type === "select") { + return ( + <div key={key} className={getContainerClassName()}> + <label className="cru-input-label" htmlFor={inputId}> + {c(label)} + </label> + <select + id={inputId} + value={value} + onChange={(event) => { + const e = event.target.value; + changeValue(e); + }} + disabled={disabled} + > + {item.options.map((option) => { + return ( + <option value={option.value} key={option.value}> + {option.icon} + {c(option.label)} + </option> + ); + })} + </select> + </div> + ); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/components/input/index.ts b/FrontEnd/src/components/input/index.ts new file mode 100644 index 00000000..ca183089 --- /dev/null +++ b/FrontEnd/src/components/input/index.ts @@ -0,0 +1,11 @@ +export { useInputs, InputGroup } from "./InputGroup"; + +export type { + InputValueDict, + InputErrorDict, + InputDirtyDict, + InputDisabledDict, + InputConfirmValueDict, + Validator, + Initializer, +} from "./InputGroup"; diff --git a/FrontEnd/src/components/list/ListContainer.css b/FrontEnd/src/components/list/ListContainer.css new file mode 100644 index 00000000..53781834 --- /dev/null +++ b/FrontEnd/src/components/list/ListContainer.css @@ -0,0 +1,4 @@ +.cru-list-container { + border: 1px solid var(--cru-clickable-primary-normal-color); + border-radius: 5px; +} diff --git a/FrontEnd/src/components/list/ListContainer.tsx b/FrontEnd/src/components/list/ListContainer.tsx new file mode 100644 index 00000000..c27e67d4 --- /dev/null +++ b/FrontEnd/src/components/list/ListContainer.tsx @@ -0,0 +1,23 @@ +import { ComponentPropsWithoutRef, forwardRef, Ref } from "react"; +import classNames from "classnames"; + +import "./ListContainer.css"; + +function _ListContainer( + { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">, + ref: Ref<HTMLDivElement>, +) { + return ( + <div + ref={ref} + className={classNames("cru-list-container", className)} + {...otherProps} + > + {children} + </div> + ); +} + +const ListContainer = forwardRef(_ListContainer); + +export default ListContainer; diff --git a/FrontEnd/src/components/list/ListItemContainer.css b/FrontEnd/src/components/list/ListItemContainer.css new file mode 100644 index 00000000..49468bc2 --- /dev/null +++ b/FrontEnd/src/components/list/ListItemContainer.css @@ -0,0 +1,7 @@ +.cru-list-item-container { + border-bottom: 1px solid var(--cru-clickable-primary-normal-color); +} + +.cru-list-item-container:last-child { + border-bottom: none; +} diff --git a/FrontEnd/src/components/list/ListItemContainer.tsx b/FrontEnd/src/components/list/ListItemContainer.tsx new file mode 100644 index 00000000..315cbd6e --- /dev/null +++ b/FrontEnd/src/components/list/ListItemContainer.tsx @@ -0,0 +1,23 @@ +import { ComponentPropsWithoutRef, forwardRef, Ref } from "react"; +import classNames from "classnames"; + +import "./ListItemContainer.css"; + +function _ListItemContainer( + { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">, + ref: Ref<HTMLDivElement>, +) { + return ( + <div + ref={ref} + className={classNames("cru-list-item-container", className)} + {...otherProps} + > + {children} + </div> + ); +} + +const ListItemContainer = forwardRef(_ListItemContainer); + +export default ListItemContainer; diff --git a/FrontEnd/src/components/list/index.ts b/FrontEnd/src/components/list/index.ts new file mode 100644 index 00000000..e183f7da --- /dev/null +++ b/FrontEnd/src/components/list/index.ts @@ -0,0 +1,4 @@ +import ListContainer from "./ListContainer"; +import ListItemContainer from "./ListItemContainer"; + +export { ListContainer, ListItemContainer }; diff --git a/FrontEnd/src/components/menu/Menu.css b/FrontEnd/src/components/menu/Menu.css new file mode 100644 index 00000000..75734533 --- /dev/null +++ b/FrontEnd/src/components/menu/Menu.css @@ -0,0 +1,36 @@ +.cru-menu {
+ min-width: 200px;
+}
+
+.cru-menu-item {
+ display: block;
+ font-size: 1em;
+ width: 100%;
+ padding: 0.5em 1.5em;
+ transition: all 0.5s;
+ color: var(--cru-clickable-normal-color);
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: none;
+ cursor: pointer;
+}
+
+.cru-menu-item:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-menu-item:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-menu-item:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-menu-item-icon {
+ margin-right: 1em;
+}
+
+.cru-menu-divider {
+ border-width: 0;
+ border-top: 1px solid var(--cru-primary-color);
+}
\ No newline at end of file diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx new file mode 100644 index 00000000..1a196a69 --- /dev/null +++ b/FrontEnd/src/components/menu/Menu.tsx @@ -0,0 +1,62 @@ +import { MouseEvent, CSSProperties } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; +import Icon from "../Icon"; + +import "./Menu.css"; + +export type MenuItem = + | { + type: "divider"; + } + | { + type: "button"; + text: Text; + icon?: string; + color?: ThemeColor; + onClick?: (e: MouseEvent<HTMLButtonElement>) => void; + }; + +export type MenuItems = MenuItem[]; + +export type MenuProps = { + items: MenuItems; + onItemClick?: (e: MouseEvent<HTMLButtonElement>) => void; + className?: string; + style?: CSSProperties; +}; + +export default function Menu({ + items, + onItemClick, + className, + style, +}: MenuProps) { + const c = useC(); + + return ( + <div className={classNames("cru-menu", className)} style={style}> + {items.map((item, index) => { + if (item.type === "divider") { + return <hr key={index} className="cru-menu-divider" />; + } else { + const { text, color, icon, onClick } = item; + return ( + <button + key={index} + className={`cru-menu-item cru-clickable-${color ?? "primary"}`} + onClick={(e) => { + onClick?.(e); + onItemClick?.(e); + }} + > + {icon != null && <Icon color={color} icon={icon} />} + {c(text)} + </button> + ); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/components/menu/PopupMenu.css b/FrontEnd/src/components/menu/PopupMenu.css new file mode 100644 index 00000000..149e0699 --- /dev/null +++ b/FrontEnd/src/components/menu/PopupMenu.css @@ -0,0 +1,7 @@ +.cru-popup-menu-menu-container {
+ z-index: 1040;
+ border-radius: 3px;
+ border: var(--cru-clickable-normal-color) 1.5px solid;
+ background-color: var(--cru-background-color);
+ overflow: hidden;
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx new file mode 100644 index 00000000..7ac2abfe --- /dev/null +++ b/FrontEnd/src/components/menu/PopupMenu.tsx @@ -0,0 +1,72 @@ +import { useState, CSSProperties, ReactNode } from "react"; +import classNames from "classnames"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; + +import { ThemeColor } from "../common"; +import { useClickOutside } from "../hooks"; +import Menu, { MenuItems } from "./Menu"; + +import "./PopupMenu.css"; + +export interface PopupMenuProps { + color?: ThemeColor; + items: MenuItems; + children?: ReactNode; + containerClassName?: string; + containerStyle?: CSSProperties; +} + +export default function PopupMenu({ + color, + items, + children, + containerClassName, + containerStyle, +}: PopupMenuProps) { + const [show, setShow] = useState<boolean>(false); + + const [referenceElement, setReferenceElement] = + useState<HTMLDivElement | null>(null); + const [popperElement, setPopperElement] = useState<HTMLDivElement | null>( + null, + ); + const { styles, attributes } = usePopper(referenceElement, popperElement); + + useClickOutside(popperElement, () => setShow(false), true); + + return ( + <div + ref={setReferenceElement} + className={classNames( + "cru-popup-menu-trigger-container", + containerClassName, + )} + style={containerStyle} + onClick={() => setShow(true)} + > + {children} + {show && + createPortal( + <div + ref={setPopperElement} + className={`cru-popup-menu-menu-container cru-clickable-${ + color ?? "primary" + }`} + style={styles.popper} + {...attributes.popper} + > + <Menu + items={items} + onItemClick={(e) => { + setShow(false); + e.stopPropagation(); + }} + /> + </div>, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")!, + )} + </div> + ); +} diff --git a/FrontEnd/src/components/tab/TabBar.css b/FrontEnd/src/components/tab/TabBar.css new file mode 100644 index 00000000..dc6970c7 --- /dev/null +++ b/FrontEnd/src/components/tab/TabBar.css @@ -0,0 +1,32 @@ +.cru-tab-bar {
+ display: flex;
+}
+
+.cru-tab-bar-tab-area {
+ display: flex;
+ align-items: center;
+ border: var(--cru-clickable-normal-color) 1.6px solid;
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.cru-tab-bar-item {
+ color: var(--cru-text-minor-color);
+ transition: all 0.2s;
+ cursor: pointer;
+ padding: 0.3em 1em;
+}
+
+.cru-tab-bar-item:hover {
+ color: var(--cru-clickable-normal-color);
+}
+
+.cru-tab-bar-item.active {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-primary-color);
+}
+
+.cru-tab-bar-action-area {
+ margin-left: auto;
+}
diff --git a/FrontEnd/src/components/tab/TabBar.tsx b/FrontEnd/src/components/tab/TabBar.tsx new file mode 100644 index 00000000..601f664d --- /dev/null +++ b/FrontEnd/src/components/tab/TabBar.tsx @@ -0,0 +1,69 @@ +import { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import classNames from "classnames"; + +import { Text, ThemeColor, useC } from "../common"; + +import "./TabBar.css"; + +export interface Tab { + name: string; + text: Text; + link?: string; + onClick?: () => void; +} + +export interface TabsProps { + activeTabName?: string; + tabs: Tab[]; + color?: ThemeColor; + actions?: ReactNode; + dense?: boolean; + className?: string; +} + +export default function TabBar(props: TabsProps) { + const { tabs, color, activeTabName, className, dense, actions } = props; + + const c = useC(); + + return ( + <div + className={classNames( + "cru-tab-bar", + dense && "dense", + `cru-clickable-${color ?? "primary"}`, + className, + )} + > + <div className="cru-tab-bar-tab-area"> + {tabs.map((tab) => { + const { name, text, link, onClick } = tab; + + const active = activeTabName === name; + const className = classNames("cru-tab-bar-item", active && "active"); + + if (link != null) { + return ( + <Link + key={name} + to={link} + onClick={onClick} + className={className} + > + {c(text)} + </Link> + ); + } else { + return ( + <span key={name} onClick={onClick} className={className}> + {c(text)} + </span> + ); + } + })} + </div> + <div className="cru-tab-bar-action-area">{actions}</div> + </div> + ); +} diff --git a/FrontEnd/src/components/tab/TabPages.css b/FrontEnd/src/components/tab/TabPages.css new file mode 100644 index 00000000..c07d042e --- /dev/null +++ b/FrontEnd/src/components/tab/TabPages.css @@ -0,0 +1,3 @@ +.cru-tab-page-container { + padding-top: 0.5em; +} diff --git a/FrontEnd/src/components/tab/TabPages.tsx b/FrontEnd/src/components/tab/TabPages.tsx new file mode 100644 index 00000000..ab45ffdf --- /dev/null +++ b/FrontEnd/src/components/tab/TabPages.tsx @@ -0,0 +1,61 @@ +import { ReactNode, useState } from "react"; +import classNames from "classnames"; + +import { Text, UiLogicError } from "../common"; + +import Tabs from "./TabBar"; + +import "./TabPages.css"; + +interface TabPage { + name: string; + text: Text; + page: ReactNode; +} + +interface TabPagesProps { + pages: TabPage[]; + actions?: ReactNode; + dense?: boolean; + className?: string; + tabBarClassName?: string; + pageContainerClassName?: string; +} + +export default function TabPages({ + pages, + actions, + dense, + className, + tabBarClassName, + pageContainerClassName, +}: TabPagesProps) { + const [tab, setTab] = useState<string>(pages[0].name); + + const currentPage = pages.find((p) => p.name === tab); + + if (currentPage == null) throw new UiLogicError(); + + return ( + <div className={className}> + <Tabs + tabs={pages.map((page) => ({ + name: page.name, + text: page.text, + onClick: () => { + setTab(page.name); + }, + }))} + dense={dense} + activeTabName={tab} + className={tabBarClassName} + actions={actions} + /> + <div + className={classNames("cru-tab-page-container", pageContainerClassName)} + > + {currentPage.page} + </div> + </div> + ); +} diff --git a/FrontEnd/src/components/tab/index.ts b/FrontEnd/src/components/tab/index.ts new file mode 100644 index 00000000..43d545cc --- /dev/null +++ b/FrontEnd/src/components/tab/index.ts @@ -0,0 +1,2 @@ +export { default as TabBar } from "./TabBar"; +export { default as TabPages } from "./TabPages"; diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css new file mode 100644 index 00000000..68dd780f --- /dev/null +++ b/FrontEnd/src/components/theme.css @@ -0,0 +1,201 @@ +:root { + --cru-default-font-family: 'Segoe UI', 'DengXian', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + --cru-page-padding: 1em 2em; + + --cru-border-radius: 4px; + --cru-card-border-radius: 4px; +} + +/* theme colors */ +:root { + --cru-primary-color: hsl(210 100% 50%); + --cru-secondary-color: hsl(30 100% 50%); + --cru-create-color: hsl(120 100% 25%); + --cru-danger-color: hsl(0 100% 50%); + --cru-warn-color: #e4a700; +} + +.cru-theme-primary { + --cru-theme-color: var(--cru-primary-color); +} + +.cru-theme-secondary { + --cru-theme-color: var(--cru-secondary-color); +} + +.cru-theme-create { + --cru-theme-color: var(--cru-create-color); +} + +.cru-theme-danger { + --cru-theme-color: var(--cru-danger-color); +} + +/* common colors */ +:root { + --cru-background-color: hsl(0 0% 100%); + --cru-container-background-color: hsl(0 0% 97%); + --cru-text-major-color: hsl(0 0% 0%); + --cru-text-minor-color: hsl(0 0% 38%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-background-color: hsl(0 0% 0%); + --cru-container-background-color: hsl(0 0% 2%); + --cru-text-major-color: hsl(0 0% 100%); + --cru-text-minor-color: hsl(0 0% 85%); + } +} + +:root { + --cru-body-background-color: var(--cru-background-color); +} + +/* dialog color */ + +:root { + --cru-dialog-overlay-color: hsl(0 0% 100%); + --cru-dialog-container-background-color: hsl(0 0% 100%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-dialog-overlay-color: hsl(0 0% 0%); + --cru-dialog-container-background-color: hsl(0 0% 0%); + } +} + +/* clickable color */ +:root { + --cru-clickable-primary-normal-color: var(--cru-primary-color); + --cru-clickable-primary-hover-color: hsl(210 100% 60%); + --cru-clickable-primary-focus-color: hsl(210 100% 60%); + --cru-clickable-primary-active-color: hsl(210 100% 70%); + --cru-clickable-secondary-normal-color: var(--cru-secondary-color); + --cru-clickable-secondary-hover-color: hsl(30 100% 60%); + --cru-clickable-secondary-focus-color: hsl(30 100% 60%); + --cru-clickable-secondary-active-color: hsl(30 100% 70%); + --cru-clickable-create-normal-color: var(--cru-create-color); + --cru-clickable-create-hover-color: hsl(120 100% 35%); + --cru-clickable-create-focus-color: hsl(120 100% 35%); + --cru-clickable-create-active-color: hsl(120 100% 35%); + --cru-clickable-danger-normal-color: var(--cru-danger-color); + --cru-clickable-danger-hover-color: hsl(0 100% 60%); + --cru-clickable-danger-focus-color: hsl(0 100% 60%); + --cru-clickable-danger-active-color: hsl(0 100% 70%); + --cru-clickable-grayscale-normal-color: hsl(0 0% 100%); + --cru-clickable-grayscale-hover-color: hsl(0 0% 92%); + --cru-clickable-grayscale-focus-color: hsl(0 0% 92%); + --cru-clickable-grayscale-active-color: hsl(0 0% 88%); + --cru-clickable-light-normal-color: hsl(0 0% 100%); + --cru-clickable-light-hover-color: hsl(0 0% 92%); + --cru-clickable-light-focus-color: hsl(0 0% 92%); + --cru-clickable-light-active-color: hsl(0 0% 88%); + --cru-clickable-minor-normal-color: hsl(0 0% 30%); + --cru-clickable-minor-hover-color: hsl(0 0% 40%); + --cru-clickable-minor-focus-color: hsl(0 0% 40%); + --cru-clickable-minor-active-color: hsl(0 0% 45%); + --cru-clickable-disabled-color: hsl(0 0% 50%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-clickable-minor-normal-color: hsl(0 0% 74%); + --cru-clickable-minor-hover-color: hsl(0 0% 82%); + --cru-clickable-minor-focus-color: hsl(0 0% 82%); + --cru-clickable-minor-active-color: hsl(0 0% 90%); + --cru-clickable-grayscale-normal-color: hsl(0 0% 0%); + --cru-clickable-grayscale-hover-color: hsl(0 0% 10%); + --cru-clickable-grayscale-focus-color: hsl(0 0% 10%); + --cru-clickable-grayscale-active-color: hsl(0 0% 20%); + } +} + +.cru-clickable-primary { + --cru-clickable-normal-color: var(--cru-clickable-primary-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-primary-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-primary-focus-color); + --cru-clickable-active-color: var(--cru-clickable-primary-active-color); +} + +.cru-clickable-secondary { + --cru-clickable-normal-color: var(--cru-clickable-secondary-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-secondary-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-secondary-focus-color); + --cru-clickable-active-color: var(--cru-clickable-secondary-active-color); +} + +.cru-clickable-create { + --cru-clickable-normal-color: var(--cru-clickable-create-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-create-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-create-focus-color); + --cru-clickable-active-color: var(--cru-clickable-create-active-color); +} + +.cru-clickable-danger { + --cru-clickable-normal-color: var(--cru-clickable-danger-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-danger-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-danger-focus-color); + --cru-clickable-active-color: var(--cru-clickable-danger-active-color); +} + +.cru-clickable-grayscale { + --cru-clickable-normal-color: var(--cru-clickable-grayscale-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-grayscale-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-grayscale-focus-color); + --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color); +} + +.cru-clickable-light { + --cru-clickable-normal-color: var(--cru-clickable-light-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-light-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-light-focus-color); + --cru-clickable-active-color: var(--cru-clickable-light-active-color); +} + +.cru-clickable-minor { + --cru-clickable-normal-color: var(--cru-clickable-minor-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-minor-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-minor-focus-color); + --cru-clickable-active-color: var(--cru-clickable-minor-active-color); +} + +/* button colors */ +:root { + /* push button colors */ + --cru-push-button-text-color: #ffffff; + --cru-push-button-disabled-text-color: hsl(0 0% 80%); +} + +/* Card colors */ +:root { + --cru-card-background-primary-color: hsl(210 100% 50%); + --cru-card-border-primary-color: hsl(210 100% 50%); + --cru-card-background-secondary-color: hsl(30 100% 50%); + --cru-card-border-secondary-color: hsl(30 100% 50%); + --cru-card-background-create-color: hsl(120 100% 25%); + --cru-card-border-create-color: hsl(120 100% 25%); + --cru-card-background-danger-color: hsl(0 100% 50%); + --cru-card-border-danger-color: hsl(0 100% 50%); +} + +.cru-card-primary { + --cru-card-background-color: var(--cru-card-background-primary-color); + --cru-card-border-color: var(--cru-card-border-primary-color) +} + +.cru-card-secondary { + --cru-card-background-color: var(--cru-card-background-secondary-color); + --cru-card-border-color: var(--cru-card-border-secondary-color) +} + +.cru-card-create { + --cru-card-background-color: var(--cru-card-background-create-color); + --cru-card-border-color: var(--cru-card-border-create-color) +} + +.cru-card-danger { + --cru-card-background-color: var(--cru-card-background-danger-color); + --cru-card-border-color: var(--cru-card-border-danger-color) +} diff --git a/FrontEnd/src/components/user/UserAvatar.tsx b/FrontEnd/src/components/user/UserAvatar.tsx new file mode 100644 index 00000000..8671f2d8 --- /dev/null +++ b/FrontEnd/src/components/user/UserAvatar.tsx @@ -0,0 +1,22 @@ +import { Ref, ComponentPropsWithoutRef } from "react"; + +import { getHttpUserClient } from "~src/http/user"; + +export interface UserAvatarProps extends ComponentPropsWithoutRef<"img"> { + username: string; + imgRef?: Ref<HTMLImageElement> | null; +} + +export default function UserAvatar({ + username, + imgRef, + ...otherProps +}: UserAvatarProps) { + return ( + <img + ref={imgRef} + src={getHttpUserClient().generateAvatarUrl(username)} + {...otherProps} + /> + ); +} diff --git a/FrontEnd/src/http/bookmark.ts b/FrontEnd/src/http/bookmark.ts index 40e121cc..311f9a0f 100644 --- a/FrontEnd/src/http/bookmark.ts +++ b/FrontEnd/src/http/bookmark.ts @@ -1,4 +1,4 @@ -import { withQuery } from "@/utilities/url"; +import { withQuery } from "~src/utilities/url"; import { axios, apiBaseUrl, extractResponseData, Page } from "./common"; diff --git a/FrontEnd/src/http/timeline.ts b/FrontEnd/src/http/timeline.ts index 401ae116..255c786e 100644 --- a/FrontEnd/src/http/timeline.ts +++ b/FrontEnd/src/http/timeline.ts @@ -1,4 +1,4 @@ -import { withQuery } from "@/utilities/url"; +import { withQuery } from "~src/utilities/url"; import { axios, diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css index 419ccb8c..f779297b 100644 --- a/FrontEnd/src/index.css +++ b/FrontEnd/src/index.css @@ -1,13 +1,5 @@ -@import "npm:bootstrap/dist/css/bootstrap-reboot.css";
-@import "npm:bootstrap/dist/css/bootstrap-grid.css";
@import "npm:bootstrap-icons/font/bootstrap-icons.css";
-@import "./views/common/index.css";
-
-body {
- background: var(--cru-background-color);
-}
-
small {
line-height: 1.2;
}
@@ -24,7 +16,7 @@ small { textarea {
resize: none;
outline: none;
- border-color: var(--cru-background-2-color);
+ border-color: var(--cru-bg-2-color);
}
textarea:hover {
@@ -35,22 +27,6 @@ textarea:focus { border-color: var(--cru-primary-color);
}
-input:not([type="checkbox"]):not([type="radio"]) {
- resize: none;
- outline: none;
- border: 1px solid;
- transition: all 0.5s;
- border-color: var(--cru-background-2-color);
-}
-
-input:hover:not([type="checkbox"]):not([type="radio"]) {
- border-color: var(--cru-primary-r2-color);
-}
-
-input:focus:not([type="checkbox"]):not([type="radio"]) {
- border-color: var(--cru-primary-color);
-}
-
.white-space-no-wrap {
white-space: nowrap;
}
@@ -75,6 +51,7 @@ i { .markdown-container {
white-space: initial;
}
+
.markdown-container img {
max-height: 200px;
max-width: 100%;
@@ -82,4 +59,4 @@ i { a {
text-decoration: none;
-}
+}
\ No newline at end of file diff --git a/FrontEnd/src/index.tsx b/FrontEnd/src/index.tsx index ba61d357..64d39cd5 100644 --- a/FrontEnd/src/index.tsx +++ b/FrontEnd/src/index.tsx @@ -1,14 +1,9 @@ -import "regenerator-runtime"; -import "core-js/modules/es.promise"; -import "core-js/modules/es.array.iterator"; - import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; import "./i18n"; -import "./palette"; import App from "./App"; @@ -18,5 +13,5 @@ const root = createRoot(container!); root.render( <StrictMode> <App /> - </StrictMode> + </StrictMode>, ); diff --git a/FrontEnd/src/locales/en/translation.json b/FrontEnd/src/locales/en/translation.json index 21c826bd..a7e4efe5 100644 --- a/FrontEnd/src/locales/en/translation.json +++ b/FrontEnd/src/locales/en/translation.json @@ -86,7 +86,7 @@ "ok": "OK!", "processing": "Processing...", "success": "Success!", - "error": "An error occured." + "error": "An error occurred." }, "timeline": { "messageCantSee": "Sorry, you are not allowed to see this timeline.😅", @@ -176,7 +176,7 @@ "noAccount": "If you don't have an account and know a register code, then click <1>here</1> to register." }, "settings": { - "subheaders": { + "subheader": { "account": "Account", "customization": "Customization" }, @@ -186,7 +186,6 @@ "logout": "Log out this account", "changeAvatar": "Change avatar", "changeNickname": "Change nickname", - "changeBookmarkVisibility": "Change bookmark visibility", "myRegisterCode": "My register code:", "myRegisterCodeDesc": "Click to create a new register code.", "renewRegisterCode": "Renew Register Code", @@ -224,23 +223,11 @@ } }, "about": { - "author": { - "title": "Site Developer", - "name": "Name: ", - "introduction": "Introduction: ", - "introductionContent": "A programmer coding based on coincidence", - "links": "Links: " - }, - "site": { - "title": "Site Information", - "content": "The name of this site is <1>Timeline</1>, which is a Web App with <3>timeline</3> as its core concept. Its frontend and backend are both developed by <5>me</5>, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.", - "repo": "GitHub Repo" - }, "credits": { "title": "Credits", - "content": "Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.", - "frontend": "Frontend: ", - "backend": "Backend: " + "content": "Timeline stands on shoulders of giants. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.", + "frontend": "Frontend", + "backend": "Backend" } }, "admin": { diff --git a/FrontEnd/src/locales/zh/translation.json b/FrontEnd/src/locales/zh/translation.json index b7212128..8a2f628f 100644 --- a/FrontEnd/src/locales/zh/translation.json +++ b/FrontEnd/src/locales/zh/translation.json @@ -176,7 +176,7 @@ "noAccount": "如果你没有账号但有一个注册码,请点击<1>这里</1>注册账号。" }, "settings": { - "subheaders": { + "subheader": { "account": "账户", "customization": "个性化" }, diff --git a/FrontEnd/src/views/admin/Admin.tsx b/FrontEnd/src/migrating/admin/Admin.tsx index 986c36b4..986c36b4 100644 --- a/FrontEnd/src/views/admin/Admin.tsx +++ b/FrontEnd/src/migrating/admin/Admin.tsx diff --git a/FrontEnd/src/views/admin/AdminNav.tsx b/FrontEnd/src/migrating/admin/AdminNav.tsx index b7385e5c..b7385e5c 100644 --- a/FrontEnd/src/views/admin/AdminNav.tsx +++ b/FrontEnd/src/migrating/admin/AdminNav.tsx diff --git a/FrontEnd/src/views/admin/MoreAdmin.tsx b/FrontEnd/src/migrating/admin/MoreAdmin.tsx index d49d211f..d49d211f 100644 --- a/FrontEnd/src/views/admin/MoreAdmin.tsx +++ b/FrontEnd/src/migrating/admin/MoreAdmin.tsx diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/migrating/admin/UserAdmin.tsx index d5179bf5..08560c87 100644 --- a/FrontEnd/src/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/migrating/admin/UserAdmin.tsx @@ -1,3 +1,6 @@ +// eslint-disable +// @ts-nocheck + import { useState, useEffect } from "react"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; @@ -5,9 +8,7 @@ import classnames from "classnames"; import { getHttpUserClient, HttpUser, kUserPermissionList } from "@/http/user"; -import OperationDialog, { - OperationDialogBoolInput, -} from "../common/dialog/OperationDialog"; +import OperationDialog from "../common/dialog/OperationDialog"; import Button from "../common/button/Button"; import Spinner from "../common/Spinner"; import FlatButton from "../common/button/FlatButton"; @@ -21,18 +22,15 @@ const CreateUserDialog: React.FC<{ return ( <OperationDialog title="admin:user.dialog.create.title" - themeColor="success" inputPrompt="admin:user.dialog.create.prompt" - inputScheme={ - [ - { type: "text", label: "admin:user.username" }, - { type: "text", label: "admin:user.password" }, - ] as const - } - onProcess={([username, password]) => + inputs={[ + { key: "username", type: "text", label: "admin:user.username" }, + { key: "password", type: "text", label: "admin:user.password" }, + ]} + onProcess={({ username, password }) => getHttpUserClient().post({ - username, - password, + username: username as string, + password: password as string, }) } onClose={close} @@ -80,13 +78,12 @@ const UserModifyDialog: React.FC<{ open={open} onClose={close} title="admin:user.dialog.modify.title" - themeColor="danger" - inputPrompt={() => ( + inputPromptNode={ <Trans i18nKey="admin:user.dialog.modify.prompt"> 0<UsernameLabel>{user.username}</UsernameLabel>2 </Trans> - )} - inputScheme={ + } + inputs={ [ { type: "text", @@ -120,7 +117,7 @@ const UserPermissionModifyDialog: React.FC<{ onSuccess: () => void; }> = ({ open, close, user, onSuccess }) => { const oldPermissionBoolList: boolean[] = kUserPermissionList.map( - (permission) => user.permissions.includes(permission) + (permission) => user.permissions.includes(permission), ); return ( @@ -139,7 +136,7 @@ const UserPermissionModifyDialog: React.FC<{ type: "bool", label: { type: "custom", value: permission }, initValue: oldPermissionBoolList[index], - }) + }), )} onProcess={async (newPermissionBoolList): Promise<boolean[]> => { for (let index = 0; index < kUserPermissionList.length; index++) { @@ -150,12 +147,12 @@ const UserPermissionModifyDialog: React.FC<{ if (newValue) { await getHttpUserClient().putUserPermission( user.username, - permission + permission, ); } else { await getHttpUserClient().deleteUserPermission( user.username, - permission + permission, ); } } diff --git a/FrontEnd/src/views/admin/index.css b/FrontEnd/src/migrating/admin/index.css index 17e24586..17e24586 100644 --- a/FrontEnd/src/views/admin/index.css +++ b/FrontEnd/src/migrating/admin/index.css diff --git a/FrontEnd/src/views/admin/index.tsx b/FrontEnd/src/migrating/admin/index.tsx index 0467711d..0467711d 100644 --- a/FrontEnd/src/views/admin/index.tsx +++ b/FrontEnd/src/migrating/admin/index.tsx diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/migrating/center/CenterBoards.tsx index a8be2c29..f1c3fc6a 100644 --- a/FrontEnd/src/views/center/CenterBoards.tsx +++ b/FrontEnd/src/migrating/center/CenterBoards.tsx @@ -1,13 +1,13 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; -import { highlightTimelineUsername } from "@/common"; +import { highlightTimelineUsername } from "~src/common"; -import { pushAlert } from "@/services/alert"; -import { useUserLoggedIn } from "@/services/user"; +import { pushAlert } from "~src/services/alert"; +import { useUserLoggedIn } from "~src/services/user"; -import { getHttpTimelineClient } from "@/http/timeline"; -import { getHttpBookmarkClient } from "@/http/bookmark"; +import { getHttpTimelineClient } from "~src/http/timeline"; +import { getHttpBookmarkClient } from "~src/http/bookmark"; import TimelineBoard from "./TimelineBoard"; diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/migrating/center/TimelineBoard.tsx index b3ccdf8c..8f4401bc 100644 --- a/FrontEnd/src/views/center/TimelineBoard.tsx +++ b/FrontEnd/src/migrating/center/TimelineBoard.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import classnames from "classnames"; import { Link } from "react-router-dom"; -import { TimelineBookmark } from "@/http/bookmark"; +import { TimelineBookmark } from "~src/http/bookmark"; import TimelineLogo from "../common/TimelineLogo"; import LoadFailReload from "../common/LoadFailReload"; diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx index 63742936..340a08fe 100644 --- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx +++ b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx @@ -1,11 +1,11 @@ import * as React from "react"; import { useNavigate } from "react-router-dom"; -import { validateTimelineName } from "@/services/timeline"; -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; +import { validateTimelineName } from "~src/services/timeline"; +import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline"; import OperationDialog from "../common/dialog/OperationDialog"; -import { useUserLoggedIn } from "@/services/user"; +import { useUserLoggedIn } from "~src/services/user"; interface TimelineCreateDialogProps { open: boolean; diff --git a/FrontEnd/src/views/center/index.css b/FrontEnd/src/migrating/center/index.css index a779ff90..a779ff90 100644 --- a/FrontEnd/src/views/center/index.css +++ b/FrontEnd/src/migrating/center/index.css diff --git a/FrontEnd/src/views/center/index.tsx b/FrontEnd/src/migrating/center/index.tsx index 77af2c20..11502517 100644 --- a/FrontEnd/src/views/center/index.tsx +++ b/FrontEnd/src/migrating/center/index.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { useNavigate } from "react-router-dom"; -import { useUserLoggedIn } from "@/services/user"; +import { useUserLoggedIn } from "~src/services/user"; import SearchInput from "../common/SearchInput"; import Button from "../common/button/Button"; diff --git a/FrontEnd/src/pages/404/index.css b/FrontEnd/src/pages/404/index.css new file mode 100644 index 00000000..cf5efbe7 --- /dev/null +++ b/FrontEnd/src/pages/404/index.css @@ -0,0 +1,7 @@ +.page-404 { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-danger-color); +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/404/index.tsx b/FrontEnd/src/pages/404/index.tsx new file mode 100644 index 00000000..751a450b --- /dev/null +++ b/FrontEnd/src/pages/404/index.tsx @@ -0,0 +1,5 @@ +import "./index.css"; + +export default function NotFoundPage() { + return <div className="page-404">Ah-oh, 404!</div>; +} diff --git a/FrontEnd/src/pages/about/index.css b/FrontEnd/src/pages/about/index.css new file mode 100644 index 00000000..1ce7a7c8 --- /dev/null +++ b/FrontEnd/src/pages/about/index.css @@ -0,0 +1,7 @@ +.about-page { + line-height: 1.5; +} + +.about-page a { + color: var(--cru-surface-on-color); +} diff --git a/FrontEnd/src/pages/about/index.tsx b/FrontEnd/src/pages/about/index.tsx new file mode 100644 index 00000000..bce64322 --- /dev/null +++ b/FrontEnd/src/pages/about/index.tsx @@ -0,0 +1,87 @@ +import "./index.css"; + +import { useC } from "~src/common"; +import Page from "~src/components/Page"; + +interface Credit { + name: string; + url: string; +} + +type Credits = Credit[]; + +const frontendCredits: Credits = [ + { + name: "react.js", + url: "https://reactjs.org", + }, + { + name: "typescript", + url: "https://www.typescriptlang.org", + }, + { + name: "bootstrap", + url: "https://getbootstrap.com", + }, + { + name: "parcel.js", + url: "https://parceljs.org", + }, + { + name: "eslint", + url: "https://eslint.org", + }, + { + name: "prettier", + url: "https://prettier.io", + }, +]; + +const backendCredits: Credits = [ + { + name: "ASP.NET Core", + url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core", + }, + { name: "sqlite", url: "https://sqlite.org" }, + { + name: "ImageSharp", + url: "https://github.com/SixLabors/ImageSharp", + }, +]; + +export default function AboutPage() { + const c = useC(); + + return ( + <Page className="about-page"> + <h2>{c("about.credits.title")}</h2> + <p>{c("about.credits.content")}</p> + <h3>{c("about.credits.frontend")}</h3> + <ul> + {frontendCredits.map((item, index) => { + return ( + <li key={index}> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + <h3>{c("about.credits.backend")}</h3> + <ul> + {backendCredits.map((item, index) => { + return ( + <li key={index}> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + </Page> + ); +} diff --git a/FrontEnd/src/pages/home/index.css b/FrontEnd/src/pages/home/index.css new file mode 100644 index 00000000..16601d8a --- /dev/null +++ b/FrontEnd/src/pages/home/index.css @@ -0,0 +1,13 @@ +.home-page { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-primary-color); +} + +.home-page-2 { + width: 100%; + text-align: center; + margin-top: 2em; +} diff --git a/FrontEnd/src/pages/home/index.tsx b/FrontEnd/src/pages/home/index.tsx new file mode 100644 index 00000000..c29a1ca5 --- /dev/null +++ b/FrontEnd/src/pages/home/index.tsx @@ -0,0 +1,12 @@ +import "./index.css"; + +export default function HomePage() { + return ( + <> + <div className="home-page">Be patient! I'm working on this...</div> + <div className="home-page-2"> + Have a look at <a href="/crupest">here</a>! + </div> + </> + ); +} diff --git a/FrontEnd/src/pages/loading/index.css b/FrontEnd/src/pages/loading/index.css new file mode 100644 index 00000000..08e43c22 --- /dev/null +++ b/FrontEnd/src/pages/loading/index.css @@ -0,0 +1,7 @@ +.loading-page { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-primary-color); +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/loading/index.tsx b/FrontEnd/src/pages/loading/index.tsx new file mode 100644 index 00000000..29d27adc --- /dev/null +++ b/FrontEnd/src/pages/loading/index.tsx @@ -0,0 +1,11 @@ +import Spinner from "~src/components/Spinner"; + +import "./index.css"; + +export default function LoadingPage() { + return ( + <div className="loading-page"> + <Spinner /> + </div> + ); +} diff --git a/FrontEnd/src/pages/login/index.css b/FrontEnd/src/pages/login/index.css new file mode 100644 index 00000000..ef97359c --- /dev/null +++ b/FrontEnd/src/pages/login/index.css @@ -0,0 +1,14 @@ +.login-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.login-page-welcome {
+ text-align: center;
+ font-size: 2em;
+}
+
+.login-page-error {
+ color: var(--cru-danger-color);
+}
\ No newline at end of file diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx new file mode 100644 index 00000000..39ea3831 --- /dev/null +++ b/FrontEnd/src/pages/login/index.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Trans } from "react-i18next"; + +import { useUser, userService } from "~src/services/user"; + +import { useC } from "~src/components/common"; +import LoadingButton from "~src/components/button/LoadingButton"; +import { InputGroup, useInputs } from "~src/components/input/InputGroup"; +import Page from "~src/components/Page"; + +import "./index.css"; + +export default function LoginPage() { + const c = useC(); + + const user = useUser(); + + const navigate = useNavigate(); + + const [process, setProcess] = useState<boolean>(false); + const [error, setError] = useState<string | null>(null); + + const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } = + useInputs({ + init: { + scheme: { + inputs: [ + { + key: "username", + type: "text", + label: "user.username", + }, + { + key: "password", + type: "text", + label: "user.password", + password: true, + }, + { + key: "rememberMe", + type: "bool", + label: "user.rememberMe", + }, + ], + validator: ({ username, password }, errors) => { + if (username === "") { + errors["username"] = "login.emptyUsername"; + } + if (password === "") { + errors["password"] = "login.emptyPassword"; + } + }, + }, + dataInit: {}, + }, + }); + + useEffect(() => { + if (user != null) { + const id = setTimeout(() => navigate("/"), 3000); + return () => { + clearTimeout(id); + }; + } + }, [navigate, user]); + + if (user != null) { + return <p>{c("login.alreadyLogin")}</p>; + } + + const submit = (): void => { + const confirmResult = confirm(); + if (confirmResult.type === "ok") { + const { username, password, rememberMe } = confirmResult.values; + setAllDisabled(true); + setProcess(true); + userService + .login( + { + username: username as string, + password: password as string, + }, + rememberMe as boolean, + ) + .then( + () => { + if (history.length === 0) { + navigate("/"); + } else { + navigate(-1); + } + }, + (e: Error) => { + setProcess(false); + setAllDisabled(false); + setError(e.message); + }, + ); + } + }; + + return ( + <Page className="login-page"> + <div className="login-page-container"> + <div className="login-page-welcome">{c("welcome")}</div> + <InputGroup {...inputGroupProps} /> + {error ? <p className="login-page-error">{c(error)}</p> : null} + <div className="login-page-button-row"> + <LoadingButton + loading={process} + onClick={(e) => { + submit(); + e.preventDefault(); + }} + disabled={hasErrorAndDirty} + > + {c("user.login")} + </LoadingButton> + </div> + <Trans i18nKey="login.noAccount"> + 0<Link to="/register">1</Link>2 + </Trans> + </div> + </Page> + ); +} diff --git a/FrontEnd/src/views/register/index.css b/FrontEnd/src/pages/register/index.css index c0078b28..c0078b28 100644 --- a/FrontEnd/src/views/register/index.css +++ b/FrontEnd/src/pages/register/index.css diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx new file mode 100644 index 00000000..fa25c2c2 --- /dev/null +++ b/FrontEnd/src/pages/register/index.tsx @@ -0,0 +1,130 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { HttpBadRequestError } from "~src/http/common"; +import { getHttpTokenClient } from "~src/http/token"; +import { userService, useUser } from "~src/services/user"; + +import { LoadingButton } from "~src/components/button"; +import { useInputs, InputGroup } from "~src/components/input/InputGroup"; + +import "./index.css"; + +export default function RegisterPage() { + const navigate = useNavigate(); + + const { t } = useTranslation(); + + const user = useUser(); + + const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } = + useInputs({ + init: { + scheme: { + inputs: [ + { + key: "username", + type: "text", + label: "register.username", + }, + { + key: "password", + type: "text", + label: "register.password", + password: true, + }, + { + key: "confirmPassword", + type: "text", + label: "register.confirmPassword", + password: true, + }, + { + key: "registerCode", + + type: "text", + label: "register.registerCode", + }, + ], + validator: ( + { username, password, confirmPassword, registerCode }, + errors, + ) => { + if (username === "") { + errors["username"] = "register.error.usernameEmpty"; + } + if (password === "") { + errors["password"] = "register.error.passwordEmpty"; + } + if (confirmPassword !== password) { + errors["confirmPassword"] = "register.error.confirmPasswordWrong"; + } + if (registerCode === "") { + errors["registerCode"] = "register.error.registerCodeEmpty"; + } + }, + }, + dataInit: {}, + }, + }); + + const [process, setProcess] = useState<boolean>(false); + const [resultError, setResultError] = useState<string | null>(null); + + useEffect(() => { + if (user != null) { + navigate("/"); + } + }, [navigate, user]); + + return ( + <div className="container register-page"> + <InputGroup {...inputGroupProps} /> + {resultError && <div className="cru-color-danger">{t(resultError)}</div>} + <LoadingButton + text="register.register" + loading={process} + disabled={hasErrorAndDirty} + onClick={() => { + const confirmResult = confirm(); + if (confirmResult.type === "ok") { + const { username, password, registerCode } = confirmResult.values; + setProcess(true); + setAllDisabled(true); + void getHttpTokenClient() + .register({ + username: username as string, + password: password as string, + registerCode: registerCode as string, + }) + .then( + () => { + void userService + .login( + { + username: username as string, + password: password as string, + }, + true, + ) + .then(() => { + navigate("/"); + }); + }, + (error) => { + if (error instanceof HttpBadRequestError) { + setResultError("register.error.registerCodeInvalid"); + } else { + setResultError("error.network"); + } + setProcess(false); + setAllDisabled(false); + }, + ); + } + }} + /> + </div> + ); +} diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css new file mode 100644 index 00000000..c9eb8011 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css @@ -0,0 +1,22 @@ +.change-avatar-dialog-prompt { + margin: 0.5em 0; +} + +.change-avatar-dialog-prompt.success { + color: var(--cru-create-color); +} + +.change-avatar-dialog-prompt.error { + color: var(--cru-danger-color); +} + +.change-avatar-cropper { + max-width: 400px; + max-height: 400px; +} + +.change-avatar-preview-image { + min-width: 50%; + max-width: 100%; + max-height: 300px; +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..0df10411 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx @@ -0,0 +1,276 @@ +import { useState, ChangeEvent, ComponentPropsWithoutRef } from "react"; + +import { useC, Text, UiLogicError } from "~src/common"; + +import { useUser } from "~src/services/user"; + +import { getHttpUserClient } from "~src/http/user"; + +import { ImageCropper, useImageCrop } from "~src/components/ImageCropper"; +import BlobImage from "~src/components/BlobImage"; +import { ButtonRowV2 } from "~src/components/button"; +import { + Dialog, + DialogContainer, + useDialogController, +} from "~src/components/dialog"; + +import "./ChangeAvatarDialog.css"; + +export default function ChangeAvatarDialog() { + const c = useC(); + + const user = useUser(); + + const controller = useDialogController(); + + type State = + | "select" + | "crop" + | "process-crop" + | "preview" + | "uploading" + | "success" + | "error"; + const [state, setState] = useState<State>("select"); + + const [file, setFile] = useState<File | null>(null); + + const { canCrop, crop, imageCropperProps } = useImageCrop(file, { + constraint: { + ratio: 1, + }, + }); + + const [resultBlob, setResultBlob] = useState<Blob | null>(null); + const [message, setMessage] = useState<Text>( + "settings.dialogChangeAvatar.prompt.select", + ); + + const close = controller.closeDialog; + + const onSelectFile = (e: ChangeEvent<HTMLInputElement>): void => { + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + } else { + setFile(files[0]); + } + }; + + const onCropNext = () => { + if (!canCrop) { + throw new UiLogicError(); + } + + setState("process-crop"); + + void crop().then((b) => { + setState("preview"); + setResultBlob(b); + }); + }; + + const onCropPrevious = () => { + setFile(null); + setState("select"); + }; + + const onPreviewPrevious = () => { + setState("crop"); + }; + + const upload = () => { + if (resultBlob == null) { + throw new UiLogicError(); + } + + if (user == null) { + throw new UiLogicError(); + } + + setState("uploading"); + controller.setCanSwitchDialog(false); + getHttpUserClient() + .putAvatar(user.username, resultBlob) + .then( + () => { + setState("success"); + }, + () => { + setState("error"); + setMessage("operationDialog.error"); + }, + ) + .finally(() => { + controller.setCanSwitchDialog(true); + }); + }; + + const cancelButton = { + key: "cancel", + text: "operationDialog.cancel", + onClick: close, + } as const; + + const createPreviousButton = (onClick: () => void) => + ({ + key: "previous", + text: "operationDialog.previousStep", + onClick, + }) as const; + + const buttonsMap: Record< + State, + ComponentPropsWithoutRef<typeof ButtonRowV2>["buttons"] + > = { + select: [ + cancelButton, + { + key: "next", + action: "major", + text: "operationDialog.nextStep", + onClick: () => setState("crop"), + disabled: file == null, + }, + ], + crop: [ + cancelButton, + createPreviousButton(onCropPrevious), + { + key: "next", + action: "major", + text: "operationDialog.nextStep", + onClick: onCropNext, + disabled: !canCrop, + }, + ], + "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)], + preview: [ + cancelButton, + createPreviousButton(onPreviewPrevious), + { + key: "upload", + action: "major", + text: "settings.dialogChangeAvatar.upload", + onClick: upload, + }, + ], + uploading: [], + success: [ + { + key: "ok", + text: "operationDialog.ok", + color: "create", + onClick: close, + }, + ], + error: [ + cancelButton, + { + key: "retry", + action: "major", + text: "operationDialog.retry", + onClick: upload, + }, + ], + }; + + return ( + <Dialog> + <DialogContainer + title="settings.dialogChangeAvatar.title" + titleColor="primary" + buttonsV2={buttonsMap[state]} + > + {(() => { + if (state === "select") { + return ( + <div className="change-avatar-dialog-container"> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.select")} + </div> + <input + className="change-avatar-select-input" + type="file" + accept="image/*" + onChange={onSelectFile} + /> + </div> + ); + } else if (state === "crop") { + if (file == null) { + throw new UiLogicError(); + } + return ( + <div className="change-avatar-dialog-container"> + <ImageCropper + {...imageCropperProps} + containerClassName="change-avatar-cropper" + /> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.crop")} + </div> + </div> + ); + } else if (state === "process-crop") { + return ( + <div className="change-avatar-dialog-container"> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.processingCrop")} + </div> + </div> + ); + } else if (state === "preview") { + return ( + <div className="change-avatar-dialog-container"> + <BlobImage + className="change-avatar-preview-image" + src={resultBlob} + alt={ + c("settings.dialogChangeAvatar.previewImgAlt") ?? undefined + } + /> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.preview")} + </div> + </div> + ); + } else if (state === "uploading") { + return ( + <div className="change-avatar-dialog-container"> + <BlobImage + className="change-avatar-preview-image" + src={resultBlob} + /> + <div className="change-avatar-dialog-prompt"> + {c("settings.dialogChangeAvatar.prompt.uploading")} + </div> + </div> + ); + } else if (state === "success") { + return ( + <div className="change-avatar-dialog-container"> + <div className="change-avatar-dialog-prompt success"> + {c("operationDialog.success")} + </div> + </div> + ); + } else { + return ( + <div className="change-avatar-dialog-container"> + <BlobImage + className="change-avatar-preview-image" + src={resultBlob} + /> + <div className="change-avatar-dialog-prompt error"> + {c(message)} + </div> + </div> + ); + } + })()} + </DialogContainer> + </Dialog> + ); +} diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..912f554f --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx @@ -0,0 +1,26 @@ +import { getHttpUserClient } from "~src/http/user"; +import { useUserLoggedIn } from "~src/services/user"; + +import { OperationDialog } from "~src/components/dialog"; + +export default function ChangeNicknameDialog() { + const user = useUserLoggedIn(); + + return ( + <OperationDialog + title="settings.dialogChangeNickname.title" + inputs={[ + { + key: "newNickname", + type: "text", + label: "settings.dialogChangeNickname.inputLabel", + }, + ]} + onProcess={({ newNickname }) => { + return getHttpUserClient().patch(user.username, { + nickname: newNickname, + }); + }} + /> + ); +} diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx new file mode 100644 index 00000000..c3111ac8 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { userService } from "~src/services/user"; + +import { OperationDialog } from "~src/components/dialog"; + +export function ChangePasswordDialog() { + const navigate = useNavigate(); + + const [redirect, setRedirect] = useState<boolean>(false); + + return ( + <OperationDialog + title="settings.dialogChangePassword.title" + color="danger" + inputPrompt="settings.dialogChangePassword.prompt" + inputs={{ + inputs: [ + { + key: "oldPassword", + type: "text", + label: "settings.dialogChangePassword.inputOldPassword", + password: true, + }, + { + key: "newPassword", + type: "text", + label: "settings.dialogChangePassword.inputNewPassword", + password: true, + }, + { + key: "retypedNewPassword", + type: "text", + label: "settings.dialogChangePassword.inputRetypeNewPassword", + password: true, + }, + ], + validator: ( + { oldPassword, newPassword, retypedNewPassword }, + errors, + ) => { + if (oldPassword === "") { + errors["oldPassword"] = + "settings.dialogChangePassword.errorEmptyOldPassword"; + } + if (newPassword === "") { + errors["newPassword"] = + "settings.dialogChangePassword.errorEmptyNewPassword"; + } + if (retypedNewPassword !== newPassword) { + errors["retypedNewPassword"] = + "settings.dialogChangePassword.errorRetypeNotMatch"; + } + }, + }} + onProcess={async ({ oldPassword, newPassword }) => { + await userService.changePassword(oldPassword, newPassword); + setRedirect(true); + }} + onSuccessAndClose={() => { + if (redirect) { + navigate("/login"); + } + }} + /> + ); +} + +export default ChangePasswordDialog; diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css new file mode 100644 index 00000000..19e7cff4 --- /dev/null +++ b/FrontEnd/src/pages/setting/index.css @@ -0,0 +1,76 @@ +.setting-section {
+ padding: 1em 0;
+ margin: 1em 0;
+}
+
+.setting-section-title {
+ padding: 0 1em;
+}
+
+.setting-section-item-area {
+ margin-top: 1em;
+ border-top: 1px solid var(--cru-primary-color);
+}
+
+.setting-item-container {
+ padding: 0.5em 1em;
+ transition: background-color 0.3s;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border-bottom: 1px solid var(--cru-clickable-grayscale-active-color);
+ display: flex;
+ align-items: center;
+}
+
+.setting-item-container:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.setting-item-container:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.setting-item-container:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.setting-item-container.danger {
+ color: var(--cru-danger-color);
+}
+
+.setting-item-label-sub {
+ color: var(--cru-text-minor-color);
+}
+
+.setting-item-value-area {
+ margin-left: auto;
+}
+
+.setting-item-container.setting-type-button {
+ cursor: pointer;
+}
+
+.register-code {
+ background: var(--cru-text-major-color);
+ color: var(--cru-background-color);
+ border-radius: 3px;
+ padding: 0.2em;
+ cursor: pointer;
+}
+
+@media (max-width: 576) {
+ .setting-item-container.setting-type-select {
+ flex-direction: column;
+ }
+
+ .setting-item-container.setting-type-select .setting-item-value-area {
+ margin-top: 1em;
+ }
+
+ .register-code-setting-item {
+ flex-direction: column;
+ }
+
+ .register-code-setting-item .register-code {
+ margin-top: 1em;
+ }
+}
\ No newline at end of file diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx new file mode 100644 index 00000000..88ab5cb2 --- /dev/null +++ b/FrontEnd/src/pages/setting/index.tsx @@ -0,0 +1,297 @@ +import { + useState, + useEffect, + ReactNode, + ComponentPropsWithoutRef, +} from "react"; +import { useTranslation } from "react-i18next"; // For change language. +import { useNavigate } from "react-router-dom"; +import classNames from "classnames"; + +import { useUser, userService } from "~src/services/user"; +import { getHttpUserClient } from "~src/http/user"; + +import { useC, Text } from "~src/common"; + +import { pushAlert } from "~src/components/alert"; +import { + useDialog, + DialogProvider, + ConfirmDialog, +} from "~src/components/dialog"; +import Card from "~src/components/Card"; +import Spinner from "~src/components/Spinner"; +import Page from "~src/components/Page"; + +import ChangePasswordDialog from "./ChangePasswordDialog"; +import ChangeAvatarDialog from "./ChangeAvatarDialog"; +import ChangeNicknameDialog from "./ChangeNicknameDialog"; + +import "./index.css"; + +interface SettingSectionProps + extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> { + title: Text; + children?: ReactNode; +} + +function SettingSection({ + title, + className, + children, + ...otherProps +}: SettingSectionProps) { + const c = useC(); + + return ( + <Card className={classNames(className, "setting-section")} {...otherProps}> + <h2 className="setting-section-title">{c(title)}</h2> + <div className="setting-section-item-area">{children}</div> + </Card> + ); +} + +interface SettingItemContainerProps + extends Omit<ComponentPropsWithoutRef<"div">, "title"> { + title: Text; + description?: Text; + danger?: boolean; + extraClassName?: string; +} + +function SettingItemContainer({ + title, + description, + danger, + extraClassName, + className, + children, + ...otherProps +}: SettingItemContainerProps) { + const c = useC(); + + return ( + <div + className={classNames( + className, + "setting-item-container", + danger && "danger", + extraClassName, + )} + {...otherProps} + > + <div className="setting-item-label-area"> + <div className="setting-item-label-title">{c(title)}</div> + <small className="setting-item-label-sub">{c(description)}</small> + </div> + <div className="setting-item-value-area">{children}</div> + </div> + ); +} + +type ButtonSettingItemProps = Omit<SettingItemContainerProps, "extraClassName">; + +function ButtonSettingItem(props: ButtonSettingItemProps) { + return ( + <SettingItemContainer extraClassName="setting-type-button" {...props} /> + ); +} + +interface SelectSettingItemProps + extends Omit<SettingItemContainerProps, "onSelect" | "extraClassName"> { + options: { + value: string; + label: Text; + }[]; + value?: string | null; + onSelect: (value: string) => void; +} + +function SelectSettingsItem({ + options, + value, + onSelect, + ...extraProps +}: SelectSettingItemProps) { + const c = useC(); + + return ( + <SettingItemContainer extraClassName="setting-type-select" {...extraProps}> + {value == null ? ( + <Spinner /> + ) : ( + <select + className="select-setting-item-select" + value={value} + onChange={(e) => { + onSelect(e.target.value); + }} + > + {options.map(({ value, label }) => ( + <option key={value} value={value}> + {c(label)} + </option> + ))} + </select> + )} + </SettingItemContainer> + ); +} + +function RegisterCodeSettingItem() { + const user = useUser(); + + // undefined: loading + const [registerCode, setRegisterCode] = useState<undefined | null | string>(); + + const { controller, createDialogSwitch } = useDialog({ + confirm: ( + <ConfirmDialog + title="settings.renewRegisterCode" + body="settings.renewRegisterCodeDesc" + onConfirm={() => { + if (user == null) throw new Error(); + void getHttpUserClient() + .renewRegisterCode(user.username) + .then(() => { + setRegisterCode(undefined); + }); + }} + /> + ), + }); + + useEffect(() => { + setRegisterCode(undefined); + }, [user]); + + useEffect(() => { + if (user != null && registerCode === undefined) { + void getHttpUserClient() + .getRegisterCode(user.username) + .then((code) => { + setRegisterCode(code.registerCode ?? null); + }); + } + }, [user, registerCode]); + + return ( + <> + <SettingItemContainer + title="settings.myRegisterCode" + description="settings.myRegisterCodeDesc" + className="register-code-setting-item" + onClick={createDialogSwitch("confirm")} + > + {registerCode === undefined ? ( + <Spinner /> + ) : registerCode === null ? ( + <span>Noop</span> + ) : ( + <code + className="register-code" + onClick={(event) => { + void navigator.clipboard.writeText(registerCode).then(() => { + pushAlert({ + color: "create", + message: "settings.myRegisterCodeCopied", + }); + }); + event.stopPropagation(); + }} + > + {registerCode} + </code> + )} + </SettingItemContainer> + <DialogProvider controller={controller} /> + </> + ); +} + +function LanguageChangeSettingItem() { + const { i18n } = useTranslation(); + + const language = i18n.language.slice(0, 2); + + return ( + <SelectSettingsItem + title="settings.languagePrimary" + description="settings.languageSecondary" + options={[ + { + value: "zh", + label: { + type: "custom", + value: "中文", + }, + }, + { + value: "en", + label: { + type: "custom", + value: "English", + }, + }, + ]} + value={language} + onSelect={(value) => { + void i18n.changeLanguage(value); + }} + /> + ); +} + +export default function SettingPage() { + const user = useUser(); + const navigate = useNavigate(); + + const { controller, createDialogSwitch } = useDialog({ + "change-nickname": <ChangeNicknameDialog />, + "change-avatar": <ChangeAvatarDialog />, + "change-password": <ChangePasswordDialog />, + logout: ( + <ConfirmDialog + title="settings.dialogConfirmLogout.title" + body="settings.dialogConfirmLogout.prompt" + onConfirm={() => { + void userService.logout().then(() => { + navigate("/"); + }); + }} + /> + ), + }); + + return ( + <Page noTopPadding> + {user ? ( + <SettingSection title="settings.subheader.account"> + <RegisterCodeSettingItem /> + <ButtonSettingItem + title="settings.changeAvatar" + onClick={createDialogSwitch("change-avatar")} + /> + <ButtonSettingItem + title="settings.changeNickname" + onClick={createDialogSwitch("change-nickname")} + /> + <ButtonSettingItem + title="settings.changePassword" + onClick={createDialogSwitch("change-password")} + danger + /> + <ButtonSettingItem + title="settings.logout" + onClick={createDialogSwitch("logout")} + danger + /> + </SettingSection> + ) : null} + <SettingSection title="settings.subheader.customization"> + <LanguageChangeSettingItem /> + </SettingSection> + <DialogProvider controller={controller} /> + </Page> + ); +} diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css index 7fe83b9b..0a6979cb 100644 --- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css +++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css @@ -2,8 +2,8 @@ font-size: 0.8em;
border-radius: 5px;
padding: 0.1em 1em;
- background-color: #eaf2ff;
}
+
.connection-status-badge::before {
width: 10px;
height: 10px;
@@ -12,25 +12,27 @@ content: "";
margin-right: 0.6em;
}
+
.connection-status-badge.success {
- color: #006100;
+ color: var(--cru-create-color);
}
+
.connection-status-badge.success::before {
- background-color: #006100;
+ background-color: var(--cru-create-color);
}
.connection-status-badge.warning {
- color: #e4a700;
+ color: var(--cru-warn-color);
}
.connection-status-badge.warning::before {
- background-color: #e4a700;
+ background-color: var(--cru-warn-color);
}
.connection-status-badge.danger {
- color: #fd1616;
+ color: var(--cru-danger-color);
}
.connection-status-badge.danger::before {
- background-color: #fd1616;
+ background-color: var(--cru-danger-color);
}
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx index 2b820454..63990878 100644 --- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx +++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx @@ -1,14 +1,13 @@ -import * as React from "react"; -import classnames from "classnames"; +import classNames from "classnames"; import { HubConnectionState } from "@microsoft/signalr"; -import { useTranslation } from "react-i18next"; + +import { useC }from '~/src/components/common'; import "./ConnectionStatusBadge.css"; -export interface ConnectionStatusBadgeProps { +interface ConnectionStatusBadgeProps { status: HubConnectionState; className?: string; - style?: React.CSSProperties; } const classNameMap: Record<HubConnectionState, string> = { @@ -19,23 +18,19 @@ const classNameMap: Record<HubConnectionState, string> = { Reconnecting: "warning", }; -const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => { - const { status, className, style } = props; - - const { t } = useTranslation(); +export default function ConnectionStatusBadge({status, className}: ConnectionStatusBadgeProps) { + const c = useC(); return ( <div - className={classnames( + className={classNames( "connection-status-badge", classNameMap[status], className )} - style={style} > - {t(`connectionState.${status}`)} + {c(`connectionState.${status}`)} </div> ); }; -export default ConnectionStatusBadge; diff --git a/FrontEnd/src/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css new file mode 100644 index 00000000..db25eda0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/Timeline.css @@ -0,0 +1,42 @@ +.timeline-container { + --timeline-background-color: hsl(0, 0%, 95%); + --timeline-shadow-color: hsla(0, 0%, 0%, 0.5); + --timeline-card-shadow: 2px 1px 10px -2px var(--timeline-shadow-color); + --timeline-post-card-background-color: hsl(0, 0%, 100%); + --timeline-post-card-shadow: 0px 0px 11px -2px var(--timeline-shadow-color); + --timeline-post-card-border-radius: 10px; + --timeline-post-text-color: hsl(0, 0%, 0%); + --timeline-datetime-label-background-color: hsl(0, 0%, 30%); +} + +@media (prefers-color-scheme: dark) { + .timeline-container { + --timeline-background-color: hsl(0, 0%, 0%); + --timeline-post-card-background-color: hsl(0, 0%, 15%); + --timeline-post-card-shadow: none; + } +} + +.timeline { + z-index: 0; + position: relative; + width: 100%; + padding-top: 10px; + background: var(--timeline-background-color); +} + +.timeline-sync-state-badge { + font-size: 0.8em; + padding: 3px 8px; + border-radius: 5px; + background: #e8fbff; +} + +.timeline-sync-state-badge-pin { + display: inline-block; + width: 0.4em; + height: 0.4em; + border-radius: 50%; + vertical-align: middle; + margin-right: 0.6em; +} diff --git a/FrontEnd/src/views/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx index 3a7fbd00..32cbf8c8 100644 --- a/FrontEnd/src/views/timeline/Timeline.tsx +++ b/FrontEnd/src/pages/timeline/Timeline.tsx @@ -1,69 +1,63 @@ -import * as React from "react"; -import classnames from "classnames"; -import { useScrollToBottom } from "@/utilities/hooks"; +import { useState, useEffect } from "react"; +import classNames from "classnames"; import { HubConnectionState } from "@microsoft/signalr"; import { HttpForbiddenError, HttpNetworkError, HttpNotFoundError, -} from "@/http/common"; +} from "~src/http/common"; import { getHttpTimelineClient, HttpTimelineInfo, HttpTimelinePostInfo, -} from "@/http/timeline"; +} from "~src/http/timeline"; -import { useUser } from "@/services/user"; -import { getTimelinePostUpdate$ } from "@/services/timeline"; +import { getTimelinePostUpdate$ } from "~src/services/timeline"; -import TimelinePostListView from "./TimelinePostListView"; -import TimelineEmptyItem from "./TimelineEmptyItem"; -import TimelineLoading from "./TimelineLoading"; -import TimelinePostEdit from "./TimelinePostEdit"; -import TimelinePostEditNoLogin from "./TimelinePostEditNoLogin"; -import TimelineCard from "./TimelineCard"; +import { useScrollToBottom } from "~src/components/hooks"; + +import TimelinePostList from "./TimelinePostList"; +import TimelineInfoCard from "./TimelineInfoCard"; +import TimelinePostEdit from "./edit/TimelinePostCreateView"; import "./Timeline.css"; export interface TimelineProps { className?: string; - style?: React.CSSProperties; timelineOwner: string; timelineName: string; } -const Timeline: React.FC<TimelineProps> = (props) => { - const { timelineOwner, timelineName, className, style } = props; - - const user = useUser(); +export function Timeline(props: TimelineProps) { + const { timelineOwner, timelineName, className } = props; - const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null); - const [posts, setPosts] = React.useState<HttpTimelinePostInfo[] | null>(null); - const [signalrState, setSignalrState] = React.useState<HubConnectionState>( - HubConnectionState.Connecting + const [timeline, setTimeline] = useState<HttpTimelineInfo | null>(null); + const [posts, setPosts] = useState<HttpTimelinePostInfo[] | null>(null); + const [signalrState, setSignalrState] = useState<HubConnectionState>( + HubConnectionState.Connecting, ); - const [error, setError] = React.useState< + const [error, setError] = useState< "offline" | "forbid" | "notfound" | "error" | null >(null); - const [currentPage, setCurrentPage] = React.useState(1); - const [totalPage, setTotalPage] = React.useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [totalPage, setTotalPage] = useState(0); - const [timelineReloadKey, setTimelineReloadKey] = React.useState(0); - const [postsReloadKey, setPostsReloadKey] = React.useState(0); + const [timelineReloadKey, setTimelineReloadKey] = useState(0); + const [postsReloadKey, setPostsReloadKey] = useState(0); const updateTimeline = (): void => setTimelineReloadKey((o) => o + 1); const updatePosts = (): void => setPostsReloadKey((o) => o + 1); - React.useEffect(() => { + useEffect(() => { setTimeline(null); setPosts(null); setError(null); setSignalrState(HubConnectionState.Connecting); }, [timelineOwner, timelineName]); - React.useEffect(() => { + useEffect(() => { getHttpTimelineClient() .getTimeline(timelineOwner, timelineName) .then( @@ -81,17 +75,17 @@ const Timeline: React.FC<TimelineProps> = (props) => { console.error(error); setError("error"); } - } + }, ); }, [timelineOwner, timelineName, timelineReloadKey]); - React.useEffect(() => { + useEffect(() => { getHttpTimelineClient() .listPost(timelineOwner, timelineName, 1) .then( (page) => { setPosts( - page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted) + page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted), ); setTotalPage(page.totalPageCount); }, @@ -106,14 +100,14 @@ const Timeline: React.FC<TimelineProps> = (props) => { console.error(error); setError("error"); } - } + }, ); }, [timelineOwner, timelineName, postsReloadKey]); - React.useEffect(() => { + useEffect(() => { const timelinePostUpdate$ = getTimelinePostUpdate$( timelineOwner, - timelineName + timelineName, ); const subscription = timelinePostUpdate$.subscribe(({ update, state }) => { if (update) { @@ -134,7 +128,7 @@ const Timeline: React.FC<TimelineProps> = (props) => { .then( (page) => { const ps = page.items.filter( - (p): p is HttpTimelinePostInfo => !p.deleted + (p): p is HttpTimelinePostInfo => !p.deleted, ); setPosts((old) => [...(old ?? []), ...ps]); }, @@ -149,59 +143,38 @@ const Timeline: React.FC<TimelineProps> = (props) => { console.error(error); setError("error"); } - } + }, ); }, currentPage < totalPage); if (error === "offline") { - return ( - <div className={className} style={style}> - Offline. - </div> - ); + return <div className={className}>Offline.</div>; } else if (error === "notfound") { - return ( - <div className={className} style={style}> - Not exist. - </div> - ); + return <div className={className}>Not exist.</div>; } else if (error === "forbid") { - return ( - <div className={className} style={style}> - Forbid. - </div> - ); + return <div className={className}>Forbid.</div>; } else if (error === "error") { - return ( - <div className={className} style={style}> - Error. - </div> - ); + return <div className={className}>Error.</div>; } return ( - <> - {timeline == null && posts == null && <TimelineLoading />} + <div className="timeline-container"> {timeline && ( - <TimelineCard - className="timeline-card" + <TimelineInfoCard timeline={timeline} connectionStatus={signalrState} onReload={updateTimeline} /> )} {posts && ( - <div style={style} className={classnames("timeline", className)}> - <TimelineEmptyItem className="timeline-top" height={50} /> - {timeline?.postable ? ( + <div className={classNames("timeline", className)}> + {timeline?.postable && ( <TimelinePostEdit timeline={timeline} onPosted={updatePosts} /> - ) : user == null ? ( - <TimelinePostEditNoLogin /> - ) : null} - <TimelinePostListView posts={posts} onReload={updatePosts} /> + )} + <TimelinePostList posts={posts} onReload={updatePosts} /> </div> )} - </> + </div> ); -}; +} export default Timeline; diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.css b/FrontEnd/src/pages/timeline/TimelineDateLabel.css new file mode 100644 index 00000000..47a4cb44 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.css @@ -0,0 +1,9 @@ +.timeline-post-date-badge { + display: inline-block; + padding: 0.2em 0.5em; + border-radius: 0.4em; + background: var(--timeline-datetime-label-background-color); + color: white; + font-size: 0.8em; + margin-left: 5em; +} diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx new file mode 100644 index 00000000..eaadcc1a --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx @@ -0,0 +1,13 @@ +import TimelinePostContainer from "./TimelinePostContainer"; + +import "./TimelineDateLabel.css"; + +export default function TimelineDateLabel({ date }: { date: Date }) { + return ( + <TimelinePostContainer> + <div className="timeline-post-date-badge"> + {date.toLocaleDateString()} + </div> + </TimelinePostContainer> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..d1af364b --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,54 @@ +import { useNavigate } from "react-router-dom"; +import { Trans } from "react-i18next"; + +import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline"; + +import { OperationDialog } from "~src/components/dialog"; + +interface TimelineDeleteDialog { + timeline: HttpTimelineInfo; +} + +export default function TimelineDeleteDialog({ timeline }: TimelineDeleteDialog) { + const navigate = useNavigate(); + + return ( + <OperationDialog + title="timeline.deleteDialog.title" + color="danger" + inputPromptNode={ + <Trans + i18nKey="timeline.deleteDialog.inputPrompt" + values={{ name: timeline.nameV2 }} + > + 0<code>1</code>2 + </Trans> + } + inputs={{ + inputs: [ + { + key: "name", + type: "text", + label: "", + }, + ], + validator: ({ name }, errors) => { + if (name !== timeline.nameV2) { + errors.name = "timeline.deleteDialog.notMatch"; + } + }, + }} + onProcess={() => { + return getHttpTimelineClient().deleteTimeline( + timeline.owner.username, + timeline.nameV2, + ); + }} + onSuccessAndClose={() => { + navigate("/", { replace: true }); + }} + /> + ); +}; + +TimelineDeleteDialog; diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.css b/FrontEnd/src/pages/timeline/TimelineInfoCard.css new file mode 100644 index 00000000..afcb6409 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.css @@ -0,0 +1,63 @@ +.timeline-card { + position: fixed; + z-index: 1029; + top: 56px; + right: 0; + margin: 0.5em; + padding: 0.5em; + box-shadow: var(--timeline-card-shadow); +} + +@media (min-width: 576px) { + .timeline-card-expand { + min-width: 400px; + } +} + +.timeline-card-title { + display: inline-block; + vertical-align: middle; + color: var(--cru-text-major-color); + margin: 0.5em 1em; +} + +.timeline-card-title-name { + margin-inline-start: 1em; + color: var(--cru-text-minor-color); +} + +.timeline-card-user { + display: flex; + align-items: center; + margin: 0 1em 0.5em; +} + +.timeline-card-user-avatar { + width: 2em; + height: 2em; + border-radius: 50%; +} + +.timeline-card-user-nickname { + margin-inline: 0.6em; +} + +.timeline-card-description { + margin: 0 1em 0.5em; +} + +.timeline-card-top-right-area { + float: right; + display: flex; + align-items: center; + margin: 0 1em; +} + +.timeline-card-buttons { + display: flex; + justify-content: end; +} + +.timeline-card-button { + margin: 0 0.2em; +} diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx new file mode 100644 index 00000000..2bc40877 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx @@ -0,0 +1,208 @@ +import { useState } from "react"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { useUser } from "~src/services/user"; + +import { HttpTimelineInfo } from "~src/http/timeline"; +import { getHttpBookmarkClient } from "~src/http/bookmark"; + +import { pushAlert } from "~src/components/alert"; +import { useMobile } from "~src/components/hooks"; +import { IconButton } from "~src/components/button"; +import { + Dialog, + FullPageDialog, + DialogProvider, + useDialog, +} from "~src/components/dialog"; +import UserAvatar from "~src/components/user/UserAvatar"; +import PopupMenu from "~src/components/menu/PopupMenu"; +import Card from "~src/components/Card"; + +import TimelineDeleteDialog from "./TimelineDeleteDialog"; +import ConnectionStatusBadge from "./ConnectionStatusBadge"; +import TimelineMember from "./TimelineMember"; +import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; + +import "./TimelineInfoCard.css"; + +function CollapseButton({ + collapse, + onClick, + className, +}: { + collapse: boolean; + onClick: () => void; + className?: string; +}) { + return ( + <IconButton + color="primary" + icon={collapse ? "info-circle" : "x-circle"} + onClick={onClick} + className={className} + /> + ); +} + +interface TimelineInfoCardProps { + timeline: HttpTimelineInfo; + connectionStatus: HubConnectionState; + onReload: () => void; +} + +function TimelineInfoContent({ + timeline, + onReload, +}: Omit<TimelineInfoCardProps, "connectionStatus">) { + const user = useUser(); + + const { controller, createDialogSwitch } = useDialog({ + member: ( + <Dialog> + <TimelineMember timeline={timeline} onChange={onReload} /> + </Dialog> + ), + property: ( + <TimelinePropertyChangeDialog timeline={timeline} onChange={onReload} /> + ), + delete: <TimelineDeleteDialog timeline={timeline} />, + }); + + return ( + <div> + <h3 className="timeline-card-title"> + {timeline.title} + <small className="timeline-card-title-name">{timeline.nameV2}</small> + </h3> + <div className="timeline-card-user"> + <UserAvatar + username={timeline.owner.username} + className="timeline-card-user-avatar" + /> + <span className="timeline-card-user-nickname"> + {timeline.owner.nickname} + </span> + <small className="timeline-card-user-username"> + @{timeline.owner.username} + </small> + </div> + <p className="timeline-card-description">{timeline.description}</p> + <div className="timeline-card-buttons"> + {user && ( + <IconButton + icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"} + color="primary" + className="timeline-card-button" + onClick={() => { + getHttpBookmarkClient() + [timeline.isBookmark ? "delete" : "post"]( + user.username, + timeline.owner.username, + timeline.nameV2, + ) + .then(onReload, () => { + pushAlert({ + message: timeline.isBookmark + ? "timeline.removeBookmarkFail" + : "timeline.addBookmarkFail", + color: "danger", + }); + }); + }} + /> + )} + <IconButton + icon="people" + color="primary" + className="timeline-card-button" + onClick={createDialogSwitch("member")} + /> + {timeline.manageable && ( + <PopupMenu + items={[ + { + type: "button", + text: "timeline.manageItem.property", + onClick: createDialogSwitch("property"), + }, + { type: "divider" }, + { + type: "button", + onClick: createDialogSwitch("delete"), + color: "danger", + text: "timeline.manageItem.delete", + }, + ]} + containerClassName="d-inline" + > + <IconButton + color="primary" + className="timeline-card-button" + icon="three-dots-vertical" + /> + </PopupMenu> + )} + </div> + <DialogProvider controller={controller} /> + </div> + ); +} + +export default function TimelineInfoCard(props: TimelineInfoCardProps) { + const { timeline, connectionStatus, onReload } = props; + + const [collapse, setCollapse] = useState(true); + + const isMobile = useMobile((mobile) => { + if (!mobile) { + switchDialog(null); + } else { + setCollapse(true); + } + }); + + const { controller, switchDialog } = useDialog( + { + "full-page": ( + <FullPageDialog> + <TimelineInfoContent timeline={timeline} onReload={onReload} /> + </FullPageDialog> + ), + }, + { + onClose: { + "full-page": () => { + setCollapse(true); + }, + }, + }, + ); + + return ( + <Card + color="secondary" + className={`timeline-card timeline-card-${ + collapse ? "collapse" : "expand" + }`} + > + <div className="timeline-card-top-right-area"> + <ConnectionStatusBadge status={connectionStatus} /> + <CollapseButton + collapse={collapse} + onClick={() => { + const open = collapse; + setCollapse(!open); + if (isMobile && open) { + switchDialog("full-page"); + } + }} + /> + </div> + {!collapse && !isMobile && ( + <TimelineInfoContent timeline={timeline} onReload={onReload} /> + )} + <DialogProvider controller={controller} /> + </Card> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css new file mode 100644 index 00000000..3ad74c57 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineMember.css @@ -0,0 +1,20 @@ +.timeline-member-item {
+ align-items: center;
+ display: flex;
+ padding: 0.6em;
+}
+
+.timeline-member-avatar {
+ height: 50px;
+ width: 50px;
+ border-radius: 50%;
+}
+
+.timeline-member-info {
+ margin-left: 1em;
+ margin-right: auto;
+}
+
+.timeline-member-user-search {
+ margin-top: 1em;
+}
diff --git a/FrontEnd/src/views/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx index aaafd173..0812016f 100644 --- a/FrontEnd/src/views/timeline/TimelineMember.tsx +++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx @@ -1,55 +1,59 @@ import { useState } from "react"; -import * as React from "react"; import { useTranslation } from "react-i18next"; -import { convertI18nText, I18nText } from "@/common"; +import { convertI18nText, I18nText } from "~src/common"; -import { HttpUser } from "@/http/user"; -import { getHttpSearchClient } from "@/http/search"; -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; +import { HttpUser } from "~src/http/user"; +import { getHttpSearchClient } from "~src/http/search"; +import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline"; -import SearchInput from "../common/SearchInput"; -import UserAvatar from "../common/user/UserAvatar"; -import Button from "../common/button/Button"; -import Dialog from "../common/dialog/Dialog"; +import SearchInput from "~src/components/SearchInput"; +import UserAvatar from "~src/components/user/UserAvatar"; +import { IconButton } from "~src/components/button"; +import { ListContainer, ListItemContainer } from "~src/components/list"; import "./TimelineMember.css"; -const TimelineMemberItem: React.FC<{ +function TimelineMemberItem({ + user, + add, + onAction, +}: { user: HttpUser; add?: boolean; onAction?: (username: string) => void; -}> = ({ user, add, onAction }) => { +}) { return ( - <div className="container timeline-member-item"> - <div className="row"> - <div className="col col-auto"> - <UserAvatar username={user.username} className="cru-avatar small" /> - </div> - <div className="col"> - <div className="row">{user.nickname}</div> - <small className="row">{"@" + user.username}</small> - </div> - {onAction ? ( - <div className="col col-auto"> - <Button - text={`timeline.member.${add ? "add" : "remove"}`} - color={add ? "success" : "danger"} - onClick={() => { - onAction(user.username); - }} - /> - </div> - ) : null} + <ListItemContainer className="timeline-member-item"> + <UserAvatar username={user.username} className="timeline-member-avatar" /> + <div className="timeline-member-info"> + <div className="timeline-member-nickname">{user.nickname}</div> + <small className="timeline-member-username"> + {"@" + user.username} + </small> </div> - </div> + {onAction ? ( + <div className="timeline-member-action"> + <IconButton + icon={add ? "plus-lg" : "trash"} + color={add ? "create" : "danger"} + onClick={() => { + onAction(user.username); + }} + /> + </div> + ) : null} + </ListItemContainer> ); -}; +} -const TimelineMemberUserSearch: React.FC<{ +function TimelineMemberUserSearch({ + timeline, + onChange, +}: { timeline: HttpTimelineInfo; onChange: () => void; -}> = ({ timeline, onChange }) => { +}) { const { t } = useTranslation(); const [userSearchText, setUserSearchText] = useState<string>(""); @@ -64,9 +68,9 @@ const TimelineMemberUserSearch: React.FC<{ >({ type: "init" }); return ( - <> + <div className="timeline-member-user-search"> <SearchInput - className="mt-3" + className="" value={userSearchText} onChange={(v) => { setUserSearchText(v); @@ -88,8 +92,8 @@ const TimelineMemberUserSearch: React.FC<{ users = users.filter( (user) => timeline.members.findIndex( - (m) => m.username === user.username - ) === -1 && timeline.owner.username !== user.username + (m) => m.username === user.username, + ) === -1 && timeline.owner.username !== user.username, ); setUserSearchState({ type: "users", data: users }); }, @@ -98,7 +102,7 @@ const TimelineMemberUserSearch: React.FC<{ type: "error", data: { type: "custom", value: String(e) }, }); - } + }, ); }} /> @@ -109,7 +113,7 @@ const TimelineMemberUserSearch: React.FC<{ return <div>{t("timeline.member.noUserAvailableToAdd")}</div>; } else { return ( - <div className="mt-2"> + <div className=""> {users.map((user) => ( <TimelineMemberItem key={user.username} @@ -120,7 +124,7 @@ const TimelineMemberUserSearch: React.FC<{ .memberPut( timeline.owner.username, timeline.nameV2, - user.username + user.username, ) .then(() => { setUserSearchText(""); @@ -141,22 +145,22 @@ const TimelineMemberUserSearch: React.FC<{ ); } })()} - </> + </div> ); -}; +} -export interface TimelineMemberProps { +interface TimelineMemberProps { timeline: HttpTimelineInfo; onChange: () => void; } -const TimelineMember: React.FC<TimelineMemberProps> = (props) => { +export default function TimelineMember(props: TimelineMemberProps) { const { timeline, onChange } = props; const members = [timeline.owner, ...timeline.members]; return ( <div className="container px-4 py-3"> - <div> + <ListContainer> {members.map((member, index) => ( <TimelineMemberItem key={member.username} @@ -168,7 +172,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => { .memberDelete( timeline.owner.username, timeline.nameV2, - member.username + member.username, ) .then(onChange); } @@ -176,27 +180,10 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => { } /> ))} - </div> + </ListContainer> {timeline.manageable ? ( <TimelineMemberUserSearch timeline={timeline} onChange={onChange} /> ) : null} </div> ); -}; - -export default TimelineMember; - -export interface TimelineMemberDialogProps extends TimelineMemberProps { - open: boolean; - onClose: () => void; } - -export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = ( - props -) => { - return ( - <Dialog open={props.open} onClose={props.onClose}> - <TimelineMember {...props} /> - </Dialog> - ); -}; diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.css b/FrontEnd/src/pages/timeline/TimelinePostCard.css new file mode 100644 index 00000000..f60610c0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostCard.css @@ -0,0 +1,9 @@ +.timeline-post-card { + padding: 1em 1em 1em 3em; + background-color: var(--timeline-post-card-background-color); + box-shadow: var(--timeline-post-card-shadow); + border-radius: var(--timeline-post-card-border-radius); + border: none; + position: relative; + z-index: 1; +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx new file mode 100644 index 00000000..d3fd3215 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from "react"; +import classNames from "classnames"; + +import Card from "~src/components/Card"; + +import "./TimelinePostCard.css"; + +interface TimelinePostCardProps { + className?: string; + children?: ReactNode; +} + +export default function TimelinePostCard({ + className, + children, +}: TimelinePostCardProps) { + return ( + <Card color="primary" className={classNames("timeline-post-card", className)}> + {children} + </Card> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.css b/FrontEnd/src/pages/timeline/TimelinePostContainer.css new file mode 100644 index 00000000..a12f70b1 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.css @@ -0,0 +1,3 @@ +.timeline-post-container { + padding: 0.5em 1em; +}
\ No newline at end of file diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx new file mode 100644 index 00000000..9dc211b2 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from "react"; +import classNames from "classnames"; + +import "./TimelinePostContainer.css"; + +interface TimelinePostContainerProps { + className?: string; + children?: ReactNode; +} + +export default function TimelinePostContainer({ + className, + children, +}: TimelinePostContainerProps) { + return ( + <div className={classNames("timeline-post-container", className)}> + {children} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostList.css b/FrontEnd/src/pages/timeline/TimelinePostList.css new file mode 100644 index 00000000..bd575554 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostList.css @@ -0,0 +1,10 @@ +.timeline-post-timeline { + position: absolute; + left: 2.5em; + width: 1em; + top: 0; + bottom: 0; + background-color: var(--timeline-post-line-color); + box-shadow: var(--timeline-post-line-shadow); + z-index: -1; +}
\ No newline at end of file diff --git a/FrontEnd/src/views/timeline/TimelinePostListView.tsx b/FrontEnd/src/pages/timeline/TimelinePostList.tsx index f878b004..66262ccd 100644 --- a/FrontEnd/src/views/timeline/TimelinePostListView.tsx +++ b/FrontEnd/src/pages/timeline/TimelinePostList.tsx @@ -1,11 +1,12 @@ -import { Fragment } from "react"; -import * as React from "react"; +import { useMemo, Fragment } from "react"; -import { HttpTimelinePostInfo } from "@/http/timeline"; +import { HttpTimelinePostInfo } from "~src/http/timeline"; import TimelinePostView from "./TimelinePostView"; import TimelineDateLabel from "./TimelineDateLabel"; +import "./TimelinePostList.css"; + function dateEqual(left: Date, right: Date): boolean { return ( left.getDate() == right.getDate() && @@ -14,15 +15,15 @@ function dateEqual(left: Date, right: Date): boolean { ); } -export interface TimelinePostListViewProps { +interface TimelinePostListViewProps { posts: HttpTimelinePostInfo[]; onReload: () => void; } -const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => { +export default function TimelinePostList(props: TimelinePostListViewProps) { const { posts, onReload } = props; - const groupedPosts = React.useMemo< + const groupedPosts = useMemo< { date: Date; posts: (HttpTimelinePostInfo & { index: number })[]; @@ -51,7 +52,7 @@ const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => { }, [posts]); return ( - <> + <div> {groupedPosts.map((group) => { return ( <Fragment key={group.date.toDateString()}> @@ -69,8 +70,6 @@ const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => { </Fragment> ); })} - </> + </div> ); -}; - -export default TimelinePostListView; +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.css b/FrontEnd/src/pages/timeline/TimelinePostView.css new file mode 100644 index 00000000..a8db46bf --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostView.css @@ -0,0 +1,37 @@ +.timeline-post-header { + display: flex; + align-items: center; +} + +.timeline-post-author-avatar { + border-radius: 50%; + width: 2em; + height: 2em; +} + +.timeline-post-author-nickname { + margin: 0 1em; +} + +.timeline-post-edit-button { + float: right; +} + +.timeline-post-options-mask { + position: absolute; + inset: 0; + background-color: hsla(0, 0%, 100%, 0.9); + display: flex; + align-items: center; + justify-content: space-around; +} + +@media (prefers-color-scheme: dark) { + .timeline-post-options-mask { + background-color: hsla(0, 0%, 0%, 0.8); + } +} + +.timeline-post-content { + margin-top: 0.5em; +} diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx new file mode 100644 index 00000000..4f0460ff --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; + +import { + getHttpTimelineClient, + HttpTimelinePostInfo, +} from "~src/http/timeline"; + +import { pushAlert } from "~src/components/alert"; +import { useClickOutside } from "~src/components/hooks"; +import UserAvatar from "~src/components/user/UserAvatar"; +import { DialogProvider, useDialog } from "~src/components/dialog"; +import FlatButton from "~src/components/button/FlatButton"; +import ConfirmDialog from "~src/components/dialog/ConfirmDialog"; +import TimelinePostContentView from "./view/TimelinePostContentView"; +import IconButton from "~src/components/button/IconButton"; + +import TimelinePostContainer from "./TimelinePostContainer"; +import TimelinePostCard from "./TimelinePostCard"; + +import "./TimelinePostView.css"; + +interface TimelinePostViewProps { + post: HttpTimelinePostInfo; + className?: string; + onChanged: (post: HttpTimelinePostInfo) => void; + onDeleted: () => void; +} + +export default function TimelinePostView(props: TimelinePostViewProps) { + const { post, onDeleted } = props; + + const [operationMaskVisible, setOperationMaskVisible] = + useState<boolean>(false); + + const { controller, switchDialog } = useDialog( + { + delete: ( + <ConfirmDialog + title="timeline.post.deleteDialog.title" + body="timeline.post.deleteDialog.prompt" + onConfirm={() => { + void getHttpTimelineClient() + .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id) + .then(onDeleted, () => { + pushAlert({ + color: "danger", + message: "timeline.deletePostFailed", + }); + }); + }} + /> + ), + }, + { + onClose: { + delete: () => { + setOperationMaskVisible(false); + }, + }, + }, + ); + + const [maskElement, setMaskElement] = useState<HTMLElement | null>(null); + useClickOutside(maskElement, () => setOperationMaskVisible(false)); + + return ( + <TimelinePostContainer> + <TimelinePostCard className="cru-primary"> + {post.editable && ( + <IconButton + color="primary" + icon="chevron-down" + className="timeline-post-edit-button" + onClick={(e) => { + setOperationMaskVisible(true); + e.stopPropagation(); + }} + /> + )} + <div className="timeline-post-header"> + <UserAvatar + username={post.author.username} + className="timeline-post-author-avatar" + /> + <small className="timeline-post-author-nickname"> + {post.author.nickname} + </small> + <small className="timeline-post-time"> + {new Date(post.time).toLocaleTimeString()} + </small> + </div> + <div className="timeline-post-content"> + <TimelinePostContentView post={post} /> + </div> + {operationMaskVisible ? ( + <div + ref={setMaskElement} + className="timeline-post-options-mask" + onClick={() => { + setOperationMaskVisible(false); + }} + > + <FlatButton + text="changeProperty" + onClick={(e) => { + e.stopPropagation(); + }} + /> + <FlatButton + text="delete" + color="danger" + onClick={(e) => { + switchDialog("delete"); + e.stopPropagation(); + }} + /> + </div> + ) : null} + </TimelinePostCard> + <DialogProvider controller={controller} /> + </TimelinePostContainer> + ); +} diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..79838d58 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,79 @@ +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePatchRequest, + kTimelineVisibilities, + TimelineVisibility, +} from "~src/http/timeline"; + +import OperationDialog from "~src/components/dialog/OperationDialog"; + +interface TimelinePropertyChangeDialogProps { + timeline: HttpTimelineInfo; + onChange: () => void; +} + +const labelMap: { [key in TimelineVisibility]: string } = { + Private: "timeline.visibility.private", + Public: "timeline.visibility.public", + Register: "timeline.visibility.register", +}; + +export default function TimelinePropertyChangeDialog({ + timeline, + onChange, +}: TimelinePropertyChangeDialogProps) { + return ( + <OperationDialog + title={"timeline.dialogChangeProperty.title"} + inputs={{ + scheme: { + inputs: [ + { + key: "title", + type: "text", + label: "timeline.dialogChangeProperty.titleField", + }, + { + key: "visibility", + type: "select", + label: "timeline.dialogChangeProperty.visibility", + options: kTimelineVisibilities.map((v) => ({ + label: labelMap[v], + value: v, + })), + }, + { + key: "description", + type: "text", + label: "timeline.dialogChangeProperty.description", + }, + ], + }, + dataInit: { + values: { + title: timeline.title, + visibility: timeline.visibility, + description: timeline.description, + }, + }, + }} + onProcess={({ title, visibility, description }) => { + const req: HttpTimelinePatchRequest = {}; + if (title !== timeline.title) { + req.title = title; + } + if (visibility !== timeline.visibility) { + req.visibility = visibility; + } + if (description !== timeline.description) { + req.description = description; + } + return getHttpTimelineClient() + .patchTimeline(timeline.owner.username, timeline.nameV2, req) + .then(onChange); + }} + /> + ); +} + diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css new file mode 100644 index 00000000..232681c8 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css @@ -0,0 +1,5 @@ +.timeline-edit-image-image { + max-width: 100px; + max-height: 100px; +} + diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx new file mode 100644 index 00000000..c62c8ee5 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx @@ -0,0 +1,36 @@ +import classNames from "classnames"; + +import BlobImage from "~/src/components/BlobImage"; + +import "./ImagePostEdit.css"; + +interface TimelinePostEditImageProps { + file: File | null; + onChange: (file: File | null) => void; + disabled: boolean; + className?: string; +} + +export default function ImagePostEdit(props: TimelinePostEditImageProps) { + const { file, onChange, disabled, className } = props; + + return ( + <div className={classNames("timeline-edit-image-container", className)}> + <input + type="file" + accept="image/*" + disabled={disabled} + onChange={(e) => { + const files = e.target.files; + if (files == null || files.length === 0) { + onChange(null); + } else { + onChange(files[0]); + } + }} + className="timeline-edit-image-input" + /> + {file && <BlobImage src={file} className="timeline-edit-image-image" />} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css new file mode 100644 index 00000000..c5b41b40 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css @@ -0,0 +1,24 @@ +.timeline-edit-markdown-tab-page {
+ min-height: 8em;
+ display: flex;
+}
+
+.timeline-edit-markdown-text {
+ width: 100%;
+}
+
+.timeline-edit-markdown-images {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.timeline-edit-markdown-images img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
+.timeline-edit-markdown-preview img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx new file mode 100644 index 00000000..36a5572b --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from "react"; +import classnames from "classnames"; +import { marked } from "marked"; + +import { HttpTimelinePostPostRequestData } from "~src/http/timeline"; + +import base64 from "~src/utilities/base64"; + +import { array } from "~src/components/common"; +import { TabPages } from "~src/components/tab"; +import { IconButton } from "~src/components/button"; +import BlobImage from "~src/components/BlobImage"; + +import "./MarkdownPostEdit.css"; + +class MarkedRenderer extends marked.Renderer { + constructor(public images: string[]) { + super(); + } + + // Custom image parser for indexed image link. + image(href: string, title: string | null, text: string): string { + const i = parseInt(href); + if (!isNaN(i) && i > 0 && i <= this.images.length) { + href = this.images[i - 1]; + } + + return super.image(href, title, text); + } +} + +function generateMarkedOptions(imageUrls: string[]) { + return { + renderer: new MarkedRenderer(imageUrls), + async: false, + } as const; +} + +function renderHtml(text: string, imageUrls: string[]): string { + return marked.parse(text, generateMarkedOptions(imageUrls)); +} + +async function build( + text: string, + images: File[], +): Promise<HttpTimelinePostPostRequestData[]> { + return [ + { + contentType: "text/markdown", + data: await base64(text), + }, + ...(await Promise.all( + images.map(async (image) => { + const data = await base64(image); + return { contentType: image.type, data }; + }), + )), + ]; +} + +export function useMarkdownEdit(disabled: boolean): { + hasContent: boolean; + clear: () => void; + build: () => Promise<HttpTimelinePostPostRequestData[]>; + markdownEditProps: Omit<MarkdownPostEditProps, "className">; +} { + const [text, setText] = useState<string>(""); + const [images, setImages] = useState<File[]>([]); + + return { + hasContent: text !== "" || images.length !== 0, + clear: () => { + setText(""); + setImages([]); + }, + build: () => { + return build(text, images); + }, + markdownEditProps: { + disabled, + text, + images, + onTextChange: setText, + onImageAppend: (image) => setImages(array.copy_push(images, image)), + onImageMove: (o, n) => setImages(array.copy_move(images, o, n)), + onImageDelete: (i) => setImages(array.copy_delete(images, i)), + }, + }; +} + +function MarkdownPreview({ text, images }: { text: string; images: File[] }) { + const [html, setHtml] = useState(""); + + useEffect(() => { + const imageUrls = images.map((image) => URL.createObjectURL(image)); + + setHtml(renderHtml(text, imageUrls)); + + return () => { + imageUrls.forEach((url) => URL.revokeObjectURL(url)); + }; + }, [text, images]); + + return ( + <div + className="timeline-edit-markdown-preview" + dangerouslySetInnerHTML={{ __html: html }} + /> + ); +} + +interface MarkdownPostEditProps { + disabled: boolean; + text: string; + images: File[]; + onTextChange: (text: string) => void; + onImageAppend: (image: File) => void; + onImageMove: (oldIndex: number, newIndex: number) => void; + onImageDelete: (index: number) => void; + className?: string; +} + +export function MarkdownPostEdit({ + disabled, + text, + images, + onTextChange, + onImageAppend, + // onImageMove, + onImageDelete, + className, +}: MarkdownPostEditProps) { + return ( + <TabPages + className={className} + pageContainerClassName="timeline-edit-markdown-tab-page" + dense + pages={[ + { + name: "text", + text: "edit", + page: ( + <textarea + value={text} + disabled={disabled} + className="timeline-edit-markdown-text" + onChange={(event) => { + onTextChange(event.currentTarget.value); + }} + /> + ), + }, + { + name: "images", + text: "image", + page: ( + <div className="timeline-edit-markdown-images"> + {images.map((image, index) => ( + <div + key={image.name} + className="timeline-edit-markdown-image-container" + > + <BlobImage src={image} /> + <IconButton + icon="trash" + color="danger" + className={classnames( + "timeline-edit-markdown-image-delete", + process && "d-none", + )} + onClick={() => { + onImageDelete(index); + }} + /> + </div> + ))} + <input + type="file" + accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + onImageAppend(files[0]); + } + }} + disabled={disabled} + /> + </div> + ), + }, + { + name: "preview", + text: "preview", + page: <MarkdownPreview text={text} images={images} />, + }, + ]} + /> + ); +} diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css new file mode 100644 index 00000000..d1a61793 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css @@ -0,0 +1,12 @@ +.timeline-edit-plain-text-container { + width: 100%; + height: 100%; +} + +.timeline-edit-plain-text-input { + width: 100%; + height: 100%; + padding: 0.5em; + border-radius: 4px; +} + diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx new file mode 100644 index 00000000..7f3663b2 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx @@ -0,0 +1,29 @@ +import classNames from "classnames"; + +import "./PlainTextPostEdit.css"; + +interface TimelinePostEditTextProps { + text: string; + disabled: boolean; + onChange: (text: string) => void; + className?: string; +} + +export default function TimelinePostEditText(props: TimelinePostEditTextProps) { + const { text, disabled, onChange, className } = props; + + return ( + <div + className={classNames("timeline-edit-plain-text-container", className)} + > + <textarea + value={text} + disabled={disabled} + onChange={(event) => { + onChange(event.target.value); + }} + className={classNames("timeline-edit-plain-text-input")} + /> + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css new file mode 100644 index 00000000..6efe93e9 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css @@ -0,0 +1,35 @@ +.timeline-post-create-card {
+ position: sticky !important;
+ top: 106px;
+ z-index: 100;
+ margin-right: 200px;
+}
+
+@media (max-width: 576px) {
+ .timeline-post-create-container {
+ padding-top: 60px;
+ }
+
+ .timeline-post-create-card {
+ margin-right: 0;
+ }
+}
+
+.timeline-post-create {
+ display: flex;
+}
+
+.timeline-post-create-edit-area {
+ flex-grow: 1;
+}
+
+.timeline-post-create-right-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-left: 1em;
+}
+
+.timeline-post-create-send {
+ margin-top: auto;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx new file mode 100644 index 00000000..c0a80ad0 --- /dev/null +++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx @@ -0,0 +1,193 @@ +import { useState } from "react"; +import classNames from "classnames"; + +import { UiLogicError } from "~src/common"; + +import { + getHttpTimelineClient, + HttpTimelineInfo, + HttpTimelinePostInfo, + HttpTimelinePostPostRequestData, +} from "~src/http/timeline"; + +import base64 from "~src/utilities/base64"; + +import { useC } from "~/src/components/common"; +import { pushAlert } from "~src/components/alert"; +import { IconButton, LoadingButton } from "~src/components/button"; +import PopupMenu from "~src/components/menu/PopupMenu"; +import { useWindowLeave } from "~src/components/hooks"; + +import TimelinePostCard from "../TimelinePostCard"; +import TimelinePostContainer from "../TimelinePostContainer"; +import PlainTextPostEdit from "./PlainTextPostEdit"; +import ImagePostEdit from "./ImagePostEdit"; +import { MarkdownPostEdit, useMarkdownEdit } from "./MarkdownPostEdit"; + +import "./TimelinePostCreateView.css"; + +type PostKind = "text" | "markdown" | "image"; + +const postKindIconMap: Record<PostKind, string> = { + text: "fonts", + markdown: "markdown", + image: "image", +}; + +export interface TimelinePostEditProps { + className?: string; + timeline: HttpTimelineInfo; + onPosted: (newPost: HttpTimelinePostInfo) => void; +} + +function TimelinePostEdit(props: TimelinePostEditProps) { + const { timeline, className, onPosted } = props; + + const c = useC(); + + const [process, setProcess] = useState<boolean>(false); + + const [kind, setKind] = useState<PostKind>("text"); + + const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`; + const [text, setText] = useState<string>( + () => window.localStorage.getItem(draftTextLocalStorageKey) ?? "", + ); + const [image, setImage] = useState<File | null>(null); + const { + hasContent: mdHasContent, + build: mdBuild, + clear: mdClear, + markdownEditProps, + } = useMarkdownEdit(process); + + useWindowLeave(!mdHasContent && !image); + + const canSend = + (kind === "text" && text.length !== 0) || + (kind === "image" && image != null) || + (kind === "markdown" && mdHasContent); + + const onPostError = (): void => { + pushAlert({ + color: "danger", + message: "timeline.sendPostFailed", + }); + }; + + const onSend = async (): Promise<void> => { + setProcess(true); + + let requestDataList: HttpTimelinePostPostRequestData[]; + switch (kind) { + case "text": + requestDataList = [ + { + contentType: "text/plain", + data: await base64(text), + }, + ]; + break; + case "image": + if (image == null) { + throw new UiLogicError(); + } + requestDataList = [ + { + contentType: image.type, + data: await base64(image), + }, + ]; + break; + case "markdown": + if (!mdHasContent) { + throw new UiLogicError(); + } + requestDataList = await mdBuild(); + break; + default: + throw new UiLogicError("Unknown content type."); + } + + try { + const res = await getHttpTimelineClient().postPost( + timeline.owner.username, + timeline.nameV2, + { + dataList: requestDataList, + }, + ); + + if (kind === "text") { + setText(""); + window.localStorage.removeItem(draftTextLocalStorageKey); + } else if (kind === "image") { + setImage(null); + } else if (kind === "markdown") { + mdClear(); + } + onPosted(res); + } catch (e) { + onPostError(); + } finally { + setProcess(false); + } + }; + + return ( + <TimelinePostContainer + className={classNames(className, "timeline-post-create-container")} + > + <TimelinePostCard className="timeline-post-create-card"> + <div className="timeline-post-create"> + <div className="timeline-post-create-edit-area"> + {kind === "text" && ( + <PlainTextPostEdit + text={text} + disabled={process} + onChange={(text) => { + setText(text); + window.localStorage.setItem(draftTextLocalStorageKey, text); + }} + /> + )} + {kind === "image" && ( + <ImagePostEdit + file={image} + onChange={setImage} + disabled={process} + /> + )} + {kind === "markdown" && <MarkdownPostEdit {...markdownEditProps} />} + </div> + <div className="timeline-post-create-right-area"> + <PopupMenu + containerClassName="timeline-post-create-kind-select" + items={(["text", "image", "markdown"] as const).map((kind) => ({ + type: "button", + text: `timeline.post.type.${kind}`, + iconClassName: postKindIconMap[kind], + onClick: () => { + setKind(kind); + }, + }))} + > + <IconButton color="primary" icon={postKindIconMap[kind]} /> + </PopupMenu> + <LoadingButton + className="timeline-post-create-send" + onClick={() => void onSend()} + color="primary" + disabled={!canSend} + loading={process} + > + {c("timeline.send")} + </LoadingButton> + </div> + </div> + </TimelinePostCard> + </TimelinePostContainer> + ); +} + +export default TimelinePostEdit; diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx index 1dffdcc1..6cd1ded0 100644 --- a/FrontEnd/src/views/timeline/index.tsx +++ b/FrontEnd/src/pages/timeline/index.tsx @@ -1,11 +1,10 @@ -import * as React from "react"; import { useParams } from "react-router-dom"; -import { UiLogicError } from "@/common"; +import { UiLogicError } from "~src/common"; import Timeline from "./Timeline"; -const TimelinePage: React.FC = () => { +export default function TimelinePage() { const { owner, timeline: timelineNameParam } = useParams(); if (owner == null || owner == "") @@ -13,11 +12,5 @@ const TimelinePage: React.FC = () => { const timeline = timelineNameParam || "self"; - return ( - <div className="container"> - <Timeline timelineOwner={owner} timelineName={timeline} /> - </div> - ); + return <Timeline timelineOwner={owner} timelineName={timeline} />; }; - -export default TimelinePage; diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.css b/FrontEnd/src/pages/timeline/view/ImagePostView.css new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/ImagePostView.css diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.tsx b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx new file mode 100644 index 00000000..85179475 --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import classNames from "classnames"; + +import { + HttpTimelinePostInfo, + getHttpTimelineClient, +} from "~src/http/timeline"; + +import "./ImagePostView.css"; + +interface ImagePostViewProps { + post?: HttpTimelinePostInfo; + className?: string; +} + +export default function ImagePostView({ post, className }: ImagePostViewProps) { + const [url, setUrl] = useState<string | null>(null); + + useEffect(() => { + if (post) { + setUrl( + getHttpTimelineClient().generatePostDataUrl( + post.timelineOwnerV2, + post.timelineNameV2, + post.id, + ), + ); + } else { + setUrl(null); + } + }, [post]); + + return ( + <div className={classNames("timeline-view-image-container", className)}> + <img src={url ?? undefined} className="timeline-view-image" /> + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.css b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css new file mode 100644 index 00000000..48a893eb --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css @@ -0,0 +1,4 @@ +.timeline-view-markdown img { + max-width: 100%; + max-height: 200px; +} diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx new file mode 100644 index 00000000..9bb9f980 --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx @@ -0,0 +1,59 @@ +import { useMemo, useState } from "react"; +import { marked } from "marked"; +import classNames from "classnames"; + +import { + HttpTimelinePostInfo, + getHttpTimelineClient, +} from "~src/http/timeline"; + +import { useAutoUnsubscribePromise } from "~src/components/hooks"; +import Skeleton from "~src/components/Skeleton"; + +import "./MarkdownPostView.css"; + +interface MarkdownPostViewProps { + post?: HttpTimelinePostInfo; + className?: string; +} + +export default function MarkdownPostView({ + post, + className, +}: MarkdownPostViewProps) { + const [markdown, setMarkdown] = useState<string | null>(null); + + useAutoUnsubscribePromise( + () => { + if (post) { + return getHttpTimelineClient().getPostDataAsString( + post.timelineOwnerV2, + post.timelineNameV2, + post.id, + ); + } + }, + setMarkdown, + [post], + ); + + const markdownHtml = useMemo<string | null>(() => { + if (markdown == null) return null; + return marked.parse(markdown, { + async: false, + }); + }, [markdown]); + + return ( + <div className={classNames("timeline-view-markdown-container", className)}> + {markdownHtml == null ? ( + <Skeleton /> + ) : ( + <div + className="timeline-view-markdown" + dangerouslySetInnerHTML={{ __html: markdownHtml }} + /> + )} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.css b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx new file mode 100644 index 00000000..b964187d --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import classNames from "classnames"; + +import { + HttpTimelinePostInfo, + getHttpTimelineClient, +} from "~src/http/timeline"; + +import Skeleton from "~src/components/Skeleton"; +import { useAutoUnsubscribePromise } from "~src/components/hooks"; + +import "./PlainTextPostView.css"; + +interface PlainTextPostViewProps { + post?: HttpTimelinePostInfo; + className?: string; +} + +export default function PlainTextPostView({ + post, + className, +}: PlainTextPostViewProps) { + const [text, setText] = useState<string | null>(null); + + useAutoUnsubscribePromise( + () => { + if (post) { + return getHttpTimelineClient().getPostDataAsString( + post.timelineOwnerV2, + post.timelineNameV2, + post.id, + ); + } + }, + setText, + [post], + ); + + return ( + <div + className={classNames("timeline-view-plain-text-container", className)} + > + {text == null ? ( + <Skeleton /> + ) : ( + <div className="timeline-view-plain-text">{text}</div> + )} + </div> + ); +} diff --git a/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx new file mode 100644 index 00000000..851a9a33 --- /dev/null +++ b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx @@ -0,0 +1,37 @@ +import ImagePostView from "./ImagePostView"; +import MarkdownPostView from "./MarkdownPostView"; +import PlainTextPostView from "./PlainTextPostView"; + +import type { HttpTimelinePostInfo } from "~src/http/timeline"; + +interface TimelinePostContentViewProps { + post?: HttpTimelinePostInfo; + className?: string; +} + +const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = { + "text/plain": PlainTextPostView, + "text/markdown": MarkdownPostView, + "image/png": ImagePostView, + "image/jpeg": ImagePostView, + "image/gif": ImagePostView, + "image/webp": ImagePostView, +}; + +export default function TimelinePostContentView({ + post, + className, +}: TimelinePostContentViewProps) { + if (post == null) { + return <div />; + } + + const type = post.dataList[0].kind; + + if (type in viewMap) { + const View = viewMap[type]; + return <View post={post} className={className} />; + } + + return <div>Unknown post type.</div>; +} diff --git a/FrontEnd/src/palette.ts b/FrontEnd/src/palette.ts deleted file mode 100644 index d06f9b19..00000000 --- a/FrontEnd/src/palette.ts +++ /dev/null @@ -1,167 +0,0 @@ -import Color from "color"; -import { BehaviorSubject, Observable } from "rxjs"; - -import refreshAnimation from "./utilities/refreshAnimation"; - -function lightenBy(color: Color, ratio: number): Color { - const lightness = color.lightness(); - return color.lightness(lightness + (100 - lightness) * ratio); -} - -function darkenBy(color: Color, ratio: number): Color { - const lightness = color.lightness(); - return color.lightness(lightness - lightness * ratio); -} - -export interface PaletteColor { - color: string; - l1: string; - l2: string; - l3: string; - d1: string; - d2: string; - d3: string; - f1: string; - f2: string; - f3: string; - r1: string; - r2: string; - r3: string; - t: string; - t1: string; - t2: string; - t3: string; - [key: string]: string; -} - -const paletteColorList = [ - "primary", - "primary-enhance", - "secondary", - "danger", - "success", -] as const; - -export type PaletteColorType = (typeof paletteColorList)[number]; - -export type Palette = Record<PaletteColorType, PaletteColor>; - -export function generatePaletteColor(color: string): PaletteColor { - const c = Color(color); - const light = c.lightness() > 60; - const l1 = lightenBy(c, 0.1).rgb().toString(); - const l2 = lightenBy(c, 0.2).rgb().toString(); - const l3 = lightenBy(c, 0.3).rgb().toString(); - const d1 = darkenBy(c, 0.1).rgb().toString(); - const d2 = darkenBy(c, 0.2).rgb().toString(); - const d3 = darkenBy(c, 0.3).rgb().toString(); - const f1 = light ? l1 : d1; - const f2 = light ? l2 : d2; - const f3 = light ? l3 : d3; - const r1 = light ? d1 : l1; - const r2 = light ? d2 : l2; - const r3 = light ? d3 : l3; - const _t = light ? Color("black") : Color("white"); - const t = _t.rgb().toString(); - const _b = light ? lightenBy : darkenBy; - const t1 = _b(_t, 0.1).rgb().toString(); - const t2 = _b(_t, 0.2).rgb().toString(); - const t3 = _b(_t, 0.3).rgb().toString(); - - return { - color: c.rgb().toString(), - l1, - l2, - l3, - d1, - d2, - d3, - f1, - f2, - f3, - r1, - r2, - r3, - t, - t1, - t2, - t3, - }; -} - -export function generatePalette(options: { - primary: string; - primaryEnhance?: string; - secondary?: string; -}): Palette { - const { primary, primaryEnhance, secondary } = options; - const p = Color(primary); - const pe = - primaryEnhance == null - ? lightenBy(p, 0.3).saturate(0.3) - : Color(primaryEnhance); - const s = secondary == null ? Color("gray") : Color(secondary); - - return { - primary: generatePaletteColor(p.toString()), - "primary-enhance": generatePaletteColor(pe.toString()), - secondary: generatePaletteColor(s.toString()), - danger: generatePaletteColor("red"), - success: generatePaletteColor("green"), - }; -} - -export function generatePaletteCSS(palette: Palette): string { - const colors: [string, string][] = []; - for (const colorType of paletteColorList) { - const paletteColor = palette[colorType]; - for (const variant in paletteColor) { - let key = `--cru-${colorType}`; - if (variant !== "color") key += `-${variant}`; - key += "-color"; - colors.push([key, paletteColor[variant]]); - } - } - - return `:root {${colors - .map(([key, color]) => `${key} : ${color};`) - .join("")}}`; -} - -const paletteSubject: BehaviorSubject<Palette | null> = - new BehaviorSubject<Palette | null>( - generatePalette({ primary: "rgb(0, 123, 255)" }) - ); - -export const palette$: Observable<Palette | null> = - paletteSubject.asObservable(); - -palette$.subscribe((palette) => { - const styleTagId = "timeline-palette-css"; - if (palette != null) { - let styleTag = document.getElementById(styleTagId); - if (styleTag == null) { - styleTag = document.createElement("style"); - styleTag.id = styleTagId; - document.head.append(styleTag); - } - styleTag.innerHTML = generatePaletteCSS(palette); - } else { - const styleTag = document.getElementById(styleTagId); - if (styleTag != null) { - styleTag.parentElement?.removeChild(styleTag); - } - } - - refreshAnimation(); -}); - -export function setPalette(palette: Palette): () => void { - const old = paletteSubject.value; - - paletteSubject.next(palette); - - return () => { - paletteSubject.next(old); - }; -} diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts deleted file mode 100644 index 83d63abe..00000000 --- a/FrontEnd/src/services/TimelinePostBuilder.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { marked } from "marked"; - -import { UiLogicError } from "@/common"; - -import base64 from "@/utilities/base64"; - -import { HttpTimelinePostPostRequest } from "@/http/timeline"; - -class TimelinePostMarkedRenderer extends marked.Renderer { - constructor(private _images: { file: File; url: string }[]) { - super(); - } - - image(href: string | null, title: string | null, text: string): string { - if (href != null) { - const i = parseInt(href); - if (!isNaN(i) && i > 0 && i <= this._images.length) { - href = this._images[i - 1].url; - } - } - return this.image(href, title, text); - } -} - -export default class TimelinePostBuilder { - private _onChange: () => void; - private _text = ""; - private _images: { file: File; url: string }[] = []; - private _markedOptions: marked.MarkedOptions; - - constructor(onChange: () => void) { - this._onChange = onChange; - this._markedOptions = { - renderer: new TimelinePostMarkedRenderer(this._images), - }; - } - - setMarkdownText(text: string): void { - this._text = text; - this._onChange(); - } - - appendImage(file: File): void { - this._images = this._images.slice(); - this._images.push({ - file, - url: URL.createObjectURL(file), - }); - this._onChange(); - } - - moveImage(oldIndex: number, newIndex: number): void { - if (oldIndex < 0 || oldIndex >= this._images.length) { - throw new UiLogicError("Old index out of range."); - } - - if (newIndex < 0) { - newIndex = 0; - } - - if (newIndex >= this._images.length) { - newIndex = this._images.length - 1; - } - - this._images = this._images.slice(); - - const [old] = this._images.splice(oldIndex, 1); - this._images.splice(newIndex, 0, old); - - this._onChange(); - } - - deleteImage(index: number): void { - if (index < 0 || index >= this._images.length) { - throw new UiLogicError("Old index out of range."); - } - - this._images = this._images.slice(); - - URL.revokeObjectURL(this._images[index].url); - this._images.splice(index, 1); - - this._onChange(); - } - - get text(): string { - return this._text; - } - - get images(): { file: File; url: string }[] { - return this._images; - } - - get isEmpty(): boolean { - return this._text.length === 0 && this._images.length === 0; - } - - renderHtml(): string { - return marked.parse(this._text); - } - - dispose(): void { - for (const image of this._images) { - URL.revokeObjectURL(image.url); - } - this._images = []; - } - - async build(): Promise<HttpTimelinePostPostRequest["dataList"]> { - return [ - { - contentType: "text/markdown", - data: await base64(this._text), - }, - ...(await Promise.all( - this._images.map((image) => - base64(image.file).then((data) => ({ - contentType: image.file.type, - data, - })), - ), - )), - ]; - } -} diff --git a/FrontEnd/src/services/alert.ts b/FrontEnd/src/services/alert.ts index 42b14451..0fa37848 100644 --- a/FrontEnd/src/services/alert.ts +++ b/FrontEnd/src/services/alert.ts @@ -1,10 +1,10 @@ import pull from "lodash/pull"; -import { I18nText } from "@/common"; -import { PaletteColorType } from "@/palette"; +import { I18nText } from "~src/common"; +import { ThemeColor } from "~src/components/common"; export interface AlertInfo { - type?: PaletteColorType; + type?: ThemeColor; message?: I18nText; customMessage?: React.ReactElement; dismissTime?: number | "never"; diff --git a/FrontEnd/src/services/timeline.ts b/FrontEnd/src/services/timeline.ts index 707c956f..41a7bff0 100644 --- a/FrontEnd/src/services/timeline.ts +++ b/FrontEnd/src/services/timeline.ts @@ -1,9 +1,22 @@ -import { TimelineVisibility } from "@/http/timeline"; import XRegExp from "xregexp"; -import { Observable } from "rxjs"; -import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr"; - -import { getHttpToken } from "@/http/common"; +import { + Observable, + BehaviorSubject, + switchMap, + filter, + first, + distinctUntilChanged, +} from "rxjs"; +import { + HubConnection, + HubConnectionBuilder, + HubConnectionState, +} from "@microsoft/signalr"; + +import { TimelineVisibility } from "~src/http/timeline"; +import { token$ } from "~src/http/common"; + +// cSpell:ignore onreconnected onreconnecting const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u"); @@ -20,17 +33,23 @@ export const timelineVisibilityTooltipTranslationMap: Record< Private: "timeline.visibilityTooltip.private", }; -export function getTimelinePostUpdate$( - owner: string, - timeline: string, -): Observable<{ update: boolean; state: HubConnectionState }> { - return new Observable((subscriber) => { - subscriber.next({ - update: false, - state: HubConnectionState.Connecting, - }); +type ConnectionState = + | "Connecting" + | "Reconnecting" + | "Disconnected" + | "Connected"; + +type Connection = { + connection: HubConnection; + state$: Observable<ConnectionState>; +}; + +function createConnection$(token: string | null): Observable<Connection> { + return new Observable<Connection>((subscriber) => { + const connectionStateSubject = new BehaviorSubject<ConnectionState>( + "Connecting", + ); - const token = getHttpToken(); const connection = new HubConnectionBuilder() .withUrl("/api/hub/timeline", { accessTokenFactory: token == null ? undefined : () => token, @@ -38,56 +57,138 @@ export function getTimelinePostUpdate$( .withAutomaticReconnect() .build(); - const o = owner; - const t = timeline; + connection.onclose = () => { + connectionStateSubject.next("Disconnected"); + }; + + connection.onreconnecting = () => { + connectionStateSubject.next("Reconnecting"); + }; + + connection.onreconnected = () => { + connectionStateSubject.next("Connected"); + }; + + let requestStopped = false; + + void connection.start().then( + () => { + connectionStateSubject.next("Connected"); + }, + (e) => { + if (!requestStopped) { + throw e; + } + }, + ); + + subscriber.next({ + connection, + state$: connectionStateSubject.asObservable(), + }); + + return () => { + void connection.stop(); + requestStopped = true; + }; + }); +} + +const connectionSubject = new BehaviorSubject<Connection | null>(null); + +token$ + .pipe(distinctUntilChanged(), switchMap(createConnection$)) + .subscribe(connectionSubject); + +const connection$ = connectionSubject + .asObservable() + .pipe(filter((c): c is Connection => c != null)); + +function createTimelinePostUpdateCount$( + connection: Connection, + owner: string, + timeline: string, +): Observable<number> { + const [o, t] = [owner, timeline]; + return new Observable<number>((subscriber) => { + const hubConnection = connection.connection; + let count = 0; const handler = (owner: string, timeline: string): void => { if (owner === o && timeline === t) { - subscriber.next({ update: true, state: connection.state }); + subscriber.next(count++); } }; - connection.onclose(() => { - subscriber.next({ - update: false, - state: HubConnectionState.Disconnected, + let hubOn = false; + + const subscription = connection.state$ + .pipe(first((state) => state === "Connected")) + .subscribe(() => { + hubConnection.on("OnTimelinePostChangedV2", handler); + void hubConnection.invoke( + "SubscribeTimelinePostChangeV2", + owner, + timeline, + ); + hubOn = true; }); - }); - connection.onreconnecting(() => { - subscriber.next({ - update: false, - state: HubConnectionState.Reconnecting, - }); - }); + return () => { + if (hubOn) { + void hubConnection.invoke( + "UnsubscribeTimelinePostChangeV2", + owner, + timeline, + ); + hubConnection.off("OnTimelinePostChangedV2", handler); + } + + subscription.unsubscribe(); + }; + }); +} + +type OldUpdateInfo = { update: boolean; state: HubConnectionState }; - connection.onreconnected(() => { +function createTimelinePostOldUpdateInfo$( + connection: Connection, + owner: string, + timeline: string, +): Observable<OldUpdateInfo> { + return new Observable<OldUpdateInfo>((subscriber) => { + let savedState: ConnectionState = "Connecting"; + + const postUpdateSubscription = createTimelinePostUpdateCount$( + connection, + owner, + timeline, + ).subscribe(() => { subscriber.next({ - update: false, - state: HubConnectionState.Connected, + update: true, + state: savedState as HubConnectionState, }); }); - connection.on("OnTimelinePostChangedV2", handler); - - void connection.start().then(() => { - subscriber.next({ update: false, state: HubConnectionState.Connected }); - - return connection.invoke( - "SubscribeTimelinePostChangeV2", - owner, - timeline, - ); + const stateSubscription = connection.state$.subscribe((state) => { + savedState = state; + subscriber.next({ update: false, state: state as HubConnectionState }); }); return () => { - connection.off("OnTimelinePostChangedV2", handler); - - if (connection.state === HubConnectionState.Connected) { - void connection - .invoke("UnsubscribeTimelinePostChangeV2", owner, timeline) - .then(() => connection.stop()); - } + stateSubscription.unsubscribe(); + postUpdateSubscription.unsubscribe(); }; }); } + +export function getTimelinePostUpdate$( + owner: string, + timeline: string, +): Observable<OldUpdateInfo> { + return connection$.pipe( + switchMap((connection) => + createTimelinePostOldUpdateInfo$(connection, owner, timeline), + ), + ); +} diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts index c89ca893..5f682a36 100644 --- a/FrontEnd/src/services/user.ts +++ b/FrontEnd/src/services/user.ts @@ -2,20 +2,23 @@ import { useState, useEffect } from "react"; import { BehaviorSubject, Observable } from "rxjs"; import { AxiosError } from "axios"; -import { UiLogicError } from "@/common"; +import { UiLogicError } from "~src/common"; -import { setHttpToken, axios, HttpBadRequestError } from "@/http/common"; -import { getHttpTokenClient } from "@/http/token"; -import { getHttpUserClient, HttpUser, UserPermission } from "@/http/user"; +import { setHttpToken, axios, HttpBadRequestError } from "~src/http/common"; +import { getHttpTokenClient } from "~src/http/token"; +import { getHttpUserClient, HttpUser, UserPermission } from "~src/http/user"; -import { pushAlert } from "./alert"; +import { pushAlert } from "~src/components/alert"; interface IAuthUser extends HttpUser { token: string; } export class AuthUser implements IAuthUser { - constructor(user: HttpUser, public token: string) { + constructor( + user: HttpUser, + public token: string, + ) { this.uniqueId = user.uniqueId; this.username = user.username; this.permissions = user.permissions; @@ -61,7 +64,7 @@ export class UserService { if (e.isAxiosError && e.response && e.response.status === 401) { this.userSubject.next(null); pushAlert({ - type: "danger", + color: "danger", message: "user.tokenInvalid", }); } else { @@ -97,11 +100,11 @@ export class UserService { localStorage.removeItem(USER_STORAGE_KEY); this.userSubject.next(null); pushAlert({ - type: "danger", + color: "danger", message: "user.tokenInvalid", }); } - } + }, ); } } @@ -118,7 +121,7 @@ export class UserService { async login( credentials: LoginCredentials, - rememberMe: boolean + rememberMe: boolean, ): Promise<void> { if (this.currentUser) { throw new UiLogicError("Already login."); diff --git a/FrontEnd/src/utilities/array.ts b/FrontEnd/src/utilities/array.ts new file mode 100644 index 00000000..838e8744 --- /dev/null +++ b/FrontEnd/src/utilities/array.ts @@ -0,0 +1,41 @@ +export function copy_move<T>( + array: T[], + oldIndex: number, + newIndex: number, +): T[] { + if (oldIndex < 0 || oldIndex >= array.length) { + throw new Error("Old index out of range."); + } + + if (newIndex < 0) { + newIndex = 0; + } + + if (newIndex >= array.length) { + newIndex = array.length - 1; + } + + const result = array.slice(); + const [element] = result.splice(oldIndex, 1); + result.splice(newIndex, 0, element); + + return result; +} + +export function copy_insert<T>(array: T[], index: number, element: T): T[] { + const result = array.slice(); + result.splice(index, 0, element); + return result; +} + +export function copy_push<T>(array: T[], element: T): T[] { + const result = array.slice(); + result.push(element); + return result; +} + +export function copy_delete<T>(array: T[], index: number): T[] { + const result = array.slice(); + result.splice(index, 1); + return array; +} diff --git a/FrontEnd/src/utilities/base64.ts b/FrontEnd/src/utilities/base64.ts index 59de7512..6eece979 100644 --- a/FrontEnd/src/utilities/base64.ts +++ b/FrontEnd/src/utilities/base64.ts @@ -1,8 +1,19 @@ -import { Base64 } from "js-base64"; +function bytesToBase64(bytes: Uint8Array): string { + const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join(""); + return btoa(binString); +} + +export default function base64( + data: Blob | Uint8Array | string, +): Promise<string> { + if (typeof data === "string") { + // From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + const binString = new TextEncoder().encode(data); + return Promise.resolve(bytesToBase64(binString)); + } -export default function base64(blob: Blob | string): Promise<string> { - if (typeof blob === "string") { - return Promise.resolve(Base64.encode(blob)); + if (data instanceof Uint8Array) { + return Promise.resolve(bytesToBase64(data)); } return new Promise<string>((resolve) => { @@ -10,6 +21,6 @@ export default function base64(blob: Blob | string): Promise<string> { reader.onload = function () { resolve((reader.result as string).replace(/^data:.*;base64,/, "")); }; - reader.readAsDataURL(blob); + reader.readAsDataURL(data); }); } diff --git a/FrontEnd/src/utilities/geometry.ts b/FrontEnd/src/utilities/geometry.ts new file mode 100644 index 00000000..60a8d3d4 --- /dev/null +++ b/FrontEnd/src/utilities/geometry.ts @@ -0,0 +1,292 @@ +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export interface Point { + x: number; + y: number; +} + +export type Movement = Point; + +export interface Size { + width: number; + height: number; +} + +export class Rect { + static empty = new Rect(0, 0, 0, 0); + static max = new Rect( + Number.MIN_VALUE, + Number.MIN_VALUE, + Number.MAX_VALUE, + Number.MAX_VALUE, + ); + + static from({ + left, + top, + width, + height, + }: { + left: number; + top: number; + width: number; + height: number; + }): Rect { + return new Rect(left, top, width, height); + } + + constructor( + public left: number, + public top: number, + public width: number, + public height: number, + ) {} + + get right(): number { + return this.left + this.width; + } + + set right(value: number) { + this.width = value - this.left; + } + + get bottom(): number { + return this.top + this.height; + } + + set bottom(value: number) { + this.height = value - this.top; + } + + get ratio(): number { + return this.height / this.width; + } + + get position(): Point { + return { + x: this.left, + y: this.top, + }; + } + + set position(value: Point) { + this.left = value.x; + this.top = value.y; + } + + get size(): Size { + return { + width: this.width, + height: this.height, + }; + } + + set size(value: Size) { + this.width = value.width; + this.height = value.height; + } + + get normalizedLeft(): number { + return this.width >= 0 ? this.left : this.right; + } + + get normalizedTop(): number { + return this.height >= 0 ? this.top : this.bottom; + } + + get normalizedRight(): number { + return this.width >= 0 ? this.right : this.left; + } + + get normalizedBottom(): number { + return this.height >= 0 ? this.bottom : this.top; + } + + get normalizedWidth(): number { + return Math.abs(this.width); + } + + get normalizedHeight(): number { + return Math.abs(this.height); + } + + get normalizedSize(): Size { + return { + width: this.normalizedWidth, + height: this.normalizedHeight, + }; + } + + get normalizedRatio(): number { + return Math.abs(this.ratio); + } + + normalize(): Rect { + if (this.width < 0) { + this.width = -this.width; + this.left -= this.width; + } + if (this.height < 0) { + this.height = -this.height; + this.top -= this.height; + } + return this; + } + + move(movement: Movement): Rect { + this.left += movement.x; + this.top += movement.y; + return this; + } + + expand(size: Size | Point): Rect { + if ("x" in size) { + this.width += size.x; + this.height += size.y; + } else { + this.width += size.width; + this.height += size.height; + } + return this; + } + + copy(): Rect { + return new Rect(this.left, this.top, this.width, this.height); + } +} + +export function adjustRectToContainer( + rect: Rect, + container: Rect, + mode: "move" | "resize" | "both", + options?: { + targetRatio?: number; + resizeNoFlip?: boolean; + ratioCorrectBasedOn?: "bigger" | "smaller" | "width" | "height"; + }, +): Rect { + rect = rect.copy(); + container = container.copy().normalize(); + + if (process.env.NODE_ENV === "development") { + if (mode === "move") { + if (rect.normalizedWidth > container.width) { + console.warn( + "adjust rect (move): rect.normalizedWidth > container.normalizedWidth", + ); + } + if (rect.normalizedHeight > container.height) { + console.warn( + "adjust rect (move): rect.normalizedHeight > container.normalizedHeight", + ); + } + } + if (mode === "resize") { + if (rect.left < container.left) { + console.warn( + "adjust rect (resize): rect.left < container.normalizedLeft", + ); + } + if (rect.left > container.right) { + console.warn( + "adjust rect (resize): rect.left > container.normalizedRight", + ); + } + if (rect.top < container.top) { + console.warn( + "adjust rect (resize): rect.top < container.normalizedTop", + ); + } + if (rect.top > container.bottom) { + console.warn( + "adjust rect (resize): rect.top > container.normalizedBottom", + ); + } + } + } + + if (mode === "move") { + rect.left = + rect.width >= 0 + ? clamp(rect.left, container.left, container.right - rect.width) + : clamp(rect.left, container.left - rect.width, container.right); + rect.top = + rect.height >= 0 + ? clamp(rect.top, container.top, container.bottom - rect.height) + : clamp(rect.top, container.top - rect.height, container.bottom); + } else if (mode === "resize") { + const noFlip = options?.resizeNoFlip; + const newRight = clamp( + rect.right, + rect.width > 0 && noFlip ? rect.left : container.left, + rect.width < 0 && noFlip ? rect.left : container.right, + ); + rect.right = newRight; + rect.bottom = clamp( + rect.bottom, + rect.height > 0 && noFlip ? rect.top : container.top, + rect.height < 0 && noFlip ? rect.top : container.bottom, + ); + } else { + rect.left = clamp(rect.left, container.left, container.right); + rect.top = clamp(rect.top, container.top, container.bottom); + rect.right = clamp(rect.right, container.left, container.right); + rect.bottom = clamp(rect.bottom, container.top, container.bottom); + } + + // Now correct ratio + const currentRatio = rect.normalizedRatio; + let targetRatio = options?.targetRatio; + if (targetRatio != null) targetRatio = Math.abs(targetRatio); + if (targetRatio != null && currentRatio !== targetRatio) { + const { ratioCorrectBasedOn } = options ?? {}; + + const newWidth = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + const newHeight = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + + const newBottom = rect.top + newHeight; + const newRight = rect.left + newWidth; + + if (ratioCorrectBasedOn === "width") { + if (newBottom >= container.top && newBottom <= container.bottom) { + rect.height = newHeight; + } else { + rect.bottom = clamp(newBottom, container.top, container.bottom); + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } + } else if (ratioCorrectBasedOn === "height") { + if (newRight >= container.left && newRight <= container.right) { + rect.width = newWidth; + } else { + rect.right = clamp(newRight, container.left, container.right); + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } else if (ratioCorrectBasedOn === "smaller") { + if (currentRatio > targetRatio) { + // too tall + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } else { + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } else { + if (currentRatio < targetRatio) { + // too wide + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } else { + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } + } + + return rect; +} diff --git a/FrontEnd/src/utilities/hooks.ts b/FrontEnd/src/utilities/hooks.ts deleted file mode 100644 index a59f7167..00000000 --- a/FrontEnd/src/utilities/hooks.ts +++ /dev/null @@ -1,5 +0,0 @@ -import useClickOutside from "./hooks/useClickOutside"; -import useScrollToBottom from "./hooks/useScrollToBottom"; -import { useIsSmallScreen } from "./hooks/mediaQuery"; - -export { useClickOutside, useScrollToBottom, useIsSmallScreen }; diff --git a/FrontEnd/src/utilities/hooks/mediaQuery.ts b/FrontEnd/src/utilities/hooks/mediaQuery.ts deleted file mode 100644 index ad55c3c0..00000000 --- a/FrontEnd/src/utilities/hooks/mediaQuery.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useMediaQuery } from "react-responsive"; - -export function useIsSmallScreen(): boolean { - return useMediaQuery({ maxWidth: 576 }); -} diff --git a/FrontEnd/src/utilities/index.ts b/FrontEnd/src/utilities/index.ts new file mode 100644 index 00000000..7659a8aa --- /dev/null +++ b/FrontEnd/src/utilities/index.ts @@ -0,0 +1,27 @@ +export { default as base64 } from "./base64"; +export { withQuery } from "./url"; + +export function delay(milliseconds: number): Promise<void> { + return new Promise<void>((resolve) => { + setTimeout(() => { + resolve(); + }, milliseconds); + }); +} + +export function range(stop: number): number[]; +export function range(start: number, stop: number, step?: number): number[]; +export function range(start: number, stop?: number, step?: number): number[] { + if (stop == undefined) { + stop = start; + start = 0; + } + if (step == undefined) { + step = 1; + } + const result: number[] = []; + for (let i = start; i < stop; i += step) { + result.push(i); + } + return result; +} diff --git a/FrontEnd/src/views/about/author-avatar.png b/FrontEnd/src/views/about/author-avatar.png Binary files differdeleted file mode 100644 index d890d8d0..00000000 --- a/FrontEnd/src/views/about/author-avatar.png +++ /dev/null diff --git a/FrontEnd/src/views/about/github.png b/FrontEnd/src/views/about/github.png Binary files differdeleted file mode 100644 index ea6ff545..00000000 --- a/FrontEnd/src/views/about/github.png +++ /dev/null diff --git a/FrontEnd/src/views/about/index.css b/FrontEnd/src/views/about/index.css deleted file mode 100644 index 2574f4b7..00000000 --- a/FrontEnd/src/views/about/index.css +++ /dev/null @@ -1,4 +0,0 @@ -.about-link-icon {
- width: 1.2em;
- height: 1.2em;
-}
diff --git a/FrontEnd/src/views/about/index.tsx b/FrontEnd/src/views/about/index.tsx deleted file mode 100644 index 093da894..00000000 --- a/FrontEnd/src/views/about/index.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useTranslation, Trans } from "react-i18next"; - -import authorAvatarUrl from "./author-avatar.png"; -import githubLogoUrl from "./github.png"; - -import Card from "../common/Card"; - -import "./index.css"; - -const frontendCredits: { - name: string; - url: string; -}[] = [ - { - name: "reactjs", - url: "https://reactjs.org", - }, - { - name: "typescript", - url: "https://www.typescriptlang.org", - }, - { - name: "bootstrap", - url: "https://getbootstrap.com", - }, - { - name: "vite", - url: "https://vitejs.dev", - }, - { - name: "eslint", - url: "https://eslint.org", - }, - { - name: "prettier", - url: "https://prettier.io", - }, - { - name: "pepjs", - url: "https://github.com/jquery/PEP", - }, -]; - -const backendCredits: { - name: string; - url: string; -}[] = [ - { - name: "ASP.NET Core", - url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core", - }, - { name: "sqlite", url: "https://sqlite.org" }, - { - name: "ImageSharp", - url: "https://github.com/SixLabors/ImageSharp", - }, -]; - -export default function AboutPage() { - const { t } = useTranslation(); - - return ( - <div className="px-2 mb-4"> - <Card className="container mt-4 py-3"> - <h4 id="author-info">{t("about.author.title")}</h4> - <div> - <div className="d-block"> - <img - src={authorAvatarUrl} - className="cru-avatar large cru-round cru-float-left" - /> - <p> - <small>{t("about.author.name")}</small> - <span className="cru-color-primary">crupest</span> - </p> - <p> - <small>{t("about.author.introduction")}</small> - {t("about.author.introductionContent")} - </p> - </div> - <p> - <small>{t("about.author.links")}</small> - <a - href="https://github.com/crupest" - target="_blank" - rel="noopener noreferrer" - > - <img src={githubLogoUrl} className="about-link-icon" /> - </a> - </p> - </div> - </Card> - <Card className="container mt-4 py-3"> - <h4>{t("about.site.title")}</h4> - <p> - <Trans i18nKey="about.site.content"> - 0<span className="cru-color-primary">1</span>2<b>3</b>4 - <a href="#author-info">5</a>6 - </Trans> - </p> - <p> - <a - href="https://github.com/crupest/Timeline" - target="_blank" - rel="noopener noreferrer" - > - {t("about.site.repo")} - </a> - </p> - </Card> - <Card className="container mt-4 py-3"> - <h4>{t("about.credits.title")}</h4> - <p>{t("about.credits.content")}</p> - <p>{t("about.credits.frontend")}</p> - <ul> - {frontendCredits.map((item, index) => { - return ( - <li key={index}> - <a href={item.url} target="_blank" rel="noopener noreferrer"> - {item.name} - </a> - </li> - ); - })} - <li>...</li> - </ul> - <p>{t("about.credits.backend")}</p> - <ul> - {backendCredits.map((item, index) => { - return ( - <li key={index}> - <a href={item.url} target="_blank" rel="noopener noreferrer"> - {item.name} - </a> - </li> - ); - })} - <li>...</li> - </ul> - </Card> - </div> - ); -} diff --git a/FrontEnd/src/views/common/AppBar.css b/FrontEnd/src/views/common/AppBar.css deleted file mode 100644 index 3ec4fa36..00000000 --- a/FrontEnd/src/views/common/AppBar.css +++ /dev/null @@ -1,95 +0,0 @@ -.app-bar {
- display: flex;
- align-items: center;
- height: 56px;
- position: fixed;
- z-index: 1030;
- top: 0;
- left: 0;
- right: 0;
- background-color: var(--cru-primary-color);
- transition: background-color 1s;
-}
-
-.app-bar .cru-avatar {
- background-color: white;
-}
-
-.app-bar a {
- color: var(--cru-primary-t1-color);
- text-decoration: none;
- margin: 0 1em;
- transition: color 1s;
-}
-.app-bar a:hover {
- color: var(--cru-primary-t-color);
-}
-.app-bar a.active {
- color: var(--cru-primary-t-color);
-}
-
-.app-bar-brand {
- display: flex;
- align-items: center;
-}
-
-.app-bar-brand-icon {
- height: 2em;
-}
-
-.app-bar-main-area {
- display: flex;
- flex-grow: 1;
-}
-
-.app-bar-link-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
-}
-
-.app-bar-user-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
- margin-left: auto;
-}
-
-.small-screen .app-bar-main-area {
- position: absolute;
- top: 56px;
- left: 0;
- right: 0;
- transform-origin: top;
- transition: transform 0.6s, background-color 1s;
- background-color: var(--cru-primary-color);
- flex-direction: column;
-}
-.small-screen .app-bar-main-area.app-bar-collapse {
- transform: scale(1, 0);
-}
-.small-screen .app-bar-main-area a {
- text-align: left;
- padding: 0.5em 0.5em;
-}
-.small-screen .app-bar-link-area {
- flex-direction: column;
- align-items: stretch;
-}
-.small-screen .app-bar-user-area {
- flex-direction: column;
- align-items: stretch;
- margin-left: unset;
-}
-.small-screen .app-bar-avatar {
- align-self: flex-end;
-}
-
-.app-bar-toggler {
- margin-left: auto;
- font-size: 2em;
- margin-right: 1em;
- color: var(--cru-primary-t-color);
- cursor: pointer;
- user-select: none;
-}
diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx deleted file mode 100644 index 278c70fd..00000000 --- a/FrontEnd/src/views/common/AppBar.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; -import { Link, NavLink } from "react-router-dom"; -import { useMediaQuery } from "react-responsive"; - -import { useUser } from "@/services/user"; - -import TimelineLogo from "./TimelineLogo"; -import UserAvatar from "./user/UserAvatar"; - -import "./AppBar.css"; - -const AppBar: React.FC = () => { - const { t } = useTranslation(); - - const user = useUser(); - const hasAdministrationPermission = user && user.hasAdministrationPermission; - - const isSmallScreen = useMediaQuery({ maxWidth: 576 }); - - const [expand, setExpand] = React.useState<boolean>(false); - const collapse = (): void => setExpand(false); - const toggleExpand = (): void => setExpand(!expand); - - const createLink = ( - link: string, - label: React.ReactNode, - className?: string - ): React.ReactNode => ( - <NavLink - to={link} - onClick={collapse} - className={({ isActive }) => classnames(className, isActive && "active")} - > - {label} - </NavLink> - ); - - return ( - <nav className={classnames("app-bar", isSmallScreen && "small-screen")}> - <Link to="/" className="app-bar-brand active"> - <TimelineLogo className="app-bar-brand-icon" /> - Timeline - </Link> - - {isSmallScreen && ( - <i className="bi-list app-bar-toggler" onClick={toggleExpand} /> - )} - - <div - className={classnames( - "app-bar-main-area", - !expand && "app-bar-collapse" - )} - > - <div className="app-bar-link-area"> - {createLink("/settings", t("nav.settings"))} - {createLink("/about", t("nav.about"))} - {hasAdministrationPermission && - createLink("/admin", t("nav.administration"))} - </div> - - <div className="app-bar-user-area"> - {user != null - ? createLink( - "/", - <UserAvatar - username={user.username} - className="cru-avatar small cru-round cursor-pointer ml-auto" - />, - "app-bar-avatar" - ) - : createLink("/login", t("nav.login"))} - </div> - </div> - </nav> - ); -}; - -export default AppBar; diff --git a/FrontEnd/src/views/common/BlobImage.tsx b/FrontEnd/src/views/common/BlobImage.tsx deleted file mode 100644 index 5e050ebe..00000000 --- a/FrontEnd/src/views/common/BlobImage.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from "react"; - -const BlobImage: React.FC< - Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & { - blob?: Blob | unknown; - } -> = (props) => { - const { blob, ...otherProps } = props; - - const [url, setUrl] = React.useState<string | undefined>(undefined); - - React.useEffect(() => { - if (blob instanceof Blob) { - const url = URL.createObjectURL(blob); - setUrl(url); - return () => { - URL.revokeObjectURL(url); - }; - } else { - setUrl(undefined); - } - }, [blob]); - - return <img {...otherProps} src={url} />; -}; - -export default BlobImage; diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css deleted file mode 100644 index 6de0dd8e..00000000 --- a/FrontEnd/src/views/common/Card.css +++ /dev/null @@ -1,15 +0,0 @@ -:root {
- --cru-card-border-radius: 8px;
-}
-
-.cru-card {
- border: 1px solid;
- border-color: #e9ecef;
- border-radius: var(--cru-card-border-radius);
- background: #fefeff;
- transition: all 0.3s;
-}
-
-.cru-card:hover {
- border-color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/Card.tsx b/FrontEnd/src/views/common/Card.tsx deleted file mode 100644 index ebbce77e..00000000 --- a/FrontEnd/src/views/common/Card.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import classNames from "classnames"; -import * as React from "react"; - -import "./Card.css"; - -function _Card( - { - className, - children, - ...otherProps - }: React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>, - ref: React.ForwardedRef<HTMLDivElement> -): React.ReactElement | null { - return ( - <div - ref={ref} - className={classNames("cru-card", className)} - {...otherProps} - > - {children} - </div> - ); -} - -const Card = React.forwardRef(_Card); - -export default Card; diff --git a/FrontEnd/src/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx deleted file mode 100644 index 04e17415..00000000 --- a/FrontEnd/src/views/common/ImageCropper.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import { UiLogicError } from "@/common"; - -import "./ImageCropper.css"; - -export interface Clip { - left: number; - top: number; - width: number; -} - -interface NormailizedClip extends Clip { - height: number; -} - -interface ImageInfo { - width: number; - height: number; - landscape: boolean; - ratio: number; - maxClipWidth: number; - maxClipHeight: number; -} - -interface ImageCropperSavedState { - clip: NormailizedClip; - x: number; - y: number; - pointerId: number; -} - -export interface ImageCropperProps { - clip: Clip | null; - imageUrl: string; - onChange: (clip: Clip) => void; - imageElementCallback?: (element: HTMLImageElement | null) => void; - className?: string; -} - -const ImageCropper = (props: ImageCropperProps): React.ReactElement => { - const { clip, imageUrl, onChange, imageElementCallback, className } = props; - - const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>( - null - ); - const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null); - - const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { - if (c == null) { - return { left: 0, top: 0, width: 0, height: 0 }; - } - - return { - left: c.left || 0, - top: c.top || 0, - width: c.width || 0, - height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, - }; - }; - - const c = normalizeClip(clip); - - const imgElementRef = React.useRef<HTMLImageElement | null>(null); - - const onImageRef = React.useCallback( - (e: HTMLImageElement | null) => { - imgElementRef.current = e; - if (imageElementCallback != null && e == null) { - imageElementCallback(null); - } - }, - [imageElementCallback] - ); - - const onImageLoad = React.useCallback( - (e: React.SyntheticEvent<HTMLImageElement>) => { - const img = e.currentTarget; - const landscape = img.naturalWidth >= img.naturalHeight; - - const info = { - width: img.naturalWidth, - height: img.naturalHeight, - landscape, - ratio: img.naturalHeight / img.naturalWidth, - maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1, - maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight, - }; - setImageInfo(info); - onChange({ left: 0, top: 0, width: info.maxClipWidth }); - if (imageElementCallback != null) { - imageElementCallback(img); - } - }, - [onChange, imageElementCallback] - ); - - const onPointerDown = React.useCallback( - (e: React.PointerEvent) => { - if (oldState != null) return; - e.currentTarget.setPointerCapture(e.pointerId); - setOldState({ - x: e.clientX, - y: e.clientY, - clip: c, - pointerId: e.pointerId, - }); - }, - [oldState, c] - ); - - const onPointerUp = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null || oldState.pointerId !== e.pointerId) return; - e.currentTarget.releasePointerCapture(e.pointerId); - setOldState(null); - }, - [oldState] - ); - - const onPointerMove = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null) return; - - const oldClip = oldState.clip; - - const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; - - const { current: imgElement } = imgElementRef; - - if (imgElement == null) throw new UiLogicError("Image element is null."); - - const moveRatio = { - x: movement.x / imgElement.width, - y: movement.y / imgElement.height, - }; - - const newRatio = { - x: oldClip.left + moveRatio.x, - y: oldClip.top + moveRatio.y, - }; - if (newRatio.x < 0) { - newRatio.x = 0; - } else if (newRatio.x > 1 - oldClip.width) { - newRatio.x = 1 - oldClip.width; - } - if (newRatio.y < 0) { - newRatio.y = 0; - } else if (newRatio.y > 1 - oldClip.height) { - newRatio.y = 1 - oldClip.height; - } - - onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); - }, - [oldState, onChange] - ); - - const onHandlerPointerMove = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null) return; - - const oldClip = oldState.clip; - - const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; - - const ratio = imageInfo == null ? 1 : imageInfo.ratio; - - const { current: imgElement } = imgElementRef; - - if (imgElement == null) throw new UiLogicError("Image element is null."); - - const moveRatio = { - x: movement.x / imgElement.width, - y: movement.x / imgElement.width / ratio, - }; - - const newRatio = { - x: oldClip.width + moveRatio.x, - y: oldClip.height + moveRatio.y, - }; - - const maxRatio = { - x: Math.min(1 - oldClip.left, newRatio.x), - y: Math.min(1 - oldClip.top, newRatio.y), - }; - - const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); - - let newWidth; - if (newRatio.x < 0) { - newWidth = 0; - } else if (newRatio.x > maxWidthRatio) { - newWidth = maxWidthRatio; - } else { - newWidth = newRatio.x; - } - - onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); - }, - [imageInfo, oldState, onChange] - ); - - const toPercentage = (n: number): string => `${n}%`; - - // fuck!!! I just can't find a better way to implement this in pure css - const containerStyle: React.CSSProperties = (() => { - if (imageInfo == null) { - return { width: "100%", paddingTop: "100%", height: 0 }; - } else { - if (imageInfo.ratio > 1) { - return { - width: toPercentage(100 / imageInfo.ratio), - paddingTop: "100%", - height: 0, - }; - } else { - return { - width: "100%", - paddingTop: toPercentage(100 * imageInfo.ratio), - height: 0, - }; - } - } - })(); - - return ( - <div - className={classnames("image-cropper-container", className)} - style={containerStyle} - > - <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" /> - <div className="image-cropper-mask-container"> - <div - className="image-cropper-mask" - style={{ - left: toPercentage(c.left * 100), - top: toPercentage(c.top * 100), - width: toPercentage(c.width * 100), - height: toPercentage(c.height * 100), - }} - onPointerMove={onPointerMove} - onPointerDown={onPointerDown} - onPointerUp={onPointerUp} - /> - </div> - <div - className="image-cropper-handler" - style={{ - left: `calc(${(c.left + c.width) * 100}% - 15px)`, - top: `calc(${(c.top + c.height) * 100}% - 15px)`, - }} - onPointerMove={onHandlerPointerMove} - onPointerDown={onPointerDown} - onPointerUp={onPointerUp} - /> - </div> - ); -}; - -export default ImageCropper; - -export function applyClipToImage( - image: HTMLImageElement, - clip: Clip, - mimeType: string -): Promise<Blob> { - return new Promise((resolve, reject) => { - const naturalSize = { - width: image.naturalWidth, - height: image.naturalHeight, - }; - const clipArea = { - x: naturalSize.width * clip.left, - y: naturalSize.height * clip.top, - length: naturalSize.width * clip.width, - }; - - const canvas = document.createElement("canvas"); - canvas.width = clipArea.length; - canvas.height = clipArea.length; - const context = canvas.getContext("2d"); - - if (context == null) throw new Error("Failed to create context."); - - context.drawImage( - image, - clipArea.x, - clipArea.y, - clipArea.length, - clipArea.length, - 0, - 0, - clipArea.length, - clipArea.length - ); - - canvas.toBlob((blob) => { - if (blob == null) { - reject(new Error("canvas.toBlob returns null")); - } else { - resolve(blob); - } - }, mimeType); - }); -} diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx deleted file mode 100644 index 35ee1aa8..00000000 --- a/FrontEnd/src/views/common/LoadingPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from "react"; - -import Spinner from "./Spinner"; - -const LoadingPage: React.FC = () => { - return ( - <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center"> - <Spinner /> - </div> - ); -}; - -export default LoadingPage; diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx deleted file mode 100644 index 9d644ab7..00000000 --- a/FrontEnd/src/views/common/SearchInput.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useCallback } from "react"; -import * as React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; - -import LoadingButton from "./button/LoadingButton"; - -import "./SearchInput.css"; - -export interface SearchInputProps { - value: string; - onChange: (value: string) => void; - onButtonClick: () => void; - className?: string; - loading?: boolean; - buttonText?: string; - placeholder?: string; - additionalButton?: React.ReactNode; - alwaysOneline?: boolean; -} - -const SearchInput: React.FC<SearchInputProps> = (props) => { - const { onChange, onButtonClick, alwaysOneline } = props; - - const { t } = useTranslation(); - - const onInputChange = useCallback( - (event: React.ChangeEvent<HTMLInputElement>): void => { - onChange(event.currentTarget.value); - }, - [onChange] - ); - - const onInputKeyPress = useCallback( - (event: React.KeyboardEvent<HTMLInputElement>): void => { - if (event.key === "Enter") { - onButtonClick(); - event.preventDefault(); - } - }, - [onButtonClick] - ); - - return ( - <div - className={classnames( - "cru-search-input", - alwaysOneline ? "flex-nowrap" : "flex-sm-nowrap", - props.className - )} - > - <input - type="text" - className="cru-search-input-input me-sm-2 flex-grow-1" - value={props.value} - onChange={onInputChange} - onKeyPress={onInputKeyPress} - placeholder={props.placeholder} - /> - {props.additionalButton ? ( - <div className="mt-2 mt-sm-0 flex-shrink-0 order-sm-last ms-sm-2"> - {props.additionalButton} - </div> - ) : null} - <div - className={classnames( - alwaysOneline ? "mt-0 ms-2" : "mt-2 mt-sm-0 ms-auto ms-sm-0", - "flex-shrink-0" - )} - > - <LoadingButton loading={props.loading} onClick={props.onButtonClick}> - {props.buttonText ?? t("search")} - </LoadingButton> - </div> - </div> - ); -}; - -export default SearchInput; diff --git a/FrontEnd/src/views/common/Skeleton.css b/FrontEnd/src/views/common/Skeleton.css deleted file mode 100644 index db1a1c34..00000000 --- a/FrontEnd/src/views/common/Skeleton.css +++ /dev/null @@ -1,14 +0,0 @@ -.cru-skeleton {
- padding: 0 1em;
-}
-
-.cru-skeleton-line {
- height: 1em;
- background-color: #e6e6e6;
- margin: 0.7em 0;
- border-radius: 0.2em;
-}
-
-.cru-skeleton-line.last {
- width: 50%;
-}
diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx deleted file mode 100644 index 3b149db9..00000000 --- a/FrontEnd/src/views/common/Skeleton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import range from "lodash/range"; - -import "./Skeleton.css"; - -export interface SkeletonProps { - lineNumber?: number; - className?: string; - style?: React.CSSProperties; -} - -const Skeleton: React.FC<SkeletonProps> = (props) => { - const { lineNumber: lineNumberProps, className, style } = props; - const lineNumber = lineNumberProps ?? 3; - - return ( - <div className={classnames(className, "cru-skeleton")} style={style}> - {range(lineNumber).map((i) => ( - <div - key={i} - className={classnames( - "cru-skeleton-line", - i === lineNumber - 1 && "last" - )} - /> - ))} - </div> - ); -}; - -export default Skeleton; diff --git a/FrontEnd/src/views/common/Spinner.tsx b/FrontEnd/src/views/common/Spinner.tsx deleted file mode 100644 index e99a9d1b..00000000 --- a/FrontEnd/src/views/common/Spinner.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import { PaletteColorType } from "@/palette"; - -import "./Spinner.css"; - -export interface SpinnerProps { - size?: "sm" | "md" | "lg" | number | string; - color?: PaletteColorType; - className?: string; - style?: React.CSSProperties; -} - -export default function Spinner( - props: SpinnerProps -): React.ReactElement | null { - const { size, color, className, style } = props; - const calculatedSize = - size === "sm" - ? "18px" - : size === "md" - ? "30px" - : size === "lg" - ? "42px" - : typeof size === "number" - ? size - : size == null - ? "20px" - : size; - const calculatedColor = color ?? "primary"; - - return ( - <span - className={classnames( - "cru-spinner", - `cru-color-${calculatedColor}`, - className - )} - style={{ width: calculatedSize, height: calculatedSize, ...style }} - /> - ); -} diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx deleted file mode 100644 index 42074781..00000000 --- a/FrontEnd/src/views/common/alert/AlertHost.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import * as React from "react"; -import without from "lodash/without"; -import { useTranslation } from "react-i18next"; -import classNames from "classnames"; - -import { alertService, AlertInfoEx, AlertInfo } from "@/services/alert"; -import { convertI18nText } from "@/common"; - -import IconButton from "../button/IconButton"; - -import "./alert.css"; - -interface AutoCloseAlertProps { - alert: AlertInfo; - close: () => void; -} - -export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => { - const { alert, close } = props; - const { dismissTime } = alert; - - const { t } = useTranslation(); - - const timerTag = React.useRef<number | null>(null); - const closeHandler = React.useRef<(() => void) | null>(null); - - React.useEffect(() => { - closeHandler.current = close; - }, [close]); - - React.useEffect(() => { - const tag = - dismissTime === "never" - ? null - : typeof dismissTime === "number" - ? window.setTimeout(() => closeHandler.current?.(), dismissTime) - : window.setTimeout(() => closeHandler.current?.(), 5000); - timerTag.current = tag; - return () => { - if (tag != null) { - window.clearTimeout(tag); - } - }; - }, [dismissTime]); - - const cancelTimer = (): void => { - const { current: tag } = timerTag; - if (tag != null) { - window.clearTimeout(tag); - } - }; - - return ( - <div - className={classNames( - "m-3 cru-alert", - "cru-" + (alert.type ?? "primary") - )} - onClick={cancelTimer} - > - <div className="cru-alert-content"> - {(() => { - const { message, customMessage } = alert; - if (customMessage != null) { - return customMessage; - } else { - return convertI18nText(message, t); - } - })()} - </div> - <div className="cru-alert-close-button-container"> - <IconButton - icon="x" - className="cru-alert-close-button" - onClick={close} - /> - </div> - </div> - ); -}; - -const AlertHost: React.FC = () => { - const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]); - - React.useEffect(() => { - const consume = (alert: AlertInfoEx): void => { - setAlerts((old) => [...old, alert]); - }; - - alertService.registerConsumer(consume); - return () => { - alertService.unregisterConsumer(consume); - }; - }, []); - - return ( - <div className="alert-container"> - {alerts.map((alert) => { - return ( - <AutoCloseAlert - key={alert.id} - alert={alert} - close={() => { - setAlerts((old) => without(old, alert)); - }} - /> - ); - })} - </div> - ); -}; - -export default AlertHost; diff --git a/FrontEnd/src/views/common/alert/alert.css b/FrontEnd/src/views/common/alert/alert.css deleted file mode 100644 index fc15e3cb..00000000 --- a/FrontEnd/src/views/common/alert/alert.css +++ /dev/null @@ -1,33 +0,0 @@ -.alert-container {
- position: fixed;
- z-index: 1040;
-}
-
-.cru-alert {
- border-radius: 5px;
- border: var(--cru-theme-color) 1px solid;
- color: var(--cru-theme-t-color);
- background-color: var(--cru-theme-r1-color);
-
- display: flex;
- overflow: hidden;
-}
-
-.cru-alert-content {
- padding: 0.5em 2em;
-}
-
-.cru-alert-close-button-container {
- flex-shrink: 0;
- margin-left: auto;
- width: 2em;
- text-align: center;
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: var(--cru-theme-t-color);
-}
-
-.cru-alert-close-button {
- color: var(--cru-theme-color);
-}
diff --git a/FrontEnd/src/views/common/button/Button.css b/FrontEnd/src/views/common/button/Button.css deleted file mode 100644 index c34176f6..00000000 --- a/FrontEnd/src/views/common/button/Button.css +++ /dev/null @@ -1,51 +0,0 @@ -.cru-button:not(.outline) {
- color: var(--cru-theme-t-color);
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- border: none;
- transition: all 0.5s;
- background-color: var(--cru-theme-color);
-}
-
-.cru-button:not(.outline):hover {
- background-color: var(--cru-theme-f1-color);
-}
-
-.cru-button:not(.outline):active {
- background-color: var(--cru-theme-f2-color);
-}
-
-.cru-button:not(.outline):disabled {
- background-color: var(--cru-disable-color);
- cursor: auto;
-}
-
-.cru-button.outline {
- color: var(--cru-theme-color);
- border: var(--cru-theme-color) 1px solid;
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- transition: all 0.6s;
- background-color: white;
-}
-
-.cru-button.outline:hover {
- color: var(--cru-theme-f1-color);
- border-color: var(--cru-theme-f1-color);
- background-color: var(--cru-background-color);
-}
-
-.cru-button.outline:active {
- color: var(--cru-theme-f2-color);
- border-color: var(--cru-theme-f2-color);
- background-color: var(--cru-background-1-color);
-}
-
-.cru-button.outline:disabled {
- color: var(--cru-disable-color);
- border-color: var(--cru-disable-color);
- background-color: white;
- cursor: auto;
-}
diff --git a/FrontEnd/src/views/common/button/FlatButton.css b/FrontEnd/src/views/common/button/FlatButton.css deleted file mode 100644 index f0d33153..00000000 --- a/FrontEnd/src/views/common/button/FlatButton.css +++ /dev/null @@ -1,18 +0,0 @@ -.cru-flat-button {
- cursor: pointer;
- padding: 0.2em 0.5em;
- border-radius: 0.2em;
- border: none;
- background-color: transparent;
- transition: all 0.6s;
- color: var(--cru-theme-color);
-}
-
-.cru-flat-button.disabled {
- color: var(--cru-theme-l1-color);
- cursor: default;
-}
-
-.cru-flat-button:hover:not(.disabled) {
- background-color: #e9ecef;
-}
diff --git a/FrontEnd/src/views/common/button/IconButton.css b/FrontEnd/src/views/common/button/IconButton.css deleted file mode 100644 index 45fb103c..00000000 --- a/FrontEnd/src/views/common/button/IconButton.css +++ /dev/null @@ -1,10 +0,0 @@ -.cru-icon-button { - color: var(--cru-theme-color); - font-size: 1.4rem; - background: none; - border: none; -} - -.cru-icon-button.large { - font-size: 1.6rem; -} diff --git a/FrontEnd/src/views/common/button/LoadingButton.tsx b/FrontEnd/src/views/common/button/LoadingButton.tsx deleted file mode 100644 index fceaec27..00000000 --- a/FrontEnd/src/views/common/button/LoadingButton.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from "react"; -import classNames from "classnames"; -import { useTranslation } from "react-i18next"; - -import { convertI18nText, I18nText } from "@/common"; -import { PaletteColorType } from "@/palette"; - -import Spinner from "../Spinner"; - -interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> { - color?: PaletteColorType; - text?: I18nText; - loading?: boolean; -} - -function LoadingButton(props: LoadingButtonProps): JSX.Element { - const { t } = useTranslation(); - - const { color, text, loading, className, children, ...otherProps } = props; - - if (text != null && children != null) { - console.warn("You can't set both text and children props."); - } - - return ( - <button - className={classNames( - "cru-" + (color ?? "primary"), - "cru-button outline", - className, - )} - {...otherProps} - > - {text != null ? convertI18nText(text, t) : children} - {loading && <Spinner />} - </button> - ); -} - -export default LoadingButton; diff --git a/FrontEnd/src/views/common/button/index.tsx b/FrontEnd/src/views/common/button/index.tsx deleted file mode 100644 index cff5ba3f..00000000 --- a/FrontEnd/src/views/common/button/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Button from "./Button"; -import FlatButton from "./FlatButton"; -import IconButton from "./IconButton"; -import LoadingButton from "./LoadingButton"; - -export { Button, FlatButton, IconButton, LoadingButton }; diff --git a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx deleted file mode 100644 index 8c2cea5a..00000000 --- a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { convertI18nText, I18nText } from "@/common"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; - -import Button from "../button/Button"; -import Dialog from "./Dialog"; - -const ConfirmDialog: React.FC<{ - open: boolean; - onClose: () => void; - onConfirm: () => void; - title: I18nText; - body: I18nText; -}> = ({ open, onClose, onConfirm, title, body }) => { - const { t } = useTranslation(); - - return ( - <Dialog onClose={onClose} open={open}> - <h3 className="cru-color-danger">{convertI18nText(title, t)}</h3> - <hr /> - <p>{convertI18nText(body, t)}</p> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - outline - onClick={onClose} - /> - <Button - text="operationDialog.confirm" - color="danger" - onClick={() => { - onConfirm(); - onClose(); - }} - /> - </div> - </Dialog> - ); -}; - -export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css deleted file mode 100644 index 21ea52fc..00000000 --- a/FrontEnd/src/views/common/dialog/Dialog.css +++ /dev/null @@ -1,55 +0,0 @@ -.cru-dialog-overlay {
- position: fixed;
- z-index: 1040;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: rgba(255, 255, 255, 0.92);
-
- display: flex;
- padding: 2em;
-
- overflow: auto;
-}
-
-.cru-dialog-container {
- max-width: 100%;
- min-width: 30vw;
-
- margin: auto;
-
- border: var(--cru-primary-color) 1px solid;
- border-radius: 5px;
- padding: 1.5em;
- background-color: white;
-}
-
-.cru-dialog-bottom-area {
- display: flex;
- justify-content: flex-end;
-}
-
-.cru-dialog-bottom-area > * {
- margin: 0 0.5em;
-}
-
-.cru-dialog-enter .cru-dialog-container {
- transform: scale(0, 0);
- opacity: 0;
- transform-origin: center;
-}
-
-.cru-dialog-enter-active .cru-dialog-container {
- transform: scale(1, 1);
- opacity: 1;
- transition: transform 0.3s, opacity 0.3s;
- transform-origin: center;
-}
-
-.cru-dialog-exit-active .cru-dialog-container {
- transition: transform 0.3s, opacity 0.3s;
- transform: scale(0, 0);
- opacity: 0;
- transform-origin: center;
-}
diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx deleted file mode 100644 index 923c636b..00000000 --- a/FrontEnd/src/views/common/dialog/Dialog.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { ReactNode } from "react"; -import ReactDOM from "react-dom"; -import { CSSTransition } from "react-transition-group"; - -import "./Dialog.css"; - -const optionalPortalElement = document.getElementById("portal"); -if (optionalPortalElement == null) { - throw new Error("Portal element not found"); -} -const portalElement = optionalPortalElement; - -interface DialogProps { - onClose: () => void; - open: boolean; - children?: ReactNode; - disableCloseOnClickOnOverlay?: boolean; -} - -export default function Dialog(props: DialogProps) { - const { open, onClose, children, disableCloseOnClickOnOverlay } = props; - - return ReactDOM.createPortal( - <CSSTransition - mountOnEnter - unmountOnExit - in={open} - timeout={300} - classNames="cru-dialog" - > - <div - className="cru-dialog-overlay" - onPointerDown={ - disableCloseOnClickOnOverlay - ? undefined - : () => { - onClose(); - } - } - > - <div - className="cru-dialog-container" - onPointerDown={(e) => e.stopPropagation()} - > - {children} - </div> - </div> - </CSSTransition>, - portalElement, - ); -} diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.css b/FrontEnd/src/views/common/dialog/FullPageDialog.css deleted file mode 100644 index 2f1fc636..00000000 --- a/FrontEnd/src/views/common/dialog/FullPageDialog.css +++ /dev/null @@ -1,44 +0,0 @@ -.cru-full-page {
- position: fixed;
- z-index: 1030;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: white;
- padding-top: 56px;
-}
-
-.cru-full-page-top-bar {
- height: 56px;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- z-index: 1;
- background-color: var(--cru-primary-color);
- display: flex;
- align-items: center;
-}
-
-.cru-full-page-content-container {
- overflow: scroll;
-}
-
-.cru-full-page-back-button {
- color: var(--cru-primary-t-color);
-}
-
-.cru-full-page-enter {
- transform: translate(100%, 0);
-}
-
-.cru-full-page-enter-active {
- transform: none;
- transition: transform 0.3s;
-}
-
-.cru-full-page-exit-active {
- transition: transform 0.3s;
- transform: translate(100%, 0);
-}
diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx b/FrontEnd/src/views/common/dialog/FullPageDialog.tsx deleted file mode 100644 index 6368fc0a..00000000 --- a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; -import { createPortal } from "react-dom"; -import classnames from "classnames"; -import { CSSTransition } from "react-transition-group"; - -import "./FullPageDialog.css"; -import IconButton from "../button/IconButton"; - -export interface FullPageDialogProps { - show: boolean; - onBack: () => void; - contentContainerClassName?: string; - children: React.ReactNode; -} - -const FullPageDialog: React.FC<FullPageDialogProps> = ({ - show, - onBack, - children, - contentContainerClassName, -}) => { - return createPortal( - <CSSTransition - mountOnEnter - unmountOnExit - in={show} - timeout={300} - classNames="cru-full-page" - > - <div className="cru-full-page"> - <div className="cru-full-page-top-bar"> - <IconButton - icon="arrow-left" - className="ms-3 cru-full-page-back-button" - onClick={onBack} - /> - </div> - <div - className={classnames( - "cru-full-page-content-container", - contentContainerClassName - )} - > - {children} - </div> - </div> - </CSSTransition>, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.getElementById("portal")! - ); -}; - -export default FullPageDialog; diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css deleted file mode 100644 index 2f7617d0..00000000 --- a/FrontEnd/src/views/common/dialog/OperationDialog.css +++ /dev/null @@ -1,25 +0,0 @@ -.cru-operation-dialog-group {
- display: block;
- margin: 0.4em 0;
-}
-
-.cru-operation-dialog-label {
- display: block;
- color: var(--cru-primary-color);
-}
-
-.cru-operation-dialog-inline-label {
- margin-inline-start: 0.5em;
-}
-
-.cru-operation-dialog-error-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-danger-color);
-}
-
-.cru-operation-dialog-helper-text {
- display: block;
- font-size: 0.8em;
- color: var(--cru-primary-color);
-}
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx deleted file mode 100644 index 71be030a..00000000 --- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx +++ /dev/null @@ -1,531 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { TwitterPicker } from "react-color"; -import classNames from "classnames"; -import moment from "moment"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; - -import { PaletteColorType } from "@/palette"; - -import Button from "../button/Button"; -import LoadingButton from "../button/LoadingButton"; -import Dialog from "./Dialog"; - -import "./OperationDialog.css"; - -interface DefaultErrorPromptProps { - error?: string; -} - -const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => { - const { t } = useTranslation(); - - let result = <p className="cru-color-danger">{t("operationDialog.error")}</p>; - - if (props.error != null) { - result = ( - <> - {result} - <p className="cru-color-danger">{props.error}</p> - </> - ); - } - - return result; -}; - -export interface OperationDialogTextInput { - type: "text"; - label?: I18nText; - password?: boolean; - initValue?: string; - textFieldProps?: Omit< - React.InputHTMLAttributes<HTMLInputElement>, - "type" | "value" | "onChange" - >; - helperText?: string; -} - -export interface OperationDialogBoolInput { - type: "bool"; - label: I18nText; - initValue?: boolean; - helperText?: string; -} - -export interface OperationDialogSelectInputOption { - value: string; - label: I18nText; - icon?: React.ReactElement; -} - -export interface OperationDialogSelectInput { - type: "select"; - label: I18nText; - options: OperationDialogSelectInputOption[]; - initValue?: string; -} - -export interface OperationDialogColorInput { - type: "color"; - label?: I18nText; - initValue?: string | null; - canBeNull?: boolean; -} - -export interface OperationDialogDateTimeInput { - type: "datetime"; - label?: I18nText; - initValue?: string; - helperText?: string; -} - -export type OperationDialogInput = - | OperationDialogTextInput - | OperationDialogBoolInput - | OperationDialogSelectInput - | OperationDialogColorInput - | OperationDialogDateTimeInput; - -interface OperationInputTypeStringToValueTypeMap { - text: string; - bool: boolean; - select: string; - color: string | null; - datetime: string; -} - -type MapOperationInputTypeStringToValueType<Type> = - Type extends keyof OperationInputTypeStringToValueTypeMap - ? OperationInputTypeStringToValueTypeMap[Type] - : never; - -type MapOperationInputInfoValueType<T> = T extends OperationDialogInput - ? MapOperationInputTypeStringToValueType<T["type"]> - : T; - -const initValueMapperMap: { - [T in OperationDialogInput as T["type"]]: ( - item: T - ) => MapOperationInputInfoValueType<T>; -} = { - bool: (item) => item.initValue ?? false, - color: (item) => item.initValue ?? null, - datetime: (item) => { - if (item.initValue != null) { - return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss"); - } else { - return ""; - } - }, - select: (item) => item.initValue ?? item.options[0].value, - text: (item) => item.initValue ?? "", -}; - -type MapOperationInputInfoValueTypeList< - Tuple extends readonly OperationDialogInput[] -> = { - [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>; -} & { length: Tuple["length"] }; - -export type OperationInputError = - | { - [index: number]: I18nText | null | undefined; - } - | null - | undefined; - -const isNoError = (error: OperationInputError): boolean => { - if (error == null) return true; - for (const key in error) { - if (error[key] != null) return false; - } - return true; -}; - -export interface OperationDialogProps< - TData, - OperationInputInfoList extends readonly OperationDialogInput[] -> { - open: boolean; - onClose: () => void; - title: I18nText | (() => React.ReactNode); - themeColor?: PaletteColorType; - onProcess: ( - inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> - ) => Promise<TData>; - inputScheme?: OperationInputInfoList; - inputValidator?: ( - inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> - ) => OperationInputError; - inputPrompt?: I18nText | (() => React.ReactNode); - processPrompt?: () => React.ReactNode; - successPrompt?: (data: TData) => React.ReactNode; - failurePrompt?: (error: unknown) => React.ReactNode; - onSuccessAndClose?: (data: TData) => void; -} - -const OperationDialog = < - TData, - OperationInputInfoList extends readonly OperationDialogInput[] ->( - props: OperationDialogProps<TData, OperationInputInfoList> -): React.ReactElement => { - const inputScheme = (props.inputScheme ?? - []) as readonly OperationDialogInput[]; - - const { t } = useTranslation(); - - type Step = - | { type: "input" } - | { type: "process" } - | { - type: "success"; - data: TData; - } - | { - type: "failure"; - data: unknown; - }; - const [step, setStep] = useState<Step>({ type: "input" }); - - type ValueType = boolean | string | null | undefined; - - const [values, setValues] = useState<ValueType[]>( - inputScheme.map((item) => { - if (item.type in initValueMapperMap) { - return ( - initValueMapperMap[item.type] as ( - i: OperationDialogInput - ) => ValueType - )(item); - } else { - throw new UiLogicError("Unknown input scheme."); - } - }) - ); - const [dirtyList, setDirtyList] = useState<boolean[]>(() => - inputScheme.map(() => false) - ); - const [inputError, setInputError] = useState<OperationInputError>(); - - const close = (): void => { - if (step.type !== "process") { - props.onClose(); - if (step.type === "success" && props.onSuccessAndClose) { - props.onSuccessAndClose(step.data); - } - } else { - console.log("Attempt to close modal when processing."); - } - }; - - const onConfirm = (): void => { - setStep({ type: "process" }); - props - .onProcess( - values.map((v, index) => { - if (inputScheme[index].type === "datetime" && v !== "") - return new Date(v as string).toISOString(); - else return v; - }) as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList> - ) - .then( - (d) => { - setStep({ - type: "success", - data: d, - }); - }, - (e: unknown) => { - setStep({ - type: "failure", - data: e, - }); - } - ); - }; - - let body: React.ReactNode; - if (step.type === "input" || step.type === "process") { - const process = step.type === "process"; - - let inputPrompt = - typeof props.inputPrompt === "function" - ? props.inputPrompt() - : convertI18nText(props.inputPrompt, t); - inputPrompt = <h6>{inputPrompt}</h6>; - - const validate = (values: ValueType[]): boolean => { - const { inputValidator } = props; - if (inputValidator != null) { - const result = inputValidator( - values as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList> - ); - setInputError(result); - return isNoError(result); - } - return true; - }; - - const updateValue = (index: number, newValue: ValueType): void => { - const oldValues = values; - const newValues = oldValues.slice(); - newValues[index] = newValue; - setValues(newValues); - if (dirtyList[index] === false) { - const newDirtyList = dirtyList.slice(); - newDirtyList[index] = true; - setDirtyList(newDirtyList); - } - validate(newValues); - }; - - const canProcess = isNoError(inputError); - - body = ( - <> - <div> - {inputPrompt} - {inputScheme.map((item, index) => { - const value = values[index]; - const error: string | null = - dirtyList[index] && inputError != null - ? convertI18nText(inputError[index], t) - : null; - - if (item.type === "text") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - {item.label && ( - <label className="cru-operation-dialog-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type={item.password === true ? "password" : "text"} - value={value as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={process} - /> - {error != null && ( - <div className="cru-operation-dialog-error-text"> - {error} - </div> - )} - {item.helperText && ( - <div className="cru-operation-dialog-helper-text"> - {t(item.helperText)} - </div> - )} - </div> - ); - } else if (item.type === "bool") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - <input - type="checkbox" - checked={value as boolean} - onChange={(event) => { - updateValue(index, event.currentTarget.checked); - }} - disabled={process} - /> - <label className="cru-operation-dialog-inline-label"> - {convertI18nText(item.label, t)} - </label> - {error != null && ( - <div className="cru-operation-dialog-error-text"> - {error} - </div> - )} - {item.helperText && ( - <div className="cru-operation-dialog-helper-text"> - {t(item.helperText)} - </div> - )} - </div> - ); - } else if (item.type === "select") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - <label className="cru-operation-dialog-label"> - {convertI18nText(item.label, t)} - </label> - <select - value={value as string} - onChange={(event) => { - updateValue(index, event.target.value); - }} - disabled={process} - > - {item.options.map((option, i) => { - return ( - <option value={option.value} key={i}> - {option.icon} - {convertI18nText(option.label, t)} - </option> - ); - })} - </select> - </div> - ); - } else if (item.type === "color") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - {item.canBeNull ? ( - <input - type="checkbox" - checked={value !== null} - onChange={(event) => { - if (event.currentTarget.checked) { - updateValue(index, "#007bff"); - } else { - updateValue(index, null); - } - }} - disabled={process} - /> - ) : null} - <label className="cru-operation-dialog-inline-label"> - {convertI18nText(item.label, t)} - </label> - {value !== null && ( - <TwitterPicker - color={value as string} - triangle="hide" - onChange={(result) => updateValue(index, result.hex)} - /> - )} - </div> - ); - } else if (item.type === "datetime") { - return ( - <div - key={index} - className={classNames( - "cru-operation-dialog-group", - error != null ? "error" : null - )} - > - {item.label && ( - <label className="cru-operation-dialog-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type="datetime-local" - value={value as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={process} - /> - {error != null && <div>{error}</div>} - </div> - ); - } - })} - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - outline - onClick={close} - disabled={process} - /> - <LoadingButton - color={props.themeColor} - loading={process} - disabled={!canProcess} - onClick={() => { - setDirtyList(inputScheme.map(() => true)); - if (validate(values)) { - onConfirm(); - } - }} - > - {t("operationDialog.confirm")} - </LoadingButton> - </div> - </> - ); - } else { - let content: React.ReactNode; - const result = step; - if (result.type === "success") { - content = - props.successPrompt?.(result.data) ?? t("operationDialog.success"); - if (typeof content === "string") - content = <p className="cru-color-success">{content}</p>; - } else { - content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />; - if (typeof content === "string") - content = <DefaultErrorPrompt error={content} />; - } - body = ( - <> - <div>{content}</div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button text="operationDialog.ok" color="primary" onClick={close} /> - </div> - </> - ); - } - - const title = - typeof props.title === "function" - ? props.title() - : convertI18nText(props.title, t); - - return ( - <Dialog open={props.open} onClose={close}> - <h3 - className={ - props.themeColor != null - ? "cru-color-" + props.themeColor - : "cru-color-primary" - } - > - {title} - </h3> - <hr /> - {body} - </Dialog> - ); -}; - -export default OperationDialog; diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css deleted file mode 100644 index 111a3ec0..00000000 --- a/FrontEnd/src/views/common/index.css +++ /dev/null @@ -1,293 +0,0 @@ -:root {
- --cru-background-color: #f8f9fa;
- --cru-background-1-color: #e9ecef;
- --cru-background-2-color: #dee2e6;
-
- --cru-disable-color: #ced4da;
-
- /*
- --cru-primary-color: rgb(0, 123, 255);
- --cru-primary-l1-color: rgb(26, 136, 255);
- --cru-primary-l2-color: rgb(51, 149, 255);
- --cru-primary-l3-color: rgb(77, 163, 255);
- --cru-primary-d1-color: rgb(0, 111, 230);
- --cru-primary-d2-color: rgb(0, 98, 204);
- --cru-primary-d3-color: rgb(0, 86, 179);
- --cru-primary-f1-color: rgb(0, 111, 230);
- --cru-primary-f2-color: rgb(0, 98, 204);
- --cru-primary-f3-color: rgb(0, 86, 179);
- --cru-primary-r1-color: rgb(26, 136, 255);
- --cru-primary-r2-color: rgb(51, 149, 255);
- --cru-primary-r3-color: rgb(77, 163, 255);
- --cru-primary-t-color: rgb(255, 255, 255);
- --cru-primary-t1-color: rgb(230, 230, 230);
- --cru-primary-t2-color: rgb(204, 204, 204);
- --cru-primary-t3-color: rgb(179, 179, 179);
- --cru-primary-enhance-color: rgb(77, 163, 255);
- --cru-primary-enhance-l1-color: rgb(94, 172, 255);
- --cru-primary-enhance-l2-color: rgb(112, 181, 255);
- --cru-primary-enhance-l3-color: rgb(130, 190, 255);
- --cru-primary-enhance-d1-color: rgb(43, 145, 255);
- --cru-primary-enhance-d2-color: rgb(10, 128, 255);
- --cru-primary-enhance-d3-color: rgb(0, 112, 232);
- --cru-primary-enhance-f1-color: rgb(94, 172, 255);
- --cru-primary-enhance-f2-color: rgb(112, 181, 255);
- --cru-primary-enhance-f3-color: rgb(130, 190, 255);
- --cru-primary-enhance-r1-color: rgb(43, 145, 255);
- --cru-primary-enhance-r2-color: rgb(10, 128, 255);
- --cru-primary-enhance-r3-color: rgb(0, 112, 232);
- --cru-primary-enhance-t-color: rgb(0, 0, 0);
- --cru-primary-enhance-t1-color: rgb(26, 26, 26);
- --cru-primary-enhance-t2-color: rgb(51, 51, 51);
- --cru-primary-enhance-t3-color: rgb(77, 77, 77);
- --cru-secondary-color: rgb(128, 128, 128);
- --cru-secondary-l1-color: rgb(141, 141, 141);
- --cru-secondary-l2-color: rgb(153, 153, 153);
- --cru-secondary-l3-color: rgb(166, 166, 166);
- --cru-secondary-d1-color: rgb(115, 115, 115);
- --cru-secondary-d2-color: rgb(102, 102, 102);
- --cru-secondary-d3-color: rgb(90, 90, 90);
- --cru-secondary-f1-color: rgb(115, 115, 115);
- --cru-secondary-f2-color: rgb(102, 102, 102);
- --cru-secondary-f3-color: rgb(90, 90, 90);
- --cru-secondary-r1-color: rgb(141, 141, 141);
- --cru-secondary-r2-color: rgb(153, 153, 153);
- --cru-secondary-r3-color: rgb(166, 166, 166);
- --cru-secondary-t-color: rgb(255, 255, 255);
- --cru-secondary-t1-color: rgb(230, 230, 230);
- --cru-secondary-t2-color: rgb(204, 204, 204);
- --cru-secondary-t3-color: rgb(179, 179, 179);
- --cru-danger-color: rgb(255, 0, 0);
- --cru-danger-l1-color: rgb(255, 26, 26);
- --cru-danger-l2-color: rgb(255, 51, 51);
- --cru-danger-l3-color: rgb(255, 77, 77);
- --cru-danger-d1-color: rgb(230, 0, 0);
- --cru-danger-d2-color: rgb(204, 0, 0);
- --cru-danger-d3-color: rgb(179, 0, 0);
- --cru-danger-f1-color: rgb(230, 0, 0);
- --cru-danger-f2-color: rgb(204, 0, 0);
- --cru-danger-f3-color: rgb(179, 0, 0);
- --cru-danger-r1-color: rgb(255, 26, 26);
- --cru-danger-r2-color: rgb(255, 51, 51);
- --cru-danger-r3-color: rgb(255, 77, 77);
- --cru-danger-t-color: rgb(255, 255, 255);
- --cru-danger-t1-color: rgb(230, 230, 230);
- --cru-danger-t2-color: rgb(204, 204, 204);
- --cru-danger-t3-color: rgb(179, 179, 179);
- --cru-success-color: rgb(0, 128, 0);
- --cru-success-l1-color: rgb(0, 166, 0);
- --cru-success-l2-color: rgb(0, 204, 0);
- --cru-success-l3-color: rgb(0, 243, 0);
- --cru-success-d1-color: rgb(0, 115, 0);
- --cru-success-d2-color: rgb(0, 102, 0);
- --cru-success-d3-color: rgb(0, 90, 0);
- --cru-success-f1-color: rgb(0, 115, 0);
- --cru-success-f2-color: rgb(0, 102, 0);
- --cru-success-f3-color: rgb(0, 90, 0);
- --cru-success-r1-color: rgb(0, 166, 0);
- --cru-success-r2-color: rgb(0, 204, 0);
- --cru-success-r3-color: rgb(0, 243, 0);
- --cru-success-t-color: rgb(255, 255, 255);
- --cru-success-t1-color: rgb(230, 230, 230);
- --cru-success-t2-color: rgb(204, 204, 204);
- --cru-success-t3-color: rgb(179, 179, 179);
- */
-}
-
-.cru-primary {
- --cru-theme-color: var(--cru-primary-color);
- --cru-theme-l1-color: var(--cru-primary-l1-color);
- --cru-theme-l2-color: var(--cru-primary-l2-color);
- --cru-theme-l3-color: var(--cru-primary-l3-color);
- --cru-theme-d1-color: var(--cru-primary-d1-color);
- --cru-theme-d2-color: var(--cru-primary-d2-color);
- --cru-theme-d3-color: var(--cru-primary-d3-color);
- --cru-theme-f1-color: var(--cru-primary-f1-color);
- --cru-theme-f2-color: var(--cru-primary-f2-color);
- --cru-theme-f3-color: var(--cru-primary-f3-color);
- --cru-theme-r1-color: var(--cru-primary-r1-color);
- --cru-theme-r2-color: var(--cru-primary-r2-color);
- --cru-theme-r3-color: var(--cru-primary-r3-color);
- --cru-theme-t-color: var(--cru-primary-t-color);
- --cru-theme-t1-color: var(--cru-primary-t1-color);
- --cru-theme-t2-color: var(--cru-primary-t2-color);
- --cru-theme-t3-color: var(--cru-primary-t3-color);
-}
-
-.cru-primary-enhance {
- --cru-theme-color: var(--cru-primary-enhance-color);
- --cru-theme-l1-color: var(--cru-primary-enhance-l1-color);
- --cru-theme-l2-color: var(--cru-primary-enhance-l2-color);
- --cru-theme-l3-color: var(--cru-primary-enhance-l3-color);
- --cru-theme-d1-color: var(--cru-primary-enhance-d1-color);
- --cru-theme-d2-color: var(--cru-primary-enhance-d2-color);
- --cru-theme-d3-color: var(--cru-primary-enhance-d3-color);
- --cru-theme-f1-color: var(--cru-primary-enhance-f1-color);
- --cru-theme-f2-color: var(--cru-primary-enhance-f2-color);
- --cru-theme-f3-color: var(--cru-primary-enhance-f3-color);
- --cru-theme-r1-color: var(--cru-primary-enhance-r1-color);
- --cru-theme-r2-color: var(--cru-primary-enhance-r2-color);
- --cru-theme-r3-color: var(--cru-primary-enhance-r3-color);
- --cru-theme-t-color: var(--cru-primary-enhance-t-color);
- --cru-theme-t1-color: var(--cru-primary-enhance-t1-color);
- --cru-theme-t2-color: var(--cru-primary-enhance-t2-color);
- --cru-theme-t3-color: var(--cru-primary-enhance-t3-color);
-}
-
-.cru-secondary {
- --cru-theme-color: var(--cru-secondary-color);
- --cru-theme-l1-color: var(--cru-secondary-l1-color);
- --cru-theme-l2-color: var(--cru-secondary-l2-color);
- --cru-theme-l3-color: var(--cru-secondary-l3-color);
- --cru-theme-d1-color: var(--cru-secondary-d1-color);
- --cru-theme-d2-color: var(--cru-secondary-d2-color);
- --cru-theme-d3-color: var(--cru-secondary-d3-color);
- --cru-theme-f1-color: var(--cru-secondary-f1-color);
- --cru-theme-f2-color: var(--cru-secondary-f2-color);
- --cru-theme-f3-color: var(--cru-secondary-f3-color);
- --cru-theme-r1-color: var(--cru-secondary-r1-color);
- --cru-theme-r2-color: var(--cru-secondary-r2-color);
- --cru-theme-r3-color: var(--cru-secondary-r3-color);
- --cru-theme-t-color: var(--cru-secondary-t-color);
- --cru-theme-t1-color: var(--cru-secondary-t1-color);
- --cru-theme-t2-color: var(--cru-secondary-t2-color);
- --cru-theme-t3-color: var(--cru-secondary-t3-color);
-}
-
-.cru-success {
- --cru-theme-color: var(--cru-success-color);
- --cru-theme-l1-color: var(--cru-success-l1-color);
- --cru-theme-l2-color: var(--cru-success-l2-color);
- --cru-theme-l3-color: var(--cru-success-l3-color);
- --cru-theme-d1-color: var(--cru-success-d1-color);
- --cru-theme-d2-color: var(--cru-success-d2-color);
- --cru-theme-d3-color: var(--cru-success-d3-color);
- --cru-theme-f1-color: var(--cru-success-f1-color);
- --cru-theme-f2-color: var(--cru-success-f2-color);
- --cru-theme-f3-color: var(--cru-success-f3-color);
- --cru-theme-r1-color: var(--cru-success-r1-color);
- --cru-theme-r2-color: var(--cru-success-r2-color);
- --cru-theme-r3-color: var(--cru-success-r3-color);
- --cru-theme-t-color: var(--cru-success-t-color);
- --cru-theme-t1-color: var(--cru-success-t1-color);
- --cru-theme-t2-color: var(--cru-success-t2-color);
- --cru-theme-t3-color: var(--cru-success-t3-color);
-}
-
-.cru-danger {
- --cru-theme-color: var(--cru-danger-color);
- --cru-theme-l1-color: var(--cru-danger-l1-color);
- --cru-theme-l2-color: var(--cru-danger-l2-color);
- --cru-theme-l3-color: var(--cru-danger-l3-color);
- --cru-theme-d1-color: var(--cru-danger-d1-color);
- --cru-theme-d2-color: var(--cru-danger-d2-color);
- --cru-theme-d3-color: var(--cru-danger-d3-color);
- --cru-theme-f1-color: var(--cru-danger-f1-color);
- --cru-theme-f2-color: var(--cru-danger-f2-color);
- --cru-theme-f3-color: var(--cru-danger-f3-color);
- --cru-theme-r1-color: var(--cru-danger-r1-color);
- --cru-theme-r2-color: var(--cru-danger-r2-color);
- --cru-theme-r3-color: var(--cru-danger-r3-color);
- --cru-theme-t-color: var(--cru-danger-t-color);
- --cru-theme-t1-color: var(--cru-danger-t1-color);
- --cru-theme-t2-color: var(--cru-danger-t2-color);
- --cru-theme-t3-color: var(--cru-danger-t3-color);
-}
-
-.cru-color-primary {
- color: var(--cru-primary-color);
-}
-
-.cru-color-primary-enhance {
- color: var(--cru-primary-enhance-color);
-}
-
-.cru-color-secondary {
- color: var(--cru-secondary-color);
-}
-
-.cru-color-success {
- color: var(--cru-success-color);
-}
-
-.cru-color-danger {
- color: var(--cru-danger-color);
-}
-
-.cru-text-center {
- text-align: center;
-}
-
-.cru-text-end {
- text-align: end;
-}
-
-.cru-float-left {
- float: left;
-}
-
-.cru-float-right {
- float: right;
-}
-
-.cru-align-text-bottom {
- vertical-align: text-bottom;
-}
-
-.cru-align-middle {
- vertical-align: middle;
-}
-
-.cru-clearfix::after {
- clear: both;
-}
-
-.cru-fill-parent {
- width: 100%;
- height: 100%;
-}
-
-.cru-avatar {
- width: 60px;
- height: 60px;
-}
-
-.cru-avatar.large {
- width: 100px;
- height: 100px;
-}
-
-.cru-avatar.small {
- width: 40px;
- height: 40px;
-}
-
-.cru-round {
- border-radius: 50%;
-}
-
-.cru-tab-pages-action-area {
- display: flex;
- align-items: center;
-}
-
-.alert-container {
- position: fixed;
- z-index: 1070;
-}
-
-@media (min-width: 576px) {
- .alert-container {
- bottom: 0;
- right: 0;
- }
-}
-
-@media (max-width: 575.98px) {
- .alert-container {
- bottom: 0;
- right: 0;
- left: 0;
- text-align: center;
- }
-}
\ No newline at end of file diff --git a/FrontEnd/src/views/common/input/InputPanel.css b/FrontEnd/src/views/common/input/InputPanel.css deleted file mode 100644 index f9d6ac8b..00000000 --- a/FrontEnd/src/views/common/input/InputPanel.css +++ /dev/null @@ -1,25 +0,0 @@ -.cru-input-panel-group { - display: block; - margin: 0.4em 0; -} - -.cru-input-panel-label { - display: block; - color: var(--cru-primary-color); -} - -.cru-input-panel-inline-label { - margin-inline-start: 0.5em; -} - -.cru-input-panel-error-text { - display: block; - font-size: 0.8em; - color: var(--cru-danger-color); -} - -.cru-input-panel-helper-text { - display: block; - font-size: 0.8em; - color: var(--cru-primary-color); -} diff --git a/FrontEnd/src/views/common/input/InputPanel.tsx b/FrontEnd/src/views/common/input/InputPanel.tsx deleted file mode 100644 index 234ed267..00000000 --- a/FrontEnd/src/views/common/input/InputPanel.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import * as React from "react"; -import classNames from "classnames"; -import { useTranslation } from "react-i18next"; -import { TwitterPicker } from "react-color"; - -import { convertI18nText, I18nText } from "@/common"; - -import "./InputPanel.css"; - -export interface TextInput { - type: "text"; - label?: I18nText; - helper?: I18nText; - password?: boolean; -} - -export interface BoolInput { - type: "bool"; - label: I18nText; - helper?: I18nText; -} - -export interface SelectInputOption { - value: string; - label: I18nText; - icon?: React.ReactElement; -} - -export interface SelectInput { - type: "select"; - label: I18nText; - options: SelectInputOption[]; -} - -export interface ColorInput { - type: "color"; - label?: I18nText; -} - -export interface DateTimeInput { - type: "datetime"; - label?: I18nText; - helper?: I18nText; -} - -export type Input = - | TextInput - | BoolInput - | SelectInput - | ColorInput - | DateTimeInput; - -interface InputTypeToValueTypeMap { - text: string; - bool: boolean; - select: string; - color: string; - datetime: string; -} - -type ValueTypes = InputTypeToValueTypeMap[keyof InputTypeToValueTypeMap]; - -type MapInputTypeToValueType<Type> = Type extends keyof InputTypeToValueTypeMap - ? InputTypeToValueTypeMap[Type] - : never; - -type MapInputToValueType<T> = T extends Input - ? MapInputTypeToValueType<T["type"]> - : T; - -type MapInputListToValueTypeList<Tuple extends readonly Input[]> = { - [Index in keyof Tuple]: MapInputToValueType<Tuple[Index]>; -} & { length: Tuple["length"] }; - -export type InputPanelError = { - [index: number]: I18nText | null | undefined; -}; - -export function hasError(e: InputPanelError | null | undefined): boolean { - if (e == null) return false; - for (const key of Object.keys(e)) { - if (e[key as unknown as number] != null) return true; - } - return false; -} - -export interface InputPanelProps<InputList extends readonly Input[]> { - scheme: InputList; - values: MapInputListToValueTypeList<InputList>; - onChange: ( - values: MapInputListToValueTypeList<InputList>, - index: number - ) => void; - error?: InputPanelError; - disable?: boolean; -} - -const InputPanel = <InputList extends readonly Input[]>( - props: InputPanelProps<InputList> -): React.ReactElement => { - const { values, onChange, scheme, error, disable } = props; - - const { t } = useTranslation(); - - const updateValue = (index: number, newValue: ValueTypes): void => { - const oldValues = values; - const newValues = oldValues.slice(); - newValues[index] = newValue; - onChange( - newValues as unknown as MapInputListToValueTypeList<InputList>, - index - ); - }; - - return ( - <div> - {scheme.map((item, index) => { - const v = values[index]; - const e: string | null = convertI18nText(error?.[index], t); - - if (item.type === "text") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - {item.label && ( - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type={item.password === true ? "password" : "text"} - value={v as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={disable} - /> - {e && <div className="cru-input-panel-error-text">{e}</div>} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } else if (item.type === "bool") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <input - type="checkbox" - checked={v as boolean} - onChange={(event) => { - const value = event.currentTarget.checked; - updateValue(index, value); - }} - disabled={disable} - /> - <label className="cru-input-panel-inline-label"> - {convertI18nText(item.label, t)} - </label> - {e != null && ( - <div className="cru-input-panel-error-text">{e}</div> - )} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } else if (item.type === "select") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - <select - value={v as string} - onChange={(event) => { - const value = event.target.value; - updateValue(index, value); - }} - disabled={disable} - > - {item.options.map((option, i) => { - return ( - <option value={option.value} key={i}> - {option.icon} - {convertI18nText(option.label, t)} - </option> - ); - })} - </select> - </div> - ); - } else if (item.type === "color") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <label className="cru-input-panel-inline-label"> - {convertI18nText(item.label, t)} - </label> - <TwitterPicker - color={v as string} - triangle="hide" - onChange={(result) => updateValue(index, result.hex)} - /> - </div> - ); - } else if (item.type === "datetime") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - {item.label && ( - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type="datetime-local" - value={v as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={disable} - /> - {e != null && ( - <div className="cru-input-panel-error-text">{e}</div> - )} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } - })} - </div> - ); -}; - -export default InputPanel; diff --git a/FrontEnd/src/views/common/menu/Menu.css b/FrontEnd/src/views/common/menu/Menu.css deleted file mode 100644 index c3fa82c4..00000000 --- a/FrontEnd/src/views/common/menu/Menu.css +++ /dev/null @@ -1,24 +0,0 @@ -.cru-menu {
- min-width: 200px;
-}
-
-.cru-menu-item {
- font-size: 1em;
- padding: 0.5em 1.5em;
- cursor: pointer;
- transition: all 0.5s;
- color: var(--cru-theme-color);
-}
-
-.cru-menu-item:hover {
- color: var(--cru-theme-t-color);
- background-color: var(--cru-theme-color);
-}
-
-.cru-menu-item-icon {
- margin-right: 1em;
-}
-
-.cru-menu-divider {
- border-top: 1px solid #e9ecef;
-}
diff --git a/FrontEnd/src/views/common/menu/Menu.tsx b/FrontEnd/src/views/common/menu/Menu.tsx deleted file mode 100644 index de3b1664..00000000 --- a/FrontEnd/src/views/common/menu/Menu.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; - -import { convertI18nText, I18nText } from "@/common"; -import { PaletteColorType } from "@/palette"; - -import "./Menu.css"; - -export type MenuItem = - | { - type: "divider"; - } - | { - type: "button"; - text: I18nText; - iconClassName?: string; - color?: PaletteColorType; - onClick: () => void; - }; - -export type MenuItems = MenuItem[]; - -export type MenuProps = { - items: MenuItems; - onItemClicked?: () => void; - className?: string; - style?: React.CSSProperties; -}; - -export default function _Menu({ - items, - onItemClicked, - className, - style, -}: MenuProps): React.ReactElement | null { - const { t } = useTranslation(); - - return ( - <div className={classnames("cru-menu", className)} style={style}> - {items.map((item, index) => { - if (item.type === "divider") { - return <div key={index} className="cru-menu-divider" />; - } else { - return ( - <div - key={index} - className={classnames( - "cru-menu-item", - `cru-${item.color ?? "primary"}` - )} - onClick={() => { - item.onClick(); - onItemClicked?.(); - }} - > - {item.iconClassName != null ? ( - <i - className={classnames( - item.iconClassName, - "cru-menu-item-icon" - )} - /> - ) : null} - {convertI18nText(item.text, t)} - </div> - ); - } - })} - </div> - ); -} diff --git a/FrontEnd/src/views/common/menu/PopupMenu.css b/FrontEnd/src/views/common/menu/PopupMenu.css deleted file mode 100644 index f6654f68..00000000 --- a/FrontEnd/src/views/common/menu/PopupMenu.css +++ /dev/null @@ -1,6 +0,0 @@ -.cru-popup-menu-menu-container {
- z-index: 1040;
- border-radius: 5px;
- border: var(--cru-primary-color) 1px solid;
- background-color: white;
-}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.tsx b/FrontEnd/src/views/common/menu/PopupMenu.tsx deleted file mode 100644 index 74ca7aba..00000000 --- a/FrontEnd/src/views/common/menu/PopupMenu.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import classNames from "classnames"; -import * as React from "react"; -import { createPortal } from "react-dom"; -import { usePopper } from "react-popper"; - -import { useClickOutside } from "@/utilities/hooks"; - -import Menu, { MenuItems } from "./Menu"; - -import "./PopupMenu.css"; - -export interface PopupMenuProps { - items: MenuItems; - children?: React.ReactNode; - containerClassName?: string; - containerStyle?: React.CSSProperties; -} - -const PopupMenu: React.FC<PopupMenuProps> = ({ - items, - children, - containerClassName, - containerStyle, -}) => { - const [show, setShow] = React.useState<boolean>(false); - - const [referenceElement, setReferenceElement] = - React.useState<HTMLDivElement | null>(null); - const [popperElement, setPopperElement] = - React.useState<HTMLDivElement | null>(null); - const { styles, attributes } = usePopper(referenceElement, popperElement); - - useClickOutside(popperElement, () => setShow(false), true); - - return ( - <> - <div - ref={setReferenceElement} - className={classNames( - "cru-popup-menu-trigger-container", - containerClassName - )} - style={containerStyle} - onClick={() => setShow(true)} - > - {children} - </div> - {show - ? createPortal( - <div - ref={setPopperElement} - className="cru-popup-menu-menu-container" - style={styles.popper} - {...attributes.popper} - > - <Menu - items={items} - onItemClicked={() => { - setShow(false); - }} - /> - </div>, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.getElementById("portal")! - ) - : null} - </> - ); -}; - -export default PopupMenu; diff --git a/FrontEnd/src/views/common/tab/TabPages.tsx b/FrontEnd/src/views/common/tab/TabPages.tsx deleted file mode 100644 index cdb988e0..00000000 --- a/FrontEnd/src/views/common/tab/TabPages.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from "react"; - -import { I18nText, UiLogicError } from "@/common"; - -import Tabs from "./Tabs"; - -export interface TabPage { - name: string; - text: I18nText; - page: React.ReactNode; -} - -export interface TabPagesProps { - pages: TabPage[]; - actions?: React.ReactNode; - dense?: boolean; - className?: string; - style?: React.CSSProperties; - navClassName?: string; - navStyle?: React.CSSProperties; - pageContainerClassName?: string; - pageContainerStyle?: React.CSSProperties; -} - -const TabPages: React.FC<TabPagesProps> = ({ - pages, - actions, - dense, - className, - style, - navClassName, - navStyle, - pageContainerClassName, - pageContainerStyle, -}) => { - if (pages.length === 0) { - throw new UiLogicError("Page list can't be empty."); - } - - const [tab, setTab] = React.useState<string>(pages[0].name); - - const currentPage = pages.find((p) => p.name === tab); - - if (currentPage == null) { - throw new UiLogicError("Current tab value is bad."); - } - - return ( - <div className={className} style={style}> - <Tabs - tabs={pages.map((page) => ({ - name: page.name, - text: page.text, - onClick: () => { - setTab(page.name); - }, - }))} - dense={dense} - activeTabName={tab} - className={navClassName} - style={navStyle} - actions={actions} - /> - <div className={pageContainerClassName} style={pageContainerStyle}> - {currentPage.page} - </div> - </div> - ); -}; - -export default TabPages; diff --git a/FrontEnd/src/views/common/tab/Tabs.css b/FrontEnd/src/views/common/tab/Tabs.css deleted file mode 100644 index 395d16a7..00000000 --- a/FrontEnd/src/views/common/tab/Tabs.css +++ /dev/null @@ -1,33 +0,0 @@ -.cru-nav {
- border-bottom: var(--cru-primary-color) 1px solid;
- display: flex;
-}
-
-.cru-nav-item {
- color: var(--cru-primary-color);
- border: var(--cru-background-2-color) 0.5px solid;
- border-bottom: none;
- padding: 0.5em 1.5em;
- border-top-left-radius: 5px;
- border-top-right-radius: 5px;
- transition: all 0.5s;
- cursor: pointer;
-}
-
-.cru-nav.dense .cru-nav-item {
- padding: 0.2em 1em;
-}
-
-.cru-nav-item:hover {
- background-color: var(--cru-background-1-color);
-}
-
-.cru-nav-item.active {
- color: var(--cru-primary-t-color);
- background-color: var(--cru-primary-color);
- border-color: var(--cru-primary-color);
-}
-
-.cru-nav-action-area {
- margin-left: auto;
-}
diff --git a/FrontEnd/src/views/common/tab/Tabs.tsx b/FrontEnd/src/views/common/tab/Tabs.tsx deleted file mode 100644 index 3e3ef6fa..00000000 --- a/FrontEnd/src/views/common/tab/Tabs.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import * as React from "react"; -import { Link } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import classnames from "classnames"; - -import { convertI18nText, I18nText } from "@/common"; - -import "./Tabs.css"; - -export interface Tab { - name: string; - text: I18nText; - link?: string; - onClick?: () => void; -} - -export interface TabsProps { - activeTabName?: string; - actions?: React.ReactNode; - dense?: boolean; - tabs: Tab[]; - className?: string; - style?: React.CSSProperties; -} - -export default function Tabs(props: TabsProps): React.ReactElement | null { - const { tabs, activeTabName, className, style, dense, actions } = props; - - const { t } = useTranslation(); - - return ( - <div - className={classnames("cru-nav", dense && "dense", className)} - style={style} - > - {tabs.map((tab) => { - const active = activeTabName === tab.name; - const className = classnames("cru-nav-item", active && "active"); - - if (tab.link != null) { - return ( - <Link - key={tab.name} - to={tab.link} - onClick={tab.onClick} - className={className} - > - {convertI18nText(tab.text, t)} - </Link> - ); - } else { - return ( - <span key={tab.name} onClick={tab.onClick} className={className}> - {convertI18nText(tab.text, t)} - </span> - ); - } - })} - <div className="cru-nav-action-area">{actions}</div> - </div> - ); -} diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx deleted file mode 100644 index fcff8c69..00000000 --- a/FrontEnd/src/views/common/user/UserAvatar.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react"; - -import { getHttpUserClient } from "@/http/user"; - -export interface UserAvatarProps - extends React.ImgHTMLAttributes<HTMLImageElement> { - username: string; -} - -const UserAvatar: React.FC<UserAvatarProps> = ({ username, ...otherProps }) => { - return ( - <img - src={getHttpUserClient().generateAvatarUrl(username)} - {...otherProps} - /> - ); -}; - -export default UserAvatar; diff --git a/FrontEnd/src/views/home/TimelineListView.tsx b/FrontEnd/src/views/home/TimelineListView.tsx deleted file mode 100644 index fbcdc9b0..00000000 --- a/FrontEnd/src/views/home/TimelineListView.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -import { convertI18nText, I18nText } from "@/common"; - -import { TimelineBookmark } from "@/http/bookmark"; - -import IconButton from "../common/button/IconButton"; - -interface TimelineListItemProps { - timeline: TimelineBookmark; -} - -const TimelineListItem: React.FC<TimelineListItemProps> = ({ timeline }) => { - return ( - <div className="home-timeline-list-item home-timeline-list-item-timeline"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 100"> - <path - d="M 80,50 m 0,-12 a 12 12 180 1 1 0,24 12 12 180 1 1 0,-24 z M 60,0 h 40 v 100 h -40 z" - fillRule="evenodd" - fill="#007bff" - /> - </svg> - <div> - {timeline.timelineOwner}/{timeline.timelineName} - </div> - <Link to={`${timeline.timelineOwner}/${timeline.timelineName}`}> - <IconButton icon="arrow-right" className="ms-3" /> - </Link> - </div> - ); -}; - -const TimelineListArrow: React.FC = () => { - return ( - <div> - <div className="home-timeline-list-item"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 60"> - <path d="M 60,0 h 40 v 20 l -20,20 l -20,-20 z" fill="#007bff" /> - </svg> - </div> - <div className="home-timeline-list-item"> - <svg - className="home-timeline-list-item-line home-timeline-list-loading-head" - viewBox="0 0 120 40" - > - <path - d="M 60,10 l 20,20 l 20,-20" - fill="none" - stroke="#007bff" - strokeWidth="5" - /> - </svg> - </div> - </div> - ); -}; - -interface TimelineListViewProps { - headerText?: I18nText; - timelines?: TimelineBookmark[]; -} - -const TimelineListView: React.FC<TimelineListViewProps> = ({ - headerText, - timelines, -}) => { - const { t } = useTranslation(); - - return ( - <div className="home-timeline-list"> - <div className="home-timeline-list-item"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 120"> - <path - d="M 0,20 Q 80,20 80,80 l 0,40" - stroke="#007bff" - strokeWidth="40" - fill="none" - /> - </svg> - <h3>{convertI18nText(headerText, t)}</h3> - </div> - {timelines != null - ? timelines.map((t) => ( - <TimelineListItem - key={`${t.timelineOwner}/${t.timelineName}`} - timeline={t} - /> - )) - : null} - <TimelineListArrow /> - </div> - ); -}; - -export default TimelineListView; diff --git a/FrontEnd/src/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/views/home/WebsiteIntroduction.tsx deleted file mode 100644 index e843c325..00000000 --- a/FrontEnd/src/views/home/WebsiteIntroduction.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -const WebsiteIntroduction: React.FC<{ - className?: string; - style?: React.CSSProperties; -}> = ({ className, style }) => { - const { i18n } = useTranslation(); - - if (i18n.language.startsWith("zh")) { - return ( - <div className={className} style={style}> - <h2> - 欢迎来到<strong>时间线</strong>!🎉🎉🎉 - </h2> - <p> - 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。 - </p> - <p> - 如果你拥有一个账号,<Link to="/login">登陆</Link> - 后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。 - </p> - <p> - 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。 - </p> - <p> - 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在 - <Link to="/about">关于</Link>页面找到一些信息。 - </p> - <p> - <small className="text-secondary"> - 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅 - </small> - </p> - </div> - ); - } else { - return ( - <div className={className} style={style}> - <h2> - Welcome to <strong>Timeline</strong>!🎉🎉🎉 - </h2> - <p> - This website consists of many individual timelines. Each timeline is a - list of messages just like a chat app. - </p> - <p> - If you do have an account, you can <Link to="/login">login</Link> and - post messages, which supports Markdown and images, in your timelines. - You can also create a new timeline to open a new topic. You can set - the permission of a timeline to only allow specified people to see the - content of the timeline. - </p> - <p> - If you don't have an account, you can view some public timelines - like highlight timelines below set by website manager. - </p> - <p> - Since this website is hosted on my tiny server, so account registry is - not opened. If you want to host this service on your own server, you - can find some useful information on <Link to="/about">about</Link>{" "} - page. - </p> - <p> - <small className="text-secondary"> - This introduction is added after my lover complained a lot of times - about the obscuration of my website. May you understand the logic of - it!😅 - </small> - </p> - </div> - ); - } -}; - -export default WebsiteIntroduction; diff --git a/FrontEnd/src/views/home/index.css b/FrontEnd/src/views/home/index.css deleted file mode 100644 index 89d36f0d..00000000 --- a/FrontEnd/src/views/home/index.css +++ /dev/null @@ -1,42 +0,0 @@ -.home-timeline-list-item {
- display: flex;
- align-items: center;
-}
-
-.home-timeline-list-item-timeline {
- transition: background 0.8s;
- animation: 0.8s home-timeline-list-item-timeline-enter;
-}
-.home-timeline-list-item-timeline:hover {
- background: #e9ecef;
-}
-
-@keyframes home-timeline-list-item-timeline-enter {
- from {
- transform: translate(-100%, 0);
- opacity: 0;
- }
-}
-.home-timeline-list-item-line {
- width: 80px;
- flex-shrink: 0;
-}
-
-@keyframes home-timeline-list-loading-head-animation {
- from {
- transform: translate(0, -30px);
- opacity: 1;
- }
- to {
- opacity: 0;
- }
-}
-.home-timeline-list-loading-head {
- animation: 1s infinite home-timeline-list-loading-head-animation;
-}
-
-@media (min-width: 576px) {
- .home-search {
- float: right;
- }
-}
diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx deleted file mode 100644 index 3c80fb0c..00000000 --- a/FrontEnd/src/views/home/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react"; -import { useNavigate } from "react-router-dom"; - -import { highlightTimelineUsername } from "@/common"; - -import { Page } from "@/http/common"; -import { getHttpBookmarkClient, TimelineBookmark } from "@/http/bookmark"; - -import SearchInput from "../common/SearchInput"; -import TimelineListView from "./TimelineListView"; -import WebsiteIntroduction from "./WebsiteIntroduction"; - -import "./index.css"; - -const highlightTimelineMessageMap = { - loading: "home.loadingHighlightTimelines", - done: "home.loadedHighlightTimelines", - error: "home.errorHighlightTimelines", -} as const; - -const HomeV2: React.FC = () => { - const navigate = useNavigate(); - - const [navText, setNavText] = React.useState<string>(""); - - const [highlightTimelineState, setHighlightTimelineState] = React.useState< - "loading" | "done" | "error" - >("loading"); - const [highlightTimelines, setHighlightTimelines] = React.useState< - Page<TimelineBookmark> | undefined - >(); - - React.useEffect(() => { - if (highlightTimelineState === "loading") { - let subscribe = true; - void getHttpBookmarkClient() - .list(highlightTimelineUsername) - .then( - (data) => { - if (subscribe) { - setHighlightTimelineState("done"); - setHighlightTimelines(data); - } - }, - () => { - if (subscribe) { - setHighlightTimelineState("error"); - setHighlightTimelines(undefined); - } - } - ); - return () => { - subscribe = false; - }; - } - }, [highlightTimelineState]); - - return ( - <> - <SearchInput - className="mx-2 my-3 home-search" - value={navText} - onChange={setNavText} - onButtonClick={() => { - navigate(`search?q=${navText}`); - }} - alwaysOneline - /> - <WebsiteIntroduction className="m-2" /> - <TimelineListView - headerText={highlightTimelineMessageMap[highlightTimelineState]} - timelines={highlightTimelines?.items} - /> - </> - ); -}; - -export default HomeV2; diff --git a/FrontEnd/src/views/login/index.css b/FrontEnd/src/views/login/index.css deleted file mode 100644 index aefe57e8..00000000 --- a/FrontEnd/src/views/login/index.css +++ /dev/null @@ -1,8 +0,0 @@ -.login-container {
- max-width: 25em;
-}
-
-.login-container input[type="text"],
-.login-container input[type="password"] {
- width: 100%;
-}
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx deleted file mode 100644 index cc1d9865..00000000 --- a/FrontEnd/src/views/login/index.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import * as React from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { useTranslation, Trans } from "react-i18next"; - -import { useUser, userService } from "@/services/user"; - -import AppBar from "../common/AppBar"; -import LoadingButton from "../common/button/LoadingButton"; - -import "./index.css"; - -const LoginPage: React.FC = () => { - const { t } = useTranslation(); - - const navigate = useNavigate(); - - const [username, setUsername] = React.useState<string>(""); - const [usernameDirty, setUsernameDirty] = React.useState<boolean>(false); - const [password, setPassword] = React.useState<string>(""); - const [passwordDirty, setPasswordDirty] = React.useState<boolean>(false); - const [rememberMe, setRememberMe] = React.useState<boolean>(true); - const [process, setProcess] = React.useState<boolean>(false); - const [error, setError] = React.useState<string | null>(null); - - const user = useUser(); - - React.useEffect(() => { - if (user != null) { - const id = setTimeout(() => navigate("/"), 3000); - return () => { - clearTimeout(id); - }; - } - }, [navigate, user]); - - if (user != null) { - return ( - <> - <AppBar /> - <p>{t("login.alreadyLogin")}</p> - </> - ); - } - - const submit = (): void => { - if (username === "" || password === "") { - setUsernameDirty(true); - setPasswordDirty(true); - return; - } - - setProcess(true); - userService - .login( - { - username: username, - password: password, - }, - rememberMe - ) - .then( - () => { - if (history.length === 0) { - navigate("/"); - } else { - navigate(-1); - } - }, - (e: Error) => { - setProcess(false); - setError(e.message); - } - ); - }; - - const onEnterPressInPassword: React.KeyboardEventHandler = (e) => { - if (e.key === "Enter") { - submit(); - } - }; - - return ( - <div className="login-container container-fluid mt-2"> - <h1 className="cru-text-center cru-color-primary">{t("welcome")}</h1> - <div className="cru-operation-dialog-group"> - <label className="cru-operation-dialog-label" htmlFor="username"> - {t("user.username")} - </label> - <input - id="username" - type="text" - disabled={process} - onChange={(e) => { - setUsername(e.target.value); - setUsernameDirty(true); - }} - value={username} - /> - {usernameDirty && username === "" && ( - <div className="cru-operation-dialog-error-text"> - {t("login.emptyUsername")} - </div> - )} - </div> - <div className="cru-operation-dialog-group"> - <label className="cru-operation-dialog-label" htmlFor="password"> - {t("user.password")} - </label> - <input - id="password" - type="password" - disabled={process} - onChange={(e) => { - setPassword(e.target.value); - setPasswordDirty(true); - }} - value={password} - onKeyDown={onEnterPressInPassword} - /> - {passwordDirty && password === "" && ( - <div className="cru-operation-dialog-error-text"> - {t("login.emptyPassword")} - </div> - )} - </div> - <div className="cru-operation-dialog-group"> - <input - id="remember-me" - type="checkbox" - checked={rememberMe} - onChange={(e) => { - setRememberMe(e.currentTarget.checked); - }} - /> - <label className="cru-operation-dialog-inline-label"> - {t("user.rememberMe")} - </label> - </div> - {error ? <p className="cru-color-danger">{t(error)}</p> : null} - <div className="cru-text-end"> - <LoadingButton - loading={process} - onClick={(e) => { - submit(); - e.preventDefault(); - }} - disabled={username === "" || password === "" ? true : undefined} - > - {t("user.login")} - </LoadingButton> - </div> - <Trans i18nKey="login.noAccount"> - 0<Link to="/register">1</Link>2 - </Trans> - </div> - ); -}; - -export default LoginPage; diff --git a/FrontEnd/src/views/register/index.tsx b/FrontEnd/src/views/register/index.tsx deleted file mode 100644 index c1b95ff7..00000000 --- a/FrontEnd/src/views/register/index.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; - -import { HttpBadRequestError } from "@/http/common"; -import { getHttpTokenClient } from "@/http/token"; -import { userService, useUser } from "@/services/user"; - -import { LoadingButton } from "../common/button"; -import InputPanel, { - hasError, - InputPanelError, -} from "../common/input/InputPanel"; - -import "./index.css"; - -const validate = (values: string[], dirties: boolean[]): InputPanelError => { - const e: InputPanelError = {}; - if (dirties[0] && values[0].length === 0) { - e[0] = "register.error.usernameEmpty"; - } - if (dirties[1] && values[1].length === 0) { - e[1] = "register.error.passwordEmpty"; - } - if (dirties[2] && values[2] !== values[1]) { - e[2] = "register.error.confirmPasswordWrong"; - } - if (dirties[3] && values[3].length === 0) { - e[3] = "register.error.registerCodeEmpty"; - } - return e; -}; - -const RegisterPage: React.FC = () => { - const navigate = useNavigate(); - - const { t } = useTranslation(); - - const [username, setUsername] = React.useState<string>(""); - const [password, setPassword] = React.useState<string>(""); - const [confirmPassword, setConfirmPassword] = React.useState<string>(""); - const [registerCode, setRegisterCode] = React.useState<string>(""); - - const [dirty, setDirty] = React.useState<boolean[]>(new Array(4).fill(false)); - - const [process, setProcess] = React.useState<boolean>(false); - - const [inputError, setInputError] = React.useState<InputPanelError>(); - const [resultError, setResultError] = React.useState<string | null>(null); - - const user = useUser(); - - React.useEffect(() => { - if (user != null) { - navigate("/"); - } - }); - - return ( - <div className="container register-page"> - <InputPanel - scheme={[ - { - type: "text", - label: "register.username", - }, - { - type: "text", - label: "register.password", - password: true, - }, - { - type: "text", - label: "register.confirmPassword", - password: true, - }, - { type: "text", label: "register.registerCode" }, - ]} - values={[username, password, confirmPassword, registerCode]} - onChange={(values, index) => { - setUsername(values[0]); - setPassword(values[1]); - setConfirmPassword(values[2]); - setRegisterCode(values[3]); - const newDirty = dirty.slice(); - newDirty[index] = true; - setDirty(newDirty); - - setInputError(validate(values, newDirty)); - }} - error={inputError} - disable={process} - /> - {resultError && <div className="cru-color-danger">{t(resultError)}</div>} - <LoadingButton - text="register.register" - loading={process} - disabled={hasError(inputError)} - onClick={() => { - const newDirty = dirty.slice().fill(true); - setDirty(newDirty); - const e = validate( - [username, password, confirmPassword, registerCode], - newDirty - ); - if (hasError(e)) { - setInputError(e); - } else { - setProcess(true); - void getHttpTokenClient() - .register({ - username, - password, - registerCode, - }) - .then( - () => { - void userService - .login({ username, password }, true) - .then(() => { - navigate("/"); - }); - }, - (error) => { - if (error instanceof HttpBadRequestError) { - setResultError("register.error.registerCodeInvalid"); - } else { - setResultError("error.network"); - } - setProcess(false); - } - ); - } - }} - /> - </div> - ); -}; - -export default RegisterPage; diff --git a/FrontEnd/src/views/search/index.css b/FrontEnd/src/views/search/index.css deleted file mode 100644 index 6ff4d9fa..00000000 --- a/FrontEnd/src/views/search/index.css +++ /dev/null @@ -1,15 +0,0 @@ -.timeline-search-result-item {
- border: 1px solid;
- border-color: #e9ecef;
- background: #f8f9fa;
- transition: all 0.3s;
-}
-.timeline-search-result-item:hover {
- border-color: #0d6efd;
-}
-
-.timeline-search-result-item-avatar {
- width: 2em;
- height: 2em;
- border-radius: 50%;
-}
diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx deleted file mode 100644 index 58257465..00000000 --- a/FrontEnd/src/views/search/index.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useNavigate, useLocation } from "react-router-dom"; -import { Link } from "react-router-dom"; - -import { HttpNetworkError } from "@/http/common"; -import { getHttpSearchClient } from "@/http/search"; -import { HttpTimelineInfo } from "@/http/timeline"; - -import SearchInput from "../common/SearchInput"; -import UserAvatar from "../common/user/UserAvatar"; - -import "./index.css"; - -const TimelineSearchResultItemView: React.FC<{ - timeline: HttpTimelineInfo; -}> = ({ timeline }) => { - return ( - <div className="timeline-search-result-item my-2 p-3"> - <h4> - <Link - to={`/${timeline.owner.username}/${timeline.nameV2}`} - className="mb-2 text-primary" - > - {timeline.title} - <small className="ms-3 text-secondary">{timeline.nameV2}</small> - </Link> - </h4> - <div> - <UserAvatar - username={timeline.owner.username} - className="timeline-search-result-item-avatar me-2" - /> - {timeline.owner.nickname} - <small className="ms-3 text-secondary"> - @{timeline.owner.username} - </small> - </div> - </div> - ); -}; - -const SearchPage: React.FC = () => { - const { t } = useTranslation(); - - const navigate = useNavigate(); - const location = useLocation(); - const searchParams = new URLSearchParams(location.search); - const queryParam = searchParams.get("q"); - - const [searchText, setSearchText] = React.useState<string>(""); - const [state, setState] = React.useState< - HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error" - >("init"); - - const [forceResearchKey, setForceResearchKey] = React.useState<number>(0); - - React.useEffect(() => { - setState("init"); - if (queryParam != null && queryParam.length > 0) { - setSearchText(queryParam); - setState("loading"); - void getHttpSearchClient() - .searchTimelines(queryParam) - .then( - (ts) => { - setState(ts); - }, - (e) => { - if (e instanceof HttpNetworkError) { - setState("network-error"); - } else { - setState("error"); - } - } - ); - } - }, [queryParam, forceResearchKey]); - - return ( - <div className="container my-3"> - <div className="row justify-content-center"> - <SearchInput - className="col-12 col-sm-9 col-md-6" - value={searchText} - onChange={setSearchText} - loading={state === "loading"} - onButtonClick={() => { - if (queryParam === searchText) { - setForceResearchKey((old) => old + 1); - } else { - navigate(`/search?q=${searchText}`); - } - }} - /> - </div> - {(() => { - switch (state) { - case "init": { - if (queryParam == null || queryParam.length === 0) { - return <div>{t("searchPage.input")}</div>; - } - break; - } - case "loading": { - return <div>{t("searchPage.loading")}</div>; - } - case "network-error": { - return <div className="text-danger">{t("error.network")}</div>; - } - case "error": { - return <div className="text-danger">{t("error.unknown")}</div>; - } - default: { - if (state.length === 0) { - return <div>{t("searchPage.noResult")}</div>; - } - return state.map((t) => ( - <TimelineSearchResultItemView - key={`${t.owner.username}/${t.nameV2}`} - timeline={t} - /> - )); - } - } - })()} - </div> - ); -}; - -export default SearchPage; diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx deleted file mode 100644 index 44bd2c68..00000000 --- a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { useState, useEffect } from "react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { AxiosError } from "axios"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; - -import { useUser } from "@/services/user"; - -import { getHttpUserClient } from "@/http/user"; - -import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; -import Button from "../common/button/Button"; -import Dialog from "../common/dialog/Dialog"; - -export interface ChangeAvatarDialogProps { - open: boolean; - close: () => void; -} - -const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => { - const { t } = useTranslation(); - - const user = useUser(); - - const [file, setFile] = React.useState<File | null>(null); - const [fileUrl, setFileUrl] = React.useState<string | null>(null); - const [clip, setClip] = React.useState<Clip | null>(null); - const [cropImgElement, setCropImgElement] = - React.useState<HTMLImageElement | null>(null); - const [resultBlob, setResultBlob] = React.useState<Blob | null>(null); - const [resultUrl, setResultUrl] = React.useState<string | null>(null); - - const [state, setState] = React.useState< - | "select" - | "crop" - | "processcrop" - | "preview" - | "uploading" - | "success" - | "error" - >("select"); - - const [message, setMessage] = useState<I18nText>( - "settings.dialogChangeAvatar.prompt.select" - ); - - const trueMessage = convertI18nText(message, t); - - const closeDialog = props.close; - - const close = React.useCallback((): void => { - if (!(state === "uploading")) { - closeDialog(); - } - }, [state, closeDialog]); - - useEffect(() => { - if (file != null) { - const url = URL.createObjectURL(file); - setClip(null); - setFileUrl(url); - setState("crop"); - return () => { - URL.revokeObjectURL(url); - }; - } else { - setFileUrl(null); - setState("select"); - } - }, [file]); - - React.useEffect(() => { - if (resultBlob != null) { - const url = URL.createObjectURL(resultBlob); - setResultUrl(url); - setState("preview"); - return () => { - URL.revokeObjectURL(url); - }; - } else { - setResultUrl(null); - } - }, [resultBlob]); - - const onSelectFile = React.useCallback( - (e: React.ChangeEvent<HTMLInputElement>): void => { - const files = e.target.files; - if (files == null || files.length === 0) { - setFile(null); - } else { - setFile(files[0]); - } - }, - [] - ); - - const onCropNext = React.useCallback(() => { - if ( - cropImgElement == null || - clip == null || - clip.width === 0 || - file == null - ) { - throw new UiLogicError(); - } - - setState("processcrop"); - void applyClipToImage(cropImgElement, clip, file.type).then((b) => { - setResultBlob(b); - }); - }, [cropImgElement, clip, file]); - - const onCropPrevious = React.useCallback(() => { - setFile(null); - setState("select"); - }, []); - - const onPreviewPrevious = React.useCallback(() => { - setResultBlob(null); - setState("crop"); - }, []); - - const upload = React.useCallback(() => { - if (resultBlob == null) { - throw new UiLogicError(); - } - - if (user == null) { - throw new UiLogicError(); - } - - setState("uploading"); - getHttpUserClient() - .putAvatar(user.username, resultBlob) - .then( - () => { - setState("success"); - }, - (e: unknown) => { - setState("error"); - setMessage({ type: "custom", value: (e as AxiosError).message }); - } - ); - }, [user, resultBlob]); - - const createPreviewRow = (): React.ReactElement => { - if (resultUrl == null) { - throw new UiLogicError(); - } - return ( - <div className="row justify-content-center"> - <div className="col col-auto"> - <img - className="change-avatar-img" - src={resultUrl} - alt={t("settings.dialogChangeAvatar.previewImgAlt") ?? undefined} - /> - </div> - </div> - ); - }; - - return ( - <Dialog open={props.open} onClose={close}> - <h3 className="cru-color-primary"> - {t("settings.dialogChangeAvatar.title")} - </h3> - <hr /> - {(() => { - if (state === "select") { - return ( - <> - <div className="container"> - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.select")} - </div> - <div className="row"> - <input - className="px-0" - type="file" - accept="image/*" - onChange={onSelectFile} - /> - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - onClick={close} - /> - </div> - </> - ); - } else if (state === "crop") { - if (fileUrl == null) { - throw new UiLogicError(); - } - return ( - <> - <div className="container"> - <div className="row justify-content-center"> - <ImageCropper - clip={clip} - onChange={setClip} - imageUrl={fileUrl} - imageElementCallback={setCropImgElement} - /> - </div> - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.crop")} - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - outline - onClick={close} - /> - <Button - text="operationDialog.previousStep" - color="secondary" - outline - onClick={onCropPrevious} - /> - <Button - text="operationDialog.nextStep" - color="primary" - onClick={onCropNext} - disabled={ - cropImgElement == null || clip == null || clip.width === 0 - } - /> - </div> - </> - ); - } else if (state === "processcrop") { - return ( - <> - <div className="container"> - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.processingCrop")} - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - onClick={close} - outline - /> - <Button - text="operationDialog.previousStep" - color="secondary" - onClick={onPreviewPrevious} - outline - /> - </div> - </> - ); - } else if (state === "preview") { - return ( - <> - <div className="container"> - {createPreviewRow()} - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.preview")} - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.cancel" - color="secondary" - outline - onClick={close} - /> - <Button - text="operationDialog.previousStep" - color="secondary" - outline - onClick={onPreviewPrevious} - /> - <Button - text="settings.dialogChangeAvatar.upload" - color="primary" - onClick={upload} - /> - </div> - </> - ); - } else if (state === "uploading") { - return ( - <> - <div className="container"> - {createPreviewRow()} - <div className="row"> - {t("settings.dialogChangeAvatar.prompt.uploading")} - </div> - </div> - </> - ); - } else if (state === "success") { - return ( - <> - <div className="container"> - <div className="row p-4 text-success"> - {t("operationDialog.success")} - </div> - </div> - <hr /> - <div className="cru-dialog-bottom-area"> - <Button - text="operationDialog.ok" - color="success" - onClick={close} - /> - </div> - </> - ); - } else { - return ( - <> - <div className="container"> - {createPreviewRow()} - <div className="row text-danger">{trueMessage}</div> - </div> - <hr /> - <div> - <Button - text="operationDialog.cancel" - color="secondary" - onClick={close} - /> - <Button - text="operationDialog.retry" - color="primary" - onClick={upload} - /> - </div> - </> - ); - } - })()} - </Dialog> - ); -}; - -export default ChangeAvatarDialog; diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx deleted file mode 100644 index 7ba12de8..00000000 --- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { getHttpUserClient } from "@/http/user"; -import { useUser } from "@/services/user"; -import * as React from "react"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -export interface ChangeNicknameDialogProps { - open: boolean; - close: () => void; -} - -const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => { - const user = useUser(); - - if (user == null) return null; - - return ( - <OperationDialog - open={props.open} - title="settings.dialogChangeNickname.title" - inputScheme={[ - { type: "text", label: "settings.dialogChangeNickname.inputLabel" }, - ]} - onProcess={([newNickname]) => { - return getHttpUserClient().patch(user.username, { - nickname: newNickname, - }); - }} - onClose={props.close} - /> - ); -}; - -export default ChangeNicknameDialog; diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx deleted file mode 100644 index a34ca4a7..00000000 --- a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useNavigate } from "react-router-dom"; - -import { userService } from "@/services/user"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -export interface ChangePasswordDialogProps { - open: boolean; - close: () => void; -} - -const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { - const navigate = useNavigate(); - - const [redirect, setRedirect] = useState<boolean>(false); - - return ( - <OperationDialog - open={props.open} - title="settings.dialogChangePassword.title" - themeColor="danger" - inputPrompt="settings.dialogChangePassword.prompt" - inputScheme={[ - { - type: "text", - label: "settings.dialogChangePassword.inputOldPassword", - password: true, - }, - { - type: "text", - label: "settings.dialogChangePassword.inputNewPassword", - password: true, - }, - { - type: "text", - label: "settings.dialogChangePassword.inputRetypeNewPassword", - password: true, - }, - ]} - inputValidator={([oldPassword, newPassword, retypedNewPassword]) => { - const result: Record<number, string> = {}; - if (oldPassword === "") { - result[0] = "settings.dialogChangePassword.errorEmptyOldPassword"; - } - if (newPassword === "") { - result[1] = "settings.dialogChangePassword.errorEmptyNewPassword"; - } - if (retypedNewPassword !== newPassword) { - result[2] = "settings.dialogChangePassword.errorRetypeNotMatch"; - } - return result; - }} - onProcess={async ([oldPassword, newPassword]) => { - await userService.changePassword(oldPassword, newPassword); - setRedirect(true); - }} - onClose={() => { - props.close(); - if (redirect) { - navigate("/login"); - } - }} - /> - ); -}; - -export default ChangePasswordDialog; diff --git a/FrontEnd/src/views/settings/index.css b/FrontEnd/src/views/settings/index.css deleted file mode 100644 index ccf7a97a..00000000 --- a/FrontEnd/src/views/settings/index.css +++ /dev/null @@ -1,31 +0,0 @@ -.change-avatar-cropper-row {
- max-height: 400px;
-}
-
-.change-avatar-img {
- min-width: 50%;
- max-width: 100%;
- max-height: 400px;
-}
-
-.settings-item {
- padding: 0.5em 1em;
- transition: background 0.3s;
- border-bottom: 1px solid #e9ecef;
- align-items: center;
-}
-.settings-item.first {
- border-top: 1px solid #e9ecef;
-}
-.settings-item.clickable {
- cursor: pointer;
-}
-.settings-item:hover {
- background: #dee2e6;
-}
-
-.register-code {
- border: 1px solid black;
- border-radius: 3px;
- padding: 0.2em;
-}
\ No newline at end of file diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx deleted file mode 100644 index 6647826f..00000000 --- a/FrontEnd/src/views/settings/index.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import classNames from "classnames"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; -import { useUser, userService } from "@/services/user"; -import { getHttpUserClient } from "@/http/user"; -import { TimelineVisibility } from "@/http/timeline"; - -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import Card from "../common/Card"; -import Spinner from "../common/Spinner"; -import ChangePasswordDialog from "./ChangePasswordDialog"; -import ChangeAvatarDialog from "./ChangeAvatarDialog"; -import ChangeNicknameDialog from "./ChangeNicknameDialog"; - -import "./index.css"; -import { pushAlert } from "@/services/alert"; - -interface SettingSectionProps { - title: I18nText; - children: React.ReactNode; -} - -const SettingSection: React.FC<SettingSectionProps> = ({ title, children }) => { - const { t } = useTranslation(); - - return ( - <Card className="my-3 py-3"> - <h3 className="px-3 mb-3 cru-color-primary"> - {convertI18nText(title, t)} - </h3> - {children} - </Card> - ); -}; - -interface SettingItemContainerWithoutChildrenProps { - title: I18nText; - subtext?: I18nText; - first?: boolean; - danger?: boolean; - style?: React.CSSProperties; - className?: string; - onClick?: () => void; -} - -interface SettingItemContainerProps - extends SettingItemContainerWithoutChildrenProps { - children?: React.ReactNode; -} - -function SettingItemContainer({ - title, - subtext, - first, - danger, - children, - style, - className, - onClick, -}: SettingItemContainerProps): JSX.Element { - const { t } = useTranslation(); - - return ( - <div - style={style} - className={classNames( - "row settings-item mx-0", - first && "first", - onClick && "clickable", - className, - )} - onClick={onClick} - > - <div className="px-0 col col-auto"> - <div className={classNames(danger && "cru-color-danger")}> - {convertI18nText(title, t)} - </div> - <small className="d-block cru-color-secondary"> - {convertI18nText(subtext, t)} - </small> - </div> - <div className="col col-auto">{children}</div> - </div> - ); -} - -type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps; - -const ButtonSettingItem: React.FC<ButtonSettingItemProps> = ({ ...props }) => { - return <SettingItemContainer {...props} />; -}; - -interface SelectSettingItemProps - extends SettingItemContainerWithoutChildrenProps { - options: { - value: string; - label: I18nText; - }[]; - value?: string; - onSelect: (value: string) => void; -} - -const SelectSettingsItem: React.FC<SelectSettingItemProps> = ({ - options, - value, - onSelect, - ...props -}) => { - const { t } = useTranslation(); - - return ( - <SettingItemContainer {...props}> - {value == null ? ( - <Spinner /> - ) : ( - <select - value={value} - onChange={(e) => { - onSelect(e.target.value); - }} - > - {options.map(({ value, label }) => ( - <option key={value} value={value}> - {convertI18nText(label, t)} - </option> - ))} - </select> - )} - </SettingItemContainer> - ); -}; - -const SettingsPage: React.FC = () => { - const { i18n } = useTranslation(); - const user = useUser(); - const navigate = useNavigate(); - - const [dialog, setDialog] = useState< - | null - | "changepassword" - | "changeavatar" - | "changenickname" - | "logout" - | "renewregistercode" - >(null); - - const [registerCode, setRegisterCode] = useState<undefined | null | string>( - undefined, - ); - - const [bookmarkVisibility, setBookmarkVisibility] = - useState<TimelineVisibility>(); - - React.useEffect(() => { - if (user != null) { - void getHttpUserClient() - .getBookmarkVisibility(user.username) - .then(({ visibility }) => { - setBookmarkVisibility(visibility); - }); - } else { - setBookmarkVisibility(undefined); - } - }, [user]); - - React.useEffect(() => { - setRegisterCode(undefined); - }, [user]); - - React.useEffect(() => { - if (user != null && registerCode === undefined) { - void getHttpUserClient() - .getRegisterCode(user.username) - .then((code) => { - setRegisterCode(code.registerCode ?? null); - }); - } - }, [user, registerCode]); - - const language = i18n.language.slice(0, 2); - - return ( - <> - <div className="container"> - {user ? ( - <SettingSection title="settings.subheaders.account"> - <SettingItemContainer - title="settings.myRegisterCode" - subtext="settings.myRegisterCodeDesc" - onClick={() => setDialog("renewregistercode")} - > - {registerCode === undefined ? ( - <Spinner /> - ) : registerCode === null ? ( - <span>Noop</span> - ) : ( - <code - className="register-code" - onClick={(event) => { - void navigator.clipboard - .writeText(registerCode) - .then(() => { - pushAlert({ - type: "success", - message: "settings.myRegisterCodeCopied", - }); - }); - event.stopPropagation(); - }} - > - {registerCode} - </code> - )} - </SettingItemContainer> - <ButtonSettingItem - title="settings.changeAvatar" - onClick={() => setDialog("changeavatar")} - first - /> - <ButtonSettingItem - title="settings.changeNickname" - onClick={() => setDialog("changenickname")} - /> - <SelectSettingsItem - title="settings.changeBookmarkVisibility" - options={[ - { - value: "Private", - label: "visibility.private", - }, - { - value: "Register", - label: "visibility.register", - }, - { - value: "Public", - label: "visibility.public", - }, - ]} - value={bookmarkVisibility} - onSelect={(value) => { - void getHttpUserClient() - .putBookmarkVisibility(user.username, { - visibility: value as TimelineVisibility, - }) - .then(() => { - setBookmarkVisibility(value as TimelineVisibility); - }); - }} - /> - <ButtonSettingItem - title="settings.changePassword" - onClick={() => setDialog("changepassword")} - danger - /> - <ButtonSettingItem - title="settings.logout" - onClick={() => { - setDialog("logout"); - }} - danger - /> - </SettingSection> - ) : null} - <SettingSection title="settings.subheaders.customization"> - <SelectSettingsItem - title="settings.languagePrimary" - subtext="settings.languageSecondary" - options={[ - { - value: "zh", - label: { - type: "custom", - value: "中文", - }, - }, - { - value: "en", - label: { - type: "custom", - value: "English", - }, - }, - ]} - value={language} - onSelect={(value) => { - void i18n.changeLanguage(value); - }} - first - /> - </SettingSection> - </div> - <ChangePasswordDialog - open={dialog === "changepassword"} - close={() => setDialog(null)} - /> - <ConfirmDialog - title="settings.dialogConfirmLogout.title" - body="settings.dialogConfirmLogout.prompt" - onClose={() => setDialog(null)} - open={dialog === "logout"} - onConfirm={() => { - void userService.logout().then(() => { - navigate("/"); - }); - }} - /> - <ConfirmDialog - title="settings.renewRegisterCode" - body="settings.renewRegisterCodeDesc" - onClose={() => setDialog(null)} - open={dialog === "renewregistercode"} - onConfirm={() => { - if (user == null) throw new UiLogicError(); - void getHttpUserClient() - .renewRegisterCode(user.username) - .then(() => { - setRegisterCode(undefined); - }); - }} - /> - <ChangeAvatarDialog - open={dialog === "changeavatar"} - close={() => setDialog(null)} - /> - <ChangeNicknameDialog - open={dialog === "changenickname"} - close={() => setDialog(null)} - /> - </> - ); -}; - -export default SettingsPage; diff --git a/FrontEnd/src/views/timeline/CollapseButton.tsx b/FrontEnd/src/views/timeline/CollapseButton.tsx deleted file mode 100644 index 374ccc2e..00000000 --- a/FrontEnd/src/views/timeline/CollapseButton.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; - -import IconButton from "../common/button/IconButton"; - -const CollapseButton: React.FC<{ - collapse: boolean; - onClick: () => void; - className?: string; - style?: React.CSSProperties; -}> = ({ collapse, onClick, className, style }) => { - return ( - <IconButton - icon={collapse ? "arrows-angle-expand" : "arrows-angle-contract"} - onClick={onClick} - className={className} - style={style} - /> - ); -}; - -export default CollapseButton; diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.css b/FrontEnd/src/views/timeline/MarkdownPostEdit.css deleted file mode 100644 index e36be992..00000000 --- a/FrontEnd/src/views/timeline/MarkdownPostEdit.css +++ /dev/null @@ -1,21 +0,0 @@ -.timeline-markdown-post-edit-page {
- overflow: auto;
- max-height: 300px;
-}
-
-.timeline-markdown-post-edit-image-container {
- position: relative;
- text-align: center;
- margin-bottom: 1em;
-}
-
-.timeline-markdown-post-edit-image {
- max-width: 100%;
- max-height: 200px;
-}
-
-.timeline-markdown-post-edit-image-delete-button {
- position: absolute;
- right: 10px;
- top: 2px;
-}
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx deleted file mode 100644 index 6401cfaa..00000000 --- a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import { useTranslation } from "react-i18next"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import TimelinePostBuilder from "@/services/TimelinePostBuilder"; - -import FlatButton from "../common/button/FlatButton"; -import TabPages from "../common/tab/TabPages"; -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import Spinner from "../common/Spinner"; -import IconButton from "../common/button/IconButton"; - -import "./MarkdownPostEdit.css"; - -export interface MarkdownPostEditProps { - owner: string; - timeline: string; - onPosted: (post: HttpTimelinePostInfo) => void; - onPostError: () => void; - onClose: () => void; - className?: string; - style?: React.CSSProperties; -} - -const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ - owner: ownerUsername, - timeline: timelineName, - onPosted, - onClose, - onPostError, - className, - style, -}) => { - const { t } = useTranslation(); - - const [canLeave, setCanLeave] = React.useState<boolean>(true); - - const [process, setProcess] = React.useState<boolean>(false); - - const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] = - React.useState<boolean>(false); - - const [text, _setText] = React.useState<string>(""); - const [images, _setImages] = React.useState<{ file: File; url: string }[]>( - [] - ); - const [previewHtml, _setPreviewHtml] = React.useState<string>(""); - - const _builder = React.useRef<TimelinePostBuilder | null>(null); - - const getBuilder = (): TimelinePostBuilder => { - if (_builder.current == null) { - const builder = new TimelinePostBuilder(() => { - setCanLeave(builder.isEmpty); - _setText(builder.text); - _setImages(builder.images); - _setPreviewHtml(builder.renderHtml()); - }); - _builder.current = builder; - } - return _builder.current; - }; - - const canSend = text.length > 0; - - React.useEffect(() => { - return () => { - getBuilder().dispose(); - }; - }, []); - - React.useEffect(() => { - window.onbeforeunload = (): unknown => { - if (!canLeave) { - return t("timeline.confirmLeave"); - } - }; - - return () => { - window.onbeforeunload = null; - }; - }, [canLeave, t]); - - const send = async (): Promise<void> => { - setProcess(true); - try { - const dataList = await getBuilder().build(); - const post = await getHttpTimelineClient().postPost( - ownerUsername, - timelineName, - { - dataList, - } - ); - onPosted(post); - onClose(); - } catch (e) { - setProcess(false); - onPostError(); - } - }; - - return ( - <> - <TabPages - className={className} - style={style} - pageContainerClassName="py-2" - dense - actions={ - process ? ( - <Spinner /> - ) : ( - <div> - <IconButton - icon="x" - color="danger" - large - className="cru-align-middle me-2" - onClick={() => { - if (canLeave) { - onClose(); - } else { - setShowLeaveConfirmDialog(true); - } - }} - /> - {canSend && ( - <FlatButton text="timeline.send" onClick={() => void send()} /> - )} - </div> - ) - } - pages={[ - { - name: "text", - text: "edit", - page: ( - <textarea - value={text} - disabled={process} - className="cru-fill-parent" - onChange={(event) => { - getBuilder().setMarkdownText(event.currentTarget.value); - }} - /> - ), - }, - { - name: "images", - text: "image", - page: ( - <div className="timeline-markdown-post-edit-page"> - {images.map((image, index) => ( - <div - key={image.url} - className="timeline-markdown-post-edit-image-container" - > - <img - src={image.url} - className="timeline-markdown-post-edit-image" - /> - <IconButton - icon="trash" - color="danger" - className={classnames( - "timeline-markdown-post-edit-image-delete-button", - process && "d-none" - )} - onClick={() => { - getBuilder().deleteImage(index); - }} - /> - </div> - ))} - <input - type="file" - accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" - onChange={(event: React.ChangeEvent<HTMLInputElement>) => { - const { files } = event.currentTarget; - if (files != null && files.length !== 0) { - getBuilder().appendImage(files[0]); - } - }} - disabled={process} - /> - </div> - ), - }, - { - name: "preview", - text: "preview", - page: ( - <div - className="markdown-container timeline-markdown-post-edit-page" - dangerouslySetInnerHTML={{ __html: previewHtml }} - /> - ), - }, - ]} - /> - <ConfirmDialog - onClose={() => setShowLeaveConfirmDialog(false)} - onConfirm={onClose} - open={showLeaveConfirmDialog} - title="timeline.dropDraft" - body="timeline.confirmLeave" - /> - </> - ); -}; - -export default MarkdownPostEdit; diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx deleted file mode 100644 index fc55185c..00000000 --- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from "react"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -function PostPropertyChangeDialog(props: { - open: boolean; - onClose: () => void; - post: HttpTimelinePostInfo; - onSuccess: (post: HttpTimelinePostInfo) => void; -}): React.ReactElement | null { - const { open, onClose, post, onSuccess } = props; - - return ( - <OperationDialog - title="timeline.changePostPropertyDialog.title" - onClose={onClose} - open={open} - inputScheme={[ - { - label: "timeline.changePostPropertyDialog.time", - type: "datetime", - initValue: post.time, - }, - ]} - onProcess={([time]) => { - return getHttpTimelineClient().patchPost( - post.timelineOwnerV2, - post.timelineNameV2, - post.id, - { - time: time === "" ? undefined : new Date(time).toISOString(), - } - ); - }} - onSuccessAndClose={onSuccess} - /> - ); -} - -export default PostPropertyChangeDialog; diff --git a/FrontEnd/src/views/timeline/Timeline.css b/FrontEnd/src/views/timeline/Timeline.css deleted file mode 100644 index 4dd4fdcc..00000000 --- a/FrontEnd/src/views/timeline/Timeline.css +++ /dev/null @@ -1,244 +0,0 @@ -.timeline { - z-index: 0; - position: relative; - width: 100%; -} - -@keyframes timeline-line-node { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); - } -} - -@keyframes timeline-line-node-current { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color); - } -} - -@keyframes timeline-line-node-loading { - to { - box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); - } -} - -@keyframes timeline-line-node-loading-edge { - from { - transform: rotate(0turn); - } - to { - transform: rotate(1turn); - } -} - -@keyframes timeline-top-loading-enter { - from { - transform: translate(0, -100%); - } -} - -@keyframes timeline-post-enter { - from { - transform: translate(0, 100%); - opacity: 0; - } - to { - opacity: 1; - } -} - -.timeline-top-loading-enter { - animation: 1s timeline-top-loading-enter; -} - -.timeline-line { - display: flex; - flex-direction: column; - align-items: center; - width: 30px; - position: absolute; - z-index: 1; - left: 2em; - top: 0; - bottom: 0; - transition: left 0.5s; -} - -@media (max-width: 575.98px) { - .timeline-line { - left: 1em; - } -} - -.timeline-line .segment { - width: 7px; - background: var(--cru-primary-color); -} -.timeline-line .segment.start { - height: 1.8em; - flex: 0 0 auto; -} -.timeline-line .segment.end { - flex: 1 1 auto; -} -.timeline-line .segment.current-end { - height: 2em; - flex: 0 0 auto; - background: linear-gradient(var(--cru-primary-enhance-color), white); -} -.timeline-line .node-container { - flex: 0 0 auto; - position: relative; - width: 18px; - height: 18px; -} -.timeline-line .node { - width: 20px; - height: 20px; - position: absolute; - background: var(--cru-primary-color); - left: -1px; - top: -1px; - border-radius: 50%; - box-sizing: border-box; - z-index: 1; - animation: 1s infinite alternate; - animation-name: timeline-line-node; -} -.timeline-line .node-loading-edge { - color: var(--cru-primary-color); - width: 38px; - height: 38px; - position: absolute; - left: -10px; - top: -10px; - box-sizing: border-box; - z-index: 2; - animation: 1.5s linear infinite timeline-line-node-loading-edge; -} -.timeline-line.current .segment.start { - background: linear-gradient( - var(--cru-primary-color), - var(--cru-primary-enhance-color) - ); -} - -.timeline-line.current .segment.end { - background: var(--cru-primary-enhance-color); -} - -.timeline-line.current .node { - background: var(--cru-primary-enhance-color); - animation-name: timeline-line-node-current; -} - -.timeline-line.loading .node { - background: var(--cru-primary-color); - animation-name: timeline-line-node-loading; -} - -.timeline-item { - position: relative; - padding: 0.5em; -} - -.timeline-item-card { - position: relative; - padding: 0.5em 0.5em 0.5em 4em; -} - -.timeline-item-card.enter-animation { - animation: 0.6s forwards; - opacity: 0; -} - -@media (max-width: 575.98px) { - .timeline-item-card { - padding-left: 3em; - } -} - -.timeline-item-header { - display: flex; - align-items: center; -} - -.timeline-avatar { - border-radius: 50%; - width: 2em; - height: 2em; -} - -.timeline-item-delete-button { - position: absolute; - right: 0; - bottom: 0; -} - -.timeline-content { - white-space: pre-line; -} - -.timeline-content-image { - max-width: 80%; - max-height: 200px; -} - -.timeline-date-item { - position: relative; - padding: 0.3em 0 0.3em 4em; -} - -.timeline-date-item-badge { - display: inline-block; - padding: 0.1em 0.4em; - border-radius: 0.4em; - background: #7c7c7c; - color: white; - font-size: 0.8em; -} - -.timeline-post-item-options-mask { - background: rgba(255, 255, 255, 0.85); - z-index: 100; - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - - display: flex; - justify-content: space-around; - align-items: center; - - border-radius: var(--cru-card-border-radius); -} - -.timeline-sync-state-badge { - font-size: 0.8em; - padding: 3px 8px; - border-radius: 5px; - background: #e8fbff; -} - -.timeline-sync-state-badge-pin { - display: inline-block; - width: 0.4em; - height: 0.4em; - border-radius: 50%; - vertical-align: middle; - margin-right: 0.6em; -} - -.timeline-card { - position: fixed; - z-index: 1029; - top: 56px; - right: 0; - margin: 0.5em; -} - -.timeline-top { - position: sticky; - top: 56px; -} diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx deleted file mode 100644 index fdf7f0a0..00000000 --- a/FrontEnd/src/views/timeline/TimelineCard.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import classnames from "classnames"; -import { HubConnectionState } from "@microsoft/signalr"; - -import { useIsSmallScreen } from "@/utilities/hooks"; -import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; -import { useUser } from "@/services/user"; -import { pushAlert } from "@/services/alert"; -import { HttpTimelineInfo } from "@/http/timeline"; -import { getHttpBookmarkClient } from "@/http/bookmark"; - -import UserAvatar from "../common/user/UserAvatar"; -import PopupMenu from "../common/menu/PopupMenu"; -import FullPageDialog from "../common/dialog/FullPageDialog"; -import Card from "../common/Card"; -import TimelineDeleteDialog from "./TimelineDeleteDialog"; -import ConnectionStatusBadge from "./ConnectionStatusBadge"; -import CollapseButton from "./CollapseButton"; -import { TimelineMemberDialog } from "./TimelineMember"; -import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; -import IconButton from "../common/button/IconButton"; - -export interface TimelinePageCardProps { - timeline: HttpTimelineInfo; - connectionStatus: HubConnectionState; - className?: string; - onReload: () => void; -} - -const TimelineCard: React.FC<TimelinePageCardProps> = (props) => { - const { timeline, connectionStatus, onReload, className } = props; - - const { t } = useTranslation(); - - const [dialog, setDialog] = React.useState< - "member" | "property" | "delete" | null - >(null); - - const [collapse, setCollapse] = React.useState(true); - const toggleCollapse = (): void => { - setCollapse((o) => !o); - }; - - const isSmallScreen = useIsSmallScreen(); - - const user = useUser(); - - const content = ( - <> - <h3 className="cru-color-primary d-inline-block align-middle"> - {timeline.title} - <small className="ms-3 cru-color-secondary">{timeline.nameV2}</small> - </h3> - <div> - <UserAvatar - username={timeline.owner.username} - className="cru-avatar small cru-round me-3" - /> - {timeline.owner.nickname} - <small className="ms-3 cru-color-secondary"> - @{timeline.owner.username} - </small> - </div> - <p className="mb-0">{timeline.description}</p> - <small className="mt-1 d-block"> - {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} - </small> - <div className="mt-2 cru-text-end"> - {user != null ? ( - <IconButton - icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"} - className="me-3" - onClick={() => { - getHttpBookmarkClient() - [timeline.isBookmark ? "delete" : "post"]( - user.username, - timeline.owner.username, - timeline.nameV2 - ) - .then(onReload, () => { - pushAlert({ - message: timeline.isBookmark - ? "timeline.removeBookmarkFail" - : "timeline.addBookmarkFail", - type: "danger", - }); - }); - }} - /> - ) : null} - <IconButton - icon="people" - className="me-3" - onClick={() => setDialog("member")} - /> - {timeline.manageable ? ( - <PopupMenu - items={[ - { - type: "button", - text: "timeline.manageItem.property", - onClick: () => setDialog("property"), - }, - { type: "divider" }, - { - type: "button", - onClick: () => setDialog("delete"), - color: "danger", - text: "timeline.manageItem.delete", - }, - ]} - containerClassName="d-inline" - > - <IconButton icon="three-dots-vertical" /> - </PopupMenu> - ) : null} - </div> - </> - ); - - return ( - <> - <Card className={classnames("p-2 cru-clearfix", className)}> - <div - className={classnames( - "cru-float-right d-flex align-items-center", - !collapse && "ms-3" - )} - > - <ConnectionStatusBadge status={connectionStatus} className="me-2" /> - <CollapseButton collapse={collapse} onClick={toggleCollapse} /> - </div> - {isSmallScreen ? ( - <FullPageDialog - onBack={toggleCollapse} - show={!collapse} - contentContainerClassName="p-2" - > - {content} - </FullPageDialog> - ) : ( - <div style={{ display: collapse ? "none" : "inline" }}>{content}</div> - )} - </Card> - <TimelineMemberDialog - timeline={timeline} - onClose={() => setDialog(null)} - open={dialog === "member"} - onChange={onReload} - /> - <TimelinePropertyChangeDialog - timeline={timeline} - close={() => setDialog(null)} - open={dialog === "property"} - onChange={onReload} - /> - <TimelineDeleteDialog - timeline={timeline} - open={dialog === "delete"} - close={() => setDialog(null)} - /> - </> - ); -}; - -export default TimelineCard; diff --git a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline/TimelineDateLabel.tsx deleted file mode 100644 index 5f4ac706..00000000 --- a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react"; -import TimelineLine from "./TimelineLine"; - -export interface TimelineDateItemProps { - date: Date; -} - -const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => { - return ( - <div className="timeline-date-item"> - <TimelineLine center="none" /> - <div className="timeline-date-item-badge"> - {date.toLocaleDateString()} - </div> - </div> - ); -}; - -export default TimelineDateLabel; diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx deleted file mode 100644 index c960b3c2..00000000 --- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import * as React from "react"; -import { useNavigate } from "react-router-dom"; -import { Trans } from "react-i18next"; - -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -interface TimelineDeleteDialog { - timeline: HttpTimelineInfo; - open: boolean; - close: () => void; -} - -const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { - const navigate = useNavigate(); - - const { timeline } = props; - - return ( - <OperationDialog - open={props.open} - onClose={props.close} - title="timeline.deleteDialog.title" - themeColor="danger" - inputPrompt={() => { - return ( - <Trans - i18nKey="timeline.deleteDialog.inputPrompt" - values={{ name: timeline.nameV2 }} - > - 0<code className="mx-2">1</code>2 - </Trans> - ); - }} - inputScheme={[ - { - type: "text", - }, - ]} - inputValidator={([value]) => { - if (value !== timeline.nameV2) { - return { 0: "timeline.deleteDialog.notMatch" }; - } else { - return null; - } - }} - onProcess={() => { - return getHttpTimelineClient().deleteTimeline( - timeline.owner.username, - timeline.nameV2 - ); - }} - onSuccessAndClose={() => { - navigate("/", { replace: true }); - }} - /> - ); -}; - -export default TimelineDeleteDialog; diff --git a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx deleted file mode 100644 index 5e0728d4..00000000 --- a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import TimelineLine, { TimelineLineProps } from "./TimelineLine"; - -export interface TimelineEmptyItemProps extends Partial<TimelineLineProps> { - height?: number | string; - className?: string; - style?: React.CSSProperties; -} - -const TimelineEmptyItem: React.FC<TimelineEmptyItemProps> = (props) => { - const { height, style, className, center, ...lineProps } = props; - - return ( - <div - style={{ ...style, height: height }} - className={classnames("timeline-item", className)} - > - <TimelineLine center={center ?? "none"} {...lineProps} /> - </div> - ); -}; - -export default TimelineEmptyItem; diff --git a/FrontEnd/src/views/timeline/TimelineLine.tsx b/FrontEnd/src/views/timeline/TimelineLine.tsx deleted file mode 100644 index 4a87e6e0..00000000 --- a/FrontEnd/src/views/timeline/TimelineLine.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -export interface TimelineLineProps { - current?: boolean; - startSegmentLength?: string | number; - center: "node" | "loading" | "none"; - className?: string; - style?: React.CSSProperties; -} - -const TimelineLine: React.FC<TimelineLineProps> = ({ - startSegmentLength, - center, - current, - className, - style, -}) => { - return ( - <div - className={classnames( - "timeline-line", - current && "current", - center === "loading" && "loading", - className - )} - style={style} - > - <div className="segment start" style={{ height: startSegmentLength }} /> - {center !== "none" ? ( - <div className="node-container"> - <div className="node"></div> - {center === "loading" ? ( - <svg className="node-loading-edge" viewBox="0 0 100 100"> - <path - d="M 50,10 A 40 40 45 0 1 78.28,21.72" - stroke="currentcolor" - strokeLinecap="square" - strokeWidth="8" - /> - </svg> - ) : null} - </div> - ) : null} - {center !== "loading" ? <div className="segment end"></div> : null} - {current && <div className="segment current-end" />} - </div> - ); -}; - -export default TimelineLine; diff --git a/FrontEnd/src/views/timeline/TimelineLoading.tsx b/FrontEnd/src/views/timeline/TimelineLoading.tsx deleted file mode 100644 index f876cba9..00000000 --- a/FrontEnd/src/views/timeline/TimelineLoading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from "react"; - -import TimelineEmptyItem from "./TimelineEmptyItem"; - -const TimelineLoading: React.FC = () => { - return ( - <TimelineEmptyItem - className="timeline-top-loading-enter" - height={100} - center="loading" - startSegmentLength={56} - /> - ); -}; - -export default TimelineLoading; diff --git a/FrontEnd/src/views/timeline/TimelineMember.css b/FrontEnd/src/views/timeline/TimelineMember.css deleted file mode 100644 index adb78764..00000000 --- a/FrontEnd/src/views/timeline/TimelineMember.css +++ /dev/null @@ -1,8 +0,0 @@ -.timeline-member-item {
- border: var(--cru-background-1-color) solid;
- border-width: 0.5px 1px;
-}
-
-.timeline-member-item > div {
- padding: 0.5em;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline/TimelinePostContentView.tsx deleted file mode 100644 index 9ed192e5..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; -import { marked } from "marked"; - -import { UiLogicError } from "@/common"; - -import { HttpNetworkError } from "@/http/common"; -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import { useUser } from "@/services/user"; - -import Skeleton from "../common/Skeleton"; -import LoadFailReload from "../common/LoadFailReload"; - -const TextView: React.FC<TimelinePostContentViewProps> = (props) => { - const { post, className, style } = props; - - const [text, setText] = React.useState<string | null>(null); - const [error, setError] = React.useState<"offline" | "error" | null>(null); - - const [reloadKey, setReloadKey] = React.useState<number>(0); - - React.useEffect(() => { - let subscribe = true; - - setText(null); - setError(null); - - void getHttpTimelineClient() - .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id) - .then( - (data) => { - if (subscribe) setText(data); - }, - (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setError("offline"); - } else { - setError("error"); - } - } - } - ); - - return () => { - subscribe = false; - }; - }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]); - - if (error != null) { - return ( - <LoadFailReload - className={className} - style={style} - onReload={() => setReloadKey(reloadKey + 1)} - /> - ); - } else if (text == null) { - return <Skeleton />; - } else { - return ( - <div className={className} style={style}> - {text} - </div> - ); - } -}; - -const ImageView: React.FC<TimelinePostContentViewProps> = (props) => { - const { post, className, style } = props; - - useUser(); - - return ( - <img - src={getHttpTimelineClient().generatePostDataUrl( - post.timelineOwnerV2, - post.timelineNameV2, - post.id - )} - className={classnames(className, "timeline-content-image")} - style={style} - /> - ); -}; - -const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => { - const { post, className, style } = props; - - const [markdown, setMarkdown] = React.useState<string | null>(null); - const [error, setError] = React.useState<"offline" | "error" | null>(null); - - const [reloadKey, setReloadKey] = React.useState<number>(0); - - React.useEffect(() => { - let subscribe = true; - - setMarkdown(null); - setError(null); - - void getHttpTimelineClient() - .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id) - .then( - (data) => { - if (subscribe) setMarkdown(data); - }, - (error) => { - if (subscribe) { - if (error instanceof HttpNetworkError) { - setError("offline"); - } else { - setError("error"); - } - } - } - ); - - return () => { - subscribe = false; - }; - }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]); - - const markdownHtml = React.useMemo<string | null>(() => { - if (markdown == null) return null; - return marked.parse(markdown); - }, [markdown]); - - if (error != null) { - return ( - <LoadFailReload - className={className} - style={style} - onReload={() => setReloadKey(reloadKey + 1)} - /> - ); - } else if (markdown == null) { - return <Skeleton />; - } else { - if (markdownHtml == null) { - throw new UiLogicError("Markdown is not null but markdown html is."); - } - return ( - <div - className={classnames(className, "markdown-container")} - style={style} - dangerouslySetInnerHTML={{ - __html: markdownHtml, - }} - /> - ); - } -}; - -export interface TimelinePostContentViewProps { - post: HttpTimelinePostInfo; - className?: string; - style?: React.CSSProperties; -} - -const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = { - "text/plain": TextView, - "text/markdown": MarkdownView, - "image/png": ImageView, - "image/jpeg": ImageView, - "image/gif": ImageView, - "image/webp": ImageView, -}; - -const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = ( - props -) => { - const { post, className, style } = props; - - const type = post.dataList[0].kind; - - if (type in viewMap) { - const View = viewMap[type]; - return <View post={post} className={className} style={style} />; - } else { - // TODO: i18n - console.error("Unknown post type", post); - return <div>Error, unknown post type!</div>; - } -}; - -export default TimelinePostContentView; diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.css b/FrontEnd/src/views/timeline/TimelinePostEdit.css deleted file mode 100644 index 9b7629e2..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEdit.css +++ /dev/null @@ -1,10 +0,0 @@ -.timeline-post-edit {
- position: sticky !important;
- top: 106px;
- z-index: 100;
-}
-
-.timeline-post-edit-image {
- max-width: 100px;
- max-height: 100px;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline/TimelinePostEdit.tsx deleted file mode 100644 index 38e72264..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; - -import { UiLogicError } from "@/common"; - -import { - getHttpTimelineClient, - HttpTimelineInfo, - HttpTimelinePostInfo, - HttpTimelinePostPostRequestData, -} from "@/http/timeline"; - -import { pushAlert } from "@/services/alert"; - -import base64 from "@/utilities/base64"; - -import BlobImage from "../common/BlobImage"; -import LoadingButton from "../common/button/LoadingButton"; -import PopupMenu from "../common/menu/PopupMenu"; -import MarkdownPostEdit from "./MarkdownPostEdit"; -import TimelinePostEditCard from "./TimelinePostEditCard"; -import IconButton from "../common/button/IconButton"; - -import "./TimelinePostEdit.css"; - -interface TimelinePostEditTextProps { - text: string; - disabled: boolean; - onChange: (text: string) => void; - className?: string; - style?: React.CSSProperties; -} - -const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => { - const { text, disabled, onChange, className, style } = props; - - return ( - <textarea - value={text} - disabled={disabled} - onChange={(event) => { - onChange(event.target.value); - }} - className={className} - style={style} - /> - ); -}; - -interface TimelinePostEditImageProps { - onSelect: (file: File | null) => void; - disabled: boolean; -} - -const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => { - const { onSelect, disabled } = props; - - const { t } = useTranslation(); - - const [file, setFile] = React.useState<File | null>(null); - const [error, setError] = React.useState<boolean>(false); - - const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { - setError(false); - const files = e.target.files; - if (files == null || files.length === 0) { - setFile(null); - onSelect(null); - } else { - setFile(files[0]); - } - }; - - React.useEffect(() => { - return () => { - onSelect(null); - }; - }, [onSelect]); - - return ( - <> - <input - type="file" - onChange={onInputChange} - accept="image/*" - disabled={disabled} - className="mx-3 my-1" - /> - {file != null && !error && ( - <BlobImage - blob={file} - className="timeline-post-edit-image" - onLoad={() => onSelect(file)} - onError={() => { - onSelect(null); - setError(true); - }} - /> - )} - {error ? <div className="text-danger">{t("loadImageError")}</div> : null} - </> - ); -}; - -type PostKind = "text" | "markdown" | "image"; - -const postKindIconMap: Record<PostKind, string> = { - text: "fonts", - markdown: "markdown", - image: "image", -}; - -export interface TimelinePostEditProps { - className?: string; - style?: React.CSSProperties; - timeline: HttpTimelineInfo; - onPosted: (newPost: HttpTimelinePostInfo) => void; -} - -const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { - const { timeline, style, className, onPosted } = props; - - const { t } = useTranslation(); - - const [process, setProcess] = React.useState<boolean>(false); - - const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text"); - const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false); - - const [text, setText] = React.useState<string>(""); - const [image, setImage] = React.useState<File | null>(null); - - const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`; - - React.useEffect(() => { - setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? ""); - }, [draftTextLocalStorageKey]); - - const canSend = - (kind === "text" && text.length !== 0) || - (kind === "image" && image != null); - - const onPostError = (): void => { - pushAlert({ - type: "danger", - message: "timeline.sendPostFailed", - }); - }; - - const onSend = async (): Promise<void> => { - setProcess(true); - - let requestData: HttpTimelinePostPostRequestData; - switch (kind) { - case "text": - requestData = { - contentType: "text/plain", - data: await base64(text), - }; - break; - case "image": - if (image == null) { - throw new UiLogicError( - "Content type is image but image blob is null.", - ); - } - requestData = { - contentType: image.type, - data: await base64(image), - }; - break; - default: - throw new UiLogicError("Unknown content type."); - } - - getHttpTimelineClient() - .postPost(timeline.owner.username, timeline.nameV2, { - dataList: [requestData], - }) - .then( - (data) => { - if (kind === "text") { - setText(""); - window.localStorage.removeItem(draftTextLocalStorageKey); - } - setProcess(false); - setKind("text"); - onPosted(data); - }, - () => { - setProcess(false); - onPostError(); - }, - ); - }; - - return ( - <TimelinePostEditCard className={className} style={style}> - {showMarkdown ? ( - <MarkdownPostEdit - className="cru-fill-parent" - onClose={() => setShowMarkdown(false)} - owner={timeline.owner.username} - timeline={timeline.nameV2} - onPosted={onPosted} - onPostError={onPostError} - /> - ) : ( - <div className="row"> - <div className="col px-1 py-1"> - {(() => { - if (kind === "text") { - return ( - <TimelinePostEditText - className="cru-fill-parent timeline-post-edit" - text={text} - disabled={process} - onChange={(t) => { - setText(t); - window.localStorage.setItem(draftTextLocalStorageKey, t); - }} - /> - ); - } else if (kind === "image") { - return ( - <TimelinePostEditImage - onSelect={setImage} - disabled={process} - /> - ); - } - })()} - </div> - <div className="col col-auto align-self-end m-1"> - <div className="d-block cru-text-center mt-1 mb-2"> - <PopupMenu - items={(["text", "image", "markdown"] as const).map((kind) => ({ - type: "button", - text: `timeline.post.type.${kind}`, - iconClassName: postKindIconMap[kind], - onClick: () => { - if (kind === "markdown") { - setShowMarkdown(true); - } else { - setKind(kind); - } - }, - }))} - > - <IconButton large icon={postKindIconMap[kind]} /> - </PopupMenu> - </div> - <LoadingButton - onClick={() => void onSend()} - disabled={!canSend} - loading={process} - > - {t("timeline.send")} - </LoadingButton> - </div> - </div> - )} - </TimelinePostEditCard> - ); -}; - -export default TimelinePostEdit; diff --git a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx deleted file mode 100644 index d2f7bd72..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import Card from "../common/Card"; -import TimelineLine from "./TimelineLine"; - -import "./TimelinePostEdit.css"; - -export interface TimelinePostEditCardProps { - className?: string; - style?: React.CSSProperties; - children?: React.ReactNode; -} - -const TimelinePostEdit: React.FC<TimelinePostEditCardProps> = ({ - className, - style, - children, -}) => { - return ( - <div - className={classnames("timeline-item timeline-post-edit", className)} - style={style} - > - <TimelineLine center="node" /> - <Card className="timeline-item-card">{children}</Card> - </div> - ); -}; - -export default TimelinePostEdit; diff --git a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx deleted file mode 100644 index 1ef0a287..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from "react"; -import { Trans } from "react-i18next"; -import { Link } from "react-router-dom"; - -import TimelinePostEditCard from "./TimelinePostEditCard"; - -export default function TimelinePostEditNoLogin(): React.ReactElement | null { - return ( - <TimelinePostEditCard> - <div className="mt-3 mb-4"> - <Trans - i18nKey="timeline.postNoLogin" - components={{ l: <Link to="/login" /> }} - /> - </div> - </TimelinePostEditCard> - ); -} diff --git a/FrontEnd/src/views/timeline/TimelinePostView.tsx b/FrontEnd/src/views/timeline/TimelinePostView.tsx deleted file mode 100644 index e3eac0f4..00000000 --- a/FrontEnd/src/views/timeline/TimelinePostView.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import * as React from "react"; -import classnames from "classnames"; - -import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; - -import { pushAlert } from "@/services/alert"; - -import { useClickOutside } from "@/utilities/hooks"; - -import UserAvatar from "../common/user/UserAvatar"; -import Card from "../common/Card"; -import FlatButton from "../common/button/FlatButton"; -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import TimelineLine from "./TimelineLine"; -import TimelinePostContentView from "./TimelinePostContentView"; -import PostPropertyChangeDialog from "./PostPropertyChangeDialog"; -import IconButton from "../common/button/IconButton"; - -export interface TimelinePostViewProps { - post: HttpTimelinePostInfo; - className?: string; - style?: React.CSSProperties; - cardStyle?: React.CSSProperties; - onChanged: (post: HttpTimelinePostInfo) => void; - onDeleted: () => void; -} - -const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => { - const { post, className, style, cardStyle, onChanged, onDeleted } = props; - - const [operationMaskVisible, setOperationMaskVisible] = - React.useState<boolean>(false); - const [dialog, setDialog] = React.useState< - "delete" | "changeproperty" | null - >(null); - - const [maskElement, setMaskElement] = React.useState<HTMLElement | null>( - null - ); - - useClickOutside(maskElement, () => setOperationMaskVisible(false)); - - const cardRef = React.useRef<HTMLDivElement>(null); - React.useEffect(() => { - const cardIntersectionObserver = new IntersectionObserver(([e]) => { - if (e.intersectionRatio > 0) { - if (cardRef.current != null) { - cardRef.current.style.animationName = "timeline-post-enter"; - } - } - }); - if (cardRef.current) { - cardIntersectionObserver.observe(cardRef.current); - } - - return () => { - cardIntersectionObserver.disconnect(); - }; - }, []); - - return ( - <div - id={`timeline-post-${post.id}`} - className={classnames("timeline-item", className)} - style={style} - > - <TimelineLine center="node" /> - <Card - ref={cardRef} - className="timeline-item-card enter-animation" - style={cardStyle} - > - {post.editable ? ( - <IconButton - icon="chevron-down" - color="primary-enhance" - className="cru-float-right" - onClick={(e) => { - setOperationMaskVisible(true); - e.stopPropagation(); - }} - /> - ) : null} - <div className="timeline-item-header"> - <span className="me-2"> - <span> - <UserAvatar - username={post.author.username} - className="timeline-avatar me-1" - /> - <small className="text-dark me-2">{post.author.nickname}</small> - <small className="text-secondary white-space-no-wrap"> - {new Date(post.time).toLocaleTimeString()} - </small> - </span> - </span> - </div> - <div className="timeline-content"> - <TimelinePostContentView post={post} /> - </div> - {operationMaskVisible ? ( - <div - ref={setMaskElement} - className="timeline-post-item-options-mask" - onClick={() => { - setOperationMaskVisible(false); - }} - > - <FlatButton - text="changeProperty" - onClick={(e) => { - setDialog("changeproperty"); - e.stopPropagation(); - }} - /> - <FlatButton - text="delete" - color="danger" - onClick={(e) => { - setDialog("delete"); - e.stopPropagation(); - }} - /> - </div> - ) : null} - </Card> - <ConfirmDialog - title="timeline.post.deleteDialog.title" - body="timeline.post.deleteDialog.prompt" - open={dialog === "delete"} - onClose={() => { - setDialog(null); - setOperationMaskVisible(false); - }} - onConfirm={() => { - void getHttpTimelineClient() - .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id) - .then(onDeleted, () => { - pushAlert({ - type: "danger", - message: "timeline.deletePostFailed", - }); - }); - }} - /> - <PostPropertyChangeDialog - open={dialog === "changeproperty"} - onClose={() => { - setDialog(null); - setOperationMaskVisible(false); - }} - post={post} - onSuccess={onChanged} - /> - </div> - ); -}; - -export default TimelinePostView; diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx deleted file mode 100644 index 63750445..00000000 --- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import * as React from "react"; - -import { - getHttpTimelineClient, - HttpTimelineInfo, - HttpTimelinePatchRequest, - kTimelineVisibilities, - TimelineVisibility, -} from "@/http/timeline"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -export interface TimelinePropertyChangeDialogProps { - open: boolean; - close: () => void; - timeline: HttpTimelineInfo; - onChange: () => void; -} - -const labelMap: { [key in TimelineVisibility]: string } = { - Private: "timeline.visibility.private", - Public: "timeline.visibility.public", - Register: "timeline.visibility.register", -}; - -const TimelinePropertyChangeDialog: React.FC< - TimelinePropertyChangeDialogProps -> = (props) => { - const { timeline, onChange } = props; - - return ( - <OperationDialog - title={"timeline.dialogChangeProperty.title"} - inputScheme={ - [ - { - type: "text", - label: "timeline.dialogChangeProperty.titleField", - initValue: timeline.title, - }, - { - type: "select", - label: "timeline.dialogChangeProperty.visibility", - options: kTimelineVisibilities.map((v) => ({ - label: labelMap[v], - value: v, - })), - initValue: timeline.visibility, - }, - { - type: "text", - label: "timeline.dialogChangeProperty.description", - initValue: timeline.description, - }, - { - type: "color", - label: "timeline.dialogChangeProperty.color", - initValue: timeline.color ?? null, - canBeNull: true, - }, - ] as const - } - open={props.open} - onClose={props.close} - onProcess={([newTitle, newVisibility, newDescription, newColor]) => { - const req: HttpTimelinePatchRequest = {}; - if (newTitle !== timeline.title) { - req.title = newTitle; - } - if (newVisibility !== timeline.visibility) { - req.visibility = newVisibility as TimelineVisibility; - } - if (newDescription !== timeline.description) { - req.description = newDescription; - } - const nc = newColor ?? ""; - if (nc !== timeline.color) { - req.color = nc; - } - return getHttpTimelineClient() - .patchTimeline(timeline.owner.username, timeline.nameV2, req) - .then(onChange); - }} - /> - ); -}; - -export default TimelinePropertyChangeDialog; diff --git a/FrontEnd/tools/theme-generator.ts b/FrontEnd/tools/theme-generator.ts new file mode 100644 index 00000000..3583d240 --- /dev/null +++ b/FrontEnd/tools/theme-generator.ts @@ -0,0 +1,495 @@ +#!/usr/bin/env ts-node + +/** + * Color variable name scheme: + * no variant: --[prefix]-[name]-color: [color]; + * with variant: --[prefix]-[name]-[variant]-color: [color]; + * + * Lightness variants come from material design (https://m3.material.io/styles/color/the-color-system/tokens) + */ + +import { stdout } from "process"; + +interface CssSegment { + toCssString(): string; +} + +interface Color extends CssSegment { + readonly type: "hsl" | "css-var"; + toString(): string; +} + +class HslColor implements Color { + readonly type = "hsl"; + + constructor( + public h: number, + public s: number, + public l: number, + ) {} + + withLightness(lightness: number): HslColor { + return new HslColor(this.h, this.s, lightness); + } + + toCssString(): string { + return this.toString(); + } + + toString(): string { + return `hsl(${this.h} ${this.s}% ${this.l}%)`; + } + + static readonly white = new HslColor(0, 0, 100); + static readonly black = new HslColor(0, 0, 0); +} + +class ColorVariable implements CssSegment { + constructor( + public prefix: string, + public name: string, + public variant: string, + ) {} + + toString(): string { + const variantPart = this.variant !== "" ? `-${this.variant}` : ""; + return `--${this.prefix}-${this.name}${variantPart}-color`; + } + + toCssString(): string { + return this.toString(); + } +} + +class CssVarColor implements Color { + readonly type = "css-var"; + + constructor(public colorVariable: ColorVariable) {} + + toCssString(): string { + return this.toString(); + } + + toString(): string { + return `var(${this.colorVariable.toString()})`; + } +} + +class ColorVariableDefinition implements CssSegment { + constructor( + public variable: ColorVariable, + public color: Color, + ) {} + + toCssString(): string { + return `${this.variable.toCssString()}: ${this.color.toCssString()};`; + } +} + +abstract class ColorGroup implements CssSegment { + abstract getColorVariables(): ColorVariableDefinition[]; + toCssString(): string { + return this.getColorVariables() + .map((c) => c.toCssString()) + .join("\n"); + } +} + +interface LightnessVariantInfo { + name: string; + lightness: number; +} + +class LightnessVariantColorGroup extends ColorGroup { + constructor( + public prefix: string, + public name: string, + public baseColor: HslColor, + public variants: LightnessVariantInfo[], + ) { + super(); + } + + getColorVariables(): ColorVariableDefinition[] { + const result: ColorVariableDefinition[] = []; + + for (const variant of this.variants) { + const color = this.baseColor.withLightness(variant.lightness); + result.push( + new ColorVariableDefinition( + new ColorVariable(this.prefix, this.name, variant.name), + color, + ), + ); + } + + return result; + } +} + +class VarAliasColorGroup extends ColorGroup { + constructor( + public prefix: string, + public newName: string, + public oldName: string, + public variants: string[], + ) { + super(); + } + + getColorVariables(): ColorVariableDefinition[] { + const result = []; + for (const variant of this.variants) { + result.push( + new ColorVariableDefinition( + new ColorVariable(this.prefix, this.newName, variant), + new CssVarColor( + new ColorVariable(this.prefix, this.oldName, variant), + ), + ), + ); + } + return result; + } +} + +class CompositeColorGroup extends ColorGroup { + constructor(public groups: ColorGroup[]) { + super(); + } + + getColorVariables(): ColorVariableDefinition[] { + return this.groups + .map((g) => g.getColorVariables()) + .reduce((prev, curr) => prev.concat(curr), []); + } +} + +interface ThemeColorsInfo { + keyColors: { name: string; color: HslColor }[]; + neutralColor: HslColor; +} + +type ColorMode = "light" | "dark"; + +type ThemeColorVariantLightnessVariantsInfo = + | number + | number[] + | { + base: number; + direction: "darker" | "lighter"; + levels: number; + step: number; + }; + +interface ThemeColorVariantInfo { + name: string; + variants: { + light: ThemeColorVariantLightnessVariantsInfo; + dark: ThemeColorVariantLightnessVariantsInfo; + }; +} + +class ThemeColorVariant { + constructor( + public name: string, + public variants: { + light: ThemeColorVariantLightnessVariantsInfo; + dark: ThemeColorVariantLightnessVariantsInfo; + }, + ) {} + getLightnessVariants(mode: ColorMode): LightnessVariantInfo[] { + const { name, variants } = this; + const list = variants[mode]; + + function variantName(i: number) { + if (name.length === 0) { + return i === 0 ? "" : String(i); + } else { + return i === 0 ? name : `${name}-${i}`; + } + } + + function fromList(list: number[]): LightnessVariantInfo[] { + return list.map((l, i) => ({ + name: variantName(i), + lightness: l, + })); + } + + if (typeof list === "number") { + return fromList([list]); + } else if (Array.isArray(list)) { + return fromList(list); + } else { + const l = [list.base]; + for (let i = 1; i <= list.levels; i++) { + if (list.direction === "darker") { + l.push(list.base - i * list.step); + } else { + l.push(list.base + i * list.step); + } + } + return fromList(l); + } + } + + static from(info: ThemeColorVariantInfo): ThemeColorVariant { + return new ThemeColorVariant(info.name, info.variants); + } +} + +class ThemeColor { + variants: ThemeColorVariant[]; + + constructor( + public prefix: string, + public name: string, + public color: HslColor, + variants: ThemeColorVariantInfo[], + ) { + this.variants = variants.map((v) => ThemeColorVariant.from(v)); + } + + getLightnessVariants(mode: ColorMode): LightnessVariantInfo[] { + return this.variants.flatMap((v) => v.getLightnessVariants(mode)); + } + + getLightnessVariantColorGroup(mode: ColorMode): LightnessVariantColorGroup { + return new LightnessVariantColorGroup( + this.prefix, + this.name, + this.color, + this.getLightnessVariants(mode), + ); + } +} + +class Theme { + static keyColorVariants: ThemeColorVariantInfo[] = [ + { + name: "", + variants: { + light: [40, 37, 34], + dark: [80, 75, 68], + }, + }, + { + name: "on", + variants: { + light: 100, + dark: 20, + }, + }, + { + name: "container", + variants: { + light: [90, 80, 70], + dark: [30, 25, 20], + }, + }, + { + name: "on-container", + variants: { + light: 10, + dark: 90, + }, + }, + ]; + + static surfaceColorVariants: ThemeColorVariantInfo[] = [ + { + name: "dim", + variants: { + light: 87, + dark: 6, + }, + }, + { + name: "", + variants: { + light: [98, 90, 82], + dark: [6, 25, 40], + }, + }, + { + name: "bright", + variants: { + light: 98, + dark: 24, + }, + }, + { + name: "container-lowest", + variants: { + light: 100, + dark: 4, + }, + }, + { + name: "container-low", + variants: { + light: 96, + dark: 10, + }, + }, + { + name: "container", + variants: { + light: 94, + dark: 12, + }, + }, + { + name: "container-high", + variants: { + light: 92, + dark: 17, + }, + }, + { + name: "container-highest", + variants: { + light: 90, + dark: 22, + }, + }, + { + name: "on", + variants: { + light: 10, + dark: 90, + }, + }, + { + name: "on-variant", + variants: { + light: 30, + dark: 80, + }, + }, + { + name: "outline", + variants: { + light: 50, + dark: 60, + }, + }, + { + name: "outline-variant", + variants: { + light: 80, + dark: 30, + }, + }, + ]; + + constructor( + public prefix: string, + public themeColors: ThemeColorsInfo, + ) {} + + getColorModeColorDefinitions(mode: ColorMode): ColorGroup { + const groups: ColorGroup[] = []; + for (const { name, color } of this.themeColors.keyColors) { + const themeColor = new ThemeColor( + this.prefix, + name, + color, + Theme.keyColorVariants, + ); + groups.push(themeColor.getLightnessVariantColorGroup(mode)); + } + const neutralThemeColor = new ThemeColor( + this.prefix, + "surface", + this.themeColors.neutralColor, + Theme.surfaceColorVariants, + ); + groups.push(neutralThemeColor.getLightnessVariantColorGroup(mode)); + return new CompositeColorGroup(groups); + } + + getAliasColorDefinitions(name: string): ColorGroup { + const sampleThemeColor = this.themeColors.keyColors[0]; + const themeColor = new ThemeColor( + this.prefix, + sampleThemeColor.name, + sampleThemeColor.color, + Theme.keyColorVariants, + ); + const sampleMode = "light"; + return new VarAliasColorGroup( + this.prefix, + "key", + name, + themeColor.getLightnessVariants(sampleMode).map((v) => v.name), + ); + } + + generateCss(print: (text: string, indent: number) => void): void { + print(":root {", 0); + print(this.getColorModeColorDefinitions("light").toCssString(), 1); + print("}", 0); + + print("", 0); + + print("@media (prefers-color-scheme: dark) {", 0); + print(":root {", 1); + print(this.getColorModeColorDefinitions("dark").toCssString(), 2); + print("}", 1); + print("}", 0); + + print("", 0); + + for (const { name } of this.themeColors.keyColors) { + print(`.${this.prefix}-${name} {`, 0); + print(this.getAliasColorDefinitions(name).toCssString(), 1); + print("}", 0); + + print("", 0); + } + } +} + +(function main() { + const prefix = "cru"; + const themeColors: ThemeColorsInfo = { + keyColors: [ + { name: "primary", color: new HslColor(210, 100, 50) }, + { name: "secondary", color: new HslColor(40, 100, 50) }, + { name: "tertiary", color: new HslColor(160, 100, 50) }, + { name: "danger", color: new HslColor(0, 100, 50) }, + { name: "success", color: new HslColor(120, 60, 50) }, + ], + neutralColor: new HslColor(0, 0, 50), + }; + + const theme = new Theme(prefix, themeColors); + + let output = ""; + + function indentText( + text: string, + level: number, + indentWidth = 2, + appendNewlines = 1, + ): string { + const lines = text.split("\n"); + const indent = " ".repeat(level * indentWidth); + return ( + lines + .map((line) => (line.length === 0 ? "" : `${indent}${line}`)) + .join("\n") + "\n".repeat(appendNewlines) + ); + } + + function print(text: string, indent = 0, appendNewlines = 1) { + output += indentText(text, indent, 2, appendNewlines); + } + + print("/* Generated by theme-generator.ts */\n"); + theme.generateCss(print); + + stdout.write(output); +})(); diff --git a/FrontEnd/tools/tsconfig.json b/FrontEnd/tools/tsconfig.json new file mode 100644 index 00000000..08f53190 --- /dev/null +++ b/FrontEnd/tools/tsconfig.json @@ -0,0 +1,21 @@ +{ + // This is an alias to @tsconfig/node20: https://github.com/tsconfig/bases + "extends": "@tsconfig/node20/tsconfig.json", + // Most ts-node options can be specified here using their programmatic names. + "ts-node": { + // It is faster to skip typechecking. + // Remove if you want ts-node to do typechecking. + "transpileOnly": true, + "compilerOptions": { + // compilerOptions specified here will override those declared below, + // but *only* in ts-node. Useful if you want ts-node and tsc to use + // different options with a single tsconfig.json. + } + }, + "compilerOptions": { + // typescript options here + }, + "include": [ + "**/*" + ] +}
\ No newline at end of file diff --git a/FrontEnd/tsconfig.json b/FrontEnd/tsconfig.json index 97946126..bf106d95 100644 --- a/FrontEnd/tsconfig.json +++ b/FrontEnd/tsconfig.json @@ -21,13 +21,13 @@ "~*": [
"./*"
],
- "@/*": [
- "./src/*"
- ]
},
"noEmit": true
},
"include": [
"src"
+ ],
+ "exclude": [
+ "src/migrating"
]
}
\ No newline at end of file @@ -2,5 +2,5 @@ MYDIR="$(dirname "$(realpath "$0")")" -exec tmux new-session "cd ${MYDIR}/FrontEnd && pnpm run start" \; \ - split-window -h "cd ${MYDIR}/BackEnd/Timeline && dotnet run --launch-profile Dev" +exec tmux new-session "./dev-frontend" \; \ + split-window -h "./dev-backend" diff --git a/dev-backend b/dev-backend new file mode 100755 index 00000000..1ed31f4e --- /dev/null +++ b/dev-backend @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +MYDIR="$(dirname "$(realpath "$0")")" + +cd ${MYDIR}/BackEnd/Timeline && dotnet run --launch-profile Dev diff --git a/dev-backend.ps1 b/dev-backend.ps1 new file mode 100644 index 00000000..958ae75e --- /dev/null +++ b/dev-backend.ps1 @@ -0,0 +1,3 @@ +Push-Location $PSCommandPath/../Backend/Timeline
+dotnet run --launch-profile Dev
+Pop-Location
diff --git a/dev-frontend b/dev-frontend new file mode 100755 index 00000000..28342ba2 --- /dev/null +++ b/dev-frontend @@ -0,0 +1,5 @@ +#!/usr/bin/env sh + +MYDIR="$(dirname "$(realpath "$0")")" + +cd ${MYDIR}/FrontEnd && pnpm run start diff --git a/dev-frontend.ps1 b/dev-frontend.ps1 new file mode 100644 index 00000000..e004980d --- /dev/null +++ b/dev-frontend.ps1 @@ -0,0 +1,3 @@ +Push-Location $PSCommandPath/../FrontEnd
+pnpm run start
+Pop-Location
|