diff --git a/cake-game/.eslintrc.cjs b/cake-game/.eslintrc.cjs index 3e212e1..ff4d29a 100644 --- a/cake-game/.eslintrc.cjs +++ b/cake-game/.eslintrc.cjs @@ -13,6 +13,7 @@ module.exports = { plugins: ['react-refresh'], rules: { 'react/jsx-no-target-blank': 'off', + 'react/prop-types': 'off', 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, diff --git a/cake-game/package-lock.json b/cake-game/package-lock.json index a6eb898..e334078 100644 --- a/cake-game/package-lock.json +++ b/cake-game/package-lock.json @@ -14,7 +14,8 @@ "axios": "^1.6.8", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.22.3" + "react-router-dom": "^6.22.3", + "uuid": "^9.0.1" }, "devDependencies": { "@jest/globals": "^29.7.0", @@ -8227,6 +8228,18 @@ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", @@ -14325,6 +14338,11 @@ "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==" }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + }, "v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", diff --git a/cake-game/package.json b/cake-game/package.json index 08c443f..38620bb 100644 --- a/cake-game/package.json +++ b/cake-game/package.json @@ -17,7 +17,8 @@ "axios": "^1.6.8", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.22.3" + "react-router-dom": "^6.22.3", + "uuid": "^9.0.1" }, "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/cake-game/src/components/classifier-table-row/ClassifierTableRow.css b/cake-game/src/components/classifier-table-row/ClassifierTableRow.css new file mode 100644 index 0000000..90bcb95 --- /dev/null +++ b/cake-game/src/components/classifier-table-row/ClassifierTableRow.css @@ -0,0 +1,20 @@ +.image { + width: 50px; + height: auto; +} + +.classification { + font-weight: normal; +} + +tr { + padding: 0.5rem 3rem; +} + +.row { + background-color: var(--qwik-dark-background); +} + +.alternative-row { + background-color: var(--qwik-alt-background); +} \ No newline at end of file diff --git a/cake-game/src/components/classifier-table-row/ClassifierTableRow.jsx b/cake-game/src/components/classifier-table-row/ClassifierTableRow.jsx new file mode 100644 index 0000000..e9606b7 --- /dev/null +++ b/cake-game/src/components/classifier-table-row/ClassifierTableRow.jsx @@ -0,0 +1,37 @@ +import "./ClassifierTableRow.css"; + +function ClassifierTableRow(props) { + + function formatClassificationCollections(predictions, attributeName) { + if (!predictions || predictions.length === 0) { + return 'N/A'; + } + + return predictions[0][attributeName]; + } + + function formatClassificationString(classifier) { + if (!classifier) { + return 'N/A'; + } + + return classifier; + } + + return ( + <> + + + Random image + + { props.result.user_category } + { formatClassificationCollections(props.result.models.mobilenet_classifier, 'className') } + { formatClassificationCollections(props.result.models?.coco_ssd_predictions, 'class') } + { formatClassificationString(props.result.models?.my_transfer_model_classifier?.category) } + { formatClassificationString(props.result.models?.my_model_classifier.category) } + + + ); +} + +export default ClassifierTableRow; diff --git a/cake-game/src/components/classifier-table-row/ClassifierTableRow.test.js b/cake-game/src/components/classifier-table-row/ClassifierTableRow.test.js new file mode 100644 index 0000000..d8b1a6f --- /dev/null +++ b/cake-game/src/components/classifier-table-row/ClassifierTableRow.test.js @@ -0,0 +1,9 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import ClassifierTableRow from './ClassifierTableRow'; + +test.use({ viewport: { width: 500, height: 500 } }); + +test('should render', async ({ mount }) => { + const component = await mount(); + expect(component).toBeDefined(); +}); \ No newline at end of file diff --git a/cake-game/src/functions/game_results.js b/cake-game/src/functions/game_results.js new file mode 100644 index 0000000..01af1ef --- /dev/null +++ b/cake-game/src/functions/game_results.js @@ -0,0 +1,26 @@ +import { getGameResults } from "../util/elasticsearch"; +import { convertRequest, generateResponse } from "../util/helper"; + +/** + * Get a random image + * Note: Netlify deploys this function at the endpoint /.netlify/functions/image + * @param {*} event + * @param {*} context + * @returns + */ +export async function handler(event, context) { + const gameMetadata = convertRequest(event.body); + + try { + const response = await getGameResults(gameMetadata); + const results = response.hits.hits.flatMap((document) => { + return document._source; + }); + + return generateResponse(200, results); + } catch (e) { + console.log(e); + + return generateResponse(500, e); + } +} diff --git a/cake-game/src/index.css b/cake-game/src/index.css index c91f1c5..8a87e16 100644 --- a/cake-game/src/index.css +++ b/cake-game/src/index.css @@ -6,6 +6,7 @@ --qwik-dark-purple: #713fc2; --qwik-dirty-black: #1d2033; --qwik-dark-background: #151934; + --qwik-alt-background: #332b6a; --qwik-dark-text: #ffffff; } diff --git a/cake-game/src/main.jsx b/cake-game/src/main.jsx index d695785..e7325d4 100644 --- a/cake-game/src/main.jsx +++ b/cake-game/src/main.jsx @@ -12,6 +12,7 @@ import Error from './Error.jsx'; import Start from './routes/start/Start.jsx'; import Play from './routes/play/Play.jsx'; import Home from './routes/home/Home.jsx'; +import End from './routes/end/End.jsx'; const router = createBrowserRouter([ { @@ -29,7 +30,11 @@ const router = createBrowserRouter([ }, { path: '/play', - element: + element: , + }, + { + path: '/end', + element: } ] }, diff --git a/cake-game/src/routes/end/End.css b/cake-game/src/routes/end/End.css new file mode 100644 index 0000000..b5e2ea3 --- /dev/null +++ b/cake-game/src/routes/end/End.css @@ -0,0 +1,29 @@ +section { + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; + + margin: auto; +} + +table { + width: 95%; + + /*padding: 0.5rem 2rem;*/ + + border: 1px solid; + border-color: var(--qwik-dark-purple); +} + +.header-row { + background-color: var(--qwik-dark-background); + color: var(--qwik-dark-text); + font-size: large; + + height: 5rem; +} + +th { + text-wrap: wrap; +} \ No newline at end of file diff --git a/cake-game/src/routes/end/End.jsx b/cake-game/src/routes/end/End.jsx new file mode 100644 index 0000000..a1afdb3 --- /dev/null +++ b/cake-game/src/routes/end/End.jsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react"; //TO DO should I use useMemo to get results and classifications +import { useNavigate, useSearchParams } from "react-router-dom"; + +import axios from "axios"; + +import "./End.css"; +import ClassifierTableRow from "../../components/classifier-table-row/ClassifierTableRow"; +import StartButton from "../../components/start-button/StartButton"; + +function End() { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + const [username, setUsername] = useState(); + const [gameId, setGameId] = useState(); + + const [results, setResults] = useState(); + + useEffect(() => { + if (searchParams && !gameId) { + const user = searchParams.get("username"); + const game = searchParams.get("game_id"); + + setUsername(user); + setGameId(game); + + getGameResults(user, game); + } + }, [username, gameId]); + + async function getGameResults(user, game) { + const gameMetadata = { username: user, game_id: game }; + + try { + const response = await axios.post( + ".netlify/functions/game_results", + gameMetadata + ); + + if (response.status !== 200) { + throw new Error("Unable to get game results"); + } + + const gameResults = response.data; + setResults(gameResults); + } catch (error) { + console.log("Unable to get game results"); + setResults([]); + } + } + + return ( + <> +
+

