diff --git a/.dockerignore b/.dockerignore index 7bbbfb779..1858fa026 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,5 @@ docat/__pycache__ docat/upload docat/.tox web/node_modules +web/build +web/.env* diff --git a/.github/workflows/docat.yml b/.github/workflows/docat.yml index 14a1e9639..84b505d31 100644 --- a/.github/workflows/docat.yml +++ b/.github/workflows/docat.yml @@ -60,7 +60,7 @@ jobs: - name: run test suite working-directory: web - run: yarn jest + run: yarn test container-image: runs-on: ubuntu-latest diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..c5220423b --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "none" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2584c488b..169e577c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,21 @@ # building frontend -FROM node:16.14 as build-deps +FROM node:16.14 as frontend +WORKDIR /app/frontend COPY web ./ + # fix docker not following symlinks COPY doc/getting-started.md ./src/assets/ + RUN yarn install --frozen-lockfile RUN yarn lint -RUN yarn run test:unit + +# fix test not exiting by default +ARG CI=true +RUN yarn test + RUN yarn build # setup Python -# TODO(Fliiiix): FastApi is broken in Python 3.11 -# We need to wait for a fix: -# https://github.com/tiangolo/fastapi/issues/5048 FROM python:3.11.0-alpine3.15 AS backend # configure docker container @@ -29,12 +33,9 @@ COPY /docat/pyproject.toml /docat/poetry.lock /app/ # Install the application WORKDIR /app/docat -RUN poetry install --no-root --no-ansi --no-dev +RUN poetry install --no-root --no-ansi --only main # production -# TODO(Fliiiix): FastApi is broken in Python 3.11 -# We need to wait for a fix: -# https://github.com/tiangolo/fastapi/issues/5048 FROM python:3.11.0-alpine3.15 # set up the system @@ -46,7 +47,7 @@ RUN mkdir -p /var/docat/doc # install the application RUN mkdir -p /var/www/html -COPY --from=build-deps /dist /var/www/html +COPY --from=frontend /app/frontend/build /var/www/html COPY docat /app/docat WORKDIR /app/docat diff --git a/docat/docat/app.py b/docat/docat/app.py index 5f6b3f502..33ae23291 100644 --- a/docat/docat/app.py +++ b/docat/docat/app.py @@ -40,6 +40,7 @@ get_all_projects, get_project_details, index_all_projects, + is_forbidden_project_name, remove_docs, remove_file_index_from_db, remove_version_from_version_index, @@ -113,7 +114,12 @@ def get_project(project): @app.get("/api/search", response_model=SearchResponse, status_code=status.HTTP_200_OK) @app.get("/api/search/", response_model=SearchResponse, status_code=status.HTTP_200_OK) def search(query: str): - query = query.lower() + query = query.lower().strip() + + # an empty string would match almost everything + if not query: + return SearchResponse(projects=[], versions=[], files=[]) + found_projects: list[SearchResultProject] = [] found_versions: list[SearchResultVersion] = [] found_files: list[SearchResultFile] = [] @@ -312,6 +318,10 @@ def upload( docat_api_key: Optional[str] = Header(None), db: TinyDB = Depends(get_db), ): + if is_forbidden_project_name(project): + response.status_code = status.HTTP_400_BAD_REQUEST + return ApiResponse(message=f'Project name "{project}" is forbidden, as it conflicts with pages in docat web.') + project_base_path = DOCAT_UPLOAD_FOLDER / project base_path = project_base_path / version target_file = base_path / file.filename @@ -391,6 +401,10 @@ def claim(project: str, db: TinyDB = Depends(get_db)): @app.put("/api/{project}/rename/{new_project_name}", response_model=ApiResponse, status_code=status.HTTP_200_OK) @app.put("/api/{project}/rename/{new_project_name}/", response_model=ApiResponse, status_code=status.HTTP_200_OK) def rename(project: str, new_project_name: str, response: Response, docat_api_key: str = Header(None), db: TinyDB = Depends(get_db)): + if is_forbidden_project_name(new_project_name): + response.status_code = status.HTTP_400_BAD_REQUEST + return ApiResponse(message=f'New project name "{new_project_name}" is forbidden, as it conflicts with pages in docat web.') + project_base_path = DOCAT_UPLOAD_FOLDER / project new_project_base_path = DOCAT_UPLOAD_FOLDER / new_project_name diff --git a/docat/docat/utils.py b/docat/docat/utils.py index 9c2cd0145..51c4191cd 100644 --- a/docat/docat/utils.py +++ b/docat/docat/utils.py @@ -99,6 +99,16 @@ def calculate_token(password, salt): return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100000).hex() +def is_forbidden_project_name(name: str) -> bool: + """ + Checks if the given project name is forbidden. + The project name is forbidden if it conflicts with + a page on the docat website. + """ + name = name.lower().strip() + return name in ["upload", "claim", "delete", "search", "help"] + + def get_all_projects(upload_folder_path: Path) -> Projects: """ Returns all projects in the upload folder. diff --git a/docat/tests/test_rename.py b/docat/tests/test_rename.py index 4fbfb83d9..6a25d23aa 100644 --- a/docat/tests/test_rename.py +++ b/docat/tests/test_rename.py @@ -72,3 +72,26 @@ def test_rename_success(client_with_claimed_project): assert len(claims_with_old_name) == 0 claims_with_new_name = table.search(Project.name == "new-project-name") assert len(claims_with_new_name) == 1 + + +def test_rename_rejects_forbidden_project_name(client_with_claimed_project): + """ + Names that conflict with pages in docat web are forbidden, + and renaming a project to such a name should fail. + """ + + create_response = client_with_claimed_project.post( + "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} + ) + assert create_response.status_code == 201 + + with patch("os.rename") as rename_mock: + for project_name in ["upload", "claim", "delete", "Search ", "help"]: + + rename_response = client_with_claimed_project.put(f"/api/some-project/rename/{project_name}", headers={"Docat-Api-Key": "1234"}) + assert rename_response.status_code == 400 + assert rename_response.json() == { + "message": f'New project name "{project_name}" is forbidden, as it conflicts with pages in docat web.' + } + + assert rename_mock.mock_calls == [] diff --git a/docat/tests/test_search.py b/docat/tests/test_search.py index 811b3a417..ac844f20c 100644 --- a/docat/tests/test_search.py +++ b/docat/tests/test_search.py @@ -46,6 +46,25 @@ def test_search_project_by_name_negative(client_with_claimed_project): assert search_response.json() == {"projects": [], "versions": [], "files": []} +def test_search_ignores_empty_query(client_with_claimed_project): + """ + Search should return an empty result if the query is empty. + """ + create_project_response = client_with_claimed_project.post( + "/api/some-project/1.0.0", + files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")}, + ) + assert create_project_response.status_code == 201 + + search_response = client_with_claimed_project.get("/api/search?query=%20") + assert search_response.status_code == 200 + assert search_response.json() == {"projects": [], "versions": [], "files": []} + + search_response = client_with_claimed_project.get("/api/search?query=&") + assert search_response.status_code == 200 + assert search_response.json() == {"projects": [], "versions": [], "files": []} + + def test_search_finds_tag(client_with_claimed_project): """ Search should find a tag by name. (Partial match) diff --git a/docat/tests/test_upload.py b/docat/tests/test_upload.py index 1d5746693..8542894e6 100644 --- a/docat/tests/test_upload.py +++ b/docat/tests/test_upload.py @@ -150,3 +150,20 @@ def test_fails_with_invalid_token(client_with_claimed_project): assert response_data["message"] == "Docat-Api-Key token is not valid for some-project" assert remove_mock.mock_calls == [] + + +def test_upload_rejects_forbidden_project_name(client_with_claimed_project): + """ + Names that conflict with pages in docat web are forbidden, + and creating a project with such a name should fail. + """ + + with patch("docat.app.remove_docs") as remove_mock: + for project_name in ["upload", "claim", "delete", "Search ", "help"]: + response = client_with_claimed_project.post( + f"/api/{project_name}/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} + ) + assert response.status_code == 400 + assert response.json() == {"message": f'Project name "{project_name}" is forbidden, as it conflicts with pages in docat web.'} + + assert remove_mock.mock_calls == [] diff --git a/web/.eslintrc.json b/web/.eslintrc.json new file mode 100644 index 000000000..1f9084a58 --- /dev/null +++ b/web/.eslintrc.json @@ -0,0 +1,45 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "plugin:react/recommended", + "standard-with-typescript" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx" + ], + "extends": [ + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" + ], + "parserOptions": { + "project": [ + "./tsconfig.json" + ] + }, + "rules": { + "@typescript-eslint/space-before-function-paren": "off" + } + }, + { + "files": [ + "src/react-app-env.d.ts" + ], + "rules": { + "@typescript-eslint/triple-slash-reference": "off" + } + } + ], + "parserOptions": { + "ecmaVersion": "latest" + }, + "plugins": [ + "react" + ], + "rules": {} +} \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore index a0dddc6fb..900f50e0b 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,21 +1,29 @@ -.DS_Store -node_modules -/dist +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# local env files +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.prettierrc +.DS_Store .env.local -.env.*.local +.env.development.local +.env.test.local +.env.production.local -# Log files npm-debug.log* yarn-debug.log* yarn-error.log* -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +# local env files +.env +.env.local +.env.*.local diff --git a/web/README.md b/web/README.md index d9ab52661..cf8add862 100644 --- a/web/README.md +++ b/web/README.md @@ -8,18 +8,16 @@ yarn install [--pure-lockfile] ### Compiles and hot-reloads for development -Configure the backend connection by setting -port and host in `.env.development.local`. -Like this configuration for the host `127.0.0.1` -and the port `1337`. - +Configure both the frontend port and the backend connection +by setting them in `.env.development.local`. ```sh -VUE_APP_BACKEND_PORT=1337 -VUE_APP_BACKEND_HOST=127.0.0.1 +PORT=8080 +BACKEND_HOST=127.0.0.1 +BACKEND_PORT=5000 ``` ```sh -yarn serve +yarn start ``` ### Compiles and minifies for production @@ -34,7 +32,13 @@ yarn build yarn lint ``` -### Basic Header Theeming +### Tests + +```sh +yarn test +``` + +### Basic Header Theming Not happy with the default Docat logo and header? Just add your custom html header to the `/var/www/html/config.json` file. @@ -43,21 +47,22 @@ Just add your custom html header to the `/var/www/html/config.json` file. { "headerHTML": "

