├── .gitignore ├── LICENSE ├── README.md ├── backend ├── cloud-function-trigger │ ├── README.md │ ├── deploy.sh │ ├── firestore.py │ ├── main.py │ ├── requirements.dev.txt │ ├── requirements.txt │ └── slack.py └── cloud-run-api │ ├── .env.example │ ├── .gitignore │ ├── Makefile │ ├── README.md │ ├── app │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── errors │ │ │ ├── http_error.py │ │ │ └── validation_error.py │ │ └── routes │ │ │ ├── __init__.py │ │ │ ├── annotation.py │ │ │ └── api.py │ ├── config │ │ ├── __init__.py │ │ ├── annotation_config.py │ │ └── api_config.py │ ├── functions │ │ ├── __init__.py │ │ ├── demo.py │ │ ├── firebase.py │ │ ├── firestore.py │ │ ├── task.py │ │ └── user.py │ ├── main.py │ ├── models │ │ ├── __init__.py │ │ ├── firestore.py │ │ ├── log.py │ │ ├── result.py │ │ └── upload.py │ └── utils │ │ ├── __init__.py │ │ └── multi_thread.py │ ├── bin │ └── exec.sh │ ├── cloudbuild.yaml │ ├── docker-compose.yml │ ├── docker │ ├── api │ │ └── Dockerfile │ ├── common │ │ └── requirements.txt │ └── jupyter │ │ └── Dockerfile │ └── notebooks │ └── QuickStart-API.ipynb ├── docs └── imgs │ ├── db_latest.png │ ├── db_v1.0.0.png │ ├── db_v1.0.1.png │ ├── db_v1.0.2.png │ ├── db_v1.0.3.png │ ├── db_v1.0.4.png │ ├── front_admin_latest.png │ ├── front_admin_v1.0.0.png │ ├── front_annotator_latest.png │ ├── front_annotator_v1.0.0.png │ ├── front_db_latest.png │ ├── front_db_v1.0.0.png │ ├── infra_latest.png │ ├── infra_v1.0.0.png │ ├── infra_v1.0.1.png │ ├── infra_v1.0.2.png │ ├── logo_latest.png │ ├── main_latest.png │ ├── mock_latest.png │ └── mock_v1.0.0.png └── frontend ├── .dockerignore ├── .env.example ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── app ├── .babelrc ├── .eslintrc.json ├── .firebaserc ├── .gitignore ├── README.md ├── configs │ ├── jest.json │ ├── jest.preprocessor.js │ └── webpack │ │ ├── common.js │ │ ├── dev.js │ │ └── prod.js ├── express.js ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── package.json ├── src │ ├── App.css │ ├── assets │ │ ├── favicon │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── browserconfig.xml │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── mstile-150x150.png │ │ │ ├── safari-pinned-tab.svg │ │ │ └── site.webmanifest │ │ ├── img │ │ │ ├── check_mobile.png │ │ │ ├── checked.png │ │ │ ├── front_nav_lady_b.png │ │ │ ├── front_nav_lady_g.png │ │ │ ├── good_hand.png │ │ │ ├── hot_tea_b.png │ │ │ ├── hot_tea_g.png │ │ │ ├── react_logo.svg │ │ │ ├── thanks_lady.png │ │ │ └── thanks_lady_stand.png │ │ └── scss │ │ │ └── App.scss │ ├── index.html.ejs │ ├── index.tsx │ ├── pages │ │ ├── Annotation │ │ │ ├── AnnotationContext.tsx │ │ │ ├── AnnotationPage.tsx │ │ │ ├── AnnotationThanksPage.tsx │ │ │ ├── AnnotationUtils.tsx │ │ │ └── components │ │ │ │ ├── AnnotationTopBar.tsx │ │ │ │ ├── CardAnnotation.tsx │ │ │ │ └── MultiLabelAnnotation.tsx │ │ ├── Auth │ │ │ ├── LoginSignupPage.tsx │ │ │ └── SignupThanksPage.tsx │ │ ├── Common │ │ │ ├── LoadingPage.tsx │ │ │ └── components │ │ │ │ ├── IconMenu.tsx │ │ │ │ ├── RoundButton.tsx │ │ │ │ └── TopBar.tsx │ │ ├── Home │ │ │ └── HomePage.tsx │ │ └── Task │ │ │ ├── TaskDetailPage.tsx │ │ │ └── TasksPage.tsx │ ├── plugins │ │ ├── Auth.tsx │ │ ├── DataController.tsx │ │ ├── DeviceInfo.tsx │ │ ├── Schemas.tsx │ │ ├── Utils.tsx │ │ ├── Viewport.tsx │ │ ├── firebase.tsx │ │ └── useDBUserStatus.tsx │ └── types │ │ └── import-png.d.ts ├── tests │ ├── App.test.tsx │ └── __mocks__ │ │ ├── fileMock.js │ │ ├── shim.js │ │ └── styleMock.js ├── tsconfig.json └── yarn.lock ├── bin └── exec.sh └── docker-compose.yml /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | .idea 3 | .devcontainer 4 | .vscode 5 | 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 Heartex, Inc 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [Landing Page](https://fast-annotation-tool.app)・[Quick Start](../../wiki/Quick-Start)・[Video](https://youtu.be/17KyKwbJ_-o) 4 | 5 | demo 6 | 7 | ## FAST: Fast Annotation tool for SmarT devices 8 | 9 | FAST is an annotation tool that focuses on mobile devices. It is simple to use and fast to annotate. Also, it is designed to be serverless, so it can be operated for a long time at a low cost. 10 | 11 | ## Feature 12 | 13 | - Responsive UI/UX with a mobile focus 14 | - Detailed annotation log 15 | - Serverless design 16 | 17 | ## Requirement 18 | 19 | - [Docker/Docker Compose](https://docs.docker.com/get-docker/) 20 | - [Google Cloud SDK](https://cloud.google.com/sdk/docs/install) 21 | - [Firebase CLI](https://firebase.google.com/docs/cli#install-cli-mac-linux) 22 | 23 | ## Quick Start 24 | 25 | Please refer to [wiki](../../wiki/Quick-Start). 26 | 27 | 28 | 29 | ## Citation 30 | 31 | ``` 32 | @misc{FAST, 33 | title={{FAST}: Fast Annotation tool for SmarT devices}, 34 | url={https://github.com/CyberAgent/fast-annotation-tool}, 35 | note={Open source software available from https://github.com/CyberAgent/fast-annotation-tool}, 36 | author={ 37 | Shunyo Kawamoto and 38 | Yu Sawai and 39 | Peinan Zhang and 40 | Kohei Wakimoto 41 | }, 42 | year={2021}, 43 | } 44 | ``` 45 | 46 | ## License 47 | 48 | This software is licensed under the [Apache 2.0 LICENSE](./LICENSE) 49 | -------------------------------------------------------------------------------- /backend/cloud-function-trigger/README.md: -------------------------------------------------------------------------------- 1 | # Cloud Function Trigger 2 | 3 | - Notify Slack in the following cases. 4 | - User registration 5 | - Tasks are assigned to users. 6 | - User starts a Task 7 | - User completes a Task 8 | 9 | fast 10 | 11 | ## Quick Start 12 | 13 | 1. (If necessary) [Install Google Cloud SDK](https://cloud.google.com/sdk/docs/install) 14 | 2. Create a Slack channel for notifications 15 | 3. Get a Slack API key. 16 | 4. Set the variable of deploy.sh 17 | 5. Run deploy.sh 18 | 19 | -------------------------------------------------------------------------------- /backend/cloud-function-trigger/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | ENV="SLACK_API_KEY=${SLACK_API_KEY},SLACK_CHANNEL=${SLACK_CHANNEL}" 3 | 4 | gcloud functions deploy notify_slack_on_user_created \ 5 | --runtime python39 \ 6 | --trigger-event providers/cloud.firestore/eventTypes/document.create \ 7 | --trigger-resource "projects/${PROJECT_ID}/databases/(default)/documents/users/{name}" \ 8 | --set-env-vars $ENV 9 | 10 | 11 | gcloud functions deploy notify_slack_on_usertask_created \ 12 | --runtime python39 \ 13 | --trigger-event providers/cloud.firestore/eventTypes/document.create \ 14 | --trigger-resource "projects/${PROJECT_ID}/databases/(default)/documents/users_tasks/{name}" \ 15 | --set-env-vars $ENV 16 | 17 | 18 | gcloud functions deploy notify_slack_on_usertask_updated \ 19 | --runtime python39 \ 20 | --trigger-event providers/cloud.firestore/eventTypes/document.update \ 21 | --trigger-resource "projects/${PROJECT_ID}/databases/(default)/documents/users_tasks/{name}" \ 22 | --set-env-vars $ENV 23 | -------------------------------------------------------------------------------- /backend/cloud-function-trigger/firestore.py: -------------------------------------------------------------------------------- 1 | import firebase_admin 2 | from firebase_admin import firestore 3 | 4 | firebase_admin.initialize_app() 5 | db = firestore.client() 6 | 7 | 8 | def snapshot_to_dict(snapshot): 9 | return {**snapshot.to_dict(), **{"id": snapshot.id}} 10 | 11 | 12 | def snapshots_to_dicts(snapshots): 13 | return [snapshot_to_dict(sp) for sp in snapshots] 14 | 15 | 16 | def is_doc_exist(collection_name, doc_id): 17 | return db.collection(collection_name).document(doc_id).get().exists 18 | 19 | 20 | def get_collection_docs(collection_name, limit=100): 21 | return snapshots_to_dicts(db.collection(collection_name).get()) 22 | 23 | 24 | def split_list(lst, n): 25 | """Yield successive n-sized chunks from lst.""" 26 | for i in range(0, len(lst), n): 27 | yield lst[i : i + n] 28 | 29 | 30 | def get_collection_docs_where_in(collection_name, field_name, parent_list): 31 | items = [] 32 | for _parent_chunk in split_list(parent_list, 10): 33 | items += snapshots_to_dicts( 34 | db.collection(collection_name).where(field_name, "in", _parent_chunk).get() 35 | ) 36 | return items 37 | 38 | 39 | def get_doc_dict_by_id(collection_name, doc_id): 40 | return snapshot_to_dict(db.collection(collection_name).document(doc_id).get()) 41 | -------------------------------------------------------------------------------- /backend/cloud-function-trigger/main.py: -------------------------------------------------------------------------------- 1 | import slack 2 | from firestore import get_doc_dict_by_id 3 | 4 | 5 | def notify_slack_on_user_created(data, context): 6 | trigger_resource = context.resource 7 | print("Function triggered by created : %s" % trigger_resource) 8 | print(data) 9 | 10 | def _get_str(key): 11 | return data["value"]["fields"][key]["stringValue"] 12 | 13 | slack.post( 14 | f"email: {_get_str('email')}\nuid: {_get_str('id')}", 15 | pretext="新規ユーザが登録されました", 16 | title=_get_str("name"), 17 | color="#ffa500", 18 | ) 19 | 20 | 21 | def notify_slack_on_usertask_created(data, context): 22 | trigger_resource = context.resource 23 | print("Function triggered by created : %s" % trigger_resource) 24 | print(data) 25 | 26 | def _get_str(key): 27 | return list(data["value"]["fields"][key].values())[0] 28 | 29 | user_id = _get_str("user_id") 30 | task_id = _get_str("task_id") 31 | user_dict = get_doc_dict_by_id("users", user_id) 32 | task_dict = get_doc_dict_by_id("tasks", task_id) 33 | print(user_dict) 34 | print(task_dict) 35 | 36 | slack.post( 37 | f"user_name: {user_dict.get('name')}\n件数: {_get_str('annotation_num')}\ntask_id: {task_id}\nuid: {user_id}", 38 | pretext="ユーザにタスクが振り分けられました", 39 | title=task_dict.get("title"), 40 | color="#008080", 41 | ) 42 | 43 | 44 | def notify_slack_on_usertask_updated(data, context): 45 | trigger_resource = context.resource 46 | print("Function triggered by updated : %s" % trigger_resource) 47 | print(data) 48 | 49 | def _get_str(key): 50 | return list(data["value"]["fields"][key].values())[0] 51 | 52 | annotation_num = int(_get_str("annotation_num")) 53 | submitted_num = int(_get_str("submitted_num")) 54 | user_id = _get_str("user_id") 55 | task_id = _get_str("task_id") 56 | user_dict = get_doc_dict_by_id("users", user_id) 57 | task_dict = get_doc_dict_by_id("tasks", task_id) 58 | print(user_dict) 59 | print(task_dict) 60 | print(f"Annotation {submitted_num}/{annotation_num}") 61 | 62 | if annotation_num == submitted_num: 63 | # Done 64 | print("Annotation Done") 65 | slack.post( 66 | f"user_name: {user_dict.get('name')}\n件数: {_get_str('annotation_num')}\ntask_id: {task_id}\nuid: {user_id}", 67 | pretext="ユーザがタスクを完了しました", 68 | title=task_dict.get("title"), 69 | color="#4169e1", 70 | ) 71 | elif submitted_num == 1: 72 | # WARNING: 呼び出しではキャッシュがとられるので、1がスキップされる可能性高 73 | # Start 74 | print("Annotation Start") 75 | slack.post( 76 | f"user_name: {user_dict.get('name')}\n件数: {_get_str('annotation_num')}\ntask_id: {task_id}\nuid: {user_id}", 77 | pretext="ユーザがタスクを開始しました", 78 | title=task_dict.get("title"), 79 | color="#2e8b57", 80 | ) 81 | -------------------------------------------------------------------------------- /backend/cloud-function-trigger/requirements.dev.txt: -------------------------------------------------------------------------------- 1 | firebase-admin==4.4.0 2 | google-cloud-firestore==1.7.0 3 | slacker==0.14.0 4 | black==21.5b0 5 | mypy==0.812 6 | mypy-extensions==0.4.3 7 | pytest==6.2.4 8 | -------------------------------------------------------------------------------- /backend/cloud-function-trigger/requirements.txt: -------------------------------------------------------------------------------- 1 | slacker==0.14.0 2 | firebase-admin==4.4.0 3 | google-cloud-firestore==1.7.0 -------------------------------------------------------------------------------- /backend/cloud-function-trigger/slack.py: -------------------------------------------------------------------------------- 1 | from slacker import Slacker 2 | import os 3 | 4 | API_KEY = os.environ["SLACK_API_KEY"] 5 | CHANNEL = os.environ["SLACK_CHANNEL"] 6 | slacker = Slacker(API_KEY) 7 | 8 | 9 | def post(_str, pretext=None, title=None, color="good", channel=None, image_url=None): 10 | if channel is None: 11 | channel = CHANNEL 12 | slacker.chat.post_message( 13 | channel, 14 | attachments=[ 15 | { 16 | "title": title, 17 | "pretext": pretext, 18 | "color": color, 19 | "text": _str, 20 | "image_url": image_url, 21 | } 22 | ], 23 | username="fast-cat", 24 | icon_emoji=":cat-sit:", 25 | ) 26 | -------------------------------------------------------------------------------- /backend/cloud-run-api/.env.example: -------------------------------------------------------------------------------- 1 | GOOGLE_APPLICATION_CREDENTIALS=/root/.config/gcloud/application_default_credentials.json 2 | GOOGLE_CLOUD_PROJECT= 3 | API_VERSION=1.0.0 4 | API_PROJECT_NAME=fast-annotation-tool-api 5 | APP_URL=https://.web.app -------------------------------------------------------------------------------- /backend/cloud-run-api/.gitignore: -------------------------------------------------------------------------------- 1 | credentials 2 | data -------------------------------------------------------------------------------- /backend/cloud-run-api/Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: up 4 | up: ## Up api in docker compose 5 | docker-compose up --remove-orphans --build 6 | 7 | .PHONY: deploy 8 | deploy: ## Deploy to Cloud Run 9 | gcloud builds submit 10 | 11 | .PHONY: set_demo_tasks 12 | set_demo_tasks: ## Set the task data for the demo to Firestore 13 | . bin/exec.sh python app/functions/demo.py 14 | 15 | .PHONY: set_user_role 16 | set_user_role: ## Set the user's role to Firestore 17 | . bin/exec.sh python app/functions/user.py set_user_role $(email) $(role) 18 | 19 | .PHONY: format 20 | format: ## Formatting with black 21 | black -l 100 -t py38 . 22 | 23 | .PHONY: help 24 | help: ## Display this help screen 25 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 26 | -------------------------------------------------------------------------------- /backend/cloud-run-api/README.md: -------------------------------------------------------------------------------- 1 | # Cloud Run API 2 | 3 | ## Quick Start 4 | 5 | See [here](../../../../wiki/Quick-Start#3-backend). 6 | 7 | 8 | 9 | ## Dev Commands 10 | 11 | #### Start up the Local API server 12 | 13 | ```shell 14 | make up 15 | ``` 16 | 17 | #### Upload mock data 18 | 19 | ```shell 20 | make set_demo_tasks 21 | ``` 22 | 23 | #### Granting Admin privileges 24 | 25 | ```shell 26 | make set_user_role email= role=admin 27 | ``` 28 | 29 | #### Deploy 30 | 31 | ```shell 32 | gcloud builds submit --substitutions _PROJECT=,_SERVICE_ACCOUNT=***.iam.gserviceaccount,_APP_URL=.web.app 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/backend/cloud-run-api/app/__init__.py -------------------------------------------------------------------------------- /backend/cloud-run-api/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/backend/cloud-run-api/app/api/__init__.py -------------------------------------------------------------------------------- /backend/cloud-run-api/app/api/errors/http_error.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from starlette.requests import Request 3 | from starlette.responses import JSONResponse 4 | 5 | 6 | async def http_error_handler(_: Request, exc: HTTPException) -> JSONResponse: 7 | return JSONResponse({"errors": [exc.detail]}, status_code=exc.status_code) 8 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/api/errors/validation_error.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from fastapi.exceptions import RequestValidationError 4 | from fastapi.openapi.constants import REF_PREFIX 5 | from fastapi.openapi.utils import validation_error_response_definition 6 | from pydantic import ValidationError 7 | from starlette.requests import Request 8 | from starlette.responses import JSONResponse 9 | from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY 10 | 11 | 12 | async def http422_error_handler( 13 | _: Request, 14 | exc: Union[RequestValidationError, ValidationError], 15 | ) -> JSONResponse: 16 | return JSONResponse( 17 | {"errors": exc.errors()}, 18 | status_code=HTTP_422_UNPROCESSABLE_ENTITY, 19 | ) 20 | 21 | 22 | validation_error_response_definition["properties"] = { 23 | "errors": { 24 | "title": "Errors", 25 | "type": "array", 26 | "items": {"$ref": "{0}ValidationError".format(REF_PREFIX)}, 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/api/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/backend/cloud-run-api/app/api/routes/__init__.py -------------------------------------------------------------------------------- /backend/cloud-run-api/app/api/routes/annotation.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List 2 | 3 | from app.config.api_config import APP_URL 4 | from app.functions.firestore import get_collection_docs, is_doc_exist 5 | from app.functions.task import get_task_logs, get_task_result, set_task_annotations 6 | from app.models.firestore import Task, annot_cls_dict 7 | from app.models.log import ResponseAnnotationLogs 8 | from app.models.result import ResponseTaskResult 9 | from app.models.upload import RequestTaskUpload, ResponseTaskUpload 10 | from fastapi import APIRouter, HTTPException 11 | 12 | router = APIRouter() 13 | 14 | 15 | @router.get("/tasks") 16 | async def get_tasks(): 17 | return get_collection_docs("tasks") 18 | 19 | 20 | @router.post("/tasks", response_model=ResponseTaskUpload) 21 | async def annotations_upload(req: RequestTaskUpload): 22 | try: 23 | task = Task( 24 | id=req.task_id, 25 | annotation_type=req.annotation_type, 26 | title=req.title, 27 | question=req.question, 28 | description=req.description, 29 | ) 30 | annotations = [ 31 | annot_cls_dict[task.annotation_type](task_id=task.id, data=annot_data) 32 | for annot_data in req.annotations_data 33 | ] 34 | set_task_annotations(task, annotations) 35 | return ResponseTaskUpload( 36 | message="Success upload.", 37 | task_id=req.task_id, 38 | annotation_num=len(req.annotations_data), 39 | task_url=f"{APP_URL}/task/{req.task_id}", 40 | ) 41 | except ValueError as e: 42 | raise HTTPException(status_code=400, detail=str(e)) 43 | 44 | 45 | @router.get("/tasks/{task_id}", response_model=ResponseTaskResult) 46 | async def get_result(task_id: str): 47 | return ResponseTaskResult(**get_task_result(task_id)) 48 | 49 | 50 | @router.get("/tasks/{task_id}/logs", response_model=ResponseAnnotationLogs) 51 | async def get_logs(task_id: str): 52 | return ResponseAnnotationLogs(**get_task_logs(task_id)) 53 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/api/routes/api.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.routes import annotation 4 | 5 | router = APIRouter() 6 | 7 | router.include_router(annotation.router, tags=["annotation"]) 8 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/backend/cloud-run-api/app/config/__init__.py -------------------------------------------------------------------------------- /backend/cloud-run-api/app/config/annotation_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | APP_URL = os.environ["APP_URL"] 4 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/config/api_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | VERSION = os.environ["API_VERSION"] 4 | PROJECT_NAME = os.environ["API_PROJECT_NAME"] 5 | DEBUG = False 6 | APP_URL = os.environ["APP_URL"] 7 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/functions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/backend/cloud-run-api/app/functions/__init__.py -------------------------------------------------------------------------------- /backend/cloud-run-api/app/functions/demo.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import List 3 | 4 | from app.api.routes.annotation import annotations_upload 5 | from app.models.upload import RequestTaskUpload 6 | 7 | 8 | def set_demo_task() -> None: 9 | loop = asyncio.get_event_loop() 10 | loop.create_task(set_demo_multi_label_task()) 11 | loop.run_until_complete(set_demo_card_task()) 12 | 13 | 14 | async def set_demo_multi_label_task() -> None: 15 | annotations_data = [ 16 | { 17 | "text": f"This is a test{i}.", 18 | "choices": ["ChoiceA", "ChoiceB", "ChoiceC", "ChoiceD"], 19 | "baseline_text": "Baseline Text", 20 | "hidden_data": {"desc": "Data for aggregation. It can be a dictionary or a string."}, 21 | } 22 | for i in range(100) 23 | ] 24 | request = RequestTaskUpload( 25 | task_id="demo-multilabel", 26 | annotation_type="multi_label", 27 | title="Multi-Label Demo", 28 | question="This is multi-label demo", 29 | description="This is a multi-label demo, so feel free to annotate it as you wish.", 30 | annotations_data=annotations_data, 31 | ) 32 | await annotations_upload(request) 33 | 34 | 35 | async def set_demo_card_task() -> None: 36 | annotations_data = [ 37 | { 38 | "text": f"This is a test{i}.", 39 | "show_ambiguous_button": True, 40 | "hidden_data": {"desc": "Data for aggregation. It can be a dictionary or a string."}, 41 | } 42 | for i in range(100) 43 | ] 44 | request = RequestTaskUpload( 45 | task_id="demo-card", 46 | annotation_type="card", 47 | title="Card Demo", 48 | question="This is card demo", 49 | description="This is a card demo, so feel free to annotate it as you wish.", 50 | annotations_data=annotations_data, 51 | ) 52 | await annotations_upload(request) 53 | 54 | 55 | if __name__ == "__main__": 56 | set_demo_task() 57 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/functions/firebase.py: -------------------------------------------------------------------------------- 1 | import firebase_admin 2 | from firebase_admin import credentials 3 | from firebase_admin import storage 4 | from firebase_admin import firestore 5 | 6 | firebase_admin.initialize_app() 7 | db = firestore.client() 8 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/functions/firestore.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Dict, List, Union 3 | 4 | import pandas as pd 5 | from app.functions.firebase import db 6 | from app.utils.multi_thread import multi_thread_flat 7 | from google.cloud.firestore_v1.document import DocumentSnapshot 8 | from tqdm.auto import tqdm 9 | 10 | 11 | def snapshots_to_dicts(snapshots: List[DocumentSnapshot]) -> List[Dict[str, any]]: 12 | return [{**sp.to_dict(), **{"id": sp.id}} for sp in snapshots] 13 | 14 | 15 | def is_doc_exist(collection_name: str, docId: str) -> bool: 16 | return db.collection(collection_name).document(docId).get().exists 17 | 18 | 19 | def get_collection_docs(collection_name: str, limit=100) -> List[Dict[str, any]]: 20 | return snapshots_to_dicts(db.collection(collection_name).get()) 21 | 22 | 23 | def get_collection_docs_where_in( 24 | collection_name: str, field_name: str, parent_list: List[Union[str, int, float]] 25 | ): 26 | assert type(parent_list) == list 27 | 28 | def _get_docs_where_in(_chunks): 29 | return snapshots_to_dicts( 30 | db.collection(collection_name).where(field_name, "in", _chunks).get() 31 | ) 32 | 33 | parent_chunks = split_list(parent_list, 10) 34 | items = multi_thread_flat(parent_chunks, _get_docs_where_in, 20) 35 | return items 36 | 37 | 38 | def add_common_fields(_dict: Dict[str, any]): 39 | return { 40 | **_dict, 41 | "created_at": datetime.now(), 42 | "updated_at": datetime.now(), 43 | } 44 | 45 | 46 | def insert_dict(_dict: Dict[str, any], collection_name: str, document_id: str = None): 47 | _dict = add_common_fields(_dict) 48 | _ref = db.collection(collection_name).document(_dict.get("id", document_id)) 49 | if _dict.get("id", document_id) is None: 50 | _dict["id"] = _ref.id 51 | _ref.set(_dict) 52 | return _ref 53 | 54 | 55 | def insert_batch_dicts(dicts: List[Dict[str, any]], collection_name: str): 56 | batch = db.batch() 57 | chunk_size = 400 58 | chunks = list(split_list(dicts, chunk_size)) 59 | for _chunk in tqdm(chunks): 60 | for _dict in _chunk: 61 | _dict = add_common_fields(_dict) 62 | _ref = db.collection(collection_name).document(_dict.get("id")) 63 | if _dict.get("id") is None: 64 | _dict["id"] = _ref.id 65 | batch.set(_ref, _dict) 66 | _ = batch.commit() 67 | 68 | 69 | def update_doc(_dict, snapshot: DocumentSnapshot): 70 | return snapshot.reference.update({"updated_at": datetime.now(), **_dict}) 71 | 72 | 73 | def split_list(lst, n): 74 | """Yield successive n-sized chunks from lst.""" 75 | for i in range(0, len(lst), n): 76 | yield lst[i : i + n] 77 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/functions/task.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pandas as pd 4 | from app.functions.firebase import db 5 | from app.functions.firestore import ( 6 | get_collection_docs_where_in, 7 | insert_batch_dicts, 8 | insert_dict, 9 | snapshots_to_dicts, 10 | ) 11 | from app.models.firestore import Annotation, Task 12 | 13 | 14 | def set_task_annotations(task: Task, annotations: List[Annotation]) -> None: 15 | # create tasks 16 | task_ref = insert_dict(task.dict(), "tasks", task.id) 17 | print(f"insert task: {task_ref.id}") 18 | # create annotaions 19 | print(f"insert {len(annotations)} annotations") 20 | insert_batch_dicts([annot.dict() for annot in annotations], "annotations") 21 | 22 | 23 | def get_task_result(task_id: str): 24 | # get data 25 | task = snapshots_to_dicts(db.collection("tasks").where("id", "==", task_id).get())[0] 26 | users_tasks = snapshots_to_dicts( 27 | db.collection("users_tasks").where("task_id", "==", task_id).get() 28 | ) 29 | user_task_ids = [ut["id"] for ut in users_tasks] 30 | user_ids = [ut["user_id"] for ut in users_tasks] 31 | users = get_collection_docs_where_in("users", "id", user_ids) 32 | print("users", len(users)) 33 | users_annotations = get_collection_docs_where_in( 34 | "users_annotations", "user_task_id", user_task_ids 35 | ) 36 | print("users_annotations", len(users_annotations)) 37 | annotation_ids = list(set([uannot["annotation_id"] for uannot in users_annotations])) 38 | print("annotation_ids", len(annotation_ids)) 39 | annotations = get_collection_docs_where_in("annotations", "id", annotation_ids) 40 | # parse data 41 | df_users = pd.DataFrame(users)[["id", "name", "email"]].rename(columns={"id": "user_id"}) 42 | df_users_tasks = pd.DataFrame(users_tasks)[["id", "user_id"]].rename( 43 | columns={"id": "user_task_id"} 44 | ) 45 | df_users_annotations = pd.DataFrame(users_annotations) 46 | df_annotations = pd.DataFrame(annotations).rename(columns={"id": "annotation_id"})[ 47 | ["annotation_id", "data"] 48 | ] 49 | df = pd.merge(df_users_tasks, df_users, on="user_id").drop(columns=["user_id"]) 50 | df = pd.merge(df, df_users_annotations, on="user_task_id") 51 | df = pd.merge(df, df_annotations, on="annotation_id") 52 | df = df[ 53 | [ 54 | "id", 55 | "name", 56 | "email", 57 | "data", 58 | "result_data", 59 | "order_index", 60 | "user_id", 61 | "user_task_id", 62 | "annotation_id", 63 | "created_at", 64 | "updated_at", 65 | ] 66 | ] 67 | res_annotations = df.to_dict(orient="records") 68 | return {"task": task, "annotations": res_annotations} 69 | 70 | 71 | def get_task_logs(task_id): 72 | # get data 73 | user_tasks = snapshots_to_dicts( 74 | db.collection("users_tasks").where("task_id", "==", task_id).get() 75 | ) 76 | user_task_ids = [ut["id"] for ut in user_tasks] 77 | logs = get_collection_docs_where_in("user_annotations_logs", "user_task_id", user_task_ids) 78 | user_ids = [ut["user_id"] for ut in user_tasks] 79 | users = get_collection_docs_where_in("users", "id", user_ids) 80 | # parse data 81 | df_log = pd.DataFrame(logs) 82 | df_users = pd.DataFrame(users)[["id", "name", "email"]].rename(columns={"id": "user_id"}) 83 | df_users_tasks = pd.DataFrame(user_tasks)[["id", "user_id"]].rename( 84 | columns={"id": "user_task_id"} 85 | ) 86 | df = pd.merge(df_log, df_users_tasks, on="user_task_id") 87 | df = pd.merge(df, df_users, on="user_id").drop(columns=["user_id"]) 88 | df = df[ 89 | [ 90 | "id", 91 | "name", 92 | "email", 93 | "action_type", 94 | "action_data", 95 | "user_task_id", 96 | "user_annotation_id", 97 | "created_at", 98 | ] 99 | ] 100 | res_logs = df.to_dict(orient="records") 101 | return {"logs": res_logs} 102 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/functions/user.py: -------------------------------------------------------------------------------- 1 | from app.functions.firebase import db 2 | from app.functions.firestore import snapshots_to_dicts, update_doc 3 | from app.models.firestore import CollectionName, UserRoleEnum 4 | 5 | 6 | def set_user_role(email: str, role: UserRoleEnum): 7 | assert role in UserRoleEnum.values(), f"Select a role from {UserRoleEnum.values()}" 8 | user_docs = list(db.collection(CollectionName.users).where("email", "==", email).stream()) 9 | assert len(user_docs) > 0, f"User not found: {email}" 10 | assert len(user_docs) < 2, f"Multiple users found: {email}" 11 | user_doc = user_docs[0] 12 | _user_dict = snapshots_to_dicts(user_docs)[0] 13 | print(f"User found: {_user_dict}") 14 | print(f'change role: {_user_dict["role"]} -> {role}') 15 | update_doc({"role": role}, user_doc) 16 | 17 | 18 | if __name__ == "__main__": 19 | import fire 20 | 21 | fire.Fire({"set_user_role": set_user_role}) 22 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.exceptions import RequestValidationError 3 | from starlette.exceptions import HTTPException 4 | 5 | from app.api.errors.http_error import http_error_handler 6 | from app.api.errors.validation_error import http422_error_handler 7 | from app.api.routes.api import router as api_router 8 | from app.config.api_config import PROJECT_NAME, VERSION, DEBUG 9 | 10 | 11 | def get_app() -> FastAPI: 12 | app = FastAPI(title=PROJECT_NAME, version=VERSION, debug=DEBUG) 13 | 14 | app.add_exception_handler(HTTPException, http_error_handler) 15 | app.add_exception_handler(RequestValidationError, http422_error_handler) 16 | 17 | app.include_router(api_router) 18 | 19 | return app 20 | 21 | 22 | app = get_app() 23 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/backend/cloud-run-api/app/models/__init__.py -------------------------------------------------------------------------------- /backend/cloud-run-api/app/models/firestore.py: -------------------------------------------------------------------------------- 1 | """Data models: (see also: frontend/app/src/plugins/Schemas.tsx)""" 2 | from datetime import datetime 3 | from enum import Enum 4 | from typing import Any, Dict, Generic, List, Optional, TypeVar 5 | 6 | from google.cloud.firestore_v1.document import DocumentReference 7 | from pydantic import BaseModel, ValidationError, validator 8 | from pydantic.generics import GenericModel 9 | 10 | DataT = TypeVar("DataT") 11 | 12 | 13 | class StrEnum(str, Enum): 14 | @classmethod 15 | def values(cls): 16 | return list(map(lambda c: c.value, cls)) 17 | 18 | 19 | # NOTE: ==== FireStore Meta ==== 20 | 21 | 22 | class CollectionName(StrEnum): 23 | tasks = "tasks" 24 | users_tasks = "users_tasks" 25 | users = "users" 26 | annotations = "annotations" 27 | user_annotations_logs = "user_annotations_logs" 28 | users_annotations = "users_annotations" 29 | 30 | 31 | # NOTE: ==== FireStore Models ==== 32 | 33 | 34 | class UserRoleEnum(StrEnum): 35 | admin = "admin" 36 | annotator = "annotator" 37 | 38 | 39 | class AnnotationTypeEnum(StrEnum): 40 | card = "card" 41 | multi_label = "multi_label" 42 | 43 | 44 | class User(BaseModel): 45 | id: str 46 | name: str 47 | email: str 48 | photo_url: str 49 | role: UserRoleEnum 50 | created_at: datetime = datetime.now() 51 | updated_at: datetime = datetime.now() 52 | 53 | 54 | class Task(BaseModel): 55 | id: str 56 | annotation_type: AnnotationTypeEnum 57 | title: str 58 | question: str 59 | description: str 60 | created_at: datetime = datetime.now() 61 | updated_at: datetime = datetime.now() 62 | 63 | 64 | class UserTask(BaseModel): 65 | # user*task で固有 66 | id: str 67 | user_id: str 68 | task_id: str 69 | annotation_num: int 70 | submitted_num: int 71 | created_at: datetime = datetime.now() 72 | updated_at: datetime = datetime.now() 73 | 74 | 75 | class UserAnnotation(BaseModel): 76 | # user_task*annotation で固有 77 | id: str 78 | user_id: str 79 | order_index: int 80 | result_data: Optional[dict] 81 | annotation_id: str 82 | user_task_id: str 83 | created_at: datetime = datetime.now() 84 | updated_at: datetime = datetime.now() 85 | 86 | 87 | class Annotation(GenericModel, Generic[DataT]): 88 | id: Optional[str] 89 | task_id: str 90 | data: DataT 91 | created_at: datetime = datetime.now() 92 | updated_at: datetime = datetime.now() 93 | 94 | 95 | # NOTE: ==== Annotation Data ==== 96 | 97 | # --- Card --- 98 | 99 | 100 | class CardAnnotationData(BaseModel): 101 | text: str 102 | baseline_text: Optional[str] 103 | show_ambiguous_button: Optional[bool] = True 104 | question_overwrite: Optional[str] 105 | yes_button_label: Optional[str] 106 | no_button_label: Optional[str] 107 | hidden_data: Any 108 | 109 | 110 | # --- MultiLabel --- 111 | 112 | 113 | class MultiLabelAnnotationData(BaseModel): 114 | text: str 115 | choices: List[str] 116 | max_select_num: Optional[int] 117 | baseline_text: Optional[str] 118 | hidden_data: Any 119 | 120 | 121 | annot_cls_dict = { 122 | AnnotationTypeEnum.card: Annotation[CardAnnotationData], 123 | AnnotationTypeEnum.multi_label: Annotation[MultiLabelAnnotationData], 124 | } 125 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/models/log.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | from pydantic import BaseModel, ValidationError, root_validator, validator 3 | 4 | 5 | class AnnotationLog(BaseModel): 6 | id: str 7 | name: str 8 | email: str 9 | data: Any 10 | action_type: Any 11 | action_data: Any 12 | user_task_id: str 13 | user_annotation_id: str 14 | created_at: Any 15 | 16 | 17 | class ResponseAnnotationLogs(BaseModel): 18 | logs: List[AnnotationLog] 19 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/models/result.py: -------------------------------------------------------------------------------- 1 | from app.functions.firestore import is_doc_exist 2 | from app.models.firestore import Task 3 | from typing import Any, List 4 | from pydantic import BaseModel, ValidationError, root_validator, validator 5 | 6 | 7 | class AnnotationResult(BaseModel): 8 | id: str 9 | name: str 10 | email: str 11 | data: Any 12 | result_data: Any 13 | order_index: int 14 | user_id: str 15 | user_task_id: str 16 | annotation_id: str 17 | created_at: Any 18 | updated_at: Any 19 | 20 | 21 | class ResponseTaskResult(BaseModel): 22 | task: Task 23 | annotations: List[AnnotationResult] 24 | 25 | 26 | class RequestTaskResult(BaseModel): 27 | task_id: str 28 | 29 | @validator("task_id") 30 | def task_id_is_exist(cls, v): 31 | if not is_doc_exist("tasks", v): 32 | raise ValueError(f"task_id: {v} is not found.") 33 | return v 34 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/models/upload.py: -------------------------------------------------------------------------------- 1 | from app.functions.firestore import is_doc_exist 2 | from app.models.firestore import AnnotationTypeEnum 3 | from typing import Any, Dict, List 4 | from pydantic import BaseModel, ValidationError, root_validator, validator 5 | from app.models.firestore import annot_cls_dict 6 | 7 | 8 | class RequestTaskUpload(BaseModel): 9 | task_id: str 10 | annotation_type: AnnotationTypeEnum 11 | title: str 12 | question: str 13 | description: str 14 | annotations_data: List 15 | 16 | @validator("task_id") 17 | def task_id_is_unique(cls, v) -> str: 18 | if is_doc_exist("tasks", v): 19 | raise ValueError(f"task_id: {v} は既に存在します.") 20 | return v 21 | 22 | @validator("task_id") 23 | def task_id_not_contains_slash(cls, v) -> str: 24 | if "/" in v: 25 | raise ValueError('task_id に "/" を含めることはできません') 26 | return v 27 | 28 | 29 | class ResponseTaskUpload(BaseModel): 30 | message: str 31 | task_id: str 32 | annotation_num: int 33 | task_url: str 34 | -------------------------------------------------------------------------------- /backend/cloud-run-api/app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/backend/cloud-run-api/app/utils/__init__.py -------------------------------------------------------------------------------- /backend/cloud-run-api/app/utils/multi_thread.py: -------------------------------------------------------------------------------- 1 | from concurrent import futures 2 | from itertools import chain 3 | 4 | 5 | def multi_thread_flat(iters, func, max_workers=20): 6 | items = multi_thread(iters, func, max_workers) 7 | return list(chain.from_iterable(items)) 8 | 9 | 10 | def multi_thread(iters, func, max_workers=20): 11 | with futures.ThreadPoolExecutor(max_workers=max_workers) as executor: 12 | result_futures = executor.map(func, iters) 13 | return list(result_futures) 14 | -------------------------------------------------------------------------------- /backend/cloud-run-api/bin/exec.sh: -------------------------------------------------------------------------------- 1 | # 一時的にDockerコンテナを立ち上げ、内部でコマンドを実行します. 2 | # Temporarily launch a Docker container and run commands inside it. 3 | EXEC_CMD='' 4 | 5 | UPP_CMD='docker-compose run --rm api sh -c' 6 | DOCKER_CMD=${UPP_CMD} 7 | 8 | while [ "$1" != "" ] 9 | do 10 | EXEC_CMD="${EXEC_CMD} $1" 11 | shift 12 | done 13 | 14 | echo ${DOCKER_CMD} "${EXEC_CMD}" 15 | ${DOCKER_CMD} "${EXEC_CMD}" 16 | -------------------------------------------------------------------------------- /backend/cloud-run-api/cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | steps: 2 | - id: kaniko_build_push 3 | name: 'gcr.io/kaniko-project/executor:v1.6.0' 4 | args: 5 | - --destination=asia.gcr.io/${_PROJECT}/${_IMAGE} 6 | - --dockerfile=${_DOCKERFILE} 7 | - --context=. 8 | - --cache=true 9 | - --cache-ttl=6h 10 | - --insecure 11 | - id: deploy 12 | name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:slim' 13 | args: ['gcloud', 'beta', 'run', 'deploy', '${_SERVICE}', '--image', 'asia.gcr.io/${_PROJECT}/${_IMAGE}', 14 | '--region', '${_REGION}', '--platform', 'managed', '--memory', '2Gi', '--concurrency', '80', 15 | '--allow-unauthenticated', '--update-env-vars', 'API_VERSION=${_API_VERSION},API_PROJECT_NAME=${_API_PROJECT_NAME},APP_URL=${_APP_URL}'] 16 | timeout: 1800s 17 | substitutions: 18 | _DOCKERFILE: './docker/api/Dockerfile' 19 | _IMAGE: fast-annotation-tool-api 20 | _SERVICE: fast-annotation-tool-api 21 | _REGION: asia-northeast1 22 | _API_VERSION: 1.0.0 23 | _API_PROJECT_NAME: fast-annotation-tool-api 24 | # _APP_URL: https://******.web.app 25 | # _PROJECT: ****** 26 | options: 27 | substitution_option: "MUST_MATCH" 28 | -------------------------------------------------------------------------------- /backend/cloud-run-api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | jupyter: 4 | build: 5 | context: ./ 6 | dockerfile: ./docker/jupyter/Dockerfile 7 | working_dir: /root/user/work 8 | ports: 9 | - 8888:8888 10 | volumes: 11 | - ./app:/root/user/app 12 | - ./notebooks:/root/user/work 13 | - $HOME/.config/gcloud:/root/.config/gcloud 14 | command: sh -c "echo Run at http://localhost:8888/ && sh /run.sh" 15 | depends_on: 16 | - api 17 | env_file: .env 18 | api: 19 | build: 20 | context: ./ 21 | dockerfile: ./docker/api/Dockerfile 22 | ports: 23 | - 5000:5000 24 | volumes: 25 | - ./app:/root/app 26 | - $HOME/.config/gcloud:/root/.config/gcloud 27 | env_file: .env 28 | command: sh -c "echo Run at http://localhost:5000/ && exec uvicorn --port 5000 --host 0.0.0.0 app.main:app --reload" 29 | -------------------------------------------------------------------------------- /backend/cloud-run-api/docker/api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim 2 | WORKDIR /root 3 | RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends sudo curl && curl -sL https://firebase.tools | bash 4 | COPY ./docker/common/requirements.txt /tmp 5 | RUN pip install pip setuptools -U && pip install --upgrade pip \ 6 | && pip install -r /tmp/requirements.txt 7 | COPY ./app /root/app 8 | ENV PORT 5000 9 | EXPOSE 5000 10 | ENV PYTHONPATH ${PYTHONPATH}:/root 11 | CMD exec uvicorn --port $PORT --host 0.0.0.0 app.main:app 12 | -------------------------------------------------------------------------------- /backend/cloud-run-api/docker/common/requirements.txt: -------------------------------------------------------------------------------- 1 | pandas~=1.1.5 2 | matplotlib~=3.2.2 3 | google-api-python-client~=1.7 4 | google-auth-httplib2==0.0.4 5 | google-auth-oauthlib==0.4.2 6 | hurry==1.1 7 | hurry.filesize==0.9 8 | firebase-admin==4.4.0 9 | grpcio~=1.37.1 10 | google-cloud-firestore==1.7.0 11 | tqdm~=4.41 12 | fastapi==0.60.1 13 | pydantic==1.7.3 14 | uvicorn==0.13.4 15 | starlette==0.13.6 16 | fire==0.4.0 -------------------------------------------------------------------------------- /backend/cloud-run-api/docker/jupyter/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM syunyooo/jupyter-notebook 2 | 3 | RUN curl -sL https://firebase.tools | bash 4 | 5 | COPY ./docker/common/requirements.txt /tmp 6 | RUN pip install pip setuptools -U && pip install --upgrade pip \ 7 | && pip install -r /tmp/requirements.txt 8 | 9 | ENV PYTHONPATH ${PYTHONPATH}:/root -------------------------------------------------------------------------------- /docs/imgs/db_latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/db_latest.png -------------------------------------------------------------------------------- /docs/imgs/db_v1.0.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/db_v1.0.0.png -------------------------------------------------------------------------------- /docs/imgs/db_v1.0.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/db_v1.0.1.png -------------------------------------------------------------------------------- /docs/imgs/db_v1.0.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/db_v1.0.2.png -------------------------------------------------------------------------------- /docs/imgs/db_v1.0.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/db_v1.0.3.png -------------------------------------------------------------------------------- /docs/imgs/db_v1.0.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/db_v1.0.4.png -------------------------------------------------------------------------------- /docs/imgs/front_admin_latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/front_admin_latest.png -------------------------------------------------------------------------------- /docs/imgs/front_admin_v1.0.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/front_admin_v1.0.0.png -------------------------------------------------------------------------------- /docs/imgs/front_annotator_latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/front_annotator_latest.png -------------------------------------------------------------------------------- /docs/imgs/front_annotator_v1.0.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/front_annotator_v1.0.0.png -------------------------------------------------------------------------------- /docs/imgs/front_db_latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/front_db_latest.png -------------------------------------------------------------------------------- /docs/imgs/front_db_v1.0.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/front_db_v1.0.0.png -------------------------------------------------------------------------------- /docs/imgs/infra_latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/infra_latest.png -------------------------------------------------------------------------------- /docs/imgs/infra_v1.0.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/infra_v1.0.0.png -------------------------------------------------------------------------------- /docs/imgs/infra_v1.0.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/infra_v1.0.1.png -------------------------------------------------------------------------------- /docs/imgs/infra_v1.0.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/infra_v1.0.2.png -------------------------------------------------------------------------------- /docs/imgs/logo_latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/logo_latest.png -------------------------------------------------------------------------------- /docs/imgs/main_latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/main_latest.png -------------------------------------------------------------------------------- /docs/imgs/mock_latest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/mock_latest.png -------------------------------------------------------------------------------- /docs/imgs/mock_v1.0.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/docs/imgs/mock_v1.0.0.png -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Items that don't need to be in a Docker image. 2 | # Anything not used by the build system should go here. 3 | Dockerfile 4 | .dockerignore 5 | .gitignore 6 | .git 7 | README.md 8 | 9 | # Artifacts that will be built during image creation. 10 | # This should contain all files created during `npm run build`. 11 | **/dist 12 | **/node_modules -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_DEV_PORT=8080 2 | REACT_APP_PRD_PORT=3000 3 | FIREBASE_TOKEN=***** 4 | REACT_APP_FIREBASE_API_KEY=***** 5 | REACT_APP_FIREBASE_AUTH_DOMAIN=*****.firebaseapp.com 6 | REACT_APP_FIREBASE_PROJECT_ID=***** 7 | REACT_APP_FIREBASE_MESSAGING_SENDER_ID=***** 8 | REACT_APP_FIREBASE_APP_ID=***** 9 | REACT_APP_MEASUREMENT_ID=***** 10 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | 9 | # Firebase cache 10 | .firebase/ 11 | 12 | # Firebase config 13 | 14 | # Uncomment this if you'd like others to create their own Firebase project. 15 | # For a team working on the same Firebase project(s), it is recommended to leave 16 | # it commented so all members can deploy to the same project(s) in .firebaserc. 17 | # .firebaserc 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (http://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:15.0.0-alpine 2 | RUN npm install -g firebase-tools node-gyp node-pre-gyp node-gyp-build webpack-dev-server 3 | RUN apk update && apk upgrade && \ 4 | apk add --no-cache git build-base \ 5 | g++ \ 6 | cairo-dev \ 7 | jpeg-dev \ 8 | pango-dev \ 9 | giflib-dev \ 10 | python \ 11 | autoconf \ 12 | automake 13 | WORKDIR /usr/src/app 14 | -------------------------------------------------------------------------------- /frontend/Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | .PHONY: init 4 | init: ## Install packages 5 | rm -rf app/node_modules 6 | . bin/exec.sh "yarn install" 7 | 8 | .PHONY: up 9 | up: ## Up app in docker compose 10 | docker-compose up --build 11 | 12 | .PHONY: deploy 13 | deploy: ## Deploy to Firebase Hosting 14 | . bin/exec.sh "yarn deploy" 15 | 16 | .PHONY: export_index 17 | export_index: ## Export Firestore Index to Local 18 | . bin/exec.sh "firebase firestore:indexes > firestore.indexes.json" 19 | 20 | .PHONY: help 21 | help: ## Display this help screen 22 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 23 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Front 2 | 3 | ## Architecture 4 | 5 | ![infra](../docs/imgs/infra_latest.png) 6 | 7 | ## Quick Start 8 | 9 | See [here](../../../wiki/Quick-Start#4-frontend). 10 | 11 | 12 | 13 | ## Dev Commands 14 | 15 | #### Initialization 16 | 17 | ``` 18 | make init 19 | ``` 20 | 21 | #### Start up the Local App server 22 | 23 | ``` 24 | make up 25 | ``` 26 | 27 | #### Deploy 28 | 29 | ``` 30 | make deploy 31 | ``` 32 | 33 | #### Install package 34 | 35 | ``` 36 | . bin/exec.sh yarn add {package} 37 | ``` 38 | 39 | #### Update package 40 | 41 | ``` 42 | . bin/exec.sh yarn upgrade {package} 43 | ``` 44 | 45 | #### Firebase Index Export 46 | 47 | ```shell 48 | make export_index 49 | ``` 50 | 51 | -------------------------------------------------------------------------------- /frontend/app/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", {"modules": false}], 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "react-hot-loader/babel" 8 | ], 9 | "env": { 10 | "production": { 11 | "presets": ["minify"] 12 | }, 13 | "test": { 14 | "presets": ["@babel/preset-env", "@babel/preset-react"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:prettier/recommended", 7 | "prettier/@typescript-eslint" 8 | ], 9 | "plugins": [ 10 | "@typescript-eslint", 11 | "eslint-plugin-react" 12 | ], 13 | "env": { 14 | "node": true, 15 | "es6": true 16 | }, 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "sourceType": "module", 20 | "project": "./tsconfig.json" 21 | }, 22 | "rules": { 23 | "react/jsx-uses-vars": 1, 24 | "react/jsx-uses-react": [1], 25 | "@typescript-eslint/no-var-requires": 0, 26 | "@typescript-eslint/camelcase": ["warn", {"properties": "never"}] 27 | }, 28 | "settings": { 29 | "import/resolver": "webpack" 30 | } 31 | } -------------------------------------------------------------------------------- /frontend/app/.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/app/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | node_modules/ 4 | src/**/*.jsx 5 | tests/__coverage__/ 6 | tests/**/*.jsx 7 | -------------------------------------------------------------------------------- /frontend/app/README.md: -------------------------------------------------------------------------------- 1 | # React Webpack Typescript Starter 2 | > Minimal starter with hot module replacement (HMR) for rapid development. 3 | 4 | * **[React](https://facebook.github.io/react/)** (16.x) 5 | * **[Webpack](https://webpack.js.org/)** (4.x) 6 | * **[Typescript](https://www.typescriptlang.org/)** (3.x) 7 | * **[Hot Module Replacement (HMR)](https://webpack.js.org/concepts/hot-module-replacement/)** using [React Hot Loader](https://github.com/gaearon/react-hot-loader) (4.x) 8 | * [Babel](http://babeljs.io/) (7.x) 9 | * [SASS](http://sass-lang.com/) 10 | * [Jest](https://facebook.github.io/jest/) - Testing framework for React applications 11 | * Production build script 12 | * Image loading/minification using [Image Webpack Loader](https://github.com/tcoopman/image-webpack-loader) 13 | * Typescript compiling using [Awesome Typescript Loader](https://github.com/s-panferov/awesome-typescript-loader) (5.x) 14 | * Code quality (linting) for Typescript. 15 | 16 | ## Installation 17 | 1. Clone/download repo 18 | 2. `yarn install` (or `npm install` for npm) 19 | 20 | ## Usage 21 | **Development** 22 | 23 | `yarn run start-dev` 24 | 25 | * Build app continuously (HMR enabled) 26 | * App served @ `http://localhost:8080` 27 | 28 | **Production** 29 | 30 | `yarn run start-prod` 31 | 32 | * Build app once (HMR disabled) to `/dist/` 33 | * App served @ `http://localhost:3000` 34 | 35 | --- 36 | 37 | **All commands** 38 | 39 | Command | Description 40 | --- | --- 41 | `yarn run start-dev` | Build app continuously (HMR enabled) and serve @ `http://localhost:8080` 42 | `yarn run start-prod` | Build app once (HMR disabled) to `/dist/` and serve @ `http://localhost:3000` 43 | `yarn run build` | Build app to `/dist/` 44 | `yarn run test` | Run tests 45 | `yarn run lint` | Run Typescript linter 46 | `yarn run lint --fix` | Run Typescript linter and fix issues 47 | `yarn run start` | (alias of `yarn run start-dev`) 48 | 49 | **Note**: replace `yarn` with `npm` in `package.json` if you use npm. 50 | 51 | ## See also 52 | * [React Webpack Babel Starter](https://github.com/vikpe/react-webpack-babel-starter) 53 | * [Isomorphic Webapp Starter](https://github.com/vikpe/isomorphic-webapp-starter) 54 | -------------------------------------------------------------------------------- /frontend/app/configs/jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": "..", 3 | "coverageDirectory": "/tests/__coverage__/", 4 | "setupFiles": [ 5 | "/tests/__mocks__/shim.js" 6 | ], 7 | "roots": [ 8 | "/src/", 9 | "/tests/" 10 | ], 11 | "moduleNameMapper": { 12 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/tests/__mocks__/fileMock.js", 13 | "\\.(css|scss|less)$": "/tests/__mocks__/styleMock.js" 14 | }, 15 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx"], 16 | "transform": { 17 | "^.+\\.(ts|tsx)$": "/configs/jest.preprocessor.js" 18 | }, 19 | "transformIgnorePatterns": [ 20 | "/node_modules/" 21 | ], 22 | "testRegex": "/tests/.*\\.(ts|tsx)$", 23 | "moduleDirectories": [ 24 | "node_modules" 25 | ], 26 | "globals": { 27 | "DEVELOPMENT": false, 28 | "FAKE_SERVER": false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend/app/configs/jest.preprocessor.js: -------------------------------------------------------------------------------- 1 | const tsc = require('typescript'); 2 | const tsConfig = require('./../tsconfig.json'); 3 | 4 | module.exports = { 5 | process(src, path) { 6 | const isTs = path.endsWith('.ts'); 7 | const isTsx = path.endsWith('.tsx'); 8 | const isTypescriptFile = (isTs || isTsx); 9 | 10 | if ( isTypescriptFile ) { 11 | return tsc.transpileModule( 12 | src, 13 | { 14 | compilerOptions: tsConfig.compilerOptions, 15 | fileName: path 16 | } 17 | ).outputText; 18 | } 19 | 20 | return src; 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/app/configs/webpack/common.js: -------------------------------------------------------------------------------- 1 | // shared config (dev and prod) 2 | const { resolve } = require('path'); 3 | const { CheckerPlugin } = require('awesome-typescript-loader'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const webpack = require('webpack'); 6 | const copyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = { 9 | resolve: { 10 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 11 | }, 12 | context: resolve(__dirname, '../../src'), 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.js$/, 17 | use: ['babel-loader', 'source-map-loader'], 18 | exclude: /node_modules/, 19 | }, 20 | { 21 | test: /\.tsx?$/, 22 | use: ['babel-loader', 'awesome-typescript-loader'], 23 | }, 24 | { 25 | test: /\.css$/, 26 | use: ['style-loader', { loader: 'css-loader', options: { importLoaders: 1 } }], 27 | }, 28 | { 29 | test: /\.(scss|sass)$/, 30 | loaders: [ 31 | 'style-loader', 32 | { loader: 'css-loader', options: { importLoaders: 1 } }, 33 | 'sass-loader', 34 | ], 35 | }, 36 | { 37 | test: /\.(jpe?g|png|gif|svg)$/i, 38 | loaders: [ 39 | 'file-loader?hash=sha512&digest=hex&name=img/[hash].[ext]', 40 | 'image-webpack-loader?bypassOnDebug&optipng.optimizationLevel=7&gifsicle.interlaced=false', 41 | ], 42 | },{ 43 | test: /\.js$/, 44 | exclude: /node_modules[/\\](?!react-data-grid[/\\]lib)/, 45 | use: 'babel-loader' 46 | } 47 | ], 48 | }, 49 | plugins: [ 50 | new CheckerPlugin(), 51 | new HtmlWebpackPlugin({ template: 'index.html.ejs', }), 52 | new copyWebpackPlugin({ 53 | patterns: [{ 54 | from: 'assets/favicon', 55 | to: './' 56 | }] 57 | }), 58 | new webpack.DefinePlugin({ 59 | 'process.env': { 60 | REACT_APP_FIREBASE_API_KEY: JSON.stringify(process.env.REACT_APP_FIREBASE_API_KEY), 61 | REACT_APP_FIREBASE_AUTH_DOMAIN: JSON.stringify(process.env.REACT_APP_FIREBASE_AUTH_DOMAIN), 62 | REACT_APP_FIREBASE_PROJECT_ID: JSON.stringify(process.env.REACT_APP_FIREBASE_PROJECT_ID), 63 | REACT_APP_FIREBASE_MESSAGING_SENDER_ID: JSON.stringify(process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID), 64 | REACT_APP_FIREBASE_APP_ID: JSON.stringify(process.env.REACT_APP_FIREBASE_APP_ID), 65 | REACT_APP_MEASUREMENT_ID: JSON.stringify(process.env.REACT_APP_MEASUREMENT_ID), 66 | } 67 | }) 68 | ], 69 | externals: { 70 | 'react': 'React', 71 | 'react-dom': 'ReactDOM', 72 | }, 73 | performance: { 74 | hints: false, 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /frontend/app/configs/webpack/dev.js: -------------------------------------------------------------------------------- 1 | // development config 2 | const merge = require('webpack-merge'); 3 | const webpack = require('webpack'); 4 | const commonConfig = require('./common'); 5 | 6 | module.exports = merge(commonConfig, { 7 | mode: 'development', 8 | entry: [ 9 | 'react-hot-loader/patch', // activate HMR for React 10 | `webpack-dev-server/client?http://localhost:${process.env.REACT_APP_DEV_PORT}`,// bundle the client for webpack-dev-server and connect to the provided endpoint 11 | 'webpack/hot/only-dev-server', // bundle the client for hot reloading, only- means to only hot reload for successful updates 12 | './index.tsx' // the entry point of our app 13 | ], 14 | devServer: { 15 | hot: true, // enable HMR on the server 16 | host: '0.0.0.0', 17 | disableHostCheck: true, 18 | historyApiFallback: true, 19 | publicPath: '/', 20 | }, 21 | output: { 22 | publicPath: '/', 23 | }, 24 | devtool: 'cheap-module-eval-source-map', 25 | plugins: [ 26 | new webpack.HotModuleReplacementPlugin(), // enable HMR globally 27 | new webpack.DefinePlugin({ 28 | 'process.env': { 29 | NODE_ENV: JSON.stringify('development'), 30 | REACT_APP_TITLE: JSON.stringify('Local FAST'), 31 | } 32 | }), 33 | ], 34 | }); 35 | -------------------------------------------------------------------------------- /frontend/app/configs/webpack/prod.js: -------------------------------------------------------------------------------- 1 | // production config 2 | const merge = require('webpack-merge'); 3 | const webpack = require('webpack'); 4 | const {resolve} = require('path'); 5 | 6 | const commonConfig = require('./common'); 7 | 8 | module.exports = merge(commonConfig, { 9 | mode: 'production', 10 | entry: './index.tsx', 11 | output: { 12 | filename: 'js/bundle.[hash].min.js', 13 | path: resolve(__dirname, '../../dist'), 14 | publicPath: '/', 15 | }, 16 | devtool: 'source-map', 17 | plugins: [ 18 | new webpack.DefinePlugin({ 19 | 'process.env': { 20 | NODE_ENV: JSON.stringify('production'), 21 | REACT_APP_TITLE: JSON.stringify('FAST'), 22 | } 23 | }), 24 | ], 25 | }); 26 | -------------------------------------------------------------------------------- /frontend/app/express.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const portNumber = process.env.REACT_APP_PRD_PORT; 4 | const sourceDir = 'dist'; 5 | 6 | app.use(express.static(sourceDir)); 7 | 8 | // https://dev-daikichi.hatenablog.com/entry/2019/04/17/144159 9 | app.get('/*', (req, res) => { 10 | res.sendFile(__dirname + '/' + sourceDir + '/index.html'); 11 | }); 12 | 13 | app.listen(portNumber, () => { 14 | console.log(`Express web server started: http://localhost:${portNumber}`); 15 | console.log(`Serving content from /${sourceDir}/`); 16 | }); 17 | -------------------------------------------------------------------------------- /frontend/app/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "hosting": { 7 | "public": "dist", 8 | "ignore": [ 9 | "firebase.json", 10 | "**/.*", 11 | "**/node_modules/**" 12 | ], 13 | "rewrites": [ 14 | { 15 | "source": "**", 16 | "destination": "/index.html" 17 | } 18 | ] 19 | }, 20 | "emulators": { 21 | "hosting": { 22 | "port": 10080 23 | }, 24 | "firestore": { 25 | "port": 10081 26 | }, 27 | "ui": { 28 | "enabled": true 29 | }, 30 | "auth": { 31 | "port": 9099 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /frontend/app/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "annotations", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "task_id", 9 | "order": "ASCENDING" 10 | }, 11 | { 12 | "fieldPath": "created_at", 13 | "order": "ASCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "annotations", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "task_id", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "updated_at", 27 | "order": "ASCENDING" 28 | } 29 | ] 30 | }, 31 | { 32 | "collectionGroup": "users_annotations", 33 | "queryScope": "COLLECTION", 34 | "fields": [ 35 | { 36 | "fieldPath": "result_data", 37 | "order": "ASCENDING" 38 | }, 39 | { 40 | "fieldPath": "user_task_id", 41 | "order": "ASCENDING" 42 | }, 43 | { 44 | "fieldPath": "order_index", 45 | "order": "ASCENDING" 46 | } 47 | ] 48 | }, 49 | { 50 | "collectionGroup": "users_annotations", 51 | "queryScope": "COLLECTION", 52 | "fields": [ 53 | { 54 | "fieldPath": "user_id", 55 | "order": "ASCENDING" 56 | }, 57 | { 58 | "fieldPath": "order_index", 59 | "order": "ASCENDING" 60 | } 61 | ] 62 | }, 63 | { 64 | "collectionGroup": "users_annotations", 65 | "queryScope": "COLLECTION", 66 | "fields": [ 67 | { 68 | "fieldPath": "user_task_id", 69 | "order": "ASCENDING" 70 | }, 71 | { 72 | "fieldPath": "order_index", 73 | "order": "ASCENDING" 74 | } 75 | ] 76 | }, 77 | { 78 | "collectionGroup": "users_annotations", 79 | "queryScope": "COLLECTION", 80 | "fields": [ 81 | { 82 | "fieldPath": "user_task_id", 83 | "order": "ASCENDING" 84 | }, 85 | { 86 | "fieldPath": "order_index", 87 | "order": "DESCENDING" 88 | } 89 | ] 90 | }, 91 | { 92 | "collectionGroup": "users_annotations", 93 | "queryScope": "COLLECTION", 94 | "fields": [ 95 | { 96 | "fieldPath": "user_task_id", 97 | "order": "ASCENDING" 98 | }, 99 | { 100 | "fieldPath": "result_data", 101 | "order": "ASCENDING" 102 | } 103 | ] 104 | }, 105 | { 106 | "collectionGroup": "users_annotations", 107 | "queryScope": "COLLECTION", 108 | "fields": [ 109 | { 110 | "fieldPath": "user_task_id", 111 | "order": "ASCENDING" 112 | }, 113 | { 114 | "fieldPath": "result_data", 115 | "order": "DESCENDING" 116 | }, 117 | { 118 | "fieldPath": "updated_at", 119 | "order": "DESCENDING" 120 | } 121 | ] 122 | }, 123 | { 124 | "collectionGroup": "users_annotations", 125 | "queryScope": "COLLECTION", 126 | "fields": [ 127 | { 128 | "fieldPath": "user_task_id", 129 | "order": "ASCENDING" 130 | }, 131 | { 132 | "fieldPath": "updated_at", 133 | "order": "ASCENDING" 134 | } 135 | ] 136 | }, 137 | { 138 | "collectionGroup": "users_tasks", 139 | "queryScope": "COLLECTION", 140 | "fields": [ 141 | { 142 | "fieldPath": "task_id", 143 | "order": "ASCENDING" 144 | }, 145 | { 146 | "fieldPath": "created_at", 147 | "order": "DESCENDING" 148 | } 149 | ] 150 | }, 151 | { 152 | "collectionGroup": "users_tasks", 153 | "queryScope": "COLLECTION", 154 | "fields": [ 155 | { 156 | "fieldPath": "user_id", 157 | "order": "ASCENDING" 158 | }, 159 | { 160 | "fieldPath": "created_at", 161 | "order": "DESCENDING" 162 | } 163 | ] 164 | } 165 | ], 166 | "fieldOverrides": [] 167 | } 168 | -------------------------------------------------------------------------------- /frontend/app/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /{document=**} { 5 | allow read, write: if request.auth.uid != null; 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /frontend/app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "description": "React App", 5 | "license": "UNLICENSED", 6 | "private": true, 7 | "homepage": ".", 8 | "keywords": [ 9 | "react", 10 | "webpack", 11 | "typescript", 12 | "babel", 13 | "sass", 14 | "hmr", 15 | "starter", 16 | "boilerplate" 17 | ], 18 | "scripts": { 19 | "build": "yarn run clean-dist && webpack -p --config=configs/webpack/prod.js", 20 | "clean-dist": "rimraf dist/*", 21 | "deploy": "yarn run build && firebase deploy --token $FIREBASE_TOKEN", 22 | "lint": "eslint './src/**/*.ts*'", 23 | "lint:fix": "eslint --fix './src/**/*.ts*'", 24 | "start": "yarn run start-dev", 25 | "start-dev": "webpack-dev-server --config=configs/webpack/dev.js", 26 | "start-prod": "yarn run build && node express.js", 27 | "test": "jest --coverage --watchAll --config=configs/jest.json" 28 | }, 29 | "devDependencies": { 30 | "@babel/cli": "^7.13.10", 31 | "@babel/core": "^7.9.0", 32 | "@babel/preset-env": "^7.9.0", 33 | "@babel/preset-react": "^7.9.4", 34 | "@types/jest": "^25.2.1", 35 | "@types/node": "^13.11.0", 36 | "@types/react": "^16.9.32", 37 | "@types/react-dom": "^16.9.6", 38 | "@typescript-eslint/eslint-plugin": "^2.31.0", 39 | "@typescript-eslint/parser": "^2.31.0", 40 | "awesome-typescript-loader": "^5.2.1", 41 | "babel-loader": "^8.1.0", 42 | "css-loader": "^3.4.2", 43 | "eslint": "^6.8.0", 44 | "eslint-config-prettier": "^6.11.0", 45 | "eslint-import-resolver-webpack": "^0.12.1", 46 | "eslint-plugin-prettier": "^3.1.3", 47 | "eslint-plugin-react": "^7.19.0", 48 | "express": "^4.17.1", 49 | "file-loader": "^6.0.0", 50 | "html-webpack-plugin": "^4.0.4", 51 | "image-webpack-loader": "^6.0.0", 52 | "jest": "^25.2.7", 53 | "node-sass": "^4.13.1", 54 | "prettier": "^2.0.5", 55 | "react": "^16.13.1", 56 | "react-dom": "^16.13.1", 57 | "react-hot-loader": "^4.12.20", 58 | "rimraf": "^3.0.2", 59 | "sass-loader": "^8.0.2", 60 | "style-loader": "^1.1.3", 61 | "typescript": "^3.8.3", 62 | "uglifyjs-webpack-plugin": "^1.1.2", 63 | "webpack": "^4.42.1", 64 | "webpack-cli": "^3.3.11", 65 | "webpack-dev-middleware": "^3.7.2", 66 | "webpack-dev-server": "^3.11.0", 67 | "webpack-merge": "^4.2.2" 68 | }, 69 | "dependencies": { 70 | "@material-ui/core": "^4.11.0", 71 | "@material-ui/data-grid": "^4.0.0-alpha.6", 72 | "@material-ui/icons": "^4.9.1", 73 | "@material-ui/lab": "^4.0.0-alpha.56", 74 | "bufferutil": "^4.0.1", 75 | "canvas": "^2.6.1", 76 | "copy-webpack-plugin": "^6.2.1", 77 | "eslint-plugin-import": "^2.22.0", 78 | "fibers": "^5.0.0", 79 | "firebase": "^7.24.0", 80 | "moment": "^2.29.1", 81 | "notistack": "^0.9.11", 82 | "react-data-grid": "^7.0.0-canary.15", 83 | "react-device-detect": "^1.15.0", 84 | "react-firebase-hooks": "^2.2.0", 85 | "react-router-dom": "^5.1.2", 86 | "react-swipeable-views": "^0.13.9", 87 | "react-tinder-card": "^1.4.0", 88 | "sass": "^1.26.11", 89 | "stackdriver-errors-js": "^0.8.0", 90 | "utf-8-validate": "^5.0.2" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /frontend/app/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | overflow: hidden; 3 | } -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/favicon/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/favicon/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/favicon/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/favicon/favicon.ico -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /frontend/app/src/assets/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/favicons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "theme_color": "#ffffff", 12 | "background_color": "#ffffff", 13 | "display": "standalone" 14 | } 15 | -------------------------------------------------------------------------------- /frontend/app/src/assets/img/check_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/check_mobile.png -------------------------------------------------------------------------------- /frontend/app/src/assets/img/checked.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/checked.png -------------------------------------------------------------------------------- /frontend/app/src/assets/img/front_nav_lady_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/front_nav_lady_b.png -------------------------------------------------------------------------------- /frontend/app/src/assets/img/front_nav_lady_g.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/front_nav_lady_g.png -------------------------------------------------------------------------------- /frontend/app/src/assets/img/good_hand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/good_hand.png -------------------------------------------------------------------------------- /frontend/app/src/assets/img/hot_tea_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/hot_tea_b.png -------------------------------------------------------------------------------- /frontend/app/src/assets/img/hot_tea_g.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/hot_tea_g.png -------------------------------------------------------------------------------- /frontend/app/src/assets/img/react_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/app/src/assets/img/thanks_lady.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/thanks_lady.png -------------------------------------------------------------------------------- /frontend/app/src/assets/img/thanks_lady_stand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CyberAgent/fast-annotation-tool/121ce65a4201c431c247e0f39e7a4571149dbd26/frontend/app/src/assets/img/thanks_lady_stand.png -------------------------------------------------------------------------------- /frontend/app/src/assets/scss/App.scss: -------------------------------------------------------------------------------- 1 | $bg-color: yellow; 2 | $border-color: red; 3 | 4 | .app { 5 | font-family: helvetica, arial, sans-serif; 6 | padding: 2em; 7 | border: 5px solid $border-color; 8 | 9 | p { 10 | background-color: $bg-color; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/app/src/index.html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%= process.env.REACT_APP_TITLE %> 14 | 15 | 16 |
17 | 18 | 19 | <% if (webpackConfig.mode == 'production') { %> 20 | 21 | 22 | <% } else { %> 23 | 24 | 25 | <% } %> 26 | 27 | 28 | -------------------------------------------------------------------------------- /frontend/app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "react-dom"; 2 | import { 3 | BrowserRouter, 4 | Route, 5 | Switch, 6 | Redirect, 7 | RouteComponentProps, 8 | } from "react-router-dom"; 9 | import LoginSignupPage from "./pages/Auth/LoginSignupPage"; 10 | import * as React from "react"; 11 | import { SnackbarProvider } from "notistack"; 12 | import firebase from "./plugins/firebase"; 13 | import { useAuthState } from "react-firebase-hooks/auth"; 14 | import HomePage from "./pages/Home/HomePage"; 15 | import AnnotationPage from "./pages/Annotation/AnnotationPage"; 16 | import TasksPage from "./pages/Task/TasksPage"; 17 | import TaskDetailPage from "./pages/Task/TaskDetailPage"; 18 | import "./App.css"; 19 | import "./plugins/Viewport"; 20 | import { LinearProgress } from "@material-ui/core"; 21 | import LoadingPage from "./pages/Common/LoadingPage"; 22 | import SignupThanksPage from "./pages/Auth/SignupThanksPage"; 23 | import AnnotationThanksPage from "./pages/Annotation/AnnotationThanksPage"; 24 | import DataController from "./plugins/DataController"; 25 | import { UserRole } from "./plugins/Schemas"; 26 | import useDBUserStatus from "./plugins/useDBUserStatus"; 27 | import { useSnackbar } from "notistack"; 28 | 29 | // eslint-disable-next-line @typescript-eslint/no-empty-function 30 | process.env.NODE_ENV !== "development" && (console.log = (): void => {}); 31 | 32 | const rootEl = document.getElementById("root"); 33 | 34 | const RedirectRoute: React.FC = ({ 35 | pathname, 36 | ...rest 37 | }) => { 38 | return ( 39 | ( 42 | 48 | )} 49 | /> 50 | ); 51 | }; 52 | 53 | const RoleRoute: React.FC = ({ 54 | component: Component, 55 | validRole: validRole, 56 | ...rest 57 | }) => { 58 | const [user, loading] = useDBUserStatus(); 59 | const { enqueueSnackbar } = useSnackbar(); 60 | if (loading) { 61 | return ; 62 | } 63 | console.log(`RoleRoute(${validRole})`, user); 64 | if (user && user.role == validRole) { 65 | return ( 66 | } 69 | /> 70 | ); 71 | } else { 72 | enqueueSnackbar( 73 | `${rest.path} は ${user && user.email} には許可されていないURLです.`, 74 | { 75 | variant: "error", 76 | } 77 | ); 78 | return ; 79 | } 80 | }; 81 | 82 | const PrivateRoute: React.FC = ({ 83 | component: Component, 84 | ...rest 85 | }) => { 86 | const [user, loading] = useAuthState(firebase.auth()); 87 | console.log(user ? "login success" : "login failed"); 88 | if (loading) { 89 | return ; 90 | } 91 | if (user) { 92 | return ( 93 | } 96 | /> 97 | ); 98 | } else { 99 | return ; 100 | } 101 | }; 102 | 103 | render( 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | ( 115 | 122 | )} 123 | /> 124 | 125 | 126 | 131 | 136 | 142 | 148 | 149 | 150 | , 151 | rootEl 152 | ); 153 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Annotation/AnnotationContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { 3 | Task, 4 | CardChoice, 5 | UserAnnotationSet, 6 | MultiLabelSelect, 7 | ActionType, 8 | UserSelect, 9 | } from "../../plugins/Schemas"; 10 | 11 | interface AnnotationFnProps { 12 | answer: ( 13 | userAnnotationSet: UserAnnotationSet, 14 | nextUserAnnotationSet?: UserAnnotationSet 15 | ) => (input: UserSelect) => Promise; 16 | answerCurrent: (input: UserSelect) => () => Promise; 17 | goBack: () => void; 18 | } 19 | 20 | interface UserAnnotationInfoProps { 21 | currentUserAnnotationSet: UserAnnotationSet; 22 | currentOrderIndex: number; 23 | task: Task; 24 | userAnnotationSets: UserAnnotationSet[]; 25 | postLog: (actionType: ActionType, actionData: any) => void; 26 | } 27 | 28 | export const annotationFnContext = createContext({} as AnnotationFnProps); 29 | 30 | export const userAnnotationInfoContext = createContext( 31 | {} as UserAnnotationInfoProps 32 | ); 33 | 34 | export interface AnswerAreaHandler { 35 | onKeyPress: (e: KeyboardEvent) => void; 36 | } 37 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Annotation/AnnotationPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, ReactNode } from "react"; 2 | import CssBaseline from "@material-ui/core/CssBaseline"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { useAuthState } from "react-firebase-hooks/auth"; 5 | import dataController from "../../plugins/DataController"; 6 | import { 7 | Task, 8 | UserAnnotation, 9 | UserTask, 10 | UserAnnotationSet, 11 | UserSelect, 12 | UserAnnotationLog, 13 | ActionType, 14 | } from "../../plugins/Schemas"; 15 | import { useParams, useHistory } from "react-router-dom"; 16 | import AnnotationTopBar from "./components/AnnotationTopBar"; 17 | import LoadingPage from "../Common/LoadingPage"; 18 | import { useSnackbar } from "notistack"; 19 | import { setTitle, sleep } from "../../plugins/Utils"; 20 | import { 21 | AnswerAreaHandler, 22 | userAnnotationInfoContext, 23 | annotationFnContext, 24 | } from "./AnnotationContext"; 25 | import { 26 | userAnnotSetCompareDescFn, 27 | userAnnotSetCompareAscFn, 28 | } from "./AnnotationUtils"; 29 | import CardAnnotation from "./components/CardAnnotation"; 30 | import MultiLabelAnnotation from "./components/MultiLabelAnnotation"; 31 | import { getDeviceInfo } from "../../plugins/DeviceInfo"; 32 | 33 | const diffLimit = 10; 34 | 35 | const useStyles = makeStyles(() => ({ 36 | root: { 37 | flexGrow: 1, 38 | flexDirection: "column", 39 | }, 40 | })); 41 | 42 | const AnnotationPage: React.FC = () => { 43 | const classes = useStyles(); 44 | const [task, setTask] = useState(); 45 | const [userTask, setUserTask] = useState(); 46 | const [loading, setLoading] = useState(true); 47 | const { userTaskId, userAnnotationId } = useParams(); 48 | const history = useHistory(); 49 | const [userAnnotationSets, setUserAnnotationSets] = useState< 50 | UserAnnotationSet[] 51 | >([]); 52 | const [currentOrderIndex, setCurrentOrderIndex] = useState(); 53 | const { enqueueSnackbar } = useSnackbar(); 54 | const isLatest = userAnnotationId == "latest"; 55 | const answerArea = useRef({} as AnswerAreaHandler); 56 | 57 | const notifyErrorAndGoHome = async (error: string): Promise => { 58 | enqueueSnackbar(error, { 59 | variant: "error", 60 | }); 61 | await sleep(500); 62 | enqueueSnackbar("ホーム画面に戻ります...", { 63 | variant: "info", 64 | }); 65 | await sleep(1000); 66 | history.push("/home"); 67 | }; 68 | 69 | const redirectLatest = async (): Promise => { 70 | setLoading(true); 71 | // latest指定の場合, 最新のものを取得しリダイレクトする 72 | const latestAnnotation = await dataController.getLatestUserAnnotation( 73 | userTaskId 74 | ); 75 | console.log("latestAnnotation:", latestAnnotation); 76 | if (latestAnnotation) { 77 | history.push( 78 | `/user_task/${userTaskId}/annotation/${latestAnnotation.id}` 79 | ); 80 | } else { 81 | await notifyErrorAndGoHome("タスク情報の取得に失敗しました"); 82 | } 83 | setLoading(false); 84 | }; 85 | 86 | const getUserAnnotationSetByOrderIndex = ( 87 | orderIndex: number 88 | ): UserAnnotationSet | null => { 89 | return userAnnotationSets.filter( 90 | (annotSet) => annotSet.userAnnotation.order_index == orderIndex 91 | )[0]; 92 | }; 93 | 94 | const setAnnotationLocation = (userAnnotation: UserAnnotation): void => { 95 | history.push(`/user_task/${userTaskId}/annotation/${userAnnotation.id}`); 96 | }; 97 | 98 | const getOrderIndexes = (userAnnotSets: UserAnnotationSet[]): number[] => { 99 | return userAnnotSets.map((set) => set.userAnnotation.order_index); 100 | }; 101 | 102 | const getMinMaxOrderIndex = ( 103 | userAnnotSets: UserAnnotationSet[] 104 | ): [number, number] => { 105 | const orderIndexes = getOrderIndexes(userAnnotSets); 106 | const maxIndex = Math.max(...orderIndexes); 107 | const minIndex = Math.min(...orderIndexes); 108 | return [minIndex, maxIndex]; 109 | }; 110 | 111 | const postLog = (actionType: ActionType, actionData: any): void => { 112 | actionData = { ...actionData, ...{ deviceInfo: getDeviceInfo() } }; 113 | console.log("postLog", actionType, actionData, userAnnotationId); 114 | dataController.postUserAnnotationLog( 115 | userTaskId, 116 | userAnnotationId, 117 | actionType, 118 | actionData 119 | ); 120 | }; 121 | 122 | const fetchAnnotationsIfNeed = async (): Promise => { 123 | // 必要に応じてアノテーションデータを取得する 124 | const [minIndex, maxIndex] = getMinMaxOrderIndex(userAnnotationSets); 125 | 126 | console.log( 127 | `check index ${currentOrderIndex} min:${minIndex}, max:${maxIndex}, 全体:${userTask.annotation_num}` 128 | ); 129 | if ( 130 | maxIndex - currentOrderIndex < diffLimit && 131 | maxIndex != userTask.annotation_num 132 | ) { 133 | // 追加のnextを取得し, 降順にして配列先頭に追加 134 | console.log("fetch next"); 135 | const _sets = await dataController.getUserAnnotationSets( 136 | userTaskId, 137 | maxIndex, 138 | "next", 139 | 10 140 | ); 141 | setUserAnnotationSets([ 142 | ..._sets.sort(userAnnotSetCompareDescFn), 143 | ...userAnnotationSets, 144 | ]); 145 | } 146 | if (currentOrderIndex - minIndex < diffLimit && minIndex != 1) { 147 | // 追加のpreviousを取得し, 降順にして配列末尾に追加 148 | console.log("fetch previous"); 149 | const _sets = await dataController.getUserAnnotationSets( 150 | userTaskId, 151 | minIndex, 152 | "previous", 153 | 10 154 | ); 155 | setUserAnnotationSets([ 156 | ...userAnnotationSets, 157 | ..._sets.sort(userAnnotSetCompareAscFn), 158 | ]); 159 | } 160 | }; 161 | 162 | const goBack = (): void => { 163 | // 1個前のAnnotationに戻る 164 | const _preOrderIndex = currentOrderIndex - 1; 165 | const _preUserAnnotationSet = getUserAnnotationSetByOrderIndex( 166 | _preOrderIndex 167 | ); 168 | postLog("back", { 169 | from_order_index: currentOrderIndex, 170 | to_order_index: _preOrderIndex, 171 | }); 172 | if (_preUserAnnotationSet) { 173 | setCurrentOrderIndex(_preOrderIndex); 174 | setAnnotationLocation(_preUserAnnotationSet.userAnnotation); 175 | } 176 | fetchAnnotationsIfNeed(); 177 | }; 178 | 179 | const goThanks = (): void => { 180 | history.push("/annotation-thanks"); 181 | }; 182 | 183 | const initUserAnnotationSets = async (): Promise => { 184 | const _currentUserAnnotationSet = await dataController.getUserAnnotationSetById( 185 | userAnnotationId 186 | ); 187 | console.log("current", _currentUserAnnotationSet); 188 | const _previousAnnotationSets = await dataController.getUserAnnotationSets( 189 | userTaskId, 190 | _currentUserAnnotationSet.userAnnotation.order_index, 191 | "previous", 192 | 10 193 | ); 194 | console.log("previous", _previousAnnotationSets); 195 | const _nextAnnotationSets = await dataController.getUserAnnotationSets( 196 | userTaskId, 197 | _currentUserAnnotationSet.userAnnotation.order_index, 198 | "next", 199 | 10 200 | ); 201 | console.log("next", _nextAnnotationSets); 202 | setCurrentOrderIndex(_currentUserAnnotationSet.userAnnotation.order_index); 203 | 204 | setUserAnnotationSets( 205 | [ 206 | ..._previousAnnotationSets, 207 | _currentUserAnnotationSet, 208 | ..._nextAnnotationSets, 209 | ].sort(userAnnotSetCompareDescFn) 210 | ); 211 | }; 212 | 213 | useEffect(() => { 214 | // ログ用 215 | // 初回表示(タスク一覧から読み込み or 更新 or リンクから読み込み)の場合は initialize=true でログ 216 | const logData = currentOrderIndex ? null : { initialize: true }; 217 | postLog("display", logData); 218 | }, [currentOrderIndex]); 219 | 220 | useEffect(() => { 221 | const f = async (): Promise => { 222 | console.log("useEffect", loading, task, userTask); 223 | if (task && userTask && userAnnotationSets.length > 0) { 224 | console.log("in useEffect data filled"); 225 | return; 226 | } 227 | 228 | // latest の場合リダイレクト 229 | if (isLatest) { 230 | await redirectLatest(); 231 | return; 232 | } 233 | 234 | // ユーザタスク & タスクを取得 235 | setLoading(true); 236 | try { 237 | const _userTask = await dataController.getUserTaskById(userTaskId); 238 | const _task = await dataController.getTaskById(_userTask.task_id); 239 | setUserTask(_userTask); 240 | setTask(_task); 241 | setTitle(_task.title); 242 | // 前,現在,後のアノテーション群を取得 243 | await initUserAnnotationSets(); 244 | } catch { 245 | await notifyErrorAndGoHome("タスク情報の取得に失敗しました"); 246 | } 247 | setLoading(false); 248 | }; 249 | f(); 250 | }, [loading]); 251 | 252 | const answer = ( 253 | userAnnotationSet: UserAnnotationSet, 254 | nextUserAnnotationSet?: UserAnnotationSet 255 | ) => async (input: UserSelect): Promise => { 256 | console.log( 257 | "answer", 258 | currentOrderIndex, 259 | input, 260 | userAnnotationSet, 261 | "→", 262 | nextUserAnnotationSet 263 | ); 264 | if (nextUserAnnotationSet) { 265 | setCurrentOrderIndex(nextUserAnnotationSet.userAnnotation.order_index); 266 | setAnnotationLocation(nextUserAnnotationSet.userAnnotation); 267 | } 268 | fetchAnnotationsIfNeed(); 269 | // 結果をPost 270 | const result = { 271 | result: input, 272 | }; 273 | await dataController.postAnnotation( 274 | userTask, 275 | userAnnotationSet.userAnnotation, 276 | result 277 | ); 278 | if (!nextUserAnnotationSet) { 279 | // サンクス画面へ 280 | goThanks(); 281 | } 282 | }; 283 | 284 | const answerCurrent = (input: UserSelect) => async (): Promise => { 285 | const _annots: UserAnnotationSet[] = userAnnotationSets.filter( 286 | (annotSet) => { 287 | const _orderIndex = annotSet.userAnnotation.order_index; 288 | return ( 289 | _orderIndex == currentOrderIndex || 290 | _orderIndex == currentOrderIndex + 1 291 | ); 292 | } 293 | ); 294 | _annots.sort(userAnnotSetCompareAscFn); 295 | answer(_annots[0], _annots[1])(input); 296 | }; 297 | 298 | const onKeyPress = (e: KeyboardEvent): void => { 299 | answerArea.current.onKeyPress && answerArea.current.onKeyPress(e); 300 | }; 301 | 302 | useEffect(() => { 303 | document.addEventListener("keydown", onKeyPress); 304 | return (): void => { 305 | document.removeEventListener("keydown", onKeyPress); 306 | }; 307 | }, []); 308 | 309 | const renderAnswerArea = (): ReactNode => { 310 | // アノテーションのタイプを増やすごとにここに追加していく 311 | const rest = { ref: answerArea }; 312 | switch (task.annotation_type) { 313 | case "card": 314 | return ; 315 | case "multi_label": 316 | return ; 317 | default: 318 | notifyErrorAndGoHome( 319 | `${task.annotation_type} は対応しないアノテーションタイプです。` 320 | ); 321 | break; 322 | } 323 | }; 324 | 325 | if (!loading && task && userTask && userAnnotationSets) { 326 | console.log("render annotation", currentOrderIndex); 327 | return ( 328 |
329 | 330 | 331 | 337 | userAnnotSet.userAnnotation.order_index == currentOrderIndex 338 | ), 339 | task: task, 340 | postLog: postLog, 341 | }} 342 | > 343 | 350 | {renderAnswerArea()} 351 | 352 | 353 |
354 | ); 355 | } else { 356 | // Loading 357 | return ; 358 | } 359 | }; 360 | 361 | export default AnnotationPage; 362 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Annotation/AnnotationThanksPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import CssBaseline from "@material-ui/core/CssBaseline"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { Card, LinearProgress, Typography } from "@material-ui/core"; 5 | import hotTeaImage from "../../assets/img/hot_tea_b.png"; 6 | import { useHistory, Link } from "react-router-dom"; 7 | import { setTitle } from "../../plugins/Utils"; 8 | 9 | const useStyles = makeStyles(() => ({ 10 | root: { 11 | flexGrow: 1, 12 | flexDirection: "column", 13 | alignItems: "center", 14 | justifyContent: "center", 15 | display: "flex", 16 | height: "100vh", 17 | }, 18 | content: { 19 | display: "flex", 20 | flexDirection: "column", 21 | alignItems: "center", 22 | }, 23 | mainImage: { 24 | marginTop: "10px", 25 | width: "40vw", 26 | maxWidth: "300px", 27 | height: "auto", 28 | }, 29 | description: { 30 | marginTop: "10px", 31 | }, 32 | })); 33 | 34 | const AnnotationThanksPage: React.FC = () => { 35 | const classes = useStyles(); 36 | const [remainSec, setRemainSec] = useState(5); 37 | const refRemainSec = useRef(remainSec); 38 | const history = useHistory(); 39 | 40 | useEffect(() => { 41 | setTitle("アノテーション完了"); 42 | }); 43 | 44 | useEffect(() => { 45 | refRemainSec.current = remainSec; 46 | }, [remainSec]); 47 | 48 | useEffect(() => { 49 | const interval = setInterval(() => { 50 | console.log(refRemainSec.current); 51 | if (refRemainSec.current <= 0) { 52 | try { 53 | clearInterval(interval); 54 | } finally { 55 | history.push("/home"); 56 | } 57 | } 58 | setRemainSec(refRemainSec.current - 1); 59 | }, 1000); 60 | return () => { 61 | clearInterval(interval); 62 | }; 63 | }, []); 64 | 65 | return ( 66 |
67 | 68 |
69 | 70 | Thank you! 71 | 72 | 73 | 74 | アノテーションが完了しました。お疲れさまでした! 75 |
76 | {remainSec}秒後にホーム画面へ戻ります... 77 |
78 |
79 |
80 | ); 81 | }; 82 | 83 | export default AnnotationThanksPage; 84 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Annotation/AnnotationUtils.tsx: -------------------------------------------------------------------------------- 1 | export const userAnnotSetCompareDescFn = (a, b): number => 2 | b.userAnnotation.order_index - a.userAnnotation.order_index; 3 | export const userAnnotSetCompareAscFn = (a, b): number => 4 | a.userAnnotation.order_index - b.userAnnotation.order_index; 5 | export const createdAtDescFn = (a, b): number => 6 | a.created_at < b.created_at ? 1 : -1; 7 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Annotation/components/AnnotationTopBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import Typography from "@material-ui/core/Typography"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { IconButton } from "@material-ui/core"; 5 | import AppBar from "@material-ui/core/AppBar"; 6 | import Toolbar from "@material-ui/core/Toolbar"; 7 | import { useHistory } from "react-router-dom"; 8 | import { UserTask } from "../../../plugins/Schemas"; 9 | import { User } from "firebase"; 10 | import HomeIcon from "@material-ui/icons/Home"; 11 | import IconMenu from "../../Common/components/IconMenu"; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | title: { 15 | flexGrow: 1, 16 | textAlign: "center", 17 | }, 18 | menuButton: { 19 | marginRight: theme.spacing(2), 20 | }, 21 | })); 22 | 23 | const AnnotationTopBar: React.FC<{ 24 | userTask: UserTask; 25 | orderIndex: number; 26 | }> = ({ userTask, orderIndex }) => { 27 | const classes = useStyles(); 28 | const history = useHistory(); 29 | 30 | const goHome = (): void => { 31 | history.push("/home"); 32 | }; 33 | 34 | return ( 35 | 36 | 37 | 44 | 45 | 46 | 47 | {orderIndex | 0} / {userTask.annotation_num | 0} 48 | 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default AnnotationTopBar; 56 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Annotation/components/CardAnnotation.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useRef, 3 | useContext, 4 | useImperativeHandle, 5 | forwardRef, 6 | } from "react"; 7 | import { makeStyles } from "@material-ui/core/styles"; 8 | import { Card } from "@material-ui/core"; 9 | import { 10 | CardAnnotationData, 11 | CardChoice, 12 | UserAnnotationSet, 13 | } from "../../../plugins/Schemas"; 14 | import TinderCard from "react-tinder-card"; 15 | import ReplyIcon from "@material-ui/icons/Reply"; 16 | import CloseIcon from "@material-ui/icons/Close"; 17 | import ThumbUpIcon from "@material-ui/icons/ThumbUp"; 18 | import { 19 | RoundButton, 20 | RoundButtonWithDesc, 21 | } from "../../Common/components/RoundButton"; 22 | import { 23 | AnswerAreaHandler, 24 | annotationFnContext, 25 | userAnnotationInfoContext, 26 | } from "../AnnotationContext"; 27 | 28 | import { userAnnotSetCompareDescFn } from "../AnnotationUtils"; 29 | import { useSnackbar } from "notistack"; 30 | 31 | const limitRenderCard = 10; 32 | 33 | type Direction = "left" | "right" | "up" | "down"; 34 | const DirectionResult: { [key in Direction]: CardChoice } = { 35 | left: "No", 36 | right: "Yes", 37 | up: "Ambiguous", 38 | down: "Ambiguous", 39 | }; 40 | 41 | const useStyles = makeStyles(() => ({ 42 | question: { 43 | fontSize: "1.5rem", 44 | textAlign: "center", 45 | margin: "30px 10px", 46 | }, 47 | baselineCard: { 48 | width: "80vw", 49 | maxWidth: "500px", 50 | margin: "20px auto", 51 | }, 52 | baselineText: { 53 | fontSize: "1.2rem", 54 | textAlign: "center", 55 | margin: "1.5rem", 56 | MozUserSelect: "none", 57 | WebkitUserSelect: "none", 58 | msUserSelect: "none", 59 | }, 60 | cardArea: { 61 | position: "relative", 62 | zIndex: 10, 63 | flex: 1, 64 | height: "50vh", 65 | width: "80vw", 66 | "& > div": { 67 | position: "absolute", 68 | height: "100%", 69 | width: "100%", 70 | left: "10vw", 71 | }, 72 | }, 73 | card: { 74 | width: "100%", 75 | maxWidth: "500px", 76 | height: "100%", 77 | margin: "0 auto", 78 | display: "flex", 79 | flexDirection: "column", 80 | justifyContent: "center", 81 | }, 82 | annotation: { 83 | fontSize: "1.2rem", 84 | textAlign: "center", 85 | margin: "1.5rem", 86 | MozUserSelect: "none", 87 | WebkitUserSelect: "none", 88 | msUserSelect: "none", 89 | }, 90 | bottomArea: { 91 | display: "flex", 92 | flexDirection: "row", 93 | justifyContent: "center", 94 | alignItems: "flex-end", 95 | margin: "10px", 96 | MozUserSelect: "none", 97 | WebkitUserSelect: "none", 98 | msUserSelect: "none", 99 | }, 100 | })); 101 | 102 | let prevQuestion = ""; 103 | let prevBaseline = ""; 104 | 105 | const CardAnnotation = forwardRef((_, ref) => { 106 | // NOTE: https://qiita.com/otanu/items/994fdf9d8fb7327d41d5 107 | const classes = useStyles(); 108 | const { goBack, answer, answerCurrent } = useContext(annotationFnContext); 109 | const { 110 | currentOrderIndex, 111 | userAnnotationSets, 112 | task, 113 | currentUserAnnotationSet, 114 | postLog, 115 | } = useContext(userAnnotationInfoContext); 116 | const { enqueueSnackbar } = useSnackbar(); 117 | const refBackButton = useRef(null); 118 | const refNoButton = useRef(null); 119 | const refAmbiguousButton = useRef(null); 120 | const refYesButton = useRef(null); 121 | const currentAnnotationData: CardAnnotationData = currentUserAnnotationSet 122 | .annotation.data as CardAnnotationData; 123 | const preventSwipe: Direction[] = currentAnnotationData.show_ambiguous_button 124 | ? [] 125 | : ["up", "down"]; 126 | 127 | const trimAnnotationText = (text: string): string => { 128 | // 長すぎるテキストを切り取る 129 | if (text.length < 160) { 130 | return text; 131 | } 132 | try { 133 | const [bstart, bend] = [ 134 | text.match("").index, 135 | text.match("").index + "<\b>".length, 136 | ]; 137 | return text.slice(Math.max(bstart - 50, 0), bend + 100); 138 | } catch { 139 | console.log("error trim ", text); 140 | return text; 141 | } 142 | }; 143 | 144 | const onSwipe = ( 145 | userAnnotationSet: UserAnnotationSet, 146 | nextUserAnnotationSet: UserAnnotationSet 147 | ) => async (direction: Direction): Promise => { 148 | const result = DirectionResult[direction]; 149 | 150 | if (!currentAnnotationData.show_ambiguous_button && result == "Ambiguous") { 151 | postLog("invalid_submit", { by: "swipe", direction: direction }); 152 | enqueueSnackbar( 153 | "不正なスワイプです。スワイプは右か左のみにしてください", 154 | { 155 | variant: "warning", 156 | } 157 | ); 158 | return; 159 | } 160 | 161 | postLog("submit", { by: "swipe" }); 162 | answer( 163 | userAnnotationSet, 164 | nextUserAnnotationSet 165 | )(DirectionResult[direction]); 166 | }; 167 | 168 | useImperativeHandle(ref, () => ({ 169 | onKeyPress: (e: KeyboardEvent): void => { 170 | console.log(e.code); 171 | postLog("submit", { by: "keyboard" }); 172 | switch (e.code) { 173 | case "ArrowLeft": 174 | answerCurrent("No")(); 175 | break; 176 | case "ArrowUp": 177 | case "ArrowDown": 178 | if (currentAnnotationData.show_ambiguous_button) { 179 | answerCurrent("Ambiguous")(); 180 | } 181 | break; 182 | case "ArrowRight": 183 | answerCurrent("Yes")(); 184 | break; 185 | default: 186 | console.log("Out KeyCode ", e.code); 187 | } 188 | }, 189 | })); 190 | 191 | const answerByButton = (choice: CardChoice) => (): void => { 192 | answerCurrent(choice)(); 193 | postLog("submit", { by: "button" }); 194 | }; 195 | 196 | // 全て出すとイベント検出でむちゃくちゃ重たくなるためフィルタリング 197 | const diplayUserAnnotationSets = userAnnotationSets 198 | .filter( 199 | (userAnnotSet) => 200 | userAnnotSet.userAnnotation.order_index >= currentOrderIndex && 201 | userAnnotSet.userAnnotation.order_index < 202 | currentOrderIndex + limitRenderCard 203 | ) 204 | .sort(userAnnotSetCompareDescFn); 205 | console.log("diplayUserAnnotationSets ", diplayUserAnnotationSets); 206 | console.log("currentUserAnnotationSet ", currentUserAnnotationSet); 207 | 208 | let questionMargin = "30px 10px"; 209 | let cardHeight = "50vh"; 210 | if (currentAnnotationData.baseline_text) { 211 | cardHeight = "40vh"; 212 | questionMargin = "20px 10px"; 213 | } 214 | 215 | const questionOverwrite = currentAnnotationData.question_overwrite; 216 | if (questionOverwrite) { 217 | if (prevQuestion && prevQuestion != questionOverwrite) { 218 | enqueueSnackbar( 219 | `質問が「${currentAnnotationData.cand_entity}」に切り替わりました`, 220 | { 221 | variant: "info", 222 | anchorOrigin: { vertical: "top", horizontal: "center" }, 223 | } 224 | ); 225 | } 226 | prevQuestion = questionOverwrite; 227 | } 228 | 229 | if ( 230 | currentAnnotationData.baseline_text && 231 | prevBaseline && 232 | prevBaseline != currentAnnotationData.baseline_text 233 | ) { 234 | enqueueSnackbar( 235 | `ベースラインが「${currentAnnotationData.baseline_text}」に切り替わりました`, 236 | { 237 | variant: "info", 238 | anchorOrigin: { vertical: "top", horizontal: "center" }, 239 | } 240 | ); 241 | } 242 | prevBaseline = currentAnnotationData.baseline_text; 243 | 244 | return ( 245 | 246 |
255 | {/* NOTE: Baseline Card Area */} 256 | {currentAnnotationData.baseline_text && ( 257 |
258 | 259 |
265 |
266 |
267 | )} 268 | {/* NOTE: Card Area */} 269 |
270 | {diplayUserAnnotationSets.map((userAnnotSet, i) => ( 271 | 276 | 277 |
285 |
286 |
287 | ))} 288 |
289 | {/* NOTE: Bottom Buttons */} 290 |
291 | } 294 | description="一つ前へ" 295 | onClick={goBack} 296 | buttonRef={refBackButton} 297 | buttonKey={"backButton"} 298 | isDummy={currentOrderIndex == 1} 299 | /> 300 | 308 | } 309 | description={currentAnnotationData.no_button_label ?? "いいえ"} 310 | onClick={answerByButton("No")} 311 | buttonRef={refNoButton} 312 | buttonKey={"noButton"} 313 | /> 314 | {currentAnnotationData.show_ambiguous_button && ( 315 | ?
} 318 | description={ 319 |
320 | 部分的にそう 321 |
322 | わからない 323 |
324 | } 325 | onClick={answerByButton("Ambiguous")} 326 | buttonRef={refAmbiguousButton} 327 | buttonKey={"ambiguousButton"} 328 | /> 329 | )} 330 | 334 | } 335 | description={currentAnnotationData.yes_button_label ?? "はい"} 336 | onClick={answerByButton("Yes")} 337 | buttonRef={refYesButton} 338 | buttonKey={"yesButton"} 339 | /> 340 | 341 |
342 | 343 |
344 |
345 | ); 346 | }); 347 | 348 | export default CardAnnotation; 349 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Annotation/components/MultiLabelAnnotation.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useContext, forwardRef, useState } from "react"; 2 | import { makeStyles } from "@material-ui/core/styles"; 3 | import { Card, Fab } from "@material-ui/core"; 4 | import { MultiLabelAnnotationData } from "../../../plugins/Schemas"; 5 | import { 6 | RoundButton, 7 | RoundButtonWithDesc, 8 | } from "../../Common/components/RoundButton"; 9 | import ReplyIcon from "@material-ui/icons/Reply"; 10 | import { 11 | AnswerAreaHandler, 12 | annotationFnContext, 13 | userAnnotationInfoContext, 14 | } from "../AnnotationContext"; 15 | import Chip from "@material-ui/core/Chip"; 16 | 17 | const useStyles = makeStyles((theme) => ({ 18 | root: {}, 19 | question: { 20 | fontSize: "1.2rem", 21 | textAlign: "center", 22 | margin: "30px 20px", 23 | wordWrap: "break-word", 24 | }, 25 | baselineCard: { 26 | width: "80vw", 27 | maxWidth: "500px", 28 | margin: "20px auto", 29 | }, 30 | baselineText: { 31 | fontSize: "1.2rem", 32 | textAlign: "center", 33 | margin: "1.5rem", 34 | MozUserSelect: "none", 35 | WebkitUserSelect: "none", 36 | msUserSelect: "none", 37 | }, 38 | card: { 39 | width: "100%", 40 | // maxWidth: "90vw", 41 | margin: "0 auto", 42 | display: "flex", 43 | flexDirection: "column", 44 | justifyContent: "center", 45 | }, 46 | annotation: { 47 | fontSize: "1.2rem", 48 | textAlign: "center", 49 | margin: "1.5rem", 50 | }, 51 | tipsAreaWrapper: { 52 | padding: "1rem", 53 | }, 54 | tipsArea: { 55 | display: "flex", 56 | justifyContent: "center", 57 | flexWrap: "wrap", 58 | }, 59 | tip: { 60 | margin: theme.spacing(0.5), 61 | boxSizing: "border-box", 62 | border: "1px solid rgba(0, 0, 0, 0.23)", 63 | fontSize: "0.9rem", 64 | }, 65 | bottomArea: { 66 | position: "absolute", 67 | bottom: 0, 68 | width: "100%", 69 | display: "flex", 70 | flexDirection: "row", 71 | justifyContent: "center", 72 | alignItems: "flex-start", 73 | MozUserSelect: "none", 74 | WebkitUserSelect: "none", 75 | msUserSelect: "none", 76 | }, 77 | nextButton: { 78 | margin: "10px 20px", 79 | width: "200px", 80 | }, 81 | })); 82 | 83 | const MultiLabelAnnotation = forwardRef((_, ref) => { 84 | const classes = useStyles(); 85 | const { goBack, answerCurrent } = useContext(annotationFnContext); 86 | const { 87 | currentOrderIndex, 88 | task, 89 | currentUserAnnotationSet, 90 | postLog, 91 | } = useContext(userAnnotationInfoContext); 92 | const annotData = currentUserAnnotationSet.annotation 93 | .data as MultiLabelAnnotationData; 94 | const choices: string[] = annotData.choices; 95 | const [userChoices, setUserChoices] = useState([]); 96 | const refBackButton = useRef(null); 97 | 98 | const onClickTip = (choice: string) => (): void => { 99 | console.log(choice); 100 | const alreadyChoiced = userChoices.includes(choice); 101 | postLog("select", { 102 | choice: choice, 103 | action: alreadyChoiced ? "deselect" : "select", 104 | }); 105 | if (alreadyChoiced) { 106 | setUserChoices(userChoices.filter((c) => c != choice)); 107 | } else { 108 | const new_choices = [...userChoices, choice]; 109 | if ( 110 | annotData.max_select_num && 111 | annotData.max_select_num < new_choices.length 112 | ) { 113 | new_choices.shift(); 114 | } 115 | setUserChoices(new_choices); 116 | } 117 | }; 118 | 119 | const onClickNext = async (): Promise => { 120 | console.log("answer", userChoices); 121 | postLog("submit", { choices: userChoices }); 122 | answerCurrent(userChoices)(); 123 | setUserChoices([]); 124 | }; 125 | 126 | console.log(userChoices); 127 | 128 | return ( 129 |
130 |
134 | {/* NOTE: Baseline Card Area */} 135 | {annotData.baseline_text && ( 136 |
137 | 138 |
144 |
145 |
146 | )} 147 | {/* NOTE: Main Card Area */} 148 | 149 |
155 |
156 |
157 |
158 | {choices.map((choice, i) => { 159 | const isChoiced = userChoices.includes(choice); 160 | return ( 161 | 169 | ); 170 | })} 171 |
172 |
173 |
174 | } 177 | description="一つ前へ" 178 | onClick={goBack} 179 | buttonRef={refBackButton} 180 | buttonKey={"backButton"} 181 | isDummy={currentOrderIndex == 1} 182 | /> 183 | 191 | 次へ 192 | 193 | 194 |
195 | 196 |
197 |
198 | ); 199 | }); 200 | 201 | export default MultiLabelAnnotation; 202 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Auth/LoginSignupPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import Avatar from "@material-ui/core/Avatar"; 3 | import Button from "@material-ui/core/Button"; 4 | import CssBaseline from "@material-ui/core/CssBaseline"; 5 | import TextField from "@material-ui/core/TextField"; 6 | import FormControlLabel from "@material-ui/core/FormControlLabel"; 7 | import Checkbox from "@material-ui/core/Checkbox"; 8 | import Link from "@material-ui/core/Link"; 9 | import Grid from "@material-ui/core/Grid"; 10 | import LockOutlinedIcon from "@material-ui/icons/LockOutlined"; 11 | import Typography from "@material-ui/core/Typography"; 12 | import { makeStyles } from "@material-ui/core/styles"; 13 | import Container from "@material-ui/core/Container"; 14 | import { Link as RouterLink } from "react-router-dom"; 15 | import { Box, Paper, LinearProgress } from "@material-ui/core"; 16 | import { useHistory, useLocation } from "react-router-dom"; 17 | import { googleLogin } from "../../plugins/Auth"; 18 | import firebase, { analytics } from "../../plugins/firebase"; 19 | import querystring from "querystring"; 20 | import dataController from "../../plugins/DataController"; 21 | import { setTitle } from "../../plugins/Utils"; 22 | 23 | const useStyles = makeStyles((theme) => ({ 24 | paper: { 25 | display: "flex", 26 | flexDirection: "column", 27 | alignItems: "center", 28 | padding: theme.spacing(6), 29 | width: "80vw", 30 | maxWidth: "500px", 31 | }, 32 | avatar: { 33 | margin: theme.spacing(1), 34 | backgroundColor: theme.palette.secondary.main, 35 | }, 36 | submit: { 37 | margin: theme.spacing(3, 0, 2), 38 | // width: "40vw", 39 | padding: theme.spacing(1), 40 | textTransform: "none", 41 | }, 42 | progress: { 43 | width: "100%", 44 | }, 45 | })); 46 | 47 | const LoginSignupPage: React.FC = () => { 48 | const classes = useStyles(); 49 | const history = useHistory(); 50 | const location = useLocation(); 51 | const [loading, setLoading] = useState(false); 52 | const [isSignUp, setIsSignUp] = useState(false); 53 | 54 | const updateSignUpState = async (): Promise => { 55 | setLoading(true); 56 | const res = await firebase.auth().getRedirectResult(); 57 | console.log("getRedirectResult:", res); 58 | setIsSignUp(Boolean(res.user)); 59 | setLoading(false); 60 | if (res.user) { 61 | await dataController.postUserIfNeed(res.user); 62 | } 63 | }; 64 | 65 | const getNext = (): string => { 66 | const q = querystring.parse(location.search.replace("?", "")); 67 | const next = q["next"]; 68 | return next as string; 69 | }; 70 | 71 | const init = async (): Promise => { 72 | console.log(location); 73 | await updateSignUpState(); 74 | if (isSignUp) { 75 | analytics.logEvent("login", { 76 | method: "google.com", 77 | }); 78 | const next = getNext(); 79 | if (next) { 80 | history.push(`/${next}`); 81 | } else { 82 | history.push("/home"); 83 | } 84 | } 85 | }; 86 | 87 | useEffect(() => { 88 | setTitle("ログイン"); 89 | init(); 90 | }, [isSignUp]); 91 | 92 | return ( 93 | 101 | 102 | 103 | {loading && } 104 | 105 | 106 | FAST 107 | 108 | 118 | 119 | 120 | 121 | ); 122 | }; 123 | 124 | export default LoginSignupPage; 125 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Auth/SignupThanksPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import CssBaseline from "@material-ui/core/CssBaseline"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { Card, LinearProgress, Typography } from "@material-ui/core"; 5 | import goodHandImage from "../../assets/img/good_hand.png"; 6 | 7 | const useStyles = makeStyles(() => ({ 8 | root: { 9 | flexGrow: 1, 10 | flexDirection: "column", 11 | alignItems: "center", 12 | justifyContent: "center", 13 | display: "flex", 14 | height: "100vh", 15 | }, 16 | content: { 17 | display: "flex", 18 | flexDirection: "column", 19 | alignItems: "center", 20 | }, 21 | mainImage: { 22 | marginTop: "10px", 23 | width: "40vw", 24 | maxWidth: "300px", 25 | height: "auto", 26 | }, 27 | description: { 28 | marginTop: "10px", 29 | }, 30 | })); 31 | 32 | const SignupThanksPage: React.FC = () => { 33 | const classes = useStyles(); 34 | 35 | return ( 36 |
37 | 38 |
39 | 40 | Thank you! 41 | 42 | 43 | 44 | ユーザ登録が完了しました。 45 |
46 | 画面を閉じて管理者の指示をお待ちください。 47 |
48 |
49 |
50 | ); 51 | }; 52 | 53 | export default SignupThanksPage; 54 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Common/LoadingPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import CssBaseline from "@material-ui/core/CssBaseline"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { LinearProgress, Typography } from "@material-ui/core"; 5 | 6 | const useStyles = makeStyles(() => ({ 7 | root: { 8 | flexGrow: 1, 9 | flexDirection: "column", 10 | }, 11 | description: { 12 | textAlign: "center", 13 | margin: "1rem", 14 | }, 15 | })); 16 | 17 | const LoadingPage: React.FC<{ 18 | description?: string; 19 | }> = ({ description }) => { 20 | const classes = useStyles(); 21 | return ( 22 |
23 | 24 | 25 | {description && ( 26 | 27 | {description} 28 | 29 | )} 30 |
31 | ); 32 | }; 33 | 34 | export default LoadingPage; 35 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Common/components/IconMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, Fragment } from "react"; 2 | import { makeStyles } from "@material-ui/core/styles"; 3 | import Avatar from "@material-ui/core/Avatar"; 4 | import Button from "@material-ui/core/Button"; 5 | import { Menu, MenuItem } from "@material-ui/core"; 6 | import { logout } from "../../../plugins/Auth"; 7 | import useDBUserStatus from "../../../plugins/useDBUserStatus"; 8 | import { Link } from "react-router-dom"; 9 | 10 | const useStyles = makeStyles(() => ({ 11 | title: { 12 | flexGrow: 1, 13 | }, 14 | linkItem: { 15 | "& a": { textDecoration: "none" }, 16 | "& a:visited": { color: "inherit" }, 17 | }, 18 | })); 19 | 20 | const IconMenu: React.FC = () => { 21 | const classes = useStyles(); 22 | const [user, ,] = useDBUserStatus(); 23 | const [anchorEl, setAnchorEl] = useState(null); 24 | const onClickIcon = (event): void => { 25 | setAnchorEl(event.currentTarget); 26 | }; 27 | 28 | const onCloseIcon = (): void => { 29 | setAnchorEl(null); 30 | }; 31 | 32 | return ( 33 | 34 | 37 | 52 | 53 | ホーム 54 | 55 | {user && user.role == "admin" && ( 56 | 57 | タスク一覧 58 | 59 | )} 60 | ログアウト 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default IconMenu; 67 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Common/components/RoundButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { makeStyles } from "@material-ui/core/styles"; 3 | import { Fab } from "@material-ui/core"; 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | roundButton: { 7 | margin: "10px", 8 | backgroundColor: "white", 9 | boxShadow: "0 2px 4px rgba(0,0,0,0.3)", 10 | // display: "flex", 11 | // flexDirection: "column", 12 | // justifyContent: "center", 13 | // cursor: "pointer", 14 | }, 15 | roundButtonContent: { 16 | margin: "0 auto", 17 | "& *": { 18 | verticalAlign: "middle", 19 | }, 20 | }, 21 | buttonDesc: { 22 | fontSize: "0.7rem", 23 | textAlign: "center", 24 | color: "#757575", 25 | height: "1.5rem", 26 | }, 27 | })); 28 | 29 | export const RoundButtonWithDesc: React.FC<{ 30 | size: number; 31 | isDummy?: boolean; 32 | icon: React.ReactNode; 33 | description: string | React.ReactNode; 34 | onClick?: (event: React.MouseEvent) => void; 35 | buttonKey?: string; 36 | buttonRef?: React.RefObject; 37 | disabled?: boolean; 38 | }> = ({ 39 | icon, 40 | size, 41 | isDummy, 42 | description, 43 | onClick, 44 | buttonKey, 45 | buttonRef, 46 | disabled, 47 | }) => { 48 | const classes = useStyles(); 49 | return ( 50 |
51 | 59 | {icon} 60 | 61 |
{!isDummy && description}
62 |
63 | ); 64 | }; 65 | 66 | export const RoundButton: React.FC<{ 67 | size: number; 68 | isDummy?: boolean; 69 | children: React.ReactNode; 70 | onClick?: (event: React.MouseEvent) => void; 71 | buttonKey?: string; 72 | buttonRef?: React.RefObject; 73 | disabled?: boolean; 74 | }> = ({ 75 | children, 76 | size, 77 | onClick, 78 | isDummy = false, 79 | buttonKey, 80 | buttonRef, 81 | disabled, 82 | }) => { 83 | const classes = useStyles(); 84 | const _style = { 85 | height: size, 86 | width: size, 87 | borderRadius: size / 2, 88 | }; 89 | if (isDummy) { 90 | _style["opacity"] = 0; 91 | } 92 | 93 | return ( 94 | 102 |
{children}
103 |
104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Common/components/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import Typography from "@material-ui/core/Typography"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { useAuthState } from "react-firebase-hooks/auth"; 5 | import AppBar from "@material-ui/core/AppBar"; 6 | import Toolbar from "@material-ui/core/Toolbar"; 7 | import IconMenu from "./IconMenu"; 8 | import { Button } from "@material-ui/core"; 9 | import { useHistory } from "react-router-dom"; 10 | 11 | const useStyles = makeStyles((theme) => ({ 12 | title: { 13 | textTransform: "none", 14 | color: "white", 15 | }, 16 | })); 17 | 18 | const TopBar: React.FC<{ hideShadow?: boolean }> = ({ hideShadow }) => { 19 | const classes = useStyles(); 20 | const extStyle = hideShadow ? { boxShadow: "none" } : null; 21 | const history = useHistory(); 22 | 23 | return ( 24 | 25 | 26 | 34 |
35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default TopBar; 42 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | 3 | import CssBaseline from "@material-ui/core/CssBaseline"; 4 | import Typography from "@material-ui/core/Typography"; 5 | import { makeStyles } from "@material-ui/core/styles"; 6 | import { useAuthState } from "react-firebase-hooks/auth"; 7 | import { useCollectionData } from "react-firebase-hooks/firestore"; 8 | import CircularProgress from "@material-ui/core/CircularProgress"; 9 | import firebase from "../../plugins/firebase"; 10 | import { Box, Divider, Menu, MenuItem } from "@material-ui/core"; 11 | 12 | import dataController from "../../plugins/DataController"; 13 | import { Task, User, UserTask } from "../../plugins/Schemas"; 14 | import List from "@material-ui/core/List"; 15 | import ListItem from "@material-ui/core/ListItem"; 16 | import { zip, sleep, setTitle } from "../../plugins/Utils"; 17 | import { Link } from "react-router-dom"; 18 | import { useHistory } from "react-router-dom"; 19 | import { logout } from "../../plugins/Auth"; 20 | import LoadingPage from "../Common/LoadingPage"; 21 | import TopBar from "../Common/components/TopBar"; 22 | import { useSnackbar } from "notistack"; 23 | 24 | import { analytics } from "../../plugins/firebase"; 25 | 26 | import { isBrowser, isMobile } from "react-device-detect"; 27 | 28 | const useStyles = makeStyles((theme) => ({ 29 | root: { 30 | flexGrow: 1, 31 | }, 32 | taskList: {}, 33 | taskListItem: { 34 | display: "flex", 35 | height: "100px", 36 | backgroundColor: "white", 37 | cursor: "pointer", 38 | }, 39 | taskListItemLeft: {}, 40 | taskListItemRight: { 41 | marginLeft: "1rem", 42 | }, 43 | taskListItemTitle: { 44 | fontSize: "1.3rem", 45 | }, 46 | taskListItemDescription: { 47 | fontSize: "0.8rem", 48 | marginTop: "5px", 49 | color: "rgba(0, 0, 0, 0.54)", 50 | }, 51 | })); 52 | 53 | const CircularProgressWithLabel: React.FC<{ value: number }> = ({ value }) => ( 54 | 55 | 61 | 62 | 72 | {`${Math.round(value)}%`} 78 | 79 | 80 | ); 81 | 82 | const HomePage: React.FC = () => { 83 | const classes = useStyles(); 84 | const [user, initialising, userError] = useAuthState(firebase.auth()); 85 | const [tasks, setTasks] = useState<[UserTask, Task][]>([]); 86 | const history = useHistory(); 87 | const { enqueueSnackbar } = useSnackbar(); 88 | const [loading, setLoading] = useState(false); 89 | 90 | console.log("Home", user, initialising, userError); 91 | 92 | const goAnnotationPage = ( 93 | userTask: UserTask, 94 | task: Task 95 | ): (() => void) => (): void => { 96 | history.push(`/user_task/${userTask.id}/annotation/latest`); 97 | }; 98 | 99 | useEffect(() => { 100 | setTitle("Home"); 101 | analytics.setUserId(user.email); 102 | }); 103 | 104 | useEffect(() => { 105 | const f = async (): Promise => { 106 | if (!user) { 107 | return; 108 | } 109 | setLoading(true); 110 | // ユーザデータの登録 111 | const dbUser: User = await dataController.postUserIfNeed(user); 112 | let userTasks: UserTask[] = await dataController.getUserTasksByUserId( 113 | dbUser.id 114 | ); 115 | const taskIds = userTasks.map((ut) => ut.task_id); 116 | const tasks = await dataController.getTasksByIds(taskIds); 117 | setTasks(zip([userTasks, tasks])); 118 | console.log("home userTasks:", userTasks, tasks); 119 | setLoading(false); 120 | }; 121 | f(); 122 | }, []); 123 | 124 | return ( 125 |
126 | 127 | 128 | {loading ? ( 129 | 130 | ) : ( 131 | 132 | {tasks.map(([userTask, task]) => { 133 | return ( 134 |
135 | 136 |
137 | 142 |
143 |
144 |
145 | {task.title} 146 |
147 |
148 | {task.description} 149 |
150 |
151 |
152 | 153 |
154 | ); 155 | })} 156 |
157 | )} 158 |
159 | ); 160 | }; 161 | 162 | export default HomePage; 163 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Task/TaskDetailPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, useMemo } from "react"; 2 | import CssBaseline from "@material-ui/core/CssBaseline"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { Button, Card, Fab, Typography } from "@material-ui/core"; 5 | import { useHistory, useParams } from "react-router-dom"; 6 | import TopBar from "../Common/components/TopBar"; 7 | import LoadingPage from "../Common/LoadingPage"; 8 | import { Task, User, UserTask } from "../../plugins/Schemas"; 9 | import DataController from "../../plugins/DataController"; 10 | import { useSnackbar } from "notistack"; 11 | import { DateUtil, setTitle, sleep } from "../../plugins/Utils"; 12 | import Skeleton from "@material-ui/lab/Skeleton"; 13 | import Accordion from "@material-ui/core/Accordion"; 14 | import AccordionSummary from "@material-ui/core/AccordionSummary"; 15 | import AccordionDetails from "@material-ui/core/AccordionDetails"; 16 | import Modal from "@material-ui/core/Modal"; 17 | import ArrowBackIcon from "@material-ui/icons/ArrowBackIos"; 18 | import DeleteIcon from "@material-ui/icons/Delete"; 19 | 20 | import Dialog from "@material-ui/core/Dialog"; 21 | import DialogActions from "@material-ui/core/DialogActions"; 22 | import DialogContent from "@material-ui/core/DialogContent"; 23 | import DialogContentText from "@material-ui/core/DialogContentText"; 24 | import DialogTitle from "@material-ui/core/DialogTitle"; 25 | 26 | import DataGrid, { 27 | Column, 28 | SelectColumn, 29 | DataGridHandle, 30 | RowsUpdateEvent, 31 | CalculatedColumn, 32 | } from "react-data-grid"; 33 | import "react-data-grid/dist/react-data-grid.css"; 34 | import IconMenu from "../Common/components/IconMenu"; 35 | 36 | const useStyles = makeStyles((thema) => ({ 37 | root: { 38 | flexGrow: 1, 39 | }, 40 | header: { 41 | backgroundColor: thema.palette.primary.dark, 42 | height: "350px", 43 | }, 44 | headerContent: { 45 | padding: thema.spacing(3), 46 | display: "flex", 47 | }, 48 | mainTitle: { 49 | color: "white", 50 | }, 51 | content: { 52 | width: "100vw", 53 | // margin: "20px auto", 54 | }, 55 | card: { 56 | margin: "1rem", 57 | padding: "20px", 58 | }, 59 | dangerCard: { 60 | border: "1px solid red", 61 | }, 62 | cardContent: { 63 | margin: "20px 0", 64 | }, 65 | emptyGrid: { 66 | margin: "20px 0", 67 | }, 68 | featureCard: { 69 | margin: "1rem", 70 | marginTop: "-230px", 71 | padding: "20px", 72 | }, 73 | cardGrid: { 74 | margin: "20px 0", 75 | }, 76 | featureCardGrid: { 77 | margin: "20px 0", 78 | }, 79 | blankCardGrid: { 80 | height: "200px", 81 | margin: "20px 0", 82 | }, 83 | gridHeader: { 84 | display: "flex", 85 | "& h6": { 86 | lineHeight: 2, 87 | }, 88 | "& button": { 89 | marginLeft: "10px", 90 | }, 91 | }, 92 | })); 93 | 94 | type TaskFeatureRow = { key: string; value: string }; 95 | type UnAssignedUserRow = { id: string; username: string; email: string }; 96 | type AssignedUserRow = { 97 | id: string; 98 | username: string; 99 | email: string; 100 | annotationNum: number; 101 | submittedNum: number; 102 | }; 103 | 104 | const TaskDetailPage: React.FC = () => { 105 | const classes = useStyles(); 106 | const history = useHistory(); 107 | const { taskId } = useParams(); 108 | const [task, setTask] = useState(null); 109 | const [userTasks, setUserTasks] = useState(null); 110 | const [users, setUsers] = useState(null); 111 | const { enqueueSnackbar } = useSnackbar(); 112 | const [loading, setLoading] = useState(true); 113 | const [selectedUnAssignedUserRows, setSelectedUnAssignedUserRows] = useState( 114 | () => new Set() 115 | ); 116 | const [selectedAssignedUserRows, setSelectedAssignedUserRows] = useState( 117 | () => new Set() 118 | ); 119 | const [openDialog, setOpenDialog] = React.useState(false); 120 | 121 | const handleCloseDialog = (): void => { 122 | setOpenDialog(false); 123 | }; 124 | 125 | useEffect(() => { 126 | setTitle("タスク詳細"); 127 | }); 128 | 129 | useEffect(() => { 130 | const f = async (): Promise => { 131 | setLoading(true); 132 | const task = await DataController.getTaskById(taskId); 133 | setTask(task); 134 | console.log("task", task); 135 | setLoading(false); 136 | 137 | const userTasks = await DataController.getUserTasksByTaskId(task.id); 138 | setUserTasks(userTasks); 139 | const users = await DataController.getUsersAll(); 140 | setUsers(users); 141 | console.log("user, userTasks", users, userTasks); 142 | }; 143 | f(); 144 | }, []); 145 | 146 | const SkeltonGrid: React.FC = () => ( 147 |
148 | 149 | 150 |
151 | ); 152 | 153 | const EmptyGrid: React.FC<{ message: string }> = ({ 154 | message, 155 | }: { 156 | message: string; 157 | }) => ( 158 |
159 | 160 | {message} 161 | 162 |
163 | ); 164 | 165 | const TaskGrid: React.FC = () => { 166 | if (!task) { 167 | return ; 168 | } 169 | const rows: TaskFeatureRow[] = useMemo( 170 | () => [ 171 | { key: "id", value: task.id }, 172 | { key: "title", value: task.title }, 173 | { key: "annotation_type", value: task.annotation_type }, 174 | { key: "description", value: task.description }, 175 | { key: "question", value: task.question }, 176 | { 177 | key: "updated_at", 178 | value: DateUtil.toStringDataTime(task.updated_at.toDate()), 179 | }, 180 | { 181 | key: "created_at", 182 | value: DateUtil.toStringDataTime(task.created_at.toDate()), 183 | } 184 | ], 185 | [] 186 | ); 187 | console.log("renderTask", task); 188 | return ( 189 |
190 | {rows.map((r, i) => { 191 | const _value = 192 | r.key == "FireStoreUrl" ? ( 193 | 194 | {r.value} 195 | 196 | ) : ( 197 | r.value 198 | ); 199 | return ( 200 | 201 | ・ {r.key}: {_value} 202 | 203 | ); 204 | })} 205 |
206 | ); 207 | }; 208 | 209 | const getUserById = (userId: string): User => { 210 | return users.filter((user) => user.id == userId)[0]; 211 | }; 212 | 213 | const AssignedUsersTasksGrid: React.FC = () => { 214 | if (!userTasks || !users) { 215 | return ; 216 | } 217 | 218 | const assignedUserIds: string[] = useMemo( 219 | () => userTasks.map((ut) => ut.user_id), 220 | [JSON.stringify(userTasks.map((ut) => ut.id))] 221 | ); 222 | 223 | const rows: AssignedUserRow[] = useMemo( 224 | () => 225 | userTasks.map((userTask) => { 226 | const user = getUserById(userTask.user_id); 227 | return { 228 | id: userTask.id, 229 | uid: user.id, 230 | username: user.name, 231 | email: user.email, 232 | annotationNum: userTask.annotation_num, 233 | submittedNum: userTask.submitted_num, 234 | }; 235 | }), 236 | [JSON.stringify(assignedUserIds)] 237 | ); 238 | 239 | const _renderProgressCell = (value: number) => ( 240 |
248 |
258 |
266 | {value}% 267 |
268 |
269 | ); 270 | 271 | const columns: Column[] = useMemo( 272 | () => [ 273 | SelectColumn, 274 | { key: "username", name: "ユーザー名" }, 275 | { key: "email", name: "メールアドレス" }, 276 | { 277 | key: "annotationNum", 278 | name: "振り分けアノテーション数", 279 | }, 280 | { 281 | key: "submittedNum", 282 | name: "提出済みアノテーション数", 283 | }, 284 | { 285 | key: "progress", 286 | name: "完了率", 287 | width: 150, 288 | formatter(props) { 289 | const value = Math.round( 290 | (props.row.submittedNum / props.row.annotationNum) * 100 291 | ); 292 | return _renderProgressCell(value); 293 | }, 294 | }, 295 | ], 296 | [] 297 | ); 298 | 299 | console.log("振り分け済みユーザー", rows); 300 | 301 | return ( 302 |
303 | ( 306 |
307 | 312 | 振り分け済みのユーザはいません 313 | 314 |
315 | )} 316 | rows={rows} 317 | columns={columns} 318 | selectedRows={selectedAssignedUserRows} 319 | onSelectedRowsChange={setSelectedAssignedUserRows} 320 | height={ 321 | rows.length == 0 ? 100 : Math.min(500, 35 * (rows.length + 1)) 322 | } 323 | /> 324 |
325 | ); 326 | }; 327 | 328 | const UnAssignedUsersTasksGrid: React.FC = () => { 329 | if (!userTasks || !users) { 330 | return ; 331 | } 332 | 333 | const assignedUserIds: string[] = useMemo( 334 | () => userTasks.map((ut) => ut.user_id), 335 | [JSON.stringify(userTasks.map((ut) => ut.id))] 336 | ); 337 | 338 | const rows: UnAssignedUserRow[] = useMemo( 339 | () => 340 | users 341 | .filter((user) => !assignedUserIds.includes(user.id)) 342 | .map((user) => { 343 | return { 344 | id: user.id, 345 | username: user.name, 346 | email: user.email, 347 | }; 348 | }), 349 | [JSON.stringify(assignedUserIds)] 350 | ); 351 | 352 | const columns: Column[] = useMemo( 353 | () => [ 354 | SelectColumn, 355 | { key: "username", name: "ユーザー名" }, 356 | { key: "email", name: "メールアドレス" }, 357 | ], 358 | [] 359 | ); 360 | 361 | console.log("未振り分けユーザー", rows); 362 | 363 | return rows.length > 0 ? ( 364 |
365 | 375 |
376 | ) : ( 377 |
378 | 379 | 未振り分けのユーザはいません 380 | 381 |
382 | ); 383 | }; 384 | 385 | const onClickAssignTask = async (): Promise => { 386 | const userIds = Array.from(selectedUnAssignedUserRows.keys()); 387 | const _userTasks = await DataController.assignUsersTasksAllAnnotations( 388 | userIds, 389 | taskId 390 | ); 391 | setUserTasks([...userTasks, ..._userTasks]); 392 | setSelectedUnAssignedUserRows(new Set()); 393 | enqueueSnackbar(`${userIds.length}名にタスクを振り分けました`, { 394 | variant: "info", 395 | }); 396 | }; 397 | 398 | const onClickUnAssignTask = async (): Promise => { 399 | const userTaskIds = Array.from(selectedAssignedUserRows.keys()); 400 | console.log(userTaskIds); 401 | await Promise.all( 402 | userTaskIds.map((userTaskId) => DataController.deleteUserTask(userTaskId)) 403 | ); 404 | setUserTasks( 405 | userTasks.filter((userTask) => !userTaskIds.includes(userTask.id)) 406 | ); 407 | setSelectedAssignedUserRows(new Set()); 408 | enqueueSnackbar(`${userTaskIds.length}個のタスクを削除しました`, { 409 | variant: "info", 410 | }); 411 | }; 412 | 413 | const onClickDeleteTask = async (): Promise => { 414 | setLoading(true); 415 | await DataController.deleteTask(taskId); 416 | history.push("/tasks"); 417 | }; 418 | 419 | return ( 420 |
421 | 422 |
423 | {loading ? ( 424 | 425 | ) : ( 426 |
427 |
428 |
429 | 436 |
437 | {task.title} 438 | {task.id} 439 |
440 |
441 | 442 |
443 |
444 | 445 | 詳細 446 | 447 | 448 | 449 |
450 | 振り分け済みユーザー 451 | 459 |
460 | 461 |
462 | 未振り分けユーザー 463 | 471 |
472 | 473 |
474 | 475 | Danger Zone 476 |
477 | 486 |
487 |
488 |
489 | )} 490 |
491 | {task && ( 492 | 498 | 499 | {`本当に「${task.title}(${task.id})」を削除しますか`} 500 | 501 | 502 | 503 | {task.title}({task.id}) 504 | を削除すると、これまでユーザがアノテーションされたデータも含めて全てが削除されます。 505 | 506 | 507 | 508 | 516 | 523 | 524 | 525 | )} 526 |
527 | ); 528 | }; 529 | 530 | export default TaskDetailPage; 531 | -------------------------------------------------------------------------------- /frontend/app/src/pages/Task/TasksPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import CssBaseline from "@material-ui/core/CssBaseline"; 3 | import { makeStyles } from "@material-ui/core/styles"; 4 | import { 5 | Button, 6 | ButtonBase, 7 | Card, 8 | CardActions, 9 | CardContent, 10 | LinearProgress, 11 | Typography, 12 | } from "@material-ui/core"; 13 | import hotTeaImage from "../../assets/img/hot_tea_b.png"; 14 | import { useHistory } from "react-router-dom"; 15 | import TopBar from "../Common/components/TopBar"; 16 | import LoadingPage from "../Common/LoadingPage"; 17 | import { Task } from "../../plugins/Schemas"; 18 | import DataController from "../../plugins/DataController"; 19 | import { useSnackbar } from "notistack"; 20 | import { DateUtil, setTitle } from "../../plugins/Utils"; 21 | import { createdAtDescFn } from "../Annotation/AnnotationUtils"; 22 | 23 | const useStyles = makeStyles(() => ({ 24 | root: { 25 | flexGrow: 1, 26 | }, 27 | title: { 28 | margin: "1rem", 29 | marginBottom: 0, 30 | }, 31 | content: { 32 | width: "90vw", 33 | margin: "0 auto", 34 | }, 35 | cardList: { 36 | display: "flex", 37 | flexWrap: "wrap", 38 | // justifyContent: "center", 39 | }, 40 | cardButton: { 41 | display: "block", 42 | height: "100%", 43 | width: "100%", 44 | }, 45 | card: { 46 | margin: "1rem", 47 | width: "350px", 48 | height: "200px", 49 | borderRadius: "12px", 50 | }, 51 | cardContent: { 52 | padding: "25px", 53 | height: "100%", 54 | textAlign: "left", 55 | }, 56 | taskId: { 57 | fontSize: "1rem", 58 | }, 59 | cardDescription: { 60 | marginTop: "1rem", 61 | }, 62 | })); 63 | 64 | const TasksPage: React.FC = () => { 65 | const classes = useStyles(); 66 | const history = useHistory(); 67 | const [tasks, setTasks] = useState([]); 68 | const { enqueueSnackbar, closeSnackbar } = useSnackbar(); 69 | const [loading, setLoading] = useState(false); 70 | 71 | useEffect(() => { 72 | setTitle("タスク一覧"); 73 | }); 74 | 75 | useEffect(() => { 76 | const f = async (): Promise => { 77 | setLoading(true); 78 | const tasks = await DataController.getTasksAll(); 79 | tasks.sort(createdAtDescFn); 80 | setTasks(tasks); 81 | console.log(tasks); 82 | setLoading(false); 83 | }; 84 | f(); 85 | }, []); 86 | 87 | const goTaskDetailPage = (taskId: string): (() => void) => (): void => { 88 | history.push(`/task/${taskId}`); 89 | }; 90 | 91 | const TaskList: React.FC<{ tasks: Task[] }> = () => ( 92 |
93 | {tasks.map((task) => ( 94 | 95 | 99 | 100 | 101 | {task.title} 102 | 103 | 104 | {task.id} 105 | 106 | 107 | 111 | 形式: {task.annotation_type}, 作成日:{" "} 112 | {DateUtil.parseDateWithAgo(task.created_at.toDate())} 113 | 114 | 115 | 116 | 117 | ))} 118 |
119 | ); 120 | 121 | return ( 122 |
123 | 124 | 125 |
126 | 127 | タスク一覧 128 | 129 | {loading ? ( 130 | 131 | ) : ( 132 | 133 | )} 134 |
135 |
136 | ); 137 | }; 138 | 139 | export default TasksPage; 140 | -------------------------------------------------------------------------------- /frontend/app/src/plugins/Auth.tsx: -------------------------------------------------------------------------------- 1 | import firebase from "./firebase"; 2 | 3 | const googleAuthProvider = new firebase.auth.GoogleAuthProvider(); 4 | 5 | export const googleLogin = async (): Promise => { 6 | console.log("login"); 7 | try { 8 | const res = await firebase.auth().signInWithRedirect(googleAuthProvider); 9 | console.log("res", res); 10 | } catch (error) { 11 | console.log("error", error); 12 | } 13 | }; 14 | 15 | export const logout = () => { 16 | firebase.auth().signOut(); 17 | }; 18 | -------------------------------------------------------------------------------- /frontend/app/src/plugins/DataController.tsx: -------------------------------------------------------------------------------- 1 | import { firestore } from "firebase"; 2 | import firebase from "./firebase"; 3 | import { 4 | User, 5 | UserTask, 6 | Task, 7 | Annotation, 8 | UserAnnotation, 9 | UserAnnotationSet, 10 | CardResult, 11 | MultiLabelResult, 12 | UserAnnotationLog, 13 | ActionType, 14 | UserResult, 15 | } from "./Schemas"; 16 | import { chunk } from "./Utils"; 17 | 18 | class DataController { 19 | private _db: firebase.firestore.Firestore; 20 | 21 | constructor() { 22 | this._db = firebase.firestore(); 23 | } 24 | 25 | // NOTE: --- POST --- 26 | 27 | postUser = async (user: firebase.User): Promise => { 28 | const userRef = this._db.collection("users").doc(user.uid); 29 | const dbUser: User = { 30 | id: user.uid, 31 | email: user.email, 32 | photo_url: user.photoURL, 33 | name: user.displayName, 34 | role: "annotator", 35 | created_at: firestore.Timestamp.now(), 36 | updated_at: firestore.Timestamp.now(), 37 | }; 38 | await userRef.set(dbUser); 39 | console.log("postUser:", dbUser); 40 | }; 41 | 42 | postAnnotation = async ( 43 | userTask: UserTask, 44 | userAnnotation: UserAnnotation, 45 | resultData: UserResult 46 | ): Promise => { 47 | console.log("postAnnotation", userAnnotation.order_index, resultData); 48 | const userAnnotationRef = this._db 49 | .collection("users_annotations") 50 | .doc(userAnnotation.id); 51 | await userAnnotationRef.update({ 52 | result_data: resultData, 53 | updated_at: firestore.Timestamp.now(), 54 | }); 55 | }; 56 | 57 | postUserIfNeed = async (user: firebase.User): Promise => { 58 | // ユーザが登録済みでなければ登録 59 | // const userRef = this._db.collection("users").doc(user.uid); 60 | const dbUser = await this.getUserById(user.uid); 61 | if (dbUser) { 62 | console.log("user exists", dbUser); 63 | return dbUser; 64 | } else { 65 | await this.postUser(user); 66 | return this.getUserById(user.uid); 67 | } 68 | }; 69 | 70 | postUserAnnotationLog = async ( 71 | userTaskId: string, 72 | userAnnotationId: string, 73 | actionType: ActionType, 74 | actionData?: any 75 | ): Promise => { 76 | const userAnnotationLogDoc = this._db 77 | .collection("user_annotations_logs") 78 | .doc(); 79 | const postUserAnnotationLog: UserAnnotationLog = { 80 | id: userAnnotationLogDoc.id, 81 | user_task_id: userTaskId, 82 | user_annotation_id: userAnnotationId, 83 | action_type: actionType, 84 | action_data: actionData, 85 | created_at: firestore.Timestamp.now(), 86 | }; 87 | await userAnnotationLogDoc.set(postUserAnnotationLog); 88 | return postUserAnnotationLog; 89 | }; 90 | 91 | updateUserTaskSubmittedNumIfNeed = async ( 92 | userTask: UserTask 93 | ): Promise => { 94 | // UserAnnotaitonに更新があればUserTaskのsubmitted_numを更新する 95 | const _updated = !( 96 | await this._db 97 | .collection("users_annotations") 98 | .where("user_task_id", "==", userTask.id) 99 | .where("updated_at", ">", userTask.updated_at) 100 | .limit(1) 101 | .get() 102 | ).empty; 103 | if (_updated) { 104 | const userAnnotationSubmitted = await this._db 105 | .collection("users_annotations") 106 | .where("user_task_id", "==", userTask.id) 107 | .where("result_data", "!=", null) 108 | .get(); 109 | const submittedNum = userAnnotationSubmitted.size; 110 | console.log( 111 | "userTask submitted_num update", 112 | userTask.submitted_num, 113 | "→", 114 | submittedNum 115 | ); 116 | userTask.submitted_num = submittedNum; 117 | const userTaskRef = this._db.collection("users_tasks").doc(userTask.id); 118 | await userTaskRef.update({ 119 | submitted_num: submittedNum, 120 | updated_at: firestore.Timestamp.now(), 121 | }); 122 | } 123 | }; 124 | 125 | // NOTE: --- Data Transform --- 126 | 127 | docToData = (doc: any) => { 128 | return { id: doc.id, ...doc.data() }; 129 | }; 130 | 131 | refToDataList = async ( 132 | ref: firebase.firestore.CollectionReference | firebase.firestore.Query 133 | ): Promise => { 134 | return (await ref.get()).docs.map((doc) => { 135 | return this.docToData(doc); 136 | }); 137 | }; 138 | 139 | // NOTE: -- get all docs 140 | 141 | getDocsDataAll = async ( 142 | collectionPath: string 143 | ): Promise => { 144 | return this.refToDataList(this._db.collection(collectionPath)); 145 | }; 146 | 147 | getTasksAll = async (): Promise => { 148 | return this.getDocsDataAll("tasks"); 149 | }; 150 | 151 | getUsersAll = async (): Promise => { 152 | return this.getDocsDataAll("users"); 153 | }; 154 | 155 | // NOTE: -- get single doc by id --- 156 | 157 | getDocDataById = async ( 158 | collectionPath: string, 159 | docId: string 160 | ): Promise => { 161 | const _ref = await this._db.collection(collectionPath).doc(docId).get(); 162 | return _ref.exists ? (this.docToData(_ref) as T) : null; 163 | }; 164 | 165 | getUserById = async (docId: string): Promise => { 166 | return this.getDocDataById("users", docId); 167 | }; 168 | 169 | getUserTaskById = async (docId: string): Promise => { 170 | return this.getDocDataById("users_tasks", docId); 171 | }; 172 | 173 | getUserAnnotationById = async (docId: string): Promise => { 174 | return this.getDocDataById("users_annotations", docId); 175 | }; 176 | 177 | getTaskById = async (docId: string): Promise => { 178 | return this.getDocDataById("tasks", docId); 179 | }; 180 | 181 | getAnnotationById = async (docId: string): Promise => { 182 | return this.getDocDataById("annotations", docId); 183 | }; 184 | 185 | // NOTE: --- Home --- 186 | 187 | getTasksByIds = async (taskIds: string[]): Promise => { 188 | // get tasks 189 | const _taskChunks: Task[][] = await Promise.all( 190 | chunk(taskIds, 10).map((chunkIds) => 191 | this.refToDataList( 192 | this._db.collection("tasks").where("id", "in", chunkIds) 193 | ) 194 | ) 195 | ); 196 | const _tasks: Task[] = ([] as Task[]).concat(..._taskChunks); 197 | // sort by input ids 198 | const resTasks = taskIds.map((taskId) => 199 | _tasks.find((t) => t.id == taskId) 200 | ); 201 | return resTasks; 202 | }; 203 | 204 | getUserTasksByUserId = async (userId: string): Promise => { 205 | const userTasks = (await this.refToDataList( 206 | this._db 207 | .collection("users_tasks") 208 | .where("user_id", "==", userId) 209 | .orderBy("created_at", "desc") 210 | )) as UserTask[]; 211 | await Promise.all( 212 | userTasks.map((userTask) => 213 | this.updateUserTaskSubmittedNumIfNeed(userTask) 214 | ) 215 | ); 216 | return userTasks; 217 | }; 218 | 219 | // NOTE: -- Tasks -- 220 | 221 | getUserTasksByTaskId = async (taskId: string): Promise => { 222 | const userTasks = (await this.refToDataList( 223 | this._db 224 | .collection("users_tasks") 225 | .where("task_id", "==", taskId) 226 | .orderBy("created_at", "desc") 227 | )) as UserTask[]; 228 | await Promise.all( 229 | userTasks.map((userTask) => 230 | this.updateUserTaskSubmittedNumIfNeed(userTask) 231 | ) 232 | ); 233 | return userTasks; 234 | }; 235 | 236 | assignUserTaskAllAnnotations = async ( 237 | userId: string, 238 | taskId: string, 239 | annotations?: firestore.QuerySnapshot 240 | ): Promise => { 241 | if (!annotations) { 242 | annotations = await this._db 243 | .collection("annotations") 244 | .where("task_id", "==", taskId) 245 | .orderBy("created_at") 246 | .get(); 247 | } 248 | const userTaskDoc = this._db.collection("users_tasks").doc(); 249 | const userTask: UserTask = { 250 | id: userTaskDoc.id, 251 | user_id: userId, 252 | task_id: taskId, 253 | annotation_num: annotations.size, 254 | submitted_num: 0, 255 | created_at: firestore.Timestamp.now(), 256 | updated_at: firestore.Timestamp.now(), 257 | }; 258 | userTaskDoc.set(userTask); 259 | // set UserAnnotations 260 | const chunkSize = 500; 261 | chunk(annotations.docs, chunkSize).forEach((annotDocs, ci) => { 262 | const batch = this._db.batch(); 263 | annotDocs.forEach((annotDoc, i) => { 264 | const orderIndex = chunkSize * ci + i + 1; 265 | const doc = this._db.collection("users_annotations").doc(); 266 | const userAnnotation: UserAnnotation = { 267 | id: doc.id, 268 | user_id: userId, 269 | annotation_id: annotDoc.id, 270 | user_task_id: userTask.id, 271 | order_index: orderIndex, 272 | result_data: null, 273 | created_at: firestore.Timestamp.now(), 274 | updated_at: firestore.Timestamp.now(), 275 | }; 276 | batch.set(doc, userAnnotation); 277 | }); 278 | batch.commit(); 279 | }); 280 | 281 | return userTask; 282 | }; 283 | 284 | assignUsersTasksAllAnnotations = async ( 285 | userIds: string[], 286 | taskId: string 287 | ): Promise => { 288 | // ユーザーにタスク内の全てのアノテーションを振り分け 289 | // アノテーション全体の取得 290 | const annotations = await this._db 291 | .collection("annotations") 292 | .where("task_id", "==", taskId) 293 | .orderBy("created_at") 294 | .get(); 295 | // set UserTask 296 | const userTasks = await Promise.all( 297 | userIds.map((userId) => 298 | this.assignUserTaskAllAnnotations(userId, taskId, annotations) 299 | ) 300 | ); 301 | 302 | return userTasks; 303 | }; 304 | 305 | deleteTask = async (taskId: string): Promise => { 306 | // タスクと関連するデータを削除 307 | // delete Task 308 | const task = await this._db 309 | .collection("tasks") 310 | .where("id", "==", taskId) 311 | .get(); 312 | task.docs.forEach((doc) => doc.ref.delete()); 313 | // delete Annotation 314 | const annotations = await this._db 315 | .collection("annotations") 316 | .where("task_id", "==", taskId) 317 | .get(); 318 | chunk(annotations.docs, 500).forEach((docs) => { 319 | const batch = this._db.batch(); 320 | console.log(`annotations:${docs.length} deleted`); 321 | docs.forEach((doc) => batch.delete(doc.ref)); 322 | batch.commit(); 323 | }); 324 | // delete User Task 325 | const userTasks = await this._db 326 | .collection("users_tasks") 327 | .where("task_id", "==", taskId) 328 | .get(); 329 | userTasks.forEach((userTask) => this.deleteUserTask(userTask.id)); 330 | }; 331 | 332 | deleteUserTask = async (userTaskId: string): Promise => { 333 | // ユーザータスクと紐づくユーザアノテーションを削除 334 | // delete User Task 335 | const userTask = await this._db 336 | .collection("users_tasks") 337 | .where("id", "==", userTaskId) 338 | .get(); 339 | const batch = this._db.batch(); 340 | userTask.docs.forEach((d) => batch.delete(d.ref)); 341 | batch.commit(); 342 | // delete UserAnnotation 343 | const userAnnotations = await this._db 344 | .collection("users_annotations") 345 | .where("user_task_id", "==", userTaskId) 346 | .get(); 347 | console.log(`userAnnotations:${userAnnotations.docs.length} deleting`); 348 | chunk(userAnnotations.docs, 500).forEach((docs) => { 349 | const batch = this._db.batch(); 350 | console.log(`userAnnotations:${docs.length} deleted`); 351 | docs.forEach((doc) => batch.delete(doc.ref)); 352 | batch.commit(); 353 | }); 354 | // delete UserAnnotationLog 355 | const userAnnotationLogs = await this._db 356 | .collection("user_annotations_logs") 357 | .where("user_task_id", "==", userTaskId) 358 | .get(); 359 | chunk(userAnnotationLogs.docs, 500).forEach((docs) => { 360 | const batch = this._db.batch(); 361 | console.log(`userAnnotationLogs:${docs.length} deleted`); 362 | docs.forEach((doc) => batch.delete(doc.ref)); 363 | batch.commit(); 364 | }); 365 | }; 366 | 367 | // NOTE: -- Annotation -- 368 | 369 | getLatestUserAnnotation = async ( 370 | userTaskId: string 371 | ): Promise => { 372 | // 回答途中: 回答していないアノテーションの中で最小order_indexのもの 373 | // 全回答済み: 最大order_indexのアノテーション 374 | const latestResultNull = await this._db 375 | .collection("users_annotations") 376 | .where("user_task_id", "==", userTaskId) 377 | .where("result_data", "==", null) 378 | .orderBy("order_index") 379 | .limit(1) 380 | .get(); 381 | if (!latestResultNull.empty) { 382 | return this.docToData(latestResultNull.docs[0]) as UserAnnotation; 383 | } else { 384 | const lastUserAnnotation = await this._db 385 | .collection("users_annotations") 386 | .where("user_task_id", "==", userTaskId) 387 | .orderBy("order_index", "desc") 388 | .limit(1) 389 | .get(); 390 | return this.docToData(lastUserAnnotation.docs[0]) as UserAnnotation; 391 | } 392 | }; 393 | 394 | getUserAnnotationSetById = async ( 395 | docId: string 396 | ): Promise => { 397 | const _userAnnotation = await this.getUserAnnotationById(docId); 398 | const _annotation = await this.getAnnotationById( 399 | _userAnnotation.annotation_id 400 | ); 401 | return { 402 | userAnnotation: _userAnnotation, 403 | annotation: _annotation, 404 | }; 405 | }; 406 | 407 | getUserAnnotationSets = async ( 408 | userTaskId: string, 409 | offsetOrderIndex: number, 410 | type: "next" | "previous", 411 | limit = 100 412 | ): Promise => { 413 | const ltgt = type == "next" ? ">" : "<"; 414 | const orderType = type == "next" ? "asc" : "desc"; 415 | const _userAnnotationsRef = await this._db 416 | .collection("users_annotations") 417 | .where("user_task_id", "==", userTaskId) 418 | .where("order_index", ltgt, offsetOrderIndex) 419 | .orderBy("order_index", orderType) 420 | .limit(limit); 421 | const _userAnnotations = (await this.refToDataList( 422 | _userAnnotationsRef 423 | )) as UserAnnotation[]; 424 | const _annotations: Annotation[] = await Promise.all( 425 | _userAnnotations.map((uannot) => 426 | this.getAnnotationById(uannot.annotation_id) 427 | ) 428 | ); 429 | return _userAnnotations.map((uannot, i) => { 430 | return { 431 | userAnnotation: uannot, 432 | annotation: _annotations[i], 433 | } as UserAnnotationSet; 434 | }); 435 | }; 436 | } 437 | 438 | const dataController = new DataController(); 439 | export default dataController; 440 | -------------------------------------------------------------------------------- /frontend/app/src/plugins/DeviceInfo.tsx: -------------------------------------------------------------------------------- 1 | function isObject(val: any): boolean { 2 | if (val !== null && typeof val === "object" && val.constructor === Object) { 3 | return true; 4 | } 5 | return false; 6 | } 7 | 8 | function nullToString(dict: any): any { 9 | if (isObject(dict)) { 10 | for (const key in dict) { 11 | dict[key] = nullToString(dict[key]); 12 | } 13 | return dict; 14 | } else { 15 | return dict || `${dict}`; 16 | } 17 | } 18 | 19 | export function getDeviceInfo(): any { 20 | const navigator = window.navigator; 21 | const info = { 22 | navigator: { 23 | userAgent: navigator.userAgent, 24 | appCodeName: navigator.appCodeName, 25 | appName: navigator.appName, 26 | appVersion: navigator.appVersion, 27 | languages: navigator.languages, 28 | pointerEnabled: navigator.pointerEnabled, 29 | maxTouchPoints: navigator.maxTouchPoints, 30 | platform: navigator.platform, 31 | product: navigator.product, 32 | productSub: navigator.productSub, 33 | vendor: navigator.vendor, 34 | vendorSub: navigator.vendorSub, 35 | }, 36 | view: { 37 | innerWidth: window.innerWidth, 38 | innerHeight: window.innerHeight, 39 | availHeight: screen.availHeight, 40 | availWidth: screen.availWidth, 41 | colorDepth: screen.colorDepth, 42 | pixelDepth: screen.pixelDepth, 43 | screenWidth: screen.width, 44 | screenHeight: screen.height, 45 | }, 46 | location: { 47 | href: location.href, 48 | referrer: document.referrer, 49 | domain: document.domain, 50 | }, 51 | }; 52 | return nullToString(info); 53 | } 54 | -------------------------------------------------------------------------------- /frontend/app/src/plugins/Schemas.tsx: -------------------------------------------------------------------------------- 1 | import firebase from "./firebase"; 2 | 3 | export type UserRole = "admin" | "annotator"; 4 | 5 | export type FsDate = firebase.firestore.Timestamp; 6 | 7 | export interface User { 8 | id: string; 9 | name: string; 10 | email: string; 11 | role: UserRole; 12 | photo_url: string; 13 | created_at: FsDate; 14 | updated_at: FsDate; 15 | } 16 | 17 | export type AnnotationType = "card" | "multi_label"; 18 | 19 | export interface Task { 20 | id: string; 21 | annotation_type: AnnotationType; 22 | title: string; 23 | question: string; 24 | description: string; 25 | created_at: FsDate; 26 | updated_at: FsDate; 27 | } 28 | 29 | export interface Annotation { 30 | id: string; 31 | task_id: string; 32 | data: AnnotationData; 33 | created_at: FsDate; 34 | updated_at: FsDate; 35 | } 36 | 37 | export interface UserTask { 38 | id: string; 39 | user_id: string; 40 | task_id: string; 41 | annotation_num: number; 42 | submitted_num: number; 43 | created_at: FsDate; 44 | updated_at: FsDate; 45 | } 46 | 47 | export interface UserAnnotation { 48 | id: string; 49 | user_id: string; 50 | annotation_id: string; 51 | user_task_id: string; 52 | result_data: AnnotationResult | null; 53 | order_index: number; 54 | created_at: FsDate; 55 | updated_at: FsDate; 56 | } 57 | 58 | export interface UserAnnotationSet { 59 | userAnnotation: UserAnnotation; 60 | annotation: Annotation; 61 | } 62 | 63 | export type ActionType = 64 | | "display" 65 | | "select" 66 | | "submit" 67 | | "back" 68 | | "invalid_submit"; 69 | 70 | export interface UserAnnotationLog { 71 | id: string; 72 | user_task_id: string; 73 | user_annotation_id: string; 74 | action_type: ActionType; 75 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 76 | action_data: any; 77 | created_at: FsDate; 78 | } 79 | 80 | // --- タスク依存系 --- 81 | 82 | export type AnnotationData = CardAnnotationData | MultiLabelAnnotationData; 83 | 84 | export interface CardAnnotationData { 85 | text: string; 86 | baseline_text?: string; 87 | cand_entity?: string; 88 | show_ambiguous_button?: boolean; 89 | question_overwrite?: string; 90 | yes_button_label?: string; 91 | no_button_label?: string; 92 | } 93 | 94 | export interface MultiLabelAnnotationData { 95 | text: string; 96 | choices: string[]; 97 | max_select_num?: number; 98 | baseline_text?: string; 99 | } 100 | 101 | export interface AnnotationResult { 102 | result: T; 103 | } 104 | 105 | export type UserResult = CardResult | MultiLabelResult; 106 | export type UserSelect = CardChoice | MultiLabelSelect; 107 | 108 | export type CardChoice = "Yes" | "No" | "Ambiguous"; 109 | export type CardResult = AnnotationResult; 110 | 111 | export type MultiLabelSelect = string[]; 112 | export type MultiLabelResult = AnnotationResult; 113 | -------------------------------------------------------------------------------- /frontend/app/src/plugins/Utils.tsx: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import "moment/min/locales"; 3 | import { analytics } from "./firebase"; 4 | moment.locale("ja"); 5 | 6 | export class DateUtil { 7 | static parseDateWithAgo(date: Date, from_ago = 1): string { 8 | // 直近の日付は「●日前」 9 | // 昔の日付は「2019年10月1日」のように整形する 10 | // from_agoで直近を指定。 11 | const _date = moment(date); 12 | const beforeNow = moment().subtract(from_ago, "d"); 13 | if (_date.isAfter(beforeNow)) { 14 | return _date.fromNow(); 15 | } else { 16 | return _date.format("LL"); 17 | } 18 | } 19 | 20 | static toStringDataTime(date: Date): string { 21 | return moment(date).format("llll"); 22 | } 23 | } 24 | 25 | export const zip = (rows) => rows[0].map((_, c) => rows.map((row) => row[c])); 26 | export const sleep = (msec) => 27 | new Promise((resolve) => setTimeout(resolve, msec)); 28 | 29 | export const chunk = (arr: T, size: number): T[] => { 30 | return arr.reduce( 31 | (newarr, _, i) => (i % size ? newarr : [...newarr, arr.slice(i, i + size)]), 32 | [] as T[] 33 | ); 34 | }; 35 | 36 | export const setTitle = (title?: string): void => { 37 | let _title = process.env.REACT_APP_TITLE; 38 | if (title) { 39 | _title = `${title} - ${_title}`; 40 | } 41 | document.title = _title; 42 | 43 | analytics.setCurrentScreen(_title); 44 | analytics.logEvent("page_view", { 45 | page_location: location.pathname + location.search, 46 | page_path: location.pathname, 47 | page_title: _title, 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /frontend/app/src/plugins/Viewport.tsx: -------------------------------------------------------------------------------- 1 | export const updateViewport = (): void => { 2 | const vh = window.innerHeight / 100; 3 | const vw = window.innerWidth / 100; 4 | 5 | const root = document.documentElement; 6 | 7 | // 各カスタムプロパティに`window.innerHeight / 100`,`window.innerWidth / 100`の値をセット 8 | root.style.setProperty("--vh", `${vh}px`); 9 | console.log("updateViewport", vh, vw); 10 | if (vh > vw) { 11 | root.style.setProperty("--vmax", `${vh}px`); 12 | root.style.setProperty("--vmin", `${vw}px`); 13 | } else { 14 | root.style.setProperty("--vmax", `${vw}px`); 15 | root.style.setProperty("--vmin", `${vh}px`); 16 | } 17 | }; 18 | 19 | updateViewport(); 20 | 21 | window.addEventListener("resize", updateViewport); 22 | -------------------------------------------------------------------------------- /frontend/app/src/plugins/firebase.tsx: -------------------------------------------------------------------------------- 1 | import * as firebase from "firebase/app"; 2 | import "firebase/analytics"; 3 | import "firebase/auth"; 4 | import "firebase/firestore"; 5 | 6 | const firebaseConfig = { 7 | apiKey: process.env.REACT_APP_FIREBASE_API_KEY, 8 | authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, 9 | projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID, 10 | messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, 11 | appId: process.env.REACT_APP_FIREBASE_APP_ID, 12 | measurementId: process.env.REACT_APP_MEASUREMENT_ID, 13 | }; 14 | 15 | if (!firebase.apps.length) { 16 | firebase.initializeApp(firebaseConfig); 17 | } 18 | 19 | export const analytics = firebase.analytics(); 20 | export default firebase; 21 | -------------------------------------------------------------------------------- /frontend/app/src/plugins/useDBUserStatus.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import firebase from "./firebase"; 3 | import { useAuthState } from "react-firebase-hooks/auth"; 4 | import { User } from "./Schemas"; 5 | import DataController from "./DataController"; 6 | 7 | export default function useDBUserStatus(): [ 8 | User, 9 | boolean, 10 | firebase.auth.Error 11 | ] { 12 | const [user, loading, error] = useAuthState(firebase.auth()); 13 | const [dbUser, setDBUser] = useState(null); 14 | const [dbLoading, setDBLoading] = useState(true); 15 | 16 | console.log("useDBUserStatus", user, loading); 17 | 18 | useEffect(() => { 19 | const f = async (): Promise => { 20 | console.log("loading:", loading); 21 | if (!loading) { 22 | if (user) { 23 | await setDBLoading(true); 24 | const _dbUser = await DataController.getUserById(user.uid); 25 | setDBUser(_dbUser); 26 | setDBLoading(false); 27 | } else { 28 | setDBLoading(false); 29 | } 30 | } 31 | }; 32 | f(); 33 | }, [user, loading]); 34 | return [dbUser, dbLoading, error]; 35 | } 36 | -------------------------------------------------------------------------------- /frontend/app/src/types/import-png.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png" { 2 | const value: any; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/app/tests/App.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import * as TestUtils from 'react-dom/test-utils'; 4 | import App from '../src/components/App'; 5 | 6 | it('App is rendered', () => { 7 | // Render App in the document 8 | // const appElement: App = TestUtils.renderIntoDocument( 9 | // 10 | // ); 11 | 12 | // const appNode = ReactDOM.findDOMNode(appElement); 13 | 14 | // Verify text content 15 | // expect(appNode.textContent).toEqual('Hello World!Foo to the barz'); 16 | }); 17 | -------------------------------------------------------------------------------- /frontend/app/tests/__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /frontend/app/tests/__mocks__/shim.js: -------------------------------------------------------------------------------- 1 | global.requestAnimationFrame = (callback) => { 2 | setTimeout(callback, 0); 3 | }; 4 | -------------------------------------------------------------------------------- /frontend/app/tests/__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /frontend/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": false, 6 | "module": "commonjs", 7 | "target": "es5", 8 | "jsx": "react", 9 | "lib": ["es5", "es6", "dom"], 10 | "moduleResolution": "node", 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "baseUrl": ".", 18 | "paths": {"import-png": ["src/types/import-png"]}, 19 | "typeRoots": ["src/types", "node_modules/@types"], 20 | }, 21 | "include": [ 22 | "./src/**/*" 23 | ], 24 | "awesomeTypescriptLoaderOptions": { 25 | "reportFiles": [ 26 | "./src/**/*" 27 | ] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/bin/exec.sh: -------------------------------------------------------------------------------- 1 | # 一時的にDockerコンテナを立ち上げ、内部でコマンドを実行します. 2 | # Temporarily launch a Docker container and run commands inside it. 3 | EXEC_CMD='' 4 | 5 | UPP_CMD='docker-compose run --rm app sh -c' 6 | DOCKER_CMD=${UPP_CMD} 7 | 8 | while [ "$1" != "" ] 9 | do 10 | EXEC_CMD="${EXEC_CMD} $1" 11 | shift 12 | done 13 | 14 | echo ${DOCKER_CMD} "${EXEC_CMD}" 15 | ${DOCKER_CMD} "${EXEC_CMD}" 16 | -------------------------------------------------------------------------------- /frontend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - ./app:/usr/src/app 9 | ports: 10 | - ${REACT_APP_DEV_PORT}:${REACT_APP_DEV_PORT} 11 | - ${REACT_APP_PRD_PORT}:${REACT_APP_PRD_PORT} 12 | command: sh -c "echo Run at http://localhost:${REACT_APP_DEV_PORT}/ && yarn run start" 13 | # command: sh -c "echo Run at http://localhost:${REACT_APP_PRD_PORT}/ && yarn run start-prod" 14 | stdin_open: true 15 | env_file: .env 16 | --------------------------------------------------------------------------------