├── .nvmrc ├── actions-server ├── .gitignore ├── .dockerignore ├── .env ├── nodemon.json ├── Dockerfile ├── tsconfig.json ├── codegen.js ├── src │ ├── handlers │ │ ├── newGame.ts │ │ ├── operations.graphql │ │ └── joinGame.ts │ ├── graphQLClient.ts │ └── server.ts ├── README.md └── package.json ├── app ├── src │ ├── types.d.ts │ ├── assets │ │ └── audio │ │ │ └── bell.mp3 │ ├── components │ │ ├── Fishbowl │ │ │ ├── fish.png │ │ │ ├── FishbowlLoading.tsx │ │ │ ├── index.tsx │ │ │ └── style.css │ │ ├── RoundSettingsList.tsx │ │ ├── BowlCard.tsx │ │ ├── PlayerChipList.tsx │ │ ├── PlayerChip.tsx │ │ ├── Layout.tsx │ │ ├── GameStateRedirects.tsx │ │ ├── GameLayout.tsx │ │ ├── BuyMeACoffeeButton.tsx │ │ ├── ScreenCard.tsx │ │ └── PlayerArena.tsx │ ├── lib │ │ ├── cards.ts │ │ ├── score.ts │ │ ├── useOnClickOutside.ts │ │ ├── useInterval.tsx │ │ ├── team.ts │ │ └── turn.ts │ ├── react-app-env.d.ts │ ├── contexts │ │ ├── Notification.ts │ │ ├── CurrentAuth.ts │ │ ├── CurrentGame.ts │ │ └── CurrentPlayer.ts │ ├── index.css │ ├── routes.ts │ ├── locales │ │ ├── index.ts │ │ └── functions.ts │ ├── pages │ │ ├── Home │ │ │ ├── operations.graphql │ │ │ ├── Host.tsx │ │ │ ├── LanguagePicker.tsx │ │ │ ├── HowToPlay.tsx │ │ │ └── Join.tsx │ │ ├── CardSubmission │ │ │ ├── operations.graphql │ │ │ ├── AssignTeamsButton.tsx │ │ │ ├── SubmissionCard.tsx │ │ │ ├── index.tsx │ │ │ ├── SubmissionForm.tsx │ │ │ └── WaitingForSubmissions.tsx │ │ ├── TeamAssignment │ │ │ └── operations.graphql │ │ ├── Play │ │ │ ├── functions.ts │ │ │ ├── useSecondsLeft.ts │ │ │ ├── NoMoreRounds.tsx │ │ │ ├── operations.graphql │ │ │ ├── GameRoundInstructionCard.tsx │ │ │ ├── TurnContextPanel.tsx │ │ │ └── TeamContent.tsx │ │ ├── Lobby │ │ │ ├── RoundSettings.tsx │ │ │ ├── Inputs │ │ │ │ ├── AllowCardSkipsCheckbox.tsx │ │ │ │ ├── ScreenCardsCheckbox.tsx │ │ │ │ └── UsernameInput.tsx │ │ │ ├── operations.graphql │ │ │ ├── ShareSection.tsx │ │ │ ├── SettingsSummary.tsx │ │ │ ├── CardSettings.tsx │ │ │ ├── SettingsSection.tsx │ │ │ ├── index.tsx │ │ │ └── WaitingRoom.tsx │ │ ├── Pending │ │ │ └── index.tsx │ │ └── Settings │ │ │ └── index.tsx │ ├── AuthWrapper.tsx │ ├── App.tsx │ ├── graphql │ │ └── operations.graphql │ ├── index.tsx │ ├── i18n.ts │ ├── ApolloWrapper.tsx │ ├── hooks │ │ └── useServerTimeOffset.ts │ └── serviceWorker.ts ├── public │ ├── sitemap.txt │ ├── favicon.ico │ ├── robots.txt │ ├── share-image.jpg │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-256x256.png │ ├── browserconfig.xml │ └── site.webmanifest ├── .dockerignore ├── .env.local.sample ├── .env ├── Dockerfile ├── .gitignore ├── tsconfig.json ├── .babelrc ├── codegen.js ├── README.md └── package.json ├── graphql-server ├── metadata │ ├── allow_list.yaml │ ├── functions.yaml │ ├── query_collections.yaml │ ├── remote_schemas.yaml │ ├── version.yaml │ ├── actions.graphql │ └── actions.yaml ├── migrations │ ├── 1586843904227_add_basic_fk_indices │ │ ├── down.yaml │ │ └── up.sql │ ├── 1586843073340_add_game_state_enum_values │ │ ├── down.yaml │ │ └── up.sql │ ├── 1588093468210_add_fk_index_for_rounds │ │ ├── down.yaml │ │ └── up.sql │ ├── 1586842925840_add_generate_unique_game_join_code │ │ ├── down.yaml │ │ └── up.sql │ ├── 1588053319937_add_trigger_for_initial_rounds_for_new_game │ │ ├── down.yaml │ │ └── up.sql │ ├── 1586842837139_create_table_public_games │ │ ├── down.sql │ │ └── up.sql │ ├── 1586843405802_create_table_public_cards │ │ ├── down.sql │ │ └── up.sql │ ├── 1586843592144_create_table_public_turns │ │ ├── down.sql │ │ └── up.sql │ ├── 1586843280995_create_table_public_players │ │ ├── down.sql │ │ └── up.sql │ ├── 1588052276291_create_table_public_rounds │ │ ├── down.sql │ │ └── up.sql │ ├── 1586843013743_create_table_public_game_state │ │ ├── down.sql │ │ └── up.sql │ ├── 1588958405887_create_table_public_turn_scorings │ │ ├── down.sql │ │ └── up.sql │ ├── 1607025803456_create_table_public_game_card_play_style │ │ ├── down.sql │ │ └── up.sql │ ├── 1586843116447_set_fk_public_games_state │ │ ├── down.sql │ │ └── up.sql │ ├── 1586843641453_set_fk_public_games_host_id │ │ ├── down.sql │ │ └── up.sql │ ├── 1589089139331_alter_table_public_turns_add_column_round_id │ │ ├── down.sql │ │ └── up.sql │ ├── 1589089206449_set_fk_public_turns_round_id │ │ ├── down.sql │ │ └── up.sql │ ├── 1591299339535_add_server_time_view │ │ └── up.sql │ ├── 1594486466570_alter_table_public_cards_add_column_is_allowed │ │ ├── down.sql │ │ └── up.sql │ ├── 1594486512245_alter_table_public_rounds_add_column_created_at │ │ ├── down.sql │ │ └── up.sql │ ├── 1586844658644_alter_table_public_turns_add_column_sequential_id │ │ ├── down.sql │ │ └── up.sql │ ├── 1588978173419_alter_table_public_turn_scorings_add_column_status │ │ ├── down.sql │ │ └── up.sql │ ├── 1607026722832_set_fk_public_games_card_play_style │ │ ├── down.sql │ │ └── up.sql │ ├── 1586845332297_alter_table_public_games_alter_column_join_code │ │ ├── down.sql │ │ └── up.sql │ ├── 1588890859734_alter_table_public_turns_add_column_review_started_at │ │ ├── down.sql │ │ └── up.sql │ ├── 1589049353064_alter_table_public_games_add_column_allow_card_skips │ │ ├── down.sql │ │ └── up.sql │ ├── 1607026101526_alter_table_public_games_add_column_card_play_style │ │ ├── down.sql │ │ └── up.sql │ ├── 1588958490767_alter_table_public_turn_scorings_drop_column_game_id │ │ ├── up.sql │ │ └── down.sql │ ├── 1588958524810_alter_table_public_turn_scorings_drop_column_round_id │ │ ├── up.sql │ │ └── down.sql │ ├── 1588978151662_alter_table_public_turn_scorings_drop_column_skipped │ │ ├── up.sql │ │ └── down.sql │ ├── 1589783871794_alter_table_public_turn_scorings_add_column_created_at │ │ ├── down.sql │ │ └── up.sql │ ├── 1588958516268_alter_table_public_turn_scorings_drop_column_player_id │ │ ├── up.sql │ │ └── down.sql │ ├── 1594486940770_alter_table_public_games_add_column_allow_host_to_screen_cards │ │ ├── down.sql │ │ └── up.sql │ ├── 1586844898196_alter_table_public_players_add_unique_client_uuid_game_id │ │ ├── down.sql │ │ └── up.sql │ ├── 1594487015736_alter_table_public_games_alter_column_allow_host_to_screen_cards │ │ ├── down.sql │ │ └── up.sql │ ├── 1589783589878_add_indices_for_turn_scorings │ │ └── up.sql │ ├── 1607026027356_add_game_card_play_style_enum_values │ │ └── up.sql │ ├── 1594486479146_alter_table_public_cards_add_column_updated_at │ │ ├── down.sql │ │ └── up.sql │ ├── 1594486492917_alter_table_public_games_add_column_updated_at │ │ ├── down.sql │ │ └── up.sql │ ├── 1594486549828_alter_table_public_turns_add_column_updated_at │ │ ├── down.sql │ │ └── up.sql │ ├── 1594486515870_alter_table_public_rounds_add_column_updated_at │ │ ├── down.sql │ │ └── up.sql │ ├── 1594486500618_alter_table_public_players_add_column_updated_at │ │ ├── down.sql │ │ └── up.sql │ ├── 1586843416565_alter_table_public_cards_alter_column_created_at │ │ ├── up.sql │ │ └── down.sql │ ├── 1588305432355_alter_table_public_games_alter_column_seconds_per_turn │ │ ├── up.sql │ │ └── down.sql │ ├── 1594486532190_alter_table_public_turn_scorings_add_column_updated_at │ │ ├── down.sql │ │ └── up.sql │ └── 1588309975119_alter_table_public_games_alter_column_num_entries_per_player │ │ ├── up.sql │ │ └── down.sql ├── config.yaml └── Dockerfile ├── Brewfile ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── .editorconfig ├── .idea ├── misc.xml ├── vcs.xml ├── prettier.xml ├── .gitignore ├── modules.xml ├── inspectionProfiles │ └── Project_Default.xml ├── externalDependencies.xml └── fishbowl.iml ├── scripts ├── combine_all_graphql.rb └── one-offs │ └── 2020_04_28_backfill_intial_rounds_for_all_games.sql ├── .gitignore ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── LICENSE ├── Brewfile.lock.json └── docker-compose.yaml /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /actions-server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /app/src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "locize" 2 | -------------------------------------------------------------------------------- /graphql-server/metadata/allow_list.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /graphql-server/metadata/functions.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /app/public/sitemap.txt: -------------------------------------------------------------------------------- 1 | https://www.fishbowl-game.com/ -------------------------------------------------------------------------------- /graphql-server/metadata/query_collections.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /graphql-server/metadata/remote_schemas.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /graphql-server/metadata/version.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | -------------------------------------------------------------------------------- /app/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .dockerignore 3 | Dockerfile 4 | -------------------------------------------------------------------------------- /Brewfile: -------------------------------------------------------------------------------- 1 | tap "homebrew/cask" 2 | brew "yarn" 3 | brew "direnv" 4 | brew "nvm" -------------------------------------------------------------------------------- /actions-server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .dockerignore 3 | Dockerfile 4 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843904227_add_basic_fk_indices/down.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843073340_add_game_state_enum_values/down.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1588093468210_add_fk_index_for_rounds/down.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586842925840_add_generate_unique_game_join_code/down.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /graphql-server/migrations/1588053319937_add_trigger_for_initial_rounds_for_new_game/down.yaml: -------------------------------------------------------------------------------- 1 | [] 2 | -------------------------------------------------------------------------------- /app/public/share-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/public/share-image.jpg -------------------------------------------------------------------------------- /graphql-server/migrations/1586842837139_create_table_public_games/down.sql: -------------------------------------------------------------------------------- 1 | 2 | DROP TABLE "public"."games"; -------------------------------------------------------------------------------- /graphql-server/migrations/1586843405802_create_table_public_cards/down.sql: -------------------------------------------------------------------------------- 1 | 2 | DROP TABLE "public"."cards"; -------------------------------------------------------------------------------- /graphql-server/migrations/1586843592144_create_table_public_turns/down.sql: -------------------------------------------------------------------------------- 1 | 2 | DROP TABLE "public"."turns"; -------------------------------------------------------------------------------- /app/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/public/favicon-16x16.png -------------------------------------------------------------------------------- /app/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/public/favicon-32x32.png -------------------------------------------------------------------------------- /app/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/public/mstile-150x150.png -------------------------------------------------------------------------------- /app/src/assets/audio/bell.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/src/assets/audio/bell.mp3 -------------------------------------------------------------------------------- /graphql-server/migrations/1586843280995_create_table_public_players/down.sql: -------------------------------------------------------------------------------- 1 | 2 | DROP TABLE "public"."players"; -------------------------------------------------------------------------------- /graphql-server/migrations/1588052276291_create_table_public_rounds/down.sql: -------------------------------------------------------------------------------- 1 | 2 | DROP TABLE "public"."rounds"; -------------------------------------------------------------------------------- /app/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/public/apple-touch-icon.png -------------------------------------------------------------------------------- /graphql-server/migrations/1586843013743_create_table_public_game_state/down.sql: -------------------------------------------------------------------------------- 1 | 2 | DROP TABLE "public"."game_state"; -------------------------------------------------------------------------------- /graphql-server/migrations/1588958405887_create_table_public_turn_scorings/down.sql: -------------------------------------------------------------------------------- 1 | 2 | DROP TABLE "public"."turn_scorings"; -------------------------------------------------------------------------------- /app/src/components/Fishbowl/fish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/src/components/Fishbowl/fish.png -------------------------------------------------------------------------------- /graphql-server/migrations/1588093468210_add_fk_index_for_rounds/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE INDEX rounds_game_idx ON rounds (game_id); -------------------------------------------------------------------------------- /app/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /app/public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/avimoondra/fishbowl/HEAD/app/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /graphql-server/migrations/1607025803456_create_table_public_game_card_play_style/down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE "public"."game_card_play_style"; 2 | -------------------------------------------------------------------------------- /app/.env.local.sample: -------------------------------------------------------------------------------- 1 | # Copy to .env.local to save new translation strings to Locize in development 2 | REACT_APP_FISHBOWL_LOCIZE_API_KEY= 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843116447_set_fk_public_games_state/down.sql: -------------------------------------------------------------------------------- 1 | 2 | alter table "public"."games" drop constraint "games_state_fkey"; -------------------------------------------------------------------------------- /graphql-server/migrations/1586843641453_set_fk_public_games_host_id/down.sql: -------------------------------------------------------------------------------- 1 | 2 | alter table "public"."games" drop constraint "games_host_id_fkey"; -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /graphql-server/migrations/1589089139331_alter_table_public_turns_add_column_round_id/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turns" DROP COLUMN "round_id"; -------------------------------------------------------------------------------- /graphql-server/migrations/1589089206449_set_fk_public_turns_round_id/down.sql: -------------------------------------------------------------------------------- 1 | 2 | alter table "public"."turns" drop constraint "turns_round_id_fkey"; -------------------------------------------------------------------------------- /graphql-server/migrations/1591299339535_add_server_time_view/up.sql: -------------------------------------------------------------------------------- 1 | CREATE OR REPLACE VIEW "public"."server_time" AS 2 | SELECT now() as now; 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1589089139331_alter_table_public_turns_add_column_round_id/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turns" ADD COLUMN "round_id" uuid NULL; -------------------------------------------------------------------------------- /graphql-server/migrations/1594486466570_alter_table_public_cards_add_column_is_allowed/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."cards" DROP COLUMN "is_allowed"; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486512245_alter_table_public_rounds_add_column_created_at/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."rounds" DROP COLUMN "created_at"; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586844658644_alter_table_public_turns_add_column_sequential_id/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turns" DROP COLUMN "sequential_id"; -------------------------------------------------------------------------------- /graphql-server/migrations/1588978173419_alter_table_public_turn_scorings_add_column_status/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" DROP COLUMN "status"; -------------------------------------------------------------------------------- /graphql-server/migrations/1607026722832_set_fk_public_games_card_play_style/down.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."games" drop constraint "games_card_play_style_fkey"; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586845332297_alter_table_public_games_alter_column_join_code/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."games" ALTER COLUMN "join_code" SET NOT NULL; -------------------------------------------------------------------------------- /graphql-server/migrations/1586845332297_alter_table_public_games_alter_column_join_code/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."games" ALTER COLUMN "join_code" DROP NOT NULL; -------------------------------------------------------------------------------- /graphql-server/migrations/1588890859734_alter_table_public_turns_add_column_review_started_at/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."turns" DROP COLUMN "review_started_at"; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1589049353064_alter_table_public_games_add_column_allow_card_skips/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."games" DROP COLUMN "allow_card_skips"; -------------------------------------------------------------------------------- /graphql-server/migrations/1594486466570_alter_table_public_cards_add_column_is_allowed/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."cards" ADD COLUMN "is_allowed" boolean NULL; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1607026101526_alter_table_public_games_add_column_card_play_style/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."games" DROP COLUMN "card_play_style"; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586844658644_alter_table_public_turns_add_column_sequential_id/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turns" ADD COLUMN "sequential_id" serial NOT NULL; -------------------------------------------------------------------------------- /graphql-server/migrations/1588958490767_alter_table_public_turn_scorings_drop_column_game_id/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" DROP COLUMN "game_id" CASCADE; -------------------------------------------------------------------------------- /graphql-server/migrations/1588958524810_alter_table_public_turn_scorings_drop_column_round_id/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" DROP COLUMN "round_id" CASCADE; -------------------------------------------------------------------------------- /graphql-server/migrations/1588978151662_alter_table_public_turn_scorings_drop_column_skipped/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" DROP COLUMN "skipped" CASCADE; -------------------------------------------------------------------------------- /graphql-server/migrations/1588978173419_alter_table_public_turn_scorings_add_column_status/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" ADD COLUMN "status" text NOT NULL; -------------------------------------------------------------------------------- /graphql-server/migrations/1589783871794_alter_table_public_turn_scorings_add_column_created_at/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."turn_scorings" DROP COLUMN "created_at"; 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /graphql-server/migrations/1588958516268_alter_table_public_turn_scorings_drop_column_player_id/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" DROP COLUMN "player_id" CASCADE; -------------------------------------------------------------------------------- /actions-server/.env: -------------------------------------------------------------------------------- 1 | HASURA_ENDPOINT=http://fishbowl-graphql-engine:8080/v1/graphql 2 | HASURA_GRAPHQL_JWT_SECRET=FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843013743_create_table_public_game_state/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE TABLE "public"."game_state"("value" text NOT NULL, PRIMARY KEY ("value") , UNIQUE ("value")); -------------------------------------------------------------------------------- /graphql-server/migrations/1588890859734_alter_table_public_turns_add_column_review_started_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."turns" ADD COLUMN "review_started_at" timestamptz NULL; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486512245_alter_table_public_rounds_add_column_created_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."rounds" ADD COLUMN "created_at" timestamptz NULL DEFAULT now(); 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486940770_alter_table_public_games_add_column_allow_host_to_screen_cards/down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."games" DROP COLUMN "allow_host_to_screen_cards"; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1607025803456_create_table_public_game_card_play_style/up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "public"."game_card_play_style"("value" text NOT NULL, PRIMARY KEY ("value") ); 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | "./app" 4 | ], 5 | "cSpell.words": [ 6 | "Locize", 7 | "timestamptz" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /app/src/lib/cards.ts: -------------------------------------------------------------------------------- 1 | import { compact } from "lodash" 2 | 3 | export function parseWordList(wordList: string) { 4 | return compact(wordList.split(",").map((word) => word.trim())) 5 | } 6 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586844898196_alter_table_public_players_add_unique_client_uuid_game_id/down.sql: -------------------------------------------------------------------------------- 1 | 2 | alter table "public"."players" drop constraint "players_client_uuid_game_id_key"; -------------------------------------------------------------------------------- /graphql-server/migrations/1589049353064_alter_table_public_games_add_column_allow_card_skips/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."games" ADD COLUMN "allow_card_skips" boolean NOT NULL DEFAULT true; -------------------------------------------------------------------------------- /actions-server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": true, 3 | "watch": ["src"], 4 | "ext": "ts js json", 5 | "ignore": ["src/**/*.spec.ts"], 6 | "exec": "npx ts-node ./src/server.ts" 7 | } 8 | -------------------------------------------------------------------------------- /app/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // https://stackoverflow.com/a/60235615 4 | declare module "*.mp3" { 5 | const src: string 6 | export default src 7 | } 8 | -------------------------------------------------------------------------------- /graphql-server/config.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | endpoint: http://127.0.0.1:8080 3 | metadata_directory: metadata 4 | actions: 5 | kind: synchronous 6 | handler_webhook_baseurl: http://localhost:3001 7 | -------------------------------------------------------------------------------- /graphql-server/migrations/1589783871794_alter_table_public_turn_scorings_add_column_created_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."turn_scorings" ADD COLUMN "created_at" timestamptz NOT NULL DEFAULT now(); 2 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843073340_add_game_state_enum_values/up.sql: -------------------------------------------------------------------------------- 1 | 2 | INSERT INTO "public"."game_state"(value) VALUES ('lobby'), ('card_submission'), ('team_assignment'), ('active_play'), ('ended'); -------------------------------------------------------------------------------- /graphql-server/migrations/1594487015736_alter_table_public_games_alter_column_allow_host_to_screen_cards/down.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."games" rename column "screen_cards" to "allow_host_to_screen_cards"; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594487015736_alter_table_public_games_alter_column_allow_host_to_screen_cards/up.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."games" rename column "allow_host_to_screen_cards" to "screen_cards"; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1607026101526_alter_table_public_games_add_column_card_play_style/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."games" ADD COLUMN "card_play_style" text NOT NULL DEFAULT 'players_submit_words'; 2 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /graphql-server/migrations/1589783589878_add_indices_for_turn_scorings/up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX turn_scorings_card_idx ON turn_scorings (card_id); 2 | CREATE INDEX turn_scorings_turn_idx ON turn_scorings (turn_id); 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1607026027356_add_game_card_play_style_enum_values/up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO "public"."game_card_play_style"(value) VALUES ('players_submit_words'), ('host_provides_words'), ('pack_words'); 2 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /app/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_FISHBOWL_GRAPHQL_ENDPOINT=http://localhost:8080/v1/graphql 2 | REACT_APP_FISHBOWL_WS_GRAPHQL_ENDPOINT=ws://localhost:8080/v1/graphql 3 | REACT_APP_FISHBOWL_HASURA_ADMIN_SECRET=myadminsecretkey 4 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486940770_alter_table_public_games_add_column_allow_host_to_screen_cards/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."games" ADD COLUMN "allow_host_to_screen_cards" boolean NOT NULL DEFAULT false; 2 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586844898196_alter_table_public_players_add_unique_client_uuid_game_id/up.sql: -------------------------------------------------------------------------------- 1 | 2 | alter table "public"."players" add constraint "players_client_uuid_game_id_key" unique ("client_uuid", "game_id"); -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /app/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | EXPOSE 3000 3 | WORKDIR /usr/app 4 | COPY package.json . 5 | COPY yarn.lock . 6 | RUN yarn install --frozen-lockfile 7 | COPY . . 8 | ENTRYPOINT ["yarn"] 9 | CMD ["run", "start"] 10 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486479146_alter_table_public_cards_add_column_updated_at/down.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS "set_public_cards_updated_at" ON "public"."cards"; 2 | ALTER TABLE "public"."cards" DROP COLUMN "updated_at"; 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486492917_alter_table_public_games_add_column_updated_at/down.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS "set_public_games_updated_at" ON "public"."games"; 2 | ALTER TABLE "public"."games" DROP COLUMN "updated_at"; 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486549828_alter_table_public_turns_add_column_updated_at/down.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS "set_public_turns_updated_at" ON "public"."turns"; 2 | ALTER TABLE "public"."turns" DROP COLUMN "updated_at"; 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486515870_alter_table_public_rounds_add_column_updated_at/down.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS "set_public_rounds_updated_at" ON "public"."rounds"; 2 | ALTER TABLE "public"."rounds" DROP COLUMN "updated_at"; 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486500618_alter_table_public_players_add_column_updated_at/down.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS "set_public_players_updated_at" ON "public"."players"; 2 | ALTER TABLE "public"."players" DROP COLUMN "updated_at"; 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1588978151662_alter_table_public_turn_scorings_drop_column_skipped/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" ADD COLUMN "skipped" bool; 3 | ALTER TABLE "public"."turn_scorings" ALTER COLUMN "skipped" DROP NOT NULL; -------------------------------------------------------------------------------- /graphql-server/migrations/1586843416565_alter_table_public_cards_alter_column_created_at/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."cards" ALTER COLUMN "created_at" TYPE timestamptz; 3 | ALTER TABLE ONLY "public"."cards" ALTER COLUMN "created_at" SET DEFAULT now(); -------------------------------------------------------------------------------- /graphql-server/migrations/1588305432355_alter_table_public_games_alter_column_seconds_per_turn/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."games" ALTER COLUMN "seconds_per_turn" TYPE int4; 3 | ALTER TABLE ONLY "public"."games" ALTER COLUMN "seconds_per_turn" SET DEFAULT 60; -------------------------------------------------------------------------------- /actions-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | EXPOSE 3001 3 | WORKDIR /usr/app 4 | COPY package.json . 5 | COPY yarn.lock . 6 | RUN yarn install --frozen-lockfile 7 | COPY . . 8 | RUN yarn run build 9 | ENTRYPOINT ["yarn"] 10 | CMD ["run", "start"] 11 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843416565_alter_table_public_cards_alter_column_created_at/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."cards" ALTER COLUMN "created_at" TYPE timestamp with time zone; 3 | ALTER TABLE ONLY "public"."cards" ALTER COLUMN "created_at" DROP DEFAULT; -------------------------------------------------------------------------------- /graphql-server/migrations/1588305432355_alter_table_public_games_alter_column_seconds_per_turn/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."games" ALTER COLUMN "seconds_per_turn" TYPE integer; 3 | ALTER TABLE ONLY "public"."games" ALTER COLUMN "seconds_per_turn" DROP DEFAULT; -------------------------------------------------------------------------------- /graphql-server/migrations/1594486532190_alter_table_public_turn_scorings_add_column_updated_at/down.sql: -------------------------------------------------------------------------------- 1 | DROP TRIGGER IF EXISTS "set_public_turn_scorings_updated_at" ON "public"."turn_scorings"; 2 | ALTER TABLE "public"."turn_scorings" DROP COLUMN "updated_at"; 3 | -------------------------------------------------------------------------------- /graphql-server/migrations/1588309975119_alter_table_public_games_alter_column_num_entries_per_player/up.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."games" ALTER COLUMN "num_entries_per_player" TYPE int4; 3 | ALTER TABLE ONLY "public"."games" ALTER COLUMN "num_entries_per_player" SET DEFAULT 6; -------------------------------------------------------------------------------- /graphql-server/migrations/1588309975119_alter_table_public_games_alter_column_num_entries_per_player/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."games" ALTER COLUMN "num_entries_per_player" TYPE integer; 3 | ALTER TABLE ONLY "public"."games" ALTER COLUMN "num_entries_per_player" DROP DEFAULT; -------------------------------------------------------------------------------- /app/src/contexts/Notification.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react" 2 | 3 | export type NotificationContextType = { 4 | send: (message: string) => void 5 | } 6 | 7 | export const NotificationContext = createContext({ 8 | send: () => {}, 9 | }) 10 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843116447_set_fk_public_games_state/up.sql: -------------------------------------------------------------------------------- 1 | 2 | alter table "public"."games" 3 | add constraint "games_state_fkey" 4 | foreign key ("state") 5 | references "public"."game_state" 6 | ("value") on update restrict on delete restrict; -------------------------------------------------------------------------------- /graphql-server/migrations/1586843641453_set_fk_public_games_host_id/up.sql: -------------------------------------------------------------------------------- 1 | 2 | alter table "public"."games" 3 | add constraint "games_host_id_fkey" 4 | foreign key ("host_id") 5 | references "public"."players" 6 | ("id") on update restrict on delete restrict; -------------------------------------------------------------------------------- /graphql-server/migrations/1589089206449_set_fk_public_turns_round_id/up.sql: -------------------------------------------------------------------------------- 1 | 2 | alter table "public"."turns" 3 | add constraint "turns_round_id_fkey" 4 | foreign key ("round_id") 5 | references "public"."rounds" 6 | ("id") on update restrict on delete restrict; -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #81d2f7 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/externalDependencies.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /graphql-server/migrations/1607026722832_set_fk_public_games_card_play_style/up.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."games" 2 | add constraint "games_card_play_style_fkey" 3 | foreign key ("card_play_style") 4 | references "public"."game_card_play_style" 5 | ("value") on update restrict on delete restrict; 6 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843904227_add_basic_fk_indices/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE INDEX cards_game_player_idx ON cards (game_id, player_id); 3 | CREATE UNIQUE INDEX games_join_code_idx ON games (join_code); 4 | CREATE INDEX players_uuid_idx ON players (client_uuid); 5 | CREATE INDEX players_game_idx ON players (game_id); 6 | CREATE INDEX turns_game_idx ON turns (game_id); -------------------------------------------------------------------------------- /scripts/combine_all_graphql.rb: -------------------------------------------------------------------------------- 1 | # Usage 2 | # $ cd scripts/ 3 | # $ ruby combine_all_graphql.rb 4 | 5 | outfile_name = "upload.graphql" 6 | File.delete(outfile_name) if File.exist?(outfile_name) 7 | Dir["../app/**/*.graphql"].each do |file_name| 8 | file = File.open(file_name) 9 | File.write(outfile_name, file.read + "\n\n", mode: "a") 10 | file.close 11 | end 12 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* -------------------------------------------------------------------------------- /graphql-server/metadata/actions.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | joinGame ( 3 | gameId: String! 4 | clientUuid: String! 5 | ): joinGameOutput 6 | } 7 | 8 | 9 | type Mutation { 10 | newGame : newGameOutput 11 | } 12 | 13 | 14 | 15 | 16 | type joinGameOutput { 17 | id : String! 18 | jwt_token : String! 19 | } 20 | 21 | type newGameOutput { 22 | join_code : String! 23 | } 24 | 25 | -------------------------------------------------------------------------------- /graphql-server/migrations/1588052276291_create_table_public_rounds/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 3 | CREATE TABLE "public"."rounds"("id" uuid NOT NULL DEFAULT gen_random_uuid(), "game_id" uuid NOT NULL, "value" text NOT NULL, "order_sequence" integer NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id")); -------------------------------------------------------------------------------- /graphql-server/migrations/1588958490767_alter_table_public_turn_scorings_drop_column_game_id/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" ADD COLUMN "game_id" uuid; 3 | ALTER TABLE "public"."turn_scorings" ALTER COLUMN "game_id" DROP NOT NULL; 4 | ALTER TABLE "public"."turn_scorings" ADD CONSTRAINT turn_scorings_game_id_fkey FOREIGN KEY (game_id) REFERENCES "public"."games" (id) ON DELETE restrict ON UPDATE restrict; -------------------------------------------------------------------------------- /graphql-server/migrations/1588958524810_alter_table_public_turn_scorings_drop_column_round_id/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" ADD COLUMN "round_id" uuid; 3 | ALTER TABLE "public"."turn_scorings" ALTER COLUMN "round_id" DROP NOT NULL; 4 | ALTER TABLE "public"."turn_scorings" ADD CONSTRAINT turn_scorings_round_id_fkey FOREIGN KEY (round_id) REFERENCES "public"."rounds" (id) ON DELETE restrict ON UPDATE restrict; -------------------------------------------------------------------------------- /graphql-server/migrations/1588958516268_alter_table_public_turn_scorings_drop_column_player_id/down.sql: -------------------------------------------------------------------------------- 1 | 2 | ALTER TABLE "public"."turn_scorings" ADD COLUMN "player_id" uuid; 3 | ALTER TABLE "public"."turn_scorings" ALTER COLUMN "player_id" DROP NOT NULL; 4 | ALTER TABLE "public"."turn_scorings" ADD CONSTRAINT turn_scorings_player_id_fkey FOREIGN KEY (player_id) REFERENCES "public"."players" (id) ON DELETE restrict ON UPDATE restrict; -------------------------------------------------------------------------------- /graphql-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM hasura/graphql-engine:v1.3.3.cli-migrations-v2 2 | 3 | COPY graphql-server/migrations /hasura-migrations 4 | COPY graphql-server/metadata /hasura-metadata 5 | 6 | CMD graphql-engine \ 7 | --database-url $DATABASE_URL \ 8 | serve \ 9 | --server-port $PORT \ 10 | --enabled-log-types "startup,http-log,webhook-log,websocket-log,query-log" \ 11 | --unauthorized-role "anonymous" 12 | -------------------------------------------------------------------------------- /scripts/one-offs/2020_04_28_backfill_intial_rounds_for_all_games.sql: -------------------------------------------------------------------------------- 1 | DO $$ 2 | DECLARE 3 | t_row games%rowtype; 4 | BEGIN 5 | FOR t_row in (SELECT DISTINCT games.id FROM games LEFT JOIN rounds ON games.id = rounds.game_id WHERE rounds.game_id IS NULL) LOOP 6 | INSERT INTO rounds (game_id, value, order_sequence) VALUES (t_row.id, 'taboo', 0), (t_row.id, 'charades', 1), (t_row.id, 'password', 2); 7 | END LOOP; 8 | END $$; -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: Roboto, sans-serif, source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /app/src/routes.ts: -------------------------------------------------------------------------------- 1 | const routes = { 2 | root: "/", 3 | game: { 4 | root: "/game/:joinCode/", 5 | pending: "/game/:joinCode/pending", 6 | settings: "/game/:joinCode/settings", 7 | lobby: "/game/:joinCode/lobby", 8 | cardSubmission: "/game/:joinCode/cards", 9 | teamAssignment: "/game/:joinCode/teams", 10 | play: "/game/:joinCode/play", 11 | ended: "/game/:joinCode/end", 12 | }, 13 | } 14 | 15 | export default routes 16 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586842837139_create_table_public_games/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 3 | CREATE TABLE "public"."games"("id" uuid NOT NULL DEFAULT gen_random_uuid(), "join_code" text NOT NULL, "state" text NOT NULL DEFAULT 'lobby', "host_id" uuid, "starting_letter" text, "seconds_per_turn" integer, "num_entries_per_player" integer, "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("id") , UNIQUE ("join_code"), UNIQUE ("id")); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | upload.graphql 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | */build 16 | 17 | # misc 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843280995_create_table_public_players/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 3 | CREATE TABLE "public"."players"("id" uuid NOT NULL DEFAULT gen_random_uuid(), "client_uuid" uuid, "game_id" uuid NOT NULL, "username" text, "team" text, "team_sequence" integer, "created_at" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY ("id") , FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id")); -------------------------------------------------------------------------------- /app/src/locales/index.ts: -------------------------------------------------------------------------------- 1 | import { ResourceLanguage } from "i18next" 2 | 3 | /** 4 | * Resource language with empty namespaces. 5 | * 6 | * Leverages inlined, default strings in favor of English resource fetching via Locize CDN. 7 | */ 8 | export const EmptyResourceLanguage: ResourceLanguage = { 9 | translation: {}, 10 | } 11 | 12 | export const SupportedLanguages = ["en", "fr", "de"] as const 13 | 14 | export type SupportedLanguage = typeof SupportedLanguages[number] 15 | -------------------------------------------------------------------------------- /actions-server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "allowSyntheticDefaultImports": true, 6 | "target": "es6", 7 | "noImplicitAny": true, 8 | "moduleResolution": "node", 9 | "sourceMap": true, 10 | "outDir": "build", 11 | "baseUrl": "src", 12 | "noImplicitThis": true, 13 | "strictNullChecks": true, 14 | "allowJs": true, 15 | "skipLibCheck": true 16 | }, 17 | "include": ["src"] 18 | } 19 | -------------------------------------------------------------------------------- /app/src/contexts/CurrentAuth.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react" 2 | 3 | export enum AuthRole { 4 | Anonymous = "anonymous", 5 | Player = "player", 6 | } 7 | 8 | export const AuthStorageKey = "player.jwtToken" 9 | 10 | export type CurrentAuthContextType = { 11 | jwtToken: string | null 12 | setJwtToken: (jwtToken: string | null) => void 13 | } 14 | 15 | export const CurrentAuthContext = createContext({ 16 | jwtToken: null, 17 | setJwtToken: () => {}, 18 | }) 19 | -------------------------------------------------------------------------------- /app/src/locales/functions.ts: -------------------------------------------------------------------------------- 1 | import { SupportedLanguage } from "locales" 2 | 3 | export function languageNameFromCode(code: SupportedLanguage): string { 4 | switch (code) { 5 | case "en": 6 | return "English" 7 | case "fr": 8 | return "Français" 9 | case "de": 10 | return "Deutsche" 11 | default: 12 | return assertNever(code) 13 | } 14 | } 15 | 16 | function assertNever(code: never): never { 17 | throw new Error(`Unexpected language code: ${code}`) 18 | } 19 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843405802_create_table_public_cards/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 3 | CREATE TABLE "public"."cards"("id" uuid NOT NULL DEFAULT gen_random_uuid(), "game_id" uuid NOT NULL, "player_id" uuid NOT NULL, "word" text NOT NULL, "created_at" timestamptz NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id")); -------------------------------------------------------------------------------- /app/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-256x256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /app/src/pages/Home/operations.graphql: -------------------------------------------------------------------------------- 1 | mutation StartGame { 2 | insert_games_one(object: {}) { 3 | id 4 | } 5 | } 6 | 7 | mutation JoinGame($gameId: String!, $clientUuid: String!) { 8 | joinGame(gameId: $gameId, clientUuid: $clientUuid) { 9 | id 10 | jwt_token 11 | } 12 | } 13 | 14 | query GameByJoinCode($joinCode: String!) { 15 | games(where: { join_code: { _eq: $joinCode } }) { 16 | id 17 | } 18 | } 19 | 20 | subscription GameById($id: uuid!) { 21 | games_by_pk(id: $id) { 22 | id 23 | join_code 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /graphql-server/migrations/1588053319937_add_trigger_for_initial_rounds_for_new_game/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE OR REPLACE FUNCTION insert_initial_rounds_for_new_game() RETURNS TRIGGER AS 3 | $$ 4 | BEGIN 5 | INSERT INTO rounds (game_id, value, order_sequence) VALUES (NEW.id, 'taboo', 0), (NEW.id, 'charades', 1), (NEW.id, 'password', 2); 6 | RETURN NULL; 7 | END; 8 | $$ LANGUAGE PLPGSQL; 9 | 10 | CREATE TRIGGER insert_initial_rounds_for_new_game 11 | AFTER INSERT ON games 12 | FOR EACH ROW 13 | EXECUTE PROCEDURE insert_initial_rounds_for_new_game(); -------------------------------------------------------------------------------- /app/src/components/Fishbowl/FishbowlLoading.tsx: -------------------------------------------------------------------------------- 1 | import { Box, makeStyles } from "@material-ui/core" 2 | import React from "react" 3 | import Fishbowl from "." 4 | 5 | const useStyles = makeStyles(() => ({ 6 | root: { 7 | left: "50%", 8 | position: "absolute", 9 | top: "50%", 10 | transform: "translateX(-50%) translateY(-50%)", 11 | }, 12 | })) 13 | 14 | export const FishbowlLoading = () => { 15 | const classes = useStyles() 16 | 17 | return ( 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /graphql-server/metadata/actions.yaml: -------------------------------------------------------------------------------- 1 | actions: 2 | - name: joinGame 3 | definition: 4 | kind: synchronous 5 | handler: '{{ACTION_BASE_ENDPOINT}}/joinGame' 6 | permissions: 7 | - role: anonymous 8 | - role: player 9 | - name: newGame 10 | definition: 11 | kind: synchronous 12 | handler: '{{ACTION_BASE_ENDPOINT}}/newGame' 13 | permissions: 14 | - role: anonymous 15 | - role: player 16 | custom_types: 17 | enums: [] 18 | input_objects: [] 19 | objects: 20 | - name: joinGameOutput 21 | - name: newGameOutput 22 | scalars: [] 23 | -------------------------------------------------------------------------------- /app/src/components/RoundSettingsList.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "@material-ui/core" 2 | import { grey } from "@material-ui/core/colors" 3 | import * as React from "react" 4 | 5 | function RoundSettingsList(props: { children: React.ReactNode }) { 6 | return ( 7 | 16 | {props.children} 17 | 18 | ) 19 | } 20 | 21 | export default RoundSettingsList 22 | -------------------------------------------------------------------------------- /app/src/pages/CardSubmission/operations.graphql: -------------------------------------------------------------------------------- 1 | mutation SubmitCards($cards: [cards_insert_input!]!) { 2 | insert_cards(objects: $cards) { 3 | returning { 4 | id 5 | player_id 6 | game_id 7 | word 8 | } 9 | } 10 | } 11 | 12 | mutation AcceptCard($id: uuid!) { 13 | update_cards_by_pk(pk_columns: { id: $id }, _set: { is_allowed: true }) { 14 | id 15 | is_allowed 16 | } 17 | } 18 | 19 | mutation RejectCard($id: uuid!) { 20 | update_cards_by_pk(pk_columns: { id: $id }, _set: { is_allowed: false }) { 21 | id 22 | is_allowed 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/src/pages/TeamAssignment/operations.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateAllPlayers($gameId: uuid!, $players: [players_insert_input!]!) { 2 | insert_games_one( 3 | object: { 4 | id: $gameId 5 | players: { 6 | data: $players 7 | on_conflict: { 8 | constraint: players_pkey 9 | update_columns: [team, team_sequence] 10 | } 11 | } 12 | } 13 | on_conflict: { constraint: games_pkey, update_columns: [id] } 14 | ) { 15 | id 16 | players { 17 | id 18 | game_id 19 | team 20 | team_sequence 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/contexts/CurrentGame.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CurrentGameSubscription, 3 | GameCardPlayStyleEnum, 4 | GameStateEnum, 5 | } from "generated/graphql" 6 | import { createContext } from "react" 7 | 8 | export type CurrentGameContextType = CurrentGameSubscription["games"][0] 9 | 10 | export const CurrentGameContext = createContext({ 11 | id: "", 12 | state: GameStateEnum.Lobby, 13 | rounds: [], 14 | cards: [], 15 | players: [], 16 | turns: [], 17 | allow_card_skips: true, 18 | screen_cards: false, 19 | card_play_style: GameCardPlayStyleEnum.PlayersSubmitWords, 20 | }) 21 | -------------------------------------------------------------------------------- /app/src/components/BowlCard.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, styled } from "@material-ui/core" 2 | import * as React from "react" 3 | 4 | function BowlCard(props: { children: React.ReactNode; padding?: number }) { 5 | return ( 6 | 7 | 13 | {props.children} 14 | 15 | 16 | ) 17 | } 18 | 19 | const StyledCard = styled(Card)({ 20 | minHeight: 150, 21 | width: 250, 22 | }) 23 | 24 | export default BowlCard 25 | -------------------------------------------------------------------------------- /app/src/components/Fishbowl/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Fish from "./fish.png" 3 | import "./style.css" 4 | 5 | // credit: https://www.youtube.com/watch?v=a2PXv0suX6I 6 | 7 | function Fishbowl() { 8 | return ( 9 |
10 |
11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 | ) 20 | } 21 | 22 | export default Fishbowl 23 | -------------------------------------------------------------------------------- /app/src/lib/score.ts: -------------------------------------------------------------------------------- 1 | import { CurrentGameSubscription } from "generated/graphql" 2 | import { Team } from "lib/team" 3 | import { filter, flatMap } from "lodash" 4 | 5 | export function teamScore( 6 | team: Team, 7 | turns: CurrentGameSubscription["games"][0]["turns"], 8 | players: CurrentGameSubscription["games"][0]["players"] 9 | ) { 10 | const teamPlayerIds = filter(players, (player) => player.team === team).map( 11 | (player) => player.id 12 | ) 13 | const teamTurns = filter(turns, (turn) => 14 | teamPlayerIds.includes(turn.player_id) 15 | ) 16 | return flatMap(teamTurns, (turn) => turn.completed_card_ids).length 17 | } 18 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586843592144_create_table_public_turns/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 3 | CREATE TABLE "public"."turns"("id" uuid NOT NULL DEFAULT gen_random_uuid(), "player_id" uuid NOT NULL, "game_id" uuid NOT NULL, "completed_card_ids" jsonb NOT NULL DEFAULT jsonb_build_array(), "seconds_per_turn_override" integer, "created_at" timestamptz NOT NULL DEFAULT now(), "started_at" timestamptz, "ended_at" timestamptz, PRIMARY KEY ("id") , FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON UPDATE restrict ON DELETE restrict, UNIQUE ("id")); -------------------------------------------------------------------------------- /app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["es6", "dom", "dom.iterable", "esnext"], 5 | "baseUrl": "src", 6 | "noImplicitAny": true, 7 | "noImplicitThis": true, 8 | "strictNullChecks": true, 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /app/src/pages/Home/Host.tsx: -------------------------------------------------------------------------------- 1 | import { Games, useGameByIdSubscription } from "generated/graphql" 2 | import * as React from "react" 3 | import { generatePath, Redirect } from "react-router-dom" 4 | import routes from "routes" 5 | 6 | function HostRedirect(props: { gameId: Games["id"] }) { 7 | const { data } = useGameByIdSubscription({ 8 | variables: { 9 | id: props.gameId, 10 | }, 11 | }) 12 | const joinCode = data?.games_by_pk?.join_code 13 | if (joinCode) { 14 | return ( 15 | 21 | ) 22 | } else { 23 | return null 24 | } 25 | } 26 | 27 | export default HostRedirect 28 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486479146_alter_table_public_cards_add_column_updated_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."cards" ADD COLUMN "updated_at" timestamptz NULL DEFAULT now(); 2 | 3 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 4 | RETURNS TRIGGER AS $$ 5 | DECLARE 6 | _new record; 7 | BEGIN 8 | _new := NEW; 9 | _new."updated_at" = NOW(); 10 | RETURN _new; 11 | END; 12 | $$ LANGUAGE plpgsql; 13 | CREATE TRIGGER "set_public_cards_updated_at" 14 | BEFORE UPDATE ON "public"."cards" 15 | FOR EACH ROW 16 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 17 | COMMENT ON TRIGGER "set_public_cards_updated_at" ON "public"."cards" 18 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 19 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486492917_alter_table_public_games_add_column_updated_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."games" ADD COLUMN "updated_at" timestamptz NULL DEFAULT now(); 2 | 3 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 4 | RETURNS TRIGGER AS $$ 5 | DECLARE 6 | _new record; 7 | BEGIN 8 | _new := NEW; 9 | _new."updated_at" = NOW(); 10 | RETURN _new; 11 | END; 12 | $$ LANGUAGE plpgsql; 13 | CREATE TRIGGER "set_public_games_updated_at" 14 | BEFORE UPDATE ON "public"."games" 15 | FOR EACH ROW 16 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 17 | COMMENT ON TRIGGER "set_public_games_updated_at" ON "public"."games" 18 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 19 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486549828_alter_table_public_turns_add_column_updated_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."turns" ADD COLUMN "updated_at" timestamptz NULL DEFAULT now(); 2 | 3 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 4 | RETURNS TRIGGER AS $$ 5 | DECLARE 6 | _new record; 7 | BEGIN 8 | _new := NEW; 9 | _new."updated_at" = NOW(); 10 | RETURN _new; 11 | END; 12 | $$ LANGUAGE plpgsql; 13 | CREATE TRIGGER "set_public_turns_updated_at" 14 | BEFORE UPDATE ON "public"."turns" 15 | FOR EACH ROW 16 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 17 | COMMENT ON TRIGGER "set_public_turns_updated_at" ON "public"."turns" 18 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 19 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486515870_alter_table_public_rounds_add_column_updated_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."rounds" ADD COLUMN "updated_at" timestamptz NULL DEFAULT now(); 2 | 3 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 4 | RETURNS TRIGGER AS $$ 5 | DECLARE 6 | _new record; 7 | BEGIN 8 | _new := NEW; 9 | _new."updated_at" = NOW(); 10 | RETURN _new; 11 | END; 12 | $$ LANGUAGE plpgsql; 13 | CREATE TRIGGER "set_public_rounds_updated_at" 14 | BEFORE UPDATE ON "public"."rounds" 15 | FOR EACH ROW 16 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 17 | COMMENT ON TRIGGER "set_public_rounds_updated_at" ON "public"."rounds" 18 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 19 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486500618_alter_table_public_players_add_column_updated_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."players" ADD COLUMN "updated_at" timestamptz NULL DEFAULT now(); 2 | 3 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 4 | RETURNS TRIGGER AS $$ 5 | DECLARE 6 | _new record; 7 | BEGIN 8 | _new := NEW; 9 | _new."updated_at" = NOW(); 10 | RETURN _new; 11 | END; 12 | $$ LANGUAGE plpgsql; 13 | CREATE TRIGGER "set_public_players_updated_at" 14 | BEFORE UPDATE ON "public"."players" 15 | FOR EACH ROW 16 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 17 | COMMENT ON TRIGGER "set_public_players_updated_at" ON "public"."players" 18 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 19 | -------------------------------------------------------------------------------- /app/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | [ 5 | "@babel/env", 6 | { 7 | "modules": false, 8 | "targets": { 9 | "browsers": "> 0.2%" 10 | }, 11 | "useBuiltIns": "entry" 12 | } 13 | ] 14 | ], 15 | 16 | "plugins": [ 17 | [ 18 | "@babel/plugin-transform-typescript", 19 | { 20 | "allowNamespaces": true 21 | } 22 | ] 23 | ], 24 | "env": { 25 | "development": { 26 | "plugins": [ 27 | ["emotion", { "sourceMap": true, "hoist": false, "autoLabel": true }] 28 | ] 29 | }, 30 | "production": { 31 | "plugins": [ 32 | ["emotion", { "sourceMap": false, "hoist": true, "autoLabel": true }] 33 | ] 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /.idea/fishbowl.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://www.buymeacoffee.com/fishbowlgame'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /graphql-server/migrations/1594486532190_alter_table_public_turn_scorings_add_column_updated_at/up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "public"."turn_scorings" ADD COLUMN "updated_at" timestamptz NULL DEFAULT now(); 2 | 3 | CREATE OR REPLACE FUNCTION "public"."set_current_timestamp_updated_at"() 4 | RETURNS TRIGGER AS $$ 5 | DECLARE 6 | _new record; 7 | BEGIN 8 | _new := NEW; 9 | _new."updated_at" = NOW(); 10 | RETURN _new; 11 | END; 12 | $$ LANGUAGE plpgsql; 13 | CREATE TRIGGER "set_public_turn_scorings_updated_at" 14 | BEFORE UPDATE ON "public"."turn_scorings" 15 | FOR EACH ROW 16 | EXECUTE PROCEDURE "public"."set_current_timestamp_updated_at"(); 17 | COMMENT ON TRIGGER "set_public_turn_scorings_updated_at" ON "public"."turn_scorings" 18 | IS 'trigger to set value of column "updated_at" to current timestamp on row update'; 19 | -------------------------------------------------------------------------------- /actions-server/codegen.js: -------------------------------------------------------------------------------- 1 | const schema = {} 2 | schema[`${process.env.HASURA_ENDPOINT}`] = { 3 | headers: { 4 | "X-Hasura-Admin-Secret": "myadminsecretkey", 5 | }, 6 | } 7 | 8 | module.exports = { 9 | schema: [schema], 10 | documents: ["./src/**/*.graphql"], 11 | overwrite: true, 12 | generates: { 13 | "./src/generated/graphql.ts": { 14 | plugins: [ 15 | "typescript", 16 | "typescript-operations", 17 | "typescript-graphql-request", 18 | ], 19 | config: { 20 | rawRequest: true, 21 | noNamespaces: true, 22 | skipTypename: true, 23 | transformUnderscore: true, 24 | namingConvention: { 25 | typeNames: "change-case#pascalCase", 26 | transformUnderscore: true, 27 | }, 28 | }, 29 | }, 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /app/src/pages/Play/functions.ts: -------------------------------------------------------------------------------- 1 | import { TurnFragment } from "generated/graphql" 2 | import { ActiveTurnPlayState } from "lib/turn" 3 | 4 | export const playStateFromTurn = (turn?: TurnFragment) => { 5 | if (turn?.review_started_at) { 6 | return ActiveTurnPlayState.Reviewing 7 | } 8 | 9 | if (turn?.started_at) { 10 | return ActiveTurnPlayState.Playing 11 | } 12 | 13 | return ActiveTurnPlayState.Waiting 14 | } 15 | 16 | export const calculateSecondsLeft = ( 17 | startingSeconds: number, 18 | serverTimeOffset: number, 19 | activeTurn?: TurnFragment 20 | ) => { 21 | if (!activeTurn?.started_at) { 22 | return startingSeconds 23 | } 24 | 25 | const end = new Date(activeTurn.started_at).getTime() + 1000 * startingSeconds 26 | 27 | return Math.floor((end - (serverTimeOffset + Date.now())) / 1000) 28 | } 29 | -------------------------------------------------------------------------------- /actions-server/src/handlers/newGame.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express" 2 | import { graphQLClient } from "../graphQLClient" 3 | 4 | // Request Handler 5 | const handler = async (req: Request, res: Response) => { 6 | // execute the Hasura operation(s) 7 | const { data, errors: err1 } = await graphQLClient().StartGame() 8 | 9 | if (!data?.insert_games_one?.id || err1) { 10 | return res.status(400).json({ errros: err1 }) 11 | } 12 | 13 | const { data: gameData, errors: err2 } = await graphQLClient().GameById({ 14 | id: data.insert_games_one.id, 15 | }) 16 | 17 | const join_code = gameData?.games_by_pk?.join_code 18 | 19 | if (!join_code || err2) { 20 | return res.status(400).json({ errros: err2 }) 21 | } 22 | 23 | return res.status(200).json({ 24 | join_code, 25 | }) 26 | } 27 | 28 | module.exports = handler 29 | -------------------------------------------------------------------------------- /app/src/lib/useOnClickOutside.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react" 2 | 3 | // From: https://usehooks.com/useOnClickOutside/ 4 | export function useOnClickOutside( 5 | ref: React.RefObject, 6 | handler: CallableFunction 7 | ) { 8 | useEffect(() => { 9 | const listener = (event: MouseEvent | TouchEvent) => { 10 | // Do nothing if clicking ref's element or descendent elements 11 | if (!ref.current || ref.current.contains(event.target)) { 12 | return 13 | } 14 | 15 | handler(event) 16 | } 17 | 18 | document.addEventListener("mousedown", listener) 19 | document.addEventListener("touchstart", listener) 20 | 21 | return () => { 22 | document.removeEventListener("mousedown", listener) 23 | document.removeEventListener("touchstart", listener) 24 | } 25 | }, [ref, handler]) 26 | } 27 | -------------------------------------------------------------------------------- /actions-server/src/graphQLClient.ts: -------------------------------------------------------------------------------- 1 | import "cross-fetch/polyfill" // https://github.com/prisma-labs/graphql-request/issues/206 2 | import { GraphQLClient } from "graphql-request" 3 | import { getSdk } from "./generated/graphql" 4 | 5 | export function graphQLClient( 6 | credentials: { jwt?: string; adminSecret?: string } = {}, 7 | hasuraEndpoint = process.env.HASURA_ENDPOINT || "" 8 | ) { 9 | const headers: Record = {} 10 | if (credentials.jwt) { 11 | headers["Authorization"] = `Bearer ${credentials.jwt}` 12 | } 13 | if (credentials.adminSecret) { 14 | headers["x-hasura-admin-secret"] = credentials.adminSecret 15 | } 16 | 17 | const client = new GraphQLClient(hasuraEndpoint, { 18 | headers: { 19 | ...headers, 20 | "Content-Type": "application/json", 21 | }, 22 | }) 23 | return getSdk(client) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/RoundSettings.tsx: -------------------------------------------------------------------------------- 1 | import { Box, ListItem, ListItemText } from "@material-ui/core" 2 | import RoundSettingsList from "components/RoundSettingsList" 3 | import { CurrentGameContext } from "contexts/CurrentGame" 4 | import { capitalize } from "lodash" 5 | import * as React from "react" 6 | 7 | function RoundSettings() { 8 | const currentGame = React.useContext(CurrentGameContext) 9 | 10 | return ( 11 | 12 | {currentGame.rounds.map((round, index) => { 13 | return ( 14 | 15 | 16 | 17 | {index + 1}. {capitalize(round.value)} 18 | 19 | 20 | 21 | ) 22 | })} 23 | 24 | ) 25 | } 26 | 27 | export default RoundSettings 28 | -------------------------------------------------------------------------------- /app/src/lib/useInterval.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | // https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 4 | // https://gist.github.com/babakness/faca3b633bc23d9a0924efb069c9f1f5 (typescript version) 5 | 6 | type IntervalFunction = () => unknown | void 7 | 8 | function useInterval(callback: IntervalFunction, delay: number | null) { 9 | const savedCallback = React.useRef(null) 10 | 11 | React.useEffect(() => { 12 | if (delay === null) return 13 | savedCallback.current = callback 14 | }) 15 | 16 | React.useEffect(() => { 17 | if (delay === null) return 18 | function tick() { 19 | if (savedCallback.current !== null) { 20 | savedCallback.current() 21 | } 22 | } 23 | const id = setInterval(tick, delay) 24 | return () => clearInterval(id) 25 | }, [delay]) 26 | } 27 | 28 | export default useInterval 29 | -------------------------------------------------------------------------------- /actions-server/src/handlers/operations.graphql: -------------------------------------------------------------------------------- 1 | mutation InsertPlayerForGame($gameId: uuid!, $clientUuid: uuid!) { 2 | insert_players_one(object: { game_id: $gameId, client_uuid: $clientUuid }) { 3 | id 4 | game { 5 | host_id 6 | } 7 | } 8 | } 9 | 10 | query LookupPlayerForGame($gameId: uuid!, $clientUuid: uuid!) { 11 | players( 12 | where: { game_id: { _eq: $gameId }, client_uuid: { _eq: $clientUuid } } 13 | ) { 14 | id 15 | game { 16 | host_id 17 | } 18 | } 19 | } 20 | 21 | mutation StartGame { 22 | insert_games_one(object: {}) { 23 | id 24 | } 25 | } 26 | 27 | query GameById($id: uuid!) { 28 | games_by_pk(id: $id) { 29 | id 30 | join_code 31 | } 32 | } 33 | 34 | mutation BecomeHost($gameId: uuid!, $playerId: uuid!) { 35 | update_games_by_pk( 36 | pk_columns: { id: $gameId } 37 | _set: { host_id: $playerId } 38 | ) { 39 | id 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /graphql-server/migrations/1588958405887_create_table_public_turn_scorings/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE EXTENSION IF NOT EXISTS pgcrypto; 3 | CREATE TABLE "public"."turn_scorings"("id" uuid NOT NULL DEFAULT gen_random_uuid(), "turn_id" uuid NOT NULL, "card_id" uuid NOT NULL, "round_id" uuid NOT NULL, "player_id" uuid NOT NULL, "score" integer NOT NULL, "skipped" boolean NOT NULL, "started_at" timestamptz NOT NULL, "ended_at" timestamptz NOT NULL, "game_id" uuid NOT NULL, PRIMARY KEY ("id") , FOREIGN KEY ("turn_id") REFERENCES "public"."turns"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("card_id") REFERENCES "public"."cards"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("game_id") REFERENCES "public"."games"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("round_id") REFERENCES "public"."rounds"("id") ON UPDATE restrict ON DELETE restrict, FOREIGN KEY ("player_id") REFERENCES "public"."players"("id") ON UPDATE restrict ON DELETE restrict); -------------------------------------------------------------------------------- /actions-server/README.md: -------------------------------------------------------------------------------- 1 | # nodejs-express 2 | 3 | This is a starter kit for `nodejs` with `express`. To get started: 4 | 5 | Firstly, [download the starter-kit](https://github.com/hasura/codegen-assets/raw/master/nodejs-express/nodejs-express.zip) and `cd` into it. 6 | 7 | ``` 8 | npm ci 9 | npm start 10 | ``` 11 | 12 | ## Development 13 | 14 | The entrypoint for the server lives in `src/server.js`. 15 | 16 | If you wish to add a new route (say `/greet`) , you can add it directly in the `server.js` as: 17 | 18 | ```js 19 | app.post('/greet', (req, res) => { 20 | return res.json({ 21 | "greeting": "have a nice day" 22 | }); 23 | }); 24 | ``` 25 | 26 | ### Throwing erros 27 | 28 | You can throw an error object or a list of error objects from your handler. The response must be 4xx and the error object must have a string field called `message`. 29 | 30 | ```js 31 | retun res.status(400).json({ 32 | message: 'invalid email' 33 | }); 34 | ``` 35 | -------------------------------------------------------------------------------- /app/src/components/PlayerChipList.tsx: -------------------------------------------------------------------------------- 1 | import { makeStyles } from "@material-ui/core/styles" 2 | import { Players } from "generated/graphql" 3 | import * as React from "react" 4 | import PlayerChip from "./PlayerChip" 5 | 6 | const useStyles = makeStyles((theme) => ({ 7 | root: { 8 | display: "inline-flex", 9 | listStyleType: "none", 10 | margin: 0, 11 | padding: 0, 12 | "& > li + li": { 13 | marginLeft: theme.spacing(0.5), 14 | }, 15 | }, 16 | })) 17 | 18 | interface Props { 19 | players: Array> 20 | } 21 | 22 | export const PlayerChipList: React.FC = (props: Props) => { 23 | const classes = useStyles() 24 | 25 | return ( 26 |
    27 | {props.players.map((player) => ( 28 |
  • 29 | {player?.username} 30 |
  • 31 | ))} 32 |
33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /actions-server/src/server.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node" 2 | import express from "express" 3 | 4 | if (process.env.NODE_ENV !== "production") { 5 | require("dotenv").config() 6 | } 7 | 8 | if (process.env.NODE_ENV !== "development") { 9 | Sentry.init({ 10 | dsn: 11 | "https://593a557ed4834276803af5fc8a4432b5@o392843.ingest.sentry.io/5241108", 12 | environment: process.env.NODE_ENV, 13 | }) 14 | } 15 | 16 | const app = express() 17 | 18 | const PORT = process.env.PORT || 3001 19 | 20 | app.use(express.json()) 21 | 22 | app.post("/:route", (req, res) => { 23 | try { 24 | const handler = require(`./handlers/${req.params.route}`) 25 | if (!handler) { 26 | return res.status(404).json({ 27 | message: `not found`, 28 | }) 29 | } 30 | return handler(req, res) 31 | } catch (e) { 32 | console.error(e) 33 | return res.status(500).json({ 34 | message: "unexpected error occured", 35 | }) 36 | } 37 | }) 38 | 39 | app.listen(PORT) 40 | -------------------------------------------------------------------------------- /app/codegen.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | schema: { 3 | [process.env.REACT_APP_FISHBOWL_GRAPHQL_ENDPOINT]: { 4 | headers: { 5 | "X-Hasura-Admin-Secret": 6 | process.env.REACT_APP_FISHBOWL_HASURA_ADMIN_SECRET, 7 | }, 8 | }, 9 | }, 10 | documents: ["./src/**/*.graphql"], 11 | overwrite: true, 12 | generates: { 13 | "./src/generated/graphql.tsx": { 14 | plugins: [ 15 | "typescript", 16 | "typescript-operations", 17 | "typescript-react-apollo", 18 | ], 19 | config: { 20 | noNamespaces: true, 21 | skipTypename: true, 22 | withHooks: true, 23 | withHOC: false, 24 | withComponent: false, 25 | transformUnderscore: true, 26 | namingConvention: { 27 | typeNames: "change-case#pascalCase", 28 | transformUnderscore: true, 29 | }, 30 | }, 31 | }, 32 | "./src/generated/graphql.schema.json": { 33 | plugins: ["introspection"], 34 | }, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /app/src/AuthWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AuthStorageKey, 3 | CurrentAuthContext, 4 | CurrentAuthContextType, 5 | } from "contexts/CurrentAuth" 6 | import * as React from "react" 7 | 8 | function AuthWrapper(props: { children: React.ReactNode }) { 9 | const [jwtToken, setJwtToken] = React.useState< 10 | CurrentAuthContextType["jwtToken"] 11 | >( 12 | localStorage.getItem(AuthStorageKey) 13 | ? localStorage.getItem(AuthStorageKey) 14 | : null 15 | ) 16 | 17 | return ( 18 | { 22 | if (jwtToken) { 23 | localStorage.setItem(AuthStorageKey, jwtToken) 24 | } else { 25 | localStorage.removeItem(AuthStorageKey) 26 | } 27 | setJwtToken(jwtToken) 28 | }, 29 | }} 30 | > 31 | {props.children} 32 | 33 | ) 34 | } 35 | 36 | export default AuthWrapper 37 | -------------------------------------------------------------------------------- /graphql-server/migrations/1586842925840_add_generate_unique_game_join_code/up.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE OR REPLACE FUNCTION random_text(INTEGER) RETURNS TEXT AS 3 | $$ 4 | BEGIN 5 | RETURN(SELECT array_to_string(array( 6 | SELECT SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ' FROM floor(random()*26)::int+1 FOR 1) 7 | FROM generate_series(1, $1) 8 | ), '')); 9 | END; 10 | $$ LANGUAGE PLPGSQL; 11 | 12 | CREATE OR REPLACE FUNCTION generate_unique_game_join_code() RETURNS TRIGGER AS 13 | $$ 14 | DECLARE 15 | new_code TEXT; 16 | BEGIN 17 | LOOP 18 | new_code := random_text(4) AS TEXT; 19 | BEGIN 20 | UPDATE games SET join_code = new_code WHERE games.id = NEW.id; 21 | RETURN NULL; 22 | EXIT; 23 | EXCEPTION WHEN unique_violation THEN 24 | END; 25 | END LOOP; 26 | END; 27 | $$ LANGUAGE PLPGSQL; 28 | 29 | CREATE TRIGGER generate_unique_game_join_code 30 | AFTER INSERT ON games 31 | FOR EACH ROW 32 | EXECUTE PROCEDURE generate_unique_game_join_code(); -------------------------------------------------------------------------------- /app/src/contexts/CurrentPlayer.ts: -------------------------------------------------------------------------------- 1 | import { CurrentPlayerQuery } from "generated/graphql" 2 | import { createContext } from "react" 3 | import { v4 as uuidv4 } from "uuid" 4 | 5 | const localStorageKey = "player.client_uuid" 6 | 7 | export const clientUuid = (): string => { 8 | const value = localStorage.getItem(localStorageKey) 9 | if (value !== null) { 10 | return value 11 | } else { 12 | const uuid: string = uuidv4() 13 | localStorage.setItem(localStorageKey, uuid) 14 | return uuid 15 | } 16 | } 17 | 18 | export const setClientUuid = (uuid: string) => { 19 | localStorage.setItem(localStorageKey, uuid) 20 | } 21 | 22 | export enum PlayerRole { 23 | Participant = 0, 24 | Host, 25 | } 26 | 27 | export type CurrentPlayerContextType = CurrentPlayerQuery["players"][0] & { 28 | role: PlayerRole 29 | serverTimeOffset: number 30 | } 31 | 32 | export const CurrentPlayerContext = createContext({ 33 | id: "", 34 | role: PlayerRole.Participant, 35 | serverTimeOffset: 0, 36 | game: { 37 | id: "", 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-present Avinash Moondra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /actions-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-express-actions", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "prettier": "prettier --config ../.prettierrc --check \"src/{,!(generated)/**/}*.{js,ts}\"", 8 | "lint": "yarn run prettier && tsc --noEmit", 9 | "build": "tsc", 10 | "start": "node build/server.js", 11 | "start-debug": "nodemon --inspect", 12 | "gql-gen": "graphql-codegen --config codegen.js --require dotenv/config" 13 | }, 14 | "dependencies": { 15 | "@sentry/node": "5.15.5", 16 | "express": "4.16.4", 17 | "jsonwebtoken": "^8.5.1" 18 | }, 19 | "devDependencies": { 20 | "@graphql-codegen/cli": "^1.19.0", 21 | "@graphql-codegen/typescript": "^1.17.11", 22 | "@graphql-codegen/typescript-graphql-request": "^2.0.2", 23 | "@graphql-codegen/typescript-operations": "^1.17.8", 24 | "@types/express": "^4.17.6", 25 | "@types/jsonwebtoken": "^8.5.0", 26 | "@types/node": "^14.0.1", 27 | "dotenv": "^8.2.0", 28 | "esm": "^3.2.25", 29 | "graphql": "^15.0.0", 30 | "nodemon": "1.18.4", 31 | "prettier": "^2.0.5", 32 | "ts-node": "^8.10.1", 33 | "typescript": "^4.0.5" 34 | }, 35 | "keywords": [] 36 | } 37 | -------------------------------------------------------------------------------- /app/src/components/PlayerChip.tsx: -------------------------------------------------------------------------------- 1 | import { Chip } from "@material-ui/core" 2 | import { grey } from "@material-ui/core/colors" 3 | import LoopIcon from "@material-ui/icons/Loop" 4 | import { Team } from "lib/team" 5 | import * as React from "react" 6 | 7 | interface Props { 8 | username?: string 9 | team?: string | null | undefined 10 | handleDelete?: () => void 11 | handleSwitch?: () => void 12 | } 13 | 14 | const PlayerChip: React.FC = (props) => { 15 | const username = 16 | props.username ?? (props.children ? String(props.children) : "") 17 | return ( 18 | 29 | ) 30 | } 31 | key={username} 32 | color={ 33 | props.team 34 | ? props.team === Team.Red 35 | ? "secondary" 36 | : "primary" 37 | : "default" 38 | } 39 | variant="outlined" 40 | size="small" 41 | label={username} 42 | onDelete={props.handleDelete} 43 | > 44 | ) 45 | } 46 | 47 | export default PlayerChip 48 | -------------------------------------------------------------------------------- /app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import GameRoutes from "components/GameRoutes" 2 | import Layout from "components/Layout" 3 | import Home from "pages/Home" 4 | import Pending from "pages/Pending" 5 | import * as React from "react" 6 | import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom" 7 | import routes from "./routes" 8 | 9 | function App() { 10 | return ( 11 | 12 | 13 | 14 | 15 | { 19 | return ( 20 | 23 | ) 24 | }} 25 | /> 26 | { 29 | return ( 30 | 33 | ) 34 | }} 35 | /> 36 | 37 | 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default App 46 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | push: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | app: 12 | runs-on: ubuntu-latest 13 | defaults: 14 | run: 15 | working-directory: app 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | - name: Read .nvmrc 20 | run: echo ::set-output name=NVMRC::$(cat .nvmrc) 21 | id: nvm 22 | - name: Use Node.js ${{ steps.nvm.outputs.NVMRC }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: "${{ steps.nvm.outputs.NVMRC }}" 26 | - name: Install dependencies 27 | run: yarn install --frozen-lockfile 28 | - name: Lint 29 | run: yarn run lint 30 | 31 | actions-server: 32 | runs-on: ubuntu-latest 33 | defaults: 34 | run: 35 | working-directory: actions-server 36 | steps: 37 | - name: Checkout code 38 | uses: actions/checkout@v2 39 | - name: Read .nvmrc 40 | run: echo ::set-output name=NVMRC::$(cat .nvmrc) 41 | id: nvm 42 | - name: Use Node.js ${{ steps.nvm.outputs.NVMRC }} 43 | uses: actions/setup-node@v2 44 | with: 45 | node-version: "${{ steps.nvm.outputs.NVMRC }}" 46 | - name: Install dependencies 47 | run: yarn install --frozen-lockfile 48 | - name: Lint 49 | run: yarn run lint 50 | -------------------------------------------------------------------------------- /app/src/pages/CardSubmission/AssignTeamsButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@material-ui/core" 2 | import { CurrentGameContext } from "contexts/CurrentGame" 3 | import { 4 | GameStateEnum, 5 | useUpdateAllPlayersMutation, 6 | useUpdateGameStateMutation, 7 | } from "generated/graphql" 8 | import { teamsWithSequence } from "lib/team" 9 | import * as React from "react" 10 | import { useTranslation } from "react-i18next" 11 | 12 | function AssignTeamsButton() { 13 | const { t } = useTranslation() 14 | const currentGame = React.useContext(CurrentGameContext) 15 | const [updateGameState] = useUpdateGameStateMutation() 16 | const [updateAllPlayers] = useUpdateAllPlayersMutation() 17 | 18 | return ( 19 | 45 | ) 46 | } 47 | 48 | export default AssignTeamsButton 49 | -------------------------------------------------------------------------------- /app/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grow, IconButton, Snackbar } from "@material-ui/core" 2 | import { TransitionProps } from "@material-ui/core/transitions/transition" 3 | import CloseIcon from "@material-ui/icons/Close" 4 | import { NotificationContext } from "contexts/Notification" 5 | import * as React from "react" 6 | 7 | function GrowTransition(props: TransitionProps) { 8 | return 9 | } 10 | 11 | function Layout(props: { children: React.ReactNode }) { 12 | const [open, setOpen] = React.useState(false) 13 | const [message, setMessage] = React.useState(null) 14 | 15 | const handleClose = () => setOpen(false) 16 | 17 | return ( 18 | 19 | 33 | 34 | 35 | } 36 | > 37 | { 40 | setMessage(message) 41 | setOpen(true) 42 | }, 43 | }} 44 | > 45 | {props.children} 46 | 47 | 48 | ) 49 | } 50 | 51 | export default Layout 52 | -------------------------------------------------------------------------------- /app/src/graphql/operations.graphql: -------------------------------------------------------------------------------- 1 | query CurrentPlayer($clientUuid: uuid!, $joinCode: String!) { 2 | players( 3 | where: { 4 | client_uuid: { _eq: $clientUuid } 5 | game: { join_code: { _eq: $joinCode } } 6 | } 7 | ) { 8 | id 9 | client_uuid 10 | username 11 | game { 12 | id 13 | host { 14 | id 15 | username 16 | } 17 | } 18 | } 19 | } 20 | 21 | fragment Turn on turns { 22 | id 23 | player_id 24 | started_at 25 | review_started_at 26 | completed_card_ids 27 | seconds_per_turn_override 28 | } 29 | 30 | subscription CurrentGame($joinCode: String!) { 31 | games(where: { join_code: { _eq: $joinCode } }) { 32 | id 33 | join_code 34 | starting_letter 35 | seconds_per_turn 36 | num_entries_per_player 37 | allow_card_skips 38 | screen_cards 39 | card_play_style 40 | state 41 | host { 42 | id 43 | username 44 | } 45 | rounds(order_by: { order_sequence: asc }) { 46 | id 47 | value 48 | } 49 | cards( 50 | where: { 51 | _or: [{ is_allowed: { _is_null: true } }, { is_allowed: { _eq: true } }] 52 | } 53 | ) { 54 | id 55 | word 56 | player_id 57 | is_allowed 58 | } 59 | players { 60 | id 61 | client_uuid 62 | username 63 | team 64 | team_sequence 65 | } 66 | turns(order_by: { sequential_id: asc }) { 67 | ...Turn 68 | } 69 | } 70 | } 71 | 72 | mutation UpdateGameState($id: uuid!, $state: game_state_enum!) { 73 | update_games_by_pk(pk_columns: { id: $id }, _set: { state: $state }) { 74 | id 75 | state 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/src/components/GameStateRedirects.tsx: -------------------------------------------------------------------------------- 1 | import { CurrentGameContext } from "contexts/CurrentGame" 2 | import { GameStateEnum } from "generated/graphql" 3 | import * as React from "react" 4 | import { 5 | generatePath, 6 | matchPath, 7 | Redirect, 8 | useLocation, 9 | } from "react-router-dom" 10 | import routes from "routes" 11 | 12 | const stateRoutePairs = [ 13 | { 14 | state: GameStateEnum.Lobby, 15 | route: routes.game.lobby, 16 | }, 17 | { 18 | state: GameStateEnum.CardSubmission, 19 | route: routes.game.cardSubmission, 20 | }, 21 | { 22 | state: GameStateEnum.TeamAssignment, 23 | route: routes.game.teamAssignment, 24 | }, 25 | { 26 | state: GameStateEnum.ActivePlay, 27 | route: routes.game.play, 28 | }, 29 | { 30 | state: GameStateEnum.Ended, 31 | route: routes.game.ended, 32 | }, 33 | ] 34 | 35 | function GameStateRedirects(props: { joinCode: string }) { 36 | const location = useLocation() 37 | const currentGame = React.useContext(CurrentGameContext) 38 | 39 | if ( 40 | matchPath(location.pathname, { path: routes.game.pending, exact: true }) || 41 | matchPath(location.pathname, { path: routes.game.settings, exact: true }) 42 | ) { 43 | return null 44 | } 45 | 46 | const pair = stateRoutePairs.find((pair) => pair.state === currentGame.state) 47 | if ( 48 | pair?.state && 49 | !matchPath(location.pathname, { 50 | path: pair.route, 51 | exact: true, 52 | }) 53 | ) { 54 | return ( 55 | 60 | ) 61 | } else { 62 | return null 63 | } 64 | } 65 | 66 | export default GameStateRedirects 67 | -------------------------------------------------------------------------------- /app/src/pages/Play/useSecondsLeft.ts: -------------------------------------------------------------------------------- 1 | import { CurrentGameContext } from "contexts/CurrentGame" 2 | import { CurrentPlayerContext } from "contexts/CurrentPlayer" 3 | import { ActiveTurnPlayState } from "lib/turn" 4 | import useInterval from "lib/useInterval" 5 | import { last } from "lodash" 6 | import React, { useContext, useEffect } from "react" 7 | import { calculateSecondsLeft } from "./functions" 8 | 9 | /** 10 | * Milliseconds to delay in between timer updates. 11 | * 12 | * Intentional < 1s to mitigate setInterval drift. 13 | * 14 | * @see https://stackoverflow.com/q/985670/14386557 15 | */ 16 | const INTERVAL_DELAY = 100 17 | 18 | export default function useSecondsLeft( 19 | activeTurnPlayState: ActiveTurnPlayState 20 | ): number { 21 | const { serverTimeOffset } = useContext(CurrentPlayerContext) 22 | const currentGame = useContext(CurrentGameContext) 23 | const activeTurn = last(currentGame.turns) 24 | const startingSeconds = 25 | activeTurn?.seconds_per_turn_override || currentGame.seconds_per_turn || 0 26 | 27 | const [secondsLeft, setSecondsLeft] = React.useState( 28 | calculateSecondsLeft(startingSeconds, serverTimeOffset, activeTurn) 29 | ) 30 | useEffect(() => { 31 | setSecondsLeft( 32 | calculateSecondsLeft(startingSeconds, serverTimeOffset, activeTurn) 33 | ) 34 | }, [startingSeconds, serverTimeOffset, activeTurn]) 35 | 36 | // countdown timer 37 | useInterval(() => { 38 | if ( 39 | activeTurnPlayState === ActiveTurnPlayState.Playing && 40 | activeTurn?.started_at && 41 | secondsLeft >= 1 42 | ) { 43 | setSecondsLeft( 44 | calculateSecondsLeft(startingSeconds, serverTimeOffset, activeTurn) 45 | ) 46 | } 47 | }, INTERVAL_DELAY) 48 | 49 | return secondsLeft 50 | } 51 | -------------------------------------------------------------------------------- /app/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Container, 3 | createMuiTheme, 4 | CssBaseline, 5 | makeStyles, 6 | ThemeProvider, 7 | } from "@material-ui/core" 8 | import * as Sentry from "@sentry/browser" 9 | import ApolloWrapper from "ApolloWrapper" 10 | import App from "App" 11 | import AuthWrapper from "AuthWrapper" 12 | import { FishbowlLoading } from "components/Fishbowl/FishbowlLoading" 13 | import "index.css" 14 | import React, { Suspense } from "react" 15 | import ReactDOM from "react-dom" 16 | import * as serviceWorker from "serviceWorker" 17 | import "./i18n" 18 | 19 | if (process.env.NODE_ENV !== "development") { 20 | Sentry.init({ 21 | dsn: 22 | "https://d142f9cb081e4dbe9f14316996496044@o392843.ingest.sentry.io/5241100", 23 | environment: process.env.NODE_ENV, 24 | }) 25 | } 26 | 27 | export const useTitleStyle = makeStyles({ 28 | title: { 29 | fontFamily: "Playfair Display, serif", 30 | }, 31 | }) 32 | 33 | const theme = createMuiTheme({}) 34 | 35 | ReactDOM.render( 36 | 37 | 38 | 39 | 40 | 41 | 42 | }> 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | , 51 | document.getElementById("root") 52 | ) 53 | 54 | // If you want your app to work offline and load faster, you can change 55 | // unregister() to register() below. Note this comes with some pitfalls. 56 | // Learn more about service workers: https://bit.ly/CRA-PWA 57 | serviceWorker.unregister() 58 | -------------------------------------------------------------------------------- /app/src/pages/Play/NoMoreRounds.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, Typography } from "@material-ui/core" 2 | import { CurrentGameContext } from "contexts/CurrentGame" 3 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 4 | import { GameStateEnum, useUpdateGameStateMutation } from "generated/graphql" 5 | import { useTitleStyle } from "index" 6 | import * as React from "react" 7 | import { useTranslation } from "react-i18next" 8 | 9 | function NoMoreRounds() { 10 | const { t } = useTranslation() 11 | const currentGame = React.useContext(CurrentGameContext) 12 | const currentPlayer = React.useContext(CurrentPlayerContext) 13 | const titleClasses = useTitleStyle() 14 | const [updateGameState] = useUpdateGameStateMutation() 15 | 16 | return ( 17 | 18 | 19 | 20 | {t("end.title", "Game Over")} 21 | 22 | 23 | 24 | {t( 25 | "end.endGame.description", 26 | "Your host will end the game to show some stats!" 27 | )} 28 | 29 | {currentPlayer.role === PlayerRole.Host && ( 30 | 31 | 45 | 46 | )} 47 | 48 | ) 49 | } 50 | 51 | export default NoMoreRounds 52 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/Inputs/AllowCardSkipsCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { grey } from "@material-ui/core/colors" 2 | import FormControlLabel from "@material-ui/core/FormControlLabel" 3 | import Switch from "@material-ui/core/Switch" 4 | import { CurrentGameContext } from "contexts/CurrentGame" 5 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 6 | import { useUpdateGameSettingsMutation } from "generated/graphql" 7 | import * as React from "react" 8 | import { useTranslation } from "react-i18next" 9 | 10 | export default function AllowCardSkipsCheckbox(props: { 11 | value: boolean 12 | disabled?: boolean 13 | }) { 14 | const { t } = useTranslation() 15 | const currentPlayer = React.useContext(CurrentPlayerContext) 16 | const currentGame = React.useContext(CurrentGameContext) 17 | const [updateGameSettings] = useUpdateGameSettingsMutation() 18 | const [checkboxValue, setCheckboxValue] = React.useState(props.value) 19 | const canConfigureSettings = currentPlayer.role === PlayerRole.Host 20 | 21 | React.useEffect(() => { 22 | setCheckboxValue(props.value) 23 | }, [props.value]) 24 | 25 | return ( 26 | { 33 | setCheckboxValue(checked) 34 | updateGameSettings({ 35 | variables: { 36 | id: currentGame.id, 37 | input: { allow_card_skips: checked }, 38 | }, 39 | }) 40 | }} 41 | /> 42 | } 43 | label={ 44 | 45 | {t("settings.turns.skipLabel", "Allow card skips during turn")} 46 | 47 | } 48 | /> 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/src/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next" 2 | import LanguageDetector from "i18next-browser-languagedetector" 3 | import Locize from "i18next-locize-backend" 4 | import { EmptyResourceLanguage, SupportedLanguages } from "locales" 5 | import { locizePlugin } from "locize" 6 | import queryString from "query-string" 7 | import { initReactI18next } from "react-i18next" 8 | 9 | const development = "development" === process.env.NODE_ENV 10 | const locizeApiKey = development 11 | ? process.env.REACT_APP_FISHBOWL_LOCIZE_API_KEY 12 | : undefined 13 | const saveMissing = Boolean(development && locizeApiKey) 14 | const inContext = "1" === queryString.parse(window.location.search).locize 15 | 16 | if (inContext) { 17 | i18n.use(locizePlugin) 18 | } 19 | 20 | i18n 21 | .use(Locize) 22 | .use(LanguageDetector) 23 | .use(initReactI18next) 24 | .init({ 25 | debug: development, 26 | 27 | // @see https://www.i18next.com/overview/configuration-options#languages-namespaces-resources 28 | fallbackLng: "en", 29 | supportedLngs: SupportedLanguages as any, 30 | partialBundledLanguages: true, 31 | resources: saveMissing ? undefined : { en: EmptyResourceLanguage }, 32 | interpolation: { 33 | escapeValue: false, // React already safe from XSS 34 | }, 35 | 36 | // @see https://www.i18next.com/overview/configuration-options#missing-keys 37 | saveMissing, 38 | updateMissing: saveMissing, 39 | saveMissingTo: "all", 40 | 41 | // @see https://github.com/locize/i18next-locize-backend#backend-options 42 | backend: { 43 | projectId: "3ff96254-e310-4d55-8076-f0cc49f57a8f", 44 | apiKey: locizeApiKey, 45 | referenceLng: "en", 46 | // @see https://docs.locize.com/guides-tips-and-tricks/going-production#versions-and-caching 47 | version: development || inContext ? "latest" : "production", 48 | }, 49 | }) 50 | 51 | export default i18n 52 | -------------------------------------------------------------------------------- /app/src/pages/Play/operations.graphql: -------------------------------------------------------------------------------- 1 | mutation CreateTurn($gameId: uuid!, $playerId: uuid!, $roundId: uuid!) { 2 | insert_turns_one( 3 | object: { game_id: $gameId, player_id: $playerId, round_id: $roundId } 4 | ) { 5 | id 6 | game_id 7 | player_id 8 | round_id 9 | } 10 | } 11 | 12 | mutation StartTurn($currentTurnId: uuid!) { 13 | update_turns_by_pk( 14 | pk_columns: { id: $currentTurnId } 15 | _set: { started_at: "now()" } 16 | ) { 17 | id 18 | started_at 19 | } 20 | } 21 | 22 | mutation EndCurrentTurnAndStartNextTurn( 23 | $currentTurnId: uuid! 24 | $completedCardIds: jsonb! 25 | $gameId: uuid! 26 | $currentTurnScorings: [turn_scorings_insert_input!]! 27 | $nextTurnplayerId: uuid! 28 | $nextTurnSecondsPerTurnOverride: Int 29 | $roundId: uuid 30 | ) { 31 | delete_turn_scorings(where: { turn_id: { _eq: $currentTurnId } }) { 32 | returning { 33 | id 34 | } 35 | } 36 | insert_turn_scorings(objects: $currentTurnScorings) { 37 | returning { 38 | id 39 | } 40 | } 41 | update_turns_by_pk( 42 | pk_columns: { id: $currentTurnId } 43 | _set: { ended_at: "now()", completed_card_ids: $completedCardIds } 44 | ) { 45 | id 46 | ended_at 47 | completed_card_ids 48 | } 49 | insert_turns_one( 50 | object: { 51 | game_id: $gameId 52 | player_id: $nextTurnplayerId 53 | seconds_per_turn_override: $nextTurnSecondsPerTurnOverride 54 | round_id: $roundId 55 | } 56 | ) { 57 | id 58 | game_id 59 | player_id 60 | seconds_per_turn_override 61 | round_id 62 | } 63 | } 64 | 65 | mutation StartReview($currentTurnId: uuid!) { 66 | update_turns_by_pk( 67 | pk_columns: { id: $currentTurnId } 68 | _set: { review_started_at: "now()" } 69 | ) { 70 | id 71 | review_started_at 72 | } 73 | } 74 | 75 | query ServerTime { 76 | server_time { 77 | now 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/components/GameLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Fab } from "@material-ui/core" 2 | import CloseIcon from "@material-ui/icons/Close" 3 | import SettingsIcon from "@material-ui/icons/Settings" 4 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 5 | import { some } from "lodash" 6 | import * as React from "react" 7 | import { 8 | generatePath, 9 | matchPath, 10 | useHistory, 11 | useLocation, 12 | } from "react-router-dom" 13 | import routes from "routes" 14 | 15 | function GameLayout(props: { children: React.ReactNode; joinCode: string }) { 16 | const currentPlayer = React.useContext(CurrentPlayerContext) 17 | const location = useLocation() 18 | const history = useHistory() 19 | 20 | const inSettings = matchPath(location.pathname, { 21 | path: routes.game.settings, 22 | exact: true, 23 | }) 24 | 25 | const showFabOnThisRoute = !some( 26 | [routes.game.pending, routes.game.lobby, routes.game.ended], 27 | (route) => { 28 | return matchPath(location.pathname, { 29 | path: route, 30 | exact: true, 31 | }) 32 | } 33 | ) 34 | 35 | return ( 36 | 37 | {props.children} 38 | {showFabOnThisRoute && currentPlayer.role === PlayerRole.Host && ( 39 | 40 | { 44 | if (inSettings) { 45 | history.goBack() 46 | } else { 47 | history.push( 48 | generatePath(routes.game.settings, { 49 | joinCode: props.joinCode.toLocaleUpperCase(), 50 | }) 51 | ) 52 | } 53 | }} 54 | > 55 | {inSettings ? : } 56 | 57 | 58 | )} 59 | 60 | ) 61 | } 62 | 63 | export default GameLayout 64 | -------------------------------------------------------------------------------- /Brewfile.lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "entries": { 3 | "tap": { 4 | "homebrew/cask": { 5 | "revision": "cabd12d4542441a3c592eaced85c1e649978dba8" 6 | } 7 | }, 8 | "brew": { 9 | "yarn": { 10 | "version": "1.22.4", 11 | "bottle": false 12 | }, 13 | "direnv": { 14 | "version": "2.21.2", 15 | "bottle": { 16 | "cellar": ":any_skip_relocation", 17 | "prefix": "/usr/local", 18 | "files": { 19 | "catalina": { 20 | "url": "https://homebrew.bintray.com/bottles/direnv-2.21.2.catalina.bottle.tar.gz", 21 | "sha256": "31a7b6b1f16dd3e881ec693cb83e07757500f8d4810a5ebad3ab5986f7a6ef28" 22 | }, 23 | "mojave": { 24 | "url": "https://homebrew.bintray.com/bottles/direnv-2.21.2.mojave.bottle.tar.gz", 25 | "sha256": "6a79378f41555b964e95c093c4a693fd2252c873bae453ebb309ec148eaeac7b" 26 | }, 27 | "high_sierra": { 28 | "url": "https://homebrew.bintray.com/bottles/direnv-2.21.2.high_sierra.bottle.tar.gz", 29 | "sha256": "e490c0f7a242c35343e2f4d108b069380a9f73b2c7656d23db1c8940e549920b" 30 | } 31 | } 32 | } 33 | }, 34 | "nvm": { 35 | "version": "0.35.3", 36 | "bottle": false 37 | } 38 | } 39 | }, 40 | "system": { 41 | "macos": { 42 | "mojave": { 43 | "HOMEBREW_VERSION": "2.2.12", 44 | "HOMEBREW_PREFIX": "/usr/local", 45 | "Homebrew/homebrew-core": "599734938121a8a9ebb4b681132f8e6a4a3d1b63", 46 | "CLT": "10.3.0.0.1.1562985497", 47 | "Xcode": "10.3", 48 | "macOS": "10.14.6" 49 | }, 50 | "catalina": { 51 | "HOMEBREW_VERSION": "2.2.13", 52 | "HOMEBREW_PREFIX": "/usr/local", 53 | "Homebrew/homebrew-core": "510e4a7ce2fead9c138fc0b4080ab01cc20fbc68", 54 | "CLT": "", 55 | "Xcode": "11.4.1", 56 | "macOS": "10.15.4" 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/src/components/BuyMeACoffeeButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | interface Props { 4 | children: string 5 | } 6 | 7 | function BuyMeACoffeeButton(props: Props) { 8 | return ( 9 |
.bmc-button img{height: 34px !important;width: 35px !important;margin-bottom: 1px !important;box-shadow: none !important;border: none !important;vertical-align: middle !important;}.bmc-button{padding: 7px 10px 7px 10px !important;line-height: 35px !important;height:51px !important;min-width:217px !important;text-decoration: none !important;display:inline-flex !important;color:#ffffff !important;background-color:#FF813F !important;border-radius: 5px !important;border: 1px solid transparent !important;padding: 7px 10px 7px 10px !important;font-size: 22px !important;letter-spacing: 0.6px !important;box-shadow: 0px 1px 2px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 1px 2px 2px rgba(190, 190, 190, 0.5) !important;margin: 0 auto !important;font-family:'Cookie', cursive !important;-webkit-box-sizing: border-box !important;box-sizing: border-box !important;-o-transition: 0.3s all linear !important;-webkit-transition: 0.3s all linear !important;-moz-transition: 0.3s all linear !important;-ms-transition: 0.3s all linear !important;transition: 0.3s all linear !important;}.bmc-button:hover, .bmc-button:active, .bmc-button:focus {-webkit-box-shadow: 0px 1px 2px 2px rgba(190, 190, 190, 0.5) !important;text-decoration: none !important;box-shadow: 0px 1px 2px 2px rgba(190, 190, 190, 0.5) !important;opacity: 0.85 !important;color:#ffffff !important;}${props.children}${props.children}`, 12 | }} 13 | >
14 | ) 15 | } 16 | 17 | export default BuyMeACoffeeButton 18 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/Inputs/ScreenCardsCheckbox.tsx: -------------------------------------------------------------------------------- 1 | import { grey } from "@material-ui/core/colors" 2 | import FormControlLabel from "@material-ui/core/FormControlLabel" 3 | import Switch from "@material-ui/core/Switch" 4 | import { CurrentGameContext } from "contexts/CurrentGame" 5 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 6 | import { useUpdateGameSettingsMutation } from "generated/graphql" 7 | import * as React from "react" 8 | import { useTranslation } from "react-i18next" 9 | 10 | export default function ScreenCardsCheckbox(props: { 11 | value: boolean 12 | disabled?: boolean 13 | }) { 14 | const { t } = useTranslation() 15 | const currentPlayer = React.useContext(CurrentPlayerContext) 16 | const currentGame = React.useContext(CurrentGameContext) 17 | const [updateGameSettings] = useUpdateGameSettingsMutation() 18 | const [checkboxValue, setCheckboxValue] = React.useState(props.value) 19 | const canConfigureSettings = currentPlayer.role === PlayerRole.Host 20 | 21 | React.useEffect(() => { 22 | setCheckboxValue(props.value) 23 | }, [props.value]) 24 | 25 | return ( 26 | { 33 | setCheckboxValue(checked) 34 | updateGameSettings({ 35 | variables: { 36 | id: currentGame.id, 37 | input: { screen_cards: checked }, 38 | }, 39 | }) 40 | }} 41 | /> 42 | } 43 | label={ 44 |
45 | 46 | {t("settings.cards.screen.label", "Allow host to screen cards")} 47 | 48 |
49 | {t("settings.cards.screen.helper", "e.g. for profanity")} 50 |
51 |
52 | } 53 | /> 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /app/src/pages/CardSubmission/SubmissionCard.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from "@material-ui/core" 2 | import BowlCard from "components/BowlCard" 3 | import { CurrentGameContext } from "contexts/CurrentGame" 4 | import { lowerCase } from "lodash" 5 | import * as React from "react" 6 | import { useTranslation } from "react-i18next" 7 | 8 | function SubmissionCard(props: { 9 | onChange: (value: string) => void 10 | word: string 11 | }) { 12 | const { t } = useTranslation() 13 | const currentGame = React.useContext(CurrentGameContext) 14 | 15 | const hasStartingLetterError = (word: string) => { 16 | return ( 17 | !!currentGame.starting_letter && 18 | word.length > 0 && 19 | word[0].toLocaleUpperCase() !== 20 | currentGame.starting_letter.toLocaleUpperCase() 21 | ) 22 | } 23 | 24 | const hasSimilarSubmissionError = (word: string) => { 25 | const submittedWords = currentGame.cards.map((card) => lowerCase(card.word)) 26 | return submittedWords.includes(lowerCase(word)) 27 | } 28 | 29 | return ( 30 | 31 | { 55 | props.onChange(value) 56 | }} 57 | /> 58 | 59 | ) 60 | } 61 | 62 | export default SubmissionCard 63 | -------------------------------------------------------------------------------- /app/src/pages/Pending/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Fab, Grid, Link } from "@material-ui/core" 2 | import SettingsIcon from "@material-ui/icons/Settings" 3 | import * as React from "react" 4 | import { Trans, useTranslation } from "react-i18next" 5 | import { Link as RouterLink } from "react-router-dom" 6 | import routes from "routes" 7 | 8 | function Pending(props: { joinCode: string }) { 9 | const { t } = useTranslation() 10 | return ( 11 | 12 | 13 | 14 | { 15 | "This is embarrassing, we cannot seem to figure out which player you are in game " 16 | } 17 | {{ joinCode: props.joinCode.toLocaleUpperCase() }}... 18 | 19 | 😳 20 | 21 | 22 | {t( 23 | "pending.explanation2", 24 | "Ask your host to click the settings button {{ settingsIcon }} in the bottom right corner of their page to send your unique join link so you can get back in it!", 25 | { settingsIcon: "⚙️" } 26 | )} 27 | 28 | 29 | 35 | 36 | {t("pending.settingsButtonExample", "It looks like this")} →{" "} 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {"If you meant to join a different game, "} 50 | 51 | return to the home page 52 | 53 | . 54 | 55 | 56 | 57 | ) 58 | } 59 | 60 | export default Pending 61 | -------------------------------------------------------------------------------- /app/src/ApolloWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloProvider } from "@apollo/react-hooks" 2 | import { InMemoryCache } from "apollo-cache-inmemory" 3 | import ApolloClient from "apollo-client" 4 | import { split } from "apollo-link" 5 | import { HttpLink } from "apollo-link-http" 6 | import { WebSocketLink } from "apollo-link-ws" 7 | import { getMainDefinition } from "apollo-utilities" 8 | import { 9 | AuthRole, 10 | CurrentAuthContext, 11 | CurrentAuthContextType, 12 | } from "contexts/CurrentAuth" 13 | import * as React from "react" 14 | 15 | const createApolloClient = (jwtToken: CurrentAuthContextType["jwtToken"]) => { 16 | const httpLink = new HttpLink({ 17 | uri: process.env.REACT_APP_FISHBOWL_GRAPHQL_ENDPOINT, 18 | credentials: "include", 19 | headers: jwtToken 20 | ? { 21 | Authorization: `Bearer ${jwtToken}`, 22 | "X-Hasura-Role": AuthRole.Player, 23 | } 24 | : undefined, 25 | }) 26 | 27 | const wsLink = new WebSocketLink({ 28 | uri: process.env.REACT_APP_FISHBOWL_WS_GRAPHQL_ENDPOINT || "", 29 | options: { 30 | lazy: true, 31 | reconnect: true, 32 | connectionParams: { 33 | headers: jwtToken 34 | ? { 35 | Authorization: `Bearer ${jwtToken}`, 36 | "X-Hasura-Role": AuthRole.Player, 37 | } 38 | : undefined, 39 | }, 40 | }, 41 | }) 42 | 43 | const link = split( 44 | ({ query }) => { 45 | const definition = getMainDefinition(query) 46 | return ( 47 | definition.kind === "OperationDefinition" && 48 | definition.operation === "subscription" 49 | ) 50 | }, 51 | wsLink, 52 | httpLink 53 | ) 54 | 55 | return new ApolloClient({ 56 | link: link, 57 | cache: new InMemoryCache(), 58 | connectToDevTools: true, 59 | }) 60 | } 61 | 62 | function ApolloWrapper(props: { children: React.ReactNode }) { 63 | const currentAuth = React.useContext(CurrentAuthContext) 64 | const client = createApolloClient(currentAuth.jwtToken) 65 | return {props.children} 66 | } 67 | 68 | export default ApolloWrapper 69 | -------------------------------------------------------------------------------- /app/src/pages/CardSubmission/index.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Typography } from "@material-ui/core" 2 | import { CurrentGameContext } from "contexts/CurrentGame" 3 | import { CurrentPlayerContext } from "contexts/CurrentPlayer" 4 | import { useTitleStyle } from "index" 5 | import { filter } from "lodash" 6 | import SubmissionForm from "pages/CardSubmission/SubmissionForm" 7 | import WaitingForSubmissions from "pages/CardSubmission/WaitingForSubmissions" 8 | import * as React from "react" 9 | 10 | export function Title(props: { text: string }) { 11 | const titleClasses = useTitleStyle() 12 | return ( 13 | 14 | {props.text} 15 | 16 | ) 17 | } 18 | 19 | enum CardSubmissionState { 20 | Submitting = 1, 21 | Waiting, 22 | } 23 | 24 | function CardSubmission() { 25 | const currentGame = React.useContext(CurrentGameContext) 26 | const currentPlayer = React.useContext(CurrentPlayerContext) 27 | 28 | const numSubmitted = filter( 29 | currentGame.cards, 30 | (card) => card.player_id === currentPlayer.id 31 | ).length 32 | 33 | const [cardSubmissionState, setCardSubmissionState] = React.useState< 34 | CardSubmissionState 35 | >( 36 | numSubmitted === currentGame.num_entries_per_player 37 | ? CardSubmissionState.Waiting 38 | : CardSubmissionState.Submitting 39 | ) 40 | 41 | React.useEffect(() => { 42 | if ( 43 | currentGame.num_entries_per_player && 44 | numSubmitted < currentGame.num_entries_per_player 45 | ) { 46 | setCardSubmissionState(CardSubmissionState.Submitting) 47 | } 48 | }, [numSubmitted]) 49 | 50 | return ( 51 | 58 | {cardSubmissionState === CardSubmissionState.Submitting ? ( 59 | setCardSubmissionState(CardSubmissionState.Waiting)} 61 | /> 62 | ) : ( 63 | 64 | )} 65 | 66 | ) 67 | } 68 | 69 | export default CardSubmission 70 | -------------------------------------------------------------------------------- /app/src/lib/team.ts: -------------------------------------------------------------------------------- 1 | import { CurrentGameSubscription } from "generated/graphql" 2 | import { cloneDeep, filter, find, remove, shuffle } from "lodash" 3 | 4 | export enum Team { 5 | Red = "red", 6 | Blue = "blue", 7 | } 8 | 9 | export const TeamColor = { 10 | [Team.Red]: "#f50057", 11 | [Team.Blue]: "#3f51b5", 12 | } 13 | 14 | function addTeamAndSequence(players: Players, team: Team) { 15 | return players.map((player: Player, index) => { 16 | return { ...player, team: team, team_sequence: index } 17 | }) 18 | } 19 | 20 | // [1,2,3,4,5,6].splice(0,Math.ceil(6/ 2)) 21 | // > [1, 2, 3] 22 | // [1,2,3,4,5,6].splice(Math.ceil(6/ 2), 6) 23 | // > [4, 5, 6] 24 | // [1,2,3,4,5,6,7].splice(0,Math.ceil(7/ 2)) 25 | // > [1, 2, 3, 4] (first half will always be equal or 1 longer) 26 | // [1,2,3,4,5,6,7].splice(Math.ceil(7/ 2), 7) 27 | // > [5, 6, 7] 28 | type Player = CurrentGameSubscription["games"][0]["players"][0] 29 | type Players = Array 30 | export function teamsWithSequence(players: Players) { 31 | const shuffledPlayers = shuffle(players) 32 | const halfLength = Math.ceil(shuffledPlayers.length / 2) 33 | const redTeam = addTeamAndSequence( 34 | cloneDeep(shuffledPlayers).splice(0, halfLength), 35 | Team.Red 36 | ) 37 | const blueTeam = addTeamAndSequence( 38 | cloneDeep(shuffledPlayers).splice(halfLength, shuffledPlayers.length), 39 | Team.Blue 40 | ) 41 | return redTeam.concat(blueTeam) 42 | } 43 | 44 | export function teamsWithSequenceWithUpdate( 45 | players: Players, 46 | updatedPlayer: Player 47 | ) { 48 | remove(players, (player) => player.id === updatedPlayer.id) 49 | players.push(updatedPlayer) 50 | const redTeam = addTeamAndSequence( 51 | filter(players, (player) => player.team === Team.Red), 52 | Team.Red 53 | ) 54 | const blueTeam = addTeamAndSequence( 55 | filter(players, (player) => player.team === Team.Blue), 56 | Team.Blue 57 | ) 58 | return redTeam.concat(blueTeam) 59 | } 60 | 61 | export function currentPlayerTeam( 62 | currentPlayerId: Player["id"], 63 | players: Players 64 | ): Team { 65 | return find(players, (player) => player.id === currentPlayerId)?.team as Team 66 | } 67 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/Inputs/UsernameInput.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, TextField } from "@material-ui/core" 2 | import { Players, useUpdatePlayerMutation } from "generated/graphql" 3 | import * as React from "react" 4 | import { useTranslation } from "react-i18next" 5 | 6 | function UsernameInput(props: { playerId: Players["id"]; username: string }) { 7 | const { t } = useTranslation() 8 | const [updatePlayer] = useUpdatePlayerMutation() 9 | const [value, setValue] = React.useState(props.username || "") 10 | const [savedName, setSavedName] = React.useState(value) 11 | 12 | return ( 13 |
{ 15 | event.preventDefault() 16 | const response = await updatePlayer({ 17 | variables: { 18 | id: props.playerId, 19 | input: { username: value }, 20 | }, 21 | }) 22 | if (response.data?.update_players_by_pk?.username) { 23 | setSavedName(value) 24 | } 25 | }} 26 | > 27 | 28 | 29 | { 36 | setValue(value) 37 | }} 38 | helperText={ 39 | 40 | {t("lobby.username.helper", "Emojis encouraged!")} 🌍🚀✨ 41 | 42 | } 43 | /> 44 | 45 | 46 | 56 | 57 | 58 |
59 | ) 60 | } 61 | 62 | export default UsernameInput 63 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/operations.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdateGameSettings($id: uuid!, $input: games_set_input!) { 2 | update_games_by_pk(pk_columns: { id: $id }, _set: $input) { 3 | id 4 | join_code 5 | starting_letter 6 | seconds_per_turn 7 | num_entries_per_player 8 | allow_card_skips 9 | screen_cards 10 | card_play_style 11 | } 12 | } 13 | 14 | mutation UpdatePlayer($id: uuid!, $input: players_set_input!) { 15 | update_players_by_pk(pk_columns: { id: $id }, _set: $input) { 16 | id 17 | username 18 | team 19 | team_sequence 20 | } 21 | } 22 | 23 | mutation RemovePlayer($id: uuid!) { 24 | delete_players_by_pk(id: $id) { 25 | id 26 | } 27 | } 28 | 29 | mutation DeleteRound( 30 | $id: uuid! 31 | $gameId: uuid! 32 | $rounds: [rounds_insert_input!]! 33 | ) { 34 | delete_rounds_by_pk(id: $id) { 35 | id 36 | } 37 | insert_games_one( 38 | object: { 39 | id: $gameId 40 | rounds: { 41 | data: $rounds 42 | on_conflict: { 43 | constraint: rounds_pkey 44 | update_columns: [order_sequence] 45 | } 46 | } 47 | } 48 | on_conflict: { constraint: games_pkey, update_columns: [id] } 49 | ) { 50 | id 51 | rounds { 52 | id 53 | order_sequence 54 | } 55 | } 56 | } 57 | 58 | mutation UpdateAllRounds($gameId: uuid!, $rounds: [rounds_insert_input!]!) { 59 | insert_games_one( 60 | object: { 61 | id: $gameId 62 | rounds: { 63 | data: $rounds 64 | on_conflict: { 65 | constraint: rounds_pkey 66 | update_columns: [order_sequence] 67 | } 68 | } 69 | } 70 | on_conflict: { constraint: games_pkey, update_columns: [id] } 71 | ) { 72 | id 73 | rounds { 74 | id 75 | order_sequence 76 | } 77 | } 78 | } 79 | 80 | mutation AddRound($object: rounds_insert_input!) { 81 | insert_rounds_one(object: $object) { 82 | id 83 | value 84 | order_sequence 85 | } 86 | } 87 | 88 | mutation LoadWords($objects: [cards_insert_input!]!) { 89 | insert_cards(objects: $objects) { 90 | returning { 91 | id 92 | } 93 | affected_rows 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /app/src/pages/Home/LanguagePicker.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Chip, 4 | IconButton, 5 | Link, 6 | makeStyles, 7 | Typography, 8 | } from "@material-ui/core" 9 | import AddCircleOutlineIcon from "@material-ui/icons/AddCircleOutline" 10 | import { SupportedLanguages } from "locales" 11 | import { languageNameFromCode } from "locales/functions" 12 | import * as React from "react" 13 | import { Trans, useTranslation } from "react-i18next" 14 | 15 | const useStyles = makeStyles((theme) => ({ 16 | root: { 17 | alignItems: "center", 18 | display: "flex", 19 | justifyContent: "center", 20 | listStyleType: "none", 21 | margin: `0 0 ${theme.spacing(1)}px`, 22 | padding: 0, 23 | "& > li": { 24 | marginLeft: theme.spacing(0.5), 25 | marginRight: theme.spacing(0.5), 26 | }, 27 | }, 28 | })) 29 | 30 | export const LanguagePicker = () => { 31 | const classes = useStyles() 32 | const { t, i18n } = useTranslation() 33 | 34 | return ( 35 | 36 |
    37 | {SupportedLanguages.map((code) => ( 38 |
  • 39 | { 44 | i18n.changeLanguage(code) 45 | }} 46 | /> 47 |
  • 48 | ))} 49 |
  • 50 | 56 | 57 | 58 |
  • 59 |
60 | 61 | 62 | {"Powered by our friends at "} 63 | 64 | {{ serviceName: "locize" }} 65 | 66 | . 67 | 68 | 69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | -------------------------------------------------------------------------------- /app/src/pages/Home/HowToPlay.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@material-ui/core" 2 | import { useTitleStyle } from "index" 3 | import { 4 | GameRoundDescription, 5 | GameRounds, 6 | } from "pages/Play/GameRoundInstructionCard" 7 | import * as React from "react" 8 | import { useTranslation } from "react-i18next" 9 | 10 | function HowToPlay() { 11 | const { t } = useTranslation() 12 | const titleClasses = useTitleStyle() 13 | return ( 14 | <> 15 | 16 | {t("howToPlay.heading", "How do I play?")} 17 | 18 | 19 | {t( 20 | "howToPlay.content1", 21 | 'It\'s easy! One person will "host" the game, and after everyone has joined in with a link or 4-character code, the game can start!' 22 | )} 23 | 24 | 25 | {t( 26 | "howToPlay.content2", 27 | 'First, everyone writes down a few words or phrases on "cards" that will be guessed later. The group will then randomly be split into two teams!' 28 | )} 29 | 30 | 31 | {t( 32 | "howToPlay.content3", 33 | "Players from each team will alternate turns, giving clues to their team to guess as many cards as possible against the clock. There'll be 3 rounds, and cards will get recycled after each round!" 34 | )} 35 | 36 | {GameRounds.map((round, index) => { 37 | const roundKey = round.toLowerCase() 38 | 39 | return ( 40 | 41 | 42 | {`${t("howToPlay.round.heading", "Round")} ${1 + index}: ${t( 43 | `howToPlay.round.${roundKey}.name`, 44 | round 45 | )}.`} 46 | {" "} 47 | {t( 48 | `howToPlay.round.${roundKey}.description`, 49 | GameRoundDescription[round] 50 | )} 51 | 52 | ) 53 | })} 54 | 55 | {t( 56 | "howToPlay.content4", 57 | "The team that guesses the most cards across all rounds wins!" 58 | )} 59 | 60 | 61 | ) 62 | } 63 | 64 | export default HowToPlay 65 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/ShareSection.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, TextField, Typography } from "@material-ui/core" 2 | import { green } from "@material-ui/core/colors" 3 | import { CurrentGameContext } from "contexts/CurrentGame" 4 | import * as React from "react" 5 | import Clipboard from "react-clipboard.js" 6 | import { useTranslation } from "react-i18next" 7 | 8 | function ShareSection() { 9 | const { t } = useTranslation() 10 | const currentGame = React.useContext(CurrentGameContext) 11 | const [copyButtonClicked, setCopyButtonClicked] = React.useState(false) 12 | 13 | React.useEffect(() => { 14 | let timeout: NodeJS.Timeout 15 | if (copyButtonClicked) { 16 | timeout = setTimeout(() => { 17 | setCopyButtonClicked(false) 18 | }, 1000) 19 | } 20 | return () => timeout && clearTimeout(timeout) 21 | }, [copyButtonClicked]) 22 | 23 | return ( 24 | 25 | {t("lobby.shareGame.linkLabel", "Share your link with everyone playing")} 26 | 27 | 28 | 39 | 40 | 41 | 45 | 57 | 58 | 59 | 60 | {t("lobby.shareGame.codeLabel", "Or the code")} 61 | {currentGame.join_code} 62 | 63 | ) 64 | } 65 | 66 | export default ShareSection 67 | -------------------------------------------------------------------------------- /app/src/components/ScreenCard.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, IconButton } from "@material-ui/core" 2 | import { green } from "@material-ui/core/colors" 3 | import CancelIcon from "@material-ui/icons/Cancel" 4 | import CheckCircleIcon from "@material-ui/icons/CheckCircle" 5 | import BowlCard from "components/BowlCard" 6 | import PlayerChip from "components/PlayerChip" 7 | import { CurrentGameContext } from "contexts/CurrentGame" 8 | import { 9 | CurrentGameSubscription, 10 | useAcceptCardMutation, 11 | useRejectCardMutation, 12 | } from "generated/graphql" 13 | import * as React from "react" 14 | 15 | function ScreenCard(props: { 16 | card: CurrentGameSubscription["games"][0]["cards"][0] 17 | }) { 18 | const currentGame = React.useContext(CurrentGameContext) 19 | const [acceptCard] = useAcceptCardMutation() 20 | const [rejectCard] = useRejectCardMutation() 21 | 22 | const player = currentGame.players.find( 23 | (player) => player.id === props.card.player_id 24 | ) 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | {props.card.word} 34 | 35 | 36 | { 39 | rejectCard({ 40 | variables: { 41 | id: props.card.id, 42 | }, 43 | }) 44 | }} 45 | > 46 | 47 | 48 | 49 | 50 | { 53 | acceptCard({ 54 | variables: { 55 | id: props.card.id, 56 | }, 57 | }) 58 | }} 59 | > 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ) 68 | } 69 | 70 | export default ScreenCard 71 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | services: 3 | fishbowl-postgres: 4 | image: postgres:11 5 | container_name: fishbowl-postgres 6 | ports: 7 | - 5432:5432 8 | restart: always 9 | volumes: 10 | - db_data:/var/lib/postgresql/data 11 | environment: 12 | POSTGRES_DB: postgres 13 | POSTGRES_USER: postgres 14 | POSTGRES_PASSWORD: password 15 | healthcheck: 16 | test: "pg_isready -U postgres" 17 | interval: 10s 18 | timeout: 5s 19 | retries: 5 20 | fishbowl-actions-server: 21 | build: ./actions-server 22 | image: fishbowl/actions-server:latest 23 | container_name: fishbowl-actions-server 24 | ports: 25 | - 3001:3001 26 | restart: always 27 | entrypoint: yarn 28 | command: run start-debug 29 | volumes: 30 | - ./actions-server:/usr/app 31 | - /usr/app/node_modules 32 | fishbowl-graphql-engine: 33 | image: hasura/graphql-engine:v1.3.3.cli-migrations-v2 34 | container_name: fishbowl-graphql-engine 35 | ports: 36 | - 8080:8080 37 | depends_on: 38 | - fishbowl-postgres 39 | - fishbowl-actions-server 40 | restart: always 41 | volumes: 42 | - ./graphql-server/migrations:/hasura-migrations 43 | - ./graphql-server/metadata:/hasura-metadata 44 | environment: 45 | HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:password@fishbowl-postgres:5432/postgres 46 | HASURA_GRAPHQL_ENABLE_CONSOLE: "true" # set to "false" to disable console 47 | HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup, http-log, webhook-log, websocket-log, query-log 48 | HASURA_GRAPHQL_ADMIN_SECRET: myadminsecretkey 49 | HASURA_GRAPHQL_UNAUTHORIZED_ROLE: anonymous 50 | ACTION_BASE_ENDPOINT: http://fishbowl-actions-server:3001 51 | HASURA_GRAPHQL_JWT_SECRET: '{"type":"HS256", "key": "FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE"}' 52 | healthcheck: 53 | test: "wget --quiet --spider http://localhost:8080/healthz || exit 1" 54 | interval: 30s 55 | timeout: 3s 56 | fishbowl-app: 57 | build: ./app 58 | image: fishbowl/app:latest 59 | container_name: fishbowl-app 60 | stdin_open: true 61 | ports: 62 | - 3000:3000 63 | depends_on: 64 | - fishbowl-graphql-engine 65 | restart: always 66 | volumes: 67 | - ./app:/usr/app 68 | - /usr/app/node_modules 69 | volumes: 70 | db_data: 71 | -------------------------------------------------------------------------------- /app/src/pages/Play/GameRoundInstructionCard.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Paper, styled, Typography } from "@material-ui/core" 2 | import { useTitleStyle } from "index" 3 | import * as React from "react" 4 | import { useTranslation } from "react-i18next" 5 | 6 | export enum GameRound { 7 | Taboo = "Taboo", 8 | Charades = "Charades", 9 | Password = "Password", 10 | } 11 | 12 | export const GameRounds = [ 13 | GameRound.Taboo, 14 | GameRound.Charades, 15 | GameRound.Password, 16 | ] 17 | 18 | export const GameRoundDescription: Record = { 19 | [GameRound.Taboo]: 20 | "Use words to describe the word or phrase on the card, without any acting or gestures. You cannot use any part of the word or phrase!", 21 | [GameRound.Charades]: 22 | "Without words or sounds, act and use gestures to communicate the word or phrase on the card.", 23 | [GameRound.Password]: 24 | "You can say exactly one word to describe the word or phrase on the card, no more! You'll rely on your team's memory and association.", 25 | } 26 | 27 | function GameRoundInstructionCard(props: { 28 | round: GameRound | string 29 | roundNumber: number 30 | onDismiss: () => void 31 | }) { 32 | const { t } = useTranslation() 33 | const titleClasses = useTitleStyle() 34 | let description = GameRoundDescription[props.round as GameRound] 35 | 36 | if (description) { 37 | description = t( 38 | `howToPlay.round.${props.round.toLowerCase()}.description`, 39 | description 40 | ) 41 | } else { 42 | description = t( 43 | "howToPlay.round.customDescription", 44 | "Your host will give you the rules for this one!" 45 | ) 46 | } 47 | 48 | return ( 49 | 50 | 51 | 52 | {`${t("howToPlay.round.heading", "Round")} ${props.roundNumber}: ${ 53 | props.round 54 | }`} 55 | 56 | 57 | {description} 58 | 59 | 68 | 69 | 70 | ) 71 | } 72 | 73 | const StyledPaper = styled(Paper)({ 74 | minWidth: 280, 75 | }) 76 | 77 | export default GameRoundInstructionCard 78 | -------------------------------------------------------------------------------- /app/src/components/Fishbowl/style.css: -------------------------------------------------------------------------------- 1 | .bowl { 2 | width: 300px; 3 | height: 300px; 4 | margin: auto; 5 | background-color: rgba(225, 255, 255, 0.1); 6 | border-radius: 50%; 7 | border: 4px solid black; 8 | transform-origin: bottom center; 9 | animation: animateBowl 5s linear infinite; 10 | } 11 | 12 | .bowlTop { 13 | position: relative; 14 | transform-origin: top center; 15 | height: 32px; 16 | width: 120px; 17 | background-color: #fafafa; 18 | border: 4px solid black; 19 | border-radius: 50%; 20 | top: -10px; 21 | left: 86px; 22 | } 23 | 24 | .water { 25 | position: relative; 26 | top: 40%; 27 | left: 1%; 28 | background-color: rgb(129, 209, 247); 29 | border-bottom-right-radius: 158px; 30 | border-bottom-left-radius: 158px; 31 | transform-origin: top center; 32 | animation: animateWater 5s linear infinite; 33 | height: 140px; 34 | width: 286px; 35 | } 36 | 37 | .waterTop { 38 | position: relative; 39 | top: -10px; 40 | width: 100%; 41 | height: 20px; 42 | border-radius: 50%; 43 | background-color: #1fa4e0; 44 | } 45 | 46 | .shadow { 47 | position: relative; 48 | top: -15px; 49 | width: 300px; 50 | height: 30px; 51 | margin: auto; 52 | background-color: rgba(0, 0, 0, 0.4); 53 | z-index: -1; 54 | border-radius: 50%; 55 | } 56 | 57 | img.fish { 58 | width: 100px; 59 | animation: animateFish 5s linear infinite; 60 | } 61 | 62 | @keyframes animateBowl { 63 | 0% { 64 | transform: rotate(0deg); 65 | } 66 | 25% { 67 | transform: rotate(15deg); 68 | } 69 | 50% { 70 | transform: rotate(0deg); 71 | } 72 | 75% { 73 | transform: rotate(-15deg); 74 | } 75 | 100% { 76 | transform: rotate(0deg); 77 | } 78 | } 79 | 80 | @keyframes animateWater { 81 | 0% { 82 | transform: rotate(0deg); 83 | } 84 | 25% { 85 | transform: rotate(-20deg); 86 | } 87 | 50% { 88 | transform: rotate(0deg); 89 | } 90 | 75% { 91 | transform: rotate(20deg); 92 | } 93 | 100% { 94 | transform: rotate(0deg); 95 | } 96 | } 97 | 98 | @keyframes animateFish { 99 | 0% { 100 | transform: translate(50px, 0) rotateY(180deg) rotateZ(-35deg); 101 | } 102 | 50% { 103 | transform: translate(150px, 0) rotateY(180deg) rotateZ(-35deg); 104 | } 105 | 51% { 106 | transform: translate(150px, 0) rotateY(0deg) rotateZ(-35deg); 107 | } 108 | 100% { 109 | transform: translate(50px, 0) rotateY(0deg) rotateZ(-35deg); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/src/hooks/useServerTimeOffset.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@apollo/react-hooks" 2 | import { ServerTimeDocument, ServerTimeQuery } from "generated/graphql" 3 | import { compact } from "lodash" 4 | import { mean, median, std } from "mathjs" 5 | import { useEffect, useState } from "react" 6 | 7 | const DELAY = 1000 // delay in milliseconds between each request 8 | const REPEAT = 5 // number of server requests to sample 9 | 10 | interface Sample { 11 | roundtrip: number 12 | offset: number 13 | } 14 | 15 | /** 16 | * Return the approximate client offset from the server time in milliseconds. 17 | * 18 | * @see https://github.com/enmasseio/timesync#algorithm 19 | */ 20 | export default function useServerTimeOffset(): number { 21 | const [offset, setOffset] = useState(0) 22 | const { refetch: fetchServerTime } = useQuery( 23 | ServerTimeDocument, 24 | { 25 | skip: true, 26 | } 27 | ) 28 | 29 | useEffect(() => { 30 | let first = true 31 | const samples: Array = [] 32 | 33 | const sample: () => Promise = async () => { 34 | try { 35 | const start = Date.now() 36 | const { data } = await fetchServerTime() 37 | 38 | if (!data?.server_time?.length) { 39 | throw new Error("Unexpected server time response") 40 | } 41 | 42 | const end = Date.now() 43 | const roundtrip = end - start 44 | const offset = 45 | new Date(data.server_time[0].now).getTime() - end + roundtrip / 2 // offset from local system time 46 | 47 | if (first) { 48 | first = false 49 | setOffset(offset) // apply the first ever retrieved offset immediately 50 | } 51 | 52 | return { roundtrip, offset } 53 | } catch (e) { 54 | return null // ignore failed requests 55 | } 56 | } 57 | 58 | const computeOffset = async () => { 59 | samples.push(await sample()) 60 | 61 | if (samples.length < REPEAT) { 62 | setTimeout(computeOffset, DELAY) 63 | 64 | return 65 | } 66 | 67 | // calculate the limit for outliers 68 | const roundtrips = compact(samples).map((sample) => sample.roundtrip) 69 | 70 | if (!roundtrips.length) { 71 | return 72 | } 73 | 74 | const limit = median(roundtrips) + std(roundtrips) 75 | const offsets = compact(samples) 76 | .filter((sample) => sample.roundtrip < limit) // exclude long request outliers 77 | .map((sample) => sample.offset) 78 | 79 | if (offsets.length) { 80 | setOffset(mean(offsets)) 81 | } 82 | } 83 | 84 | computeOffset() 85 | }, [fetchServerTime]) 86 | 87 | return offset 88 | } 89 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/react-hooks": "^3.1.3", 7 | "@material-ui/core": "^4.9.7", 8 | "@material-ui/icons": "^4.9.1", 9 | "@sentry/browser": "^5.15.5", 10 | "apollo-cache-inmemory": "^1.6.5", 11 | "apollo-client": "^2.6.8", 12 | "apollo-link-http": "^1.5.16", 13 | "apollo-link-ws": "^1.0.19", 14 | "array-move": "^2.2.1", 15 | "dotenv": "^8.2.0", 16 | "graphql": "^14.6.0", 17 | "i18next": "^19.9.0", 18 | "i18next-browser-languagedetector": "^6.0.1", 19 | "i18next-locize-backend": "^4.1.9", 20 | "locize": "^2.2.4", 21 | "lodash": "^4.17.15", 22 | "mathjs": "^8.1.0", 23 | "query-string": "^6.12.1", 24 | "react": "^16.13.1", 25 | "react-apollo": "^3.1.3", 26 | "react-clipboard.js": "^2.0.16", 27 | "react-device-detect": "^1.12.0", 28 | "react-dom": "^16.13.1", 29 | "react-i18next": "^11.8.5", 30 | "react-router-dom": "^5.1.2", 31 | "react-scripts": "3.4.1", 32 | "react-share": "^4.1.0", 33 | "subscriptions-transport-ws": "^0.9.16", 34 | "typescript": "~3.7.2", 35 | "use-sound": "^1.0.2", 36 | "uuid": "^7.0.2" 37 | }, 38 | "scripts": { 39 | "start": "react-scripts start", 40 | "build": "react-scripts build", 41 | "eject": "react-scripts eject", 42 | "gql-gen": "graphql-codegen --config codegen.js --require dotenv/config", 43 | "lint": "eslint --ext ts,tsx src && yarn run prettier && tsc --noEmit", 44 | "prettier": "prettier --config ../.prettierrc --check \"src/{,!(generated)/**/}*.{js,ts,jsx,tsx}\"" 45 | }, 46 | "eslintConfig": { 47 | "extends": "react-app" 48 | }, 49 | "browserslist": { 50 | "production": [ 51 | ">0.2%", 52 | "not dead", 53 | "not op_mini all" 54 | ], 55 | "development": [ 56 | "last 1 chrome version", 57 | "last 1 firefox version", 58 | "last 1 safari version" 59 | ] 60 | }, 61 | "devDependencies": { 62 | "@babel/plugin-transform-typescript": "^7.9.4", 63 | "@graphql-codegen/cli": "^1.13.1", 64 | "@graphql-codegen/introspection": "^1.13.1", 65 | "@graphql-codegen/typescript": "^1.13.1", 66 | "@graphql-codegen/typescript-operations": "^1.13.1", 67 | "@graphql-codegen/typescript-react-apollo": "^1.13.1", 68 | "@types/array-move": "^2.0.0", 69 | "@types/lodash": "^4.14.149", 70 | "@types/mathjs": "^6.0.9", 71 | "@types/node": "^12.0.0", 72 | "@types/query-string": "^6.3.0", 73 | "@types/react": "^16.9.0", 74 | "@types/react-dom": "^16.9.0", 75 | "@types/react-router-dom": "^5.1.3", 76 | "@types/uuid": "^7.0.2", 77 | "prettier": "^2.0.5" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/SettingsSummary.tsx: -------------------------------------------------------------------------------- 1 | import { CurrentGameContext } from "contexts/CurrentGame" 2 | import { GameCardPlayStyleEnum } from "generated/graphql" 3 | import { startCase } from "lodash" 4 | import * as React from "react" 5 | import { useTranslation } from "react-i18next" 6 | 7 | function SettingsSummary() { 8 | const { t } = useTranslation() 9 | const currentGame = React.useContext(CurrentGameContext) 10 | let cardPlayStyle = "" 11 | switch (currentGame.card_play_style) { 12 | case GameCardPlayStyleEnum.PlayersSubmitWords: 13 | const count = Number(currentGame.num_entries_per_player) 14 | const startingLetter = currentGame.starting_letter 15 | const submitWord = "Players will submit {{ count }} word" 16 | const startingWith = 'starting with the letter "{{ startingLetter }}"' 17 | 18 | if (startingLetter) { 19 | cardPlayStyle = t( 20 | "settings.summary.cardStyle.playersSubmitLetter", 21 | `${submitWord}, ${startingWith}.`, 22 | { 23 | count, 24 | startingLetter, 25 | defaultValue_plural: `${submitWord}s, all ${startingWith}.`, 26 | } 27 | ) 28 | } else { 29 | cardPlayStyle = t( 30 | "settings.summary.cardStyle.playersSubmit", 31 | `${submitWord}.`, 32 | { 33 | count, 34 | defaultValue_plural: `${submitWord}s.`, 35 | } 36 | ) 37 | } 38 | 39 | if (currentGame.screen_cards) { 40 | cardPlayStyle += ` ${t( 41 | "settings.summary.cardStyle.hostScreens", 42 | "The host will be able to screen words." 43 | )}` 44 | } 45 | break 46 | case GameCardPlayStyleEnum.HostProvidesWords: 47 | cardPlayStyle = t( 48 | "settings.summary.cardStyle.hostProvides", 49 | "The host will provide the words." 50 | ) 51 | break 52 | } 53 | 54 | return ( 55 | <> 56 | {cardPlayStyle} 57 |
58 |
59 | {t( 60 | "settings.summary.turnSeconds", 61 | "Turns will last {{ seconds }} seconds across rounds of {{ rounds }}.", 62 | { 63 | seconds: currentGame.seconds_per_turn, 64 | rounds: currentGame.rounds 65 | .map((round) => startCase(round.value)) 66 | .join(", "), 67 | } 68 | )}{" "} 69 | {currentGame.allow_card_skips 70 | ? t( 71 | "settings.summary.cardSkips", 72 | "Players can skip cards during their turn." 73 | ) 74 | : t( 75 | "settings.summary.noCardSkips", 76 | "Players cannot skip cards during their turn." 77 | )} 78 | 79 | ) 80 | } 81 | 82 | export default SettingsSummary 83 | -------------------------------------------------------------------------------- /app/src/pages/Play/TurnContextPanel.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | createStyles, 4 | Divider, 5 | Grid, 6 | makeStyles, 7 | Theme, 8 | } from "@material-ui/core" 9 | import { CurrentGameContext } from "contexts/CurrentGame" 10 | import { teamScore } from "lib/score" 11 | import { Team, TeamColor } from "lib/team" 12 | import { drawableCards } from "lib/turn" 13 | import * as React from "react" 14 | import { useTranslation } from "react-i18next" 15 | 16 | function CardsLeftItem() { 17 | const { t } = useTranslation() 18 | const currentGame = React.useContext(CurrentGameContext) 19 | const numCardsLeft = drawableCards(currentGame.turns, currentGame.cards) 20 | .length 21 | return ( 22 | 23 | {numCardsLeft} 24 | {t("play.turnContext.cards", "cards")} 25 | 26 | ) 27 | } 28 | 29 | function ScoreCardItem() { 30 | const { t } = useTranslation() 31 | const currentGame = React.useContext(CurrentGameContext) 32 | 33 | return ( 34 | 35 | 36 | { 37 | 38 | {teamScore(Team.Red, currentGame.turns, currentGame.players)} 39 | 40 | } 41 | {" - "} 42 | { 43 | 44 | {teamScore(Team.Blue, currentGame.turns, currentGame.players)} 45 | 46 | } 47 | 48 | {t("play.turnContext.score", "score")} 49 | 50 | ) 51 | } 52 | 53 | function CountdownTimerItem(props: { secondsLeft: number }) { 54 | const { t } = useTranslation() 55 | return ( 56 | 57 | 58 | {props.secondsLeft} 59 | 60 | {t("play.turnContext.seconds", "seconds")} 61 | 62 | ) 63 | } 64 | 65 | const useStyles = makeStyles((theme: Theme) => 66 | createStyles({ 67 | root: { 68 | // width: "fit-content", 69 | border: `1px solid ${theme.palette.divider}`, 70 | borderRadius: theme.shape.borderRadius, 71 | }, 72 | }) 73 | ) 74 | 75 | function TurnContextPanel(props: { secondsLeft: number }) { 76 | const classes = useStyles() 77 | 78 | return ( 79 | 80 | 88 | 89 | 90 | 91 | 92 | 93 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | ) 104 | } 105 | 106 | export default TurnContextPanel 107 | -------------------------------------------------------------------------------- /actions-server/src/handlers/joinGame.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express" 2 | import jwt from "jsonwebtoken" 3 | import { graphQLClient } from "../graphQLClient" 4 | 5 | // Request Handler 6 | const handler = async (req: Request, res: Response) => { 7 | if (!process.env.HASURA_GRAPHQL_JWT_SECRET) { 8 | return res.status(500) 9 | } 10 | 11 | const client = graphQLClient() 12 | 13 | // get request input 14 | const { gameId, clientUuid } = req.body.input 15 | 16 | // execute the Hasura operation(s) 17 | let playerId 18 | let hostExists = true 19 | try { 20 | const { data, errors } = await client.LookupPlayerForGame({ 21 | gameId, 22 | clientUuid, 23 | }) 24 | if (data?.players[0]) { 25 | // already joined game 26 | playerId = data.players[0].id 27 | console.log( 28 | `Player (id: ${playerId}, client_uuid: ${clientUuid}) has already joined game (id: ${gameId})` 29 | ) 30 | hostExists = Boolean(data.players[0].game.host_id) 31 | } else { 32 | // new player for game 33 | try { 34 | const { data: data_insert, errors } = await client.InsertPlayerForGame({ 35 | gameId, 36 | clientUuid, 37 | }) 38 | if (data_insert?.insert_players_one) { 39 | playerId = data_insert.insert_players_one.id 40 | hostExists = Boolean(data_insert.insert_players_one.game.host_id) 41 | } 42 | console.log( 43 | `Player (id: ${playerId}, client_uuid: ${clientUuid}) joined game (id: ${gameId})` 44 | ) 45 | } catch (e) { 46 | console.log( 47 | `Player (id: ${playerId}, client_uuid: ${clientUuid}) failed to joined game (id: ${gameId});` 48 | ) 49 | console.log(e) 50 | return res.status(400).json({ success: false, errors }) 51 | } 52 | } 53 | 54 | if (!playerId || errors) { 55 | return res.status(500).json({ success: false, errors }) 56 | } 57 | 58 | const tokenContents = { 59 | sub: playerId.toString(), 60 | iat: Date.now() / 1000, 61 | iss: "https://fishbowl-game.com/", 62 | "https://hasura.io/jwt/claims": { 63 | "x-hasura-allowed-roles": ["player", "anonymous"], 64 | "x-hasura-user-id": playerId.toString(), 65 | "x-hasura-default-role": "player", 66 | "x-hasura-role": "player", 67 | }, 68 | exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60, 69 | } 70 | 71 | const token = jwt.sign( 72 | tokenContents, 73 | process.env.HASURA_GRAPHQL_JWT_SECRET || "missing secret" 74 | ) 75 | 76 | if (!hostExists) { 77 | const { errors } = await graphQLClient({ jwt: token }).BecomeHost({ 78 | gameId, 79 | playerId, 80 | }) 81 | if (errors) { 82 | console.log( 83 | `Player (id: ${playerId}, client_uuid: ${clientUuid}) in game (id: ${gameId}) failed to become the HOST; ${errors.join( 84 | "," 85 | )}` 86 | ) 87 | } else { 88 | console.log( 89 | `Player (id: ${playerId}, client_uuid: ${clientUuid}) in game (id: ${gameId}) became the HOST` 90 | ) 91 | } 92 | } 93 | 94 | console.log( 95 | `Player (id: ${playerId}, client_uuid: ${clientUuid}) in game (id: ${gameId}) was issued a token` 96 | ) 97 | return res.status(200).json({ 98 | id: playerId.toString(), 99 | jwt_token: token, 100 | }) 101 | } catch (e) { 102 | console.log(e) 103 | return res.status(400) 104 | } 105 | } 106 | 107 | module.exports = handler 108 | -------------------------------------------------------------------------------- /app/src/lib/turn.ts: -------------------------------------------------------------------------------- 1 | import { CurrentGameSubscription } from "generated/graphql" 2 | import { Team } from "lib/team" 3 | import { 4 | countBy, 5 | difference, 6 | filter, 7 | findLast, 8 | flatMap, 9 | groupBy, 10 | max, 11 | reject, 12 | sortBy, 13 | values, 14 | } from "lodash" 15 | 16 | export enum ActiveTurnPlayState { 17 | Waiting = 1, 18 | Playing, 19 | Reviewing, 20 | } 21 | 22 | export function nextPlayerForSameTeam( 23 | activePlayer: CurrentGameSubscription["games"][0]["players"][0], 24 | players: CurrentGameSubscription["games"][0]["players"] 25 | ) { 26 | const sameTeamPlayers = filter( 27 | players, 28 | (player) => activePlayer.team === player.team 29 | ) 30 | const sameTeamPlayersSortedBySequence = sortBy(sameTeamPlayers, [ 31 | "team_sequence", 32 | ]) 33 | const nextPositionInSequence = 34 | ((activePlayer.team_sequence || 0) + 1) % 35 | sameTeamPlayersSortedBySequence.length 36 | return sameTeamPlayersSortedBySequence[nextPositionInSequence] 37 | } 38 | 39 | export function nextPlayerForNextTeam( 40 | activePlayer: CurrentGameSubscription["games"][0]["players"][0] | null, 41 | turns: CurrentGameSubscription["games"][0]["turns"], 42 | players: CurrentGameSubscription["games"][0]["players"] 43 | ) { 44 | if (!activePlayer) { 45 | return sortBy(players, ["team_sequence"])[0] 46 | } 47 | const lastTeamToPlay = activePlayer.team 48 | const nextTeamToPlay = lastTeamToPlay === Team.Blue ? Team.Red : Team.Blue 49 | const nextTeamToPlayPlayers = filter( 50 | players, 51 | (player) => player.team === nextTeamToPlay 52 | ) 53 | const lastTurnFromNextTeamToPlay = findLast(turns, (turn) => 54 | nextTeamToPlayPlayers.map((player) => player.id).includes(turn.player_id) 55 | ) 56 | const lastPlayerFromNextTeamToPlay = lastTurnFromNextTeamToPlay 57 | ? players.find( 58 | (player) => player.id === lastTurnFromNextTeamToPlay.player_id 59 | ) 60 | : null 61 | 62 | const nextTeamPlayersSortedBySequence = sortBy(nextTeamToPlayPlayers, [ 63 | "team_sequence", 64 | ]) 65 | const nextPlayerFromNextTeamToPlay = lastPlayerFromNextTeamToPlay 66 | ? nextTeamPlayersSortedBySequence[ 67 | ((lastPlayerFromNextTeamToPlay.team_sequence || 0) + 1) % 68 | nextTeamToPlayPlayers.length 69 | ] 70 | : nextTeamPlayersSortedBySequence[0] 71 | 72 | return nextPlayerFromNextTeamToPlay 73 | } 74 | 75 | export function completedCardIds( 76 | turns: CurrentGameSubscription["games"][0]["turns"] 77 | ) { 78 | return flatMap(turns, (turn) => turn.completed_card_ids) 79 | } 80 | 81 | export function drawableCards( 82 | turns: CurrentGameSubscription["games"][0]["turns"], 83 | cards: CurrentGameSubscription["games"][0]["cards"] 84 | ) { 85 | const allCompletedCardIds = flatMap(turns, (turn) => turn.completed_card_ids) 86 | 87 | const maxCount = max(values(countBy(allCompletedCardIds))) 88 | 89 | let completedCardIdsForRound = filter( 90 | groupBy(allCompletedCardIds), 91 | (arr) => arr.length === maxCount 92 | ).map((arr) => arr[0]) 93 | 94 | const remainingIdsForRound = difference( 95 | cards.map((card) => card.id), 96 | completedCardIdsForRound 97 | ) 98 | 99 | if (remainingIdsForRound.length === 0) { 100 | return cards 101 | } else { 102 | return filter(cards, (card) => remainingIdsForRound.includes(card.id)) 103 | } 104 | } 105 | 106 | export function drawableCardsWithoutCompletedCardsInActiveTurn( 107 | cards: CurrentGameSubscription["games"][0]["cards"], 108 | completedCardIdsInActiveTurn: Array 109 | ) { 110 | return reject(cards, (card) => completedCardIdsInActiveTurn.includes(card.id)) 111 | } 112 | -------------------------------------------------------------------------------- /app/src/components/PlayerArena.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, makeStyles, Paper, Theme } from "@material-ui/core" 2 | import PlayerChip from "components/PlayerChip" 3 | import { CurrentGameContext } from "contexts/CurrentGame" 4 | import { CurrentPlayerContext } from "contexts/CurrentPlayer" 5 | import { 6 | Players, 7 | useRemovePlayerMutation, 8 | useUpdateAllPlayersMutation, 9 | } from "generated/graphql" 10 | import { Team, teamsWithSequenceWithUpdate } from "lib/team" 11 | import * as React from "react" 12 | import { useTranslation } from "react-i18next" 13 | 14 | const useStyles = makeStyles((theme: Theme) => 15 | createStyles({ 16 | playerList: { 17 | minHeight: "200px", 18 | maxHeight: "300px", 19 | padding: "10px", 20 | overflow: "auto", 21 | "& > *": { 22 | margin: theme.spacing(0.5), 23 | }, 24 | }, 25 | }) 26 | ) 27 | 28 | function PlayerArena(props: { 29 | players: Array<{ 30 | id: Players["id"] 31 | username?: string | null | undefined 32 | team?: string | null | undefined 33 | }> 34 | hostCanRemovePlayer?: boolean 35 | hostCanSwitchTeams?: boolean 36 | }) { 37 | const { t } = useTranslation() 38 | const currentPlayer = React.useContext(CurrentPlayerContext) 39 | const currentGame = React.useContext(CurrentGameContext) 40 | const classes = useStyles() 41 | const [removePlayer] = useRemovePlayerMutation() 42 | const [updateAllPlayers] = useUpdateAllPlayersMutation() 43 | 44 | return ( 45 | 46 | {props.players.map((player) => { 47 | return ( 48 | player.username && ( 49 | { 53 | const updatedPlayer = currentGame.players.find( 54 | (p) => p.id === player.id 55 | ) 56 | if (updatedPlayer) { 57 | updatedPlayer.team = 58 | updatedPlayer?.team === Team.Red 59 | ? Team.Blue 60 | : Team.Red 61 | 62 | const players = teamsWithSequenceWithUpdate( 63 | currentGame.players, 64 | updatedPlayer 65 | ) 66 | updateAllPlayers({ 67 | variables: { 68 | gameId: currentGame.id, 69 | players: players.map( 70 | ({ id, team, team_sequence }) => ({ 71 | id, 72 | team, 73 | team_sequence, 74 | }) 75 | ), 76 | }, 77 | }) 78 | } 79 | } 80 | : undefined 81 | } 82 | key={player.id} 83 | username={player.username} 84 | team={player.team} 85 | handleDelete={ 86 | props.hostCanRemovePlayer && player.id !== currentPlayer.id 87 | ? () => { 88 | if ( 89 | window.confirm( 90 | t( 91 | "lobby.deletePlayerConfirmation", 92 | "Are you sure you want to delete this player?" 93 | ) 94 | ) 95 | ) { 96 | removePlayer({ 97 | variables: { 98 | id: player.id, 99 | }, 100 | }) 101 | } 102 | } 103 | : undefined 104 | } 105 | > 106 | ) 107 | ) 108 | })} 109 | 110 | ) 111 | } 112 | 113 | export default PlayerArena 114 | -------------------------------------------------------------------------------- /app/src/pages/CardSubmission/SubmissionForm.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid } from "@material-ui/core" 2 | import { CurrentGameContext } from "contexts/CurrentGame" 3 | import { CurrentPlayerContext } from "contexts/CurrentPlayer" 4 | import { useSubmitCardsMutation } from "generated/graphql" 5 | import { cloneDeep, filter, startCase } from "lodash" 6 | import { Title } from "pages/CardSubmission" 7 | import SubmissionCard from "pages/CardSubmission/SubmissionCard" 8 | import * as React from "react" 9 | import { Trans, useTranslation } from "react-i18next" 10 | 11 | function SubmissionForm(props: { onSubmit: () => void }) { 12 | const { t } = useTranslation() 13 | const currentPlayer = React.useContext(CurrentPlayerContext) 14 | const currentGame = React.useContext(CurrentGameContext) 15 | const [submitCards, { called }] = useSubmitCardsMutation() 16 | 17 | const numSubmitted = filter( 18 | currentGame.cards, 19 | (card) => card.player_id === currentPlayer.id 20 | ).length 21 | 22 | const numToSubmit = 23 | (currentGame.num_entries_per_player && 24 | currentGame.num_entries_per_player - numSubmitted) || 25 | 0 26 | 27 | const [words, setWords] = React.useState>( 28 | Array.from( 29 | { 30 | length: numToSubmit, 31 | }, 32 | () => "" 33 | ) 34 | ) 35 | 36 | React.useEffect(() => { 37 | setWords( 38 | Array.from( 39 | { 40 | length: numToSubmit, 41 | }, 42 | () => "" 43 | ) 44 | ) 45 | }, [numToSubmit]) 46 | 47 | const emptyWords = words.some((word) => word.length < 1) 48 | 49 | return ( 50 | <> 51 | 52 | 58 | </Grid> 59 | 60 | <Grid item> 61 | {t( 62 | "cardSubmission.description", 63 | 'These cards will be put into the "fishbowl," and drawn randomly in rounds of {{ rounds }}. They can be words, familiar phrases, or inside jokes!', 64 | { 65 | rounds: currentGame.rounds 66 | .map((round) => startCase(round.value)) 67 | .join(", "), 68 | } 69 | )} 70 | </Grid> 71 | 72 | {currentGame.starting_letter && ( 73 | <Grid item> 74 | <Trans t={t} i18nKey="cardSubmission.descriptionLetter"> 75 | {"They must start with the letter "} 76 | <b>{{ letter: currentGame.starting_letter.toLocaleUpperCase() }}</b> 77 | . 78 | </Trans> 79 | </Grid> 80 | )} 81 | 82 | <Grid item container direction="column" spacing={2} alignItems="center"> 83 | {words.map((_, index) => { 84 | return ( 85 | <Grid item key={index}> 86 | <SubmissionCard 87 | onChange={(value: string) => { 88 | const newWords = cloneDeep(words) 89 | newWords[index] = value 90 | setWords(newWords) 91 | }} 92 | word={words[index]} 93 | /> 94 | </Grid> 95 | ) 96 | })} 97 | </Grid> 98 | 99 | <Grid item> 100 | <Button 101 | variant="contained" 102 | color="primary" 103 | size="large" 104 | disabled={called || emptyWords} 105 | onClick={async () => { 106 | await submitCards({ 107 | variables: { 108 | cards: words.map((word) => { 109 | return { 110 | player_id: currentPlayer.id, 111 | game_id: currentGame.id, 112 | word: word, 113 | } 114 | }), 115 | }, 116 | }) 117 | props.onSubmit() 118 | }} 119 | > 120 | {t("cardSubmission.submitButton", "Submit")} 121 | </Button> 122 | </Grid> 123 | </> 124 | ) 125 | } 126 | 127 | export default SubmissionForm 128 | -------------------------------------------------------------------------------- /app/src/pages/Home/Join.tsx: -------------------------------------------------------------------------------- 1 | import { useLazyQuery } from "@apollo/react-hooks" 2 | import { Button, Grid, TextField } from "@material-ui/core" 3 | import { CurrentAuthContext } from "contexts/CurrentAuth" 4 | import { clientUuid } from "contexts/CurrentPlayer" 5 | import { NotificationContext } from "contexts/Notification" 6 | import { GameByJoinCodeDocument, useJoinGameMutation } from "generated/graphql" 7 | import * as React from "react" 8 | import { useTranslation } from "react-i18next" 9 | import { generatePath, Redirect } from "react-router-dom" 10 | import routes from "routes" 11 | 12 | function Join(props: { onBack: () => void }) { 13 | const { t } = useTranslation() 14 | const currentAuth = React.useContext(CurrentAuthContext) 15 | const notification = React.useContext(NotificationContext) 16 | 17 | const [joining, setJoining] = React.useState(false) 18 | const [redirectRoute, setRedirectRoute] = React.useState("") 19 | const [joinCode, setJoinCode] = React.useState("") 20 | 21 | const [joinGame] = useJoinGameMutation() 22 | const [loadGame] = useLazyQuery(GameByJoinCodeDocument, { 23 | variables: { joinCode: joinCode?.toLocaleUpperCase() }, 24 | onCompleted: async (data) => { 25 | if (data && data.games[0]) { 26 | try { 27 | const registration = await joinGame({ 28 | variables: { 29 | gameId: data.games[0].id, 30 | clientUuid: clientUuid(), 31 | }, 32 | context: { 33 | headers: { 34 | "X-Hasura-Role": "anonymous", 35 | }, 36 | }, 37 | }) 38 | if (registration.data?.joinGame) { 39 | await currentAuth.setJwtToken(registration.data.joinGame.jwt_token) 40 | } 41 | setJoining(false) 42 | setRedirectRoute( 43 | generatePath(routes.game.lobby, { 44 | joinCode: joinCode?.toLocaleUpperCase(), 45 | }) 46 | ) 47 | } catch { 48 | // cannot join game 49 | setRedirectRoute( 50 | generatePath(routes.game.pending, { 51 | joinCode: joinCode?.toLocaleUpperCase(), 52 | }) 53 | ) 54 | } 55 | } else { 56 | // cannot find game 57 | notification.send( 58 | t( 59 | "home.invalidJoinCode", 60 | "Cannot find game {{ joinCode }}. Double check the code!", 61 | { joinCode: joinCode?.toLocaleUpperCase() } 62 | ) + " 👀" 63 | ) 64 | props.onBack() 65 | } 66 | }, 67 | onError: (_) => { 68 | props.onBack() 69 | }, 70 | }) 71 | 72 | return ( 73 | <> 74 | {redirectRoute && joinCode && <Redirect push to={redirectRoute} />} 75 | <Grid container direction="column" spacing={3} alignItems="center"> 76 | <Grid item> 77 | <TextField 78 | autoFocus 79 | size="medium" 80 | label={t("home.joinCodeLabel", "4-letter code")} 81 | variant="outlined" 82 | value={joinCode} 83 | onChange={({ target: { value } }) => setJoinCode(value)} 84 | inputProps={{ maxLength: 4, style: { textTransform: "uppercase" } }} 85 | style={{ textTransform: "uppercase" }} 86 | ></TextField> 87 | </Grid> 88 | <Grid container item> 89 | <Button size="small" onClick={props.onBack}> 90 | {t("backButton", "Back")} 91 | </Button> 92 | <Button 93 | variant="outlined" 94 | size="large" 95 | onClick={() => { 96 | setJoining(true) 97 | loadGame({ 98 | context: { 99 | headers: { 100 | "X-Hasura-Role": "anonymous", 101 | }, 102 | }, 103 | }) 104 | }} 105 | disabled={!joinCode || joinCode.length < 4 || joining} 106 | > 107 | {t("home.joinGameButton", "Join Game")} 108 | </Button> 109 | </Grid> 110 | </Grid> 111 | </> 112 | ) 113 | } 114 | 115 | export default Join 116 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/CardSettings.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | FormControl, 4 | FormControlLabel, 5 | Grid, 6 | Radio, 7 | RadioGroup, 8 | TextField, 9 | } from "@material-ui/core" 10 | import { grey } from "@material-ui/core/colors" 11 | import { CurrentGameContext } from "contexts/CurrentGame" 12 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 13 | import { GameCardPlayStyleEnum } from "generated/graphql" 14 | import { parseWordList } from "lib/cards" 15 | import { LetterInput, SubmissionsPerPlayerInput } from "pages/Lobby/Inputs" 16 | import ScreenCardsCheckbox from "pages/Lobby/Inputs/ScreenCardsCheckbox" 17 | import * as React from "react" 18 | import { useTranslation } from "react-i18next" 19 | 20 | function CardSettings(props: { 21 | cardPlayStyle: GameCardPlayStyleEnum 22 | setCardPlayStyle?: (cardPlayStyle: GameCardPlayStyleEnum) => void 23 | debouncedSetWordList?: (wordList: string) => void 24 | }) { 25 | const { t } = useTranslation() 26 | const currentPlayer = React.useContext(CurrentPlayerContext) 27 | const currentGame = React.useContext(CurrentGameContext) 28 | const [wordList, setWordList] = React.useState("") 29 | const canConfigureSettings = currentPlayer.role === PlayerRole.Host 30 | 31 | const wordListLength = parseWordList(wordList).length 32 | 33 | return ( 34 | <> 35 | <Grid item> 36 | <FormControl component="fieldset" disabled={!canConfigureSettings}> 37 | <RadioGroup 38 | value={props.cardPlayStyle} 39 | onChange={({ target: { value } }) => { 40 | props.setCardPlayStyle && 41 | props.setCardPlayStyle(value as GameCardPlayStyleEnum) 42 | }} 43 | > 44 | <FormControlLabel 45 | value={GameCardPlayStyleEnum.PlayersSubmitWords} 46 | control={<Radio color="primary"></Radio>} 47 | label={t( 48 | "settings.cards.cardStyle.playersSubmit", 49 | "Players submit words (default)" 50 | )} 51 | ></FormControlLabel> 52 | <FormControlLabel 53 | value={GameCardPlayStyleEnum.HostProvidesWords} 54 | control={<Radio color="primary"></Radio>} 55 | label={t( 56 | "settings.cards.cardStyle.hostProvides", 57 | "Host provides words" 58 | )} 59 | ></FormControlLabel> 60 | </RadioGroup> 61 | </FormControl> 62 | </Grid> 63 | {props.cardPlayStyle === GameCardPlayStyleEnum.PlayersSubmitWords && ( 64 | <> 65 | <Grid item /> 66 | <Grid item> 67 | <SubmissionsPerPlayerInput 68 | value={String(currentGame.num_entries_per_player || "")} 69 | /> 70 | </Grid> 71 | <Grid item> 72 | <LetterInput value={currentGame.starting_letter || ""} /> 73 | </Grid> 74 | <Grid item> 75 | <ScreenCardsCheckbox value={Boolean(currentGame.screen_cards)} /> 76 | </Grid> 77 | </> 78 | )} 79 | {props.cardPlayStyle === GameCardPlayStyleEnum.HostProvidesWords && 80 | canConfigureSettings && ( 81 | <Grid item> 82 | <TextField 83 | autoFocus 84 | value={wordList} 85 | onChange={({ target: { value } }) => { 86 | setWordList(value) 87 | props.debouncedSetWordList && props.debouncedSetWordList(value) 88 | }} 89 | fullWidth 90 | label={t("settings.cards.words.label", "Words")} 91 | multiline 92 | rows={5} 93 | variant="outlined" 94 | placeholder={t( 95 | "settings.cards.words.placeholder", 96 | "Comma separated list of words here..." 97 | )} 98 | ></TextField> 99 | <Box 100 | display="flex" 101 | flexDirection="row-reverse" 102 | pt={0.5} 103 | color={grey[600]} 104 | > 105 | {t("settings.cards.words.helper", "{{ count }} word detected", { 106 | count: wordListLength, 107 | defaultValue_plural: "{{ count }} words detected", 108 | })} 109 | </Box> 110 | </Grid> 111 | )} 112 | </> 113 | ) 114 | } 115 | 116 | export default CardSettings 117 | -------------------------------------------------------------------------------- /app/src/pages/Play/TeamContent.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grid } from "@material-ui/core" 2 | import PlayerChip from "components/PlayerChip" 3 | import { CurrentGameContext } from "contexts/CurrentGame" 4 | import { CurrentPlayerContext } from "contexts/CurrentPlayer" 5 | import { CurrentGameSubscription } from "generated/graphql" 6 | import { nextPlayerForNextTeam } from "lib/turn" 7 | import * as React from "react" 8 | import { Trans, useTranslation } from "react-i18next" 9 | 10 | type Props = { 11 | activePlayer: CurrentGameSubscription["games"][0]["players"][0] 12 | activeTurn: CurrentGameSubscription["games"][0]["turns"][0] 13 | } 14 | 15 | export function YourTeamTurnContent(props: Props) { 16 | const { t } = useTranslation() 17 | const currentGame = React.useContext(CurrentGameContext) 18 | const nextActivePlayer = nextPlayerForNextTeam( 19 | props.activePlayer, 20 | currentGame.turns, 21 | currentGame.players 22 | ) 23 | 24 | return ( 25 | <Box p={2}> 26 | <Grid item container direction="column" spacing={2}> 27 | {props.activeTurn.started_at !== null ? ( 28 | <Grid item> 29 | <Trans t={t} i18nKey="play.yourTeam.context.active"> 30 | <PlayerChip team={props.activePlayer.team}> 31 | {{ playerUsername: props.activePlayer.username }} 32 | </PlayerChip> 33 | {" has started!"} 34 | </Trans> 35 | </Grid> 36 | ) : ( 37 | <Grid item> 38 | <Trans t={t} i18nKey="play.yourTeam.context.inactive"> 39 | <PlayerChip team={props.activePlayer.team}> 40 | {{ playerUsername: props.activePlayer.username }} 41 | </PlayerChip> 42 | {" from your team is about to start. Pay attention!"} 43 | </Trans> 44 | </Grid> 45 | )} 46 | 47 | <Grid item> 48 | <Trans t={t} i18nKey="play.yourTeam.context.nextTurn"> 49 | <PlayerChip team={nextActivePlayer.team}> 50 | {{ playerUsername: nextActivePlayer.username }} 51 | </PlayerChip> 52 | {" from the other team is next!"} 53 | </Trans> 54 | </Grid> 55 | </Grid> 56 | </Box> 57 | ) 58 | } 59 | 60 | export function OtherTeamContent(props: Props) { 61 | const { t } = useTranslation() 62 | const currentPlayer = React.useContext(CurrentPlayerContext) 63 | const currentGame = React.useContext(CurrentGameContext) 64 | const nextActivePlayer = nextPlayerForNextTeam( 65 | props.activePlayer, 66 | currentGame.turns, 67 | currentGame.players 68 | ) 69 | 70 | return ( 71 | <Box p={2}> 72 | <Grid item container direction="column" spacing={2}> 73 | {props.activeTurn.started_at !== null ? ( 74 | <Grid item> 75 | <Trans t={t} i18nKey="play.otherTeam.context.active"> 76 | <PlayerChip team={props.activePlayer.team}> 77 | {{ playerUsername: props.activePlayer.username }} 78 | </PlayerChip> 79 | { 80 | " has started! Pay attention to the words or phrases the other team is guessing!" 81 | } 82 | </Trans> 83 | </Grid> 84 | ) : ( 85 | <Grid item> 86 | <Trans t={t} i18nKey="play.otherTeam.context.inactive"> 87 | <PlayerChip team={props.activePlayer.team}> 88 | {{ playerUsername: props.activePlayer.username }} 89 | </PlayerChip> 90 | { 91 | " from the other team is about to start. Pay attention to the words or phrases the other team is guessing!" 92 | } 93 | </Trans> 94 | </Grid> 95 | )} 96 | 97 | <Grid item> 98 | {nextActivePlayer.id === currentPlayer.id ? ( 99 | <Trans t={t} i18nKey="play.otherTeam.context.yourNextTurn"> 100 | <PlayerChip team={nextActivePlayer.team}> 101 | {{ playerUsername: nextActivePlayer.username }} 102 | </PlayerChip> 103 | {" is next! (that's you!)"} 104 | </Trans> 105 | ) : ( 106 | <Trans t={t} i18nKey="play.otherTeam.context.nextTurn"> 107 | <PlayerChip team={nextActivePlayer.team}> 108 | {{ playerUsername: nextActivePlayer.username }} 109 | </PlayerChip> 110 | {" from your team is next!"} 111 | </Trans> 112 | )} 113 | </Grid> 114 | </Grid> 115 | </Box> 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/SettingsSection.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | ExpansionPanel, 4 | ExpansionPanelDetails, 5 | ExpansionPanelSummary, 6 | Grid, 7 | Typography, 8 | } from "@material-ui/core" 9 | import { grey } from "@material-ui/core/colors" 10 | import ExpandMoreIcon from "@material-ui/icons/ExpandMore" 11 | import { CurrentGameContext } from "contexts/CurrentGame" 12 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 13 | import { GameCardPlayStyleEnum } from "generated/graphql" 14 | import { useTitleStyle } from "index" 15 | import CardSettings from "pages/Lobby/CardSettings" 16 | import ControllableRoundSettings from "pages/Lobby/ControllableRoundSettings" 17 | import { SecondsPerTurnInput } from "pages/Lobby/Inputs" 18 | import AllowCardSkipsCheckbox from "pages/Lobby/Inputs/AllowCardSkipsCheckbox" 19 | import RoundSettings from "pages/Lobby/RoundSettings" 20 | import SettingsSummary from "pages/Lobby/SettingsSummary" 21 | import * as React from "react" 22 | import { useTranslation } from "react-i18next" 23 | 24 | function SettingsSection(props: { 25 | cardPlayStyle: GameCardPlayStyleEnum 26 | setCardPlayStyle?: (cardPlayStyle: GameCardPlayStyleEnum) => void 27 | debouncedSetWordList?: (wordList: string) => void 28 | }) { 29 | const { t } = useTranslation() 30 | const currentGame = React.useContext(CurrentGameContext) 31 | const currentPlayer = React.useContext(CurrentPlayerContext) 32 | const titleClasses = useTitleStyle() 33 | 34 | const [expanded, setExpanded] = React.useState(false) 35 | 36 | return ( 37 | <ExpansionPanel 38 | style={{ 39 | boxShadow: "none", 40 | background: "none", 41 | }} 42 | expanded={expanded} 43 | > 44 | <ExpansionPanelSummary 45 | expandIcon={<ExpandMoreIcon />} 46 | style={{ padding: 0 }} 47 | aria-controls="panel1a-content" 48 | id="panel1a-header" 49 | onClick={() => { 50 | setExpanded(!expanded) 51 | }} 52 | > 53 | <Box display="flex" flexDirection="column"> 54 | <Typography variant="h4" className={titleClasses.title}> 55 | {t("settings.heading", "Settings")} 56 | </Typography> 57 | {!expanded && ( 58 | <Box mt={2} pr={4} style={{ color: grey[600] }}> 59 | <SettingsSummary></SettingsSummary> 60 | </Box> 61 | )} 62 | </Box> 63 | </ExpansionPanelSummary> 64 | <ExpansionPanelDetails style={{ padding: 0 }}> 65 | <Grid item> 66 | <Grid container spacing={2} direction="column"> 67 | <Grid item> 68 | {currentPlayer.role === PlayerRole.Participant && ( 69 | <div style={{ color: grey[600] }}> 70 | {t( 71 | "settings.playerClarification", 72 | "(Only your host can set these)" 73 | )} 74 | </div> 75 | )} 76 | </Grid> 77 | <Grid item> 78 | <Typography variant="h6" className={titleClasses.title}> 79 | {t("settings.cards.heading", "Cards")} 80 | </Typography> 81 | </Grid> 82 | <CardSettings {...props}></CardSettings> 83 | 84 | <Grid item> 85 | <Typography variant="h6" className={titleClasses.title}> 86 | {t("settings.turns.heading", "Turns")} 87 | </Typography> 88 | </Grid> 89 | <Grid item> 90 | <SecondsPerTurnInput 91 | value={String(currentGame.seconds_per_turn || "")} 92 | /> 93 | </Grid> 94 | <Grid item> 95 | <AllowCardSkipsCheckbox 96 | value={Boolean(currentGame.allow_card_skips)} 97 | /> 98 | </Grid> 99 | <Grid item> 100 | <Typography variant="h6" className={titleClasses.title}> 101 | {t("settings.rounds.heading", "Rounds")} 102 | </Typography> 103 | <Box pl={2} pt={1} fontSize="0.75rem" color={grey[600]}> 104 | {currentPlayer.role === PlayerRole.Host && 105 | t( 106 | "settings.rounds.description", 107 | "You can add, remove, or reorder rounds. By default, cards submitted will be re-used across rounds of Taboo, Charades, and Password." 108 | )} 109 | </Box> 110 | </Grid> 111 | <Grid item> 112 | {currentPlayer.role === PlayerRole.Host ? ( 113 | <ControllableRoundSettings /> 114 | ) : ( 115 | <RoundSettings /> 116 | )} 117 | </Grid> 118 | <Grid item /> 119 | </Grid> 120 | </Grid> 121 | </ExpansionPanelDetails> 122 | </ExpansionPanel> 123 | ) 124 | } 125 | 126 | export default SettingsSection 127 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createStyles, 3 | Divider, 4 | Grid, 5 | makeStyles, 6 | Theme, 7 | Typography, 8 | } from "@material-ui/core" 9 | import { CurrentGameContext } from "contexts/CurrentGame" 10 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 11 | import { 12 | GameCardPlayStyleEnum, 13 | useUpdateGameSettingsMutation, 14 | useUpdatePlayerMutation, 15 | } from "generated/graphql" 16 | import { useTitleStyle } from "index" 17 | import { debounce } from "lodash" 18 | import UsernameInput from "pages/Lobby/Inputs/UsernameInput" 19 | import SettingsSection from "pages/Lobby/SettingsSection" 20 | import ShareSection from "pages/Lobby/ShareSection" 21 | import WaitingRoom from "pages/Lobby/WaitingRoom" 22 | import * as React from "react" 23 | import { useTranslation } from "react-i18next" 24 | import { useLocation } from "react-router-dom" 25 | 26 | const DEBOUNCE_SECONDS = 1000 27 | 28 | const useStyles = makeStyles((theme: Theme) => 29 | createStyles({ 30 | section: { 31 | margin: theme.spacing(2), 32 | }, 33 | }) 34 | ) 35 | 36 | function Lobby() { 37 | const { t } = useTranslation() 38 | const currentPlayer = React.useContext(CurrentPlayerContext) 39 | const currentGame = React.useContext(CurrentGameContext) 40 | const [updatePlayer] = useUpdatePlayerMutation() 41 | const classes = useStyles() 42 | const titleClasses = useTitleStyle() 43 | const location = useLocation() 44 | const [hideShare, setHideShare] = React.useState(false) 45 | const [updateGameSettings] = useUpdateGameSettingsMutation() 46 | const [cardPlayStyle, setCardPlayStyle] = React.useState( 47 | GameCardPlayStyleEnum.PlayersSubmitWords 48 | ) 49 | 50 | React.useEffect(() => { 51 | setCardPlayStyle(currentGame.card_play_style) 52 | }, [currentGame.card_play_style]) 53 | 54 | const [wordList, setWordList] = React.useState("") 55 | const debouncedSetWordList = React.useRef( 56 | debounce((value: string) => { 57 | setWordList(value) 58 | }, DEBOUNCE_SECONDS) 59 | ).current 60 | 61 | React.useEffect(() => { 62 | const params = new URLSearchParams(location.search) 63 | 64 | setHideShare(params.get("hideshare") === "true") 65 | 66 | const username = params.get("name") 67 | if (username) { 68 | updatePlayer({ 69 | variables: { 70 | id: currentPlayer.id, 71 | input: { username }, 72 | }, 73 | }) 74 | } 75 | }, []) 76 | 77 | return ( 78 | <div> 79 | {!hideShare ? ( 80 | <div className={classes.section}> 81 | <ShareSection /> 82 | </div> 83 | ) : ( 84 | <Typography 85 | variant="h4" 86 | style={{ textAlign: "center", marginBottom: "1em" }} 87 | className={titleClasses.title} 88 | > 89 | {t("title", "Fishbowl")} 90 | </Typography> 91 | )} 92 | 93 | <Divider variant="middle"></Divider> 94 | 95 | {currentPlayer.role === PlayerRole.Host && ( 96 | <> 97 | <div className={classes.section}> 98 | <SettingsSection 99 | cardPlayStyle={cardPlayStyle} 100 | setCardPlayStyle={(value) => { 101 | setCardPlayStyle(value as GameCardPlayStyleEnum) 102 | updateGameSettings({ 103 | variables: { 104 | id: currentGame.id, 105 | input: { 106 | card_play_style: value as GameCardPlayStyleEnum, 107 | }, 108 | }, 109 | }) 110 | }} 111 | debouncedSetWordList={debouncedSetWordList} 112 | ></SettingsSection> 113 | </div> 114 | <Divider variant="middle"></Divider> 115 | </> 116 | )} 117 | 118 | <div className={classes.section}> 119 | <Grid item> 120 | <Grid container spacing={2} direction="column"> 121 | <Grid item> 122 | <Typography variant="h4" className={titleClasses.title}> 123 | {t("lobby.heading", "Lobby")} 124 | </Typography> 125 | </Grid> 126 | <Grid item> 127 | <UsernameInput 128 | username={currentPlayer.username || ""} 129 | playerId={currentPlayer.id} 130 | /> 131 | </Grid> 132 | <WaitingRoom 133 | wordList={wordList} 134 | cardPlayStyle={cardPlayStyle} 135 | ></WaitingRoom> 136 | </Grid> 137 | </Grid> 138 | </div> 139 | 140 | {currentPlayer.role === PlayerRole.Participant && ( 141 | <> 142 | <Divider variant="middle"></Divider> 143 | <div className={classes.section}> 144 | <SettingsSection cardPlayStyle={cardPlayStyle}></SettingsSection> 145 | </div> 146 | </> 147 | )} 148 | </div> 149 | ) 150 | } 151 | 152 | export default Lobby 153 | -------------------------------------------------------------------------------- /app/src/pages/CardSubmission/WaitingForSubmissions.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, Typography } from "@material-ui/core" 2 | import Fishbowl from "components/Fishbowl" 3 | import PlayerChip from "components/PlayerChip" 4 | import ScreenCard from "components/ScreenCard" 5 | import { CurrentGameContext } from "contexts/CurrentGame" 6 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 7 | import { filter, reject } from "lodash" 8 | import { Title } from "pages/CardSubmission" 9 | import AssignTeamsButton from "pages/CardSubmission/AssignTeamsButton" 10 | import * as React from "react" 11 | import { useTranslation } from "react-i18next" 12 | 13 | enum WaitingForSubmissionsState { 14 | Waiting, 15 | SubmittedAssign, 16 | SubmittedWait, 17 | } 18 | 19 | function WaitingForSubmissions() { 20 | const { t } = useTranslation() 21 | const currentGame = React.useContext(CurrentGameContext) 22 | const currentPlayer = React.useContext(CurrentPlayerContext) 23 | 24 | const numEntriesPerPlayer = currentGame.num_entries_per_player 25 | const numPlayers = currentGame.players.length 26 | 27 | if (!numEntriesPerPlayer || !numPlayers) { 28 | return null 29 | } 30 | 31 | const total = numEntriesPerPlayer * numPlayers 32 | const submittedOrAcceptedSoFar = currentGame.cards.length 33 | 34 | const waitingForPlayers = reject(currentGame.players, (player) => { 35 | return ( 36 | filter(currentGame.cards, (card) => card.player_id === player.id) 37 | .length === numEntriesPerPlayer 38 | ) 39 | }) 40 | 41 | if (!submittedOrAcceptedSoFar) { 42 | return null 43 | } 44 | 45 | let state: WaitingForSubmissionsState 46 | 47 | if (total === 0 || submittedOrAcceptedSoFar !== total) { 48 | state = WaitingForSubmissionsState.Waiting 49 | } else if (PlayerRole.Host === currentPlayer.role) { 50 | state = WaitingForSubmissionsState.SubmittedAssign 51 | } else { 52 | state = WaitingForSubmissionsState.SubmittedWait 53 | } 54 | 55 | const unscreenedCards = 56 | PlayerRole.Host === currentPlayer.role && currentGame.screen_cards 57 | ? currentGame.cards.filter( 58 | (card) => 59 | card.is_allowed === null && card.player_id !== currentPlayer.id 60 | ) 61 | : [] 62 | 63 | return ( 64 | <> 65 | <Grid item> 66 | <Title text={t("cardSubmission.waiting.title", "Well done!")} /> 67 | </Grid> 68 | 69 | { 70 | { 71 | [WaitingForSubmissionsState.Waiting]: ( 72 | <> 73 | <Grid item container justify="center"> 74 | {t( 75 | "cardSubmission.waiting.description", 76 | "Just waiting for everyone else..." 77 | )} 78 | <div style={{ width: 4 }} /> 79 | {waitingForPlayers.map((player) => ( 80 | <> 81 | <PlayerChip username={player.username || ""} /> 82 | <div style={{ width: 4 }} /> 83 | </> 84 | ))} 85 | </Grid> 86 | <Grid item> 87 | <Typography variant="h5"> 88 | {t( 89 | "cardSubmission.waiting.progress", 90 | "{{ progress }} cards so far", 91 | { 92 | progress: `${submittedOrAcceptedSoFar}/${total}`, 93 | } 94 | )} 95 | </Typography> 96 | </Grid> 97 | </> 98 | ), 99 | [WaitingForSubmissionsState.SubmittedAssign]: ( 100 | <Grid item> 101 | {t( 102 | "cardSubmission.waiting.submitted.host", 103 | "All players submitted! As the host, you can now assign teams." 104 | )} 105 | </Grid> 106 | ), 107 | [WaitingForSubmissionsState.SubmittedWait]: ( 108 | <Grid item> 109 | {t( 110 | "cardSubmission.waiting.submitted.player", 111 | "All players submitted {{ cardCount }} cards in total. Now we are waiting on the host to start the game!", 112 | { cardCount: submittedOrAcceptedSoFar } 113 | )} 114 | </Grid> 115 | ), 116 | }[state] 117 | } 118 | 119 | {PlayerRole.Host === currentPlayer.role && currentGame.screen_cards && ( 120 | <Grid item container direction="column" spacing={2} alignItems="center"> 121 | {unscreenedCards.map((card, index) => ( 122 | <Grid item key={index}> 123 | <ScreenCard card={card} /> 124 | </Grid> 125 | ))} 126 | </Grid> 127 | )} 128 | 129 | {WaitingForSubmissionsState.SubmittedAssign === state ? ( 130 | <div style={{ marginTop: 30 }}> 131 | <AssignTeamsButton /> 132 | </div> 133 | ) : !unscreenedCards.length ? ( 134 | <div style={{ marginTop: 50 }}> 135 | <Fishbowl /> 136 | </div> 137 | ) : null} 138 | </> 139 | ) 140 | } 141 | 142 | export default WaitingForSubmissions 143 | -------------------------------------------------------------------------------- /app/src/pages/Lobby/WaitingRoom.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Grid } from "@material-ui/core" 2 | import { grey } from "@material-ui/core/colors" 3 | import PlayerArena from "components/PlayerArena" 4 | import { CurrentGameContext } from "contexts/CurrentGame" 5 | import { CurrentPlayerContext, PlayerRole } from "contexts/CurrentPlayer" 6 | import { 7 | GameCardPlayStyleEnum, 8 | GameStateEnum, 9 | useLoadWordsMutation, 10 | useRemovePlayerMutation, 11 | useUpdateAllPlayersMutation, 12 | useUpdateGameStateMutation, 13 | } from "generated/graphql" 14 | import { parseWordList } from "lib/cards" 15 | import { teamsWithSequence } from "lib/team" 16 | import { filter, find, isEmpty, reject } from "lodash" 17 | import * as React from "react" 18 | import { Trans, useTranslation } from "react-i18next" 19 | 20 | function WaitingRoom(props: { 21 | cardPlayStyle: GameCardPlayStyleEnum 22 | wordList?: string 23 | }) { 24 | const { t } = useTranslation() 25 | const MIN_NUMBER_OF_PLAYERS = 2 // TODO: Update to 4. 26 | const currentGame = React.useContext(CurrentGameContext) 27 | const currentPlayer = React.useContext(CurrentPlayerContext) 28 | const [updateGameState] = useUpdateGameStateMutation() 29 | const [removePlayer] = useRemovePlayerMutation() 30 | const [loadWords] = useLoadWordsMutation() 31 | const [updateAllPlayers] = useUpdateAllPlayersMutation() 32 | 33 | const playersWithUsernames = 34 | reject(currentGame.players, (player) => isEmpty(player.username)) || [] 35 | const playersWithoutUsernames = 36 | filter(currentGame.players, (player) => isEmpty(player.username)) || [] 37 | const canSeeStartGameButton = currentPlayer.role === PlayerRole.Host 38 | const canStartGame = 39 | canSeeStartGameButton && 40 | currentGame.seconds_per_turn && 41 | find(playersWithUsernames, (player) => player.id === currentPlayer.id) && 42 | playersWithUsernames.length >= MIN_NUMBER_OF_PLAYERS && 43 | ((props.cardPlayStyle === GameCardPlayStyleEnum.PlayersSubmitWords && 44 | currentGame.num_entries_per_player) || 45 | (props.cardPlayStyle === GameCardPlayStyleEnum.HostProvidesWords && 46 | Boolean(props.wordList))) 47 | 48 | return ( 49 | <> 50 | <Grid item> 51 | <PlayerArena 52 | players={playersWithUsernames} 53 | hostCanRemovePlayer={currentPlayer.role === PlayerRole.Host} 54 | ></PlayerArena> 55 | {currentPlayer.role === PlayerRole.Host && ( 56 | <> 57 | <Box mt={2} color={grey[600]}> 58 | {t( 59 | "lobby.hostHelper.removeHost", 60 | "In case someone is switching devices or browsers, you can remove them as the host." 61 | )} 62 | </Box> 63 | <Box my={2} color={grey[600]}> 64 | <Trans t={t} i18nKey="lobby.hostHelper.lateJoining"> 65 | <b>Once you start the game, new players cannot join.</b> We'll 66 | add support for players joining late soon! 67 | </Trans> 68 | </Box> 69 | </> 70 | )} 71 | </Grid> 72 | <Grid item style={{ textAlign: "center" }}> 73 | {canSeeStartGameButton && ( 74 | <Button 75 | onClick={async () => { 76 | await Promise.all( 77 | playersWithoutUsernames.map((player) => { 78 | return removePlayer({ 79 | variables: { 80 | id: player.id, 81 | }, 82 | }) 83 | }) 84 | ) 85 | if ( 86 | props.cardPlayStyle === 87 | GameCardPlayStyleEnum.HostProvidesWords && 88 | props.wordList 89 | ) { 90 | await loadWords({ 91 | variables: { 92 | objects: parseWordList(props.wordList).map((word) => { 93 | return { 94 | word, 95 | game_id: currentGame.id, 96 | player_id: currentPlayer.id, 97 | is_allowed: true, 98 | } 99 | }), 100 | }, 101 | }) 102 | const players = teamsWithSequence(currentGame.players) 103 | await updateAllPlayers({ 104 | variables: { 105 | gameId: currentGame.id, 106 | players: players.map(({ id, team, team_sequence }) => ({ 107 | id, 108 | team, 109 | team_sequence, 110 | })), 111 | }, 112 | }) 113 | updateGameState({ 114 | variables: { 115 | id: currentGame.id, 116 | state: GameStateEnum.TeamAssignment, 117 | }, 118 | }) 119 | } else if ( 120 | props.cardPlayStyle === GameCardPlayStyleEnum.PlayersSubmitWords 121 | ) { 122 | await updateGameState({ 123 | variables: { 124 | id: currentGame.id, 125 | state: GameStateEnum.CardSubmission, 126 | }, 127 | }) 128 | } 129 | }} 130 | disabled={!canStartGame} 131 | variant="contained" 132 | color="primary" 133 | > 134 | {t("lobby.everyoneHereButton", "Everyone's Here!")} 135 | </Button> 136 | )} 137 | </Grid> 138 | </> 139 | ) 140 | } 141 | 142 | export default WaitingRoom 143 | -------------------------------------------------------------------------------- /app/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ) 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void 26 | } 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) 32 | if (publicUrl.origin !== window.location.origin) { 33 | // Our service worker won't work if PUBLIC_URL is on a different origin 34 | // from what our page is served on. This might happen if a CDN is used to 35 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 36 | return 37 | } 38 | 39 | window.addEventListener("load", () => { 40 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` 41 | 42 | if (isLocalhost) { 43 | // This is running on localhost. Let's check if a service worker still exists or not. 44 | checkValidServiceWorker(swUrl, config) 45 | 46 | // Add some additional logging to localhost, pointing developers to the 47 | // service worker/PWA documentation. 48 | navigator.serviceWorker.ready.then(() => { 49 | console.log( 50 | "This web app is being served cache-first by a service " + 51 | "worker. To learn more, visit https://bit.ly/CRA-PWA" 52 | ) 53 | }) 54 | } else { 55 | // Is not localhost. Just register service worker 56 | registerValidSW(swUrl, config) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | function registerValidSW(swUrl: string, config?: Config) { 63 | navigator.serviceWorker 64 | .register(swUrl) 65 | .then((registration) => { 66 | registration.onupdatefound = () => { 67 | const installingWorker = registration.installing 68 | if (installingWorker == null) { 69 | return 70 | } 71 | installingWorker.onstatechange = () => { 72 | if (installingWorker.state === "installed") { 73 | if (navigator.serviceWorker.controller) { 74 | // At this point, the updated precached content has been fetched, 75 | // but the previous service worker will still serve the older 76 | // content until all client tabs are closed. 77 | console.log( 78 | "New content is available and will be used when all " + 79 | "tabs for this page are closed. See https://bit.ly/CRA-PWA." 80 | ) 81 | 82 | // Execute callback 83 | if (config && config.onUpdate) { 84 | config.onUpdate(registration) 85 | } 86 | } else { 87 | // At this point, everything has been precached. 88 | // It's the perfect time to display a 89 | // "Content is cached for offline use." message. 90 | console.log("Content is cached for offline use.") 91 | 92 | // Execute callback 93 | if (config && config.onSuccess) { 94 | config.onSuccess(registration) 95 | } 96 | } 97 | } 98 | } 99 | } 100 | }) 101 | .catch((error) => { 102 | console.error("Error during service worker registration:", error) 103 | }) 104 | } 105 | 106 | function checkValidServiceWorker(swUrl: string, config?: Config) { 107 | // Check if the service worker can be found. If it can't reload the page. 108 | fetch(swUrl, { 109 | headers: { "Service-Worker": "script" }, 110 | }) 111 | .then((response) => { 112 | // Ensure service worker exists, and that we really are getting a JS file. 113 | const contentType = response.headers.get("content-type") 114 | if ( 115 | response.status === 404 || 116 | (contentType != null && contentType.indexOf("javascript") === -1) 117 | ) { 118 | // No service worker found. Probably a different app. Reload the page. 119 | navigator.serviceWorker.ready.then((registration) => { 120 | registration.unregister().then(() => { 121 | window.location.reload() 122 | }) 123 | }) 124 | } else { 125 | // Service worker found. Proceed as normal. 126 | registerValidSW(swUrl, config) 127 | } 128 | }) 129 | .catch(() => { 130 | console.log( 131 | "No internet connection found. App is running in offline mode." 132 | ) 133 | }) 134 | } 135 | 136 | export function unregister() { 137 | if ("serviceWorker" in navigator) { 138 | navigator.serviceWorker.ready 139 | .then((registration) => { 140 | registration.unregister() 141 | }) 142 | .catch((error) => { 143 | console.error(error.message) 144 | }) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /app/src/pages/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Divider, 5 | FormControl, 6 | Grid, 7 | InputLabel, 8 | Select, 9 | TextField, 10 | } from "@material-ui/core" 11 | import { green } from "@material-ui/core/colors" 12 | import PlayerChip from "components/PlayerChip" 13 | import { CurrentGameContext } from "contexts/CurrentGame" 14 | import { Players } from "generated/graphql" 15 | import { find, last } from "lodash" 16 | import { SecondsPerTurnInput } from "pages/Lobby/Inputs" 17 | import AllowCardSkipsCheckbox from "pages/Lobby/Inputs/AllowCardSkipsCheckbox" 18 | import * as React from "react" 19 | import Clipboard from "react-clipboard.js" 20 | import { Trans, useTranslation } from "react-i18next" 21 | 22 | function Settings() { 23 | const { t } = useTranslation() 24 | const currentGame = React.useContext(CurrentGameContext) 25 | const players = currentGame.players 26 | const [selectedPlayerId, setSelectedPlayerId] = React.useState< 27 | Players["id"] | null 28 | >(null) 29 | const [copyButtonClicked, setCopyButtonClicked] = React.useState(false) 30 | 31 | const selectedPlayer = find( 32 | players, 33 | (player) => player.id === selectedPlayerId 34 | ) 35 | const selectedPlayerUrl = 36 | document.URL.replace("http://", "") 37 | .replace("https://", "") 38 | .replace("settings", "play") + 39 | `?client_uuid=${selectedPlayer?.client_uuid}` 40 | 41 | React.useEffect(() => { 42 | let timeout: NodeJS.Timeout 43 | if (copyButtonClicked) { 44 | timeout = setTimeout(() => { 45 | setCopyButtonClicked(false) 46 | }, 1000) 47 | } 48 | return () => timeout && clearTimeout(timeout) 49 | }, [copyButtonClicked]) 50 | 51 | const activeTurn = last(currentGame.turns) 52 | 53 | return ( 54 | <Grid container direction="column" spacing={2}> 55 | <Grid item> 56 | <Trans t={t} i18nKey="settings.join.codeDescription"> 57 | {"Players can rejoin the game with code "} 58 | <b>{{ joinCode: currentGame.join_code?.toLocaleUpperCase() }}</b> 59 | </Trans> 60 | </Grid> 61 | <Grid item> 62 | {t( 63 | "settings.join.link.description", 64 | "If that's not working, send a unique join link by selecting a player's username below." 65 | )} 66 | </Grid> 67 | <Grid item> 68 | <FormControl style={{ width: "200px" }}> 69 | <InputLabel htmlFor="player-name-native-simple"> 70 | {t("settings.join.link.playerSelectLabel", "Player username")} 71 | </InputLabel> 72 | <Select 73 | native 74 | value={null} 75 | onChange={({ target: { value } }) => { 76 | setSelectedPlayerId(value) 77 | }} 78 | inputProps={{ 79 | name: "Player name", 80 | id: "player-name-native-simple", 81 | }} 82 | > 83 | <option aria-label="None" value="" /> 84 | {players.map((player) => { 85 | return ( 86 | <option key={player.id} value={player.id || "unknown"}> 87 | {player.username} 88 | </option> 89 | ) 90 | })} 91 | </Select> 92 | </FormControl> 93 | </Grid> 94 | {selectedPlayer && ( 95 | <> 96 | <Grid item container spacing={2}> 97 | <Grid item xs={8}> 98 | <TextField 99 | id="standard-read-only-input" 100 | value={selectedPlayerUrl} 101 | fullWidth 102 | InputProps={{ 103 | readOnly: true, 104 | }} 105 | /> 106 | </Grid> 107 | <Grid item xs={4}> 108 | <Clipboard 109 | data-clipboard-text={selectedPlayerUrl} 110 | style={{ border: "none", background: "none" }} 111 | > 112 | <Button 113 | variant="contained" 114 | color="default" 115 | style={ 116 | copyButtonClicked 117 | ? { backgroundColor: green[600], color: "#fff" } 118 | : {} 119 | } 120 | onClick={() => setCopyButtonClicked(true)} 121 | > 122 | {copyButtonClicked 123 | ? t("copied", "Copied") 124 | : t("copy", "Copy")} 125 | </Button> 126 | </Clipboard> 127 | </Grid> 128 | </Grid> 129 | <Grid item> 130 | <Trans t={t} i18nKey="settings.join.link.helper"> 131 | {"Send this to "} 132 | <PlayerChip> 133 | {{ playerUsername: selectedPlayer?.username }} 134 | </PlayerChip> 135 | {" so they can get back in the game!"} 136 | </Trans> 137 | </Grid> 138 | </> 139 | )} 140 | <Grid item> 141 | <Box py={2}> 142 | <Divider variant="fullWidth"></Divider> 143 | </Box> 144 | </Grid> 145 | <Grid item> 146 | <Box pb={1}> 147 | {t( 148 | "settings.playDescription", 149 | "As the host, you can adjust these settings before each turn or round starts." 150 | )} 151 | </Box> 152 | </Grid> 153 | <Grid item> 154 | <SecondsPerTurnInput 155 | value={String(currentGame.seconds_per_turn || "")} 156 | disabled={activeTurn?.started_at} 157 | /> 158 | </Grid> 159 | <Grid item> 160 | <AllowCardSkipsCheckbox 161 | value={Boolean(currentGame.allow_card_skips)} 162 | disabled={activeTurn?.started_at} 163 | /> 164 | </Grid> 165 | </Grid> 166 | ) 167 | } 168 | 169 | export default Settings 170 | --------------------------------------------------------------------------------