diff --git a/src/database/models/player.model.ts b/src/database/models/player.model.ts index 4c100148..aae766e3 100644 --- a/src/database/models/player.model.ts +++ b/src/database/models/player.model.ts @@ -24,5 +24,5 @@ export interface PlayerModel { etf2lProfileId?: number cooldownLevel: number activeGame?: GameNumber - skill?: Record + skill?: Partial> } diff --git a/src/games/create.ts b/src/games/create.ts index 5ba99b5a..33f2ee1d 100644 --- a/src/games/create.ts +++ b/src/games/create.ts @@ -70,7 +70,7 @@ async function queueSlotToPlayerSlot(queueSlot: QueueSlotModel): Promise{props?.children} +} + +export function AdminPanelSidebar(props?: { children?: Children }) { + return
{props?.children}
+} + +export function AdminPanelLink(props: { href: string; active?: boolean; children: Children }) { + return ( + + {props.children} + + ) +} + +export function AdminPanelBody(props?: { children?: Children }) { + return
{props?.children}
+} + +export function AdminPanelHeader(props?: { children?: Children }) { + return

{props?.children}

+} + +export function AdminPanelContent(props?: { children?: Children }) { + return
{props?.children}
+} + +export function AdminPanelGroup(props?: { children?: Children }) { + return
{props?.children}
+} diff --git a/src/html/components/icons/icon-ban.tsx b/src/html/components/icons/icon-ban.tsx new file mode 100644 index 00000000..32511212 --- /dev/null +++ b/src/html/components/icons/icon-ban.tsx @@ -0,0 +1,10 @@ +import { makeIcon } from './make-icon' + +export const IconBan = makeIcon( + 'ban', + <> + + + + , +) diff --git a/src/html/components/icons/icon-chart-arrows-vertical.tsx b/src/html/components/icons/icon-chart-arrows-vertical.tsx new file mode 100644 index 00000000..ff0a240a --- /dev/null +++ b/src/html/components/icons/icon-chart-arrows-vertical.tsx @@ -0,0 +1,15 @@ +import { makeIcon } from './make-icon' + +export const IconChartArrowsVertical = makeIcon( + 'chart-arrows-vertical', + <> + + + + + + + + + , +) diff --git a/src/html/components/icons/icon-edit.tsx b/src/html/components/icons/icon-edit.tsx new file mode 100644 index 00000000..8bf4756d --- /dev/null +++ b/src/html/components/icons/icon-edit.tsx @@ -0,0 +1,11 @@ +import { makeIcon } from './make-icon' + +export const IconEdit = makeIcon( + 'edit', + <> + + + + + , +) diff --git a/src/html/components/icons/icon-user-scan.tsx b/src/html/components/icons/icon-user-scan.tsx new file mode 100644 index 00000000..29f9d775 --- /dev/null +++ b/src/html/components/icons/icon-user-scan.tsx @@ -0,0 +1,14 @@ +import { makeIcon } from './make-icon' + +export const IconUserScan = makeIcon( + 'user-scan', + <> + + + + + + + + , +) diff --git a/src/html/components/icons/index.ts b/src/html/components/icons/index.ts index 7aea2a45..13dfc4fd 100644 --- a/src/html/components/icons/index.ts +++ b/src/html/components/icons/index.ts @@ -1,5 +1,7 @@ export { IconAlignBoxBottomRight } from './icon-align-box-bottom-right' +export { IconBan } from './icon-ban' export { IconBrandDiscord } from './icon-brand-discord' +export { IconChartArrowsVertical } from './icon-chart-arrows-vertical' export { IconBrandSteam } from './icon-brand-steam' export { IconChartPie } from './icon-chart-pie' export { IconChevronLeft } from './icon-chevron-left' @@ -7,6 +9,7 @@ export { IconChevronRight } from './icon-chevron-right' export { IconCopy } from './icon-copy' export { IconCrown } from './icon-crown' export { IconDeviceDesktopAnalytics } from './icon-device-desktop-analytics' +export { IconEdit } from './icon-edit' export { IconExclamationCircleFilled } from './icon-exclamation-circle-filled' export { IconEye } from './icon-eye' export { IconHeartFilled } from './icon-heart-filled' @@ -22,4 +25,5 @@ export { IconReplaceFilled } from './icon-replace-filled' export { IconSettings } from './icon-settings' export { IconStars } from './icon-stars' export { IconUserCircle } from './icon-user-circle' +export { IconUserScan } from './icon-user-scan' export { IconX } from './icon-x' diff --git a/src/main.css b/src/main.css index dc395f24..057832b7 100644 --- a/src/main.css +++ b/src/main.css @@ -367,3 +367,71 @@ dialog::backdrop { .masked-overflow::-webkit-scrollbar-track { background-color: transparent; } + +.admin-panel-link { + display: flex; + flex-flow: flex nowrap; + align-items: center; + gap: 4px; + color: theme(colors.abru.light.75); + font-weight: 500; + font-size: 18px; + border-radius: 4px; + padding: 6px 12px; + position: relative; + + &:hover { + background-color: theme(colors.abru.dark.25 / 20%); + } + + &.active { + background-color: theme(colors.abru.dark.25); + + &:before { + position: absolute; + left: 4px; + top: 4px; + content: ''; + width: 4px; + height: calc(100% - 8px); + background-color: theme(colors.accent.DEFAULT); + border-radius: 2px; + } + } +} + +.admin-panel-content { + background-color: theme(colors.abru.dark.25); + border-radius: 16px; + padding: 32px; + + .group { + background-color: theme(colors.abru.light.5); + border-radius: 8px; + padding: 16px; + color: theme(colors.abru.light.75); + font-size: 18px; + + input[type='text'], + input[type='number'] { + background-color: theme(colors.abru.dark.25); + color: theme(colors.white); + border-radius: 8px; + padding: 8px; + font-size: 16px; + font-weight: 500; + } + + .input-group { + display: flex; + flex-direction: column; + gap: 4px; + + .label { + color: theme(colors.abru.light.50); + font-size: 16px; + font-weight: 500; + } + } + } +} diff --git a/src/players/routes.ts b/src/players/routes.ts index f4be168d..508aba84 100644 --- a/src/players/routes.ts +++ b/src/players/routes.ts @@ -4,7 +4,15 @@ import { PlayerPage } from './views/html/player.page' import type { ZodTypeProvider } from 'fastify-type-provider-zod' import { z } from 'zod' import { steamId64 } from '../shared/schemas/steam-id-64' +import { + EditPlayerBansPage, + EditPlayerProfilePage, + EditPlayerSkillPage, +} from './views/html/edit-player.page' import { collections } from '../database/collections' +import { PlayerRole } from '../database/models/player.model' +import { update } from './update' +import { Tf2ClassName } from '../shared/types/tf2-class-name' export default fp( // eslint-disable-next-line @typescript-eslint/require-await @@ -13,31 +21,180 @@ export default fp( reply.status(200).html(await PlayerListPage(req.user)) }) - app.withTypeProvider().get( - '/players/:steamId', - { - schema: { - params: z.object({ - steamId: steamId64, - }), - querystring: z.object({ - gamespage: z.coerce.number().optional(), - }), - }, - }, - async (req, reply) => { - const { steamId } = req.params - const player = await collections.players.findOne({ steamId }) - if (!player) { - return reply.notFound(`player not found: ${steamId}`) - } - reply - .status(200) - .html( - await PlayerPage({ player, user: req.user, page: Number(req.query.gamespage) || 1 }), + app + .withTypeProvider() + .get( + '/players/:steamId', + { + schema: { + params: z.object({ + steamId: steamId64, + }), + querystring: z.object({ + gamespage: z.coerce.number().optional(), + }), + }, + }, + async (req, reply) => { + const { steamId } = req.params + const player = await collections.players.findOne({ steamId }) + if (!player) { + return reply.notFound(`player not found: ${steamId}`) + } + reply + .status(200) + .html( + await PlayerPage({ player, user: req.user, page: Number(req.query.gamespage) || 1 }), + ) + }, + ) + .get( + '/players/:steamId/edit', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + params: z.object({ + steamId: steamId64, + }), + }, + }, + async (req, reply) => { + const { steamId } = req.params + const player = await collections.players.findOne({ steamId }) + if (player === null) { + return reply.notFound(`player not found: ${steamId}`) + } + + await reply.redirect(`/players/${steamId}/edit/profile`) + }, + ) + .get( + '/players/:steamId/edit/profile', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + params: z.object({ + steamId: steamId64, + }), + }, + }, + async (req, reply) => { + const { steamId } = req.params + const player = await collections.players.findOne({ steamId }) + if (player === null) { + return reply.notFound(`player not found: ${steamId}`) + } + + reply.status(200).html(await EditPlayerProfilePage({ player, user: req.user! })) + }, + ) + .post( + '/players/:steamId/edit/profile', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + params: z.object({ + steamId: steamId64, + }), + body: z.object({ + name: z.string(), + }), + }, + }, + async (req, reply) => { + const { steamId } = req.params + const { name } = req.body + const player = await collections.players.findOne({ steamId }) + if (player === null) { + return reply.notFound(`player not found: ${steamId}`) + } + + await update(player.steamId, { $set: { name } }) + await reply.redirect(`/players/${steamId}`) + }, + ) + .get( + '/players/:steamId/edit/skill', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + params: z.object({ + steamId: steamId64, + }), + }, + }, + async (req, reply) => { + const { steamId } = req.params + const player = await collections.players.findOne({ steamId }) + if (player === null) { + return reply.notFound(`player not found: ${steamId}`) + } + + reply.status(200).html(await EditPlayerSkillPage({ player, user: req.user! })) + }, + ) + .post( + '/players/:steamId/edit/skill', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + params: z.object({ + steamId: steamId64, + }), + body: z.object( + Object.keys(Tf2ClassName).reduce< + Partial> + >((acc, key) => ({ ...acc, [`skill.${key}`]: z.coerce.number().optional() }), {}), + ), + }, + }, + async (req, reply) => { + const { steamId } = req.params + const skill = Object.entries(req.body).reduce>>( + (acc, [key, value]) => ({ ...acc, [key.split('.')[1] as Tf2ClassName]: value }), + {}, ) - }, - ) + const player = await collections.players.findOne({ steamId }) + if (player === null) { + return reply.notFound(`player not found: ${steamId}`) + } + + await update(player.steamId, { $set: { skill } }) + await reply.redirect(`/players/${steamId}`) + }, + ) + .get( + '/players/:steamId/edit/bans', + { + config: { + authorize: [PlayerRole.admin], + }, + schema: { + params: z.object({ + steamId: steamId64, + }), + }, + }, + async (req, reply) => { + const { steamId } = req.params + const player = await collections.players.findOne({ steamId }) + if (player === null) { + return reply.notFound(`player not found: ${steamId}`) + } + + reply.status(200).html(await EditPlayerBansPage({ player, user: req.user! })) + }, + ) }, { name: 'players routes' }, ) diff --git a/src/players/views/html/edit-player.page.tsx b/src/players/views/html/edit-player.page.tsx new file mode 100644 index 00000000..0084ca8d --- /dev/null +++ b/src/players/views/html/edit-player.page.tsx @@ -0,0 +1,154 @@ +import { resolve } from 'node:path' +import type { PlayerModel } from '../../../database/models/player.model' +import { Style } from '../../../html/components/style' +import { Layout } from '../../../html/layout' +import { NavigationBar } from '../../../html/components/navigation-bar' +import type { User } from '../../../auth/types/user' +import { Page } from '../../../html/components/page' +import { Footer } from '../../../html/components/footer' +import { + AdminPanel, + AdminPanelBody, + AdminPanelHeader, + AdminPanelLink, + AdminPanelSidebar, +} from '../../../html/components/admin-panel' +import { IconBan, IconChartArrowsVertical, IconUserScan } from '../../../html/components/icons' +import type { Children } from '@kitajs/html' +import { queue } from '../../../queue' +import { GameClassIcon } from '../../../html/components/game-class-icon' +import { configuration } from '../../../configuration' + +const editPlayerPages = { + '/profile': 'Profile', + '/skill': 'Skill', + '/bans': 'Bans', +} as const + +export async function EditPlayerProfilePage(props: { player: PlayerModel; user: User }) { + return ( + +
+
+
+
+ + +
+
+
+ + +
+
+ ) +} + +export async function EditPlayerSkillPage(props: { player: PlayerModel; user: User }) { + const config = queue.config + const defaultSkill = await configuration.get('games.default_player_skill') + return ( + +
+
+
+
+ +
+ {config.classes.map(gameClass => { + const skill = + props.player.skill?.[gameClass.name] ?? defaultSkill[gameClass.name] ?? 0 + return ( +
+ + +
+ ) + })} +
+
+
+
+ + +
+
+ ) +} + +export async function EditPlayerBansPage(props: { player: PlayerModel; user: User }) { + return ( + +
+
+
+ +
+
+
+
+ ) +} + +function EditPlayer(props: { + player: PlayerModel + user: User + children: Children + activePage: keyof typeof editPlayerPages +}) { + return ( + } + > + + + + + + + Profile + + + + Skill + + + + Bans + + + + {editPlayerPages[props.activePage]} + {props.children} + + + +