From 0361eda415a008d4227c493636324ac734bc80c8 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 7 Aug 2025 20:30:13 +0000 Subject: [PATCH] 2 --- .gitignore | 5 + app-shell/src/_schema.json | 5 +- backend/src/db/api/game.js | 286 +++++++++++ backend/src/db/api/match.js | 274 ++++++++++ backend/src/db/migrations/1754598400197.js | 72 +++ backend/src/db/migrations/1754598446296.js | 47 ++ backend/src/db/migrations/1754598471537.js | 72 +++ backend/src/db/migrations/1754598499281.js | 47 ++ backend/src/db/migrations/1754598530358.js | 47 ++ backend/src/db/migrations/1754598556854.js | 49 ++ backend/src/db/migrations/1754598584672.js | 47 ++ backend/src/db/models/game.js | 57 +++ backend/src/db/models/match.js | 55 ++ .../db/seeders/20200430130760-user-roles.js | 52 ++ .../db/seeders/20231127130745-sample-data.js | 172 ++----- backend/src/db/seeders/20250807202640.js | 87 ++++ backend/src/db/seeders/20250807202751.js | 87 ++++ backend/src/index.js | 16 + backend/src/routes/game.js | 440 ++++++++++++++++ backend/src/routes/match.js | 434 ++++++++++++++++ backend/src/services/game.js | 114 +++++ backend/src/services/match.js | 114 +++++ backend/src/services/search.js | 6 + frontend/json/runtimeError.json | 1 + frontend/src/components/Game/CardGame.tsx | 131 +++++ frontend/src/components/Game/ListGame.tsx | 99 ++++ frontend/src/components/Game/TableGame.tsx | 484 ++++++++++++++++++ .../src/components/Game/configureGameCols.tsx | 100 ++++ frontend/src/components/Match/CardMatch.tsx | 120 +++++ frontend/src/components/Match/ListMatch.tsx | 94 ++++ frontend/src/components/Match/TableMatch.tsx | 484 ++++++++++++++++++ .../components/Match/configureMatchCols.tsx | 91 ++++ .../components/WebPageComponents/Header.tsx | 2 +- frontend/src/menuAside.ts | 16 + frontend/src/pages/dashboard.tsx | 70 +++ frontend/src/pages/game/[gameId].tsx | 134 +++++ frontend/src/pages/game/game-edit.tsx | 132 +++++ frontend/src/pages/game/game-list.tsx | 165 ++++++ frontend/src/pages/game/game-new.tsx | 108 ++++ frontend/src/pages/game/game-table.tsx | 164 ++++++ frontend/src/pages/game/game-view.tsx | 91 ++++ frontend/src/pages/index.tsx | 2 +- frontend/src/pages/match/[matchId].tsx | 134 +++++ frontend/src/pages/match/match-edit.tsx | 132 +++++ frontend/src/pages/match/match-list.tsx | 164 ++++++ frontend/src/pages/match/match-new.tsx | 108 ++++ frontend/src/pages/match/match-table.tsx | 163 ++++++ frontend/src/pages/match/match-view.tsx | 86 ++++ frontend/src/pages/web_pages/about.tsx | 2 +- frontend/src/stores/game/gameSlice.ts | 236 +++++++++ frontend/src/stores/match/matchSlice.ts | 236 +++++++++ frontend/src/stores/store.ts | 4 + 52 files changed, 6208 insertions(+), 130 deletions(-) create mode 100644 backend/src/db/api/game.js create mode 100644 backend/src/db/api/match.js create mode 100644 backend/src/db/migrations/1754598400197.js create mode 100644 backend/src/db/migrations/1754598446296.js create mode 100644 backend/src/db/migrations/1754598471537.js create mode 100644 backend/src/db/migrations/1754598499281.js create mode 100644 backend/src/db/migrations/1754598530358.js create mode 100644 backend/src/db/migrations/1754598556854.js create mode 100644 backend/src/db/migrations/1754598584672.js create mode 100644 backend/src/db/models/game.js create mode 100644 backend/src/db/models/match.js create mode 100644 backend/src/db/seeders/20250807202640.js create mode 100644 backend/src/db/seeders/20250807202751.js create mode 100644 backend/src/routes/game.js create mode 100644 backend/src/routes/match.js create mode 100644 backend/src/services/game.js create mode 100644 backend/src/services/match.js create mode 100644 frontend/json/runtimeError.json create mode 100644 frontend/src/components/Game/CardGame.tsx create mode 100644 frontend/src/components/Game/ListGame.tsx create mode 100644 frontend/src/components/Game/TableGame.tsx create mode 100644 frontend/src/components/Game/configureGameCols.tsx create mode 100644 frontend/src/components/Match/CardMatch.tsx create mode 100644 frontend/src/components/Match/ListMatch.tsx create mode 100644 frontend/src/components/Match/TableMatch.tsx create mode 100644 frontend/src/components/Match/configureMatchCols.tsx create mode 100644 frontend/src/pages/game/[gameId].tsx create mode 100644 frontend/src/pages/game/game-edit.tsx create mode 100644 frontend/src/pages/game/game-list.tsx create mode 100644 frontend/src/pages/game/game-new.tsx create mode 100644 frontend/src/pages/game/game-table.tsx create mode 100644 frontend/src/pages/game/game-view.tsx create mode 100644 frontend/src/pages/match/[matchId].tsx create mode 100644 frontend/src/pages/match/match-edit.tsx create mode 100644 frontend/src/pages/match/match-list.tsx create mode 100644 frontend/src/pages/match/match-new.tsx create mode 100644 frontend/src/pages/match/match-table.tsx create mode 100644 frontend/src/pages/match/match-view.tsx create mode 100644 frontend/src/stores/game/gameSlice.ts create mode 100644 frontend/src/stores/match/matchSlice.ts diff --git a/.gitignore b/.gitignore index e427ff3..d0eb167 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ node_modules/ */node_modules/ */build/ + +**/node_modules/ +**/build/ +.DS_Store +.env \ No newline at end of file diff --git a/app-shell/src/_schema.json b/app-shell/src/_schema.json index 26ade79..43c151d 100644 --- a/app-shell/src/_schema.json +++ b/app-shell/src/_schema.json @@ -1,5 +1,4 @@ - - { + "2": "{\"iv\":\"CfoMsI7yqQTYJNQz\",\"encryptedData\":\"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\"}", "Initial version": "{\"iv\":\"5db+rAZVTSNLslfc\",\"encryptedData\":\"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\"}" -} +} \ No newline at end of file diff --git a/backend/src/db/api/game.js b/backend/src/db/api/game.js new file mode 100644 index 0000000..a9bb578 --- /dev/null +++ b/backend/src/db/api/game.js @@ -0,0 +1,286 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class GameDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const game = await db.game.create( + { + id: data.id || undefined, + + description: data.description || null, + iconurl: data.iconurl || null, + entryfee: data.entryfee || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return game; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const gameData = data.map((item, index) => ({ + id: item.id || undefined, + + description: item.description || null, + iconurl: item.iconurl || null, + entryfee: item.entryfee || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const game = await db.game.bulkCreate(gameData, { transaction }); + + // For each item created, replace relation files + + return game; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const game = await db.game.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.description !== undefined) + updatePayload.description = data.description; + + if (data.iconurl !== undefined) updatePayload.iconurl = data.iconurl; + + if (data.entryfee !== undefined) updatePayload.entryfee = data.entryfee; + + updatePayload.updatedById = currentUser.id; + + await game.update(updatePayload, { transaction }); + + return game; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const game = await db.game.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of game) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of game) { + await record.destroy({ transaction }); + } + }); + + return game; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const game = await db.game.findByPk(id, options); + + await game.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await game.destroy({ + transaction, + }); + + return game; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const game = await db.game.findOne({ where }, { transaction }); + + if (!game) { + return game; + } + + const output = game.get({ plain: true }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.description) { + where = { + ...where, + [Op.and]: Utils.ilike('game', 'description', filter.description), + }; + } + + if (filter.iconurl) { + where = { + ...where, + [Op.and]: Utils.ilike('game', 'iconurl', filter.iconurl), + }; + } + + if (filter.entryfeeRange) { + const [start, end] = filter.entryfeeRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + entryfee: { + ...where.entryfee, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + entryfee: { + ...where.entryfee, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.game.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('game', 'id', query), + ], + }; + } + + const records = await db.game.findAll({ + attributes: ['id', 'id'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['id', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.id, + })); + } +}; diff --git a/backend/src/db/api/match.js b/backend/src/db/api/match.js new file mode 100644 index 0000000..c9a4fef --- /dev/null +++ b/backend/src/db/api/match.js @@ -0,0 +1,274 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class MatchDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const match = await db.match.create( + { + id: data.id || undefined, + + status: data.status || null, + prizepool: data.prizepool || null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return match; + } + + static async bulkImport(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + // Prepare data - wrapping individual data transformations in a map() method + const matchData = data.map((item, index) => ({ + id: item.id || undefined, + + status: item.status || null, + prizepool: item.prizepool || null, + importHash: item.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + createdAt: new Date(Date.now() + index * 1000), + })); + + // Bulk create items + const match = await db.match.bulkCreate(matchData, { transaction }); + + // For each item created, replace relation files + + return match; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const match = await db.match.findByPk(id, {}, { transaction }); + + const updatePayload = {}; + + if (data.status !== undefined) updatePayload.status = data.status; + + if (data.prizepool !== undefined) updatePayload.prizepool = data.prizepool; + + updatePayload.updatedById = currentUser.id; + + await match.update(updatePayload, { transaction }); + + return match; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const match = await db.match.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of match) { + await record.update({ deletedBy: currentUser.id }, { transaction }); + } + for (const record of match) { + await record.destroy({ transaction }); + } + }); + + return match; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const match = await db.match.findByPk(id, options); + + await match.update( + { + deletedBy: currentUser.id, + }, + { + transaction, + }, + ); + + await match.destroy({ + transaction, + }); + + return match; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const match = await db.match.findOne({ where }, { transaction }); + + if (!match) { + return match; + } + + const output = match.get({ plain: true }); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const orderBy = null; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { + ...where, + ['id']: Utils.uuid(filter.id), + }; + } + + if (filter.prizepoolRange) { + const [start, end] = filter.prizepoolRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + prizepool: { + ...where.prizepool, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + prizepool: { + ...where.prizepool, + [Op.lte]: end, + }, + }; + } + } + + if (filter.active !== undefined) { + where = { + ...where, + active: filter.active === true || filter.active === 'true', + }; + } + + if (filter.status) { + where = { + ...where, + status: filter.status, + }; + } + + if (filter.createdAtRange) { + const [start, end] = filter.createdAtRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + ['createdAt']: { + ...where.createdAt, + [Op.lte]: end, + }, + }; + } + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: + filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options?.transaction, + logging: console.log, + }; + + if (!options?.countOnly) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.match.findAndCountAll(queryOptions); + + return { + rows: options?.countOnly ? [] : rows, + count: count, + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('match', 'id', query), + ], + }; + } + + const records = await db.match.findAll({ + attributes: ['id', 'id'], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + orderBy: [['id', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.id, + })); + } +}; diff --git a/backend/src/db/migrations/1754598400197.js b/backend/src/db/migrations/1754598400197.js new file mode 100644 index 0000000..d7bdbdb --- /dev/null +++ b/backend/src/db/migrations/1754598400197.js @@ -0,0 +1,72 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'game', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.dropTable('game', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1754598446296.js b/backend/src/db/migrations/1754598446296.js new file mode 100644 index 0000000..7229738 --- /dev/null +++ b/backend/src/db/migrations/1754598446296.js @@ -0,0 +1,47 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'game', + 'description', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('game', 'description', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1754598471537.js b/backend/src/db/migrations/1754598471537.js new file mode 100644 index 0000000..0704d80 --- /dev/null +++ b/backend/src/db/migrations/1754598471537.js @@ -0,0 +1,72 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.createTable( + 'match', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + references: { + key: 'id', + model: 'users', + }, + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + deletedAt: { type: Sequelize.DataTypes.DATE }, + importHash: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.dropTable('match', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1754598499281.js b/backend/src/db/migrations/1754598499281.js new file mode 100644 index 0000000..4849e8d --- /dev/null +++ b/backend/src/db/migrations/1754598499281.js @@ -0,0 +1,47 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'game', + 'iconurl', + { + type: Sequelize.DataTypes.TEXT, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('game', 'iconurl', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1754598530358.js b/backend/src/db/migrations/1754598530358.js new file mode 100644 index 0000000..09d2119 --- /dev/null +++ b/backend/src/db/migrations/1754598530358.js @@ -0,0 +1,47 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'game', + 'entryfee', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('game', 'entryfee', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1754598556854.js b/backend/src/db/migrations/1754598556854.js new file mode 100644 index 0000000..c81335b --- /dev/null +++ b/backend/src/db/migrations/1754598556854.js @@ -0,0 +1,49 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'match', + 'status', + { + type: Sequelize.DataTypes.ENUM, + + values: ['value'], + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('match', 'status', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/1754598584672.js b/backend/src/db/migrations/1754598584672.js new file mode 100644 index 0000000..2344efb --- /dev/null +++ b/backend/src/db/migrations/1754598584672.js @@ -0,0 +1,47 @@ +module.exports = { + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn( + 'match', + 'prizepool', + { + type: Sequelize.DataTypes.DECIMAL, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + /** + * @param {QueryInterface} queryInterface + * @param {Sequelize} Sequelize + * @returns {Promise} + */ + async down(queryInterface, Sequelize) { + /** + * @type {Transaction} + */ + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('match', 'prizepool', { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/models/game.js b/backend/src/db/models/game.js new file mode 100644 index 0000000..ccbb2a2 --- /dev/null +++ b/backend/src/db/models/game.js @@ -0,0 +1,57 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const game = sequelize.define( + 'game', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + description: { + type: DataTypes.TEXT, + }, + + iconurl: { + type: DataTypes.TEXT, + }, + + entryfee: { + type: DataTypes.DECIMAL, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + game.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.game.belongsTo(db.users, { + as: 'createdBy', + }); + + db.game.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return game; +}; diff --git a/backend/src/db/models/match.js b/backend/src/db/models/match.js new file mode 100644 index 0000000..ae9e434 --- /dev/null +++ b/backend/src/db/models/match.js @@ -0,0 +1,55 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function (sequelize, DataTypes) { + const match = sequelize.define( + 'match', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + + status: { + type: DataTypes.ENUM, + + values: ['value'], + }, + + prizepool: { + type: DataTypes.DECIMAL, + }, + + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + match.associate = (db) => { + /// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity + + //end loop + + db.match.belongsTo(db.users, { + as: 'createdBy', + }); + + db.match.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return match; +}; diff --git a/backend/src/db/seeders/20200430130760-user-roles.js b/backend/src/db/seeders/20200430130760-user-roles.js index 6fd33b7..55752f4 100644 --- a/backend/src/db/seeders/20200430130760-user-roles.js +++ b/backend/src/db/seeders/20200430130760-user-roles.js @@ -106,6 +106,8 @@ module.exports = { 'wallets', 'roles', 'permissions', + 'game', + 'match', , ]; await queryInterface.bulkInsert( @@ -840,6 +842,56 @@ primary key ("roles_permissionsId", "permissionId") permissionId: getId('DELETE_PERMISSIONS'), }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_GAME'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_GAME'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_GAME'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_GAME'), + }, + + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('CREATE_MATCH'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('READ_MATCH'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('UPDATE_MATCH'), + }, + { + createdAt, + updatedAt, + roles_permissionsId: getId('Administrator'), + permissionId: getId('DELETE_MATCH'), + }, + { createdAt, updatedAt, diff --git a/backend/src/db/seeders/20231127130745-sample-data.js b/backend/src/db/seeders/20231127130745-sample-data.js index 698c8c4..16f953b 100644 --- a/backend/src/db/seeders/20231127130745-sample-data.js +++ b/backend/src/db/seeders/20231127130745-sample-data.js @@ -9,6 +9,10 @@ const Tokens = db.tokens; const Wallets = db.wallets; +const Game = db.game; + +const Match = db.match; + const GamesData = [ { title: 'Crypto Quest', @@ -39,26 +43,6 @@ const GamesData = [ release_date: new Date('2023-12-05T00:00:00Z'), }, - - { - title: 'Coin Collector', - - description: 'Collect coins and unlock new levels.', - - // type code here for "relation_many" field - - release_date: new Date('2024-01-20T00:00:00Z'), - }, - - { - title: 'Blockchain Battle', - - description: 'Compete in battles to earn blockchain rewards.', - - // type code here for "relation_many" field - - release_date: new Date('2024-02-10T00:00:00Z'), - }, ]; const NftsData = [ @@ -85,22 +69,6 @@ const NftsData = [ // type code here for "relation_one" field }, - - { - name: 'Token Trophy', - - // type code here for "images" field - - // type code here for "relation_one" field - }, - - { - name: 'Digital Diamond', - - // type code here for "images" field - - // type code here for "relation_one" field - }, ]; const TokensData = [ @@ -127,22 +95,6 @@ const TokensData = [ // type code here for "relation_one" field }, - - { - name: 'Blockchain Buck', - - value: 150.25, - - // type code here for "relation_one" field - }, - - { - name: 'Digital Dollar', - - value: 75, - - // type code here for "relation_one" field - }, ]; const WalletsData = [ @@ -169,21 +121,51 @@ const WalletsData = [ // type code here for "relation_one" field }, +]; +const GameData = [ { - address: '0xfedcba987654321', + description: 'Rudolf Virchow', - balance: 1000.25, + iconurl: 'Andreas Vesalius', - // type code here for "relation_one" field + entryfee: 16.12, }, { - address: '0xabcdefabcdefabc', + description: 'Comte de Buffon', - balance: 250, + iconurl: 'Louis Victor de Broglie', - // type code here for "relation_one" field + entryfee: 70.81, + }, + + { + description: 'Arthur Eddington', + + iconurl: 'Carl Gauss (Karl Friedrich Gauss)', + + entryfee: 85.42, + }, +]; + +const MatchData = [ + { + status: 'value', + + prizepool: 52.77, + }, + + { + status: 'value', + + prizepool: 30.09, + }, + + { + status: 'value', + + prizepool: 99.73, }, ]; @@ -224,28 +206,6 @@ async function associateNftWithOwner() { if (Nft2?.setOwner) { await Nft2.setOwner(relatedOwner2); } - - const relatedOwner3 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Nft3 = await Nfts.findOne({ - order: [['id', 'ASC']], - offset: 3, - }); - if (Nft3?.setOwner) { - await Nft3.setOwner(relatedOwner3); - } - - const relatedOwner4 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Nft4 = await Nfts.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (Nft4?.setOwner) { - await Nft4.setOwner(relatedOwner4); - } } async function associateTokenWithOwner() { @@ -281,28 +241,6 @@ async function associateTokenWithOwner() { if (Token2?.setOwner) { await Token2.setOwner(relatedOwner2); } - - const relatedOwner3 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Token3 = await Tokens.findOne({ - order: [['id', 'ASC']], - offset: 3, - }); - if (Token3?.setOwner) { - await Token3.setOwner(relatedOwner3); - } - - const relatedOwner4 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Token4 = await Tokens.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (Token4?.setOwner) { - await Token4.setOwner(relatedOwner4); - } } async function associateWalletWithUser() { @@ -338,28 +276,6 @@ async function associateWalletWithUser() { if (Wallet2?.setUser) { await Wallet2.setUser(relatedUser2); } - - const relatedUser3 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Wallet3 = await Wallets.findOne({ - order: [['id', 'ASC']], - offset: 3, - }); - if (Wallet3?.setUser) { - await Wallet3.setUser(relatedUser3); - } - - const relatedUser4 = await Users.findOne({ - offset: Math.floor(Math.random() * (await Users.count())), - }); - const Wallet4 = await Wallets.findOne({ - order: [['id', 'ASC']], - offset: 4, - }); - if (Wallet4?.setUser) { - await Wallet4.setUser(relatedUser4); - } } module.exports = { @@ -372,6 +288,10 @@ module.exports = { await Wallets.bulkCreate(WalletsData); + await Game.bulkCreate(GameData); + + await Match.bulkCreate(MatchData); + await Promise.all([ // Similar logic for "relation_many" @@ -393,5 +313,9 @@ module.exports = { await queryInterface.bulkDelete('tokens', null, {}); await queryInterface.bulkDelete('wallets', null, {}); + + await queryInterface.bulkDelete('game', null, {}); + + await queryInterface.bulkDelete('match', null, {}); }, }; diff --git a/backend/src/db/seeders/20250807202640.js b/backend/src/db/seeders/20250807202640.js new file mode 100644 index 0000000..53c76e7 --- /dev/null +++ b/backend/src/db/seeders/20250807202640.js @@ -0,0 +1,87 @@ +const { v4: uuid } = require('uuid'); +const db = require('../models'); +const Sequelize = require('sequelize'); +const config = require('../../config'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = ['game']; + + const createdPermissions = entities.flatMap(createPermissions); + + // Add permissions to database + await queryInterface.bulkInsert('permissions', createdPermissions); + // Get permissions ids + const permissionsIds = createdPermissions.map((p) => p.id); + // Get admin role + const adminRole = await db.roles.findOne({ + where: { name: config.roles.admin }, + }); + + if (adminRole) { + // Add permissions to admin role if it exists + await adminRole.addPermissions(permissionsIds); + } + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete( + 'permissions', + entities.flatMap(createPermissions), + ); + }, +}; diff --git a/backend/src/db/seeders/20250807202751.js b/backend/src/db/seeders/20250807202751.js new file mode 100644 index 0000000..18fe948 --- /dev/null +++ b/backend/src/db/seeders/20250807202751.js @@ -0,0 +1,87 @@ +const { v4: uuid } = require('uuid'); +const db = require('../models'); +const Sequelize = require('sequelize'); +const config = require('../../config'); + +module.exports = { + /** + * @param{import("sequelize").QueryInterface} queryInterface + * @return {Promise} + */ + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + /** @type {Map} */ + const idMap = new Map(); + + /** + * @param {string} key + * @return {string} + */ + function getId(key) { + if (idMap.has(key)) { + return idMap.get(key); + } + const id = uuid(); + idMap.set(key, id); + return id; + } + + /** + * @param {string} name + */ + function createPermissions(name) { + return [ + { + id: getId(`CREATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `CREATE_${name.toUpperCase()}`, + }, + { + id: getId(`READ_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `READ_${name.toUpperCase()}`, + }, + { + id: getId(`UPDATE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `UPDATE_${name.toUpperCase()}`, + }, + { + id: getId(`DELETE_${name.toUpperCase()}`), + createdAt, + updatedAt, + name: `DELETE_${name.toUpperCase()}`, + }, + ]; + } + + const entities = ['match']; + + const createdPermissions = entities.flatMap(createPermissions); + + // Add permissions to database + await queryInterface.bulkInsert('permissions', createdPermissions); + // Get permissions ids + const permissionsIds = createdPermissions.map((p) => p.id); + // Get admin role + const adminRole = await db.roles.findOne({ + where: { name: config.roles.admin }, + }); + + if (adminRole) { + // Add permissions to admin role if it exists + await adminRole.addPermissions(permissionsIds); + } + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.bulkDelete( + 'permissions', + entities.flatMap(createPermissions), + ); + }, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 3713345..f70b234 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -33,6 +33,10 @@ const rolesRoutes = require('./routes/roles'); const permissionsRoutes = require('./routes/permissions'); +const gameRoutes = require('./routes/game'); + +const matchRoutes = require('./routes/match'); + const getBaseUrl = (url) => { if (!url) return ''; return url.endsWith('/api') ? url.slice(0, -4) : url; @@ -140,6 +144,18 @@ app.use( permissionsRoutes, ); +app.use( + '/api/game', + passport.authenticate('jwt', { session: false }), + gameRoutes, +); + +app.use( + '/api/match', + passport.authenticate('jwt', { session: false }), + matchRoutes, +); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/game.js b/backend/src/routes/game.js new file mode 100644 index 0000000..e3a7395 --- /dev/null +++ b/backend/src/routes/game.js @@ -0,0 +1,440 @@ +const express = require('express'); + +const GameService = require('../services/game'); +const GameDBApi = require('../db/api/game'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('game')); + +/** + * @swagger + * components: + * schemas: + * Game: + * type: object + * properties: + + * description: + * type: string + * default: description + * iconurl: + * type: string + * default: iconurl + + * entryfee: + * type: integer + * format: int64 + + */ + +/** + * @swagger + * tags: + * name: Game + * description: The Game managing API + */ + +/** + * @swagger + * /api/game: + * post: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Game" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Game" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await GameService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Game" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Game" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await GameService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/game/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Game" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Game" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await GameService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/game/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Game" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await GameService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/game/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Game" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await GameService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/game: + * get: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Get all game + * description: Get all game + * responses: + * 200: + * description: Game list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Game" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await GameDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'description', 'iconurl', 'entryfee']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/game/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Count all game + * description: Count all game + * responses: + * 200: + * description: Game count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Game" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await GameDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/game/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Find all game that match search criteria + * description: Find all game that match search criteria + * responses: + * 200: + * description: Game list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Game" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await GameDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/game/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Game] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Game" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await GameDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/match.js b/backend/src/routes/match.js new file mode 100644 index 0000000..f565e13 --- /dev/null +++ b/backend/src/routes/match.js @@ -0,0 +1,434 @@ +const express = require('express'); + +const MatchService = require('../services/match'); +const MatchDBApi = require('../db/api/match'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const { parse } = require('json2csv'); + +const { checkCrudPermissions } = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('match')); + +/** + * @swagger + * components: + * schemas: + * Match: + * type: object + * properties: + + * prizepool: + * type: integer + * format: int64 + + * + */ + +/** + * @swagger + * tags: + * name: Match + * description: The Match managing API + */ + +/** + * @swagger + * /api/match: + * post: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Add new item + * description: Add new item + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Match" + * responses: + * 200: + * description: The item was successfully added + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Match" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + */ +router.post( + '/', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await MatchService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Match" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Match" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post( + '/bulk-import', + wrapAsync(async (req, res) => { + const referer = + req.headers.referer || + `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await MatchService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/match/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Match" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Match" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put( + '/:id', + wrapAsync(async (req, res) => { + await MatchService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/match/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Match" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete( + '/:id', + wrapAsync(async (req, res) => { + await MatchService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/match/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Match" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post( + '/deleteByIds', + wrapAsync(async (req, res) => { + await MatchService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/match: + * get: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Get all match + * description: Get all match + * responses: + * 200: + * description: Match list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Match" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/', + wrapAsync(async (req, res) => { + const filetype = req.query.filetype; + + const currentUser = req.currentUser; + const payload = await MatchDBApi.findAll(req.query, { currentUser }); + if (filetype && filetype === 'csv') { + const fields = ['id', 'prizepool']; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv); + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + }), +); + +/** + * @swagger + * /api/match/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Count all match + * description: Count all match + * responses: + * 200: + * description: Match count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Match" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get( + '/count', + wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const payload = await MatchDBApi.findAll(req.query, null, { + countOnly: true, + currentUser, + }); + + res.status(200).send(payload); + }), +); + +/** + * @swagger + * /api/match/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Find all match that match search criteria + * description: Find all match that match search criteria + * responses: + * 200: + * description: Match list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Match" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + const payload = await MatchDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/match/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Match] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Match" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get( + '/:id', + wrapAsync(async (req, res) => { + const payload = await MatchDBApi.findBy({ id: req.params.id }); + + res.status(200).send(payload); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/services/game.js b/backend/src/services/game.js new file mode 100644 index 0000000..5b7efef --- /dev/null +++ b/backend/src/services/game.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const GameDBApi = require('../db/api/game'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class GameService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await GameDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await GameDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let game = await GameDBApi.findBy({ id }, { transaction }); + + if (!game) { + throw new ValidationError('gameNotFound'); + } + + const updatedGame = await GameDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedGame; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await GameDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await GameDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/match.js b/backend/src/services/match.js new file mode 100644 index 0000000..9c7c142 --- /dev/null +++ b/backend/src/services/match.js @@ -0,0 +1,114 @@ +const db = require('../db/models'); +const MatchDBApi = require('../db/api/match'); +const processFile = require('../middlewares/upload'); +const ValidationError = require('./notifications/errors/validation'); +const csv = require('csv-parser'); +const axios = require('axios'); +const config = require('../config'); +const stream = require('stream'); + +module.exports = class MatchService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await MatchDBApi.create(data, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8')); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }); + + await MatchDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let match = await MatchDBApi.findBy({ id }, { transaction }); + + if (!match) { + throw new ValidationError('matchNotFound'); + } + + const updatedMatch = await MatchDBApi.update(id, data, { + currentUser, + transaction, + }); + + await transaction.commit(); + return updatedMatch; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await MatchDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await MatchDBApi.remove(id, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/search.js b/backend/src/services/search.js index 0132eb9..2c41b85 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -50,11 +50,17 @@ module.exports = class SearchService { tokens: ['name'], wallets: ['address'], + + game: ['description', 'iconurl'], }; const columnsInt = { tokens: ['value'], wallets: ['balance'], + + game: ['entryfee'], + + match: ['prizepool'], }; let allFoundRecords = []; diff --git a/frontend/json/runtimeError.json b/frontend/json/runtimeError.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/frontend/json/runtimeError.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/frontend/src/components/Game/CardGame.tsx b/frontend/src/components/Game/CardGame.tsx new file mode 100644 index 0000000..6920d8c --- /dev/null +++ b/frontend/src/components/Game/CardGame.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + game: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardGame = ({ + game, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_GAME'); + + return ( +
+ {loading && } +
    + {!loading && + game.map((item, index) => ( +
  • +
    + + {item.id} + + +
    + +
    +
    +
    +
    +
    + Description +
    +
    +
    + {item.description} +
    +
    +
    + +
    +
    + Iconurl +
    +
    +
    + {item.iconurl} +
    +
    +
    + +
    +
    + Entryfee +
    +
    +
    + {item.entryfee} +
    +
    +
    +
    +
  • + ))} + {!loading && game.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardGame; diff --git a/frontend/src/components/Game/ListGame.tsx b/frontend/src/components/Game/ListGame.tsx new file mode 100644 index 0000000..f6c8f7c --- /dev/null +++ b/frontend/src/components/Game/ListGame.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + game: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListGame = ({ + game, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_GAME'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + game.map((item) => ( +
+ +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

Description

+

{item.description}

+
+ +
+

Iconurl

+

{item.iconurl}

+
+ +
+

Entryfee

+

{item.entryfee}

+
+ + +
+
+
+ ))} + {!loading && game.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListGame; diff --git a/frontend/src/components/Game/TableGame.tsx b/frontend/src/components/Game/TableGame.tsx new file mode 100644 index 0000000..2b82594 --- /dev/null +++ b/frontend/src/components/Game/TableGame.tsx @@ -0,0 +1,484 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/game/gameSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureGameCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSampleGame = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + game, + loading, + count, + notify: gameNotify, + refetch, + } = useAppSelector((state) => state.game); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (gameNotify.showNotification) { + notify(gameNotify.typeNotification, gameNotify.textNotification); + } + }, [gameNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `game`, currentUser).then((newCols) => + setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={game ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleGame; diff --git a/frontend/src/components/Game/configureGameCols.tsx b/frontend/src/components/Game/configureGameCols.tsx new file mode 100644 index 0000000..dc0ea6d --- /dev/null +++ b/frontend/src/components/Game/configureGameCols.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_GAME'); + + return [ + { + field: 'description', + headerName: 'Description', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'iconurl', + headerName: 'Iconurl', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + }, + + { + field: 'entryfee', + headerName: 'Entryfee', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
+ +
, + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/Match/CardMatch.tsx b/frontend/src/components/Match/CardMatch.tsx new file mode 100644 index 0000000..bdcf682 --- /dev/null +++ b/frontend/src/components/Match/CardMatch.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import { saveFile } from '../../helpers/fileSaver'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + match: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardMatch = ({ + match, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_MATCH'); + + return ( +
+ {loading && } +
    + {!loading && + match.map((item, index) => ( +
  • +
    + + {item.id} + + +
    + +
    +
    +
    +
    +
    + Status +
    +
    +
    + {item.status} +
    +
    +
    + +
    +
    + Prizepool +
    +
    +
    + {item.prizepool} +
    +
    +
    +
    +
  • + ))} + {!loading && match.length === 0 && ( +
    +

    No data to display

    +
    + )} +
+
+ +
+
+ ); +}; + +export default CardMatch; diff --git a/frontend/src/components/Match/ListMatch.tsx b/frontend/src/components/Match/ListMatch.tsx new file mode 100644 index 0000000..73105a8 --- /dev/null +++ b/frontend/src/components/Match/ListMatch.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import { saveFile } from '../../helpers/fileSaver'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import { Pagination } from '../Pagination'; +import LoadingSpinner from '../LoadingSpinner'; +import Link from 'next/link'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Props = { + match: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListMatch = ({ + match, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_MATCH'); + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + return ( + <> +
+ {loading && } + {!loading && + match.map((item) => ( +
+ +
+ dark:divide-dark-700 overflow-x-auto' + } + > +
+

Status

+

{item.status}

+
+ +
+

Prizepool

+

{item.prizepool}

+
+ + +
+
+
+ ))} + {!loading && match.length === 0 && ( +
+

No data to display

+
+ )} +
+
+ +
+ + ); +}; + +export default ListMatch; diff --git a/frontend/src/components/Match/TableMatch.tsx b/frontend/src/components/Match/TableMatch.tsx new file mode 100644 index 0000000..1efebe5 --- /dev/null +++ b/frontend/src/components/Match/TableMatch.tsx @@ -0,0 +1,484 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton'; +import CardBoxModal from '../CardBoxModal'; +import CardBox from '../CardBox'; +import { + fetch, + update, + deleteItem, + setRefetch, + deleteItemsByIds, +} from '../../stores/match/matchSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { Field, Form, Formik } from 'formik'; +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { loadColumns } from './configureMatchCols'; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter'; +import { dataGridStyles } from '../../styles'; + +const perPage = 10; + +const TableSampleMatch = ({ + filterItems, + setFilterItems, + filters, + showGrid, +}) => { + const notify = (type, msg) => toast(msg, { type, position: 'bottom-center' }); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { + match, + loading, + count, + notify: matchNotify, + refetch, + } = useAppSelector((state) => state.match); + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = + Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (matchNotify.showNotification) { + notify(matchNotify.typeNotification, matchNotify.textNotification); + } + }, [matchNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false); + const [isModalTrashActive, setIsModalTrashActive] = useState(false); + + const handleModalAction = () => { + setIsModalInfoActive(false); + setIsModalTrashActive(false); + }; + + const handleDeleteModalAction = (id: string) => { + setId(id); + setIsModalTrashActive(true); + }; + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } }; + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + useEffect(() => { + if (!currentUser) return; + + loadColumns(handleDeleteModalAction, `match`, currentUser).then((newCols) => + setColumns(newCols), + ); + }, [currentUser]); + + const handleTableSubmit = async (id: string, data) => { + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + const dataGrid = ( +
+ `datagrid--row`} + rows={match ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids); + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
+ ); + + return ( + <> + {filterItems && Array.isArray(filterItems) && filterItems.length ? ( + + null} + > +
+ <> + {filterItems && + filterItems.map((filterItem) => { + return ( +
+
+
+ Filter +
+ + {filters.map((selectOption) => ( + + ))} + +
+ {filters.find( + (filter) => + filter.title === filterItem?.fields?.selectedField, + )?.type === 'enum' ? ( +
+
Value
+ + + {filters + .find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + ) + ?.options?.map((option) => ( + + ))} + +
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.number ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField, + )?.date ? ( +
+
+
+ From +
+ +
+
+
+ To +
+ +
+
+ ) : ( +
+
+ Contains +
+ +
+ )} +
+
+ Action +
+ { + deleteFilter(filterItem.id); + }} + /> +
+
+ ); + })} +
+ + +
+ +
+
+
+ ) : null} + +

Are you sure you want to delete this item?

+
+ + {dataGrid} + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ); +}; + +export default TableSampleMatch; diff --git a/frontend/src/components/Match/configureMatchCols.tsx b/frontend/src/components/Match/configureMatchCols.tsx new file mode 100644 index 0000000..30142d9 --- /dev/null +++ b/frontend/src/components/Match/configureMatchCols.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import DataGridMultiSelect from '../DataGridMultiSelect'; +import ListActionsPopover from '../ListActionsPopover'; + +import { hasPermission } from '../../helpers/userPermissions'; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user, +) => { + async function callOptionsApi(entityName: string) { + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_MATCH'); + + return [ + { + field: 'status', + headerName: 'Status', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'singleSelect', + valueOptions: ['value'], + }, + + { + field: 'prizepool', + headerName: 'Prizepool', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: hasUpdatePermission, + + type: 'number', + }, + + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + return [ +
+ +
, + ]; + }, + }, + ]; +}; diff --git a/frontend/src/components/WebPageComponents/Header.tsx b/frontend/src/components/WebPageComponents/Header.tsx index 2fee04b..03fd97a 100644 --- a/frontend/src/components/WebPageComponents/Header.tsx +++ b/frontend/src/components/WebPageComponents/Header.tsx @@ -19,7 +19,7 @@ export default function WebSiteHeader({ projectName }: WebSiteHeaderProps) { const style = HeaderStyle.PAGES_RIGHT; - const design = HeaderDesigns.DESIGN_DIVERSITY; + const design = HeaderDesigns.DEFAULT_DESIGN; return (
{ const [wallets, setWallets] = React.useState(loadingMessage); const [roles, setRoles] = React.useState(loadingMessage); const [permissions, setPermissions] = React.useState(loadingMessage); + const [game, setGame] = React.useState(loadingMessage); + const [match, setMatch] = React.useState(loadingMessage); const [widgetsRole, setWidgetsRole] = React.useState({ role: { value: '', label: '' }, @@ -53,6 +55,8 @@ const Dashboard = () => { 'wallets', 'roles', 'permissions', + 'game', + 'match', ]; const fns = [ setUsers, @@ -62,6 +66,8 @@ const Dashboard = () => { setWallets, setRoles, setPermissions, + setGame, + setMatch, ]; const requests = entities.map((entity, index) => { @@ -417,6 +423,70 @@ const Dashboard = () => {
)} + + {hasPermission(currentUser, 'READ_GAME') && ( + +
+
+
+
+ Game +
+
+ {game} +
+
+
+ +
+
+
+ + )} + + {hasPermission(currentUser, 'READ_MATCH') && ( + +
+
+
+
+ Match +
+
+ {match} +
+
+
+ +
+
+
+ + )} diff --git a/frontend/src/pages/game/[gameId].tsx b/frontend/src/pages/game/[gameId].tsx new file mode 100644 index 0000000..8c38023 --- /dev/null +++ b/frontend/src/pages/game/[gameId].tsx @@ -0,0 +1,134 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/game/gameSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditGame = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + description: '', + + iconurl: '', + + entryfee: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { game } = useAppSelector((state) => state.game); + + const { gameId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: gameId })); + }, [gameId]); + + useEffect(() => { + if (typeof game === 'object') { + setInitialValues(game); + } + }, [game]); + + useEffect(() => { + if (typeof game === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = game[el])); + + setInitialValues(newInitialVal); + } + }, [game]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: gameId, data })); + await router.push('/game/game-list'); + }; + + return ( + <> + + {getPageTitle('Edit game')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + router.push('/game/game-list')} + /> + + +
+
+
+ + ); +}; + +EditGame.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default EditGame; diff --git a/frontend/src/pages/game/game-edit.tsx b/frontend/src/pages/game/game-edit.tsx new file mode 100644 index 0000000..a16d6d1 --- /dev/null +++ b/frontend/src/pages/game/game-edit.tsx @@ -0,0 +1,132 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/game/gameSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditGamePage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + description: '', + + iconurl: '', + + entryfee: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { game } = useAppSelector((state) => state.game); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof game === 'object') { + setInitialValues(game); + } + }, [game]); + + useEffect(() => { + if (typeof game === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = game[el])); + setInitialValues(newInitialVal); + } + }, [game]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/game/game-list'); + }; + + return ( + <> + + {getPageTitle('Edit game')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + router.push('/game/game-list')} + /> + + +
+
+
+ + ); +}; + +EditGamePage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default EditGamePage; diff --git a/frontend/src/pages/game/game-list.tsx b/frontend/src/pages/game/game-list.tsx new file mode 100644 index 0000000..78f81b6 --- /dev/null +++ b/frontend/src/pages/game/game-list.tsx @@ -0,0 +1,165 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableGame from '../../components/Game/TableGame'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/game/gameSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const GameTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Description', title: 'description' }, + { label: 'Iconurl', title: 'iconurl' }, + + { label: 'Entryfee', title: 'entryfee', number: 'true' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_GAME'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getGameCSV = async () => { + const response = await axios({ + url: '/game?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'gameCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Game')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + + +
+ + + + + ); +}; + +GameTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default GameTablesPage; diff --git a/frontend/src/pages/game/game-new.tsx b/frontend/src/pages/game/game-new.tsx new file mode 100644 index 0000000..301a97d --- /dev/null +++ b/frontend/src/pages/game/game-new.tsx @@ -0,0 +1,108 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/game/gameSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + description: '', + + iconurl: '', + + entryfee: '', +}; + +const GameNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/game/game-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + router.push('/game/game-list')} + /> + + +
+
+
+ + ); +}; + +GameNew.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default GameNew; diff --git a/frontend/src/pages/game/game-table.tsx b/frontend/src/pages/game/game-table.tsx new file mode 100644 index 0000000..afa9790 --- /dev/null +++ b/frontend/src/pages/game/game-table.tsx @@ -0,0 +1,164 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableGame from '../../components/Game/TableGame'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/game/gameSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const GameTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Description', title: 'description' }, + { label: 'Iconurl', title: 'iconurl' }, + + { label: 'Entryfee', title: 'entryfee', number: 'true' }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_GAME'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getGameCSV = async () => { + const response = await axios({ + url: '/game?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'gameCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Game')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + +
+ + + + + ); +}; + +GameTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default GameTablesPage; diff --git a/frontend/src/pages/game/game-view.tsx b/frontend/src/pages/game/game-view.tsx new file mode 100644 index 0000000..af7664b --- /dev/null +++ b/frontend/src/pages/game/game-view.tsx @@ -0,0 +1,91 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/game/gameSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const GameView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { game } = useAppSelector((state) => state.game); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View game')} + + + + + + +
+

Description

+

{game?.description}

+
+ +
+

Iconurl

+

{game?.iconurl}

+
+ +
+

Entryfee

+

{game?.entryfee || 'No data'}

+
+ + + + router.push('/game/game-list')} + /> +
+
+ + ); +}; + +GameView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default GameView; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 09b17c2..8c558e4 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -93,7 +93,7 @@ export default function WebSite() { { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + status: '', + + prizepool: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { match } = useAppSelector((state) => state.match); + + const { matchId } = router.query; + + useEffect(() => { + dispatch(fetch({ id: matchId })); + }, [matchId]); + + useEffect(() => { + if (typeof match === 'object') { + setInitialValues(match); + } + }, [match]); + + useEffect(() => { + if (typeof match === 'object') { + const newInitialVal = { ...initVals }; + + Object.keys(initVals).forEach((el) => (newInitialVal[el] = match[el])); + + setInitialValues(newInitialVal); + } + }, [match]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: matchId, data })); + await router.push('/match/match-list'); + }; + + return ( + <> + + {getPageTitle('Edit match')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + router.push('/match/match-list')} + /> + + +
+
+
+ + ); +}; + +EditMatch.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditMatch; diff --git a/frontend/src/pages/match/match-edit.tsx b/frontend/src/pages/match/match-edit.tsx new file mode 100644 index 0000000..0ec3115 --- /dev/null +++ b/frontend/src/pages/match/match-edit.tsx @@ -0,0 +1,132 @@ +import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; + +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { SwitchField } from '../../components/SwitchField'; +import { RichTextField } from '../../components/RichTextField'; + +import { update, fetch } from '../../stores/match/matchSlice'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; + +const EditMatchPage = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const initVals = { + status: '', + + prizepool: '', + }; + const [initialValues, setInitialValues] = useState(initVals); + + const { match } = useAppSelector((state) => state.match); + + const { id } = router.query; + + useEffect(() => { + dispatch(fetch({ id: id })); + }, [id]); + + useEffect(() => { + if (typeof match === 'object') { + setInitialValues(match); + } + }, [match]); + + useEffect(() => { + if (typeof match === 'object') { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach((el) => (newInitialVal[el] = match[el])); + setInitialValues(newInitialVal); + } + }, [match]); + + const handleSubmit = async (data) => { + await dispatch(update({ id: id, data })); + await router.push('/match/match-list'); + }; + + return ( + <> + + {getPageTitle('Edit match')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + router.push('/match/match-list')} + /> + + +
+
+
+ + ); +}; + +EditMatchPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default EditMatchPage; diff --git a/frontend/src/pages/match/match-list.tsx b/frontend/src/pages/match/match-list.tsx new file mode 100644 index 0000000..647dd47 --- /dev/null +++ b/frontend/src/pages/match/match-list.tsx @@ -0,0 +1,164 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableMatch from '../../components/Match/TableMatch'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/match/matchSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const MatchTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Prizepool', title: 'prizepool', number: 'true' }, + + { label: 'Status', title: 'status', type: 'enum', options: ['value'] }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_MATCH'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getMatchCSV = async () => { + const response = await axios({ + url: '/match?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'matchCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Match')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + + +
+ + + + + ); +}; + +MatchTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default MatchTablesPage; diff --git a/frontend/src/pages/match/match-new.tsx b/frontend/src/pages/match/match-new.tsx new file mode 100644 index 0000000..f1b2f3d --- /dev/null +++ b/frontend/src/pages/match/match-new.tsx @@ -0,0 +1,108 @@ +import { + mdiAccount, + mdiChartTimelineVariant, + mdiMail, + mdiUpload, +} from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; + +import { Field, Form, Formik } from 'formik'; +import FormField from '../../components/FormField'; +import BaseDivider from '../../components/BaseDivider'; +import BaseButtons from '../../components/BaseButtons'; +import BaseButton from '../../components/BaseButton'; +import FormCheckRadio from '../../components/FormCheckRadio'; +import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'; +import FormFilePicker from '../../components/FormFilePicker'; +import FormImagePicker from '../../components/FormImagePicker'; +import { SwitchField } from '../../components/SwitchField'; + +import { SelectField } from '../../components/SelectField'; +import { SelectFieldMany } from '../../components/SelectFieldMany'; +import { RichTextField } from '../../components/RichTextField'; + +import { create } from '../../stores/match/matchSlice'; +import { useAppDispatch } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import moment from 'moment'; + +const initialValues = { + status: '', + + prizepool: '', +}; + +const MatchNew = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const handleSubmit = async (data) => { + await dispatch(create(data)); + await router.push('/match/match-list'); + }; + return ( + <> + + {getPageTitle('New Item')} + + + + {''} + + + handleSubmit(values)} + > +
+ + + + + + + + + + + + + + + + + router.push('/match/match-list')} + /> + + +
+
+
+ + ); +}; + +MatchNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default MatchNew; diff --git a/frontend/src/pages/match/match-table.tsx b/frontend/src/pages/match/match-table.tsx new file mode 100644 index 0000000..374f450 --- /dev/null +++ b/frontend/src/pages/match/match-table.tsx @@ -0,0 +1,163 @@ +import { mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react'; +import CardBox from '../../components/CardBox'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import SectionMain from '../../components/SectionMain'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../../config'; +import TableMatch from '../../components/Match/TableMatch'; +import BaseButton from '../../components/BaseButton'; +import axios from 'axios'; +import Link from 'next/link'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import CardBoxModal from '../../components/CardBoxModal'; +import DragDropFilePicker from '../../components/DragDropFilePicker'; +import { setRefetch, uploadCsv } from '../../stores/match/matchSlice'; + +import { hasPermission } from '../../helpers/userPermissions'; + +const MatchTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + const { currentUser } = useAppSelector((state) => state.auth); + + const dispatch = useAppDispatch(); + + const [filters] = useState([ + { label: 'Prizepool', title: 'prizepool', number: 'true' }, + + { label: 'Status', title: 'status', type: 'enum', options: ['value'] }, + ]); + + const hasCreatePermission = + currentUser && hasPermission(currentUser, 'CREATE_MATCH'); + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getMatchCSV = async () => { + const response = await axios({ + url: '/match?filetype=csv', + method: 'GET', + responseType: 'blob', + }); + const type = response.headers['content-type']; + const blob = new Blob([response.data], { type: type }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'matchCSV.csv'; + link.click(); + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Match')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
+
+
+
+ + + +
+ + + + + ); +}; + +MatchTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default MatchTablesPage; diff --git a/frontend/src/pages/match/match-view.tsx b/frontend/src/pages/match/match-view.tsx new file mode 100644 index 0000000..71b370c --- /dev/null +++ b/frontend/src/pages/match/match-view.tsx @@ -0,0 +1,86 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import dayjs from 'dayjs'; +import { useAppDispatch, useAppSelector } from '../../stores/hooks'; +import { useRouter } from 'next/router'; +import { fetch } from '../../stores/match/matchSlice'; +import { saveFile } from '../../helpers/fileSaver'; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from '../../components/ImageField'; +import LayoutAuthenticated from '../../layouts/Authenticated'; +import { getPageTitle } from '../../config'; +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; +import SectionMain from '../../components/SectionMain'; +import CardBox from '../../components/CardBox'; +import BaseButton from '../../components/BaseButton'; +import BaseDivider from '../../components/BaseDivider'; +import { mdiChartTimelineVariant } from '@mdi/js'; +import { SwitchField } from '../../components/SwitchField'; +import FormField from '../../components/FormField'; + +const MatchView = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const { match } = useAppSelector((state) => state.match); + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str, `str`); + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + return ( + <> + + {getPageTitle('View match')} + + + + + + +
+

Status

+

{match?.status ?? 'No data'}

+
+ +
+

Prizepool

+

{match?.prizepool || 'No data'}

+
+ + + + router.push('/match/match-list')} + /> +
+
+ + ); +}; + +MatchView.getLayout = function getLayout(page: ReactElement) { + return ( + {page} + ); +}; + +export default MatchView; diff --git a/frontend/src/pages/web_pages/about.tsx b/frontend/src/pages/web_pages/about.tsx index e49634f..7296ffc 100644 --- a/frontend/src/pages/web_pages/about.tsx +++ b/frontend/src/pages/web_pages/about.tsx @@ -83,7 +83,7 @@ export default function WebSite() { { + const { id, query } = data; + const result = await axios.get(`game${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'game/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('game/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'game/deleteGame', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`game/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'game/createGame', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('game', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'game/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('game/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'game/updateGame', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`game/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const gameSlice = createSlice({ + name: 'game', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.game = action.payload.rows; + state.count = action.payload.count; + } else { + state.game = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Game has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Game'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Game'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Game'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Game has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = gameSlice.actions; + +export default gameSlice.reducer; diff --git a/frontend/src/stores/match/matchSlice.ts b/frontend/src/stores/match/matchSlice.ts new file mode 100644 index 0000000..458aee1 --- /dev/null +++ b/frontend/src/stores/match/matchSlice.ts @@ -0,0 +1,236 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { + fulfilledNotify, + rejectNotify, + resetNotify, +} from '../../helpers/notifyStateHandler'; + +interface MainState { + match: any; + loading: boolean; + count: number; + refetch: boolean; + rolesWidgets: any[]; + notify: { + showNotification: boolean; + textNotification: string; + typeNotification: string; + }; +} + +const initialState: MainState = { + match: [], + loading: false, + count: 0, + refetch: false, + rolesWidgets: [], + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +}; + +export const fetch = createAsyncThunk('match/fetch', async (data: any) => { + const { id, query } = data; + const result = await axios.get(`match${query || (id ? `/${id}` : '')}`); + return id + ? result.data + : { rows: result.data.rows, count: result.data.count }; +}); + +export const deleteItemsByIds = createAsyncThunk( + 'match/deleteByIds', + async (data: any, { rejectWithValue }) => { + try { + await axios.post('match/deleteByIds', { data }); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const deleteItem = createAsyncThunk( + 'match/deleteMatch', + async (id: string, { rejectWithValue }) => { + try { + await axios.delete(`match/${id}`); + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const create = createAsyncThunk( + 'match/createMatch', + async (data: any, { rejectWithValue }) => { + try { + const result = await axios.post('match', { data }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const uploadCsv = createAsyncThunk( + 'match/uploadCsv', + async (file: File, { rejectWithValue }) => { + try { + const data = new FormData(); + data.append('file', file); + data.append('filename', file.name); + + const result = await axios.post('match/bulk-import', data, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const update = createAsyncThunk( + 'match/updateMatch', + async (payload: any, { rejectWithValue }) => { + try { + const result = await axios.put(`match/${payload.id}`, { + id: payload.id, + data: payload.data, + }); + return result.data; + } catch (error) { + if (!error.response) { + throw error; + } + + return rejectWithValue(error.response.data); + } + }, +); + +export const matchSlice = createSlice({ + name: 'match', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetch.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(fetch.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(fetch.fulfilled, (state, action) => { + if (action.payload.rows && action.payload.count >= 0) { + state.match = action.payload.rows; + state.count = action.payload.count; + } else { + state.match = action.payload; + } + state.loading = false; + }); + + builder.addCase(deleteItemsByIds.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItemsByIds.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Match has been deleted'); + }); + + builder.addCase(deleteItemsByIds.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(deleteItem.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + + builder.addCase(deleteItem.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Match'.slice(0, -1)} has been deleted`); + }); + + builder.addCase(deleteItem.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(create.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(create.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Match'.slice(0, -1)} has been created`); + }); + + builder.addCase(update.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(update.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, `${'Match'.slice(0, -1)} has been updated`); + }); + builder.addCase(update.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + + builder.addCase(uploadCsv.pending, (state) => { + state.loading = true; + resetNotify(state); + }); + builder.addCase(uploadCsv.fulfilled, (state) => { + state.loading = false; + fulfilledNotify(state, 'Match has been uploaded'); + }); + builder.addCase(uploadCsv.rejected, (state, action) => { + state.loading = false; + rejectNotify(state, action); + }); + }, +}); + +// Action creators are generated for each case reducer function +export const { setRefetch } = matchSlice.actions; + +export default matchSlice.reducer; diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index 20bd5bd..1a2a97e 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -11,6 +11,8 @@ import tokensSlice from './tokens/tokensSlice'; import walletsSlice from './wallets/walletsSlice'; import rolesSlice from './roles/rolesSlice'; import permissionsSlice from './permissions/permissionsSlice'; +import gameSlice from './game/gameSlice'; +import matchSlice from './match/matchSlice'; export const store = configureStore({ reducer: { @@ -26,6 +28,8 @@ export const store = configureStore({ wallets: walletsSlice, roles: rolesSlice, permissions: permissionsSlice, + game: gameSlice, + match: matchSlice, }, });