diff --git a/appsync/graphql.ts b/appsync/graphql.ts index b70e0af5..bf5be59a 100644 --- a/appsync/graphql.ts +++ b/appsync/graphql.ts @@ -47,7 +47,9 @@ export type JoinGameInput = { export type Mutation = { __typename?: 'Mutation'; createGame: Game; + createSection: SheetSection; joinGame: Game; + updateSection: SheetSection; }; @@ -56,10 +58,20 @@ export type MutationCreateGameArgs = { }; +export type MutationCreateSectionArgs = { + input: UpdateSectionInput; +}; + + export type MutationJoinGameArgs = { input: JoinGameInput; }; + +export type MutationUpdateSectionArgs = { + input: UpdateSectionInput; +}; + export type PlayerSheet = { __typename?: 'PlayerSheet'; characterName: Scalars['String']['output']; @@ -94,9 +106,21 @@ export type QueryGetGameArgs = { export type SheetSection = { __typename?: 'SheetSection'; + content: Scalars['String']['output']; createdAt: Scalars['AWSDateTime']['output']; gameId: Scalars['ID']['output']; + sectionId: Scalars['ID']['output']; + sectionName: Scalars['String']['output']; + sectionType: Scalars['String']['output']; type: Scalars['String']['output']; updatedAt: Scalars['AWSDateTime']['output']; userId: Scalars['ID']['output']; }; + +export type UpdateSectionInput = { + content?: InputMaybe; + gameId: Scalars['ID']['input']; + sectionName: Scalars['String']['input']; + sectionType: Scalars['String']['input']; + userId: Scalars['ID']['input']; +}; diff --git a/appsync/schema.ts b/appsync/schema.ts index 10a2a982..ce28df27 100644 --- a/appsync/schema.ts +++ b/appsync/schema.ts @@ -3,7 +3,7 @@ export const getGameQuery = ` query getGame($id: ID!) { getGame(id: $id) { - gameId gameName gameDescription playerSheets { userId gameId characterName sections { userId gameId type createdAt updatedAt } type createdAt updatedAt } joinToken createdAt updatedAt type + gameId gameName gameDescription playerSheets { userId gameId characterName sections { userId gameId sectionId type sectionName sectionType content createdAt updatedAt } type createdAt updatedAt } joinToken createdAt updatedAt type } } `; @@ -21,7 +21,7 @@ export const createGameMutation = ` mutation createGame($input: CreateGameInput!) { createGame(input: $input) { - gameId gameName gameDescription playerSheets { userId gameId characterName sections { userId gameId type createdAt updatedAt } type createdAt updatedAt } joinToken createdAt updatedAt type + gameId gameName gameDescription playerSheets { userId gameId characterName sections { userId gameId sectionId type sectionName sectionType content createdAt updatedAt } type createdAt updatedAt } joinToken createdAt updatedAt type } } `; @@ -29,7 +29,23 @@ export const joinGameMutation = ` mutation joinGame($input: JoinGameInput!) { joinGame(input: $input) { - gameId gameName gameDescription playerSheets { userId gameId characterName sections { userId gameId type createdAt updatedAt } type createdAt updatedAt } joinToken createdAt updatedAt type + gameId gameName gameDescription playerSheets { userId gameId characterName sections { userId gameId sectionId type sectionName sectionType content createdAt updatedAt } type createdAt updatedAt } joinToken createdAt updatedAt type + } + } + `; + + export const createSectionMutation = ` + mutation createSection($input: UpdateSectionInput!) { + createSection(input: $input) { + userId gameId sectionId type sectionName sectionType content createdAt updatedAt + } + } + `; + + export const updateSectionMutation = ` + mutation updateSection($input: UpdateSectionInput!) { + updateSection(input: $input) { + userId gameId sectionId type sectionName sectionType content createdAt updatedAt } } `; diff --git a/design/MAIN-PAGE-DESIGN-v2.md b/design/MAIN-PAGE-DESIGN-v2.md new file mode 100644 index 00000000..9d0c1f6d --- /dev/null +++ b/design/MAIN-PAGE-DESIGN-v2.md @@ -0,0 +1,341 @@ +# Wildsea Companion App + +## My Ask + +I am writing a webapp using React in Typescript. Below is the current code, which is just a placeholder which dumps put the JSON data received. + +I want it to have a top title bar which contains the gameName, and on its right-hand side I want a standard circular user icon (using Gravatar) which is a drop-down menu with "Log out" as an item. This bar should be abstracted into a separate function, as I'll need to use it on other displays as well. + +In the main section, it should have a tab bar, with a tab for each playerSheet. The playerSheet's should be rendered by another function. + +On a playerSheet, it should have characterName displayed, and then below that a block for each section on the playerSheet. The section display should be in a separate function. + +Each section has a sectionName, which will be rendered across the top. To the right of the name should be a pencil icon, which will change that section to edit mode. Each section also has a sectionType, with a different renderer and editor function for each. Currently, we'll only implement the TEXT sectionType. The TEXT rendered will display `content.text` as text. `content` is a string containing JSON, with different fields depending on the sectionType. The TEXT editor will let the user edit the text - and on submit, make a updateSection graphQL call (and using the returned update as a new version with the renderer). + +There should also be a + button, which would open a box allowing setting a section name, and choosing the section type (from the list of types) - which when submitted will run the createSection graphql call, and then render the returned data as a new section on the page. + +## Other Requirements + +* Use all best practices. +* Be secure +* Use react-intl +* Beware of separation of concerns + +## Current Files + +### Current Code + +Here is the current (placeholder) code, for reference: + +```typescript +import React, { useState, useEffect } from 'react'; +import { generateClient } from "aws-amplify/api"; +import { Game as GameType } from "../../appsync/graphql"; +import { getGameQuery } from "../../appsync/schema"; +import { IntlProvider, FormattedMessage, useIntl } from 'react-intl'; +import { messages } from './translations'; + +interface GameProps { + id: string; +} + +const GameContent: React.FC = ({ id }) => { + const [game, setGame] = useState(null); + const [error, setError] = useState(null); + const intl = useIntl(); + + useEffect(() => { + async function fetchGame() { + try { + const client = generateClient(); + const response = await client.graphql({ + query: getGameQuery, + variables: { + id: id + } + }) as { data: { getGame: GameType } }; + + setGame(response.data.getGame); + } catch (err) { + setError(intl.formatMessage({ id: 'errorFetchingGameData'})); + } + } + + fetchGame(); + }, [id]); + + if (error) { + return
: {error}
; + } + + if (!game) { + return
; + } + + return ( +
+

: {game.gameName}

+
+                {JSON.stringify(game, null, 2)}
+            
+
+ ); +}; + +const Game: React.FC = (props) => ( + + + +); + +export default Game; +``` + +### Current CSS + +Here is the CSS that already exists, for re-use or maintaining consistency: + +```css +.hidden { + display: none; +} + +.gameslist { + font-family: Arial, sans-serif; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #f2f2f2; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0 0 0 0.1); +} + +.allgames { + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.joingame, +.newgame { + width: 48%; + background-color: #fff; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0 0 0 0.1); +} + +h1 { + font-size: 24px; + margin-bottom: 10px; +} + +ul { + list-style-type: none; + padding: 0; +} + +li { + padding: 10px; + border-bottom: 1px solid #ddd; +} + +li:last-child { + border-bottom: none; +} + +form { + display: flex; + flex-direction: column; +} + +label { + font-weight: bold; + margin-bottom: 5px; +} + +input, +textarea { + padding: 5px; + margin-bottom: 10px; + border: 1px solid #ddd; + border-radius: 3px; +} + +button { + padding: 10px; + background-color: #4CAF50; + color: #fff; + border: none; + border-radius: 3px; + cursor: pointer; +} + +button:hover { + background-color: #45a049; +} + +.error-container { + background-color: #ffebee; + border: 1px solid #f44336; + border-radius: 3px; + padding: 10px; + margin-top: 10px; +} + +.error-container.hidden { + display: none; +} + +.error-message { + color: #f44336; + margin-bottom: 10px; +} + +.error-container button { + background-color: #f44336; + color: white; + border: none; + padding: 5px 10px; + border-radius: 3px; + cursor: pointer; +} + +.error-container button:hover { + background-color: #d32f2f; +} + +.game-link { + text-decoration: none; + color: inherit; + display: block; + width: 100%; + height: 100%; + padding: 10px; + transition: background-color 0.2s ease; +} + +.game-link:hover { + background-color: #f0f0f0; +} +``` + +### GraphQL Types + +```typescript +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + AWSDateTime: { input: string; output: string; } + AWSEmail: { input: string; output: string; } + AWSIPAddress: { input: string; output: string; } + AWSJSON: { input: string; output: string; } + AWSPhone: { input: string; output: string; } + AWSTime: { input: string; output: string; } + AWSTimestamp: { input: string; output: string; } + AWSURL: { input: string; output: string; } +}; + +export type CreateGameInput = { + description?: InputMaybe; + name: Scalars['String']['input']; +}; + +export type Game = { + __typename?: 'Game'; + createdAt: Scalars['AWSDateTime']['output']; + gameDescription?: Maybe; + gameId: Scalars['ID']['output']; + gameName: Scalars['String']['output']; + joinToken?: Maybe; + playerSheets: Array; + type: Scalars['String']['output']; + updatedAt: Scalars['AWSDateTime']['output']; +}; + +export type JoinGameInput = { + gameId: Scalars['ID']['input']; + joinToken: Scalars['ID']['input']; +}; + +export type Mutation = { + __typename?: 'Mutation'; + createGame: Game; + joinGame: Game; +}; + + +export type MutationCreateGameArgs = { + input: CreateGameInput; +}; + + +export type MutationJoinGameArgs = { + input: JoinGameInput; +}; + +export type PlayerSheet = { + __typename?: 'PlayerSheet'; + characterName: Scalars['String']['output']; + createdAt: Scalars['AWSDateTime']['output']; + gameId: Scalars['ID']['output']; + sections: Array; + type: Scalars['String']['output']; + updatedAt: Scalars['AWSDateTime']['output']; + userId: Scalars['ID']['output']; +}; + +export type PlayerSheetSummary = { + __typename?: 'PlayerSheetSummary'; + createdAt: Scalars['AWSDateTime']['output']; + gameDescription: Scalars['String']['output']; + gameId: Scalars['ID']['output']; + gameName: Scalars['String']['output']; + type: Scalars['String']['output']; + updatedAt: Scalars['AWSDateTime']['output']; +}; + +export type Query = { + __typename?: 'Query'; + getGame: Game; + getGames?: Maybe>; +}; + + +export type QueryGetGameArgs = { + id: Scalars['ID']['input']; +}; + +export type SheetSection = { + __typename?: 'SheetSection'; + content: Scalars['String']['output']; + createdAt: Scalars['AWSDateTime']['output']; + gameId: Scalars['ID']['output']; + sectionName: Scalars['String']['output']; + sectionType: Scalars['String']['output']; + type: Scalars['String']['output']; + updatedAt: Scalars['AWSDateTime']['output']; + userId: Scalars['ID']['output']; +}; +``` + +## Your Task + +Please write the code described above. diff --git a/graphql/function/fnGetGame/appsync.ts b/graphql/function/fnGetGame/appsync.ts index ccf5eca4..dd52983a 100644 --- a/graphql/function/fnGetGame/appsync.ts +++ b/graphql/function/fnGetGame/appsync.ts @@ -165,6 +165,10 @@ export function makeSheetSection(data: DataSheetSection): SheetSection { return { userId: data.userId, gameId: data.gameId, + sectionId: data.sectionId, + sectionName: data.sectionName, + sectionType: data.sectionType, + content: data.content, createdAt: data.createdAt, updatedAt: data.updatedAt, type: data.type, diff --git a/graphql/schema.graphql b/graphql/schema.graphql index cb813c4e..3183e393 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -19,6 +19,8 @@ directive @aws_cognito_user_pools( type Mutation { createGame(input: CreateGameInput!): Game! @aws_cognito_user_pools joinGame(input: JoinGameInput!): Game! @aws_cognito_user_pools + createSection(input: UpdateSectionInput!): SheetSection! @aws_cognito_user_pools + updateSection(input: UpdateSectionInput!): SheetSection! @aws_cognito_user_pools } type Query { @@ -36,6 +38,14 @@ input JoinGameInput { joinToken: ID! } +input UpdateSectionInput { + userId: ID! + gameId: ID! + sectionName: String! + sectionType: String! + content: String +} + type Game @aws_cognito_user_pools { gameId: ID! gameName: String! @@ -69,7 +79,11 @@ type PlayerSheet @aws_cognito_user_pools { type SheetSection @aws_cognito_user_pools { userId: ID! gameId: ID! + sectionId: ID! type: String! + sectionName: String! + sectionType: String! + content: String! createdAt: AWSDateTime! updatedAt: AWSDateTime! } diff --git a/graphql/tests/fnGetGame.test.ts b/graphql/tests/fnGetGame.test.ts index f505f4c4..05bd363e 100644 --- a/graphql/tests/fnGetGame.test.ts +++ b/graphql/tests/fnGetGame.test.ts @@ -427,6 +427,10 @@ describe("makeSheetSection", () => { const data: DataSheetSection = { userId: "user1", gameId: "game1", + sectionId: "XXXXXX", + sectionName: "Section 1", + sectionType: "type1", + content: "{}", createdAt: "2023-01-01", updatedAt: "2023-01-02", type: TypeSection, @@ -437,6 +441,10 @@ describe("makeSheetSection", () => { expect(result).toEqual({ userId: "user1", gameId: "game1", + sectionId: "XXXXXX", + sectionName: "Section 1", + sectionType: "type1", + content: "{}", createdAt: "2023-01-01", updatedAt: "2023-01-02", type: TypeSection, diff --git a/terraform/module/wildsea/cognito.tf b/terraform/module/wildsea/cognito.tf index e855e6a7..b724dbd0 100644 --- a/terraform/module/wildsea/cognito.tf +++ b/terraform/module/wildsea/cognito.tf @@ -32,8 +32,8 @@ resource "aws_cognito_user_pool_client" "cognito" { allowed_oauth_flows_user_pool_client = true callback_urls = ["http://localhost:5173/", "https://${aws_cloudfront_distribution.cdn.domain_name}/"] logout_urls = ["http://localhost:5173/", "https://${aws_cloudfront_distribution.cdn.domain_name}/"] - allowed_oauth_flows = ["code", "implicit"] - allowed_oauth_scopes = ["openid"] + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["openid", "aws.cognito.signin.user.admin"] supported_identity_providers = [var.saml_metadata_url == "" ? "COGNITO" : aws_cognito_identity_provider.idp[0].provider_name] } diff --git a/ui/package-lock.json b/ui/package-lock.json index cc8b42f7..1dffb08c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,14 +8,17 @@ "@aws-amplify/api-graphql": "4.3.0", "@vitejs/plugin-react": "4.3.1", "aws-amplify": "6.6.0", + "md5": "^2.3.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-icons": "^5.3.0", "react-intl": "6.6.8" }, "devDependencies": { "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "16.0.1", "@types/jest": "29.5.12", + "@types/md5": "^2.3.5", "@types/node": "22.5.4", "@types/node-fetch": "2.6.11", "@types/react": "18.3.5", @@ -3807,6 +3810,13 @@ "parse5": "^7.0.0" } }, + "node_modules/@types/md5": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.5.tgz", + "integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.5.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", @@ -4354,6 +4364,15 @@ "node": ">=10" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -4482,6 +4501,15 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -5334,6 +5362,12 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", @@ -6317,6 +6351,17 @@ "tmpl": "1.0.5" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6804,6 +6849,15 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", + "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-intl": { "version": "6.6.8", "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz", @@ -10622,6 +10676,12 @@ "parse5": "^7.0.0" } }, + "@types/md5": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.5.tgz", + "integrity": "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==", + "dev": true + }, "@types/node": { "version": "22.5.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", @@ -11028,6 +11088,11 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" + }, "ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -11124,6 +11189,11 @@ "which": "^2.0.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==" + }, "css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -11713,6 +11783,11 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "is-core-module": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", @@ -12447,6 +12522,16 @@ "tmpl": "1.0.5" } }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -12768,6 +12853,12 @@ "scheduler": "^0.23.2" } }, + "react-icons": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", + "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==", + "requires": {} + }, "react-intl": { "version": "6.6.8", "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.8.tgz", diff --git a/ui/package.json b/ui/package.json index 14ea2fcc..bcd01125 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,8 +3,10 @@ "@aws-amplify/api-graphql": "4.3.0", "@vitejs/plugin-react": "4.3.1", "aws-amplify": "6.6.0", + "md5": "^2.3.0", "react": "18.3.1", "react-dom": "18.3.1", + "react-icons": "^5.3.0", "react-intl": "6.6.8" }, "scripts": { @@ -16,6 +18,7 @@ "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "16.0.1", "@types/jest": "29.5.12", + "@types/md5": "^2.3.5", "@types/node": "22.5.4", "@types/node-fetch": "2.6.11", "@types/react": "18.3.5", diff --git a/ui/public/style.css b/ui/public/style.css index 684796e5..665b828f 100644 --- a/ui/public/style.css +++ b/ui/public/style.css @@ -119,4 +119,75 @@ button:hover { .game-link:hover { background-color: #f0f0f0; -} \ No newline at end of file +} + +.game-container { + font-family: Arial, sans-serif; + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.user-menu { + position: relative; +} + +.user-menu img { + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; +} + +.dropdown { + position: absolute; + right: 0; + top: 100%; + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; + padding: 10px; +} + +.tab-bar { + display: flex; + margin-bottom: 20px; +} + +.tab-bar button { + padding: 10px 20px; + border: none; + background-color: #f0f0f0; + cursor: pointer; +} + +.tab-bar button.active { + background-color: #ddd; +} + +.section { + margin-bottom: 20px; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; +} + +.section h3 { + display: flex; + justify-content: space-between; + align-items: center; +} + +.new-section { + margin-top: 20px; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; +} diff --git a/ui/src/amplifyconfiguration.json b/ui/src/amplifyconfiguration.json index 3a9137ce..37cc7e01 100644 --- a/ui/src/amplifyconfiguration.json +++ b/ui/src/amplifyconfiguration.json @@ -10,7 +10,8 @@ "oauth": { "domain": "From Config", "scope": [ - "openid" + "openid", + "aws.cognito.signin.user.admin" ], "redirectSignIn": "From Config", "redirectSignOut": "From Config", diff --git a/ui/src/frame.tsx b/ui/src/frame.tsx new file mode 100644 index 00000000..df3a3ee9 --- /dev/null +++ b/ui/src/frame.tsx @@ -0,0 +1,49 @@ +import md5 from 'md5'; +import { signInWithRedirect, signOut } from "@aws-amplify/auth"; +import React, { useState } from "react"; +import { FormattedMessage } from 'react-intl'; + +function handleSignInClick() { + signInWithRedirect({}); +} + +function handleSignOutClick() { + signOut(); +} + +// TopBar component +export const TopBar: React.FC<{ title: string, userEmail: string | undefined }> = ({ title, userEmail }) => { + if (userEmail) { + const [showDropdown, setShowDropdown] = useState(false); + const gravatarUrl = `https://www.gravatar.com/avatar/${md5(userEmail.toLowerCase().trim())}?d=identicon`; + + return ( +
+

{title}

+
+ User setShowDropdown(!showDropdown)} /> + {showDropdown && ( +
+ +
+ )} +
+
+ ); + } else { + return ( +
+

{title}

+
+
+ +
+
+
+ ); + } +}; diff --git a/ui/src/game.tsx b/ui/src/game.tsx index 222ba47d..5ab8df88 100644 --- a/ui/src/game.tsx +++ b/ui/src/game.tsx @@ -1,67 +1,203 @@ import React, { useState, useEffect } from 'react'; import { generateClient } from "aws-amplify/api"; -import { Game as GameType } from "../../appsync/graphql"; -import { getGameQuery } from "../../appsync/schema"; +import { Game as GameType, SheetSection, PlayerSheet } from "../../appsync/graphql"; +import { getGameQuery, updateSectionMutation, createSectionMutation } from "../../appsync/schema"; import { IntlProvider, FormattedMessage, useIntl } from 'react-intl'; +import { GraphQLResult } from "@aws-amplify/api-graphql"; import { messages } from './translations'; +import { FaPencilAlt, FaPlus } from 'react-icons/fa'; +import { TopBar } from "./frame"; -interface GameProps { - id: string; -} - -const GameContent: React.FC = ({ id }) => { - const [game, setGame] = useState(null); - const [error, setError] = useState(null); - const intl = useIntl(); - - useEffect(() => { - async function fetchGame() { - try { - const client = generateClient(); - const response = await client.graphql({ - query: getGameQuery, - variables: { - id: id - } - }) as { data: { getGame: GameType } }; - - setGame(response.data.getGame); - } catch (err) { - setError(intl.formatMessage({ id: 'errorFetchingGameData'})); - } +// Section component +const Section: React.FC<{ section: SheetSection, onUpdate: (updatedSection: SheetSection) => void }> = ({ section, onUpdate }) => { + const [isEditing, setIsEditing] = useState(false); + const [content, setContent] = useState(JSON.parse(section.content)); + + const handleUpdate = async () => { + try { + const client = generateClient(); + const response = await client.graphql({ + query: updateSectionMutation, + variables: { + input: { + gameId: section.gameId, + userId: section.userId, + sectionName: section.sectionName, + content: JSON.stringify(content) + } } + }) as GraphQLResult<{ updateSection: SheetSection }>; + onUpdate(response.data.updateSection); + setIsEditing(false); + } catch (error) { + console.error("Error updating section:", error); + } + }; - fetchGame(); - }, [id]); + if (isEditing) { + return ( +
+

{section.sectionName} setIsEditing(false)} />

+