MyCompany

" } ``` -### Customize configuration - -See [Configuration Reference](https://cli.vuejs.org/config/). - ## Development -To mount the development `dist/` folder while working on the -web frontend, you can mount the `dist/` folder as a docker volume: - ```sh sudo docker run \ --detach \ --volume /path/to/doc:/var/docat/doc/ \ - --volume /path/to/docat/web/dist:/var/www/html/ \ --publish 8000:80 \ docat ``` + +## Errors + +If you get a 403 response when trying to read a version, +try changing the permissions of your docs folder on your host. + +```sh +sudo chmod 777 /path/to/doc -r +``` \ No newline at end of file diff --git a/web/babel.config.js b/web/babel.config.js deleted file mode 100644 index e9558405f..000000000 --- a/web/babel.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - presets: [ - '@vue/cli-plugin-babel/preset' - ] -} diff --git a/web/jest.config.js b/web/jest.config.js deleted file mode 100644 index 0f9579148..000000000 --- a/web/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - preset: '@vue/cli-plugin-unit-jest' -} diff --git a/web/package.json b/web/package.json index 2922e9962..a77699eca 100644 --- a/web/package.json +++ b/web/package.json @@ -2,71 +2,63 @@ "name": "docat-web", "version": "0.1.0", "private": true, - "scripts": { - "serve": "vue-cli-service serve", - "build": "vue-cli-service build", - "test:unit": "vue-cli-service test:unit", - "lint": "vue-cli-service lint" - }, "dependencies": { - "babel-runtime": "^6.26.0", - "core-js": "^3.26.0", - "html-loader": "^0.5.5", - "node-sass": "^6.0.0", - "sass-loader": "^10.0.5", - "semver": "^7.3.7", - "v-tooltip": "^2.1.3", - "vue": "^2.6.10", - "vue-markdown": "^2.2.4", - "vuelidate": "^0.7.4" + "@emotion/react": "^11.10.4", + "@emotion/styled": "^11.10.4", + "@mui/icons-material": "^5.10.9", + "@mui/material": "^5.10.11", + "@testing-library/jest-dom": "^5.14.1", + "@testing-library/react": "^13.0.0", + "@testing-library/user-event": "^13.2.1", + "@types/jest": "^27.0.1", + "@types/lodash": "^4.14.191", + "@types/node": "^16.7.13", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "eslint": "^8.0.1", + "http-proxy-middleware": "^2.0.6", + "lodash": "^4.17.21", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-markdown": "^8.0.3", + "react-router-dom": "^6.4.2", + "react-scripts": "5.0.1", + "react-tooltip": "^4.4.3", + "typescript": "*", + "web-vitals": "^2.1.0" }, - "devDependencies": { - "@vue/cli-plugin-babel": "^5.0.8", - "@vue/cli-plugin-eslint": "^4.0.0", - "@vue/cli-plugin-unit-jest": "~4.2.0", - "@vue/cli-service": "^4.0.0", - "@vue/test-utils": "^1.3.0", - "babel-eslint": "^10.0.3", - "eslint": "^5.16.0", - "eslint-plugin-vue": "^6.2.2", - "vue-material": "^1.0.0-beta-11", - "vue-router": "^3.1.3", - "vue-template-compiler": "^2.6.10" + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --watchAll=false --testMatch **/src/**/*.test.ts", + "eject": "react-scripts eject", + "lint": "eslint ./src --ext .js,.jsx,.ts,.tsx" }, "eslintConfig": { - "root": true, - "env": { - "node": true - }, "extends": [ - "plugin:vue/essential", - "eslint:recommended" - ], - "rules": { - "no-console": "off" - }, - "parserOptions": { - "parser": "babel-eslint" - }, - "overrides": [ - { - "files": [ - "**/__tests__/*.{j,t}s?(x)", - "**/tests/unit/**/*.spec.{j,t}s?(x)" - ], - "env": { - "jest": true - } - } + "react-app", + "react-app/jest" ] }, - "postcss": { - "plugins": { - "autoprefixer": {} - } + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] }, - "browserslist": [ - "> 1%", - "last 2 versions" - ] + "proxy": "http://localhost:5000/", + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.0.0", + "eslint-config-standard-with-typescript": "^23.0.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.31.11" + } } diff --git a/web/public/index.html b/web/public/index.html index b244128a9..3029b21a2 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -4,13 +4,12 @@ - - + -
+
diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 000000000..63691ac14 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,94 @@ +import { createHashRouter, RouterProvider } from 'react-router-dom' +import React from 'react' +import { ConfigDataProvider } from './data-providers/ConfigDataProvider' +import { ProjectDataProvider } from './data-providers/ProjectDataProvider' +import Claim from './pages/Claim' +import Delete from './pages/Delete' +import Docs from './pages/Docs' +import Help from './pages/Help' +import Home from './pages/Home' +import NotFound from './pages/NotFound' +import Upload from './pages/Upload' +import Search from './pages/Search' +import EscapeSlashForDocsPath from './pages/EscapeSlashForDocsPath' +import { MessageBannerProvider } from './data-providers/MessageBannerProvider' + +function App(): JSX.Element { + const router = createHashRouter([ + { + path: '/', + errorElement: , + children: [ + { + path: '', + element: + }, + { + path: 'upload', + element: + }, + { + path: 'claim', + element: + }, + { + path: 'delete', + element: + }, + { + path: 'help', + element: + }, + { + path: 'search', + element: + }, + { + path: ':project', + children: [ + { + path: '', + element: + }, + { + path: ':version', + children: [ + { + path: '', + element: + }, + { + path: ':page', + children: [ + { + path: '', + element: + }, + { + path: '*', + element: + } + ] + } + ] + } + ] + } + ] + } + ]) + + return ( +
+ + + + + + + +
+ ) +} + +export default App diff --git a/web/src/App.vue b/web/src/App.vue deleted file mode 100644 index e4a4b0ba6..000000000 --- a/web/src/App.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/web/src/components/ClaimButton.tsx b/web/src/components/ClaimButton.tsx new file mode 100644 index 000000000..8deff6e93 --- /dev/null +++ b/web/src/components/ClaimButton.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { Lock } from '@mui/icons-material' +import ReactTooltip from 'react-tooltip' + +import styles from './../style/components/ControlButtons.module.css' + +interface Props { + isSingleButton?: boolean +} + +export default function ClaimButton (props: Props): JSX.Element { + return ( + <> + + + + + + ) +} diff --git a/web/src/components/ClaimButton.vue b/web/src/components/ClaimButton.vue deleted file mode 100644 index aaa6abf4a..000000000 --- a/web/src/components/ClaimButton.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/web/src/components/DataSelect.tsx b/web/src/components/DataSelect.tsx new file mode 100644 index 000000000..8066945ce --- /dev/null +++ b/web/src/components/DataSelect.tsx @@ -0,0 +1,50 @@ +import { FormGroup, MenuItem, TextField } from '@mui/material' +import React, { useState } from 'react' + +interface Props { + emptyMessage: string + errorMsg?: string + value?: string + label: string + values: string[] + onChange: (value: string) => void +} + +export default function DataSelect (props: Props): JSX.Element { + const [selectedValue, setSelectedValue] = useState( + props.value ?? 'none' + ) + + // clear field if selected value is not in options + if (selectedValue !== 'none' && !props.values.includes(selectedValue)) { + setSelectedValue('none') + } + + return ( + + { + setSelectedValue(e.target.value) + props.onChange(e.target.value) + }} + value={props.values.length > 0 ? selectedValue : 'none'} + label={props.label} + error={props.errorMsg !== undefined && props.errorMsg !== ''} + helperText={props.errorMsg} + select + > + + {props.emptyMessage} + + + {props.values.map((value) => { + return ( + + {value} + + ) + })} + + + ) +} diff --git a/web/src/components/DeleteButton.tsx b/web/src/components/DeleteButton.tsx new file mode 100644 index 000000000..50ff93af4 --- /dev/null +++ b/web/src/components/DeleteButton.tsx @@ -0,0 +1,29 @@ +import { Link } from 'react-router-dom' +import { Delete } from '@mui/icons-material' +import ReactTooltip from 'react-tooltip' +import React from 'react' + +import styles from './../style/components/ControlButtons.module.css' + +interface Props { + isSingleButton?: boolean +} + +export default function DeleteButton (props: Props): JSX.Element { + return ( + <> + + + + + + ) +} diff --git a/web/src/components/DeleteButton.vue b/web/src/components/DeleteButton.vue deleted file mode 100644 index 2d35db4d8..000000000 --- a/web/src/components/DeleteButton.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - - - diff --git a/web/src/components/DocumentControlButtons.tsx b/web/src/components/DocumentControlButtons.tsx new file mode 100644 index 000000000..830a96f15 --- /dev/null +++ b/web/src/components/DocumentControlButtons.tsx @@ -0,0 +1,53 @@ +import { Home, VisibilityOff } from '@mui/icons-material' +import { FormControl, MenuItem, Select } from '@mui/material' +import { Link } from 'react-router-dom' +import ReactTooltip from 'react-tooltip' +import ProjectDetails from '../models/ProjectDetails' +import React from 'react' + +import styles from './../style/components/DocumentControlButtons.module.css' + +interface Props { + version: string + versions: ProjectDetails[] + onVersionChange: (version: string) => void + onHideUi: () => void +} + +export default function DocumentControlButtons (props: Props): JSX.Element { + const buttonStyle = { width: '25px', height: '25px' } + + return ( +
+ + + + + + + + + +
+ ) +} diff --git a/web/src/components/FavoriteStar.tsx b/web/src/components/FavoriteStar.tsx new file mode 100644 index 000000000..9d7970769 --- /dev/null +++ b/web/src/components/FavoriteStar.tsx @@ -0,0 +1,31 @@ +import { Star, StarOutline } from '@mui/icons-material' +import React, { useState } from 'react' +import ProjectRepository from '../repositories/ProjectRepository' + +interface Props { + projectName: string + onFavoriteChanged: () => void +} + +export default function FavoriteStar (props: Props): JSX.Element { + const [isFavorite, setIsFavorite] = useState( + ProjectRepository.isFavorite(props.projectName) + ) + + const toggleFavorite = (): void => { + const newIsFavorite = !isFavorite + ProjectRepository.setFavorite(props.projectName, newIsFavorite) + setIsFavorite(newIsFavorite) + + props.onFavoriteChanged() + } + + const StarType = isFavorite ? Star : StarOutline + + return ( + + ) +} diff --git a/web/src/components/FileInput.tsx b/web/src/components/FileInput.tsx new file mode 100644 index 000000000..0396607bd --- /dev/null +++ b/web/src/components/FileInput.tsx @@ -0,0 +1,143 @@ +import { InputLabel } from '@mui/material' +import React, { useRef, useState } from 'react' + +import styles from './../style/components/FileInput.module.css' + +interface Props { + label: string + okTypes: string[] + file: File | undefined + onChange: (file: File | undefined) => void + isValid: (file: File) => boolean +} + +export default function FileInput(props: Props): JSX.Element { + const [fileName, setFileName] = useState( + props.file?.name !== undefined ? props.file.name : '' + ) + const [dragActive, setDragActive] = useState(false) + const inputRef = useRef(null) + + /** + * Checks if a file was selected and if it is valid + * before it is selected. + * @param files FileList from the event + */ + const updateFileIfValid = (files: FileList | null): void => { + if (files == null || files.length < 1 || files[0] == null) { + return + } + + const file = files[0] + if (!props.isValid(file)) { + return + } + + setFileName(file.name) + props.onChange(file) + } + + /** + * This updates the file upload container to show a custom style when + * the user is dragging a file into or out of the container. + * @param e drag enter event + */ + const handleDragEvents = (e: React.DragEvent): void => { + e.preventDefault() + e.stopPropagation() + + if (e.type === 'dragenter' || e.type === 'dragover') { + setDragActive(true) + } else if (e.type === 'dragleave') { + setDragActive(false) + } + } + + /** + * Handles the drop event when the user drops a file into the container. + * @param e DragEvent + */ + const handleDrop = (e: React.DragEvent): void => { + e.preventDefault() + e.stopPropagation() + setDragActive(false) + + if (e.dataTransfer?.files[0] == null) { + return + } + + updateFileIfValid(e.dataTransfer.files) + } + + /** + * Handles the file input via the file browser. + * @param e change event + */ + const handleSelect = (e: React.ChangeEvent): void => { + e.preventDefault() + + updateFileIfValid(e.target.files) + } + + /** + * This triggers the input when the container is clicked. + */ + const onButtonClick = (): void => { + if (inputRef?.current != null) { + // @ts-expect-error - the ref is not null, therefore the button should be able to be clicked + inputRef.current.click() // eslint-disable-line @typescript-eslint/no-unsafe-call + } + } + + return ( +
+ {!dragActive && ( + + {props.label} + + )} + +
+ + + {fileName !== '' && ( + <> +

{fileName}

+

-

+ + )} + +

Drag zip file here or

+ + + + {dragActive && ( +
+ )} +
+
+ ) +} diff --git a/web/src/components/Footer.tsx b/web/src/components/Footer.tsx new file mode 100644 index 000000000..dfbf34ca9 --- /dev/null +++ b/web/src/components/Footer.tsx @@ -0,0 +1,13 @@ +import { Link } from 'react-router-dom' +import styles from './../style/components/Footer.module.css' +import React from 'react' + +export default function Footer (): JSX.Element { + return ( +
+ + Help + +
+ ) +} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx new file mode 100644 index 000000000..34bb0ae70 --- /dev/null +++ b/web/src/components/Header.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react' +import { Link } from 'react-router-dom' + +import SearchBar from './SearchBar' +import { useConfig } from '../data-providers/ConfigDataProvider' + +import docatLogo from '../assets/logo.png' +import styles from './../style/components/Header.module.css' + +interface Props { + showSearchBar?: boolean +} + +export default function Header (props: Props): JSX.Element { + const defaultHeader = ( + <> + docat logo +

DOCAT

+ + ) + + const [header, setHeader] = useState(defaultHeader) + const config = useConfig() + + // set custom header if found in config + if (config.headerHTML != null && header === defaultHeader) { + setHeader(
) + } + + return ( +
+ {header} + {props.showSearchBar !== false && } +
+ ) +} diff --git a/web/src/components/Help.vue b/web/src/components/Help.vue deleted file mode 100644 index d9dc2713b..000000000 --- a/web/src/components/Help.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/web/src/components/InfoBanner.tsx b/web/src/components/InfoBanner.tsx new file mode 100644 index 000000000..626c33eba --- /dev/null +++ b/web/src/components/InfoBanner.tsx @@ -0,0 +1,47 @@ +import { Alert, Snackbar } from '@mui/material' +import { uniqueId } from 'lodash' +import React, { useEffect, useState } from 'react' +import { Message } from '../data-providers/MessageBannerProvider' + +interface Props { + errorMsg?: string + successMsg?: string +} + +export default function Banner(props: Props): JSX.Element { + const messageFromProps = (props: Props): Message => { + if (props.errorMsg != null && props.errorMsg.trim() !== '') { + return { text: props.errorMsg, type: 'error' } + } + if (props.successMsg != null && props.successMsg.trim() !== '') { + return { text: props.successMsg, type: 'success' } + } + + return { text: undefined, type: 'success' } + } + + const [msg, setMsg] = useState(messageFromProps(props)) + const [show, setShow] = useState(false) + + useEffect(() => { + setShow(true) + setMsg(messageFromProps(props)) + }, [props]) + + return ( + setShow(false)} + > + setShow(false)} + severity={msg.type} + sx={{ width: '100%' }} + > + {msg.text} + + + ) +} diff --git a/web/src/components/Layout.vue b/web/src/components/Layout.vue deleted file mode 100644 index e4e194d79..000000000 --- a/web/src/components/Layout.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - - - \ No newline at end of file diff --git a/web/src/components/NavigationTitle.tsx b/web/src/components/NavigationTitle.tsx new file mode 100644 index 000000000..cbce044ce --- /dev/null +++ b/web/src/components/NavigationTitle.tsx @@ -0,0 +1,29 @@ +import { ArrowBackIos } from '@mui/icons-material' +import { Link } from 'react-router-dom' +import React from 'react' + +import styles from './../style/components/NavigationTitle.module.css' + +interface Props { + title: string + backLink?: string + description?: string | JSX.Element +} + +export default function NavigationTitle (props: Props): JSX.Element { + return ( + <> +
+ + + +

{props.title}

+
+ +
{props.description}
+ + ) +} diff --git a/web/src/components/PageLayout.tsx b/web/src/components/PageLayout.tsx new file mode 100644 index 000000000..454f327ae --- /dev/null +++ b/web/src/components/PageLayout.tsx @@ -0,0 +1,25 @@ +import styles from './../style/components/PageLayout.module.css' +import Footer from './Footer' +import Header from './Header' +import NavigationTitle from './NavigationTitle' +import React from 'react' + +interface Props { + title: string + description?: string | JSX.Element + showSearchBar?: boolean + children: JSX.Element | JSX.Element[] +} + +export default function PageLayout (props: Props): JSX.Element { + return ( + <> +
+
+ + {props.children} +
+