├── .eslintrc ├── .github ├── FUNDING.yml └── dependabot.yml ├── .gitignore ├── .node-version ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── drizzle.config.ts ├── migrations ├── 0000_volatile_frank_castle.sql ├── 0001_thin_celestials.sql ├── 0004_whole_paladin.sql ├── 0005_strong_lady_vermin.sql ├── 0006_slow_proudstar.sql ├── 0007_low_dracula.sql ├── 0008_sour_tusk.sql ├── 0009_right_cerise.sql ├── 0010_romantic_imperial_guard.sql ├── 0011_equal_nico_minoru.sql ├── 0012_dashing_juggernaut.sql ├── 0013_plain_garia.sql ├── 0014_quiet_magneto.sql ├── 0015_polite_iron_lad.sql ├── 0016_living_yellowjacket.sql ├── 0017_many_black_bird.sql ├── 0018_yellow_vertigo.sql ├── 0019_wild_mathemanic.sql ├── 0020_lethal_justin_hammer.sql ├── 0021_cheerful_warpath.sql ├── 0022_quiet_nocturne.sql ├── 0023_neat_rhino.sql ├── 0024_fearless_grandmaster.sql ├── 0025_colorful_taskmaster.sql ├── 0026_simple_bruce_banner.sql ├── 0027_demonic_bloodstrike.sql ├── 0028_mean_thena.sql ├── 0029_steady_shotgun.sql └── meta │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ ├── 0004_snapshot.json │ ├── 0005_snapshot.json │ ├── 0006_snapshot.json │ ├── 0007_snapshot.json │ ├── 0008_snapshot.json │ ├── 0009_snapshot.json │ ├── 0010_snapshot.json │ ├── 0011_snapshot.json │ ├── 0012_snapshot.json │ ├── 0013_snapshot.json │ ├── 0014_snapshot.json │ ├── 0015_snapshot.json │ ├── 0016_snapshot.json │ ├── 0017_snapshot.json │ ├── 0018_snapshot.json │ ├── 0019_snapshot.json │ ├── 0020_snapshot.json │ ├── 0021_snapshot.json │ ├── 0022_snapshot.json │ ├── 0023_snapshot.json │ ├── 0024_snapshot.json │ ├── 0025_snapshot.json │ ├── 0026_snapshot.json │ ├── 0027_snapshot.json │ ├── 0028_snapshot.json │ ├── 0029_snapshot.json │ └── _journal.json ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.js ├── package.json ├── patches └── react-mapycz@1.2.0.patch ├── pnpm-lock.yaml ├── public ├── Logo-white.svg ├── favicon.ico ├── google3e8efa5f2044c710.html ├── map-marker-green.svg ├── map-marker-grey.svg ├── map-marker-orange.svg └── vercel.svg ├── sandbox.config.json ├── sentry.client.config.ts ├── sentry.edge.config.ts ├── sentry.properties ├── sentry.server.config.ts ├── src ├── components │ ├── Demo │ │ └── DemoSolution.tsx │ ├── ErrorBoundary │ │ └── ErrorBoundary.tsx │ ├── LightBox │ │ └── LightBox.tsx │ ├── MapPicker │ │ ├── MapPicker.tsx │ │ ├── MapWithAnswers.tsx │ │ └── types.ts │ ├── Question │ │ └── QuestionTask.tsx │ ├── common │ │ ├── Loader │ │ │ ├── Loader.tsx │ │ │ └── loader.module.css │ │ ├── MessageBox │ │ │ └── MessageBox.tsx │ │ └── Typography │ │ │ └── typography.tsx │ ├── form │ │ ├── NickNameForm.tsx │ │ └── UploadQuestionForm.tsx │ ├── navbar │ │ ├── Navbar.tsx │ │ └── UserBox.tsx │ ├── ranking │ │ ├── ScoreCalculator.tsx │ │ ├── TableTournamentMedals.tsx │ │ ├── TableTournamentPoints.tsx │ │ └── TournamentRoundLinks.tsx │ └── tournament │ │ └── TournamentPlayContainer.tsx ├── createEmotionCache.ts ├── db │ ├── drizzle.ts │ ├── schema.ts │ └── types.ts ├── hooks │ ├── use-image-upload.ts │ ├── use-is-admin.ts │ ├── use-is-mobile.ts │ ├── use-is-server.ts │ ├── use-page-loader.ts │ └── use-webview-login-redirect.ts ├── layouts │ └── DefaultLayout.tsx ├── middleware.ts ├── pages │ ├── 404.tsx │ ├── _app.tsx │ ├── _document.tsx │ ├── _error.js │ ├── admin │ │ ├── questions │ │ │ └── index.tsx │ │ └── upload-photo.tsx │ ├── api │ │ ├── qstash │ │ │ ├── end-round.ts │ │ │ └── update-nickname.ts │ │ └── trpc │ │ │ └── [trpc].ts │ ├── auth │ │ ├── login-receiver.tsx │ │ ├── login.tsx │ │ ├── sign-up.tsx │ │ ├── user.tsx │ │ └── webview.tsx │ ├── demo.tsx │ ├── faq.tsx │ ├── index.tsx │ ├── play │ │ ├── [tournamentId] │ │ │ ├── [roundOrder].tsx │ │ │ └── index.tsx │ │ └── index.tsx │ ├── ranking │ │ ├── [tournamentId] │ │ │ ├── [roundOrder].tsx │ │ │ └── index.tsx │ │ └── index.tsx │ └── unauthorized.tsx ├── server │ ├── context.ts │ ├── env.js │ ├── kafka │ │ ├── kafka.ts │ │ └── types.ts │ ├── qstash │ │ ├── qstash.ts │ │ └── types.ts │ ├── redis │ │ └── redis.ts │ ├── revalidate │ │ └── revalidate.ts │ ├── routers │ │ ├── _app.ts │ │ ├── authRouter.ts │ │ ├── cityRouter.ts │ │ ├── question │ │ │ ├── getRoundQuestion.ts │ │ │ ├── questionRouter.ts │ │ │ └── types.ts │ │ ├── ranking │ │ │ ├── rankingRouter.ts │ │ │ └── types.ts │ │ └── tournamentRouter.ts │ ├── ssgHelpers.ts │ └── trpc.ts ├── styles │ └── global.css ├── theme.ts └── utils │ ├── answer │ ├── find-duplicite-answers.test.ts │ └── find-duplicite-answers.ts │ ├── clerk │ ├── cs.json │ └── localization.tsx │ ├── formatter │ └── dateFormatter.ts │ ├── image-placeholder │ └── image-placeholder.ts │ ├── publicRuntimeConfig.ts │ ├── ranking │ ├── createDurationString.ts │ └── sortAnswers.ts │ ├── score │ ├── constants.ts │ └── evaluate-score.ts │ ├── transformer.ts │ └── trpc.ts ├── ts-reset.ts ├── tsconfig.json ├── vercel.json └── vitest.config.ts /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", // Specifies the ESLint parser 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | "plugin:react/recommended", 6 | "plugin:react-hooks/recommended", 7 | "plugin:prettier/recommended" 8 | ], 9 | "parserOptions": { 10 | "project": "tsconfig.json", 11 | "ecmaVersion": 2018, // Allows for the parsing of modern ECMAScript features 12 | "sourceType": "module" // Allows for the use of imports 13 | }, 14 | "rules": { 15 | "@typescript-eslint/explicit-function-return-type": "off", 16 | "@typescript-eslint/explicit-module-boundary-types": "off", 17 | "@typescript-eslint/no-non-null-assertion": "off", 18 | "react/react-in-jsx-scope": "off", 19 | "react/prop-types": "off", 20 | "@typescript-eslint/ban-ts-comment": "off", 21 | "@typescript-eslint/no-explicit-any": "off" 22 | }, 23 | "settings": { 24 | "react": { 25 | "version": "detect" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: dominikjasek 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pnpm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 2 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # eslint 4 | .eslintcache 5 | 6 | # dependencies 7 | /node_modules 8 | /.pnp 9 | .pnp.js 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | 21 | # misc 22 | .DS_Store 23 | tsconfig.tsbuildinfo 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # local env files 32 | .env 33 | .env.development.local 34 | .env.test.local 35 | .env.production.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | *.db 41 | *.db-journal 42 | 43 | 44 | # testing 45 | playwright/test-results 46 | 47 | # Sentry 48 | .sentryclirc 49 | 50 | # Sentry 51 | next.config.original.js 52 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.12.0 2 | 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts=true 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "dbaeumer.vscode-eslint", 5 | "prisma.prisma" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### City Hunter App 2 | App for experimenting with Next.js + tRPC + Edge Functions + PlanetScale DB serverless driver + Clerk Authentication 3 | 4 | ### Database 5 | - I am using PlanetScale MySQL database. For certain reasons, it [doesn't support foreign keys](https://planetscale.com/docs/learn/operating-without-foreign-key-constraints) 6 | - DrizzleORM is cool light way to query DB, is uses mostly sql, but also provides some [relational queries](https://orm.drizzle.team/docs/rqb) 7 | - I am using [planetscale database driver](https://github.com/drizzle-team/drizzle-orm/blob/main/drizzle-orm/src/mysql-core/README.md) which doesn't reate SQL connection with database, but rather uses http layer to communicate with planetscale http driver 8 | - For caching [Upstash Redis](https://upstash.com/) for caching question 9 | 10 | ### Api 11 | - Rest is cool, but lacking type support 12 | - GraphQL is cool with great type support, but there is a lot of boilerplate and other technical challenges like [N+1 problem](https://www.youtube.com/watch?v=uCbFMZYQbxE) 13 | - [tRPC](https://trpc.io/) is amazing alternative taking best of both worlds. I am using it within Next.js api 14 | 15 | ### Serverless 16 | - I am using Edge functions for serverless solution, because AWS Lambda has cold starts 17 | 18 | ### Messaging 19 | - I am using [Upstash](https://upstash.com/) Qstash for asynchronnous work and Kafka for messaging 20 | 21 | ### Monitoring 22 | - Sentry for logging errors 23 | - Vercel Analytics for web analytics 24 | - Axiom for data querying and web traffic 25 | 26 | ### Authentication 27 | - Clerk is nice alternative to Auth0 and works on Edge (Auth0 doesn't 😅🙈) 28 | 29 | ### Development 30 | For testing locally on phone, I can use Cloudflared tunnels: 31 | ```shell 32 | cloudflared tunnel --url http://localhost:3400 33 | ``` 34 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'drizzle-kit'; 2 | import 'dotenv/config'; 3 | 4 | const config = { 5 | out: './migrations', 6 | schema: './src/db/schema.ts', 7 | connectionString: process.env.DATABASE_URL, 8 | } satisfies Config 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /migrations/0000_volatile_frank_castle.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `users` ( 2 | `id` serial AUTO_INCREMENT PRIMARY KEY NOT NULL 3 | ); 4 | -------------------------------------------------------------------------------- /migrations/0001_thin_celestials.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users MODIFY COLUMN `id` varchar(100) NOT NULL; 2 | ALTER TABLE users ADD `nick_name` varchar(40) NOT NULL; 3 | ALTER TABLE users ADD `created_at` timestamp DEFAULT (now()) NOT NULL; 4 | ALTER TABLE users ADD `updated_at` timestamp DEFAULT (now()) NOT NULL; -------------------------------------------------------------------------------- /migrations/0004_whole_paladin.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `answers` ( 2 | `id` serial AUTO_INCREMENT PRIMARY KEY NOT NULL, 3 | `location` json NOT NULL, 4 | `score` int NOT NULL, 5 | `question_id` int NOT NULL, 6 | `user_id` varchar(100) NOT NULL, 7 | `created_at` timestamp NOT NULL DEFAULT (now()), 8 | `updated_at` timestamp NOT NULL DEFAULT (now())); 9 | 10 | CREATE TABLE `cities` ( 11 | `id` serial AUTO_INCREMENT PRIMARY KEY NOT NULL, 12 | `name` varchar(50) NOT NULL); 13 | 14 | CREATE TABLE `games` ( 15 | `id` serial AUTO_INCREMENT PRIMARY KEY NOT NULL, 16 | `name` varchar(100) NOT NULL, 17 | `city_id` int NOT NULL); 18 | 19 | CREATE TABLE `questions` ( 20 | `id` serial AUTO_INCREMENT PRIMARY KEY NOT NULL, 21 | `title` varchar(100) NOT NULL, 22 | `question_description` text NOT NULL, 23 | `answer_description` text NOT NULL, 24 | `image` varchar(100) NOT NULL, 25 | `city_id` int, 26 | `start_date` datetime, 27 | `end_date` datetime, 28 | `location` json NOT NULL, 29 | `game_id` int, 30 | `demo` boolean NOT NULL); 31 | -------------------------------------------------------------------------------- /migrations/0005_strong_lady_vermin.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE `games`; 2 | ALTER TABLE questions DROP COLUMN `game_id`; 3 | -------------------------------------------------------------------------------- /migrations/0006_slow_proudstar.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions MODIFY COLUMN `question_description` text; 2 | ALTER TABLE questions MODIFY COLUMN `answer_description` text; -------------------------------------------------------------------------------- /migrations/0007_low_dracula.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions ADD `author_id` varchar(100) NOT NULL; -------------------------------------------------------------------------------- /migrations/0008_sour_tusk.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE cities ADD `created_at` timestamp DEFAULT (now()) NOT NULL; 2 | ALTER TABLE questions ADD `created_at` timestamp DEFAULT (now()) NOT NULL; 3 | ALTER TABLE users DROP COLUMN `updated_at`; -------------------------------------------------------------------------------- /migrations/0009_right_cerise.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions MODIFY COLUMN `title` varchar(250) NOT NULL; -------------------------------------------------------------------------------- /migrations/0010_romantic_imperial_guard.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions MODIFY COLUMN `image` varchar(250) NOT NULL; -------------------------------------------------------------------------------- /migrations/0011_equal_nico_minoru.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `tournaments` ( 2 | `id` serial AUTO_INCREMENT PRIMARY KEY NOT NULL, 3 | `name` varchar(100) NOT NULL, 4 | `city_id` int NOT NULL, 5 | `created_at` timestamp NOT NULL DEFAULT (now())); 6 | -------------------------------------------------------------------------------- /migrations/0012_dashing_juggernaut.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions ADD `tournament_id` int; -------------------------------------------------------------------------------- /migrations/0013_plain_garia.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE cities ADD `preview_image_url` varchar(250) NOT NULL; -------------------------------------------------------------------------------- /migrations/0014_quiet_magneto.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE cities MODIFY COLUMN `preview_image_url` varchar(250); -------------------------------------------------------------------------------- /migrations/0015_polite_iron_lad.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tournaments DROP COLUMN `created_at`; -------------------------------------------------------------------------------- /migrations/0016_living_yellowjacket.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tournaments ADD `description` text; 2 | ALTER TABLE tournaments ADD `start_date` date; 3 | ALTER TABLE tournaments ADD `end_date` date; -------------------------------------------------------------------------------- /migrations/0017_many_black_bird.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE cities ADD `center_point` json DEFAULT ('{"lat":49.21866559856739,"lng":15.880347529353775}') NOT NULL; -------------------------------------------------------------------------------- /migrations/0018_yellow_vertigo.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE cities ADD `map_zoom` int DEFAULT 14 NOT NULL; -------------------------------------------------------------------------------- /migrations/0019_wild_mathemanic.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions ADD `question_image_url` varchar(250); 2 | ALTER TABLE questions ADD `answer_image_url` varchar(250); -------------------------------------------------------------------------------- /migrations/0020_lethal_justin_hammer.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions MODIFY COLUMN `question_image_url` varchar(250) NOT NULL; 2 | ALTER TABLE questions MODIFY COLUMN `answer_image_url` varchar(250) NOT NULL; 3 | ALTER TABLE questions DROP COLUMN `image`; -------------------------------------------------------------------------------- /migrations/0021_cheerful_warpath.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions ADD `answer_images_url` text DEFAULT ('') NOT NULL; -------------------------------------------------------------------------------- /migrations/0022_quiet_nocturne.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions DROP COLUMN `answer_image_url`; -------------------------------------------------------------------------------- /migrations/0023_neat_rhino.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE questions MODIFY COLUMN `answer_images_url` text NOT NULL; -------------------------------------------------------------------------------- /migrations/0024_fearless_grandmaster.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE answers ADD `answered_at` datetime; 2 | ALTER TABLE answers DROP COLUMN `created_at`; 3 | ALTER TABLE answers DROP COLUMN `updated_at`; -------------------------------------------------------------------------------- /migrations/0025_colorful_taskmaster.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE answers MODIFY COLUMN `answered_at` datetime NOT NULL; -------------------------------------------------------------------------------- /migrations/0026_simple_bruce_banner.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `answers` MODIFY COLUMN `answered_at` timestamp NOT NULL; 2 | ALTER TABLE `questions` MODIFY COLUMN `start_date` timestamp; 3 | ALTER TABLE `questions` MODIFY COLUMN `end_date` timestamp; -------------------------------------------------------------------------------- /migrations/0027_demonic_bloodstrike.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `questions` MODIFY COLUMN `tournament_id` varchar(100); 2 | ALTER TABLE `tournaments` MODIFY COLUMN `id` varchar(100) NOT NULL; -------------------------------------------------------------------------------- /migrations/0028_mean_thena.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `questions` ADD `round_order` int; -------------------------------------------------------------------------------- /migrations/0029_steady_shotgun.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `answers` ADD `medal` enum('GOLD','SILVER','BRONZE'); 2 | CREATE INDEX `round_order_idx` ON `questions` (`round_order`,`tournament_id`); -------------------------------------------------------------------------------- /migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "id": "b927c80a-ab83-43d7-b860-24c953f15104", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "users": { 8 | "name": "users", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "serial", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | } 17 | }, 18 | "indexes": {}, 19 | "foreignKeys": {}, 20 | "compositePrimaryKeys": {} 21 | } 22 | }, 23 | "schemas": {}, 24 | "_meta": { 25 | "schemas": {}, 26 | "tables": {}, 27 | "columns": {} 28 | } 29 | } -------------------------------------------------------------------------------- /migrations/meta/0001_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "id": "a19c40e5-9fa3-4af3-ae8f-ba212de059cc", 5 | "prevId": "b927c80a-ab83-43d7-b860-24c953f15104", 6 | "tables": { 7 | "users": { 8 | "name": "users", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "varchar(100)", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "nick_name": { 18 | "name": "nick_name", 19 | "type": "varchar(40)", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "created_at": { 25 | "name": "created_at", 26 | "type": "timestamp", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false, 30 | "default": "(now())" 31 | }, 32 | "updated_at": { 33 | "name": "updated_at", 34 | "type": "timestamp", 35 | "primaryKey": false, 36 | "notNull": true, 37 | "autoincrement": false, 38 | "default": "(now())" 39 | } 40 | }, 41 | "indexes": {}, 42 | "foreignKeys": {}, 43 | "compositePrimaryKeys": {} 44 | } 45 | }, 46 | "schemas": {}, 47 | "_meta": { 48 | "schemas": {}, 49 | "tables": {}, 50 | "columns": {} 51 | } 52 | } -------------------------------------------------------------------------------- /migrations/meta/0005_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "id": "4f1eaa64-b0a7-44ae-a298-584b02f5b011", 5 | "prevId": "a71430ab-28d8-4eee-bf64-32e922c01325", 6 | "tables": { 7 | "answers": { 8 | "name": "answers", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "serial", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "location": { 18 | "name": "location", 19 | "type": "json", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "score": { 25 | "name": "score", 26 | "type": "int", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "question_id": { 32 | "name": "question_id", 33 | "type": "int", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "user_id": { 39 | "name": "user_id", 40 | "type": "varchar(100)", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "timestamp", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false, 51 | "default": "(now())" 52 | }, 53 | "updated_at": { 54 | "name": "updated_at", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": true, 58 | "autoincrement": false, 59 | "default": "(now())" 60 | } 61 | }, 62 | "indexes": {}, 63 | "foreignKeys": {}, 64 | "compositePrimaryKeys": {} 65 | }, 66 | "cities": { 67 | "name": "cities", 68 | "columns": { 69 | "id": { 70 | "name": "id", 71 | "type": "serial", 72 | "primaryKey": true, 73 | "notNull": true, 74 | "autoincrement": true 75 | }, 76 | "name": { 77 | "name": "name", 78 | "type": "varchar(50)", 79 | "primaryKey": false, 80 | "notNull": true, 81 | "autoincrement": false 82 | } 83 | }, 84 | "indexes": {}, 85 | "foreignKeys": {}, 86 | "compositePrimaryKeys": {} 87 | }, 88 | "questions": { 89 | "name": "questions", 90 | "columns": { 91 | "id": { 92 | "name": "id", 93 | "type": "serial", 94 | "primaryKey": true, 95 | "notNull": true, 96 | "autoincrement": true 97 | }, 98 | "title": { 99 | "name": "title", 100 | "type": "varchar(100)", 101 | "primaryKey": false, 102 | "notNull": true, 103 | "autoincrement": false 104 | }, 105 | "question_description": { 106 | "name": "question_description", 107 | "type": "text", 108 | "primaryKey": false, 109 | "notNull": true, 110 | "autoincrement": false 111 | }, 112 | "answer_description": { 113 | "name": "answer_description", 114 | "type": "text", 115 | "primaryKey": false, 116 | "notNull": true, 117 | "autoincrement": false 118 | }, 119 | "image": { 120 | "name": "image", 121 | "type": "varchar(100)", 122 | "primaryKey": false, 123 | "notNull": true, 124 | "autoincrement": false 125 | }, 126 | "city_id": { 127 | "name": "city_id", 128 | "type": "int", 129 | "primaryKey": false, 130 | "notNull": false, 131 | "autoincrement": false 132 | }, 133 | "start_date": { 134 | "name": "start_date", 135 | "type": "datetime", 136 | "primaryKey": false, 137 | "notNull": false, 138 | "autoincrement": false 139 | }, 140 | "end_date": { 141 | "name": "end_date", 142 | "type": "datetime", 143 | "primaryKey": false, 144 | "notNull": false, 145 | "autoincrement": false 146 | }, 147 | "location": { 148 | "name": "location", 149 | "type": "json", 150 | "primaryKey": false, 151 | "notNull": true, 152 | "autoincrement": false 153 | }, 154 | "demo": { 155 | "name": "demo", 156 | "type": "boolean", 157 | "primaryKey": false, 158 | "notNull": true, 159 | "autoincrement": false 160 | } 161 | }, 162 | "indexes": {}, 163 | "foreignKeys": {}, 164 | "compositePrimaryKeys": {} 165 | }, 166 | "users": { 167 | "name": "users", 168 | "columns": { 169 | "id": { 170 | "name": "id", 171 | "type": "varchar(100)", 172 | "primaryKey": true, 173 | "notNull": true, 174 | "autoincrement": false 175 | }, 176 | "nick_name": { 177 | "name": "nick_name", 178 | "type": "varchar(40)", 179 | "primaryKey": false, 180 | "notNull": true, 181 | "autoincrement": false 182 | }, 183 | "created_at": { 184 | "name": "created_at", 185 | "type": "timestamp", 186 | "primaryKey": false, 187 | "notNull": true, 188 | "autoincrement": false, 189 | "default": "(now())" 190 | }, 191 | "updated_at": { 192 | "name": "updated_at", 193 | "type": "timestamp", 194 | "primaryKey": false, 195 | "notNull": true, 196 | "autoincrement": false, 197 | "default": "(now())" 198 | } 199 | }, 200 | "indexes": {}, 201 | "foreignKeys": {}, 202 | "compositePrimaryKeys": {} 203 | } 204 | }, 205 | "schemas": {}, 206 | "_meta": { 207 | "schemas": {}, 208 | "tables": {}, 209 | "columns": {} 210 | } 211 | } -------------------------------------------------------------------------------- /migrations/meta/0006_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "id": "e82a5007-7c48-47a3-8fe5-273c173dc1b6", 5 | "prevId": "4f1eaa64-b0a7-44ae-a298-584b02f5b011", 6 | "tables": { 7 | "answers": { 8 | "name": "answers", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "serial", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "location": { 18 | "name": "location", 19 | "type": "json", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "score": { 25 | "name": "score", 26 | "type": "int", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "question_id": { 32 | "name": "question_id", 33 | "type": "int", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "user_id": { 39 | "name": "user_id", 40 | "type": "varchar(100)", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "timestamp", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false, 51 | "default": "(now())" 52 | }, 53 | "updated_at": { 54 | "name": "updated_at", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": true, 58 | "autoincrement": false, 59 | "default": "(now())" 60 | } 61 | }, 62 | "indexes": {}, 63 | "foreignKeys": {}, 64 | "compositePrimaryKeys": {} 65 | }, 66 | "cities": { 67 | "name": "cities", 68 | "columns": { 69 | "id": { 70 | "name": "id", 71 | "type": "serial", 72 | "primaryKey": true, 73 | "notNull": true, 74 | "autoincrement": true 75 | }, 76 | "name": { 77 | "name": "name", 78 | "type": "varchar(50)", 79 | "primaryKey": false, 80 | "notNull": true, 81 | "autoincrement": false 82 | } 83 | }, 84 | "indexes": {}, 85 | "foreignKeys": {}, 86 | "compositePrimaryKeys": {} 87 | }, 88 | "questions": { 89 | "name": "questions", 90 | "columns": { 91 | "id": { 92 | "name": "id", 93 | "type": "serial", 94 | "primaryKey": true, 95 | "notNull": true, 96 | "autoincrement": true 97 | }, 98 | "title": { 99 | "name": "title", 100 | "type": "varchar(100)", 101 | "primaryKey": false, 102 | "notNull": true, 103 | "autoincrement": false 104 | }, 105 | "question_description": { 106 | "name": "question_description", 107 | "type": "text", 108 | "primaryKey": false, 109 | "notNull": false, 110 | "autoincrement": false 111 | }, 112 | "answer_description": { 113 | "name": "answer_description", 114 | "type": "text", 115 | "primaryKey": false, 116 | "notNull": false, 117 | "autoincrement": false 118 | }, 119 | "image": { 120 | "name": "image", 121 | "type": "varchar(100)", 122 | "primaryKey": false, 123 | "notNull": true, 124 | "autoincrement": false 125 | }, 126 | "city_id": { 127 | "name": "city_id", 128 | "type": "int", 129 | "primaryKey": false, 130 | "notNull": false, 131 | "autoincrement": false 132 | }, 133 | "start_date": { 134 | "name": "start_date", 135 | "type": "datetime", 136 | "primaryKey": false, 137 | "notNull": false, 138 | "autoincrement": false 139 | }, 140 | "end_date": { 141 | "name": "end_date", 142 | "type": "datetime", 143 | "primaryKey": false, 144 | "notNull": false, 145 | "autoincrement": false 146 | }, 147 | "location": { 148 | "name": "location", 149 | "type": "json", 150 | "primaryKey": false, 151 | "notNull": true, 152 | "autoincrement": false 153 | }, 154 | "demo": { 155 | "name": "demo", 156 | "type": "boolean", 157 | "primaryKey": false, 158 | "notNull": true, 159 | "autoincrement": false 160 | } 161 | }, 162 | "indexes": {}, 163 | "foreignKeys": {}, 164 | "compositePrimaryKeys": {} 165 | }, 166 | "users": { 167 | "name": "users", 168 | "columns": { 169 | "id": { 170 | "name": "id", 171 | "type": "varchar(100)", 172 | "primaryKey": true, 173 | "notNull": true, 174 | "autoincrement": false 175 | }, 176 | "nick_name": { 177 | "name": "nick_name", 178 | "type": "varchar(40)", 179 | "primaryKey": false, 180 | "notNull": true, 181 | "autoincrement": false 182 | }, 183 | "created_at": { 184 | "name": "created_at", 185 | "type": "timestamp", 186 | "primaryKey": false, 187 | "notNull": true, 188 | "autoincrement": false, 189 | "default": "(now())" 190 | }, 191 | "updated_at": { 192 | "name": "updated_at", 193 | "type": "timestamp", 194 | "primaryKey": false, 195 | "notNull": true, 196 | "autoincrement": false, 197 | "default": "(now())" 198 | } 199 | }, 200 | "indexes": {}, 201 | "foreignKeys": {}, 202 | "compositePrimaryKeys": {} 203 | } 204 | }, 205 | "schemas": {}, 206 | "_meta": { 207 | "schemas": {}, 208 | "tables": {}, 209 | "columns": {} 210 | } 211 | } -------------------------------------------------------------------------------- /migrations/meta/0007_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "id": "cee2f7bf-8465-495c-8cd9-07075b8b8d35", 5 | "prevId": "e82a5007-7c48-47a3-8fe5-273c173dc1b6", 6 | "tables": { 7 | "answers": { 8 | "name": "answers", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "serial", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "location": { 18 | "name": "location", 19 | "type": "json", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "score": { 25 | "name": "score", 26 | "type": "int", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "question_id": { 32 | "name": "question_id", 33 | "type": "int", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "user_id": { 39 | "name": "user_id", 40 | "type": "varchar(100)", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "timestamp", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false, 51 | "default": "(now())" 52 | }, 53 | "updated_at": { 54 | "name": "updated_at", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": true, 58 | "autoincrement": false, 59 | "default": "(now())" 60 | } 61 | }, 62 | "indexes": {}, 63 | "foreignKeys": {}, 64 | "compositePrimaryKeys": {} 65 | }, 66 | "cities": { 67 | "name": "cities", 68 | "columns": { 69 | "id": { 70 | "name": "id", 71 | "type": "serial", 72 | "primaryKey": true, 73 | "notNull": true, 74 | "autoincrement": true 75 | }, 76 | "name": { 77 | "name": "name", 78 | "type": "varchar(50)", 79 | "primaryKey": false, 80 | "notNull": true, 81 | "autoincrement": false 82 | } 83 | }, 84 | "indexes": {}, 85 | "foreignKeys": {}, 86 | "compositePrimaryKeys": {} 87 | }, 88 | "questions": { 89 | "name": "questions", 90 | "columns": { 91 | "id": { 92 | "name": "id", 93 | "type": "serial", 94 | "primaryKey": true, 95 | "notNull": true, 96 | "autoincrement": true 97 | }, 98 | "title": { 99 | "name": "title", 100 | "type": "varchar(100)", 101 | "primaryKey": false, 102 | "notNull": true, 103 | "autoincrement": false 104 | }, 105 | "question_description": { 106 | "name": "question_description", 107 | "type": "text", 108 | "primaryKey": false, 109 | "notNull": false, 110 | "autoincrement": false 111 | }, 112 | "answer_description": { 113 | "name": "answer_description", 114 | "type": "text", 115 | "primaryKey": false, 116 | "notNull": false, 117 | "autoincrement": false 118 | }, 119 | "author_id": { 120 | "name": "author_id", 121 | "type": "varchar(100)", 122 | "primaryKey": false, 123 | "notNull": true, 124 | "autoincrement": false 125 | }, 126 | "image": { 127 | "name": "image", 128 | "type": "varchar(100)", 129 | "primaryKey": false, 130 | "notNull": true, 131 | "autoincrement": false 132 | }, 133 | "city_id": { 134 | "name": "city_id", 135 | "type": "int", 136 | "primaryKey": false, 137 | "notNull": false, 138 | "autoincrement": false 139 | }, 140 | "start_date": { 141 | "name": "start_date", 142 | "type": "datetime", 143 | "primaryKey": false, 144 | "notNull": false, 145 | "autoincrement": false 146 | }, 147 | "end_date": { 148 | "name": "end_date", 149 | "type": "datetime", 150 | "primaryKey": false, 151 | "notNull": false, 152 | "autoincrement": false 153 | }, 154 | "location": { 155 | "name": "location", 156 | "type": "json", 157 | "primaryKey": false, 158 | "notNull": true, 159 | "autoincrement": false 160 | }, 161 | "demo": { 162 | "name": "demo", 163 | "type": "boolean", 164 | "primaryKey": false, 165 | "notNull": true, 166 | "autoincrement": false 167 | } 168 | }, 169 | "indexes": {}, 170 | "foreignKeys": {}, 171 | "compositePrimaryKeys": {} 172 | }, 173 | "users": { 174 | "name": "users", 175 | "columns": { 176 | "id": { 177 | "name": "id", 178 | "type": "varchar(100)", 179 | "primaryKey": true, 180 | "notNull": true, 181 | "autoincrement": false 182 | }, 183 | "nick_name": { 184 | "name": "nick_name", 185 | "type": "varchar(40)", 186 | "primaryKey": false, 187 | "notNull": true, 188 | "autoincrement": false 189 | }, 190 | "created_at": { 191 | "name": "created_at", 192 | "type": "timestamp", 193 | "primaryKey": false, 194 | "notNull": true, 195 | "autoincrement": false, 196 | "default": "(now())" 197 | }, 198 | "updated_at": { 199 | "name": "updated_at", 200 | "type": "timestamp", 201 | "primaryKey": false, 202 | "notNull": true, 203 | "autoincrement": false, 204 | "default": "(now())" 205 | } 206 | }, 207 | "indexes": {}, 208 | "foreignKeys": {}, 209 | "compositePrimaryKeys": {} 210 | } 211 | }, 212 | "schemas": {}, 213 | "_meta": { 214 | "schemas": {}, 215 | "tables": {}, 216 | "columns": {} 217 | } 218 | } -------------------------------------------------------------------------------- /migrations/meta/0008_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "id": "8dcf189d-03f8-4f21-8b71-ddc4172e75fa", 5 | "prevId": "cee2f7bf-8465-495c-8cd9-07075b8b8d35", 6 | "tables": { 7 | "answers": { 8 | "name": "answers", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "serial", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "location": { 18 | "name": "location", 19 | "type": "json", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "score": { 25 | "name": "score", 26 | "type": "int", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "question_id": { 32 | "name": "question_id", 33 | "type": "int", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "user_id": { 39 | "name": "user_id", 40 | "type": "varchar(100)", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "timestamp", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false, 51 | "default": "(now())" 52 | }, 53 | "updated_at": { 54 | "name": "updated_at", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": true, 58 | "autoincrement": false, 59 | "default": "(now())" 60 | } 61 | }, 62 | "indexes": {}, 63 | "foreignKeys": {}, 64 | "compositePrimaryKeys": {} 65 | }, 66 | "cities": { 67 | "name": "cities", 68 | "columns": { 69 | "id": { 70 | "name": "id", 71 | "type": "serial", 72 | "primaryKey": true, 73 | "notNull": true, 74 | "autoincrement": true 75 | }, 76 | "name": { 77 | "name": "name", 78 | "type": "varchar(50)", 79 | "primaryKey": false, 80 | "notNull": true, 81 | "autoincrement": false 82 | }, 83 | "created_at": { 84 | "name": "created_at", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true, 88 | "autoincrement": false, 89 | "default": "(now())" 90 | } 91 | }, 92 | "indexes": {}, 93 | "foreignKeys": {}, 94 | "compositePrimaryKeys": {} 95 | }, 96 | "questions": { 97 | "name": "questions", 98 | "columns": { 99 | "id": { 100 | "name": "id", 101 | "type": "serial", 102 | "primaryKey": true, 103 | "notNull": true, 104 | "autoincrement": true 105 | }, 106 | "title": { 107 | "name": "title", 108 | "type": "varchar(100)", 109 | "primaryKey": false, 110 | "notNull": true, 111 | "autoincrement": false 112 | }, 113 | "question_description": { 114 | "name": "question_description", 115 | "type": "text", 116 | "primaryKey": false, 117 | "notNull": false, 118 | "autoincrement": false 119 | }, 120 | "answer_description": { 121 | "name": "answer_description", 122 | "type": "text", 123 | "primaryKey": false, 124 | "notNull": false, 125 | "autoincrement": false 126 | }, 127 | "author_id": { 128 | "name": "author_id", 129 | "type": "varchar(100)", 130 | "primaryKey": false, 131 | "notNull": true, 132 | "autoincrement": false 133 | }, 134 | "image": { 135 | "name": "image", 136 | "type": "varchar(100)", 137 | "primaryKey": false, 138 | "notNull": true, 139 | "autoincrement": false 140 | }, 141 | "city_id": { 142 | "name": "city_id", 143 | "type": "int", 144 | "primaryKey": false, 145 | "notNull": false, 146 | "autoincrement": false 147 | }, 148 | "start_date": { 149 | "name": "start_date", 150 | "type": "datetime", 151 | "primaryKey": false, 152 | "notNull": false, 153 | "autoincrement": false 154 | }, 155 | "end_date": { 156 | "name": "end_date", 157 | "type": "datetime", 158 | "primaryKey": false, 159 | "notNull": false, 160 | "autoincrement": false 161 | }, 162 | "location": { 163 | "name": "location", 164 | "type": "json", 165 | "primaryKey": false, 166 | "notNull": true, 167 | "autoincrement": false 168 | }, 169 | "demo": { 170 | "name": "demo", 171 | "type": "boolean", 172 | "primaryKey": false, 173 | "notNull": true, 174 | "autoincrement": false 175 | }, 176 | "created_at": { 177 | "name": "created_at", 178 | "type": "timestamp", 179 | "primaryKey": false, 180 | "notNull": true, 181 | "autoincrement": false, 182 | "default": "(now())" 183 | } 184 | }, 185 | "indexes": {}, 186 | "foreignKeys": {}, 187 | "compositePrimaryKeys": {} 188 | }, 189 | "users": { 190 | "name": "users", 191 | "columns": { 192 | "id": { 193 | "name": "id", 194 | "type": "varchar(100)", 195 | "primaryKey": true, 196 | "notNull": true, 197 | "autoincrement": false 198 | }, 199 | "nick_name": { 200 | "name": "nick_name", 201 | "type": "varchar(40)", 202 | "primaryKey": false, 203 | "notNull": true, 204 | "autoincrement": false 205 | }, 206 | "created_at": { 207 | "name": "created_at", 208 | "type": "timestamp", 209 | "primaryKey": false, 210 | "notNull": true, 211 | "autoincrement": false, 212 | "default": "(now())" 213 | } 214 | }, 215 | "indexes": {}, 216 | "foreignKeys": {}, 217 | "compositePrimaryKeys": {} 218 | } 219 | }, 220 | "schemas": {}, 221 | "_meta": { 222 | "schemas": {}, 223 | "tables": {}, 224 | "columns": {} 225 | } 226 | } -------------------------------------------------------------------------------- /migrations/meta/0009_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "id": "66cb493a-166f-4c06-9680-486b3ab132d1", 5 | "prevId": "8dcf189d-03f8-4f21-8b71-ddc4172e75fa", 6 | "tables": { 7 | "answers": { 8 | "name": "answers", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "serial", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "location": { 18 | "name": "location", 19 | "type": "json", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "score": { 25 | "name": "score", 26 | "type": "int", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "question_id": { 32 | "name": "question_id", 33 | "type": "int", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "user_id": { 39 | "name": "user_id", 40 | "type": "varchar(100)", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "timestamp", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false, 51 | "default": "(now())" 52 | }, 53 | "updated_at": { 54 | "name": "updated_at", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": true, 58 | "autoincrement": false, 59 | "default": "(now())" 60 | } 61 | }, 62 | "indexes": {}, 63 | "foreignKeys": {}, 64 | "compositePrimaryKeys": {} 65 | }, 66 | "cities": { 67 | "name": "cities", 68 | "columns": { 69 | "id": { 70 | "name": "id", 71 | "type": "serial", 72 | "primaryKey": true, 73 | "notNull": true, 74 | "autoincrement": true 75 | }, 76 | "name": { 77 | "name": "name", 78 | "type": "varchar(50)", 79 | "primaryKey": false, 80 | "notNull": true, 81 | "autoincrement": false 82 | }, 83 | "created_at": { 84 | "name": "created_at", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true, 88 | "autoincrement": false, 89 | "default": "(now())" 90 | } 91 | }, 92 | "indexes": {}, 93 | "foreignKeys": {}, 94 | "compositePrimaryKeys": {} 95 | }, 96 | "questions": { 97 | "name": "questions", 98 | "columns": { 99 | "id": { 100 | "name": "id", 101 | "type": "serial", 102 | "primaryKey": true, 103 | "notNull": true, 104 | "autoincrement": true 105 | }, 106 | "title": { 107 | "name": "title", 108 | "type": "varchar(250)", 109 | "primaryKey": false, 110 | "notNull": true, 111 | "autoincrement": false 112 | }, 113 | "question_description": { 114 | "name": "question_description", 115 | "type": "text", 116 | "primaryKey": false, 117 | "notNull": false, 118 | "autoincrement": false 119 | }, 120 | "answer_description": { 121 | "name": "answer_description", 122 | "type": "text", 123 | "primaryKey": false, 124 | "notNull": false, 125 | "autoincrement": false 126 | }, 127 | "author_id": { 128 | "name": "author_id", 129 | "type": "varchar(100)", 130 | "primaryKey": false, 131 | "notNull": true, 132 | "autoincrement": false 133 | }, 134 | "image": { 135 | "name": "image", 136 | "type": "varchar(100)", 137 | "primaryKey": false, 138 | "notNull": true, 139 | "autoincrement": false 140 | }, 141 | "city_id": { 142 | "name": "city_id", 143 | "type": "int", 144 | "primaryKey": false, 145 | "notNull": false, 146 | "autoincrement": false 147 | }, 148 | "start_date": { 149 | "name": "start_date", 150 | "type": "datetime", 151 | "primaryKey": false, 152 | "notNull": false, 153 | "autoincrement": false 154 | }, 155 | "end_date": { 156 | "name": "end_date", 157 | "type": "datetime", 158 | "primaryKey": false, 159 | "notNull": false, 160 | "autoincrement": false 161 | }, 162 | "location": { 163 | "name": "location", 164 | "type": "json", 165 | "primaryKey": false, 166 | "notNull": true, 167 | "autoincrement": false 168 | }, 169 | "demo": { 170 | "name": "demo", 171 | "type": "boolean", 172 | "primaryKey": false, 173 | "notNull": true, 174 | "autoincrement": false 175 | }, 176 | "created_at": { 177 | "name": "created_at", 178 | "type": "timestamp", 179 | "primaryKey": false, 180 | "notNull": true, 181 | "autoincrement": false, 182 | "default": "(now())" 183 | } 184 | }, 185 | "indexes": {}, 186 | "foreignKeys": {}, 187 | "compositePrimaryKeys": {} 188 | }, 189 | "users": { 190 | "name": "users", 191 | "columns": { 192 | "id": { 193 | "name": "id", 194 | "type": "varchar(100)", 195 | "primaryKey": true, 196 | "notNull": true, 197 | "autoincrement": false 198 | }, 199 | "nick_name": { 200 | "name": "nick_name", 201 | "type": "varchar(40)", 202 | "primaryKey": false, 203 | "notNull": true, 204 | "autoincrement": false 205 | }, 206 | "created_at": { 207 | "name": "created_at", 208 | "type": "timestamp", 209 | "primaryKey": false, 210 | "notNull": true, 211 | "autoincrement": false, 212 | "default": "(now())" 213 | } 214 | }, 215 | "indexes": {}, 216 | "foreignKeys": {}, 217 | "compositePrimaryKeys": {} 218 | } 219 | }, 220 | "schemas": {}, 221 | "_meta": { 222 | "schemas": {}, 223 | "tables": {}, 224 | "columns": {} 225 | } 226 | } -------------------------------------------------------------------------------- /migrations/meta/0010_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "id": "f518588d-43a9-4f79-b0a9-c62298c7aa3c", 5 | "prevId": "66cb493a-166f-4c06-9680-486b3ab132d1", 6 | "tables": { 7 | "answers": { 8 | "name": "answers", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "serial", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "location": { 18 | "name": "location", 19 | "type": "json", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "score": { 25 | "name": "score", 26 | "type": "int", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "autoincrement": false 30 | }, 31 | "question_id": { 32 | "name": "question_id", 33 | "type": "int", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false 37 | }, 38 | "user_id": { 39 | "name": "user_id", 40 | "type": "varchar(100)", 41 | "primaryKey": false, 42 | "notNull": true, 43 | "autoincrement": false 44 | }, 45 | "created_at": { 46 | "name": "created_at", 47 | "type": "timestamp", 48 | "primaryKey": false, 49 | "notNull": true, 50 | "autoincrement": false, 51 | "default": "(now())" 52 | }, 53 | "updated_at": { 54 | "name": "updated_at", 55 | "type": "timestamp", 56 | "primaryKey": false, 57 | "notNull": true, 58 | "autoincrement": false, 59 | "default": "(now())" 60 | } 61 | }, 62 | "indexes": {}, 63 | "foreignKeys": {}, 64 | "compositePrimaryKeys": {} 65 | }, 66 | "cities": { 67 | "name": "cities", 68 | "columns": { 69 | "id": { 70 | "name": "id", 71 | "type": "serial", 72 | "primaryKey": true, 73 | "notNull": true, 74 | "autoincrement": true 75 | }, 76 | "name": { 77 | "name": "name", 78 | "type": "varchar(50)", 79 | "primaryKey": false, 80 | "notNull": true, 81 | "autoincrement": false 82 | }, 83 | "created_at": { 84 | "name": "created_at", 85 | "type": "timestamp", 86 | "primaryKey": false, 87 | "notNull": true, 88 | "autoincrement": false, 89 | "default": "(now())" 90 | } 91 | }, 92 | "indexes": {}, 93 | "foreignKeys": {}, 94 | "compositePrimaryKeys": {} 95 | }, 96 | "questions": { 97 | "name": "questions", 98 | "columns": { 99 | "id": { 100 | "name": "id", 101 | "type": "serial", 102 | "primaryKey": true, 103 | "notNull": true, 104 | "autoincrement": true 105 | }, 106 | "title": { 107 | "name": "title", 108 | "type": "varchar(250)", 109 | "primaryKey": false, 110 | "notNull": true, 111 | "autoincrement": false 112 | }, 113 | "question_description": { 114 | "name": "question_description", 115 | "type": "text", 116 | "primaryKey": false, 117 | "notNull": false, 118 | "autoincrement": false 119 | }, 120 | "answer_description": { 121 | "name": "answer_description", 122 | "type": "text", 123 | "primaryKey": false, 124 | "notNull": false, 125 | "autoincrement": false 126 | }, 127 | "author_id": { 128 | "name": "author_id", 129 | "type": "varchar(100)", 130 | "primaryKey": false, 131 | "notNull": true, 132 | "autoincrement": false 133 | }, 134 | "image": { 135 | "name": "image", 136 | "type": "varchar(250)", 137 | "primaryKey": false, 138 | "notNull": true, 139 | "autoincrement": false 140 | }, 141 | "city_id": { 142 | "name": "city_id", 143 | "type": "int", 144 | "primaryKey": false, 145 | "notNull": false, 146 | "autoincrement": false 147 | }, 148 | "start_date": { 149 | "name": "start_date", 150 | "type": "datetime", 151 | "primaryKey": false, 152 | "notNull": false, 153 | "autoincrement": false 154 | }, 155 | "end_date": { 156 | "name": "end_date", 157 | "type": "datetime", 158 | "primaryKey": false, 159 | "notNull": false, 160 | "autoincrement": false 161 | }, 162 | "location": { 163 | "name": "location", 164 | "type": "json", 165 | "primaryKey": false, 166 | "notNull": true, 167 | "autoincrement": false 168 | }, 169 | "demo": { 170 | "name": "demo", 171 | "type": "boolean", 172 | "primaryKey": false, 173 | "notNull": true, 174 | "autoincrement": false 175 | }, 176 | "created_at": { 177 | "name": "created_at", 178 | "type": "timestamp", 179 | "primaryKey": false, 180 | "notNull": true, 181 | "autoincrement": false, 182 | "default": "(now())" 183 | } 184 | }, 185 | "indexes": {}, 186 | "foreignKeys": {}, 187 | "compositePrimaryKeys": {} 188 | }, 189 | "users": { 190 | "name": "users", 191 | "columns": { 192 | "id": { 193 | "name": "id", 194 | "type": "varchar(100)", 195 | "primaryKey": true, 196 | "notNull": true, 197 | "autoincrement": false 198 | }, 199 | "nick_name": { 200 | "name": "nick_name", 201 | "type": "varchar(40)", 202 | "primaryKey": false, 203 | "notNull": true, 204 | "autoincrement": false 205 | }, 206 | "created_at": { 207 | "name": "created_at", 208 | "type": "timestamp", 209 | "primaryKey": false, 210 | "notNull": true, 211 | "autoincrement": false, 212 | "default": "(now())" 213 | } 214 | }, 215 | "indexes": {}, 216 | "foreignKeys": {}, 217 | "compositePrimaryKeys": {} 218 | } 219 | }, 220 | "schemas": {}, 221 | "_meta": { 222 | "schemas": {}, 223 | "tables": {}, 224 | "columns": {} 225 | } 226 | } -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "mysql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1680631608194, 9 | "tag": "0000_volatile_frank_castle" 10 | }, 11 | { 12 | "idx": 1, 13 | "version": "5", 14 | "when": 1681050637627, 15 | "tag": "0001_thin_celestials", 16 | "breakpoints": false 17 | }, 18 | { 19 | "idx": 2, 20 | "version": "5", 21 | "when": 1681186222428, 22 | "tag": "0002_organic_wonder_man", 23 | "breakpoints": false 24 | }, 25 | { 26 | "idx": 3, 27 | "version": "5", 28 | "when": 1681187156738, 29 | "tag": "0003_busy_dormammu", 30 | "breakpoints": false 31 | }, 32 | { 33 | "idx": 4, 34 | "version": "5", 35 | "when": 1681187307560, 36 | "tag": "0004_whole_paladin", 37 | "breakpoints": false 38 | }, 39 | { 40 | "idx": 5, 41 | "version": "5", 42 | "when": 1681366670069, 43 | "tag": "0005_strong_lady_vermin", 44 | "breakpoints": false 45 | }, 46 | { 47 | "idx": 6, 48 | "version": "5", 49 | "when": 1681845791771, 50 | "tag": "0006_slow_proudstar", 51 | "breakpoints": false 52 | }, 53 | { 54 | "idx": 7, 55 | "version": "5", 56 | "when": 1682051655088, 57 | "tag": "0007_low_dracula", 58 | "breakpoints": false 59 | }, 60 | { 61 | "idx": 8, 62 | "version": "5", 63 | "when": 1682087299976, 64 | "tag": "0008_sour_tusk", 65 | "breakpoints": false 66 | }, 67 | { 68 | "idx": 9, 69 | "version": "5", 70 | "when": 1682171564377, 71 | "tag": "0009_right_cerise", 72 | "breakpoints": false 73 | }, 74 | { 75 | "idx": 10, 76 | "version": "5", 77 | "when": 1682171671885, 78 | "tag": "0010_romantic_imperial_guard", 79 | "breakpoints": false 80 | }, 81 | { 82 | "idx": 11, 83 | "version": "5", 84 | "when": 1682192775973, 85 | "tag": "0011_equal_nico_minoru", 86 | "breakpoints": false 87 | }, 88 | { 89 | "idx": 12, 90 | "version": "5", 91 | "when": 1682193286903, 92 | "tag": "0012_dashing_juggernaut", 93 | "breakpoints": false 94 | }, 95 | { 96 | "idx": 13, 97 | "version": "5", 98 | "when": 1682239145997, 99 | "tag": "0013_plain_garia", 100 | "breakpoints": false 101 | }, 102 | { 103 | "idx": 14, 104 | "version": "5", 105 | "when": 1682265784677, 106 | "tag": "0014_quiet_magneto", 107 | "breakpoints": false 108 | }, 109 | { 110 | "idx": 15, 111 | "version": "5", 112 | "when": 1682314318841, 113 | "tag": "0015_polite_iron_lad", 114 | "breakpoints": false 115 | }, 116 | { 117 | "idx": 16, 118 | "version": "5", 119 | "when": 1682314338108, 120 | "tag": "0016_living_yellowjacket", 121 | "breakpoints": false 122 | }, 123 | { 124 | "idx": 17, 125 | "version": "5", 126 | "when": 1682418569124, 127 | "tag": "0017_many_black_bird", 128 | "breakpoints": false 129 | }, 130 | { 131 | "idx": 18, 132 | "version": "5", 133 | "when": 1682419217814, 134 | "tag": "0018_yellow_vertigo", 135 | "breakpoints": false 136 | }, 137 | { 138 | "idx": 19, 139 | "version": "5", 140 | "when": 1682497974152, 141 | "tag": "0019_wild_mathemanic", 142 | "breakpoints": false 143 | }, 144 | { 145 | "idx": 20, 146 | "version": "5", 147 | "when": 1682498273620, 148 | "tag": "0020_lethal_justin_hammer", 149 | "breakpoints": false 150 | }, 151 | { 152 | "idx": 21, 153 | "version": "5", 154 | "when": 1682669000871, 155 | "tag": "0021_cheerful_warpath", 156 | "breakpoints": false 157 | }, 158 | { 159 | "idx": 22, 160 | "version": "5", 161 | "when": 1682671607964, 162 | "tag": "0022_quiet_nocturne", 163 | "breakpoints": false 164 | }, 165 | { 166 | "idx": 23, 167 | "version": "5", 168 | "when": 1682671669613, 169 | "tag": "0023_neat_rhino", 170 | "breakpoints": false 171 | }, 172 | { 173 | "idx": 24, 174 | "version": "5", 175 | "when": 1683055353282, 176 | "tag": "0024_fearless_grandmaster", 177 | "breakpoints": false 178 | }, 179 | { 180 | "idx": 25, 181 | "version": "5", 182 | "when": 1683055478898, 183 | "tag": "0025_colorful_taskmaster", 184 | "breakpoints": false 185 | }, 186 | { 187 | "idx": 26, 188 | "version": "5", 189 | "when": 1683119158507, 190 | "tag": "0026_simple_bruce_banner", 191 | "breakpoints": false 192 | }, 193 | { 194 | "idx": 27, 195 | "version": "5", 196 | "when": 1683141194358, 197 | "tag": "0027_demonic_bloodstrike", 198 | "breakpoints": false 199 | }, 200 | { 201 | "idx": 28, 202 | "version": "5", 203 | "when": 1683142851628, 204 | "tag": "0028_mean_thena", 205 | "breakpoints": false 206 | }, 207 | { 208 | "idx": 29, 209 | "version": "5", 210 | "when": 1683317696516, 211 | "tag": "0029_steady_shotgun", 212 | "breakpoints": false 213 | } 214 | ] 215 | } -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | module.exports = { 3 | siteUrl: 'https://cityhunter.cz', 4 | changefreq: 'daily', 5 | priority: 0.7, 6 | sitemapSize: 5000, 7 | generateRobotsTxt: true, 8 | exclude: ['/admin', '/admin/**'], 9 | robotsTxtOptions: { 10 | policies: [ 11 | { 12 | userAgent: '*', 13 | allow: '/*', 14 | disallow: ['/admin', '/admin/**', '/auth/login-receiver', '/unauthorized'], 15 | }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { withSentryConfig } = require('@sentry/nextjs'); 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const { withAxiom } = require('next-axiom'); 5 | 6 | /* eslint-disable @typescript-eslint/no-var-requires */ 7 | const { env } = require('./src/server/env'); 8 | 9 | /** 10 | * Don't be scared of the generics here. 11 | * All they do is to give us autocompletion when using this. 12 | * 13 | * @template {import('next').NextConfig} T 14 | * @param {T} config - A generic parameter that flows through to the return type 15 | * @constraint {{import('next').NextConfig}} 16 | */ 17 | function getConfig(config) { 18 | return config; 19 | } 20 | 21 | /** 22 | * @link https://nextjs.org/docs/api-reference/next.config.js/introduction 23 | */ 24 | const nextConfig = getConfig({ 25 | publicRuntimeConfig: { 26 | NODE_ENV: env.NODE_ENV, 27 | }, 28 | env: { 29 | CLERK_JWK_URI: process.env.CLERK_JWK_URI ?? 'nothing', 30 | }, 31 | /** We run eslint as a separate task in CI */ 32 | eslint: { ignoreDuringBuilds: !!process.env.CI }, 33 | images: { 34 | domains: ['res.cloudinary.com'], 35 | }, 36 | }); 37 | 38 | module.exports = withAxiom(withSentryConfig(nextConfig, { silent: true }, { hideSourcemaps: true })); 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "city-hunter", 3 | "version": "10.18.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev -p 3400", 7 | "build": "next build", 8 | "postbuild": "next-sitemap", 9 | "start": "next start", 10 | "lint": "eslint --cache --ext \".js,.ts,.tsx\" --report-unused-disable-directives --report-unused-disable-directives src", 11 | "lint-fix": "pnpm lint --fix", 12 | "test:unit": "vitest run", 13 | "migration:generate": "npx drizzle-kit generate:mysql", 14 | "migration:run": "npx drizzle-kit push:mysql" 15 | }, 16 | "prettier": { 17 | "printWidth": 120, 18 | "trailingComma": "all", 19 | "singleQuote": true 20 | }, 21 | "dependencies": { 22 | "@clerk/nextjs": "^4.14.2", 23 | "@edge-runtime/types": "^2.0.8", 24 | "@emotion/cache": "^11.10.7", 25 | "@emotion/react": "^11.10.6", 26 | "@emotion/server": "^11.10.0", 27 | "@emotion/styled": "^11.10.6", 28 | "@hookform/resolvers": "^3.1.0", 29 | "@mui/icons-material": "^5.11.16", 30 | "@mui/lab": "5.0.0-alpha.129", 31 | "@mui/material": "^5.12.0", 32 | "@planetscale/database": "^1.6.0", 33 | "@sentry/browser": "^7.53.1", 34 | "@sentry/nextjs": "^7.51.2", 35 | "@tanstack/react-query": "^4.18.0", 36 | "@total-typescript/ts-reset": "^0.4.2", 37 | "@trpc/client": "^10.25.0", 38 | "@trpc/next": "^10.25.0", 39 | "@trpc/react-query": "^10.25.0", 40 | "@trpc/server": "^10.25.0", 41 | "@types/nprogress": "^0.2.0", 42 | "@upstash/qstash": "^0.3.6", 43 | "@upstash/redis": "^1.20.6", 44 | "@vercel/analytics": "^1.0.0", 45 | "clsx": "^1.1.1", 46 | "countdown": "^2.6.0", 47 | "date-fns": "^2.29.3", 48 | "date-fns-tz": "^2.0.0", 49 | "drizzle-orm": "0.26.0", 50 | "is-ua-webview": "^1.1.2", 51 | "jose": "^4.13.1", 52 | "mysql2": "^3.2.0", 53 | "next": "^13.2.1", 54 | "next-axiom": "^0.17.0", 55 | "next-seo": "^6.0.0", 56 | "next-sitemap": "^4.1.3", 57 | "nprogress": "^0.2.0", 58 | "react": "^18.2.0", 59 | "react-countdown": "^2.3.5", 60 | "react-dom": "^18.2.0", 61 | "react-hook-form": "^7.43.9", 62 | "react-mapycz": "^1.2.0", 63 | "react-photo-album": "^2.1.0", 64 | "required-properties": "^1.1.0", 65 | "styled-components": "^5.3.9", 66 | "superjson": "^1.12.2", 67 | "yet-another-react-lightbox": "^3.5.3", 68 | "zod": "^3.0.0" 69 | }, 70 | "devDependencies": { 71 | "@clerk/types": "^3.33.0", 72 | "@total-typescript/shoehorn": "^0.1.1", 73 | "@types/google-map-react": "^2.1.7", 74 | "@types/node": "^18.7.20", 75 | "@types/react": "^18.0.9", 76 | "@types/styled-components": "^5.1.26", 77 | "@typescript-eslint/eslint-plugin": "^5.47.0", 78 | "@typescript-eslint/parser": "^5.47.0", 79 | "dotenv": "^16.0.3", 80 | "drizzle-kit": "^0.18.0", 81 | "eslint": "^8.30.0", 82 | "eslint-config-next": "^13.2.1", 83 | "eslint-config-prettier": "^8.5.0", 84 | "eslint-plugin-prettier": "^4.2.1", 85 | "eslint-plugin-react": "^7.31.11", 86 | "eslint-plugin-react-hooks": "^4.6.0", 87 | "npm-run-all": "^4.1.5", 88 | "prettier": "^2.8.7", 89 | "tsx": "^3.12.3", 90 | "typescript": "^5.1.3", 91 | "vite": "^4.1.2", 92 | "vitest": "^0.28.5" 93 | }, 94 | "publishConfig": { 95 | "access": "restricted" 96 | }, 97 | "pnpm": { 98 | "patchedDependencies": { 99 | "react-mapycz@1.2.0": "patches/react-mapycz@1.2.0.patch" 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /public/Logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dominikjasek/city-hunter/06658afb7cd3e71d733fda3e0d21d8450296d1b4/public/favicon.ico -------------------------------------------------------------------------------- /public/google3e8efa5f2044c710.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google3e8efa5f2044c710.html -------------------------------------------------------------------------------- /public/map-marker-green.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/map-marker-grey.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/map-marker-orange.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "template": "next" 3 | } 4 | -------------------------------------------------------------------------------- /sentry.client.config.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/nextjs'; 2 | import * as SentryBrowser from '@sentry/browser'; 3 | 4 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 5 | 6 | Sentry.init({ 7 | dsn: SENTRY_DSN, 8 | tracesSampleRate: 1.0, 9 | replaysOnErrorSampleRate: 1.0, 10 | integrations: [ 11 | new SentryBrowser.Replay({ 12 | maskAllText: false, 13 | blockAllMedia: false, 14 | }), 15 | ], 16 | }); 17 | -------------------------------------------------------------------------------- /sentry.edge.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever middleware or an Edge route handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 8 | 9 | Sentry.init({ 10 | dsn: SENTRY_DSN, 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1.0, 13 | // ... 14 | // Note: if you want to override the automatic release value, do not set a 15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 16 | // that it will also get attached to your source maps 17 | }); 18 | -------------------------------------------------------------------------------- /sentry.properties: -------------------------------------------------------------------------------- 1 | defaults.url=https://sentry.io/ 2 | defaults.org=dominikjasek 3 | defaults.project=cityhunter 4 | 5 | -------------------------------------------------------------------------------- /sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | const SENTRY_DSN = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN; 8 | 9 | Sentry.init({ 10 | dsn: SENTRY_DSN, 11 | // Adjust this value in production, or use tracesSampler for greater control 12 | tracesSampleRate: 1.0, 13 | // ... 14 | // Note: if you want to override the automatic release value, do not set a 15 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 16 | // that it will also get attached to your source maps 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/Demo/DemoSolution.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react'; 2 | import { Box, Divider, Stack, Typography } from '@mui/material'; 3 | import { AnswerLocation, MapLocation } from '~/components/MapPicker/types'; 4 | import { MapWithAnswers } from '~/components/MapPicker/MapWithAnswers'; 5 | import { SecondaryText } from '~/components/common/Typography/typography'; 6 | import 'yet-another-react-lightbox/styles.css'; 7 | import Image from 'next/image'; 8 | import { LightBox } from '~/components/LightBox/LightBox'; 9 | import { createDurationString } from '~/utils/ranking/createDurationString'; 10 | import { imagePlaceholderProps } from '~/utils/image-placeholder/image-placeholder'; 11 | 12 | export interface QuestionSolutionProps { 13 | name: string; 14 | answerDescription: string | null; 15 | images: string[]; 16 | map: { 17 | locations?: AnswerLocation[]; 18 | centerPoint: MapLocation; 19 | zoom: number; 20 | }; 21 | score: number; 22 | durationInSeconds: number; 23 | distance: number; 24 | } 25 | 26 | export const DemoSolution: FC = (props) => { 27 | const [index, setIndex] = useState(-1); 28 | 29 | return ( 30 | <> 31 | = 0} index={index} imagesUrl={props.images} onClose={() => setIndex(-1)} /> 32 | 33 | 34 | Skóre: {props.score}/100 35 | 36 | Čas: {createDurationString(props.durationInSeconds)} 37 | Vzdálenost: {props.distance} m 38 | 39 | {props.answerDescription} 40 | 41 | 42 | {props.images.map((image) => ( 43 | 53 | {props.name} setIndex(props.images.indexOf(image))} 64 | /> 65 | 66 | ))} 67 | 68 | 69 | 77 | 82 | 83 | 84 | 85 | 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo, ReactNode } from 'react'; 2 | import { MessageBox } from '~/components/common/MessageBox/MessageBox'; 3 | 4 | interface Props { 5 | children?: ReactNode; 6 | } 7 | 8 | interface State { 9 | hasError: boolean; 10 | } 11 | 12 | class ErrorBoundary extends Component { 13 | public state: State = { 14 | hasError: false, 15 | }; 16 | 17 | public static getDerivedStateFromError(_: Error): State { 18 | // Update state so the next render will show the fallback UI. 19 | return { hasError: true }; 20 | } 21 | 22 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 23 | console.error('Uncaught error:', error, errorInfo); 24 | } 25 | 26 | public render() { 27 | if (this.state.hasError) { 28 | return ; 29 | } 30 | 31 | return this.props.children; 32 | } 33 | } 34 | 35 | export default ErrorBoundary; 36 | -------------------------------------------------------------------------------- /src/components/LightBox/LightBox.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { Lightbox } from 'yet-another-react-lightbox'; 3 | import Zoom from 'yet-another-react-lightbox/plugins/zoom'; 4 | import 'yet-another-react-lightbox/styles.css'; 5 | 6 | interface LightBoxProps { 7 | imagesUrl: string[]; 8 | isOpen: boolean; 9 | index?: number; 10 | onClose: () => void; 11 | } 12 | 13 | export const LightBox: FC = ({ imagesUrl, isOpen, index, onClose }) => { 14 | return ( 15 | null, 29 | buttonNext: () => null, 30 | } 31 | : {} 32 | } 33 | close={onClose} 34 | slides={imagesUrl.map((image) => ({ src: image }))} 35 | controller={{ 36 | closeOnBackdropClick: true, 37 | }} 38 | /> 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/MapPicker/MapPicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | KeyboardControl, 4 | Map, 5 | Marker, 6 | MarkerLayer, 7 | MouseControl, 8 | POILayer, 9 | SyncControl, 10 | ZoomControl, 11 | } from 'react-mapycz'; 12 | import { MapEventListener } from 'react-mapycz/src/Map'; 13 | import MapMarker from '@public/map-marker-orange.svg'; 14 | import Image from 'next/image'; 15 | import { MapLocation } from '~/components/MapPicker/types'; 16 | import { Box } from '@mui/material'; 17 | import Head from 'next/head'; 18 | 19 | interface MapPickerProps { 20 | centerPoint: MapLocation; 21 | zoom: number; 22 | point: MapLocation | null; 23 | onClick?: (point: MapLocation) => void; 24 | } 25 | 26 | export const MapPicker: FC = ({ point, centerPoint, zoom, onClick }: MapPickerProps) => { 27 | const handleMapClick: MapEventListener = (e, coordinates) => { 28 | if (e.type !== 'map-click') { 29 | return; 30 | } 31 | 32 | onClick?.({ lat: coordinates.y, lng: coordinates.x }); 33 | }; 34 | 35 | return ( 36 | <> 37 | 38 | 39 | 40 | 50 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ( 68 | {'Map 78 | ), 79 | }} 80 | /> 81 | 82 | 83 | 84 | 85 | ); 86 | }; 87 | -------------------------------------------------------------------------------- /src/components/MapPicker/MapWithAnswers.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import MapMarkerOrange from '@public/map-marker-orange.svg'; 3 | import MapMarkerGrey from '@public/map-marker-grey.svg'; 4 | import MapMarkerGreen from '@public/map-marker-green.svg'; 5 | import { AnswerLocation, MapLocation } from '~/components/MapPicker/types'; 6 | import { Box, Stack, Typography } from '@mui/material'; 7 | import { 8 | KeyboardControl, 9 | Map, 10 | Marker, 11 | MarkerLayer, 12 | MouseControl, 13 | POILayer, 14 | SyncControl, 15 | ZoomControl, 16 | } from 'react-mapycz'; 17 | import Image from 'next/image'; 18 | import { useUser } from '@clerk/nextjs'; 19 | 20 | const LegendRow = ({ color, text }: { color: string; text: string }) => { 21 | return ( 22 | 23 | 24 | 25 | {text} 26 | 27 | 28 | ); 29 | }; 30 | 31 | const Legend: FC<{ locations: AnswerLocation[] }> = ({ locations }) => { 32 | const { user } = useUser(); 33 | 34 | const showMyAnswer = user?.id && locations.some((location) => location.type === 'user-answer' && location.isMyAnswer); 35 | const showCorrectAnswer = locations.some((location) => location.type === 'solution'); 36 | const showSelectedAnswer = locations.some((location) => location.type === 'user-answer' && !location.isMyAnswer); 37 | 38 | return ( 39 | 51 | {showMyAnswer && } 52 | {showCorrectAnswer && } 53 | {showSelectedAnswer && } 54 | 55 | ); 56 | }; 57 | 58 | interface MapPickerProps { 59 | centerPoint: MapLocation; 60 | zoom: number; 61 | locations: AnswerLocation[]; 62 | width?: number | string; 63 | height?: number | string; 64 | } 65 | 66 | export const MapWithAnswers: FC = ({ 67 | locations, 68 | centerPoint, 69 | zoom, 70 | width = '100%', 71 | height = '100%', 72 | }: MapPickerProps) => { 73 | const getSrcForLocation = (location: AnswerLocation) => { 74 | if (location.type === 'solution') return MapMarkerGreen; 75 | if (location.isMyAnswer) return MapMarkerOrange; 76 | return MapMarkerGrey; 77 | }; 78 | 79 | return ( 80 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | {locations.map((location) => ( 101 | ( 106 | {'Map 116 | ), 117 | }} 118 | /> 119 | ))} 120 | 121 | 122 | 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/components/MapPicker/types.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | interface Window { 5 | SMap: any; 6 | } 7 | } 8 | 9 | export interface MapLocation { 10 | lat: number; 11 | lng: number; 12 | } 13 | 14 | interface BaseAnswerLocation { 15 | location: MapLocation; 16 | } 17 | 18 | interface UserAnswerLocation extends BaseAnswerLocation { 19 | type: 'user-answer'; 20 | isMyAnswer: boolean; 21 | } 22 | 23 | interface SolutionLocation extends BaseAnswerLocation { 24 | type: 'solution'; 25 | } 26 | 27 | export type AnswerLocation = UserAnswerLocation | SolutionLocation; 28 | -------------------------------------------------------------------------------- /src/components/Question/QuestionTask.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from 'react'; 2 | import { MapPicker } from '~/components/MapPicker/MapPicker'; 3 | import { Box, Stack, Typography, useTheme } from '@mui/material'; 4 | import Image from 'next/image'; 5 | import { MapLocation } from '~/components/MapPicker/types'; 6 | import { LightBox } from '~/components/LightBox/LightBox'; 7 | import LoadingButton from '@mui/lab/LoadingButton'; 8 | import { SecondaryText } from '~/components/common/Typography/typography'; 9 | import { imagePlaceholderProps } from '~/utils/image-placeholder/image-placeholder'; 10 | 11 | interface QuestionTaskProps { 12 | city: { 13 | centerPoint: MapLocation; 14 | mapZoom: number; 15 | }; 16 | title: string; 17 | questionDescription: string | null; 18 | questionImageUrl: string; 19 | isSubmitting: boolean; 20 | onSubmit: (point: MapLocation) => void; 21 | } 22 | 23 | export const QuestionTask: FC = (props) => { 24 | const [isLightboxOpen, setIsLightboxOpen] = useState(false); 25 | const [point, setPoint] = useState(null); 26 | 27 | const theme = useTheme(); 28 | 29 | return ( 30 | <> 31 | setIsLightboxOpen(false)} /> 32 | 33 | 34 | 35 | {props.title} 36 | 37 | 38 | {props.questionDescription} 39 | 40 | 49 | {props.title} setIsLightboxOpen(true)} 61 | /> 62 | 63 | 64 | 65 | 73 | 79 | 85 | 86 | props.onSubmit(point!)} 105 | > 106 | Potvrdit 107 | 108 | 109 | 110 | 111 | ); 112 | }; 113 | -------------------------------------------------------------------------------- /src/components/common/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import AutorenewIcon from '@mui/icons-material/Autorenew'; 3 | import { Stack, styled } from '@mui/material'; 4 | import styles from './loader.module.css'; 5 | 6 | const LoadingOverlay = styled(Stack)(() => ({ 7 | flexDirection: 'row', 8 | alignItems: 'center', 9 | justifyContent: 'center', 10 | gap: '5px', 11 | lineHeight: '2.5rem', 12 | margin: '1rem', 13 | textAlign: 'center', 14 | fontWeight: 'bold', 15 | })); 16 | 17 | export const Loader: FC<{ title?: string }> = ({ title }) => { 18 | return ( 19 | 20 | 21 | {title} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/common/Loader/loader.module.css: -------------------------------------------------------------------------------- 1 | .animationSpin,.animationSpin::before { 2 | animation: spinner 2s infinite linear 3 | } 4 | 5 | @keyframes spinner { 6 | from { 7 | transform: rotate(0deg) 8 | } 9 | 10 | to { 11 | transform: rotate(359deg) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/common/MessageBox/MessageBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactNode } from 'react'; 2 | import MuiAlert, { AlertColor, AlertProps } from '@mui/material/Alert'; 3 | 4 | const Alert = React.forwardRef(function Alert(props, ref) { 5 | return ; 6 | }); 7 | 8 | export const MessageBox: FC<{ type?: AlertColor; message: string | ReactNode }> = ({ type = 'info', message }) => { 9 | return {message}; 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/common/Typography/typography.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from '@mui/material'; 2 | 3 | export const SecondaryText = styled('span')(({ theme }) => ({ 4 | color: theme.palette.secondary.main, 5 | })); 6 | -------------------------------------------------------------------------------- /src/components/form/NickNameForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react'; 2 | import { z } from 'zod'; 3 | import { useForm } from 'react-hook-form'; 4 | import { zodResolver } from '@hookform/resolvers/zod'; 5 | import { Button, Stack, TextField } from '@mui/material'; 6 | 7 | const userProfileValidationSchema = z.object({ 8 | nickName: z.string().min(3, 'Přezdívka musí mít alespoň 3 znaky').max(20, 'Přezdívka může mít maximálně 20 znaků'), 9 | }); 10 | type UserProfileValidationSchema = z.infer; 11 | 12 | export const NickNameForm: FC<{ 13 | nickName: string; 14 | onSubmit: (value: UserProfileValidationSchema) => void; 15 | isSubmitting: boolean; 16 | }> = ({ nickName, onSubmit, isSubmitting }) => { 17 | const { 18 | register, 19 | handleSubmit, 20 | setValue, 21 | formState: { errors }, 22 | } = useForm({ 23 | resolver: zodResolver(userProfileValidationSchema), 24 | defaultValues: { 25 | nickName, 26 | }, 27 | }); 28 | 29 | useEffect(() => { 30 | setValue('nickName', nickName); 31 | }, [nickName]); 32 | 33 | return ( 34 |
35 | 36 | 46 | 47 | 50 | 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from 'react'; 2 | import { 3 | AppBar, 4 | Box, 5 | Divider, 6 | IconButton, 7 | List, 8 | ListItem, 9 | ListItemButton, 10 | ListItemText, 11 | Stack, 12 | Toolbar, 13 | Typography, 14 | useTheme, 15 | } from '@mui/material'; 16 | import LogoWhite from '@public/Logo-white.svg'; 17 | import Image from 'next/image'; 18 | import MenuIcon from '@mui/icons-material/Menu'; 19 | import Drawer from '@mui/material/Drawer'; 20 | import Link from 'next/link'; 21 | import { UserBox } from '~/components/navbar/UserBox'; 22 | import { useRouter } from 'next/router'; 23 | import { useIsAdmin } from '~/hooks/use-is-admin'; 24 | import { useIsServer } from '~/hooks/use-is-server'; 25 | 26 | interface NavbarLink { 27 | title: string; 28 | href: string; 29 | } 30 | 31 | export const DESKTOP_NAVBAR_HEIGHT = 88; // this value was read from devtools, not beast apporach tho 😄 32 | 33 | const drawerWidth = 250; 34 | const defaultNavbarLinks: NavbarLink[] = [ 35 | { title: 'Hrát', href: '/play' }, 36 | { title: 'Výsledky', href: '/ranking' }, 37 | { title: 'FAQ', href: '/faq' }, 38 | ]; 39 | 40 | interface NavbarProps { 41 | links: NavbarLink[]; 42 | } 43 | 44 | const MobileNavbar: React.FC = ({ links }) => { 45 | const [mobileOpen, setMobileOpen] = React.useState(false); 46 | const theme = useTheme(); 47 | const router = useRouter(); 48 | 49 | useEffect(() => { 50 | setMobileOpen(false); 51 | }, [router.pathname]); 52 | 53 | const handleDrawerToggle = () => { 54 | setMobileOpen((prevState) => !prevState); 55 | }; 56 | const isServer = useIsServer(); 57 | 58 | const drawer = ( 59 | 60 | router.push('/')} 67 | > 68 | Follow us on Twitter 69 | 77 | CITY HUNTER 78 | 79 | 80 | 81 | 82 | {links.map((item) => ( 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | ))} 91 | 92 | e.stopPropagation()}> 93 | 94 | 95 | 96 | 97 | 98 | ); 99 | 100 | const container = isServer ? undefined : () => window.document.body; 101 | 102 | return ( 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 128 | {drawer} 129 | 130 | 131 | 132 | 133 | ); 134 | }; 135 | 136 | const DesktopNavbar: React.FC = ({ links }) => { 137 | const theme = useTheme(); 138 | 139 | return ( 140 | 167 | ); 168 | }; 169 | 170 | export const Navbar: React.FC = () => { 171 | const isAdmin = useIsAdmin(); 172 | 173 | const links = useMemo(() => { 174 | if (isAdmin) { 175 | return [...defaultNavbarLinks, { title: 'Přidat místo', href: '/admin/upload-photo' }]; 176 | } 177 | 178 | return defaultNavbarLinks; 179 | }, [isAdmin]); 180 | 181 | return ( 182 | <> 183 | 184 | 185 | 186 | ); 187 | }; 188 | -------------------------------------------------------------------------------- /src/components/navbar/UserBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { SignedIn, SignedOut, UserButton, useUser } from '@clerk/nextjs'; 3 | import { Button, Skeleton } from '@mui/material'; 4 | import { useRouter } from 'next/router'; 5 | import isWebview from 'is-ua-webview'; 6 | 7 | export const UserBox: FC = () => { 8 | const { pathname, push } = useRouter(); 9 | const { isSignedIn } = useUser(); 10 | 11 | if (isSignedIn === undefined) { 12 | return ; 13 | } 14 | 15 | const isWebView = isWebview(window.navigator.userAgent); 16 | const handleLoginClick = () => { 17 | if (!isWebView) { 18 | push('/auth/login'); 19 | } else { 20 | push('/auth/webview'); 21 | } 22 | }; 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | {pathname !== '/auth/login' && ( 31 | 34 | )} 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/ranking/ScoreCalculator.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useMemo, useState } from 'react'; 2 | import { evaluateScoreFromDistance } from '~/utils/score/evaluate-score'; 3 | import { Box, Stack, TextField, Typography } from '@mui/material'; 4 | import { SecondaryText } from '~/components/common/Typography/typography'; 5 | 6 | export const ScoreCalculator: FC = () => { 7 | const [distance, setDistance] = useState('0'); 8 | const [duration, setDuration] = useState('0'); 9 | 10 | const score = useMemo(() => evaluateScoreFromDistance(Number(distance), Number(duration)), [distance, duration]); 11 | 12 | return ( 13 | 14 | setDistance(e.target.value)} 22 | /> 23 | setDuration(e.target.value)} 31 | /> 32 | 33 | 34 | Skóre: {score}/100 35 | 36 | 37 | 38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/ranking/TableTournamentMedals.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { Box, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, useTheme } from '@mui/material'; 3 | import { TournamentUserScore } from '~/server/routers/ranking/types'; 4 | import { useUser } from '@clerk/nextjs'; 5 | 6 | export const TableMedals: FC<{ ranking: TournamentUserScore[] }> = ({ ranking }) => { 7 | const theme = useTheme(); 8 | const { user } = useUser(); 9 | 10 | return ( 11 | 22 | 23 | 24 | 25 | Pořadí 26 | Přezdívka 27 | 28 | Medaile 29 | 30 | 31 | 32 | 33 | {ranking.map((row, index) => ( 34 | 42 | {index + 1} 43 | 44 | {row.nickName} 45 | 46 | 47 | 48 | {Array(row.medals.GOLD) 49 | .fill(0) 50 | .map((_, index) => ( 51 | 🥇 52 | ))} 53 | {Array(row.medals.SILVER) 54 | .fill(0) 55 | .map((_, index) => ( 56 | 🥈 57 | ))} 58 | {Array(row.medals.BRONZE) 59 | .fill(0) 60 | .map((_, index) => ( 61 | 🥉 62 | ))} 63 | 64 | 65 | 66 | ))} 67 | 68 |
69 |
70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/ranking/TableTournamentPoints.tsx: -------------------------------------------------------------------------------- 1 | import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, useTheme } from '@mui/material'; 2 | import { TournamentUserScore } from '~/server/routers/ranking/types'; 3 | import { FC } from 'react'; 4 | import { useUser } from '@clerk/nextjs'; 5 | 6 | export const TableTournamentPoints: FC<{ ranking: TournamentUserScore[] }> = ({ ranking }) => { 7 | const theme = useTheme(); 8 | const { user } = useUser(); 9 | 10 | return ( 11 | 22 | 23 | 24 | 25 | Pořadí 26 | Přezdívka 27 | 28 | Body 29 | 30 | 31 | 32 | 33 | {ranking.map((row, index) => ( 34 | 42 | {index + 1} 43 | 44 | {row.nickName} 45 | 46 | {row.score} 47 | 48 | ))} 49 | 50 |
51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/ranking/TournamentRoundLinks.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from 'react'; 2 | import { Box, Stack, Typography, useTheme } from '@mui/material'; 3 | import Link from 'next/link'; 4 | import { useRouter } from 'next/router'; 5 | 6 | const FONT_SIZE_MULTIPLIER = 1.4; 7 | 8 | const RoundOrderLink: FC> = ({ isActive, children }) => { 9 | const theme = useTheme(); 10 | 11 | return ( 12 | 25 | {children} 26 | 27 | ); 28 | }; 29 | 30 | interface TournamentRoundLinksProps { 31 | tournamentId: string; 32 | tournamentQuestions: { 33 | id: number; 34 | roundOrder: number | null; 35 | startDate: Date; 36 | endDate: Date; 37 | }[]; 38 | } 39 | 40 | export const TournamentRoundLinks: FC = ({ tournamentId, tournamentQuestions }) => { 41 | const router = useRouter(); 42 | const theme = useTheme(); 43 | 44 | const now = new Date(); 45 | 46 | return ( 47 | 48 | Kolo: 49 | 50 | CELKOVĚ 51 | 52 | 53 | {tournamentQuestions.map((question) => ( 54 | 55 | {now.getTime() < question.endDate.getTime() ? ( 56 | 57 | {question.roundOrder} 58 | 59 | ) : ( 60 | 61 | 62 | {question.roundOrder} 63 | 64 | 65 | )} 66 | 67 | ))} 68 | 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/tournament/TournamentPlayContainer.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { Card, CardActionArea, Stack, Typography } from '@mui/material'; 3 | import { formatDate } from '~/utils/formatter/dateFormatter'; 4 | import Image from 'next/image'; 5 | 6 | interface TournamentContainerProps { 7 | cityName?: string; 8 | description: string | null; 9 | tournamentId: string; 10 | tournamentName: string; 11 | previewImageUrl: string | null; 12 | startDate: Date | null; 13 | endDate: Date | null; 14 | } 15 | 16 | export const TournamentPlayContainer: FC = (props) => { 17 | return ( 18 | 29 | 35 | 36 | 43 | {props.tournamentName} 44 | {props.description} 45 | {props.startDate && props.endDate && ( 46 | {`${formatDate(props.startDate)} - ${formatDate(props.endDate)}`} 47 | )} 48 | 49 | {props?.previewImageUrl && ( 50 | {props.cityName 63 | )} 64 | 65 | 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/createEmotionCache.ts: -------------------------------------------------------------------------------- 1 | import createCache from '@emotion/cache'; 2 | 3 | const isBrowser = typeof document !== 'undefined'; 4 | 5 | // On the client side, Create a meta tag at the top of the and set it as insertionPoint. 6 | // This assures that MUI styles are loaded first. 7 | // It allows developers to easily override MUI styles with other styling solutions, like CSS modules. 8 | export default function createEmotionCache() { 9 | let insertionPoint; 10 | 11 | if (isBrowser) { 12 | const emotionInsertionPoint = document.querySelector('meta[name="emotion-insertion-point"]'); 13 | insertionPoint = emotionInsertionPoint ?? undefined; 14 | } 15 | 16 | return createCache({ key: 'mui-style', insertionPoint }); 17 | } 18 | -------------------------------------------------------------------------------- /src/db/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from 'drizzle-orm/planetscale-serverless'; 2 | 3 | import { connect } from '@planetscale/database'; 4 | import * as schema from './schema'; 5 | 6 | // create the connection 7 | const connection = connect({ 8 | url: process.env['DATABASE_URL'], 9 | }); 10 | 11 | export const db = drizzle(connection, { schema }); 12 | -------------------------------------------------------------------------------- /src/db/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | boolean, 3 | date, 4 | index, 5 | int, 6 | json, 7 | mysqlEnum, 8 | mysqlTable, 9 | serial, 10 | text, 11 | timestamp, 12 | varchar, 13 | } from 'drizzle-orm/mysql-core'; 14 | import { InferModel, relations } from 'drizzle-orm'; 15 | import { MapLocation } from '~/db/types'; 16 | 17 | // User ----------------------------------------------------- 18 | export const users = mysqlTable('users', { 19 | id: varchar('id', { length: 100 }).primaryKey(), 20 | nickName: varchar('nick_name', { length: 40 }).notNull(), 21 | createdAt: timestamp('created_at').defaultNow().notNull(), 22 | }); 23 | 24 | export type User = InferModel; 25 | export type CreateUser = InferModel; 26 | 27 | export const usersRelations = relations(users, ({ many }) => ({ 28 | questions: many(questions), 29 | answers: many(answers), 30 | })); 31 | 32 | // City ----------------------------------------------------- 33 | export const cities = mysqlTable('cities', { 34 | id: serial('id').primaryKey(), 35 | name: varchar('name', { length: 50 }).notNull(), 36 | previewImageUrl: varchar('preview_image_url', { length: 250 }), 37 | centerPoint: json('center_point') 38 | .$type() 39 | .notNull() 40 | .default({ lat: 49.21866559856739, lng: 15.880347529353775 }), 41 | mapZoom: int('map_zoom').notNull().default(14), 42 | createdAt: timestamp('created_at').defaultNow().notNull(), 43 | }); 44 | 45 | export type City = InferModel; 46 | 47 | export const citiesRelations = relations(cities, ({ many }) => ({ 48 | tournaments: many(tournaments), 49 | questions: many(questions), 50 | })); 51 | 52 | // Tournament ----------------------------------------------------- 53 | export const tournaments = mysqlTable('tournaments', { 54 | id: varchar('id', { length: 100 }).primaryKey(), 55 | name: varchar('name', { length: 100 }).notNull(), 56 | description: text('description'), 57 | startDate: date('start_date'), 58 | endDate: date('end_date'), 59 | cityId: int('city_id').notNull(), 60 | }); 61 | 62 | export const tournamentsRelations = relations(tournaments, ({ one, many }) => ({ 63 | city: one(cities, { fields: [tournaments.cityId], references: [cities.id] }), 64 | questions: many(questions), 65 | })); 66 | 67 | // Question ----------------------------------------------------- 68 | export const questions = mysqlTable( 69 | 'questions', 70 | { 71 | id: serial('id').primaryKey(), 72 | title: varchar('title', { length: 250 }).notNull(), 73 | questionDescription: text('question_description'), 74 | answerDescription: text('answer_description'), 75 | authorId: varchar('author_id', { length: 100 }).notNull(), 76 | questionImageUrl: varchar('question_image_url', { length: 250 }).notNull(), 77 | answerImagesUrl: text('answer_images_url').notNull(), 78 | cityId: int('city_id'), 79 | tournamentId: varchar('tournament_id', { length: 100 }), 80 | roundOrder: int('round_order'), 81 | startDate: timestamp('start_date'), 82 | endDate: timestamp('end_date'), 83 | location: json('location').$type().notNull(), 84 | demo: boolean('demo').notNull(), 85 | createdAt: timestamp('created_at').defaultNow().notNull(), 86 | }, 87 | (table) => ({ 88 | roundOrderIdx: index('round_order_idx').on(table.roundOrder, table.tournamentId), 89 | }), 90 | ); 91 | 92 | export type Question = InferModel; 93 | 94 | export const questionsRelations = relations(questions, ({ one, many }) => ({ 95 | city: one(cities, { fields: [questions.cityId], references: [cities.id] }), 96 | tournament: one(tournaments, { 97 | fields: [questions.tournamentId], 98 | references: [tournaments.id], 99 | }), 100 | author: one(users, { fields: [questions.authorId], references: [users.id] }), 101 | answers: many(answers), 102 | })); 103 | 104 | // Answer ----------------------------------------------------- 105 | export const answers = mysqlTable('answers', { 106 | id: serial('id').primaryKey(), 107 | location: json('location').$type().notNull(), 108 | score: int('score').notNull(), 109 | medal: mysqlEnum('medal', ['GOLD', 'SILVER', 'BRONZE']), 110 | questionId: int('question_id').notNull(), 111 | userId: varchar('user_id', { length: 100 }).notNull(), 112 | answeredAt: timestamp('answered_at').notNull(), 113 | }); 114 | 115 | export type Answer = InferModel; 116 | export type CreateAnswer = InferModel; 117 | 118 | export const answersRelations = relations(answers, ({ one }) => ({ 119 | question: one(questions, { fields: [answers.questionId], references: [questions.id] }), 120 | user: one(users, { fields: [answers.userId], references: [users.id] }), 121 | })); 122 | -------------------------------------------------------------------------------- /src/db/types.ts: -------------------------------------------------------------------------------- 1 | export interface MapLocation { 2 | lat: number; 3 | lng: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/hooks/use-image-upload.ts: -------------------------------------------------------------------------------- 1 | export const useImageUpload = () => { 2 | const uploadImage = async (file: File) => { 3 | const formData = new FormData(); 4 | formData.append('file', file); 5 | formData.append('upload_preset', 'egu3lgzw'); // value got from here https://console.cloudinary.com/console/c-3b11fb731fc6e5a47cd099ae611db4/getting-started 6 | 7 | const cloudinaryResponse = await fetch( 8 | `https://api.cloudinary.com/v1_1/dwdwjz5kb/upload`, // value got from here https://console.cloudinary.com/settings/c-3b11fb731fc6e5a47cd099ae611db4/upload 9 | { 10 | method: 'POST', 11 | body: formData, 12 | }, 13 | ); 14 | const cloudinaryData: any = await cloudinaryResponse.json(); 15 | return cloudinaryData.url.replace('/upload/', '/upload/q_40/') as string; 16 | }; 17 | 18 | return { 19 | uploadImage, 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/hooks/use-is-admin.ts: -------------------------------------------------------------------------------- 1 | import { useUser } from '@clerk/nextjs'; 2 | 3 | export const useIsAdmin = () => { 4 | const { user } = useUser(); 5 | return user?.publicMetadata.role === 'admin'; 6 | }; 7 | -------------------------------------------------------------------------------- /src/hooks/use-is-mobile.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from '@mui/material'; 2 | 3 | export const useIsMobile = () => { 4 | return useMediaQuery('(max-width:600px)'); 5 | }; 6 | -------------------------------------------------------------------------------- /src/hooks/use-is-server.ts: -------------------------------------------------------------------------------- 1 | export const useIsServer = () => { 2 | return typeof window === 'undefined'; 3 | }; 4 | -------------------------------------------------------------------------------- /src/hooks/use-page-loader.ts: -------------------------------------------------------------------------------- 1 | import NProgress from 'nprogress'; 2 | import { useEffect } from 'react'; 3 | import { useRouter } from 'next/router'; 4 | 5 | export const usePageLoader = () => { 6 | const router = useRouter(); 7 | 8 | useEffect(() => { 9 | const handleStart = () => { 10 | NProgress.start(); 11 | }; 12 | 13 | const handleStop = () => { 14 | NProgress.done(); 15 | }; 16 | 17 | router.events.on('routeChangeStart', handleStart); 18 | router.events.on('routeChangeComplete', handleStop); 19 | router.events.on('routeChangeError', handleStop); 20 | 21 | return () => { 22 | router.events.off('routeChangeStart', handleStart); 23 | router.events.off('routeChangeComplete', handleStop); 24 | router.events.off('routeChangeError', handleStop); 25 | }; 26 | }, [router]); 27 | }; 28 | -------------------------------------------------------------------------------- /src/hooks/use-webview-login-redirect.ts: -------------------------------------------------------------------------------- 1 | import isWebview from 'is-ua-webview'; 2 | import { useRouter } from 'next/router'; 3 | import { useEffect } from 'react'; 4 | 5 | export const useWebviewLoginRedirect = () => { 6 | const { push } = useRouter(); 7 | 8 | useEffect(() => { 9 | const isWebView = isWebview(window.navigator.userAgent); 10 | if (isWebView) { 11 | push('/auth/webview'); 12 | } 13 | }, []); 14 | }; 15 | -------------------------------------------------------------------------------- /src/layouts/DefaultLayout.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { ReactNode } from 'react'; 3 | import { Navbar } from '~/components/navbar/Navbar'; 4 | import { Box, Container, Typography } from '@mui/material'; 5 | 6 | type DefaultLayoutProps = { children: ReactNode }; 7 | 8 | export const DefaultLayout = ({ children }: DefaultLayoutProps) => { 9 | return ( 10 | <> 11 | 12 | City Hunter 13 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 34 | 35 | 36 | {children} 37 | 38 | 50 | 51 | City Hunter {new Date().getFullYear()} 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkClient, getAuth, withClerkMiddleware } from '@clerk/nextjs/server'; 2 | import type { NextRequest } from 'next/server'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | const adminRoutes = ['/admin**']; 6 | 7 | const protectedRoutes = ['/play/.*/.+', '/auth/user', '/auth/login-receiver']; 8 | 9 | const createRouteGuard = (routes: string[]) => (path: string) => { 10 | return routes.find((x) => path.match(new RegExp(`^${x}$`.replace('*$', '($|/)')))); 11 | }; 12 | 13 | const isAdminRoute = createRouteGuard(adminRoutes); 14 | const isProtectedRoute = createRouteGuard(protectedRoutes); 15 | 16 | export default withClerkMiddleware(async (request: NextRequest) => { 17 | const { pathname } = request.nextUrl; 18 | 19 | if (isAdminRoute(pathname)) { 20 | const { userId } = getAuth(request); 21 | if (!userId) { 22 | return NextResponse.redirect(new URL('/auth/login', request.url)); 23 | } 24 | const user = await clerkClient.users.getUser(userId); 25 | if (user.publicMetadata.role !== 'admin') { 26 | return NextResponse.redirect(new URL('/unauthorized', request.url)); 27 | } 28 | } else if (isProtectedRoute(pathname)) { 29 | const { userId } = getAuth(request); 30 | if (!userId) { 31 | return NextResponse.redirect(new URL('/auth/login', request.url)); 32 | } 33 | } 34 | 35 | return NextResponse.next({ 36 | request, 37 | }); 38 | }); 39 | 40 | export const config = { 41 | matcher: '/((?!_next/image|_next/static|favicon.ico).*)', 42 | }; 43 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next'; 2 | import { Typography } from '@mui/material'; 3 | 4 | const Custom404: NextPage = () => { 5 | return 404 - Ať hledáme jak hledáme, tady nic nenajdeme.; 6 | }; 7 | 8 | export default Custom404; 9 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import type { AppProps, AppType } from 'next/app'; 3 | import type { ReactElement, ReactNode } from 'react'; 4 | import { DefaultLayout } from '~/layouts/DefaultLayout'; 5 | import './../styles/global.css'; 6 | import { trpc } from '~/utils/trpc'; 7 | import { ClerkProvider } from '@clerk/nextjs'; 8 | import { localization } from '~/utils/clerk/localization'; 9 | import createEmotionCache from '~/createEmotionCache'; 10 | import { CacheProvider, EmotionCache } from '@emotion/react'; 11 | import { CssBaseline, ThemeProvider } from '@mui/material'; 12 | import theme from '~/theme'; 13 | import { Analytics as VercelAnalytics } from '@vercel/analytics/react'; 14 | import 'nprogress/nprogress.css'; 15 | import { usePageLoader } from '~/hooks/use-page-loader'; 16 | import { useRouter } from 'next/router'; 17 | import ErrorBoundary from '~/components/ErrorBoundary/ErrorBoundary'; 18 | 19 | export type NextPageWithLayout, TInitialProps = TProps> = NextPage< 20 | TProps, 21 | TInitialProps 22 | > & { 23 | getLayout?: (page: ReactElement) => ReactNode; 24 | }; 25 | 26 | export type AppPropsWithLayout = AppProps & { 27 | Component: NextPageWithLayout; 28 | emotionCache?: EmotionCache; 29 | }; 30 | 31 | const clientSideEmotionCache = createEmotionCache(); 32 | 33 | const MyApp = (({ Component, pageProps, emotionCache = clientSideEmotionCache }: AppPropsWithLayout) => { 34 | usePageLoader(); 35 | const { push } = useRouter(); 36 | 37 | return ( 38 | <> 39 | 40 | 41 | 42 | 43 | push(to)} 48 | > 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | }) as AppType; 60 | 61 | export default trpc.withTRPC(MyApp); 62 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Document, { Html, Head, Main, NextScript, DocumentProps, DocumentContext } from 'next/document'; 3 | import createEmotionServer from '@emotion/server/create-instance'; 4 | import { AppType } from 'next/app'; 5 | import theme from '~/theme'; 6 | import createEmotionCache from '~/createEmotionCache'; 7 | import { AppPropsWithLayout } from '~/pages/_app'; 8 | 9 | interface MyDocumentProps extends DocumentProps { 10 | emotionStyleTags: JSX.Element[]; 11 | } 12 | 13 | export default function MyDocument({ emotionStyleTags }: MyDocumentProps) { 14 | return ( 15 | 16 | 17 | {/* PWA primary color */} 18 | 19 | 20 | 21 | {emotionStyleTags} 22 | 23 | 24 |
25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | // `getInitialProps` belongs to `_document` (instead of `_app`), 32 | // it's compatible with static-site generation (SSG). 33 | MyDocument.getInitialProps = async (ctx: DocumentContext) => { 34 | // Resolution order 35 | // 36 | // On the server: 37 | // 1. app.getInitialProps 38 | // 2. page.getInitialProps 39 | // 3. document.getInitialProps 40 | // 4. app.render 41 | // 5. page.render 42 | // 6. document.render 43 | // 44 | // On the server with error: 45 | // 1. document.getInitialProps 46 | // 2. app.render 47 | // 3. page.render 48 | // 4. document.render 49 | // 50 | // On the client 51 | // 1. app.getInitialProps 52 | // 2. page.getInitialProps 53 | // 3. app.render 54 | // 4. page.render 55 | 56 | const originalRenderPage = ctx.renderPage; 57 | 58 | // You can consider sharing the same Emotion cache between all the SSR requests to speed up performance. 59 | // However, be aware that it can have global side effects. 60 | const cache = createEmotionCache(); 61 | const { extractCriticalToChunks } = createEmotionServer(cache); 62 | 63 | ctx.renderPage = () => 64 | originalRenderPage({ 65 | enhanceApp: (App: React.ComponentType & AppPropsWithLayout>) => 66 | function EnhanceApp(props) { 67 | return ; 68 | }, 69 | }); 70 | 71 | const initialProps = await Document.getInitialProps(ctx); 72 | // This is important. It prevents Emotion to render invalid HTML. 73 | // See https://github.com/mui/material-ui/issues/26561#issuecomment-855286153 74 | const emotionStyles = extractCriticalToChunks(initialProps.html); 75 | const emotionStyleTags = emotionStyles.styles.map((style) => ( 76 |