diff --git a/main_controller.py b/main_controller.py index 73da0a7..0b968e7 100644 --- a/main_controller.py +++ b/main_controller.py @@ -1,6 +1,7 @@ import asyncio, time, datetime, games, json, threading, jinja2, leagues, os -from flask import Flask, url_for, Response, render_template, request, jsonify, send_from_directory +from flask import Flask, url_for, Response, render_template, request, jsonify, send_from_directory, abort from flask_socketio import SocketIO, emit +import database as db app = Flask("the-prestige", static_folder='simmadome/build') app.config['SECRET KEY'] = 'dev' @@ -16,6 +17,27 @@ def serve(path): else: return send_from_directory(app.static_folder, 'index.html') +### API + +@app.route('/api/teams/search') +def searchteams(): + query = request.args.get('query') + page_len = int(request.args.get('page_len')) + page_num = int(request.args.get('page_num')) + + if query is None: + abort(400, "A query term is required") + + result = db.search_teams(query) + if page_len is not None: #pagination should probably be doen in the sqlite query but this will do for now + if page_num is None: + abort(400, "A page_len argument must be accompanied by a page_num argument") + result = result[page_num*page_len : (page_num + 1)*page_len] + + return jsonify([json.loads(x[0])['name'] for x in result]) #currently all we need is the name but that can change + + +### SOCKETS thread2 = threading.Thread(target=socketio.run,args=(app,'0.0.0.0')) thread2.start() diff --git a/simmadome/package-lock.json b/simmadome/package-lock.json index 9bb26d1..a27cb55 100644 --- a/simmadome/package-lock.json +++ b/simmadome/package-lock.json @@ -2276,6 +2276,15 @@ "pretty-format": "^26.0.0" } }, + "@types/jquery": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.5.tgz", + "integrity": "sha512-6RXU9Xzpc6vxNrS6FPPapN1SxSHgQ336WC6Jj/N8q30OiaBZ00l1GBgeP7usjVZPivSkGUfL1z/WW6TX989M+w==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, "@types/json-schema": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.6.tgz", @@ -2370,6 +2379,12 @@ "@types/node": "*" } }, + "@types/sizzle": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.2.tgz", + "integrity": "sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==", + "dev": true + }, "@types/socket.io-client": { "version": "1.4.34", "resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-1.4.34.tgz", @@ -9529,6 +9544,11 @@ } } }, + "jquery": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", + "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/simmadome/package.json b/simmadome/package.json index 087a124..104db30 100644 --- a/simmadome/package.json +++ b/simmadome/package.json @@ -4,6 +4,7 @@ "private": true, "proxy": "http://localhost:5000", "dependencies": { + "jquery": "^3.5.1", "react": "^17.0.1", "react-dom": "^17.0.1", "react-router": "^5.2.0", @@ -19,6 +20,7 @@ "@testing-library/react": "^11.2.2", "@testing-library/user-event": "^12.6.0", "@types/jest": "^26.0.19", + "@types/jquery": "^3.5.5", "@types/node": "^12.19.12", "@types/react": "^16.14.2", "@types/react-dom": "^16.9.10", diff --git a/simmadome/src/CreateLeague.css b/simmadome/src/CreateLeague.css new file mode 100644 index 0000000..58c3e36 --- /dev/null +++ b/simmadome/src/CreateLeague.css @@ -0,0 +1,301 @@ +.cl_table_header > .cl_subleague_bg { + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; + padding-top: 1rem; +} + +.cl_league_structure_table .cl_table_row:last-child .cl_subleague_bg { + border-bottom-left-radius: 0.5rem; + border-bottom-right-radius: 0.5rem; + padding-bottom: 1rem; +} + +.cl_league_structure_table .cl_table_row:last-child .cl_division_delete { + margin-bottom: 0.5rem; +} + +input { + border: none; + border-radius: 1rem; + height: 2rem; + padding-left: 1rem; + background: var(--background-secondary); + font-size: 14pt; +} + +input:focus { + outline: none; +} + +input[type="number"] { + -webkit-appearance: textfield; + -moz-appearance: textfield; + appearance: textfield; +} +input[type=number]::-webkit-inner-spin-button, +input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; +} + +.cl_league_main { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-top: 3rem; +} + +.cl_league_name { + margin-top: 1rem; +} + +.cl_league_options, .cl_league_structure { + display: flex; + background: var(--background-tertiary); + flex-direction: column; + max-width: 100%; + border-radius: 1rem; + padding-top: 1.5rem; +} + +.cl_league_options { + align-items: center; +} + +.cl_league_structure, .cl_subleague_add_align { + display: flex; + align-items: center; + justify-content: center; + width: min-content; +} + +.cl_league_structure { + margin-top: 1rem; +} + +.cl_league_structure_table { + display: table; + margin-right: 1rem; +} + +.cl_headers, .cl_table_row { + display: table-row; + height: min-content; +} + +.cl_table_header, .cl_delete_filler, .cl_delete_box, .cl_division_cell { + display: table-cell; + height:100%; +} + +.cl_delete_box { + vertical-align: middle; +} + +.cl_league_structure_scrollbox { + max-width: 100%; + overflow-y: scroll; +} + + /* Hide scrollbar for Chrome, Safari and Opera */ +.cl_league_structure_scrollbox::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.cl_league_structure_scrollbox { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +.cl_subleague_add_align{ + margin-left: 1.5rem; + padding-right: 1.5rem; +} + +.cl_subleague_header { + display: flex; + width:100%; + align-items: center; + justify-content: space-between; +} + +.cl_subleague_bg { + background: var(--background-main); + padding: 0.5rem 1rem; + margin: 0rem 0.5rem; + min-width: 22rem; + height: 100%; + box-sizing: border-box; +} + +.cl_subleague_name { + flex-grow: 1; + margin-right: 0.5rem; +} + +.cl_division_name_box { + width: 100%; +} + +.cl_division_name { + margin-bottom: 0.5rem; + width: 95%; +} + +.cl_newteam_name { + width: 95%; +} + +.cl_division { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; + border-radius: 0.5rem; + background: var(--background-accent); +} + +.cl_team, .cl_team_add { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin: 0.4rem 0rem; +} + +.cl_team_name { + font-size: 14pt; + padding: 0 0.5rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.cl_team_add { + display: flex; + flex-direction: column; + margin-bottom: 0rem; +} + +.cl_search_list { + width: 95%; + margin-top: 0.6rem; + padding: 0.5rem; + background: var(--background-tertiary); + border-radius: 0.5rem; +} + +.cl_search_result { + padding: 0.2rem 0.4rem; + border-radius: 0.5rem; +} + +.cl_search_result:hover { + background: var(--background-main); +} + +.cl_league_options { + padding: 1rem; + margin-top: 1.5rem; + width: 55rem; + box-sizing: border-box; +} + +.cl_option_main { + display: flex; + flex-direction: row; + justify-content: space-around; + width: 100%; +} + +.cl_option_submit_box { + display: flex; + flex-direction: column; + align-items: center; +} + +.cl_option_box { + margin: 1rem; + width: max-content; +} + +.cl_option_label, .cl_option_err, .cl_structure_err { + margin: 0.25rem; +} + +.cl_option_err, .cl_structure_err { + color: var(--accent-red); +} + +.cl_option_err { + min-height: 1.5rem; + margin-bottom: -0.5rem; + margin-top: 0.5rem; +} + +.cl_structure_err { + margin-bottom: -0.5rem; +} + +.cl_structure_err_div { + margin-top: -0.5rem; + margin-bottom: 0; +} + +.cl_structure_err_teams { + width: 98%; +} + +/* button styles */ + +button > .emoji { + margin: 0; + width: 1rem; + height: 1rem; +} + +.cl_subleague_delete, .cl_team_delete, .cl_division_delete, .cl_subleague_add, .cl_division_add { + padding: 0; + width: 2rem; + height: 2rem; + border: none; + border-radius: 1rem; + display: flex; + align-items: center; + justify-content: center; +} + +.cl_subleague_delete, .cl_team_delete, .cl_division_delete { + background: var(--accent-red); +} + +.cl_subleague_add, .cl_division_add { + background: var(--accent-green); +} + +.cl_subleague_add { + position: relative; + top: 1.6rem; +} + +.cl_division_add { + margin-top: 1.25rem; + margin-bottom: 1.25rem; +} + +.cl_delete_filler { + min-width: 3rem; +} + +.cl_option_submit { + padding: 1rem 2rem; + height: 2rem; + border: none; + border-radius: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--accent-green); + font-size: 14pt; +} \ No newline at end of file diff --git a/simmadome/src/CreateLeague.tsx b/simmadome/src/CreateLeague.tsx new file mode 100644 index 0000000..b64ce1e --- /dev/null +++ b/simmadome/src/CreateLeague.tsx @@ -0,0 +1,464 @@ +import React, {useState, useRef, useLayoutEffect, useReducer} from 'react'; +import './CreateLeague.css'; +import twemoji from 'twemoji'; + +// STATE CLASSES + +class LeagueStructureState { + subleagues: SubleagueState[] + + constructor(subleagues: SubleagueState[] = []) { + this.subleagues = subleagues; + } +} + +class SubleagueState { + name: string + divisions: DivisionState[] + id: string|number + + constructor(divisions: DivisionState[] = []) { + this.name = ""; + this.divisions = divisions; + this.id = getUID(); + } +} + +class DivisionState { + name: string + teams: TeamState[] + id: string|number + + constructor() { + this.name = ""; + this.teams = []; + this.id = getUID(); + } +} + +class TeamState { + name: string + id: string|number + + constructor(name: string = "") { + this.name = name; + this.id = getUID(); + } +} + +let getUID = function() { // does NOT generate UUIDs. Meant to create list keys ONLY + let id = 0; + return function() { return id++ } +}() + +// STRUCTURE REDUCER + +type StructureReducerActions = + {type: 'remove_subleague', subleague_index: number} | + {type: 'add_subleague'} | + {type: 'rename_subleague', subleague_index: number, name: string} | + {type: 'remove_divisions', division_index: number} | + {type: 'add_divisions'} | + {type: 'rename_division', subleague_index: number, division_index: number, name: string} | + {type: 'remove_team', subleague_index: number, division_index: number, name:string} | + {type: 'add_team', subleague_index:number, division_index:number, name:string} + +function leagueStructureReducer(state: LeagueStructureState, action: StructureReducerActions): LeagueStructureState { + switch (action.type) { + case 'remove_subleague': + return {subleagues: removeIndex(state.subleagues, action.subleague_index)}; + case 'add_subleague': + return {subleagues: append(state.subleagues, new SubleagueState( + arrayOf(state.subleagues[0].divisions.length, i => + new DivisionState() + ) + ))} + case 'rename_subleague': + return replaceSubleague(state, action.subleague_index, subleague => { + let nSubleague = shallowClone(subleague); + nSubleague.name = action.name; + return nSubleague; + }); + case 'remove_divisions': + return {subleagues: state.subleagues.map(subleague => { + let nSubleague = shallowClone(subleague); + nSubleague.divisions = removeIndex(subleague.divisions, action.division_index) + return nSubleague; + })}; + case 'add_divisions': + return {subleagues: state.subleagues.map(subleague => { + let nSubleague = shallowClone(subleague); + nSubleague.divisions = append(subleague.divisions, new DivisionState()) + return nSubleague; + })}; + case 'rename_division': + return replaceDivision(state, action.subleague_index, action.division_index, division => { + let nDivision = shallowClone(division); + nDivision.name = action.name; + return nDivision; + }); + case 'remove_team': + return replaceDivision(state, action.subleague_index, action.division_index, division => { + let nDivision = shallowClone(division); + nDivision.teams = removeIndex(division.teams, division.teams.findIndex(val => val.name === action.name)); + return nDivision; + }); + case 'add_team': + return replaceDivision(state, action.subleague_index, action.division_index, division => { + let nDivision = shallowClone(division); + nDivision.teams = append(division.teams, new TeamState(action.name)); + return nDivision; + }); + } +} + +function replaceSubleague(state: LeagueStructureState, si: number, func: (val: SubleagueState) => SubleagueState) { + return {subleagues: replaceIndex(state.subleagues, si, func(state.subleagues[si]))} +} + +function replaceDivision(state: LeagueStructureState, si: number, di: number, func:(val: DivisionState) => DivisionState) { + return replaceSubleague(state, si, subleague => { + let nSubleague = shallowClone(subleague); + nSubleague.divisions = replaceIndex(subleague.divisions, di, func(subleague.divisions[di])); + return nSubleague; + }); +} + +// OPTIONS REDUCER + +class LeagueOptionsState { + games_series = "3" + intra_division_series = "8" + inter_division_series = "16" + inter_league_series = "8" + top_postseason = "1" + wildcards = "0" +} + +type OptionsReducerActions = + {type: 'set_games_series', value: string} | + {type: 'set_intra_division_series', value: string} | + {type: 'set_inter_division_series', value: string} | + {type: 'set_inter_league_series', value: string} | + {type: 'set_top_postseason', value: string} | + {type: 'set_wildcards', value: string} + +function LeagueOptionsReducer(state: LeagueOptionsState, action: OptionsReducerActions) { + let newState = shallowClone(state); + switch (action.type) { + case 'set_games_series': + newState.games_series = action.value; + break; + case 'set_intra_division_series': + newState.intra_division_series = action.value; + break; + case 'set_inter_division_series': + newState.inter_division_series = action.value; + break; + case 'set_inter_league_series': + newState.inter_league_series = action.value; + break; + case 'set_top_postseason': + newState.top_postseason = action.value; + break; + case 'set_wildcards': + newState.wildcards = action.value; + break; + } + return newState +} + +// UTIL + +function removeIndex(arr: any[], index: number) { + return arr.slice(0, index).concat(arr.slice(index+1)); +} + +function replaceIndex(arr: T[], index: number, val: T) { + return arr.slice(0, index).concat([val]).concat(arr.slice(index+1)); +} + +function append(arr: T[], val: T) { + return arr.concat([val]); +} + +function arrayOf(length: number, func: (i: number) => T): T[] { + var out: T[] = []; + for (var i = 0; i < length; i++) { + out.push(func(i)); + } + return out; +} + +function shallowClone(obj: T): T { + return Object.assign({}, obj); +} + +type DistributiveOmit = T extends any ? Omit : never; +type DistributivePick = T extends any ? Pick : never; + +// CREATE LEAGUE + +let initLeagueStructure = { + subleagues: [0, 1].map((val) => + new SubleagueState([0, 1].map((val) => + new DivisionState() + )) + ) +}; + +function CreateLeague() { + let [name, setName] = useState(""); + let [showError, setShowError] = useState(false); + let [structure, structureDispatch] = useReducer(leagueStructureReducer, initLeagueStructure); + let [options, optionsDispatch] = useReducer(LeagueOptionsReducer, new LeagueOptionsState()); + + let self = useRef(null) + + useLayoutEffect(() => { + if (self.current) { + twemoji.parse(self.current) + } + }) + + return ( +
+ setName(e.target.value)}/> +
{name === "" && showError ? "A name is required." : ""}
+ +
+ +
+ +
{ + !validRequest(name, structure, options) && showError ? + "Cannot create league. Some information is missing or invalid." : "" + }
+
+
+
+ ); +} + +function makeRequest(name:string, structure: LeagueStructureState, options:LeagueOptionsState) { + + if (!validRequest(name, structure, options)) { + return null + } + + return ({ + structure: { + name: name, + subleagues: structure.subleagues.map(subleague => ({ + name: subleague.name, + divisions: subleague.divisions.map(division => ({ + name: division.name, + teams: division.teams + })) + })) + }, + games_per_series: Number(options.games_series), + division_series: Number(options.intra_division_series), + inter_division_series: Number(options.inter_division_series), + inter_league_series: Number(options.inter_league_series), + top_postseason: Number(options.top_postseason), + wildcards: Number(options.wildcards) + }); +} + +function validRequest(name:string, structure: LeagueStructureState, options:LeagueOptionsState) { + + return ( + name !== "" && + validNumber(options.games_series) && + validNumber(options.intra_division_series) && + validNumber(options.inter_division_series) && + validNumber(options.inter_league_series) && + validNumber(options.top_postseason) && + validNumber(options.wildcards) && + structure.subleagues.length % 2 === 0 && + structure.subleagues.every(subleague => + subleague.name !== "" && + subleague.divisions.every(division => + division.name !== "" && + division.teams.length >= 2 + ) + ) + ) +} + +function validNumber(value: string) { + return Number(value) !== NaN && Number(value) > 0 +} + +// LEAGUE STRUCUTRE + +function LeagueStructre(props: {state: LeagueStructureState, dispatch: React.Dispatch, showError: boolean}) { + return ( +
+
+
+
+ + +
+ +
+
+
{props.state.subleagues.length % 2 !== 0 && props.showError ? "Must have an even number of subleagues." : ""}
+ +
+ ); +} + +function SubleagueHeaders(props: {subleagues: SubleagueState[], dispatch: React.Dispatch, showError:boolean}) { + return ( +
+
+ {props.subleagues.map((subleague, i) => ( +
+
+ 1} dispatch={action => + props.dispatch(Object.assign({subleague_index: i}, action)) + }/> +
{subleague.name === "" && props.showError ? "A name is required." : ""}
+
+
+ ))} +
+ ); +} + +function SubleageHeader(props: {state: SubleagueState, canDelete: boolean, dispatch:(action: DistributiveOmit) => void}) { + return ( +
+ + props.dispatch({type: 'rename_subleague', name: e.target.value}) + }/> + {props.canDelete ? : null} +
+ ); +} + +function Divisions(props: {subleagues: SubleagueState[], dispatch: React.Dispatch, showError: boolean}) { + return (<> + {props.subleagues[0].divisions.map((val, di) => ( +
+
+ {props.subleagues[0].divisions.length > 1 ? + : + null + } +
+ {props.subleagues.map((subleague, si) => ( +
+
+ + props.dispatch(Object.assign({subleague_index: si, division_index: di}, action)) + } showError={props.showError}/> +
+
+ ))} +
+ ))} + ); +} + +function Division(props: {state: DivisionState, dispatch:(action: DistributiveOmit) => void, showError:boolean}) { + let [newName, setNewName] = useState(""); + let [searchResults, setSearchResults] = useState([]); + let newNameInput = useRef(null); + let resultList = useRef(null); + + useLayoutEffect(() => { + if (resultList.current) { + twemoji.parse(resultList.current) + } + }) + + return ( +
+
+ + props.dispatch({type: 'rename_division', name: e.target.value}) + }/> +
{props.state.name === "" && props.showError ? "A name is required." : ""}
+
+ {props.state.teams.map((team, i) => ( +
+
{team.name}
+ +
+ ))} +
+ { + let params = new URLSearchParams({query: e.target.value, page_len: '5', page_num: '0'}); + fetch("/api/teams/search?" + params.toString()) + .then(response => response.json()) + .then(data => setSearchResults(data)); + setNewName(e.target.value); + }}/> +
+ {searchResults.length > 0 && newName.length > 0 ? + (
+ {searchResults.map(result => +
{ + props.dispatch({type:'add_team', name: result}); + setNewName(""); + if (newNameInput.current) { + newNameInput.current.focus(); + } + }}>{result}
+ )} +
): +
+ } +
{props.state.teams.length < 2 && props.showError ? "Must have at least 2 teams." : ""}
+
+ ); +} + +// LEAGUE OPTIONS + +type StateBundle = [T, React.Dispatch>] + +function LeagueOptions(props: {state: LeagueOptionsState, dispatch: React.Dispatch, showError: boolean}) { + return ( +
+
+ + props.dispatch({type: 'set_games_series', value: value})} showError={props.showError}/> + + props.dispatch({type: 'set_top_postseason', value: value})} showError={props.showError}/> + + props.dispatch({type: 'set_wildcards', value: value})} showError={props.showError}/> +
+
+ + props.dispatch({type: 'set_intra_division_series', value: value})} showError={props.showError}/> + + props.dispatch({type: 'set_inter_division_series', value: value})} showError={props.showError}/> + + props.dispatch({type: 'set_inter_league_series', value: value})} showError={props.showError}/> +
+
+ ); +} + +function NumberInput(props: {title: string, value: string, setValue: (newVal: string) => void, showError: boolean}) { + return ( +
+
{props.title}
+ props.setValue(e.target.value)}/> +
{(Number(props.value) === NaN || Number(props.value) < 0) && props.showError ? "Must be a number greater than 0" : ""}
+
+ ); +} + +export default CreateLeague; \ No newline at end of file diff --git a/simmadome/src/index.tsx b/simmadome/src/index.tsx index 1235dcc..4f7e32a 100644 --- a/simmadome/src/index.tsx +++ b/simmadome/src/index.tsx @@ -4,6 +4,7 @@ import { BrowserRouter as Router, Switch, Route } from 'react-router-dom'; import './index.css'; import GamesPage from './GamesPage'; import GamePage from './GamePage'; +import CreateLeague from './CreateLeague'; import discordlogo from "./img/discord.png"; import reportWebVitals from './reportWebVitals'; @@ -13,6 +14,7 @@ ReactDOM.render(
+ @@ -20,6 +22,7 @@ ReactDOM.render( document.getElementById('root') ); + function Header() { return (