+ Were they (F)ake?! +

+ +

+ Did you find the cake? Check out how you fared against our models + below. +

+ + {results && results.length > 0 ? ( + + + + + + + + + + + + + {results.map((result, index) => { + return ; + })} + +
ImageYouMobileNetCOCO-SSDMobileNet Transfer ClassifierCarly Model
+ ) : ( +

No results available!

+ )} +

Want to try again?

+ +
+ + ); +} + +export default End; diff --git a/cake-game/src/routes/end/End.test.js b/cake-game/src/routes/end/End.test.js new file mode 100644 index 0000000..42b1644 --- /dev/null +++ b/cake-game/src/routes/end/End.test.js @@ -0,0 +1,10 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import End from './End'; + +test.use({ viewport: { width: 500, height: 500 } }); + +test('should work', async ({ mount }) => { + const component = await mount(); + expect(component).toBeDefined(); + //await expect(component).toContainText('Is it (F)ake?!'); +}); \ No newline at end of file diff --git a/cake-game/src/routes/play/Play.jsx b/cake-game/src/routes/play/Play.jsx index 410fd8a..77efca4 100644 --- a/cake-game/src/routes/play/Play.jsx +++ b/cake-game/src/routes/play/Play.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useNavigate, useSearchParams } from 'react-router-dom'; import axios from "axios"; +import { v4 as uuidv4 } from 'uuid'; import "./Play.css"; @@ -9,7 +10,11 @@ function Play() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); + // UUID is + const gameId = useState(uuidv4()); + const [username, setUsername] = useState(); + const [imageCount, setImageCount] = useState(0); const [imageUrl, setImageUrl] = useState(); const [expectedCategory, setExpectedCategory] = useState('cake'); @@ -45,12 +50,14 @@ function Play() { async function castVote(event) { const classification = { + game_id: gameId[0], username: username, timestamp: new Date().toISOString(), image_url: imageUrl, expected_category: expectedCategory, user_category: event.target.value - } + }; + try { const response = await axios.post('.netlify/functions/result', classification); @@ -58,7 +65,12 @@ function Play() { throw new Error('Unable to get next image'); } - await getNextRandomImage(); + setImageCount(imageCount + 1); + if (imageCount < 9) { + await getNextRandomImage(); + } else { + navigate(`/end?username=${username}&game_id=${classification.game_id}`); + } } catch(error) { console.log('Unable to get next image'); diff --git a/cake-game/src/util/elasticsearch.js b/cake-game/src/util/elasticsearch.js index 77faaba..771637f 100644 --- a/cake-game/src/util/elasticsearch.js +++ b/cake-game/src/util/elasticsearch.js @@ -1,7 +1,9 @@ import { Client } from '@elastic/elasticsearch-serverless'; const classificationsIndex = 'classifications'; -const userClassificationsIndex = 'user-classifications' +const userClassificationsIndex = 'user-classifications'; +const pipeline = 'add-classifications-to-gameplay'; + const endpoint = process.env.ELASTIC_URL || ''; const apiKey = process.env.ELASTIC_API_KEY || ''; @@ -33,12 +35,30 @@ export async function getRandomImage() { /** * Adds manual classification from user gameplay to the index - * @param { username, timestamp, imageUrl, expectedCategory, userCategory } userClassification + * @param { game_id, username, timestamp, image_url, expected_category, user_category } userClassification * @returns */ export async function saveUserClassification(userClassification) { return await client.index({ index: userClassificationsIndex, - document: userClassification + document: userClassification, + pipeline: pipeline + }); +} + +/** + * + * @param { username, game_ud} gameMetadata + * @returns + */ +export async function getGameResults(gameMetadata) { + return await client.search({ + index: userClassificationsIndex, + _source: ["image_url", "user_category", "models"], + query: { + match: { + game_id: gameMetadata.game_id + } + } }); } \ No newline at end of file