├── .devcontainer └── devcontainer.json ├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── compose.yaml ├── eslint.config.mjs ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.mjs ├── prettier.config.js ├── prisma ├── ERD.md ├── answers.ts ├── migrations │ ├── 0_init │ │ └── migration.sql │ ├── 20240420065740_create_workbook │ │ └── migration.sql │ ├── 20240420094749_create_workbook_task │ │ └── migration.sql │ ├── 20240420112403_add_on_delete_attr_to_workbook_task │ │ └── migration.sql │ ├── 20240420124016_add_title_to_workbook │ │ └── migration.sql │ ├── 20240421100012_add_genre_to_workbook_type │ │ └── migration.sql │ ├── 20240428120340_add_description_to_workbook │ │ └── migration.sql │ ├── 20240506052005_add_priority_to_workbook_task │ │ └── migration.sql │ ├── 20240506122004_use_uuid_in_workbook_task │ │ └── migration.sql │ ├── 20240506125633_fix_foreign_keys_in_workbook_task │ │ └── migration.sql │ ├── 20240511061347_add_author_id_to_workbook │ │ └── migration.sql │ ├── 20240511063729_remove_user_id_to_workbook │ │ └── migration.sql │ ├── 20240705225038_add_others_to_workbook_type │ │ └── migration.sql │ ├── 20240806043908_add_is_replenished_to_workbook │ │ └── migration.sql │ ├── 20240808004755_add_editorial_url_to_workbooks │ │ └── migration.sql │ ├── 20240813072910_add_comment_to_workbook_task │ │ └── migration.sql │ ├── 20240904092455_add_others_to_contest_type │ │ └── migration.sql │ ├── 20240905113517_add_curriculum_to_workbook_type │ │ └── migration.sql │ ├── 20240908080216_add_arc_to_contest_type │ │ └── migration.sql │ ├── 20240922115701_add_agc_to_contest_type │ │ └── migration.sql │ ├── 20241002081607_add_axc_like_to_contest_type │ │ └── migration.sql │ ├── 20241026052940_add_aoj_courses_and_pck_to_contest_type │ │ └── migration.sql │ ├── 20241109004736_add_university_to_contest_type │ │ └── migration.sql │ ├── 20241120113542_add_aoj_jag_to_contest_type │ │ └── migration.sql │ ├── 20250531081213_add_url_slug_to_workbook │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma ├── seed.ts ├── submission_statuses.ts ├── tags.ts ├── task_tags.ts ├── tasks.ts ├── tasks_for_production.ts ├── users.ts └── users_for_test.ts ├── renovate.json ├── src ├── app.css ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── actions │ │ ├── handle_dropdown.ts │ │ ├── prevent_enter_key.ts │ │ └── update_task_result.ts │ ├── clients │ │ ├── aizu_online_judge.ts │ │ ├── atcoder_problems.ts │ │ ├── cache.ts │ │ ├── cache_strategy.ts │ │ ├── http_client.ts │ │ └── index.ts │ ├── components │ │ ├── AtCoderUserValidationForm.svelte │ │ ├── AuthForm.svelte │ │ ├── ContainerWrapper.svelte │ │ ├── ExternalLinkIcon.svelte │ │ ├── ExternalLinkWrapper.svelte │ │ ├── Footer.svelte │ │ ├── FormWrapper.svelte │ │ ├── GoogleAnalytics.svelte │ │ ├── GradeLabel.svelte │ │ ├── Header.svelte │ │ ├── HeadingOne.svelte │ │ ├── InputFieldWrapper.svelte │ │ ├── LabelWithTooltips.svelte │ │ ├── LabelWrapper.svelte │ │ ├── MessageHelperWrapper.svelte │ │ ├── Messages │ │ │ └── ErrorMessageAndReturnButton.svelte │ │ ├── SelectWrapper.svelte │ │ ├── SpinnerWrapper.svelte │ │ ├── SubmissionButton.svelte │ │ ├── SubmissionStatus │ │ │ ├── AcceptedCounter.svelte │ │ │ ├── IconForUpdating.svelte │ │ │ ├── SubmissionStatusImage.svelte │ │ │ ├── UpdatingDropdown.svelte │ │ │ └── UpdatingModal.svelte │ │ ├── SubmissionStatusButton.svelte │ │ ├── TabItemWrapper.svelte │ │ ├── TagForm.svelte │ │ ├── TagListForEdit.svelte │ │ ├── TaskForm.svelte │ │ ├── TaskGradeList.svelte │ │ ├── TaskGrades │ │ │ ├── GradeGuidelineTable.svelte │ │ │ └── grade_guideline_table_data.ts │ │ ├── TaskList.svelte │ │ ├── TaskListForEdit.svelte │ │ ├── TaskListSorted.svelte │ │ ├── TaskSearchBox.svelte │ │ ├── TaskTables │ │ │ ├── TaskTable.svelte │ │ │ └── TaskTableBodyCell.svelte │ │ ├── ThermometerProgressBar.svelte │ │ ├── ToastWrapper │ │ │ └── ErrorMessageToast.svelte │ │ ├── TooltipWrapper.svelte │ │ ├── Trophies │ │ │ └── CompletedTasks.svelte │ │ ├── UserAccountDeletionForm.svelte │ │ ├── UserProfile.svelte │ │ ├── WarningMessageOnDeletingAccount.svelte │ │ ├── WorkBook │ │ │ ├── CommentAndHint.svelte │ │ │ ├── WorkBookForm.svelte │ │ │ └── WorkBookInputFields.svelte │ │ ├── WorkBookTasks │ │ │ └── WorkBookTasksTable.svelte │ │ └── WorkBooks │ │ │ ├── PublicationStatusLabel.svelte │ │ │ ├── TitleTableBodyCell.svelte │ │ │ ├── TitleTableHeadCell.svelte │ │ │ ├── WorkBookBaseTable.svelte │ │ │ └── WorkBookList.svelte │ ├── constants │ │ ├── external-links.ts │ │ ├── forms.ts │ │ ├── http-response-status-codes.ts │ │ ├── navbar-links.ts │ │ ├── product-info.ts │ │ ├── tailwind-helper.ts │ │ └── urls.ts │ ├── example.test.ts │ ├── index.ts │ ├── server │ │ ├── auth.ts │ │ ├── database.ts │ │ └── sample_data.ts │ ├── services │ │ ├── answers.ts │ │ ├── submission_status.ts │ │ ├── tags.ts │ │ ├── task_results.ts │ │ ├── task_tags.ts │ │ ├── tasks.ts │ │ ├── tasktagsApiService.ts │ │ ├── users.ts │ │ ├── validateApiService.ts │ │ ├── workbook_tasks.ts │ │ └── workbooks.ts │ ├── stores │ │ ├── active_contest_type.svelte.ts │ │ ├── active_problem_list_tab.svelte.ts │ │ ├── active_workbook_tab.ts │ │ ├── error_message.ts │ │ ├── local_storage_helper.svelte.ts │ │ ├── replenishment_workbook.svelte.ts │ │ └── task_grades_by_workbook_type.ts │ ├── types │ │ ├── answer.ts │ │ ├── apidata.ts │ │ ├── authorship.ts │ │ ├── contest.ts │ │ ├── contest_table_provider.ts │ │ ├── floating_message.ts │ │ ├── flowbite-svelte-wrapper.ts │ │ ├── submission.ts │ │ ├── tag.ts │ │ ├── task.ts │ │ ├── tasktag.ts │ │ ├── url.ts │ │ ├── user.ts │ │ └── workbook.ts │ ├── utils │ │ ├── account_transfer.ts │ │ ├── authorship.ts │ │ ├── contest.ts │ │ ├── contest_table_provider.ts │ │ ├── hash.ts │ │ ├── html.ts │ │ ├── newline.ts │ │ ├── task.ts │ │ ├── time.ts │ │ ├── url.ts │ │ ├── workbook.ts │ │ ├── workbook_tasks.ts │ │ └── workbooks.ts │ └── zod │ │ └── schema.ts ├── routes │ ├── (admin) │ │ ├── account_transfer │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── tags │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── [tag_id] │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ └── tasks │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── [task_id] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── (auth) │ │ ├── login │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── logout │ │ │ └── +page.server.ts │ │ └── signup │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── +error.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ ├── about │ │ ├── +page.svelte │ │ └── SectionSnippets.svelte │ ├── forgot_password │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── problems │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ └── [slug] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── sitemap.xml │ │ └── +server.ts │ ├── users │ │ ├── [username] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ └── edit │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ └── workbooks │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── [slug] │ │ ├── +page.server.ts │ │ └── +page.svelte │ │ ├── create │ │ ├── +page.server.ts │ │ └── +page.svelte │ │ └── edit │ │ └── [slug] │ │ ├── +page.server.ts │ │ └── +page.svelte └── test │ └── lib │ ├── clients │ ├── aizu_online_judge.test.ts │ ├── atcoder_problems.test.ts │ ├── cache.test.ts │ ├── record_requests.ts │ └── test_data │ │ ├── aizu_online_judge │ │ ├── contests.json │ │ └── tasks.json │ │ └── atcoder_problems │ │ ├── contests.json │ │ └── tasks.json │ ├── common │ ├── test_cases │ │ └── zip.ts │ ├── test_helpers.test.ts │ └── test_helpers.ts │ ├── stores │ ├── active_contest_type.svelte.test.ts │ ├── active_problem_list_tab.svelte.test.ts │ ├── active_workbook_tab.test.ts │ ├── replenishment_workbook.test.ts │ └── task_grades_by_workbook_type.test.ts │ ├── utils │ ├── account_transfer.test.ts │ ├── authorship.test.ts │ ├── contest.test.ts │ ├── contest_table_provider.test.ts │ ├── html.test.ts │ ├── task.test.ts │ ├── task_grade.test.ts │ ├── test_cases │ │ ├── account_transfer.ts │ │ ├── contest_name_and_task_index.ts │ │ ├── contest_name_labels.ts │ │ ├── contest_table_provider.ts │ │ ├── contest_type.ts │ │ ├── task_results.ts │ │ ├── task_table_header_name.ts │ │ └── task_url.ts │ ├── time.test.ts │ ├── url.test.ts │ ├── workbook.test.ts │ ├── workbook_tasks.test.ts │ └── workbooks.test.ts │ └── zod │ ├── account_transfer_schema.test.ts │ ├── auth_schema.test.ts │ └── workbook_schema.test.ts ├── static ├── ac.png ├── ac_with_editorial.png ├── completed.png ├── contest_table.png ├── favicon.png ├── grade_10Q_details.png ├── grade_11Q_4Q.png ├── ns.png ├── robots.txt ├── tle.png ├── update_answer.png └── wa.png ├── supabase └── migrations │ └── 20231122085404_remote_schema.sql ├── svelte.config.js ├── tailwind.config.ts ├── tests ├── about_page.test.ts ├── global.setup.ts ├── global.teardown.ts ├── robots.test.ts ├── signin.test.ts ├── sitemap.test.ts └── toppage.test.ts ├── tsconfig.json ├── vercel.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .history 2 | .pnpm-store 3 | .svelte-kit 4 | node_modules 5 | package-lock.json 6 | LICENSE 7 | CONTRIBUTING.md 8 | README.md -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [KATO-Hiro] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report / バグの報告 3 | about: Create a report to help us improve. 4 | --- 5 | 6 | ## Summary / 概要 7 | 8 | - Describe the bug. A clear and concise description of what the bug is. 9 | 10 | ## Steps to reproduce / 再現方法 11 | 12 | 1. xxx 13 | 2. yyy 14 | 3. zzz 15 | 16 | ### environments 17 | 18 | - Desktop (please complete the following information): 19 | 20 | OS: [e.g. Mac, Windows11, Linux] 21 | Browser [e.g. Chrome, Safari, Edge, Firefox] 22 | Version [e.g. 117.0.05938.44] 23 | 24 | - Smartphone (please complete the following information): 25 | 26 | Device: [e.g. iPhone15, Android13] 27 | OS: [e.g. iOS16.5, Android 13] 28 | Browser [e.g. Chrome, Safari] 29 | Version [e.g. 16.5] 30 | 31 | - Tablet (please complete the following information): 32 | 33 | OS: [e.g. Android OS, Chrome OS, Windows11, iPad OS] 34 | Browser [e.g. Chrome, Safari, Edge, Firefox] 35 | Version [e.g. 16.5] 36 | 37 | ## Expected behavior / 期待される挙動 38 | 39 | - A clear and concise description of what you expected to happen. 40 | 41 | ## Actual behavior / 実際の挙動 42 | 43 | ### Screenshots 44 | 45 | - If applicable, add screenshots to help explain your problem. 46 | 47 | ## Other notes / その他 48 | 49 | - Add any other context about the problem here. 50 | - Will you try to create a pull request? 51 | - yes / no 52 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request / 機能要望 3 | about: Suggest an idea for this project. 4 | --- 5 | 6 | ## Description / 説明 7 | 8 | - A clear and concise description of what you want to happen. 9 | 10 | ## Motivation / 動機 11 | 12 | - A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | ## Other notes / その他 15 | 16 | - Add any other context or screenshots about the feature request here. 17 | - Will you try to create a pull request? 18 | - yes / no 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Enable version updates for npm 9 | - package-ecosystem: 'npm' 10 | directory: '/' 11 | # Check the npm registry for updates every day (weekdays) 12 | schedule: 13 | interval: 'daily' 14 | versioning-strategy: increase 15 | 16 | - package-ecosystem: 'github-actions' 17 | # Workflow files stored in the 18 | # default location of `.github/workflows` 19 | directory: '/' 20 | schedule: 21 | interval: 'weekly' 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # pnpm 133 | .pnpm-store 134 | 135 | # SvelteKit 136 | .DS_Store 137 | /build 138 | /.svelte-kit 139 | /package 140 | .env.* 141 | !.env.example 142 | vite.config.js.timestamp-* 143 | vite.config.ts.timestamp-* 144 | 145 | # VS Code 146 | .history 147 | 148 | # Vercel 149 | .vercel 150 | 151 | # Prisma 152 | prisma/.fabbrica 153 | 154 | # Directory for playwright test results 155 | test-results 156 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | # necessary for a PNPM bug: https://github.com/pnpm/pnpm/issues/5152#issuecomment-1223449173 4 | strict-peer-dependencies=false 5 | package-import-method=clone-or-copy 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | /.pnpm-store 12 | pnpm-lock.yaml 13 | package-lock.json 14 | yarn.lock 15 | vite.config.js.timestamp-* 16 | vite.config.ts.timestamp-* 17 | 18 | prisma/ERD.md 19 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "all", 6 | "printWidth": 100, 7 | "plugins": ["prettier-plugin-svelte"], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # See: 2 | # https://github.com/devcontainers/images/tree/main/src/javascript-node 3 | ARG NODE_VERSION=22 4 | FROM mcr.microsoft.com/devcontainers/javascript-node:${NODE_VERSION} 5 | 6 | WORKDIR /usr/src/app 7 | COPY . /usr/src/app 8 | 9 | RUN apt-get update \ 10 | && apt-get -y install --no-install-recommends fish 11 | 12 | ENV NODE_PATH=/node_modules 13 | ENV PATH=$PATH:/node_modules/.bin 14 | 15 | RUN pnpm install --frozen-lockfile 16 | 17 | CMD ["pnpm", "dev"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 - 2025 AtCoder NoviSteps 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: . 4 | ports: 5 | - '5173:5173' 6 | tty: true 7 | volumes: 8 | - .:/usr/src/app:cached 9 | - ./node_modules:/usr/src/app/node_modules:cached 10 | environment: 11 | - NODE_ENV=development 12 | - DATABASE_URL=postgresql://db_user:db_password@db:5432/test_db?pgbouncer=true&connection_limit=10&connect_timeout=60&statement_timeout=60000 # Note: Local server cannot start if port is set to db:6543. 13 | - DIRECT_URL=postgresql://db_user:db_password@db:5432/test_db 14 | command: sleep infinity 15 | depends_on: 16 | - db 17 | 18 | db: 19 | image: postgres:15.1 20 | restart: unless-stopped 21 | volumes: 22 | - postgres-data:/var/lib/postgresql/data:cached 23 | environment: 24 | POSTGRES_USER: db_user 25 | POSTGRES_PASSWORD: db_password 26 | POSTGRES_DB: test_db 27 | POSTGRES_INITDB_ARGS: '--encoding=UTF8' 28 | ports: 29 | - '6543:5432' 30 | 31 | volumes: 32 | postgres-data: 33 | driver: local 34 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://eslint.org/docs/latest/use/configure/migration-guide 3 | // https://eslint.org/docs/latest/use/migrate-to-9.0.0 4 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 5 | import globals from 'globals'; 6 | import tsParser from '@typescript-eslint/parser'; 7 | import parser from 'svelte-eslint-parser'; 8 | import path from 'node:path'; 9 | import { fileURLToPath } from 'node:url'; 10 | import js from '@eslint/js'; 11 | import { FlatCompat } from '@eslint/eslintrc'; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | const compat = new FlatCompat({ 16 | baseDirectory: __dirname, 17 | recommendedConfig: js.configs.recommended, 18 | allConfig: js.configs.all, 19 | }); 20 | 21 | export default [ 22 | { 23 | ignores: [ 24 | '**/.DS_Store', 25 | '**/node_modules', 26 | 'build', 27 | 'coverage', 28 | '.svelte-kit', 29 | '.vercel', 30 | 'package', 31 | '**/.env', 32 | '**/.env.*', 33 | '!**/.env.example', 34 | '.pnpm-store', 35 | '**/pnpm-lock.yaml', 36 | '**/package-lock.json', 37 | '**/yarn.lock', 38 | '**/vite.config.js.timestamp-*', 39 | '**/vite.config.ts.timestamp-*', 40 | 'prisma/.fabbrica/index.ts', 41 | ], 42 | }, 43 | ...compat.extends( 44 | 'eslint:recommended', 45 | 'plugin:@typescript-eslint/recommended', 46 | 'plugin:svelte/recommended', 47 | 'prettier', 48 | ), 49 | { 50 | plugins: { 51 | '@typescript-eslint': typescriptEslint, 52 | }, 53 | 54 | languageOptions: { 55 | globals: { 56 | ...globals.browser, 57 | ...globals.node, 58 | }, 59 | 60 | parser: tsParser, 61 | ecmaVersion: 'latest', 62 | sourceType: 'module', 63 | 64 | parserOptions: { 65 | extraFileExtensions: ['.svelte'], 66 | }, 67 | }, 68 | 69 | rules: { 70 | '@typescript-eslint/ban-ts-comment': [ 71 | 'error', 72 | { 73 | 'ts-expect-error': 'allow-with-description', 74 | 'ts-ignore': false, 75 | }, 76 | ], 77 | }, 78 | }, 79 | { 80 | files: ['**/*.svelte'], 81 | 82 | languageOptions: { 83 | parser: parser, 84 | ecmaVersion: 5, 85 | sourceType: 'script', 86 | 87 | parserOptions: { 88 | parser: '@typescript-eslint/parser', 89 | }, 90 | }, 91 | }, 92 | ]; 93 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | webServer: { 5 | command: 'pnpm build && pnpm preview', 6 | port: 4173, 7 | timeout: 10000 * 1000, 8 | }, 9 | testDir: 'tests', 10 | projects: [ 11 | //{ 12 | // name: 'setup db', 13 | // testMatch: /global\.setup\.ts/, 14 | // teardown:'cleanup db', 15 | //}, 16 | //{ 17 | // name: 'cleanup db', 18 | // testMatch: /global\.teardown\.ts/, 19 | //}, 20 | { 21 | name: 'all', 22 | testMatch: /(.+\.)?(test|spec)\.[jt]s/, 23 | //dependencies: ['setup db'], 24 | }, 25 | ], 26 | }; 27 | 28 | export default config; 29 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | import tailwindcss from 'tailwindcss'; 2 | import autoprefixer from 'autoprefixer'; 3 | 4 | export default { 5 | plugins: [ 6 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind, 7 | tailwindcss, 8 | //But others, like autoprefixer, need to run after, 9 | autoprefixer, 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://prettier.io/docs/en/configuration.html 3 | // https://github.com/tailwindlabs/prettier-plugin-tailwindcss/issues/207 4 | module.exports = { 5 | plugins: ['prettier-plugin-tailwindcss'], 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/answers.ts: -------------------------------------------------------------------------------- 1 | export const answers = [ 2 | { 3 | id: '1', 4 | task_id: 'abc331_a', 5 | user_id: '3', 6 | status_id: '1', 7 | }, 8 | { 9 | id: '2', 10 | task_id: 'abc331_a', 11 | user_id: '2', 12 | status_id: '2', 13 | }, 14 | { 15 | id: '3', 16 | task_id: 'abc331_a', 17 | user_id: '4', 18 | status_id: '1', 19 | }, 20 | { 21 | id: '4', 22 | task_id: 'abc331_a', 23 | user_id: '5', 24 | status_id: '2', 25 | }, 26 | { 27 | id: '5', 28 | task_id: 'abc331_b', 29 | user_id: '3', 30 | status_id: '3', 31 | }, 32 | { 33 | id: '6', 34 | task_id: 'abc331_b', 35 | user_id: '2', 36 | status_id: '3', 37 | }, 38 | { 39 | id: '7', 40 | task_id: 'abc331_g', 41 | user_id: '4', 42 | status_id: '3', 43 | }, 44 | { 45 | id: '8', 46 | task_id: 'abc331_b', 47 | user_id: '5', 48 | status_id: '3', 49 | }, 50 | { 51 | id: '9', 52 | task_id: 'abc231_a', 53 | user_id: '9', 54 | status_id: '2', 55 | }, 56 | { 57 | id: '10', 58 | task_id: 'abc214_a', 59 | user_id: '9', 60 | status_id: '3', 61 | }, 62 | { 63 | id: '11', 64 | task_id: 'abc202_a', 65 | user_id: '9', 66 | status_id: '1', 67 | }, 68 | ]; 69 | -------------------------------------------------------------------------------- /prisma/migrations/20240420065740_create_workbook/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "WorkBookType" AS ENUM ('CREATED_BY_USER', 'TEXTBOOK', 'SOLUTION', 'THEME'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "workbook" ( 6 | "id" SERIAL NOT NULL, 7 | "userId" TEXT NOT NULL, 8 | "isPublished" BOOLEAN NOT NULL DEFAULT false, 9 | "isOfficial" BOOLEAN NOT NULL DEFAULT false, 10 | "workBookType" "WorkBookType" NOT NULL DEFAULT 'CREATED_BY_USER', 11 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | "updatedAt" TIMESTAMP(3) NOT NULL, 13 | 14 | CONSTRAINT "workbook_pkey" PRIMARY KEY ("id") 15 | ); 16 | 17 | -- AddForeignKey 18 | ALTER TABLE "workbook" ADD CONSTRAINT "workbook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 19 | -------------------------------------------------------------------------------- /prisma/migrations/20240420094749_create_workbook_task/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "workbooktask" ( 3 | "id" TEXT NOT NULL, 4 | "workBookId" INTEGER NOT NULL, 5 | "taskId" TEXT NOT NULL, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" TIMESTAMP(3) NOT NULL, 8 | 9 | CONSTRAINT "workbooktask_pkey" PRIMARY KEY ("id") 10 | ); 11 | 12 | -- CreateIndex 13 | CREATE UNIQUE INDEX "workbooktask_id_key" ON "workbooktask"("id"); 14 | 15 | -- AddForeignKey 16 | ALTER TABLE "workbooktask" ADD CONSTRAINT "workbooktask_workBookId_fkey" FOREIGN KEY ("workBookId") REFERENCES "workbook"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 17 | 18 | -- AddForeignKey 19 | ALTER TABLE "workbooktask" ADD CONSTRAINT "workbooktask_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "task"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 20 | -------------------------------------------------------------------------------- /prisma/migrations/20240420112403_add_on_delete_attr_to_workbook_task/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "workbooktask" DROP CONSTRAINT "workbooktask_workBookId_fkey"; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "workbooktask" ADD CONSTRAINT "workbooktask_workBookId_fkey" FOREIGN KEY ("workBookId") REFERENCES "workbook"("id") ON DELETE CASCADE ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20240420124016_add_title_to_workbook/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `title` to the `workbook` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "workbook" ADD COLUMN "title" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20240421100012_add_genre_to_workbook_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "WorkBookType" ADD VALUE 'GENRE'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240428120340_add_description_to_workbook/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "workbook" ADD COLUMN "description" TEXT NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240506052005_add_priority_to_workbook_task/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `priority` to the `workbooktask` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "workbooktask" ADD COLUMN "priority" INTEGER NOT NULL; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20240506122004_use_uuid_in_workbook_task/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "workbooktask_id_key"; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240506125633_fix_foreign_keys_in_workbook_task/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "workbooktask" DROP CONSTRAINT "workbooktask_taskId_fkey"; 3 | 4 | -- AddForeignKey 5 | ALTER TABLE "workbooktask" ADD CONSTRAINT "workbooktask_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "task"("task_id") ON DELETE RESTRICT ON UPDATE CASCADE; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20240511061347_add_author_id_to_workbook/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "workbook" ADD COLUMN "authorId" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240511063729_remove_user_id_to_workbook/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `userId` on the `workbook` table. All the data in the column will be lost. 5 | - Made the column `authorId` on table `workbook` required. This step will fail if there are existing NULL values in that column. 6 | 7 | */ 8 | -- DropForeignKey 9 | ALTER TABLE "workbook" DROP CONSTRAINT "workbook_userId_fkey"; 10 | 11 | -- AlterTable 12 | ALTER TABLE "workbook" DROP COLUMN "userId", 13 | ALTER COLUMN "authorId" SET NOT NULL; 14 | 15 | -- AddForeignKey 16 | ALTER TABLE "workbook" ADD CONSTRAINT "workbook_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 17 | -------------------------------------------------------------------------------- /prisma/migrations/20240705225038_add_others_to_workbook_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "WorkBookType" ADD VALUE 'OTHERS'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240806043908_add_is_replenished_to_workbook/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "workbook" ADD COLUMN "isReplenished" BOOLEAN NOT NULL DEFAULT false; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240808004755_add_editorial_url_to_workbooks/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "workbook" ADD COLUMN "editorialUrl" TEXT NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240813072910_add_comment_to_workbook_task/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "workbooktask" ADD COLUMN "comment" TEXT NOT NULL DEFAULT ''; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240904092455_add_others_to_contest_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ContestType" ADD VALUE 'OTHERS'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240905113517_add_curriculum_to_workbook_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "WorkBookType" ADD VALUE 'CURRICULUM'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240908080216_add_arc_to_contest_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ContestType" ADD VALUE 'ARC'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20240922115701_add_agc_to_contest_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ContestType" ADD VALUE 'AGC'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20241002081607_add_axc_like_to_contest_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | -- This migration adds more than one value to an enum. 3 | -- With PostgreSQL versions 11 and earlier, this is not possible 4 | -- in a single migration. This can be worked around by creating 5 | -- multiple migrations, each migration adding only one value to 6 | -- the enum. 7 | 8 | 9 | ALTER TYPE "ContestType" ADD VALUE 'ABC_LIKE'; 10 | ALTER TYPE "ContestType" ADD VALUE 'ARC_LIKE'; 11 | ALTER TYPE "ContestType" ADD VALUE 'AGC_LIKE'; 12 | -------------------------------------------------------------------------------- /prisma/migrations/20241026052940_add_aoj_courses_and_pck_to_contest_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | -- This migration adds more than one value to an enum. 3 | -- With PostgreSQL versions 11 and earlier, this is not possible 4 | -- in a single migration. This can be worked around by creating 5 | -- multiple migrations, each migration adding only one value to 6 | -- the enum. 7 | 8 | 9 | ALTER TYPE "ContestType" ADD VALUE 'AOJ_COURSES'; 10 | ALTER TYPE "ContestType" ADD VALUE 'AOJ_PCK'; 11 | -------------------------------------------------------------------------------- /prisma/migrations/20241109004736_add_university_to_contest_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ContestType" ADD VALUE 'UNIVERSITY'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20241120113542_add_aoj_jag_to_contest_type/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterEnum 2 | ALTER TYPE "ContestType" ADD VALUE 'AOJ_JAG'; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20250531081213_add_url_slug_to_workbook/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[urlSlug]` on the table `workbook` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "workbook" ADD COLUMN "urlSlug" TEXT; 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "workbook_urlSlug_key" ON "workbook"("urlSlug"); 12 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /prisma/submission_statuses.ts: -------------------------------------------------------------------------------- 1 | // FIXME: 配色をsrc/lib/services/submission_status.tsに合わせる 2 | // 「挑戦中」と「未挑戦」に関しては、本番DBで既にwa / nsで登録されているので変更しない方がいいかも。 3 | export const submission_statuses = [ 4 | { 5 | id: '1', 6 | status_name: 'ac', 7 | label_name: 'AC', 8 | image_path: 'ac.png', 9 | is_AC: true, 10 | button_color: 'green', 11 | }, 12 | { 13 | id: '2', 14 | status_name: 'ac_with_editorial', 15 | label_name: '解説AC', 16 | image_path: 'ac_with_editorial.png', 17 | is_AC: true, 18 | button_color: 'blue', 19 | }, 20 | { 21 | id: '3', 22 | status_name: 'wa', 23 | label_name: '挑戦中', 24 | image_path: 'wa.png', 25 | is_AC: false, 26 | button_color: 'yellow', 27 | }, 28 | { 29 | id: '4', 30 | status_name: 'ns', 31 | label_name: '未挑戦', 32 | image_path: 'ns.png', 33 | is_AC: false, 34 | button_color: 'light', 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /prisma/tasks_for_production.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://kenkoooo.com/atcoder/resources/problems.json 3 | export const tasks = []; 4 | -------------------------------------------------------------------------------- /prisma/users.ts: -------------------------------------------------------------------------------- 1 | import { Roles } from '@prisma/client'; 2 | 3 | export const users = [ 4 | { id: '1', name: 'admin', role: Roles.ADMIN }, 5 | { id: '2', name: 'guest', role: Roles.USER }, 6 | { id: '3', name: 'Alice', role: Roles.USER }, 7 | { id: '4', name: 'Bob23', role: Roles.USER }, 8 | { id: '5', name: 'Carol', role: Roles.USER }, 9 | { id: '6', name: 'Dave4', role: Roles.USER }, 10 | { id: '7', name: 'Ellen', role: Roles.USER }, 11 | { id: '8', name: 'Frank', role: Roles.USER }, 12 | { id: '9', name: 'hogehoge', role: Roles.USER }, 13 | ]; 14 | -------------------------------------------------------------------------------- /prisma/users_for_test.ts: -------------------------------------------------------------------------------- 1 | import { Roles } from '@prisma/client'; 2 | 3 | export const users = [ 4 | { name: 'admin_test', role: Roles.ADMIN }, 5 | { name: 'guest_test', role: Roles.USER }, 6 | ]; 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base", ":prHourlyLimitNone"], 4 | "timezone": "Asia/Tokyo", 5 | "schedule": ["before 5am"], 6 | "dependencyDashboard": true 7 | } 8 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | /* Write your global styles here, in PostCSS syntax */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | body { 7 | @apply flex flex-col items-center; 8 | 9 | font-family: Roboto, 'Noto Sans JP', sans-serif; 10 | font-optical-sizing: auto; 11 | font-weight: 400; 12 | font-style: normal; 13 | } 14 | 15 | #root { 16 | @apply w-full max-w-screen-xl; 17 | } 18 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | 3 | import type { Roles } from '@prisma/client'; 4 | 5 | // for information about these interfaces 6 | declare global { 7 | namespace App { 8 | // interface Error {} 9 | interface Locals { 10 | auth: import('lucia').AuthRequest; 11 | user: { 12 | id: string; 13 | name: string; 14 | role: Roles; 15 | atcoder_name: string; 16 | is_validated: boolean | null; 17 | }; 18 | } 19 | // interface PageData {} 20 | // interface Platform {} 21 | } 22 | } 23 | 24 | // See: 25 | // https://lucia-auth.com/getting-started/sveltekit/ 26 | /// 27 | declare global { 28 | namespace Lucia { 29 | type Auth = import('$lib/server/auth').Auth; 30 | type UserAttributes = { 31 | username: string; 32 | role: Roles; 33 | }; 34 | // type SessionAttributes = {}; 35 | } 36 | } 37 | 38 | // THIS IS IMPORTANT!!! 39 | export {}; 40 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | %sveltekit.head% 18 | 19 | 20 |
%sveltekit.body%
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://lucia-auth.com/getting-started/sveltekit/ 3 | // https://github.com/joysofcode/sveltekit-deploy 4 | // https://tech-blog.rakus.co.jp/entry/20230209/sveltekit 5 | import { auth } from '$lib/server/auth'; 6 | import type { Handle } from '@sveltejs/kit'; 7 | 8 | import * as userService from '$lib/services/users'; 9 | 10 | export const handle: Handle = async ({ event, resolve }) => { 11 | event.locals.auth = auth.handleRequest(event); 12 | const session = await event.locals.auth.validate(); 13 | 14 | if (!session) { 15 | return await resolve(event); 16 | } 17 | 18 | const user = await userService.getUser(session?.user.username as string); 19 | 20 | if (user) { 21 | event.locals.user = { 22 | id: user.id, 23 | name: user.username, 24 | role: user.role, 25 | atcoder_name: user.atcoder_username, 26 | is_validated: user.atcoder_validation_status, 27 | }; 28 | } 29 | 30 | return await resolve(event); 31 | }; 32 | -------------------------------------------------------------------------------- /src/lib/actions/prevent_enter_key.ts: -------------------------------------------------------------------------------- 1 | // Usage: 2 | // 3 | // 1. 任意のSvelteコンポーネントにインポート 4 | // 7 | 8 | // 2. formに use:preventEnterKey を追加 9 | //
10 | // 11 | //
12 | export function preventEnterKey(node: HTMLElement) { 13 | node.addEventListener('keydown', handleKeyDown); 14 | 15 | return { 16 | destroy() { 17 | node.removeEventListener('keydown', handleKeyDown); 18 | }, 19 | }; 20 | } 21 | 22 | function handleKeyDown(event: KeyboardEvent) { 23 | if (event.key === 'Enter') { 24 | event.preventDefault(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/actions/update_task_result.ts: -------------------------------------------------------------------------------- 1 | import { fail } from '@sveltejs/kit'; 2 | 3 | import * as crud from '$lib/services/task_results'; 4 | import { BAD_REQUEST, UNAUTHORIZED } from '$lib/constants/http-response-status-codes'; 5 | 6 | // HACK: clickを1回実行するとactionsが2回実行されてしまう。原因と修正方法が分かっていない状態。 7 | export const updateTaskResult = async ( 8 | { request, locals }: { request: Request; locals: App.Locals }, 9 | operationLog: string, 10 | ) => { 11 | console.log(operationLog); 12 | const response = await request.formData(); 13 | const session = await locals.auth.validate(); 14 | 15 | if (!session || !session.user || !session.user.userId) { 16 | return fail(UNAUTHORIZED, { 17 | message: 'ログインしていないか、もしくは、ログイン情報が不正です。', 18 | }); 19 | } 20 | 21 | const userId = session.user.userId; 22 | 23 | try { 24 | const taskId = response.get('taskId') as string; 25 | const submissionStatus = response.get('submissionStatus') as string; 26 | 27 | await crud.updateTaskResult(taskId, submissionStatus, userId); 28 | } catch (error) { 29 | console.error('Failed to update task result: ', error); 30 | return fail(BAD_REQUEST); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /src/lib/clients/atcoder_problems.ts: -------------------------------------------------------------------------------- 1 | import { ContestSiteApiClient } from '$lib/clients/http_client'; 2 | 3 | import type { ContestsForImport } from '$lib/types/contest'; 4 | import type { TasksForImport } from '$lib/types/task'; 5 | 6 | import { ATCODER_PROBLEMS_API_BASE_URL } from '$lib/constants/urls'; 7 | 8 | /** 9 | * The `AtCoderProblemsApiClient` class provides methods to interact with the AtCoder Problems API. 10 | * It extends the `ContestSiteApiClient` class and includes methods to fetch contests and tasks. 11 | * 12 | * @class 13 | * @extends {ContestSiteApiClient} 14 | * 15 | * @method getContests 16 | * Fetches the list of contests from the AtCoder Problems API. 17 | * @returns {Promise} A promise that resolves to the list of contests. 18 | * @throws Will throw an error if the fetch operation fails or if the response is invalid. 19 | * 20 | * @method getTasks 21 | * Fetches tasks from the AtCoder Problems API. 22 | * @returns {Promise} A promise that resolves to an array of tasks for import. 23 | * @throws Will throw an error if the fetch operation fails or if the response validation fails. 24 | */ 25 | export class AtCoderProblemsApiClient extends ContestSiteApiClient { 26 | /** 27 | * Fetches the list of contests from the AtCoder Problems API. 28 | * 29 | * @returns {Promise} A promise that resolves to the list of contests. 30 | * @throws Will throw an error if the fetch operation fails or if the response is invalid. 31 | */ 32 | async getContests(): Promise { 33 | try { 34 | const contests = await this.fetchApiWithConfig({ 35 | baseApiUrl: ATCODER_PROBLEMS_API_BASE_URL, 36 | endpoint: 'contests.json', 37 | errorMessage: 'Failed to fetch contests from AtCoder Problems API', 38 | validateResponse: (data) => Array.isArray(data) && data.length > 0, 39 | }); 40 | 41 | console.log(`Found AtCoder: ${contests.length} contests.`); 42 | 43 | return contests; 44 | } catch (error) { 45 | console.error(`Failed to fetch from AtCoder contests`, error); 46 | return []; 47 | } 48 | } 49 | 50 | /** 51 | * Fetches tasks from the AtCoder Problems API. 52 | * 53 | * @returns {Promise} A promise that resolves to an array of tasks for import. 54 | * @throws Will throw an error if the fetch operation fails or if the response validation fails. 55 | */ 56 | async getTasks(): Promise { 57 | try { 58 | const tasks = await this.fetchApiWithConfig({ 59 | baseApiUrl: ATCODER_PROBLEMS_API_BASE_URL, 60 | endpoint: 'problems.json', 61 | errorMessage: 'Failed to fetch tasks from AtCoder Problems API', 62 | validateResponse: (data) => Array.isArray(data) && data.length > 0, 63 | }); 64 | 65 | console.log(`Found AtCoder: ${tasks.length} tasks.`); 66 | 67 | return tasks; 68 | } catch (error) { 69 | console.error(`Failed to fetch from AtCoder tasks`, error); 70 | return []; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/clients/cache_strategy.ts: -------------------------------------------------------------------------------- 1 | import { Cache } from '$lib/clients/cache'; 2 | 3 | import type { ContestsForImport } from '$lib/types/contest'; 4 | import type { TasksForImport } from '$lib/types/task'; 5 | 6 | /** 7 | * A strategy for caching contest and task data. 8 | * Separates the caching logic from the data fetching concerns. 9 | */ 10 | export class ContestTaskCache { 11 | /** 12 | * Constructs a cache strategy with the specified contest and task caches. 13 | * @param contestCache - Cache for storing contest import data 14 | * @param taskCache - Cache for storing task import data 15 | */ 16 | constructor( 17 | private readonly contestCache: Cache, 18 | private readonly taskCache: Cache, 19 | ) {} 20 | 21 | /** 22 | * Retrieves data from cache if available, otherwise fetches it using the provided function. 23 | * 24 | * @template T - The type of data being cached and returned 25 | * @param {string} key - The unique identifier for the cached data 26 | * @param {() => Promise} fetchFunction - Function that returns a Promise resolving to data of type T 27 | * @param {Cache} cache - Cache object with get and set methods for type T 28 | * @returns {Promise} - The cached data or newly fetched data 29 | * 30 | * @example 31 | * const result = await cacheInstance.getCachedOrFetch( 32 | * 'contests-123', 33 | * () => api.fetchContests(), 34 | * contestCache 35 | * ); 36 | */ 37 | async getCachedOrFetch( 38 | key: string, 39 | fetchFunction: () => Promise, 40 | cache: Cache, 41 | ): Promise { 42 | const cachedData = cache.get(key); 43 | 44 | if (cachedData) { 45 | console.log(`Using cached data for ${key}`); 46 | return cachedData; 47 | } 48 | 49 | console.log(`Cache miss for ${key}, fetching...`); 50 | 51 | try { 52 | const contestTasks = await fetchFunction(); 53 | cache.set(key, contestTasks); 54 | 55 | return contestTasks; 56 | } catch (error) { 57 | console.error(`Failed to fetch contests and/or tasks for ${key}:`, error); 58 | return [] as unknown as T; 59 | } 60 | } 61 | 62 | /** 63 | * Gets contests from cache or fetches them. 64 | */ 65 | async getCachedOrFetchContests( 66 | key: string, 67 | fetchFunction: () => Promise, 68 | ): Promise { 69 | return this.getCachedOrFetch(key, fetchFunction, this.contestCache); 70 | } 71 | 72 | /** 73 | * Gets tasks from cache or fetches them. 74 | */ 75 | async getCachedOrFetchTasks( 76 | key: string, 77 | fetchFunction: () => Promise, 78 | ): Promise { 79 | return this.getCachedOrFetch(key, fetchFunction, this.taskCache); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/lib/components/ContainerWrapper.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 | {@render children?.()} 14 |
15 | -------------------------------------------------------------------------------- /src/lib/components/ExternalLinkIcon.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 25 | -------------------------------------------------------------------------------- /src/lib/components/ExternalLinkWrapper.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 34 | 35 | {description} 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 52 | -------------------------------------------------------------------------------- /src/lib/components/Footer.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /src/lib/components/FormWrapper.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 | {#if children} 26 | {@render children()} 27 | {/if} 28 |
29 | -------------------------------------------------------------------------------- /src/lib/components/GoogleAnalytics.svelte: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/components/GradeLabel.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 |
42 |
46 | {#if taskGrade !== TaskGrade.PENDING} 47 | {grade} 48 | {:else} 49 | {'−'} 50 | {/if} 51 |
52 |
53 | -------------------------------------------------------------------------------- /src/lib/components/HeadingOne.svelte: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 18 | 19 |

20 | {title} 21 | 22 | {#if children} 23 | {@render children()} 24 | {/if} 25 |

26 | -------------------------------------------------------------------------------- /src/lib/components/InputFieldWrapper.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 | 43 | -------------------------------------------------------------------------------- /src/lib/components/LabelWithTooltips.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 37 | -------------------------------------------------------------------------------- /src/lib/components/LabelWrapper.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 17 | -------------------------------------------------------------------------------- /src/lib/components/MessageHelperWrapper.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | {#if message} 15 | 16 | {message} 17 | 18 | {/if} 19 | -------------------------------------------------------------------------------- /src/lib/components/Messages/ErrorMessageAndReturnButton.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
18 | 19 | 20 | 25 | {errorStatus ?? ''} 26 | 27 | 28 |

{errorMessage ?? ''}

29 | 30 |
31 | 34 |
35 |
36 | -------------------------------------------------------------------------------- /src/lib/components/SelectWrapper.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 60 | 61 | 62 | 63 | 64 | 65 |
66 | 67 |
68 | 69 | 70 | 71 | 72 |
TODO: 以下に、タグ(taskTag)を追加/編集するUIを作る予定
73 | -------------------------------------------------------------------------------- /src/lib/components/TaskGradeList.svelte: -------------------------------------------------------------------------------- 1 | 48 | 49 | {#each taskGradeValues as taskGrade} 50 | 51 | 52 | {#if countTasks(taskGrade) && isShowTaskList(isAdmin, taskGrade)} 53 | 59 | {/if} 60 | {/each} 61 | -------------------------------------------------------------------------------- /src/lib/components/TaskGrades/GradeGuidelineTable.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 |
19 |
20 | AtCoder Beginners Contest(通称、ABC)の配点・問題 IDと、対応するグレードの目安を示しています。 21 |
22 |
23 | また、各グレードの基準に関しては、 24 | 25 | をご覧ください。 26 |
27 |
28 | 29 |
30 | 36 | 37 | ABC の配点 38 | 39 | 対応グレード 40 | 41 | 42 | 43 | {#each gradeGuidelineTableData as { point, task, lowerGrade, upperGrade }} 44 | 45 | {point} 46 | 47 | 48 | 49 | 50 |

51 | 52 |
53 |
54 | {/each} 55 |
56 |
57 |
58 | -------------------------------------------------------------------------------- /src/lib/components/TaskGrades/grade_guideline_table_data.ts: -------------------------------------------------------------------------------- 1 | import { TaskGrade } from '$lib/types/task'; 2 | 3 | type TaskGradeGuideline = { 4 | point: string; 5 | task: string; 6 | lowerGrade: TaskGrade; 7 | upperGrade: TaskGrade; 8 | }; 9 | 10 | type TaskGradeGuidelines = TaskGradeGuideline[]; 11 | 12 | export const gradeGuidelineTableData: TaskGradeGuidelines = [ 13 | { 14 | point: '100', 15 | task: 'A', 16 | lowerGrade: TaskGrade.Q9, 17 | upperGrade: TaskGrade.Q6, 18 | }, 19 | { 20 | point: '150', 21 | task: 'A、B', 22 | lowerGrade: TaskGrade.Q7, 23 | upperGrade: TaskGrade.Q5, 24 | }, 25 | { 26 | point: '200', 27 | task: 'B', 28 | lowerGrade: TaskGrade.Q6, 29 | upperGrade: TaskGrade.Q4, 30 | }, 31 | { 32 | point: '250', 33 | task: 'B、C', 34 | lowerGrade: TaskGrade.Q5, 35 | upperGrade: TaskGrade.Q3, 36 | }, 37 | { 38 | point: '300', 39 | task: 'C', 40 | lowerGrade: TaskGrade.Q4, 41 | upperGrade: TaskGrade.Q2, 42 | }, 43 | { 44 | point: '350', 45 | task: 'C、D', 46 | lowerGrade: TaskGrade.Q3, 47 | upperGrade: TaskGrade.Q1, 48 | }, 49 | { 50 | point: '400', 51 | task: 'D', 52 | lowerGrade: TaskGrade.Q2, 53 | upperGrade: TaskGrade.Q1, 54 | }, 55 | { 56 | point: '425', 57 | task: 'D、E', 58 | lowerGrade: TaskGrade.Q2, 59 | upperGrade: TaskGrade.Q1, 60 | }, 61 | { 62 | point: '450', 63 | task: 'D、E', 64 | lowerGrade: TaskGrade.Q1, 65 | upperGrade: TaskGrade.D1, 66 | }, 67 | { 68 | point: '475', 69 | task: 'E、F', 70 | lowerGrade: TaskGrade.Q1, 71 | upperGrade: TaskGrade.D2, 72 | }, 73 | { 74 | point: '500', 75 | task: 'E、F', 76 | lowerGrade: TaskGrade.Q1, 77 | upperGrade: TaskGrade.D3, 78 | }, 79 | { 80 | point: '525', 81 | task: 'F', 82 | lowerGrade: TaskGrade.D1, 83 | upperGrade: TaskGrade.D3, 84 | }, 85 | { 86 | point: '550', 87 | task: 'F', 88 | lowerGrade: TaskGrade.D2, 89 | upperGrade: TaskGrade.D4, 90 | }, 91 | { 92 | point: '575', 93 | task: 'F、G', 94 | lowerGrade: TaskGrade.D3, 95 | upperGrade: TaskGrade.D4, 96 | }, 97 | { 98 | point: '600', 99 | task: 'G', 100 | lowerGrade: TaskGrade.D3, 101 | upperGrade: TaskGrade.D4, 102 | }, 103 | { 104 | point: '625', 105 | task: 'G', 106 | lowerGrade: TaskGrade.D3, 107 | upperGrade: TaskGrade.D5, 108 | }, 109 | { 110 | point: '650', 111 | task: 'G', 112 | lowerGrade: TaskGrade.D4, 113 | upperGrade: TaskGrade.D5, 114 | }, 115 | { 116 | point: '675', 117 | task: 'G', 118 | lowerGrade: TaskGrade.D5, 119 | upperGrade: TaskGrade.D6, 120 | }, 121 | ]; 122 | -------------------------------------------------------------------------------- /src/lib/components/TaskListForEdit.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | コンテストID 36 | コンテスト名 37 | 問題名 38 | 39 | 40 | 41 | {#each importContests as importContest} 42 | {#if importContest.tasks.length > 0} 43 | 44 | 45 | 50 | 51 | 52 | 57 | 58 | 59 | 60 | {#each importContest.tasks as importTask} 61 |
  • {importTask.title}
  • 62 | {/each} 63 |
    64 | 65 |
    66 | 67 | 68 | 69 | 70 |
    71 |
    72 | {/if} 73 | {/each} 74 |
    75 |
    76 | -------------------------------------------------------------------------------- /src/lib/components/TaskListSorted.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | 26 | 27 | 28 | 回答 29 | 問題名 30 | 出典 31 | 更新日時 32 | 33 | 34 | 35 | {#each taskResults as taskResult} 36 | 37 | 38 | {taskResult.submission_status_label_name} 43 | 44 | 45 | 49 | {removeTaskIndexFromTitle(taskResult.title, taskResult.task_table_index)} 50 | 51 | 52 | 53 | {addContestNameToTaskIndex(taskResult.contest_id, taskResult.task_table_index)} 54 | 55 | {taskResult.updated_at.toLocaleString()} 56 | 57 | {/each} 58 | 59 |
    60 | -------------------------------------------------------------------------------- /src/lib/components/TaskTables/TaskTableBodyCell.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
    26 | {@render taskGradeLabel(taskResult)} 27 | 28 |
    29 | {@render taskTitleAndExternalLink(taskResult)} 30 | {@render submissionUpdaterAndLinksOfTaskDetailPage(taskResult)} 31 |
    32 |
    33 | 34 | {#snippet taskGradeLabel(taskResult: TaskResult)} 35 |
    36 | 42 |
    43 | {/snippet} 44 | 45 | {#snippet taskTitleAndExternalLink(taskResult: TaskResult)} 46 |
    47 | 55 |
    56 | {/snippet} 57 | 58 | 59 | 60 | {#snippet submissionUpdaterAndLinksOfTaskDetailPage(selectedTaskResult: TaskResult)} 61 |
    62 | 70 | 71 | 72 |
    73 | {/snippet} 74 | -------------------------------------------------------------------------------- /src/lib/components/ToastWrapper/ErrorMessageToast.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | {#if errorMessage !== null} 15 | 16 | {#snippet icon()} 17 | 18 | Error icon 19 | {/snippet} 20 | 21 | {errorMessage} 22 | 23 | {/if} 24 | -------------------------------------------------------------------------------- /src/lib/components/TooltipWrapper.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#if tooltipContent !== '' && titleId !== ''} 23 | 24 | {tooltipContent} 25 | 26 | 27 | 28 | 29 | 30 | {/if} 31 | -------------------------------------------------------------------------------- /src/lib/components/Trophies/CompletedTasks.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | {#if areAllTasksAccepted(taskResults, allTasks)} 23 | completed workbook 24 | {/if} 25 | -------------------------------------------------------------------------------- /src/lib/components/UserAccountDeletionForm.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 38 | {#snippet icon()} 39 | 40 | {/snippet} 41 | 警告 42 | 43 | 44 | 45 | 46 | 同意する 47 | 48 | {#if showDeleteButton} 49 | 50 | 51 | 52 |

    {username}さん、本当に削除しますか?

    53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
    61 | {/if} 62 |
    63 |
    64 | -------------------------------------------------------------------------------- /src/lib/components/UserProfile.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | {#if username.length === 0} 14 | This user is not found. 15 | {:else} 16 | 17 | 18 | 19 |
    20 | 21 |
    22 | 23 | 24 | 25 | 26 | username: 27 | {username} 28 | 29 | 30 | AtCoder username 31 | {atcoder_username} 32 | 33 | 34 |
    35 | 36 | {#if isLoggedIn} 37 | 38 | {/if} 39 | {/if} 40 | -------------------------------------------------------------------------------- /src/lib/components/WarningMessageOnDeletingAccount.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

    以下の内容が削除されます (データの復元は困難です)

    6 | 7 | 8 | 9 |
  • アカウント
  • 10 |
  • 登録した回答状況
  • 11 | 12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/lib/components/WorkBook/CommentAndHint.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#if uniqueId !== '' && commentAndHint !== ''} 16 | 17 | 24 |
    25 | {commentAndHint} 26 |
    27 |
    28 | 29 | 30 | 31 | 32 | {/if} 33 | -------------------------------------------------------------------------------- /src/lib/components/WorkBooks/PublicationStatusLabel.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | {#if !isPublished} 21 | 22 | {getPublicationStatusLabel(isPublished)} 23 | 24 | {/if} 25 | -------------------------------------------------------------------------------- /src/lib/components/WorkBooks/TitleTableBodyCell.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 30 | 31 | -------------------------------------------------------------------------------- /src/lib/components/WorkBooks/TitleTableHeadCell.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 14 | タイトル 15 | 16 | -------------------------------------------------------------------------------- /src/lib/constants/external-links.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ATCODER_BASE_URL, 3 | ATCODER_PROBLEMS_URL, 4 | AOJ_ATCODER_JOI_URL, 5 | AOJ_PCK_URL, 6 | ICPC_JAPAN_PROBLEMS_URL, 7 | GITHUB_URL, 8 | } from './urls'; 9 | 10 | export const externalLinks = [ 11 | { title: `AtCoder`, path: ATCODER_BASE_URL }, 12 | { title: `AtCoder Problems`, path: ATCODER_PROBLEMS_URL }, 13 | { title: `AOJ / AtCoder-JOI`, path: AOJ_ATCODER_JOI_URL }, 14 | { title: `AOJ-PCK`, path: AOJ_PCK_URL }, 15 | { title: `ICPC Japan Problems`, path: ICPC_JAPAN_PROBLEMS_URL }, 16 | { title: `GitHub`, path: GITHUB_URL }, 17 | ]; 18 | -------------------------------------------------------------------------------- /src/lib/constants/forms.ts: -------------------------------------------------------------------------------- 1 | export const CREATE_ACCOUNT_LABEL: string = 'アカウントを作成'; 2 | export const LOGIN_LABEL: string = 'ログイン'; 3 | 4 | export const GUEST_USER_NAME: string = 'guest'; 5 | 6 | // Note: 2024年9月時点では、staging環境、production環境を優先 7 | // ローカル環境では書き換えて対応 8 | // TODO: ローカル環境とstaging環境、production環境では値が異なるため、切り替えられるようにする 9 | export const GUEST_USER_PASSWORD_FOR_LOCAL: string = 'Ch0kuda1'; 10 | export const GUEST_USER_PASSWORD: string = 'Hell0Guest'; 11 | -------------------------------------------------------------------------------- /src/lib/constants/http-response-status-codes.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status 3 | 4 | // Redirection messages 5 | export const FOUND = 302; 6 | export const SEE_OTHER = 303; 7 | export const TEMPORARY_REDIRECT = 307; 8 | 9 | // Client error responses 10 | export const BAD_REQUEST = 400; 11 | export const UNAUTHORIZED = 401; 12 | export const FORBIDDEN = 403; 13 | export const NOT_FOUND = 404; 14 | 15 | // Server error responses 16 | export const INTERNAL_SERVER_ERROR = 500; 17 | -------------------------------------------------------------------------------- /src/lib/constants/navbar-links.ts: -------------------------------------------------------------------------------- 1 | export const HOME_PAGE = `/`; 2 | export const ABOUT_PAGE = `/about`; 3 | export const SIGNUP_PAGE = `/signup`; 4 | export const LOGIN_PAGE = `/login`; 5 | export const FORGOT_PASSWORD_PAGE = `/forgot_password`; 6 | export const WORKBOOKS_PAGE = `/workbooks`; 7 | export const PROBLEMS_PAGE = `/problems`; 8 | 9 | // For Admin 10 | export const IMPORTING_PROBLEMS_PAGE = `/tasks`; 11 | export const TAGS_PAGE = `/tags`; 12 | export const ACCOUNT_TRANSFER_PAGE = `/account_transfer`; 13 | 14 | export const navbarLinks = [ 15 | { title: `ホーム`, path: HOME_PAGE }, 16 | { title: `問題集`, path: WORKBOOKS_PAGE }, 17 | { title: `一覧表`, path: PROBLEMS_PAGE }, 18 | { title: `サービスの説明`, path: ABOUT_PAGE }, 19 | ]; 20 | 21 | export const navbarDashboardLinks = [ 22 | { title: `問題のインポート`, path: IMPORTING_PROBLEMS_PAGE }, 23 | { title: `一覧表`, path: PROBLEMS_PAGE }, 24 | { title: `問題集`, path: WORKBOOKS_PAGE }, 25 | { title: `タグ一覧`, path: TAGS_PAGE }, 26 | { title: `アカウント移行`, path: ACCOUNT_TRANSFER_PAGE }, 27 | ]; 28 | -------------------------------------------------------------------------------- /src/lib/constants/product-info.ts: -------------------------------------------------------------------------------- 1 | export const PRODUCT_NAME: string = 'AtCoder NoviSteps'; 2 | 3 | export const PRODUCT_URL: string = 'https://atcoder-novisteps.vercel.app/'; 4 | 5 | export const PRODUCT_TEAM_URL: string = 'https://github.com/orgs/AtCoder-NoviSteps/repositories'; 6 | 7 | export const PRODUCT_CATCH_PHRASE: string = '解けた喜び、伸びる楽しさ'; 8 | 9 | export const PRODUCT_DESCRIPTION: string = 10 | '【非公式】 AtCoder 上の問題について、取組み状況を記録していくサイトです。各問題が細かく難易度付けされており、必要な知識を段階的に習得できます。'; 11 | 12 | export const TWITTER_HANDLE_NAME: string = '@acnovisteps'; 13 | 14 | export const features = [ 15 | { 16 | description: 17 | '問題の回答状況を自分で記録できる: 「AC」「解説AC」「挑戦中」「未挑戦」から選べます。', 18 | }, 19 | { 20 | description: 21 | '一歩先の問題に挑戦: 17段階で難易度付けされており、自分の実力に合った問題が探せます。', 22 | }, 23 | { 24 | description: 25 | '問題集で得意を伸ばす・苦手を克服: 例題・類題を通して、各トピックの基礎から応用的な方法まで身につけられます。', 26 | }, 27 | ]; 28 | 29 | const X_BASE_URL = 'https://x.com/'; 30 | 31 | export const X_OFFICIAL_ACCOUNT_URL = `${X_BASE_URL}acnovisteps`; 32 | 33 | export const members = [ 34 | { name: '@けんちょん', account: `${X_BASE_URL}drken1215` }, 35 | { name: '@hiro', account: `${X_BASE_URL}k_hiro1818` }, 36 | { name: '@ウルズニャー', account: `${X_BASE_URL}uruzunyaa` }, 37 | { name: '@nonon', account: `${X_BASE_URL}nonon_kyopro` }, 38 | { name: '@Bluebery1001', account: `${X_BASE_URL}bluebery1001` }, 39 | { name: '@seekworser(ぷせうど)', account: `${X_BASE_URL}pseudo_thermal` }, 40 | { name: '@Satsuki / さつき先生', account: `${X_BASE_URL}Satsuki_8198` }, 41 | { name: '@あべみ', account: `${X_BASE_URL}cats0830v` }, 42 | { name: '@わさせき', account: `${X_BASE_URL}wasaseki` }, 43 | { name: '@toshi201', account: `${X_BASE_URL}toshicon201` }, 44 | ]; 45 | -------------------------------------------------------------------------------- /src/lib/constants/tailwind-helper.ts: -------------------------------------------------------------------------------- 1 | export const TOOLTIP_CLASS_BASE = 2 | 'bg-white text-gray-800 dark:text-white dark:bg-gray-700 p-2 rounded-lg shadow-lg'; 3 | -------------------------------------------------------------------------------- /src/lib/constants/urls.ts: -------------------------------------------------------------------------------- 1 | export const ATCODER_BASE_URL: string = 'https://atcoder.jp'; 2 | 3 | export const ATCODER_BASE_CONTEST_URL: string = `${ATCODER_BASE_URL}/contests`; 4 | 5 | export const ATCODER_PROBLEMS_URL: string = 'https://kenkoooo.com/atcoder/#/'; 6 | 7 | export const ATCODER_PROBLEMS_API_BASE_URL: string = 'https://kenkoooo.com/atcoder/resources/'; 8 | 9 | export const AOJ_API_BASE_URL: string = 'https://judgeapi.u-aizu.ac.jp/'; 10 | 11 | // Note: 12 | // AIZU ONLINE JUDGE (AOJ) has multiple versions: v1.0, v2.0, and v3.0. 13 | // As of late October 2024, we are using v2.0, which appears to be the main site. 14 | export const AOJ_BASE_URL: string = 'https://onlinejudge.u-aizu.ac.jp'; 15 | 16 | export const AOJ_TASKS_URL: string = `${AOJ_BASE_URL}/problems`; 17 | 18 | export const AOJ_ATCODER_JOI_URL: string = 'https://joi.goodbaton.com/'; 19 | 20 | export const AOJ_PCK_URL: string = 'https://pro-ktmr.github.io/aoj-pck/'; 21 | 22 | export const ICPC_JAPAN_PROBLEMS_URL: string = 'https://icpc-japan-problems.irrrrr.cc/'; 23 | 24 | export const GITHUB_URL: string = 'https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps'; 25 | 26 | export const TASK_GRADE_CRITERIA_SHEET_URL: string = 27 | 'https://docs.google.com/spreadsheets/d/1GJbTRRBsoBaY-CXIr3dIXmxkwacV4nHOTOIMCmo__Ug/'; 28 | -------------------------------------------------------------------------------- /src/lib/example.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'vitest'; 2 | 3 | import { getTasks } from '$lib/clients'; 4 | 5 | test('call problems api', () => { 6 | getTasks(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /src/lib/server/auth.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://lucia-auth.com/getting-started/sveltekit/ 3 | // https://lucia-auth.com/database-adapters/prisma/ 4 | import { lucia } from 'lucia'; 5 | import { sveltekit } from 'lucia/middleware'; 6 | import { dev } from '$app/environment'; 7 | import { prisma } from '@lucia-auth/adapter-prisma'; 8 | import client from '$lib/server/database'; 9 | 10 | export const auth = lucia({ 11 | env: dev ? 'DEV' : 'PROD', 12 | middleware: sveltekit(), 13 | adapter: prisma(client), 14 | 15 | // https://lucia-auth.com/reference/lucia/interfaces/#user 16 | getUserAttributes: (userData) => { 17 | return { 18 | userId: userData.id, 19 | username: userData.username, 20 | role: userData.role, 21 | }; 22 | }, 23 | }); 24 | 25 | export type Auth = typeof auth; 26 | -------------------------------------------------------------------------------- /src/lib/server/database.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://lucia-auth.com/getting-started/sveltekit/ 3 | // https://www.reddit.com/r/sveltejs/comments/ozt7mk/sveltekit_with_prisma_fix/?rdt=38249 4 | import Prisma, * as PrismaScope from '@prisma/client'; 5 | 6 | const PrismaClient = Prisma?.PrismaClient || PrismaScope?.PrismaClient; 7 | const client = new PrismaClient(); 8 | 9 | export default client; 10 | -------------------------------------------------------------------------------- /src/lib/server/sample_data.ts: -------------------------------------------------------------------------------- 1 | // TODO: Enable to fetch data from the database via API. 2 | export const tasks = [ 3 | { 4 | contest_id: 'abc318', 5 | task_id: 'abc318_a', 6 | title: 'A - foo', 7 | grade: 'Q7', 8 | }, 9 | { 10 | contest_id: 'abc231', 11 | task_id: 'abc231_a', 12 | title: 'A - Water Pressure', 13 | grade: 'Q10', 14 | }, 15 | { 16 | contest_id: 'abc214', 17 | task_id: 'abc214_a', 18 | title: 'A - New Generation ABC', 19 | grade: 'Q10', 20 | }, 21 | { 22 | contest_id: 'abc202', 23 | task_id: 'abc202_a', 24 | title: 'A - Three Dice', 25 | grade: 'Q9', 26 | }, 27 | ]; 28 | 29 | export const answers = [ 30 | { 31 | task_id: 'abc231_a', 32 | user_id: 'hogehoge', 33 | submission_status: 'wa', 34 | status_id: '2', 35 | }, 36 | { 37 | task_id: 'abc214_a', 38 | user_id: 'hogehoge', 39 | submission_status: 'ac', 40 | status_id: '3', 41 | }, 42 | { 43 | task_id: 'abc202_a', 44 | user_id: 'hogehoge', 45 | submission_status: 'ns', 46 | status_id: '1', 47 | }, 48 | ]; 49 | -------------------------------------------------------------------------------- /src/lib/services/answers.ts: -------------------------------------------------------------------------------- 1 | import { initialize } from '@quramy/prisma-fabbrica/lib/internal'; 2 | 3 | import { default as prisma } from '$lib/server/database'; 4 | import type { TaskAnswer } from '@prisma/client'; 5 | import { sha256 } from '$lib/utils/hash'; 6 | 7 | initialize({ prisma }); 8 | 9 | export async function getAnswers(user_id: string) { 10 | const answers_from_db = prisma.taskAnswer.findMany({ 11 | where: { 12 | user_id: user_id, 13 | }, 14 | }); 15 | const answersMap = new Map(); 16 | 17 | (await answers_from_db).map((answer) => { 18 | answersMap.set(answer.task_id, answer); 19 | }); 20 | return answersMap; 21 | } 22 | 23 | export async function getAnswersOrderedByUpdatedDesc(user_id: string): Promise { 24 | const answers_from_db = await prisma.taskAnswer.findMany({ 25 | where: { 26 | user_id: { equals: user_id }, 27 | }, 28 | orderBy: { 29 | updated_at: 'desc', 30 | }, 31 | take: -1, // Reverse the list 32 | include: { 33 | task: true, 34 | }, 35 | }); 36 | 37 | return answers_from_db; 38 | } 39 | 40 | export async function getAnswer(task_id: string, user_id: string) { 41 | const answers_from_db = await prisma.taskAnswer.findMany({ 42 | where: { 43 | AND: [{ task_id: task_id }, { user_id: user_id }], 44 | }, 45 | }); 46 | 47 | if (answers_from_db.length === 0) { 48 | //TODO デフォルトはNosub? 49 | return null; 50 | } 51 | return answers_from_db[0]; 52 | } 53 | 54 | export async function createAnswer(task_id: string, user_id: string, status_id: string) { 55 | const id = await sha256(task_id + user_id); 56 | const taskanswerInput: TaskAnswer = { 57 | id: id, 58 | task_id: task_id, 59 | user_id: user_id, 60 | status_id: status_id, 61 | created_at: new Date(), 62 | updated_at: new Date(), 63 | }; 64 | 65 | const taskAnswer = await prisma.taskAnswer.create({ 66 | data: taskanswerInput, 67 | }); 68 | 69 | return taskAnswer; 70 | } 71 | 72 | export async function upsertAnswer(taskId: string, userId: string, statusId: string) { 73 | try { 74 | const id = await sha256(taskId + userId); 75 | const newAnswer = { 76 | id: id, 77 | task_id: taskId, 78 | user_id: userId, 79 | status_id: statusId, 80 | created_at: new Date(), 81 | updated_at: new Date(), 82 | }; 83 | 84 | await prisma.taskAnswer.upsert({ 85 | where: { 86 | task_id_user_id: { task_id: taskId, user_id: userId }, 87 | }, 88 | update: { 89 | status_id: statusId, 90 | }, 91 | create: newAnswer, // await createAnswer(taskId, userId, statusId)とすると、一意制約違反(P2002)が発生するため 92 | }); 93 | } catch (error) { 94 | console.error( 95 | `Failed to update answer with taskId ${taskId}, userId ${userId}, statusId: ${statusId}:`, 96 | error, 97 | ); 98 | throw error; 99 | } 100 | } 101 | 102 | // TODO: updateAnswer() 103 | // TODO: deleteAnswer() 104 | -------------------------------------------------------------------------------- /src/lib/services/tags.ts: -------------------------------------------------------------------------------- 1 | import { default as db } from '$lib/server/database'; 2 | import type { Tag } from '$lib/types/tag'; 3 | 4 | // See: 5 | // https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting 6 | export async function getTags(): Promise { 7 | const tags = await db.tag.findMany({ orderBy: { id: 'desc' } }); 8 | 9 | return tags; 10 | } 11 | 12 | export async function getTag(tag_id: string): Promise { 13 | //本当はfindUniqueで取得したいがうまくいかない 14 | const tag = await db.tag.findMany({ 15 | where: { 16 | id: tag_id, 17 | }, 18 | }); 19 | console.log(tag); 20 | 21 | return tag; 22 | } 23 | 24 | export async function getTagByName(name: string): Promise { 25 | //本当はfindUniqueで取得したいがうまくいかない 26 | const tags = await db.tag.findMany({ 27 | where: { 28 | name: name, 29 | }, 30 | }); 31 | console.log(tags); 32 | 33 | return tags; 34 | } 35 | 36 | export async function createTag( 37 | id: string, 38 | name: string, 39 | is_official: boolean, 40 | is_published: boolean, 41 | ) { 42 | const tag = await db.tag.create({ 43 | data: { 44 | id: id, 45 | name: name, 46 | is_official: is_official, 47 | is_published: is_published, 48 | }, 49 | }); 50 | 51 | console.log(tag); 52 | } 53 | export async function updateTag( 54 | tag_id: string, 55 | name: string, 56 | is_official: boolean, 57 | is_published: boolean, 58 | ) { 59 | const tag = await db.tag.update({ 60 | where: { id: tag_id }, 61 | data: { 62 | id: tag_id, 63 | name: name, 64 | is_official: is_official, 65 | is_published: is_published, 66 | }, 67 | }); 68 | 69 | console.log(tag); 70 | } 71 | 72 | // TODO: deleteTag() 73 | 74 | // Note: 75 | // Uncomment only when executing the following commands directly from the script. 76 | // 77 | // pnpm dlx vite-node ./prisma/tags.ts 78 | // 79 | // 80 | // async function main() { 81 | // const tags = getTags(); 82 | // console.log(tags); 83 | 84 | // const tag_id = 'abc322_e'; 85 | // const tag = getTag(tag_id); 86 | // console.log(tag); 87 | 88 | // // updateTag(tag_id, TagGrade.Q1); 89 | // // console.log(tag); 90 | // } 91 | 92 | // main() 93 | // .catch(async (e) => { 94 | // console.error(e); 95 | // process.exit(1); 96 | // }) 97 | // .finally(async () => { 98 | // await db.$disconnect(); 99 | // }); 100 | -------------------------------------------------------------------------------- /src/lib/services/task_tags.ts: -------------------------------------------------------------------------------- 1 | import { default as db } from '$lib/server/database'; 2 | import type { Task } from '$lib/types/task'; 3 | import type { Tag } from '$lib/types/tag'; 4 | // See: 5 | // https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting 6 | export async function getTasks(tag_id: string) { 7 | const tasktags = await db.taskTag.findMany({ 8 | where: { 9 | tag_id: tag_id, 10 | }, 11 | include: { 12 | task: true, 13 | }, 14 | }); 15 | 16 | const tasks = tasktags.map((tasktag) => { 17 | return tasktag.task as Task; 18 | }); 19 | 20 | return tasks; 21 | } 22 | 23 | export async function getTags(task_id: string) { 24 | const tasktags = await db.taskTag.findMany({ 25 | where: { 26 | task_id: task_id, 27 | }, 28 | include: { 29 | tag: true, 30 | }, 31 | }); 32 | 33 | const tags = tasktags.map((tasktag) => { 34 | return tasktag.tag as Tag; 35 | }); 36 | 37 | return tags; 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/services/tasks.ts: -------------------------------------------------------------------------------- 1 | import { default as db } from '$lib/server/database'; 2 | import { classifyContest } from '$lib/utils/contest'; 3 | import type { TaskGrade } from '$lib/types/task'; 4 | import type { Task } from '$lib/types/task'; 5 | 6 | // See: 7 | // https://www.prisma.io/docs/concepts/components/prisma-client/filtering-and-sorting 8 | export async function getTasks(): Promise { 9 | const tasks = await db.task.findMany({ orderBy: { task_id: 'desc' } }); 10 | 11 | return tasks; 12 | } 13 | 14 | export async function getTasksByTaskId(): Promise> { 15 | const tasks = await db.task.findMany(); 16 | const tasksMap = new Map(); 17 | 18 | (await tasks).map((task) => { 19 | tasksMap.set(task.task_id, task); 20 | }); 21 | 22 | return tasksMap; 23 | } 24 | 25 | export async function getTask(task_id: string): Promise { 26 | //本当はfindUniqueで取得したいがうまくいかない 27 | const task = await db.task.findMany({ 28 | where: { 29 | task_id: task_id, 30 | }, 31 | }); 32 | 33 | return task; 34 | } 35 | 36 | export async function isExistsTask(task_id: string) { 37 | const registeredTask = await getTask(task_id); 38 | 39 | return registeredTask.length >= 1; 40 | } 41 | 42 | export async function createTask( 43 | id: string, 44 | task_id: string, 45 | contest_id: string, 46 | task_table_index: string, 47 | title: string, 48 | ) { 49 | const registeredTask = await isExistsTask(task_id); 50 | const contest_type = classifyContest(contest_id); 51 | 52 | if (registeredTask || contest_type === null) { 53 | return; 54 | } 55 | 56 | const task = await db.task.create({ 57 | data: { 58 | id: id, 59 | task_id: task_id, 60 | contest_id: contest_id, 61 | contest_type: contest_type, 62 | task_table_index: task_table_index, 63 | title: title, 64 | }, 65 | }); 66 | 67 | console.log(task); 68 | } 69 | 70 | export async function updateTask(task_id: string, task_grade: TaskGrade) { 71 | const task = await db.task.update({ 72 | where: { task_id: task_id }, 73 | data: { 74 | grade: task_grade, 75 | }, 76 | }); 77 | 78 | console.log(task); 79 | } 80 | 81 | // TODO: deleteTask() 82 | 83 | // Note: 84 | // Uncomment only when executing the following commands directly from the script. 85 | // 86 | // pnpm dlx vite-node ./prisma/tasks.ts 87 | // 88 | // 89 | // async function main() { 90 | // const tasks = getTasks(); 91 | // console.log(tasks); 92 | 93 | // const task_id = 'abc322_e'; 94 | // const task = getTask(task_id); 95 | // console.log(task); 96 | 97 | // // updateTask(task_id, TaskGrade.Q1); 98 | // // console.log(task); 99 | // } 100 | 101 | // main() 102 | // .catch(async (e) => { 103 | // console.error(e); 104 | // process.exit(1); 105 | // }) 106 | // .finally(async () => { 107 | // await db.$disconnect(); 108 | // }); 109 | -------------------------------------------------------------------------------- /src/lib/services/tasktagsApiService.ts: -------------------------------------------------------------------------------- 1 | const allTaskTagsUrl = 2 | 'https://script.google.com/macros/s/AKfycbxnluTPGjI0MNEcxIAHeaXPEV8oCp_LQsueC57T10bB1CyY63a5irSz5u4LxSf43ODL/exec?sheetname=all_tags'; 3 | 4 | export async function getTaskTags() { 5 | try { 6 | const response = await fetch(allTaskTagsUrl, { 7 | method: 'GET', 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | }, 11 | }); 12 | if (!response.ok) { 13 | throw new Error('Network response was not ok'); 14 | } 15 | 16 | return response.json(); 17 | } catch (error) { 18 | // Handle error 19 | console.error('There was a problem fetching data:', error); 20 | throw error; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/services/users.ts: -------------------------------------------------------------------------------- 1 | import { default as db } from '$lib/server/database'; 2 | import type { User } from '@prisma/client'; 3 | 4 | export async function getUser(username: string) { 5 | const user = await db.user.findUnique({ 6 | where: { 7 | username: username, 8 | }, 9 | }); 10 | return user; 11 | } 12 | 13 | export async function getUserById(userId: string) { 14 | const user = await db.user.findUnique({ 15 | where: { 16 | id: userId, 17 | }, 18 | }); 19 | return user; 20 | } 21 | 22 | export async function deleteUser(username: string) { 23 | const user = await db.user.delete({ 24 | where: { 25 | username: username, 26 | }, 27 | }); 28 | return user; 29 | } 30 | 31 | export async function updateValicationCode( 32 | username: string, 33 | atcoder_id: string, 34 | validationCode: string, 35 | ) { 36 | try { 37 | const user: User | null = await db.user.update({ 38 | where: { 39 | username: username, 40 | }, 41 | 42 | data: { 43 | atcoder_validation_code: validationCode, 44 | atcoder_username: atcoder_id, 45 | }, 46 | }); 47 | 48 | return user; 49 | } catch { 50 | console.log('user update error'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/services/validateApiService.ts: -------------------------------------------------------------------------------- 1 | const confirmUrl = 'https://prettyhappy.sakura.ne.jp/php_curl/index.php'; 2 | 3 | import { sha256 } from '$lib/utils/hash'; 4 | import { default as db } from '$lib/server/database'; 5 | 6 | async function confirm(atcoder_username: string, atcoder_validation_code: string) { 7 | try { 8 | const url = confirmUrl + '?user=' + atcoder_username; 9 | const response = await fetch(url); 10 | 11 | if (!response.ok) { 12 | throw new Error('Network response was not ok.'); 13 | } 14 | 15 | const jsonData = await response.json(); 16 | return jsonData.contents?.some((item: string) => item === atcoder_validation_code); 17 | } catch (error) { 18 | // Handle error 19 | console.error('There was a problem fetching data:', error); 20 | throw error; 21 | } 22 | } 23 | 24 | export async function generate(username: string, atcoder_username: string) { 25 | //ハッシュを作る 26 | const date = new Date().toISOString(); 27 | const validationCode = await sha256(username + date); 28 | console.log(username + validationCode); 29 | 30 | try { 31 | const user = await db.user.update({ 32 | where: { 33 | username: username, 34 | }, 35 | data: { 36 | atcoder_username: atcoder_username, 37 | atcoder_validation_code: validationCode, 38 | atcoder_validation_status: false, 39 | }, 40 | }); 41 | return user.atcoder_validation_code; 42 | } catch (error) { 43 | throw new Error(`Failed to generate token: ${error}`); 44 | } 45 | } 46 | 47 | export async function validate(username: string) { 48 | try { 49 | const user = await db.user.findUniqueOrThrow({ 50 | where: { 51 | username: username, 52 | }, 53 | }); 54 | 55 | console.log(user); 56 | const confirmResult = await confirm(user.atcoder_username, user.atcoder_validation_code); 57 | console.log(user, confirmResult); 58 | 59 | if (confirmResult) { 60 | await db.user.update({ 61 | where: { 62 | username: username, 63 | }, 64 | data: { 65 | //atcoder_username: atcoder_username, 66 | atcoder_validation_code: '', 67 | atcoder_validation_status: true, 68 | }, 69 | }); 70 | return true; 71 | } else { 72 | return false; 73 | } 74 | } catch (error) { 75 | throw new Error(`Failed to validate user ${username}: ${error}`); 76 | } 77 | } 78 | 79 | export async function reset(username: string) { 80 | try { 81 | await db.user.update({ 82 | where: { 83 | username: username, 84 | }, 85 | data: { 86 | atcoder_username: '', 87 | atcoder_validation_code: '', 88 | atcoder_validation_status: false, 89 | }, 90 | }); 91 | return true; 92 | } catch (error) { 93 | throw new Error(`Failed to reset validation ${username}: ${error}`); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/lib/services/workbook_tasks.ts: -------------------------------------------------------------------------------- 1 | import type { WorkBook, WorkBookTaskBase, WorkBookTasksBase } from '$lib/types/workbook'; 2 | 3 | export async function getWorkBookTasks(workBook: WorkBook): Promise { 4 | const workBookTasks: WorkBookTasksBase = await Promise.all( 5 | workBook.workBookTasks.map(async (workBookTask: WorkBookTaskBase) => { 6 | return { 7 | taskId: workBookTask.taskId, 8 | priority: workBookTask.priority, 9 | comment: workBookTask.comment, 10 | }; 11 | }), 12 | ); 13 | 14 | return workBookTasks; 15 | } 16 | 17 | export function validateRequiredFields(workBookTasks: WorkBookTasksBase): void { 18 | workBookTasks.forEach((task, index) => { 19 | if (!task.taskId || !task.priority) { 20 | throw new Error(`Not found required fields for WorkBookTask at index ${index}.`); 21 | } 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/stores/active_contest_type.svelte.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '$lib/stores/local_storage_helper.svelte'; 2 | import { type ContestTableProviders } from '$lib/utils/contest_table_provider'; 3 | 4 | /** 5 | * Store that manages the active contest type selection. 6 | * 7 | * This class uses Svelte's state management to track which contest type 8 | * is currently active button. It provides methods to get, set, and 9 | * compare the active contest type. 10 | * 11 | * The store uses the ContestTableProviders type which represents 12 | * different contest table configurations or data providers, 13 | * with a default value of 'abcLatest20Rounds'. 14 | */ 15 | export class ActiveContestTypeStore { 16 | private storage = useLocalStorage( 17 | 'contest_table_providers', 18 | 'abcLatest20Rounds', 19 | ); 20 | 21 | /** 22 | * Creates an instance with the specified contest type. 23 | * 24 | * @param defaultContestType - The default contest type to initialize. 25 | * Defaults to 'abcLatest20Rounds'. 26 | */ 27 | constructor(defaultContestType: ContestTableProviders = 'abcLatest20Rounds') { 28 | if (defaultContestType !== 'abcLatest20Rounds' || !this.storage.value) { 29 | this.storage.value = defaultContestType; 30 | } 31 | } 32 | 33 | /** 34 | * Gets the current contest table providers. 35 | * 36 | * @returns The current value of contest table providers. 37 | */ 38 | get(): ContestTableProviders { 39 | return this.storage.value; 40 | } 41 | 42 | /** 43 | * Sets the current contest type to the specified value. 44 | * 45 | * @param newContestType - The contest type to set as the current value 46 | */ 47 | set(newContestType: ContestTableProviders): void { 48 | this.storage.value = newContestType; 49 | } 50 | 51 | /** 52 | * Validates if the current contest type matches the provided contest type. 53 | * @param contestType - The contest type to compare against 54 | * @returns `true` if the current contest type matches the provided contest type, `false` otherwise 55 | */ 56 | isSame(contestType: ContestTableProviders): boolean { 57 | return this.storage.value === contestType; 58 | } 59 | 60 | /** 61 | * Resets the active contest type to the default value. 62 | * Sets the internal value to 'abcLatest20Rounds'. 63 | */ 64 | reset(): void { 65 | this.storage.value = 'abcLatest20Rounds'; 66 | } 67 | } 68 | 69 | let instance: ActiveContestTypeStore | null = null; 70 | 71 | export function getActiveContestTypeStore(): ActiveContestTypeStore { 72 | if (!instance) { 73 | instance = new ActiveContestTypeStore(); 74 | } 75 | 76 | return instance; 77 | } 78 | 79 | // Export the singleton instance of the store. 80 | export const activeContestTypeStore = getActiveContestTypeStore(); 81 | -------------------------------------------------------------------------------- /src/lib/stores/active_problem_list_tab.svelte.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from '$lib/stores/local_storage_helper.svelte'; 2 | 3 | export type ActiveProblemListTab = 'contestTable' | 'listByGrade' | 'gradeGuidelineTable'; 4 | 5 | export class ActiveProblemListTabStore { 6 | private storage = useLocalStorage( 7 | 'active_problem_list_tab', 8 | 'contestTable', 9 | ); 10 | 11 | /** 12 | * Creates an instance with the specified problem list tab. 13 | * 14 | * @param activeTab - The default problem list tab to initialize. 15 | * Defaults to 'contestTable'. 16 | */ 17 | constructor(activeTab: ActiveProblemListTab = 'contestTable') { 18 | if (activeTab !== 'contestTable' || !this.storage.value) { 19 | this.storage.value = activeTab; 20 | } 21 | } 22 | 23 | /** 24 | * Gets the current active tab. 25 | * 26 | * @returns The current active tab. 27 | */ 28 | get(): ActiveProblemListTab { 29 | return this.storage.value; 30 | } 31 | 32 | /** 33 | * Sets the current tab to the specified value. 34 | * 35 | * @param activeTab - The active tab to set as the current value 36 | */ 37 | set(activeTab: ActiveProblemListTab): void { 38 | this.storage.value = activeTab; 39 | } 40 | 41 | /** 42 | * Validates if the current tab matches the task list. 43 | * @param activeTab - The active tab to compare against 44 | * @returns `true` if the active tab matches the task list, `false` otherwise 45 | */ 46 | isSame(activeTab: ActiveProblemListTab): boolean { 47 | return this.storage.value === activeTab; 48 | } 49 | 50 | /** 51 | * Resets the active tab to the default value. 52 | * Sets the internal value to 'contestTable'. 53 | */ 54 | reset(): void { 55 | this.storage.value = 'contestTable'; 56 | } 57 | } 58 | 59 | let instance: ActiveProblemListTabStore | null = null; 60 | 61 | export function getActiveProblemListTabStore(): ActiveProblemListTabStore { 62 | if (!instance) { 63 | instance = new ActiveProblemListTabStore(); 64 | } 65 | 66 | return instance; 67 | } 68 | 69 | // Export the singleton instance of the store. 70 | export const activeProblemListTabStore = getActiveProblemListTabStore(); 71 | -------------------------------------------------------------------------------- /src/lib/stores/active_workbook_tab.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | import { WorkBookType } from '$lib/types/workbook'; 4 | 5 | const workBookTypes = Object.values(WorkBookType) as Array; 6 | // Map 7 | const initialValues = new Map( 8 | workBookTypes.map((workBookType: WorkBookType) => [workBookType, false]), 9 | ); 10 | initialValues.set(WorkBookType.CURRICULUM, true); 11 | 12 | function createActiveWorkbookTabStore() { 13 | const { subscribe, update } = writable(initialValues); 14 | 15 | return { 16 | subscribe, 17 | setActiveWorkbookTab: (activeWorkBookTab: WorkBookType) => 18 | update((currentValues) => { 19 | const newValues = new Map(currentValues); 20 | newValues.forEach((_, workBookType) => { 21 | newValues.set(workBookType, workBookType === activeWorkBookTab); 22 | }); 23 | 24 | return newValues; 25 | }), 26 | }; 27 | } 28 | 29 | export const activeWorkbookTabStore = createActiveWorkbookTabStore(); 30 | -------------------------------------------------------------------------------- /src/lib/stores/error_message.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | 3 | function createErrorMessageStore() { 4 | const { subscribe, set } = writable(null); 5 | 6 | return { 7 | subscribe, 8 | setAndClearAfterTimeout: (value: string | null, timeoutInMilliSeconds = 3000) => { 9 | set(value); 10 | setTimeout(() => set(null), timeoutInMilliSeconds); 11 | }, 12 | }; 13 | } 14 | 15 | export const errorMessageStore = createErrorMessageStore(); 16 | -------------------------------------------------------------------------------- /src/lib/stores/local_storage_helper.svelte.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://svelte.dev/docs/kit/$app-environment#browser 3 | import { browser } from '$app/environment'; 4 | 5 | export function useLocalStorage(key: string, initialValue: T) { 6 | return new LocalStorageWrapper(key, initialValue); 7 | } 8 | 9 | /** 10 | * A type-safe wrapper for interacting with localStorage. 11 | * 12 | * This class provides a convenient way to store and retrieve typed data from localStorage 13 | * with automatic JSON serialization/deserialization. It gracefully handles server-side 14 | * rendering environments where localStorage is not available. 15 | * 16 | * @template T The type of value being stored 17 | * 18 | * @example 19 | * ```typescript 20 | * // Create a wrapper for a user settings object 21 | * const userSettings = new LocalStorageWrapper('user_settings', defaultSettings); 22 | * 23 | * // Read the current value 24 | * const currentSettings = userSettings.value; 25 | * 26 | * // Update the value (automatically persists to localStorage) 27 | * userSettings.value = { ...currentSettings, theme: 'dark' }; 28 | * ``` 29 | */ 30 | class LocalStorageWrapper { 31 | private _value: T; 32 | private key: string; 33 | 34 | constructor(key: string, initialValue: T) { 35 | this.key = key; 36 | this._value = this.getInitialValue(initialValue); 37 | } 38 | 39 | private getInitialValue(defaultValue: T): T { 40 | // WHY: Cannot access localStorage during SSR (server-side rendering). 41 | if (!browser) { 42 | return defaultValue; 43 | } 44 | 45 | try { 46 | const item = localStorage.getItem(this.key); 47 | return item ? JSON.parse(item) : defaultValue; 48 | } catch (error) { 49 | console.error(`Failed to parse ${this.key} from local storage:`, error); 50 | return defaultValue; 51 | } 52 | } 53 | 54 | get value(): T { 55 | return this._value; 56 | } 57 | 58 | set value(newValue: T) { 59 | this._value = newValue; 60 | 61 | if (browser) { 62 | localStorage.setItem(this.key, JSON.stringify(newValue)); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/stores/replenishment_workbook.svelte.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://svelte.dev/docs/kit/$app-environment#browser 3 | import { browser } from '$app/environment'; 4 | 5 | // See: 6 | // https://svelte.dev/docs/svelte/stores 7 | // https://svelte.dev/docs/svelte/$state 8 | const IS_SHOWN_REPLENISHMENT_WORKBOOKS = 'is_shown_replenishment_workbooks'; 9 | 10 | class ReplenishmentWorkBooksStore { 11 | private isShown = $state(this.loadInitialState()); 12 | 13 | // Note: 14 | // The $state only manages state in memory and is reset on page reload. 15 | // In addition, it is not linked to the browser's persistent storage. 16 | private loadInitialState(): boolean { 17 | // WHY: Cannot access localStorage during SSR (server-side rendering). 18 | if (!browser) { 19 | return false; 20 | } 21 | 22 | const savedStatus = localStorage.getItem(IS_SHOWN_REPLENISHMENT_WORKBOOKS); 23 | 24 | try { 25 | return savedStatus ? JSON.parse(savedStatus) : false; 26 | } catch (error) { 27 | console.warn('Failed to parse replenishment workbooks visibility state:', error); 28 | return false; 29 | } 30 | } 31 | 32 | canView(): boolean { 33 | return this.isShown; 34 | } 35 | 36 | toggleView(): void { 37 | this.isShown = !this.isShown; 38 | 39 | if (browser) { 40 | localStorage.setItem(IS_SHOWN_REPLENISHMENT_WORKBOOKS, JSON.stringify(this.isShown)); 41 | } 42 | } 43 | 44 | reset() { 45 | this.isShown = false; 46 | } 47 | } 48 | 49 | export const replenishmentWorkBooksStore = new ReplenishmentWorkBooksStore(); 50 | -------------------------------------------------------------------------------- /src/lib/stores/task_grades_by_workbook_type.ts: -------------------------------------------------------------------------------- 1 | import { get, writable } from 'svelte/store'; 2 | 3 | import { WorkBookType } from '$lib/types/workbook'; 4 | import { TaskGrade } from '$lib/types/task'; 5 | 6 | const workBookTypes = Object.values(WorkBookType) as Array; 7 | const initialValues = new Map( 8 | workBookTypes.map((workBookType: WorkBookType) => [workBookType, TaskGrade.Q10]), 9 | ); 10 | 11 | function createTaskGradesByWorkBookTypeStore() { 12 | const { subscribe, update } = writable(initialValues); 13 | 14 | return { 15 | subscribe, 16 | updateTaskGrade: (workBookType: WorkBookType, grade: TaskGrade) => 17 | update((originalTaskGrades) => new Map(originalTaskGrades.set(workBookType, grade))), 18 | getTaskGrade: (workBookType: WorkBookType) => { 19 | const taskGrades = get(taskGradesByWorkBookTypeStore); 20 | return taskGrades.get(workBookType); 21 | }, 22 | }; 23 | } 24 | 25 | export const taskGradesByWorkBookTypeStore = createTaskGradesByWorkBookTypeStore(); 26 | -------------------------------------------------------------------------------- /src/lib/types/answer.ts: -------------------------------------------------------------------------------- 1 | export interface TaskAnswer { 2 | task_id: string; 3 | user_id: string; 4 | status_id: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/types/apidata.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AtCoder-NoviSteps/AtCoderNoviSteps/3f92057023b554a48fb8b6dff9217669f3713d5c/src/lib/types/apidata.ts -------------------------------------------------------------------------------- /src/lib/types/authorship.ts: -------------------------------------------------------------------------------- 1 | import type { Roles } from '$lib/types/user'; 2 | 3 | export type Authorship = { 4 | authorId: string; 5 | userId: string; 6 | }; 7 | 8 | export interface AuthorshipForRead extends Authorship { 9 | isPublished: boolean; 10 | } 11 | 12 | export interface AuthorshipForEdit extends Authorship { 13 | isPublished: boolean; 14 | role: Roles; 15 | } 16 | 17 | export type AuthorshipForDelete = Authorship; 18 | -------------------------------------------------------------------------------- /src/lib/types/floating_message.ts: -------------------------------------------------------------------------------- 1 | export interface FloatingMessage { 2 | message: string; 3 | status: boolean; 4 | } 5 | 6 | export type FloatingMessages = FloatingMessage[]; 7 | -------------------------------------------------------------------------------- /src/lib/types/flowbite-svelte-wrapper.ts: -------------------------------------------------------------------------------- 1 | // ドキュメントで公開されている型の一覧に載っておらず、ボタンコンポーネントの内部でのみ定義されている 2 | // FIXME: ButtonColorTypeをインポートすると、ts2322エラーが出る 3 | // HACK: 同じ属性を使っているのに、以下のようにハードコーディングするとなぜかエラーが出ない 4 | 5 | // See: 6 | // https://flowbite-svelte.com/docs/pages/typescript 7 | // https://github.com/themesberg/flowbite-svelte/blob/main/src/lib/buttons/Button.svelte 8 | // https://github.com/iamrishupatel/trello-clone/blob/16635c8bfcc46ffca1ab89c26752d745b2326b6f/src/lib/components/NewTask/NewTask.component.svelte 9 | export type ButtonColor = 10 | | 'alternative' 11 | | 'blue' 12 | | 'dark' 13 | | 'green' 14 | | 'light' 15 | | 'primary' 16 | | 'purple' 17 | | 'red' 18 | | 'yellow' 19 | | 'none'; 20 | -------------------------------------------------------------------------------- /src/lib/types/submission.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type 3 | export const submissionStatusLabels: Record = { 4 | ns: 'No Sub', 5 | wa: 'WA', 6 | ac: 'AC', 7 | } as const; 8 | 9 | type SubmissionBase = { 10 | name: string; 11 | ratioPercent: number; 12 | }; 13 | 14 | export interface SubmissionRatio extends SubmissionBase { 15 | color: string; 16 | } 17 | 18 | export type SubmissionRatios = SubmissionRatio[]; 19 | 20 | export interface SubmissionCount extends SubmissionBase { 21 | count: number; 22 | } 23 | 24 | export type SubmissionCounts = SubmissionCount[]; 25 | -------------------------------------------------------------------------------- /src/lib/types/tag.ts: -------------------------------------------------------------------------------- 1 | export interface Tag { 2 | id: string; 3 | name: string; 4 | is_official: boolean; 5 | is_published: boolean; 6 | } 7 | 8 | export type Tags = Tag[]; 9 | -------------------------------------------------------------------------------- /src/lib/types/task.ts: -------------------------------------------------------------------------------- 1 | // Import original enum as type. 2 | import type { TaskGrade as TaskGradeOrigin } from '@prisma/client'; 3 | 4 | export interface Task { 5 | contest_id: string; 6 | task_table_index: string; 7 | task_id: string; 8 | title: string; 9 | grade: string; 10 | } 11 | 12 | export type Tasks = Task[]; 13 | 14 | export interface TaskForImport { 15 | id: string; 16 | contest_id: string; 17 | problem_index: string; 18 | task_id: string; 19 | title: string; 20 | } 21 | 22 | export type TasksForImport = TaskForImport[]; 23 | 24 | // See: 25 | // https://github.com/prisma/prisma/issues/12504#issuecomment-1147356141 26 | // Guarantee that the implementation corresponds to the original type. 27 | // 28 | // 11Q(最も簡単)〜6D(最難関)。 29 | // 注: 基準は非公開。 30 | export const TaskGrade: { [key in TaskGradeOrigin]: key } = { 31 | PENDING: 'PENDING', // 未確定 32 | Q11: 'Q11', // 11Qのように表記したいが、数字を最初の文字として利用できないため 33 | Q10: 'Q10', 34 | Q9: 'Q9', 35 | Q8: 'Q8', 36 | Q7: 'Q7', 37 | Q6: 'Q6', 38 | Q5: 'Q5', 39 | Q4: 'Q4', 40 | Q3: 'Q3', 41 | Q2: 'Q2', 42 | Q1: 'Q1', 43 | D1: 'D1', 44 | D2: 'D2', 45 | D3: 'D3', 46 | D4: 'D4', 47 | D5: 'D5', 48 | D6: 'D6', 49 | } as const; 50 | 51 | export function getTaskGrade(taskGrade: string): TaskGradeOrigin | undefined { 52 | return TaskGrade[taskGrade as TaskGradeOrigin]; 53 | } 54 | 55 | // Re-exporting the original type with the original name. 56 | export type TaskGrade = TaskGradeOrigin; 57 | 58 | export type TaskGrades = TaskGrade[]; 59 | 60 | export const taskGradeValues = Object.values(TaskGrade); 61 | 62 | export interface TaskResult extends Task { 63 | user_id: string; 64 | status_name: string; 65 | status_id: string; 66 | submission_status_image_path: string; 67 | submission_status_label_name: string; 68 | is_ac: boolean; 69 | updated_at: Date; 70 | } 71 | 72 | export type TaskResults = TaskResult[]; 73 | -------------------------------------------------------------------------------- /src/lib/types/tasktag.ts: -------------------------------------------------------------------------------- 1 | export interface TaskTag { 2 | tag_id: string; 3 | task_id: string; 4 | title: string; 5 | //priority: int; 6 | } 7 | 8 | export type TaskTags = TaskTag[]; 9 | 10 | export interface ImportTaskTag { 11 | task_id: string; 12 | tags: string[]; 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/types/url.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Interface representing a URL generator for contests. 3 | * 4 | * @interface UrlGenerator 5 | * 6 | * @method canHandle 7 | * @param {string} contestId - The ID of the contest. 8 | * @returns {boolean} - Returns true if the generator can handle the given contest ID, otherwise false. 9 | * 10 | * @method generateUrl 11 | * @param {string} contestId - The ID of the contest. 12 | * @param {string} taskId - The ID of the task within the contest. 13 | * @returns {string} - Returns the generated URL for the given contest and task IDs. 14 | */ 15 | export interface UrlGenerator { 16 | canHandle(contestId: string): boolean; 17 | generateUrl(contestId: string, taskId: string): string; 18 | } 19 | 20 | export type UrlGenerators = UrlGenerator[]; 21 | -------------------------------------------------------------------------------- /src/lib/types/user.ts: -------------------------------------------------------------------------------- 1 | export enum Roles { 2 | ADMIN = 'ADMIN', 3 | USER = 'USER', 4 | } 5 | 6 | export type User = { 7 | userId: string; 8 | username: string; 9 | role: Roles; 10 | }; 11 | 12 | export type AuthUser = User; 13 | -------------------------------------------------------------------------------- /src/lib/types/workbook.ts: -------------------------------------------------------------------------------- 1 | import type { WorkBookType as WorkBookTypeOrigin } from '@prisma/client'; 2 | 3 | export type WorkBookBase = { 4 | title: string; 5 | description: string; 6 | editorialUrl: string; // カリキュラムのトピック解説用のURL。HACK: 「ユーザ作成」の場合も利用できるようにするかは要検討。 7 | isPublished: boolean; 8 | isOfficial: boolean; 9 | isReplenished: boolean; // カリキュラムの【補充】を識別するために使用 10 | workBookType: WorkBookType; 11 | urlSlug?: string | null; // 問題集(カリキュラムと解法別)をURLで識別するためのオプション。a-z、0-9、(-)ハイフンのみ使用可能。例: bfs、dfs、dp、union-find、2-sat。 12 | workBookTasks: WorkBookTaskBase[]; 13 | }; 14 | 15 | export interface WorkBook extends WorkBookBase { 16 | id: number; 17 | authorId: string; 18 | } 19 | 20 | export type WorkBooks = WorkBook[]; 21 | 22 | export interface WorkbookWithAuthorName extends WorkBookBase { 23 | authorName: string; 24 | } 25 | 26 | export type WorkbooksWithAuthorNames = WorkbookWithAuthorName[]; 27 | 28 | export interface WorkbookList extends WorkBookBase { 29 | id: number; 30 | authorId: string; 31 | authorName: string; 32 | } 33 | 34 | export type WorkbooksList = WorkbookList[]; 35 | 36 | // HACK: enumを使うときは毎回書いているので、もっと簡略化できないか? 37 | export const WorkBookType: { [key in WorkBookTypeOrigin]: key } = { 38 | CREATED_BY_USER: 'CREATED_BY_USER', // (デフォルト) ユーザ作成: サービスの利用者がさまざまなコンセプトで作成 39 | CURRICULUM: 'CURRICULUM', // カリキュラム: 例題の解法を理解すれば、その本質部分を真似することで解ける類題 40 | SOLUTION: 'SOLUTION', // 解法別: 使い方をマスターしたいアルゴリズム・データ構造・考え方・実装方針 (総称して解法と表記) をさまざまなパターンで考察しながら練習できる 41 | TEXTBOOK: 'TEXTBOOK', // (Deprecated) 旧 教科書: 表記を「カリキュラム」に変更したため廃止予定 42 | GENRE: 'GENRE', // (Deprecated) ジャンル別: 考察なしで問題文から読み取れる内容に基づいてまとめている。ネタバレなし 43 | THEME: 'THEME', // (Deprecated) テーマ別: さまざまな解法 (解法別より狭義) を横断し得るものをまとめている 44 | OTHERS: 'OTHERS', // (Deprecated) その他 45 | } as const; 46 | 47 | // Re-exporting the original type with the original name. 48 | export type WorkBookType = WorkBookTypeOrigin; 49 | 50 | export type WorkBookTaskBase = { 51 | taskId: string; 52 | priority: number; 53 | comment: string; 54 | }; 55 | 56 | export type WorkBookTasksBase = WorkBookTaskBase[]; 57 | 58 | export interface WorkBookTask extends WorkBookTaskBase { 59 | workBookId: number; 60 | } 61 | 62 | export type WorkBookTasks = WorkBookTask[]; 63 | 64 | export interface WorkBookTaskCreate extends WorkBookTaskBase { 65 | contestId: string; 66 | title: string; 67 | } 68 | 69 | export type WorkBookTasksCreate = WorkBookTaskCreate[]; 70 | 71 | export type WorkBookTaskEdit = WorkBookTaskCreate; 72 | 73 | export type WorkBookTasksEdit = WorkBookTaskEdit[]; 74 | -------------------------------------------------------------------------------- /src/lib/utils/account_transfer.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | 3 | import type { Roles } from '$lib/types/user'; 4 | import type { TaskResult } from '$lib/types/task'; 5 | import type { FloatingMessages } from '$lib/types/floating_message'; 6 | 7 | import { sanitizeHTML } from '$lib/utils/html'; 8 | import { isAdmin } from '$lib/utils/authorship'; 9 | 10 | export function isSameUser(source: User, destination: User): boolean { 11 | return source.username.toLocaleLowerCase() === destination.username.toLocaleLowerCase(); 12 | } 13 | 14 | export function validateUserAnswersTransferability( 15 | user: User, 16 | answers: Map, 17 | expectedToHaveAnswers: boolean, 18 | messages: FloatingMessages, 19 | ): boolean { 20 | if (isAdminUser(user, messages)) { 21 | return false; 22 | } 23 | 24 | if (expectedToHaveAnswers) { 25 | return validateUserAnswersExistence(user, answers, expectedToHaveAnswers, messages); 26 | } else { 27 | return !validateUserAnswersExistence(user, answers, expectedToHaveAnswers, messages); 28 | } 29 | } 30 | 31 | export function isExistingUser( 32 | userName: string, 33 | user: User | null, 34 | messages: FloatingMessages, 35 | ): boolean { 36 | const sanitizedUserName = sanitizeHTML(userName); 37 | 38 | if (user === null) { 39 | addMessage(messages, `${sanitizedUserName} が存在しません。コピーを中止します`, false); 40 | return false; 41 | } 42 | 43 | addMessage(messages, `${sanitizedUserName} が存在することを確認しました`, true); 44 | return true; 45 | } 46 | 47 | export function isAdminUser(user: User | null, messages: FloatingMessages): boolean { 48 | if (user === null) { 49 | return false; 50 | } 51 | 52 | const sanitizedUserName = sanitizeHTML(user.username); 53 | 54 | if (user.role && isAdmin(user.role as Roles)) { 55 | addMessage( 56 | messages, 57 | `${sanitizedUserName} は管理者権限をもっているためコピーできません。コピーを中止します`, 58 | false, 59 | ); 60 | 61 | return true; 62 | } 63 | 64 | return false; 65 | } 66 | 67 | export function validateUserAnswersExistence( 68 | user: User, 69 | answers: Map, 70 | expectedToHaveAnswers: boolean, 71 | messages: FloatingMessages, 72 | ): boolean { 73 | const userHasExistingAnswers = answers.size > 0; 74 | 75 | if (userHasExistingAnswers === expectedToHaveAnswers) { 76 | return expectedToHaveAnswers; 77 | } 78 | 79 | const sanitizedUserName = sanitizeHTML(user.username); 80 | 81 | addMessage( 82 | messages, 83 | expectedToHaveAnswers 84 | ? `${sanitizedUserName} にコピー対象のデータがありません。コピーを中止します` 85 | : `${sanitizedUserName} にすでにデータがあります。コピーを中止します`, 86 | false, 87 | ); 88 | 89 | return !expectedToHaveAnswers; 90 | } 91 | 92 | export function addMessage(messages: FloatingMessages, message: string, status: boolean): void { 93 | messages.push({ message, status }); 94 | } 95 | -------------------------------------------------------------------------------- /src/lib/utils/authorship.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes'; 4 | import { Roles } from '$lib/types/user'; 5 | 6 | export const ensureSessionOrRedirect = async (locals: App.Locals): Promise => { 7 | const session = await locals.auth.validate(); 8 | 9 | if (!session) { 10 | throw redirect(TEMPORARY_REDIRECT, '/login'); 11 | } 12 | }; 13 | 14 | export const getLoggedInUser = async (locals: App.Locals): Promise => { 15 | await ensureSessionOrRedirect(locals); 16 | const loggedInUser = locals.user; 17 | 18 | if (!loggedInUser) { 19 | throw redirect(TEMPORARY_REDIRECT, '/login'); 20 | } 21 | 22 | return loggedInUser; 23 | }; 24 | 25 | export const isAdmin = (role: Roles): boolean => { 26 | return role === Roles.ADMIN; 27 | }; 28 | 29 | export const hasAuthority = (userId: string, authorId: string): boolean => { 30 | return userId.toLocaleLowerCase() === authorId.toLocaleLowerCase(); 31 | }; 32 | 33 | // Note: 公開 + 非公開(本人のみ)の問題集が閲覧できる 34 | export const canRead = (isPublished: boolean, userId: string, authorId: string): boolean => { 35 | return isPublished || hasAuthority(userId, authorId); 36 | }; 37 | 38 | // Note: 特例として、管理者はユーザが公開している問題集を編集できる 39 | export const canEdit = ( 40 | userId: string, 41 | authorId: string, 42 | role: Roles, 43 | isPublished: boolean, 44 | ): boolean => { 45 | return hasAuthority(userId, authorId) || (isAdmin(role) && isPublished); 46 | }; 47 | 48 | // Note: 本人のみ削除可能 49 | export const canDelete = (userId: string, authorId: string): boolean => { 50 | return hasAuthority(userId, authorId); 51 | }; 52 | -------------------------------------------------------------------------------- /src/lib/utils/hash.ts: -------------------------------------------------------------------------------- 1 | export async function sha256(text: string): Promise { 2 | const uint8 = new TextEncoder().encode(text); 3 | const digest = await crypto.subtle.digest('SHA-256', uint8); 4 | return Array.from(new Uint8Array(digest)) 5 | .map((v) => v.toString(16).padStart(2, '0')) 6 | .join(''); 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/utils/html.ts: -------------------------------------------------------------------------------- 1 | import { type IFilterXSSOptions, FilterXSS } from 'xss'; 2 | 3 | interface SanitizeOptions { 4 | allowedTags?: string[]; 5 | allowedAttributes?: { [key: string]: string[] }; 6 | } 7 | 8 | const DEFAULT_WHITE_LIST = { 9 | a: ['href'] as string[], 10 | b: [] as string[], 11 | em: [] as string[], 12 | i: [] as string[], 13 | strong: [] as string[], 14 | }; 15 | 16 | const defaultXSS = new FilterXSS({ 17 | whiteList: { ...DEFAULT_WHITE_LIST }, 18 | onTagAttr: function (tag, name, value) { 19 | if (tag === 'a' && name === 'href') { 20 | try { 21 | const url = new URL(value); 22 | const allowedProtocols = ['http:', 'https:', 'mailto:']; 23 | 24 | if (allowedProtocols.includes(url.protocol)) { 25 | return `${name}="${value}"`; 26 | } 27 | } catch { 28 | console.error(`Invalid URL format in href attribute`, { 29 | tag, 30 | sanitized: true, 31 | }); 32 | } 33 | 34 | return ''; 35 | } 36 | }, 37 | }); 38 | 39 | export function sanitizeHTML(html: string, options?: SanitizeOptions): string { 40 | if (typeof html !== 'string') { 41 | throw new TypeError('Input expects to be a string'); 42 | } 43 | 44 | try { 45 | if (!options) { 46 | return defaultXSS.process(html); 47 | } 48 | 49 | const defaultOptions: IFilterXSSOptions = { 50 | whiteList: { ...DEFAULT_WHITE_LIST }, 51 | }; 52 | 53 | if (options.allowedTags) { 54 | defaultOptions.whiteList = options.allowedTags.reduce>( 55 | (array, tag) => { 56 | array[tag] = defaultOptions.whiteList?.[tag] ?? []; 57 | return array; 58 | }, 59 | {}, 60 | ); 61 | } 62 | 63 | if (options.allowedAttributes) { 64 | Object.keys(options.allowedAttributes).forEach((tag) => { 65 | if (defaultOptions.whiteList) { 66 | defaultOptions.whiteList[tag] = options.allowedAttributes 67 | ? options.allowedAttributes[tag] 68 | : []; 69 | } 70 | }); 71 | } 72 | 73 | const customXSS = new FilterXSS(defaultOptions); 74 | 75 | return customXSS.process(html); 76 | } catch (error) { 77 | const errorMessage = error instanceof Error ? error.message : 'Unknown error'; 78 | console.error('Failed to sanitize HTML:', { 79 | error: errorMessage, 80 | cause: 'sanitizeHTML', 81 | }); 82 | 83 | throw new Error(`HTML sanitization failed: ${errorMessage}`); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/lib/utils/newline.ts: -------------------------------------------------------------------------------- 1 | export function newline(text: string, num: number) { 2 | const ret: string[] = []; 3 | if (!text) { 4 | return ret; 5 | } 6 | 7 | for (let i = 0; i < text.length; i += num) { 8 | ret.push(text.slice(i, i + num)); 9 | } 10 | return ret; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/utils/time.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://www.sitepoint.com/delay-sleep-pause-wait/ 3 | export function delay(milliseconds: number) { 4 | if (milliseconds < 0) { 5 | throw new Error('Delay duration must be non-negative'); 6 | } 7 | 8 | return new Promise((resolve) => setTimeout(resolve, milliseconds)); 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/utils/url.ts: -------------------------------------------------------------------------------- 1 | import xss from 'xss'; 2 | 3 | export function isValidUrl(rawUrl: string): boolean { 4 | const pattern = new RegExp( 5 | '^(?:' + // start 6 | '(https?:\\/\\/)?' + // protocol (https:// or http://) 7 | '(([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.?)+[a-z]{2,}' + // domain name 8 | '(\\/[-a-z\\d%_.~+]*)*' + // path 9 | '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string 10 | ')?$', // end 11 | 'i', 12 | ); 13 | 14 | return !!pattern.test(rawUrl); 15 | } 16 | 17 | /** 18 | * Validates if a string is a valid URL slug. 19 | * 20 | * @param slug - The string to validate as a URL slug 21 | * @returns A boolean indicating whether the provided string is a valid URL slug 22 | * 23 | * A valid URL slug: 24 | * - Contains only lowercase letters (a-z), numbers (0-9), and hyphens (-) 25 | * - Must start with a lowercase letter or number 26 | * - Must end with a lowercase letter or number 27 | * - Cannot contain uppercase letters or special characters 28 | * - Cannot be entirely numeric 29 | * - Cannot start or end with a hyphen 30 | * - Cannot contain consecutive hyphens 31 | */ 32 | export function isValidUrlSlug(slug: string): boolean { 33 | if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(slug)) { 34 | return false; 35 | } 36 | 37 | if (/^\d+$/.test(slug)) { 38 | return false; 39 | } 40 | 41 | return true; 42 | } 43 | 44 | export function sanitizeUrl(rawUrl: string) { 45 | return xss(rawUrl); 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/utils/workbook.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@sveltejs/kit'; 2 | 3 | import type { WorkBook } from '$lib/types/workbook'; 4 | 5 | import * as userCrud from '$lib/services/users'; 6 | import * as workBookCrud from '$lib/services/workbooks'; 7 | 8 | import { isValidUrlSlug } from '$lib/utils/url'; 9 | import { BAD_REQUEST, NOT_FOUND } from '$lib/constants/http-response-status-codes'; 10 | 11 | // See: 12 | // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/parseInt 13 | // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Number/isNaN 14 | export async function getWorkbookWithAuthor( 15 | slug: string, 16 | ): Promise<{ workBook: WorkBook; isExistingAuthor: boolean }> { 17 | const workBookId = parseWorkBookId(slug); 18 | const workBookUrlSlug = parseWorkBookUrlSlug(slug); 19 | 20 | if (workBookId === null && workBookUrlSlug === null) { 21 | error(BAD_REQUEST, '不正な問題集idです。'); 22 | } 23 | 24 | let workBook: WorkBook | null = null; 25 | 26 | if (workBookId) { 27 | workBook = await workBookCrud.getWorkBook(workBookId); 28 | } else if (workBookUrlSlug) { 29 | workBook = await workBookCrud.getWorkBookByUrlSlug(workBookUrlSlug); 30 | } 31 | 32 | if (workBook === null) { 33 | error(NOT_FOUND, `問題集id: ${slug} は見つかりませんでした。`); 34 | } 35 | 36 | // Validate if the author of the workbook exists after the workbook has been created. 37 | const workbookAuthor = await userCrud.getUserById(workBook.authorId); 38 | const isExistingAuthor = !!workbookAuthor; 39 | 40 | return { workBook: workBook, isExistingAuthor: isExistingAuthor }; 41 | } 42 | 43 | export function parseWorkBookId(slug: string): number | null { 44 | const isOnlyDigits = (id: string) => /^\d+$/.test(id); 45 | const id = Number(slug); 46 | const isInteger = id % 1 === 0; 47 | const isValidInteger = Number.isSafeInteger(id); 48 | 49 | if (!isOnlyDigits(slug) || isNaN(id) || !isInteger || id <= 0 || !isValidInteger) { 50 | return null; 51 | } 52 | 53 | return id; 54 | } 55 | 56 | export function parseWorkBookUrlSlug(slug: string): string | null { 57 | return isValidUrlSlug(slug) ? slug : null; 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/utils/workbooks.ts: -------------------------------------------------------------------------------- 1 | import { Roles } from '$lib/types/user'; 2 | import type { WorkBook, WorkbookList } from '$lib/types/workbook'; 3 | 4 | import { isAdmin } from '$lib/utils/authorship'; 5 | 6 | // 管理者 + ユーザ向けに公開されている場合 7 | export function canViewWorkBook(role: Roles, isPublished: boolean) { 8 | return isAdmin(role) || isPublished; 9 | } 10 | 11 | /** 12 | * Gets the URL slug for a workbook, falling back to the workbook ID if no slug is available. 13 | * 14 | * @param workbook - The workbook object containing urlSlug and id properties 15 | * @returns The URL slug if available, otherwise the workbook ID as a string 16 | */ 17 | export function getUrlSlugFrom(workbook: WorkbookList | WorkBook): string { 18 | const slug = workbook.urlSlug; 19 | 20 | return slug ? slug : workbook.id.toString(); 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/(admin)/account_transfer/+page.server.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://superforms.rocks/get-started 3 | import { redirect, type Actions } from '@sveltejs/kit'; 4 | import { superValidate } from 'sveltekit-superforms/server'; 5 | import { zod } from 'sveltekit-superforms/adapters'; 6 | 7 | import * as userService from '$lib/services/users'; 8 | import * as taskResultService from '$lib/services/task_results'; 9 | 10 | import { accountTransferSchema } from '$lib/zod/schema'; 11 | import type { FloatingMessage } from '$lib/types/floating_message'; 12 | 13 | import { TEMPORARY_REDIRECT } from '$lib/constants/http-response-status-codes'; 14 | import { LOGIN_PAGE } from '$lib/constants/navbar-links'; 15 | import { Roles } from '$lib/types/user'; 16 | 17 | let accountTransferMessages: FloatingMessage[] = []; 18 | 19 | export async function load({ locals }) { 20 | const session = await locals.auth.validate(); 21 | 22 | if (!session) { 23 | throw redirect(TEMPORARY_REDIRECT, LOGIN_PAGE); 24 | } 25 | 26 | const user = await userService.getUser(session?.user.username as string); 27 | 28 | if (user && user.role !== Roles.ADMIN) { 29 | throw redirect(TEMPORARY_REDIRECT, LOGIN_PAGE); 30 | } 31 | 32 | const form = await superValidate(null, zod(accountTransferSchema)); 33 | 34 | // HACK: accountTransferMessagesは、アカウント移行に関するメッセージを確実に表示するために必要。 35 | // 原因: form送信後にload関数が呼び出されているため。 36 | // 37 | // See: 38 | // https://github.com/AtCoder-NoviSteps/AtCoderNoviSteps/pull/1371#discussion_r1798353593 39 | return { 40 | success: true, 41 | form: { ...form, message: '' }, 42 | accountTransferMessages: accountTransferMessages, 43 | }; 44 | } 45 | 46 | export const actions: Actions = { 47 | default: async ({ request }) => { 48 | try { 49 | const form = await superValidate(request, zod(accountTransferSchema)); 50 | 51 | if (!form.valid) { 52 | return { 53 | success: false, 54 | form: { 55 | ...form, 56 | message: 'アカウントの移行に失敗しました。新旧アカウント名を再入力してください。', 57 | }, 58 | accountTransferMessages: null, 59 | }; 60 | } 61 | 62 | accountTransferMessages = await taskResultService.copyTaskResults( 63 | form.data.sourceUserName.toLowerCase(), 64 | form.data.destinationUserName.toLowerCase(), 65 | ); 66 | 67 | return { 68 | success: true, 69 | form: { ...form, message: '' }, 70 | accountTransferMessages: accountTransferMessages, 71 | }; 72 | } catch (error) { 73 | console.error(error); 74 | 75 | return { 76 | success: false, 77 | form: null, 78 | accountTransferMessages: accountTransferMessages, 79 | }; 80 | } 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /src/routes/(admin)/tags/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect, type Actions } from '@sveltejs/kit'; 2 | 3 | import type { Tag } from '$lib/types/tag'; 4 | 5 | import * as tagService from '$lib/services/tags'; 6 | import * as userService from '$lib/services/users'; 7 | 8 | //import { sha256 } from '$lib/utils/hash'; 9 | 10 | import { Roles } from '$lib/types/user'; 11 | 12 | export async function load({ locals }) { 13 | const session = await locals.auth.validate(); 14 | if (!session) { 15 | redirect(302, '/login'); 16 | } 17 | 18 | const user = await userService.getUser(session?.user.username as string); 19 | if (user?.role !== Roles.ADMIN) { 20 | redirect(302, '/login'); 21 | } 22 | 23 | const tags = await tagService.getTags(); 24 | 25 | const retTags = tags.map((tag: Tag) => { 26 | return { 27 | id: tag.id, 28 | name: tag.name, 29 | is_published: tag.is_published, 30 | is_official: tag.is_official, 31 | }; 32 | }); 33 | 34 | return { 35 | tags: retTags, 36 | }; 37 | } 38 | 39 | export const actions: Actions = { 40 | create: async ({ request }) => { 41 | try { 42 | console.log('tags->actions->create'); 43 | const formData = await request.formData(); 44 | console.log(formData); 45 | const tag_id = formData.get('tag_id')?.toString() as string; 46 | 47 | console.log(tag_id); 48 | } catch { 49 | return { 50 | success: false, 51 | }; 52 | } 53 | 54 | return { 55 | success: true, 56 | }; 57 | }, 58 | 59 | update: async ({ request }) => { 60 | try { 61 | console.log('tags->actions->update'); 62 | const formData = await request.formData(); 63 | console.log(formData); 64 | const tag_id = formData.get('tag_id')?.toString(); 65 | 66 | //POSTされてこなかった場合は抜ける 67 | if (tag_id === '') { 68 | return { 69 | success: true, 70 | }; 71 | } 72 | } catch { 73 | return { 74 | success: false, 75 | }; 76 | } 77 | 78 | redirect(301, '/tags/'); 79 | }, 80 | }; 81 | -------------------------------------------------------------------------------- /src/routes/(admin)/tags/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
    10 | 11 | 12 |
    13 | -------------------------------------------------------------------------------- /src/routes/(admin)/tags/[tag_id]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | //import type { Roles } from '$lib/types/user'; 4 | import type { Tag } from '$lib/types/tag'; 5 | import type { Task } from '$lib/types/task'; 6 | import * as tagService from '$lib/services/tags'; 7 | import * as taskTagService from '$lib/services/task_tags'; 8 | import * as userService from '$lib/services/users'; 9 | import { Roles } from '$lib/types/user'; 10 | 11 | export async function load({ locals, params }) { 12 | const session = await locals.auth.validate(); 13 | if (!session) { 14 | redirect(302, '/login'); 15 | } 16 | 17 | const user = await userService.getUser(session?.user.username as string); 18 | if (user?.role !== Roles.ADMIN) { 19 | redirect(302, '/login'); 20 | } 21 | const tags: Tag[] = await tagService.getTag(params.tag_id as string); 22 | const tasks: Task[] = await taskTagService.getTasks(params.tag_id as string); 23 | 24 | //Jsonから、必要なtag 25 | 26 | console.log(tags[0]); 27 | console.log(user.role); 28 | console.log(session?.user.role); 29 | 30 | return { 31 | tag: tags[0], 32 | tasks: tasks, 33 | isAdmin: user?.role !== Roles.ADMIN, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/routes/(admin)/tags/[tag_id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
    11 | 12 |
    13 | -------------------------------------------------------------------------------- /src/routes/(admin)/tasks/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
    11 | 12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/routes/(admin)/tasks/[task_id]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | //import type { Roles } from '$lib/types/user'; 4 | import type { Task } from '$lib/types/task'; 5 | import * as taskService from '$lib/services/tasks'; 6 | import * as userService from '$lib/services/users'; 7 | //import * as tagService from '$lib/services/tags'; 8 | import { Roles } from '$lib/types/user'; 9 | import type { ImportTaskTag } from '$lib/types/tasktag'; 10 | import type { Tag } from '$lib/types/tag'; 11 | import * as taskTagsApiService from '$lib/services/tasktagsApiService'; 12 | import * as taskTagsService from '$lib/services/task_tags'; 13 | 14 | export async function load({ locals, params }) { 15 | const session = await locals.auth.validate(); 16 | if (!session) { 17 | redirect(302, '/login'); 18 | } 19 | 20 | const user = await userService.getUser(session?.user.username as string); 21 | if (user?.role !== Roles.ADMIN) { 22 | redirect(302, '/login'); 23 | } 24 | const task: Task[] = await taskService.getTask(params.task_id as string); 25 | 26 | //console.log(task); 27 | //console.log(user.role); 28 | //console.log(session?.user.role); 29 | 30 | //jsonデータから必要なTask情報を取り出す。 31 | const importTagsJson = await taskTagsApiService.getTaskTags(); 32 | const taskTagsJson = importTagsJson[0].data.filter( 33 | (taskTag: ImportTaskTag) => taskTag.task_id === params.task_id, 34 | ); 35 | const taskTags = await taskTagsService.getTags(params.task_id); 36 | 37 | const tagMap = new Map(); 38 | 39 | taskTags.map(async (tag: Tag) => { 40 | tagMap.set(tag.name, tag); 41 | //console.log(tag.name, tag) 42 | }); 43 | 44 | //console.log(taskTags) 45 | 46 | if (taskTagsJson.length > 0) { 47 | const importTags: string[] = taskTagsJson[0].tags; 48 | 49 | for (let i = 0; i < importTags.length; i++) { 50 | if (!tagMap.has(importTags[i])) { 51 | const tmpTag = { 52 | id: 'undefined', 53 | name: importTags[i], 54 | is_published: false, 55 | is_official: false, 56 | } as Tag; 57 | tagMap.set(importTags[i], tmpTag); 58 | } 59 | } 60 | //console.log(importTags) 61 | } 62 | 63 | //console.log(tagMap) 64 | //console.log(tagMap.values()) 65 | 66 | return { 67 | task: task[0], 68 | tags: Array.from(tagMap.values()), 69 | isAdmin: user?.role !== Roles.ADMIN, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/routes/(admin)/tasks/[task_id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
    13 | 14 | 15 |
    16 | 17 |
    18 | 19 | 20 |
    21 | -------------------------------------------------------------------------------- /src/routes/(auth)/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ 3 | // https://superforms.rocks/get-started 4 | import { superValidate } from 'sveltekit-superforms/server'; 5 | import { zod } from 'sveltekit-superforms/adapters'; 6 | import { fail, redirect } from '@sveltejs/kit'; 7 | import { LuciaError } from 'lucia'; 8 | 9 | import { authSchema } from '$lib/zod/schema'; 10 | import { auth } from '$lib/server/auth'; 11 | 12 | import { 13 | BAD_REQUEST, 14 | SEE_OTHER, 15 | INTERNAL_SERVER_ERROR, 16 | } from '$lib/constants/http-response-status-codes'; 17 | import { HOME_PAGE } from '$lib/constants/navbar-links'; 18 | 19 | import type { Actions, PageServerLoad } from './$types'; 20 | 21 | export const load: PageServerLoad = async ({ locals }) => { 22 | const session = await locals.auth.validate(); 23 | 24 | if (session) { 25 | redirect(SEE_OTHER, HOME_PAGE); 26 | } 27 | 28 | const form = await superValidate(null, zod(authSchema)); 29 | 30 | return { form: { ...form, message: '' } }; 31 | }; 32 | 33 | export const actions: Actions = { 34 | default: async ({ request, locals }) => { 35 | const form = await superValidate(request, zod(authSchema)); 36 | 37 | if (!form.valid) { 38 | return fail(BAD_REQUEST, { 39 | form: { 40 | ...form, 41 | message: 42 | 'ログインできませんでした。登録したユーザ名 / パスワードとなるように修正してください。', 43 | }, 44 | }); 45 | } 46 | 47 | try { 48 | // find user by key 49 | // and validate password 50 | const key = await auth.useKey( 51 | 'username', 52 | form.data.username.toLowerCase(), 53 | form.data.password, 54 | ); 55 | const session = await auth.createSession({ 56 | userId: key.userId, 57 | attributes: {}, 58 | }); 59 | 60 | locals.auth.setSession(session); // set session cookie 61 | } catch (e) { 62 | if ( 63 | e instanceof LuciaError && 64 | (e.message === 'AUTH_INVALID_KEY_ID' || e.message === 'AUTH_INVALID_PASSWORD') 65 | ) { 66 | // user does not exist or invalid password 67 | return fail(BAD_REQUEST, { 68 | form: { 69 | ...form, 70 | message: 71 | 'ログインできませんでした。登録したユーザ名 / パスワードとなるように修正してください。', 72 | }, 73 | }); 74 | } 75 | 76 | return fail(INTERNAL_SERVER_ERROR, { 77 | form: { 78 | ...form, 79 | message: 'サーバでエラーが発生しました。本サービスの開発・運営チームに連絡してください。', 80 | }, 81 | }); 82 | } 83 | 84 | // redirect to 85 | // make sure you don't throw inside a try/catch block! 86 | redirect(SEE_OTHER, HOME_PAGE); 87 | }, 88 | }; 89 | -------------------------------------------------------------------------------- /src/routes/(auth)/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /src/routes/(auth)/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | // See: 2 | // https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ 3 | import { auth } from '$lib/server/auth'; 4 | import { fail, redirect } from '@sveltejs/kit'; 5 | 6 | import { SEE_OTHER, UNAUTHORIZED } from '$lib/constants/http-response-status-codes'; 7 | import { HOME_PAGE } from '$lib/constants/navbar-links'; 8 | 9 | import type { Actions } from './$types'; 10 | 11 | export const load = () => { 12 | redirect(SEE_OTHER, HOME_PAGE); 13 | }; 14 | 15 | export const actions: Actions = { 16 | logout: async ({ locals }) => { 17 | const session = await locals.auth.validate(); 18 | 19 | if (!session) { 20 | return fail(UNAUTHORIZED); 21 | } 22 | 23 | await auth.invalidateSession(session.sessionId); // invalidate session 24 | locals.auth.setSession(null); // remove cookie 25 | 26 | redirect(SEE_OTHER, HOME_PAGE); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /src/routes/(auth)/signup/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 23 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
    13 | 14 | 15 | {#if page.error} 16 | 17 | {page.status} 18 | 19 | 20 |

    {page.error.message}

    21 | {/if} 22 | 23 |
    24 | 27 |
    28 |
    29 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PRODUCT_DESCRIPTION, 3 | PRODUCT_NAME, 4 | TWITTER_HANDLE_NAME, 5 | } from '$lib/constants/product-info'; 6 | 7 | // See: 8 | // https://lucia-auth.com/guidebook/sign-in-with-username-and-password/sveltekit/ 9 | import { Roles } from '$lib/types/user'; 10 | 11 | const getBaseMetaTags = (url: URL) => { 12 | const title: string = PRODUCT_NAME; 13 | const description: string = PRODUCT_DESCRIPTION; 14 | const imageUrl: string = new URL('/favicon.png', url.origin).href; 15 | const imageAlt: string = PRODUCT_NAME; 16 | 17 | const baseMetaTags = Object.freeze({ 18 | title: title, 19 | description: description, 20 | canonical: new URL(url.pathname, url.origin).href, 21 | openGraph: { 22 | type: 'website', 23 | url: new URL(url.pathname, url.origin).href, 24 | locale: 'ja_JP', 25 | title: title, 26 | description: description, 27 | siteName: PRODUCT_NAME, 28 | images: [ 29 | { 30 | url: imageUrl, 31 | alt: imageAlt, 32 | secureUrl: imageUrl, 33 | type: 'image/jpeg', 34 | }, 35 | ], 36 | }, 37 | twitter: { 38 | creator: TWITTER_HANDLE_NAME, 39 | site: TWITTER_HANDLE_NAME, 40 | cardType: 'summary', 41 | title: title, 42 | description: description, 43 | image: imageUrl, 44 | imageAlt: imageAlt, 45 | }, 46 | }); 47 | 48 | return baseMetaTags; 49 | }; 50 | 51 | export const load = async ({ locals, url }) => { 52 | const session = await locals.auth.validate(); 53 | const user = locals.user; 54 | const baseMetaTags = getBaseMetaTags(url); 55 | 56 | return { 57 | isAdmin: session?.user.role === Roles.ADMIN, 58 | user: user, 59 | baseMetaTags: baseMetaTags, 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
    26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {#if $navigating} 35 | 36 | {:else} 37 | {@render children?.()} 38 | {/if} 39 | 40 |