├── .prettierrc ├── app ├── logo.png ├── utils │ ├── code-block.ts │ ├── assertion.ts │ ├── type-challenges.ts │ ├── progress.ts │ └── pagination.ts ├── entry.client.tsx ├── routes │ ├── api.logout.tsx │ ├── progress.tsx │ ├── problems.$problemId._index.tsx │ ├── ranking.tsx │ ├── problems.$problemId.submit.tsx │ ├── api.login.tsx │ ├── _index.tsx │ ├── problems.$problemId.tsx │ ├── problems.$problemId.submissions.$submissionId.tsx │ └── problems.$problemId.submissions._index.tsx ├── hooks │ └── use-auth.ts ├── remix.d.ts ├── components │ ├── SubmissionStatusBadge.tsx │ ├── Pagination.tsx │ ├── ProblemButton.tsx │ ├── CodeBlock.tsx │ ├── ProblemsList.tsx │ └── ProgressCharts.tsx ├── model.ts ├── lib │ ├── firebase.ts │ └── authentication.tsx ├── entry.server.tsx ├── root.tsx └── DefaultLayout.tsx ├── .firebaserc ├── judge-worker ├── src │ ├── worker.ts │ ├── judge-worker.ts │ └── judge.ts ├── package.json ├── wrangler.toml └── tsconfig.json ├── public └── favicon.png ├── renovate.json ├── server ├── utils │ ├── assertion.ts │ ├── session.ts │ └── database.ts ├── fetch-problems.ts ├── fetch-problem.ts ├── fetch-challenge-results.ts ├── fetch-submission.ts ├── fetch-rankings.ts ├── core │ └── type-challenges-judge.ts ├── query │ ├── models.ts │ └── querier.ts ├── create-submission.ts ├── fetch-problem-submissions.ts ├── fetch-user-progress.ts └── query.sql ├── .github └── workflows │ └── ci.yml ├── scripts └── copy-typescript-lib.js ├── sqlc.yaml ├── database ├── utils │ ├── loader.js │ └── type-challenges.ts ├── seed-worker.ts ├── seed-runner.js ├── update-problems.ts ├── migrations │ └── 0000_init.sql └── schema.sql ├── firebase.json ├── .eslintrc.cjs ├── tsconfig.json ├── .gitignore ├── wrangler.toml ├── server.ts ├── remix.config.js ├── README.md └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /app/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tekihei2317/type-challenges-judge/HEAD/app/logo.png -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "type-challenges-judge" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /judge-worker/src/worker.ts: -------------------------------------------------------------------------------- 1 | import { judgeWorker } from './judge-worker' 2 | export default judgeWorker 3 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tekihei2317/type-challenges-judge/HEAD/public/favicon.png -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /app/utils/code-block.ts: -------------------------------------------------------------------------------- 1 | export function changeToCodeMarkdown(code: string, language: string) { 2 | return '```' + `${language}\n` + `${code}\n` + '```' 3 | } 4 | -------------------------------------------------------------------------------- /app/utils/assertion.ts: -------------------------------------------------------------------------------- 1 | export function assertNonNullable(val: T): asserts val is NonNullable { 2 | if (val === undefined || val === null) { 3 | throw new Error(`Expected 'val' to be defined, but received ${val}`) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /server/utils/assertion.ts: -------------------------------------------------------------------------------- 1 | export function assertNonNullable(val: T): asserts val is NonNullable { 2 | if (val === undefined || val === null) { 3 | throw new Error(`Expected 'val' to be defined, but received ${val}`) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /app/utils/type-challenges.ts: -------------------------------------------------------------------------------- 1 | import { Problem } from '../model' 2 | 3 | export const REPO = 'https://github.com/type-challenges/type-challenges' 4 | 5 | export function toGitHubUrl(problem: Problem) { 6 | const prefix = `${REPO}/blob/main` 7 | return `${prefix}/questions/${problem.id}/README.md` 8 | } 9 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from '@remix-run/react' 2 | import { startTransition, StrictMode } from 'react' 3 | import { hydrateRoot } from 'react-dom/client' 4 | 5 | startTransition(() => { 6 | hydrateRoot( 7 | document, 8 | 9 | 10 | 11 | ) 12 | }) 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | jobs: 5 | ci: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-node@v3 10 | with: 11 | node-version: 18 12 | cache: yarn 13 | - run: yarn install 14 | - run: yarn run lint 15 | -------------------------------------------------------------------------------- /judge-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "judge-worker", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "wrangler dev", 7 | "deploy": "wrangler deploy" 8 | }, 9 | "devDependencies": { 10 | "@cloudflare/workers-types": "^4.20230419.0", 11 | "typescript": "^5.0.4", 12 | "wrangler": "^3.0.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/fetch-problems.ts: -------------------------------------------------------------------------------- 1 | import { Problem } from './core/type-challenges-judge' 2 | import { getAllProblems } from './query/querier' 3 | import { parseProblem } from './utils/database' 4 | 5 | export async function fetchProblems(db: D1Database): Promise { 6 | const { results } = await getAllProblems(db) 7 | 8 | return results.map((problem) => parseProblem(problem)) 9 | } 10 | -------------------------------------------------------------------------------- /judge-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "judge-worker" 2 | main = "src/worker.ts" 3 | compatibility_date = "2023-08-01" 4 | 5 | [[queues.producers]] 6 | binding = "JUDGE_QUEUE" 7 | queue = "type-challenges-judge" 8 | 9 | [[queues.consumers]] 10 | queue = "type-challenges-judge" 11 | 12 | [[d1_databases]] 13 | binding = "DB" 14 | database_name = "type-challenges-judge" 15 | database_id = "2adb5da8-cd23-45a7-b2fb-2392319e9520" 16 | -------------------------------------------------------------------------------- /app/routes/api.logout.tsx: -------------------------------------------------------------------------------- 1 | import { ActionArgs, json } from '@remix-run/cloudflare' 2 | import { destroySession, getSession } from '../../server/utils/session' 3 | 4 | export async function action({ request }: ActionArgs) { 5 | const session = await getSession(request.headers.get('Cookie')) 6 | return json( 7 | {}, 8 | { 9 | headers: { 10 | 'Set-Cookie': await destroySession(session), 11 | }, 12 | } 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /app/hooks/use-auth.ts: -------------------------------------------------------------------------------- 1 | import { createUseAuthHook } from '../lib/authentication' 2 | 3 | async function login(body: { idToken: string; screenName: string }) { 4 | await fetch('/api/login', { 5 | method: 'POST', 6 | body: JSON.stringify(body), 7 | }) 8 | } 9 | 10 | async function logout() { 11 | await fetch('/api/logout', { 12 | method: 'POST', 13 | }) 14 | } 15 | 16 | export const useAuth = createUseAuthHook({ 17 | login, 18 | logout, 19 | }) 20 | -------------------------------------------------------------------------------- /scripts/copy-typescript-lib.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises' 2 | 3 | const es5LibPath = 'node_modules/typescript/lib/lib.es5.d.ts' 4 | 5 | const es5Lib = await fs.readFile(es5LibPath, { encoding: 'utf-8' }) 6 | const es5LibString = JSON.stringify(es5Lib) 7 | 8 | fs.writeFile( 9 | 'server/lib/typescript-lib.ts', 10 | `// Do not edit directly. This file was generated by \`yarn copy-typescript-lib\`. 11 | 12 | export const es5Lib = 13 | ${es5LibString}` 14 | ) 15 | -------------------------------------------------------------------------------- /server/fetch-problem.ts: -------------------------------------------------------------------------------- 1 | import { Problem } from './core/type-challenges-judge' 2 | import { getProblem } from './query/querier' 3 | import { parseProblem } from './utils/database' 4 | 5 | export async function fetchProblem( 6 | db: D1Database, 7 | problemId: string 8 | ): Promise { 9 | const problem = await getProblem(db, { id: problemId }) 10 | // TODO: 画面側でnullのハンドリングする 11 | if (problem === null) throw new Error('TODO:') 12 | 13 | return parseProblem(problem) 14 | } 15 | -------------------------------------------------------------------------------- /app/remix.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | type User = { userId: string; screenName: string } 4 | 5 | declare module '@remix-run/cloudflare' { 6 | interface AppLoadContext { 7 | env: { 8 | DB: D1Database 9 | KV: KVNamespace 10 | JUDGE_WORKER: Fetcher 11 | FIREBASE_PROJECT_ID: string 12 | PUBLIC_JWK_CACHE_KEY: string 13 | PUBLIC_JWK_CACHE_KV: KVNamespace 14 | FIREBASE_AUTH_EMULATOR_HOST: string | undefined 15 | } 16 | user: User | undefined 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /sqlc.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | plugins: 3 | - name: typescript-d1 4 | wasm: 5 | url: "https://github.com/orisano/sqlc-gen-ts-d1/releases/download/v0.0.0-a/sqlc-gen-ts-d1.wasm" 6 | sha256: "16b43a9fe718522e4dda27dc64f73a854d0bbed1ef59e548c220d301300b4a87" 7 | sql: 8 | - engine: sqlite 9 | schema: database/schema.sql 10 | queries: 11 | - server/query.sql 12 | codegen: 13 | - out: server/query 14 | plugin: typescript-d1 15 | options: workers-types=2022-11-30 16 | -------------------------------------------------------------------------------- /database/utils/loader.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fs from 'node:fs' 3 | import { fileURLToPath } from 'node:url' 4 | 5 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 6 | const PROBLEMS_PATH = path.resolve(__dirname, '../../problems.json') 7 | 8 | /** 9 | * @return Quiz 10 | */ 11 | export function loadProblems() { 12 | const content = fs.readFileSync(PROBLEMS_PATH, { encoding: 'utf-8' }) 13 | const problems = JSON.parse(content) 14 | 15 | return problems 16 | } 17 | 18 | loadProblems() 19 | -------------------------------------------------------------------------------- /server/fetch-challenge-results.ts: -------------------------------------------------------------------------------- 1 | import { ChallengeResult } from './core/type-challenges-judge' 2 | import { findUsersChallengeResults } from './query/querier' 3 | import { parseChallengeResult } from './utils/database' 4 | 5 | export async function fetchChallengeResults( 6 | db: D1Database, 7 | userId?: string 8 | ): Promise { 9 | if (userId == undefined) return [] 10 | 11 | const { results } = await findUsersChallengeResults(db, { userId }) 12 | return results.map((result) => parseChallengeResult(result)) 13 | } 14 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist", 4 | "redirects": [ 5 | { 6 | "source": "/:path*", 7 | "destination": "https://type-challenges-judge.pages.dev/:path", 8 | "type": 301 9 | }, 10 | { 11 | "source": "/", 12 | "destination": "https://type-challenges-judge.pages.dev/", 13 | "type": 301 14 | } 15 | ] 16 | }, 17 | "emulators": { 18 | "auth": { 19 | "port": 9099 20 | }, 21 | "ui": { 22 | "enabled": true 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/utils/session.ts: -------------------------------------------------------------------------------- 1 | import { createCookieSessionStorage } from '@remix-run/cloudflare' // or cloudflare/deno 2 | 3 | type SessionData = { 4 | userId: string 5 | screenName: string 6 | } 7 | 8 | const { getSession, commitSession, destroySession } = 9 | createCookieSessionStorage({ 10 | cookie: { 11 | name: '__session', 12 | httpOnly: true, 13 | maxAge: 30 * 24 * 60 * 60, 14 | sameSite: 'lax', 15 | // TODO: 16 | secrets: ['s3cret1'], 17 | secure: true, 18 | }, 19 | }) 20 | 21 | export { getSession, commitSession, destroySession } 22 | -------------------------------------------------------------------------------- /app/utils/progress.ts: -------------------------------------------------------------------------------- 1 | export type Progress = { 2 | difficulty: string 3 | acceptedCount: number 4 | wrongAnswerCount: number 5 | totalCount: number 6 | } 7 | 8 | export type ProgressMap = { 9 | warm: Progress 10 | easy: Progress 11 | medium: Progress 12 | hard: Progress 13 | extreme: Progress 14 | } 15 | 16 | export function mergeProgress(a: Progress, b: Progress): Progress { 17 | return { 18 | difficulty: a.difficulty, 19 | totalCount: a.totalCount + b.totalCount, 20 | acceptedCount: a.acceptedCount + b.acceptedCount, 21 | wrongAnswerCount: a.wrongAnswerCount + b.wrongAnswerCount, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es6: true, 6 | node: true, 7 | }, 8 | parser: '@typescript-eslint/parser', 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:import/recommended', 12 | 'plugin:import/typescript', 13 | 'plugin:@typescript-eslint/recommended', 14 | 'plugin:react/recommended', 15 | 'plugin:react-hooks/recommended', 16 | 'plugin:jsx-a11y/recommended', 17 | ], 18 | rules: { 19 | 'react/react-in-jsx-scope': 'off', 20 | }, 21 | settings: { 22 | react: { 23 | version: 'detect', 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /app/components/SubmissionStatusBadge.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from '@chakra-ui/react' 2 | import { SubmissionStatus } from '../model' 3 | 4 | type SubmissionStatusBadgeProps = { 5 | children: SubmissionStatus 6 | } 7 | 8 | function statusToColor(status: SubmissionStatus) { 9 | if (status === 'Judging') return 'gray' 10 | if (status === 'Accepted') return 'green' 11 | return 'red' 12 | } 13 | 14 | export const SubmissionStatusBadge = ({ 15 | children, 16 | }: SubmissionStatusBadgeProps) => { 17 | return ( 18 | 19 | {children} 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | // "module": "ESNext", 13 | "module": "CommonJS", 14 | "moduleResolution": "Node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": ["app", "server", "judge-worker/src/judge-worker.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /database/seed-worker.ts: -------------------------------------------------------------------------------- 1 | import { D1Database } from '@cloudflare/workers-types/2022-11-30' 2 | import { updateProblems } from './update-problems' 3 | import { Quiz } from './utils/type-challenges' 4 | 5 | type Env = { 6 | DB: D1Database 7 | } 8 | 9 | async function seed(db: D1Database, quizez: Quiz[]) { 10 | await updateProblems(db, quizez) 11 | } 12 | 13 | export default { 14 | async fetch(request: Request, env: Env): Promise { 15 | const quizez: Quiz[] = await request.json() 16 | try { 17 | await seed(env.DB, quizez) 18 | return new Response('Seeding succeeded') 19 | } catch (e) { 20 | return new Response(e.message) 21 | } 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # firebase 27 | .firebase 28 | firebase-adminsdk.json 29 | 30 | .env 31 | 32 | # vitest 33 | coverage 34 | 35 | /.cache 36 | /public/build 37 | /functions/\[\[path\]\].js 38 | /functions/\[\[path\]\].js.map 39 | /functions/metafile.js.json 40 | /functions/metafile.server.json 41 | .wrangler 42 | 43 | cloud-functions/lib 44 | -------------------------------------------------------------------------------- /app/model.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | userId: string 3 | screenName: string 4 | } 5 | 6 | export type ProblemDifficulty = 'warm' | 'easy' | 'medium' | 'hard' | 'extreme' 7 | 8 | export type Problem = { 9 | id: string 10 | title: string 11 | content: string 12 | difficulty: ProblemDifficulty 13 | tests: string 14 | playgroundUrl: string 15 | } 16 | 17 | export type SubmissionStatus = 'Judging' | 'Accepted' | 'Wrong Answer' 18 | 19 | export type Submission = { 20 | id: string 21 | user: User 22 | problem: Problem 23 | code: string 24 | status: SubmissionStatus 25 | codeLength: number 26 | diagnostics?: string[] 27 | commentary?: string 28 | createdAt: string 29 | } 30 | 31 | export type ProblemResultStatus = 'Accepted' | 'Wrong Answer' 32 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | kv_namespaces = [ 2 | { binding = "PUBLIC_JWK_CACHE_KV", id = "3ba2346afa80424daa438820f4506828", preview_id = "0eb5da7dd9b8483eb3b216cca2a174c8" }, 3 | { binding = "KV", id = "62b19ae2e7b04321834cdfa9db9accf0", preview_id = "1618f22ffb79441ab2e6f6d467327489" } 4 | ] 5 | 6 | services = [ 7 | { binding = "JUDGE_WORKER", service = "judge-worker" } 8 | ] 9 | 10 | [vars] 11 | FIREBASE_AUTH_EMULATOR_HOST = "localhost:9099" 12 | FIREBASE_PROJECT_ID = "type-challenges-judge" 13 | PUBLIC_JWK_CACHE_KEY = "public-jwk-cache-key" 14 | 15 | [[d1_databases]] 16 | binding = "DB" # i.e. available in your Worker on env.DB 17 | database_name = "type-challenges-judge" 18 | database_id = "2adb5da8-cd23-45a7-b2fb-2392319e9520" 19 | migrations_dir = "database/migrations" 20 | -------------------------------------------------------------------------------- /database/seed-runner.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { unstable_dev } from 'wrangler' 3 | import { fileURLToPath } from 'url' 4 | import { loadProblems } from './utils/loader.js' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | 8 | async function main() { 9 | const workerPath = path.resolve(__dirname, './seed-worker.ts') 10 | const worker = await unstable_dev(workerPath, { 11 | local: true, 12 | nodeCompat: true, 13 | config: path.resolve(__dirname, '../wrangler.toml'), 14 | }) 15 | 16 | const quizez = await loadProblems() 17 | const response = await worker.fetch('/', { 18 | method: 'POST', 19 | body: JSON.stringify(quizez), 20 | }) 21 | console.log(await response.text()) 22 | await worker.stop() 23 | } 24 | 25 | main().catch((e) => { 26 | console.error(e) 27 | process.exit(1) 28 | }) 29 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import { logDevReady } from '@remix-run/cloudflare' 2 | import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages' 3 | import * as build from '@remix-run/dev/server-build' 4 | import { getSession } from './server/utils/session' 5 | 6 | if (process.env.NODE_ENV === 'development') { 7 | logDevReady(build) 8 | } 9 | 10 | export const onRequest = createPagesFunctionHandler({ 11 | build, 12 | getLoadContext: async (context) => { 13 | const session = await getSession(context.request.headers.get('Cookie')) 14 | const userId = session.get('userId') 15 | const screenName = session.get('screenName') 16 | const user = 17 | userId !== undefined && screenName !== undefined 18 | ? { userId, screenName } 19 | : undefined 20 | 21 | return { 22 | env: context.env, 23 | user, 24 | } 25 | }, 26 | mode: process.env.NODE_ENV, 27 | }) 28 | -------------------------------------------------------------------------------- /app/components/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Button } from '@chakra-ui/react' 2 | import { PageType } from '../utils/pagination' 3 | 4 | type PaginationProps = { 5 | pages: PageType[] 6 | currentPage: number 7 | handlePageClick: (page: PageType) => void 8 | } 9 | 10 | export const Pagination = ({ 11 | pages, 12 | currentPage, 13 | handlePageClick, 14 | }: PaginationProps) => { 15 | return ( 16 | <> 17 | {pages.length > 1 && ( 18 | 19 | {pages.map((page) => ( 20 | 28 | ))} 29 | 30 | )} 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/lib/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app' 2 | import { connectAuthEmulator, getAuth } from 'firebase/auth' 3 | import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore' 4 | 5 | const app = initializeApp({ 6 | apiKey: 'AIzaSyAl93UHoFUQb1wfPhifDNPX1UDZpheWfxI', 7 | authDomain: 'type-challenges-judge.firebaseapp.com', 8 | projectId: 'type-challenges-judge', 9 | storageBucket: 'type-challenges-judge.appspot.com', 10 | messagingSenderId: '934271547976', 11 | appId: '1:934271547976:web:9408246fa529a60eb9367d', 12 | measurementId: 'G-9PK16MDC1L', 13 | }) 14 | 15 | export const auth = getAuth(app) 16 | export const db = getFirestore(app) 17 | 18 | const enableEmulator = ['development', 'test'].includes( 19 | process.env.NODE_ENV ?? 'development' 20 | ) 21 | 22 | if (enableEmulator) { 23 | connectFirestoreEmulator(db, 'localhost', 8080) 24 | connectAuthEmulator(auth, 'http://localhost:9099') 25 | } 26 | -------------------------------------------------------------------------------- /server/fetch-submission.ts: -------------------------------------------------------------------------------- 1 | import { Submission } from './core/type-challenges-judge' 2 | import { assertNonNullable } from './utils/assertion' 3 | import { findProblem, findSubmission, findUser } from './query/querier' 4 | import { parseSubmission, parseProblem } from './utils/database' 5 | 6 | export async function fetchSubmission( 7 | db: D1Database, 8 | submissionId: string 9 | ): Promise { 10 | const submission = await findSubmission(db, { id: submissionId }) 11 | if (submission === null) return undefined 12 | 13 | const [problem, user] = await Promise.all([ 14 | findProblem(db, { id: submission.problemId }), 15 | findUser(db, { userId: submission.userId }), 16 | ]) 17 | assertNonNullable(problem) 18 | assertNonNullable(user) 19 | 20 | return { 21 | ...parseSubmission(submission), 22 | codeLength: Number(submission.codeLength), 23 | user, 24 | problem: parseProblem(problem), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/routes/progress.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Text } from '@chakra-ui/react' 2 | import { json, LoaderArgs } from '@remix-run/cloudflare' 3 | import { useLoaderData } from '@remix-run/react' 4 | import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js' 5 | import { fetchUserProgress } from '../../server/fetch-user-progress' 6 | import { ProgressCharts } from '../components/ProgressCharts' 7 | 8 | ChartJS.register(ArcElement, Tooltip, Legend) 9 | 10 | export async function loader({ context }: LoaderArgs) { 11 | const progressMap = await fetchUserProgress(context.env.DB, context.user) 12 | 13 | return json({ progressMap }) 14 | } 15 | 16 | export default function ProgressPage() { 17 | const { progressMap } = useLoaderData() 18 | 19 | return ( 20 | 21 | 22 | 挑戦結果 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@remix-run/dev').AppConfig} */ 2 | export default { 3 | devServerBroadcastDelay: 1000, 4 | ignoredRouteFiles: ['**/.*'], 5 | server: './server.ts', 6 | serverBuildPath: 'functions/[[path]].js', 7 | serverConditions: ['workerd', 'worker', 'browser'], 8 | serverDependenciesToBundle: 'all', 9 | serverMainFields: ['browser', 'module', 'main'], 10 | serverMinify: true, 11 | serverModuleFormat: 'esm', 12 | serverPlatform: 'neutral', 13 | serverNodeBuiltinsPolyfill: { 14 | modules: { 15 | // typescriptを動かすための設定 16 | inspector: 'empty', 17 | path: 'empty', 18 | os: 'empty', 19 | // serverNodeBuiltinsPolyfillを設定すると、remix buildのvfileでCould not resolve ("process" | "url")になったため追加 20 | process: true, 21 | url: true, 22 | }, 23 | }, 24 | future: { 25 | v2_dev: true, 26 | v2_errorBoundary: true, 27 | v2_headers: true, 28 | v2_meta: true, 29 | v2_normalizeFormMethod: true, 30 | v2_routeConvention: true, 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from '@remix-run/cloudflare' 2 | import { RemixServer } from '@remix-run/react' 3 | import isbot from 'isbot' 4 | import { renderToReadableStream } from 'react-dom/server' 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | remixContext: EntryContext 11 | ) { 12 | const body = await renderToReadableStream( 13 | , 14 | { 15 | signal: request.signal, 16 | onError(error: unknown) { 17 | // Log streaming rendering errors from inside the shell 18 | console.error(error) 19 | responseStatusCode = 500 20 | }, 21 | } 22 | ) 23 | 24 | if (isbot(request.headers.get('user-agent'))) { 25 | await body.allReady 26 | } 27 | 28 | responseHeaders.set('Content-Type', 'text/html') 29 | return new Response(body, { 30 | headers: responseHeaders, 31 | status: responseStatusCode, 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /server/fetch-rankings.ts: -------------------------------------------------------------------------------- 1 | import { calculateRankings, calculateRankingsRow } from './query/querier' 2 | 3 | type Ranking = { 4 | userRank: number 5 | userId: string 6 | screenName: string 7 | acceptedCount: number 8 | } 9 | 10 | const rankingsCacheKey = 'rankings' 11 | 12 | function formatRankings(rankings: calculateRankingsRow[]): Ranking[] { 13 | return rankings.map((ranking) => ({ 14 | ...ranking, 15 | userRank: Number(ranking.userRank), 16 | })) 17 | } 18 | 19 | export async function fetchRankings( 20 | db: D1Database, 21 | kv: KVNamespace 22 | ): Promise { 23 | const rankingsCache = await kv.get(rankingsCacheKey, { 24 | type: 'json', 25 | }) 26 | if (rankingsCache !== null) { 27 | return rankingsCache 28 | } 29 | 30 | const { results } = await calculateRankings(db) 31 | const rankings = formatRankings(results) 32 | 33 | // ランキングの計算結果は60秒間キャッシュする 34 | await kv.put(rankingsCacheKey, JSON.stringify(rankings), { 35 | expirationTtl: 60, 36 | }) 37 | return rankings 38 | } 39 | -------------------------------------------------------------------------------- /server/core/type-challenges-judge.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | userId: string 3 | screenName: string 4 | } 5 | 6 | export type ProblemDifficulty = 'warm' | 'easy' | 'medium' | 'hard' | 'extreme' 7 | 8 | export type Problem = { 9 | id: string 10 | title: string 11 | content: string 12 | difficulty: ProblemDifficulty 13 | tests: string 14 | playgroundUrl: string 15 | } 16 | 17 | export type SubmissionStatus = 'Judging' | 'Accepted' | 'Wrong Answer' 18 | 19 | export type Submission = { 20 | id: string 21 | user: User 22 | problem: Problem 23 | code: string 24 | status: SubmissionStatus 25 | codeLength: number 26 | diagnostics?: string[] 27 | commentary?: string 28 | createdAt: string 29 | } 30 | 31 | export type JudgeStatus = 'Accepted' | 'Wrong Answer' 32 | 33 | export type ChallengeResult = { 34 | userId: string 35 | problemId: string 36 | status: JudgeStatus 37 | } 38 | 39 | export type Progress = { 40 | difficulty: string 41 | acceptedCount: number 42 | wrongAnswerCount: number 43 | totalCount: number 44 | } 45 | -------------------------------------------------------------------------------- /app/components/ProblemButton.tsx: -------------------------------------------------------------------------------- 1 | import { ProblemResultStatus } from '../model' 2 | import { Link } from '@remix-run/react' 3 | import { Box } from '@chakra-ui/react' 4 | 5 | type Problem = { id: string; title: string } 6 | 7 | type ProblemButtonProps = { 8 | problem: Problem 9 | status: ProblemResultStatus | undefined 10 | } 11 | 12 | export const ProblemButton = ({ problem, status }: ProblemButtonProps) => { 13 | return ( 14 | 15 | 34 | {problem.title} 35 | 36 | 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /app/routes/problems.$problemId._index.tsx: -------------------------------------------------------------------------------- 1 | import { useOutletContext } from '@remix-run/react' 2 | import { Button, Wrap, Box, Link } from '@chakra-ui/react' 3 | import { toGitHubUrl } from '../utils/type-challenges' 4 | import { ProblemLayoutContext } from './problems.$problemId' 5 | import { CodeBlock } from '../components/CodeBlock' 6 | 7 | export default function ProblemPage() { 8 | const { problem } = useOutletContext() 9 | 10 | return ( 11 | <> 12 | 13 | 14 | 19 | 20 | 21 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /server/query/models.ts: -------------------------------------------------------------------------------- 1 | // Code generated by sqlc-gen-ts-d1. DO NOT EDIT. 2 | // versions: 3 | // sqlc v1.19.1 4 | // sqlc-gen-ts-d1 v0.0.0-a@254c24db5bcb2e1e16559e7f8498d7fa673ada31 5 | 6 | export type D1Migrations = { 7 | id: number; 8 | name: string | null; 9 | appliedAt: number | string; 10 | }; 11 | 12 | export type SqliteSequence = { 13 | name: number | string | null; 14 | seq: number | string | null; 15 | }; 16 | 17 | export type Problem = { 18 | id: string; 19 | title: string; 20 | content: string; 21 | difficulty: string; 22 | tests: string; 23 | playgroundUrl: string; 24 | }; 25 | 26 | export type User = { 27 | userId: string; 28 | screenName: string; 29 | }; 30 | 31 | export type Submission = { 32 | id: string; 33 | problemId: string; 34 | userId: string; 35 | code: string; 36 | codeLength: number | string; 37 | status: string; 38 | createdAt: string; 39 | }; 40 | 41 | export type Judgement = { 42 | submissionId: string; 43 | status: string; 44 | diagnostics: string; 45 | createdAt: string | null; 46 | }; 47 | 48 | export type ChallengeResult = { 49 | id: number; 50 | problemId: string; 51 | userId: string; 52 | status: string; 53 | }; 54 | 55 | -------------------------------------------------------------------------------- /app/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import ReactMarkdown from 'react-markdown' 2 | import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' 3 | import { dracula as syntaxStyle } from 'react-syntax-highlighter/dist/esm/styles/prism' 4 | import { 5 | CodeComponent, 6 | CodeProps, 7 | ReactMarkdownNames, 8 | } from 'react-markdown/lib/ast-to-react' 9 | 10 | const MarkdownCodeComponent: CodeComponent | ReactMarkdownNames = ({ 11 | inline, 12 | className, 13 | children, 14 | ...props 15 | }: CodeProps) => { 16 | const match = /language-(\w+)/.exec(className || '') 17 | 18 | if (inline || !match) 19 | return ( 20 | 21 | {children} 22 | 23 | ) 24 | 25 | return ( 26 | 27 | {String(children).replace(/\n$/, '')} 28 | 29 | ) 30 | } 31 | 32 | type CodeBlockProps = { 33 | code: string | undefined 34 | } 35 | 36 | export const CodeBlock = ({ code }: CodeBlockProps) => { 37 | return code === undefined ? ( 38 | <> 39 | ) : ( 40 | 41 | {code} 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/utils/pagination.ts: -------------------------------------------------------------------------------- 1 | const LEFT_PAGE: 'LEFT' = 'LEFT' as const 2 | const RIGHT_PAGE: 'RIGHT' = 'RIGHT' as const 3 | 4 | export type PageType = number | 'LEFT' | 'RIGHT' 5 | 6 | function range(from: number, to: number): number[] { 7 | return [...Array(to - from + 1)].map((_, index) => from + index) 8 | } 9 | 10 | export function generatePages( 11 | currentPage: number, 12 | totalPage: number 13 | ): PageType[] { 14 | if (totalPage <= 9) { 15 | return range(1, totalPage) 16 | } 17 | 18 | const START_PAGE = 1 19 | 20 | let middlePages: PageType[] = [] 21 | const middlePageFrom = Math.max(START_PAGE + 2, currentPage - 1) 22 | const middlePageTo = Math.min(totalPage - 2, currentPage + 1) 23 | 24 | if (middlePageFrom === START_PAGE + 2) { 25 | middlePages = [...range(middlePageFrom, middlePageFrom + 3), RIGHT_PAGE] 26 | } else if (middlePageTo >= totalPage - 2) { 27 | middlePages = [LEFT_PAGE, ...range(middlePageTo - 3, middlePageTo)] 28 | } else { 29 | middlePages = [ 30 | LEFT_PAGE, 31 | ...range(currentPage - 1, currentPage + 1), 32 | RIGHT_PAGE, 33 | ] 34 | } 35 | 36 | return [ 37 | ...range(START_PAGE, START_PAGE + 1), 38 | ...middlePages, 39 | ...range(totalPage - 1, totalPage), 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # type-challenges-judge 2 | 3 | [type-challenges](https://github.com/type-challenges/type-challenges)のオンラインジャッジです。 4 | 5 | ![](https://i.gyazo.com/e9eff32dc0db479da0a31eef62ebdd21.png) 6 | ## できること 7 | 8 | - type-challengesの問題の閲覧する 9 | - 問題の回答の提出して、正誤を確認する 10 | - 自分がどれくらい問題を解いたかをグラフで確認する 11 | 12 | 13 | ## 環境構築 14 | 15 | - Node.js 18 16 | - sqlc 17 | - [Installing sqlc — sqlc 1.20.0 documentation](https://docs.sqlc.dev/en/stable/overview/install.html) 18 | 19 | ```bash 20 | yarn install 21 | 22 | # データベースに初期データを入れる 23 | yarn db:seed 24 | 25 | # Firebaseのエミュレーターの起動する 26 | yarn emulators 27 | ``` 28 | 29 | ```bash 30 | # 判定用のワーカーを起動する 31 | ln -s ../.wrangler judge-worker/.wrangler # ローカルのデータベースを共有する 32 | cd judge-worker 33 | yarn dev 34 | ``` 35 | 36 | ```bash 37 | # 開発サーバーを起動する 38 | yarn dev 39 | ``` 40 | 41 | ## 開発 42 | 43 | ### データベースのマイグレーション 44 | 45 | wranglerで、マイグレーションファイルを作成して実行します。 46 | 47 | ```bash 48 | wrangler d1 migrations create type-challenges-judge 49 | yarn migrate 50 | ``` 51 | 52 | マイグレーションを実行したら、sqlcで使用するための`database/schema.sql`を次のコマンドで更新します。 53 | 54 | ```bash 55 | yarn db:dump-schema 56 | ``` 57 | 58 | ### データベースにアクセスする処理を書く 59 | 60 | `server/query.sql`にSQLを書いてから、次のコマンドを実行してD1用のクエリと型を生成します。 61 | 62 | ```bash 63 | yarn generate:query 64 | ``` 65 | -------------------------------------------------------------------------------- /server/utils/database.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JudgeStatus, 3 | ProblemDifficulty, 4 | SubmissionStatus, 5 | } from '../core/type-challenges-judge' 6 | 7 | type HasProblemDifficulty = { 8 | difficulty: string 9 | } 10 | 11 | export function parseProblem( 12 | problem: T 13 | ): T & { difficulty: ProblemDifficulty } { 14 | return { ...problem, difficulty: problem.difficulty as ProblemDifficulty } 15 | } 16 | 17 | type HasSubmissionStatus = { status: string } 18 | 19 | export function parseSubmission( 20 | submission: T 21 | ): T & { status: SubmissionStatus } { 22 | return { 23 | ...submission, 24 | status: submission.status as SubmissionStatus, 25 | } 26 | } 27 | 28 | type HasJudgeStatus = { status: string } 29 | 30 | export function parseChallengeResult( 31 | challengeResult: T 32 | ): T & { status: JudgeStatus } { 33 | return { 34 | ...challengeResult, 35 | status: challengeResult.status as JudgeStatus, 36 | } 37 | } 38 | 39 | /** 40 | * 20桁の英数字のID(Firestoreの自動生成IDと同じ形式)を作成する 41 | */ 42 | export function generateAutoId(): string { 43 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 44 | let autoId = '' 45 | for (let i = 0; i < 20; i++) { 46 | autoId += chars.charAt(Math.floor(Math.random() * chars.length)) 47 | } 48 | return autoId 49 | } 50 | -------------------------------------------------------------------------------- /database/utils/type-challenges.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from 'utility-types' 2 | 3 | // https://github.com/type-challenges/type-challenges/blob/main/scripts/types.ts 4 | export interface QuizMetaInfo { 5 | title: string 6 | author: { 7 | name: string 8 | email: string 9 | github: string 10 | } 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | tsconfig?: Record 13 | original_issues: number[] 14 | recommended_solutions: number[] 15 | tags: string[] 16 | related?: string[] 17 | } 18 | 19 | export type Difficulty = 20 | | 'warm' 21 | | 'easy' 22 | | 'medium' 23 | | 'hard' 24 | | 'extreme' 25 | | 'pending' 26 | 27 | export interface Quiz { 28 | no: number 29 | difficulty: Difficulty 30 | path: string 31 | readme: Record 32 | template: string 33 | info: Record | undefined> 34 | tests?: string 35 | solutions?: { 36 | code?: string 37 | readme?: Record 38 | } 39 | } 40 | 41 | // type-challenges/type-challengesより引用 42 | function toDivider(text: string) { 43 | return `/* _____________ ${text} _____________ */\n` 44 | } 45 | 46 | export function formatToCode(quiz: Quiz) { 47 | const codes = [ 48 | toDivider('Your Code Here'), 49 | `${quiz.template.trim()}\n`, 50 | toDivider('Test Cases'), 51 | quiz.tests || '', 52 | ] 53 | 54 | return codes.join('\n') 55 | } 56 | -------------------------------------------------------------------------------- /server/create-submission.ts: -------------------------------------------------------------------------------- 1 | import invariant from 'tiny-invariant' 2 | import { assertNonNullable } from './utils/assertion' 3 | import { generateAutoId } from './utils/database' 4 | import { 5 | createSubmission as createSubmissionQuery, 6 | findProblem, 7 | } from './query/querier' 8 | import { JudgeQueueMessage } from '../judge-worker/src/judge-worker' 9 | 10 | export async function createSubmission( 11 | db: D1Database, 12 | judgeWorker: Fetcher, 13 | request: Request, 14 | submission: { userId: string; problemId: string; code: string } 15 | ): Promise<{ id: string; problemId: string }> { 16 | const problem = await findProblem(db, { id: submission.problemId }) 17 | invariant(problem, 'invalid problem') 18 | 19 | // 提出を登録する 20 | const createdSubmission = await createSubmissionQuery(db, { 21 | id: generateAutoId(), 22 | problemId: submission.problemId, 23 | userId: submission.userId, 24 | code: submission.code, 25 | codeLength: submission.code.length, 26 | status: 'Judging', 27 | }) 28 | assertNonNullable(createdSubmission) 29 | 30 | // 判定処理をワーカーで行う(TODO: リトライ) 31 | const message: JudgeQueueMessage = { 32 | submissionId: createdSubmission.id, 33 | problemId: submission.problemId, 34 | userId: submission.userId, 35 | code: submission.code, 36 | tests: problem.tests, 37 | } 38 | await judgeWorker.fetch(request, { 39 | method: 'POST', 40 | body: JSON.stringify(message), 41 | }) 42 | 43 | return createdSubmission 44 | } 45 | -------------------------------------------------------------------------------- /judge-worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 4 | "lib": [ 5 | "es2021" 6 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 7 | "jsx": "react" /* Specify what JSX code is generated. */, 8 | "module": "es2022" /* Specify what module code is generated. */, 9 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 10 | "types": [ 11 | "@cloudflare/workers-types" 12 | ] /* Specify type package names to be included without being referenced in a source file. */, 13 | "resolveJsonModule": true /* Enable importing .json files */, 14 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */, 15 | "checkJs": false /* Enable error reporting in type-checked JavaScript files. */, 16 | "noEmit": true /* Disable emitting files from a compilation. */, 17 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, 18 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 19 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 20 | "strict": true /* Enable all strict type-checking options. */, 21 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/routes/ranking.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Container, 3 | Table, 4 | TableContainer, 5 | Tbody, 6 | Td, 7 | Text, 8 | Th, 9 | Thead, 10 | Tr, 11 | } from '@chakra-ui/react' 12 | import { json, LoaderArgs } from '@remix-run/cloudflare' 13 | import { useLoaderData } from '@remix-run/react' 14 | import { fetchRankings } from '../../server/fetch-rankings' 15 | import { useAuth } from '../hooks/use-auth' 16 | 17 | export async function loader({ context }: LoaderArgs) { 18 | const rankings = await fetchRankings(context.env.DB, context.env.KV) 19 | 20 | return json({ rankings }) 21 | } 22 | 23 | export default function Ranking() { 24 | const { rankings } = useLoaderData() 25 | const { user } = useAuth() 26 | 27 | return ( 28 | 29 | 30 | ランキング 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {rankings.map((ranking) => ( 43 | 49 | 50 | 51 | 52 | 53 | ))} 54 | 55 |
ユーザー名正解数
{ranking.userRank}{ranking.screenName}{ranking.acceptedCount}
56 |
57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { json, LoaderArgs } from '@remix-run/cloudflare' 2 | import { LiveReload, Outlet, Scripts, useLoaderData } from '@remix-run/react' 3 | import { ChakraProvider } from '@chakra-ui/react' 4 | import { DefaultLayout } from './DefaultLayout' 5 | import { AuthProvider } from './lib/authentication' 6 | 7 | export function loader({ context }: LoaderArgs) { 8 | return json({ user: context.user }) 9 | } 10 | 11 | export default function Root() { 12 | const { user } = useLoaderData() 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | type-challenges-judge 21 | 22 | 26 | 27 | 31 | 35 | 36 | 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /database/update-problems.ts: -------------------------------------------------------------------------------- 1 | import { D1Database } from '@cloudflare/workers-types/2022-11-30' 2 | import lzs from 'lz-string' 3 | import { Problem } from '../app/model' 4 | import { formatToCode, Quiz } from './utils/type-challenges' 5 | 6 | const TYPESCRIPT_PLAYGROUND = 'https://www.typescriptlang.org/play' 7 | 8 | function toPlaygroundUrl(code: string, site = TYPESCRIPT_PLAYGROUND): string { 9 | return `${site}#code/${lzs.compressToEncodedURIComponent(code)}` 10 | } 11 | 12 | export async function updateProblems(db: D1Database, quizes: Quiz[]) { 13 | const statements = quizes 14 | .filter((quiz) => quiz.difficulty !== 'pending') 15 | .map((quiz) => { 16 | if (quiz.difficulty === 'pending') throw new Error() 17 | const title = quiz.info?.en?.title 18 | if (title === undefined) { 19 | throw new Error(`問題${quiz.path}のタイトルがありません`) 20 | } 21 | if (quiz.tests === undefined) { 22 | throw new Error(`問題${quiz.path}のテストがありません`) 23 | } 24 | if (quiz.info.en === undefined) { 25 | throw new Error(`問題${quiz.path}のinfoがありません`) 26 | } 27 | 28 | const problem: Problem = { 29 | id: quiz.path, 30 | title, 31 | content: quiz.readme.en, 32 | difficulty: quiz.difficulty, 33 | tests: quiz.tests, 34 | playground_url: toPlaygroundUrl(formatToCode(quiz)), 35 | } 36 | 37 | // TODO: upsertする 38 | return db 39 | .prepare( 40 | 'insert into problem (id, title, content, difficulty, tests, playground_url) values (?, ?, ?, ?, ?, ?);' 41 | ) 42 | .bind( 43 | problem.id, 44 | problem.title, 45 | problem.content, 46 | problem.difficulty, 47 | problem.tests, 48 | problem.playground_url 49 | ) 50 | }) 51 | await db.batch(statements) 52 | } 53 | -------------------------------------------------------------------------------- /database/migrations/0000_init.sql: -------------------------------------------------------------------------------- 1 | -- Migration number: 0000 2023-08-01T06:45:56.166Z 2 | 3 | -- 問題 4 | CREATE TABLE problem ( 5 | id TEXT NOT NULL PRIMARY KEY, 6 | title TEXT NOT NULL, 7 | content TEXT NOT NULL, 8 | difficulty TEXT NOT NULL CHECK (difficulty in ('warm', 'easy', 'medium', 'hard', 'extreme')), 9 | tests TEXT NOT NULL, 10 | playground_url TEXT NOT NULL 11 | ) STRICT; 12 | 13 | -- ユーザー 14 | CREATE TABLE user ( 15 | user_id TEXT NOT NULL PRIMARY KEY, 16 | screen_name TEXT NOT NULL 17 | ) STRICT; 18 | 19 | -- 提出 20 | CREATE TABLE submission ( 21 | id TEXT NOT NULL PRIMARY KEY, 22 | problem_id TEXT NOT NULL, 23 | user_id TEXT NOT NULL, 24 | code TEXT NOT NULL, 25 | code_length INT NOT NULL, 26 | status TEXT NOT NULL CHECK (status in ('Judging', 'Accepted', 'Wrong Answer')), 27 | created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, 28 | 29 | FOREIGN KEY (problem_id) REFERENCES problem (id) ON DELETE RESTRICT ON UPDATE CASCADE, 30 | FOREIGN KEY (user_id) REFERENCES user (user_id) ON DELETE RESTRICT ON UPDATE CASCADE 31 | ) STRICT; 32 | 33 | -- 提出の判定結果 34 | CREATE TABLE judgement ( 35 | submission_id TEXT NOT NULL PRIMARY KEY, 36 | status TEXT NOT NULL CHECK (status in ('Accepted', 'Wrong Answer')), 37 | diagnostics TEXT NOT NULL, -- コンパイルエラーメッセージの配列(JSONで保存する) 38 | created_at TEXT DEFAULT CURRENT_TIMESTAMP, 39 | 40 | FOREIGN KEY (submission_id) REFERENCES submission (id) ON DELETE RESTRICT ON UPDATE CASCADE 41 | ) STRICT; 42 | 43 | -- 問題の挑戦結果(ある問題をあるユーザーが正解しているか) 44 | CREATE TABLE challenge_result ( 45 | id INTEGER NOT NULL PRIMARY KEY, 46 | problem_id TEXT NOT NULL, 47 | user_id TEXT NOT NULL, 48 | status TEXT NOT NULL CHECK (status in ('Accepted', 'Wrong Answer')), 49 | 50 | UNIQUE (problem_id, user_id), 51 | FOREIGN KEY (problem_id) REFERENCES problem (id) ON DELETE RESTRICT ON UPDATE CASCADE, 52 | FOREIGN KEY (user_id) REFERENCES user (user_id) ON DELETE RESTRICT ON UPDATE CASCADE 53 | ) STRICT; 54 | -------------------------------------------------------------------------------- /app/routes/problems.$problemId.submit.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertIcon, Button, Stack, Textarea } from '@chakra-ui/react' 2 | import { createSubmission } from '../../server/create-submission' 3 | import { useAuth } from '../hooks/use-auth' 4 | import { useOutletContext, Form } from '@remix-run/react' 5 | import { ProblemLayoutContext } from './problems.$problemId' 6 | import { ActionArgs, redirect } from '@remix-run/cloudflare' 7 | import invariant from 'tiny-invariant' 8 | 9 | export async function action({ request, context }: ActionArgs) { 10 | const body = await request.formData() 11 | invariant(context.user, 'ログインが必要です') 12 | 13 | const userId = context.user.userId 14 | const problemId = await body.get('problemId') 15 | const code = await body.get('code') 16 | 17 | invariant(typeof problemId === 'string', 'problemId must be a string') 18 | invariant(typeof code === 'string', 'code must be a string') 19 | 20 | const submission = await createSubmission( 21 | context.env.DB, 22 | context.env.JUDGE_WORKER, 23 | request, 24 | { 25 | userId, 26 | problemId, 27 | code, 28 | } 29 | ) 30 | 31 | return redirect( 32 | `/problems/${submission.problemId}/submissions/${submission.id}` 33 | ) 34 | } 35 | 36 | export default function SubmitPage() { 37 | const { user } = useAuth() 38 | const { problem } = useOutletContext() 39 | const canSubmit = user !== undefined 40 | 41 | return ( 42 | 43 | {!user && ( 44 | 45 | 46 | 提出するにはログインしてください 47 | 48 | )} 49 |
50 | 51 |