From 22976690d6edcac887cbd2029b0e52abaf4b20e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Gro=C3=9Fmann?= Date: Tue, 10 Feb 2026 22:08:47 +0100 Subject: [PATCH 01/18] feat: added basic functioning event templates for defining multiple basePaths and mycorebotdocker images --- .../1770757438463-eventTemplates.ts | 20 + .../dtos/createEventStarterTemplateDto.ts | 15 + .../dtos/updateEventStarterTemplateDto.ts | 15 + .../entities/event-starter-template.entity.ts | 24 + api/src/event/entities/event.entity.ts | 6 + api/src/event/event.controller.ts | 55 +- api/src/event/event.module.ts | 10 +- api/src/event/event.service.ts | 59 ++ api/src/team/dtos/createTeamDto.ts | 7 +- api/src/team/entities/team.entity.ts | 8 +- api/src/team/team.controller.ts | 34 +- api/src/team/team.service.ts | 63 ++- api/src/user/entities/user.entity.ts | 4 + frontend/app/actions/event.ts | 60 ++- frontend/app/events/EventTable.tsx | 1 - .../components/StarterTemplatesManagement.tsx | 358 +++++++++++++ .../app/events/[id]/dashboard/dashboard.tsx | 505 +++++++++++------- .../my-team/components/TeamCreationForm.tsx | 80 ++- frontend/components/ui/dialog.tsx | 6 +- frontend/components/ui/field.tsx | 97 ++++ frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 84 +++ 22 files changed, 1250 insertions(+), 265 deletions(-) create mode 100644 api/db/migrations/1770757438463-eventTemplates.ts create mode 100644 api/src/event/dtos/createEventStarterTemplateDto.ts create mode 100644 api/src/event/dtos/updateEventStarterTemplateDto.ts create mode 100644 api/src/event/entities/event-starter-template.entity.ts create mode 100644 frontend/app/events/[id]/dashboard/components/StarterTemplatesManagement.tsx create mode 100644 frontend/components/ui/field.tsx diff --git a/api/db/migrations/1770757438463-eventTemplates.ts b/api/db/migrations/1770757438463-eventTemplates.ts new file mode 100644 index 00000000..ee803815 --- /dev/null +++ b/api/db/migrations/1770757438463-eventTemplates.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EventTemplates1770757438463 implements MigrationInterface { + name = 'EventTemplates1770757438463' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "event_starter_templates" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "basePath" character varying NOT NULL, "myCoreBotDockerImage" character varying NOT NULL, "eventId" uuid, CONSTRAINT "PK_71a1105f3641c6830dc2ca59178" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "teams" ADD "starterTemplateId" uuid`); + await queryRunner.query(`ALTER TABLE "event_starter_templates" ADD CONSTRAINT "FK_6c691c5b56253870cacd70e0413" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "teams" ADD CONSTRAINT "FK_1d9ef4aae433eba4679f8c7671d" FOREIGN KEY ("starterTemplateId") REFERENCES "event_starter_templates"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "teams" DROP CONSTRAINT "FK_1d9ef4aae433eba4679f8c7671d"`); + await queryRunner.query(`ALTER TABLE "event_starter_templates" DROP CONSTRAINT "FK_6c691c5b56253870cacd70e0413"`); + await queryRunner.query(`ALTER TABLE "teams" DROP COLUMN "starterTemplateId"`); + await queryRunner.query(`DROP TABLE "event_starter_templates"`); + } + +} diff --git a/api/src/event/dtos/createEventStarterTemplateDto.ts b/api/src/event/dtos/createEventStarterTemplateDto.ts new file mode 100644 index 00000000..57eb8987 --- /dev/null +++ b/api/src/event/dtos/createEventStarterTemplateDto.ts @@ -0,0 +1,15 @@ +import { IsString, IsNotEmpty } from "class-validator"; + +export class CreateEventStarterTemplateDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + basePath: string; + + @IsString() + @IsNotEmpty() + myCoreBotDockerImage: string; +} diff --git a/api/src/event/dtos/updateEventStarterTemplateDto.ts b/api/src/event/dtos/updateEventStarterTemplateDto.ts new file mode 100644 index 00000000..968248f3 --- /dev/null +++ b/api/src/event/dtos/updateEventStarterTemplateDto.ts @@ -0,0 +1,15 @@ +import { IsString, IsNotEmpty, IsOptional } from "class-validator"; + +export class UpdateEventStarterTemplateDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + basePath?: string; + + @IsString() + @IsOptional() + myCoreBotDockerImage?: string; +} diff --git a/api/src/event/entities/event-starter-template.entity.ts b/api/src/event/entities/event-starter-template.entity.ts new file mode 100644 index 00000000..4ac37491 --- /dev/null +++ b/api/src/event/entities/event-starter-template.entity.ts @@ -0,0 +1,24 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { EventEntity } from "./event.entity"; +import { Exclude } from "class-transformer"; + +@Entity("event_starter_templates") +export class EventStarterTemplateEntity { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column() + name: string; + + @Column() + basePath: string; + + @Column() + myCoreBotDockerImage: string; + + @Exclude() + @ManyToOne(() => EventEntity, (event) => event.starterTemplates, { + onDelete: "CASCADE", + }) + event: EventEntity; +} diff --git a/api/src/event/entities/event.entity.ts b/api/src/event/entities/event.entity.ts index cf34534f..971a8a27 100644 --- a/api/src/event/entities/event.entity.ts +++ b/api/src/event/entities/event.entity.ts @@ -14,6 +14,7 @@ import { } from "../../user/entities/user.entity"; import { TeamEntity } from "../../team/entities/team.entity"; import { Exclude } from "class-transformer"; +import { EventStarterTemplateEntity } from "./event-starter-template.entity"; @Entity("events") export class EventEntity { @@ -115,4 +116,9 @@ export class EventEntity { }, ) permissions: UserEventPermissionEntity[]; + + @OneToMany(() => EventStarterTemplateEntity, (template) => template.event, { + cascade: true, + }) + starterTemplates: EventStarterTemplateEntity[]; } diff --git a/api/src/event/event.controller.ts b/api/src/event/event.controller.ts index d1dec006..09487ff5 100644 --- a/api/src/event/event.controller.ts +++ b/api/src/event/event.controller.ts @@ -17,6 +17,8 @@ import { UserService } from "../user/user.service"; import { CreateEventDto } from "./dtos/createEventDto"; import { SetLockTeamsDateDto } from "./dtos/setLockTeamsDateDto"; import { UpdateEventSettingsDto } from "./dtos/updateEventSettingsDto"; +import { CreateEventStarterTemplateDto } from "./dtos/createEventStarterTemplateDto"; +import { UpdateEventStarterTemplateDto } from "./dtos/updateEventStarterTemplateDto"; import { JwtAuthGuard } from "../auth/jwt-auth.guard"; import { UserId } from "../guards/UserGuard"; @@ -26,7 +28,7 @@ export class EventController { private readonly eventService: EventService, private readonly teamService: TeamService, private readonly userService: UserService, - ) { } + ) {} @UseGuards(JwtAuthGuard) @Get("my") @@ -258,4 +260,55 @@ export class EventController { } return this.eventService.removeEventAdmin(eventId, adminIdToRemove); } + + @UseGuards(JwtAuthGuard) + @Get(":id/templates") + async getStarterTemplates(@Param("id", new ParseUUIDPipe()) eventId: string) { + return this.eventService.getStarterTemplates(eventId); + } + + @UseGuards(JwtAuthGuard) + @Post(":id/templates") + async createStarterTemplate( + @Param("id", new ParseUUIDPipe()) eventId: string, + @UserId() userId: string, + @Body() body: CreateEventStarterTemplateDto, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + return this.eventService.createStarterTemplate( + eventId, + body.name, + body.basePath, + body.myCoreBotDockerImage, + ); + } + + @UseGuards(JwtAuthGuard) + @Put(":id/templates/:templateId") + async updateStarterTemplate( + @Param("id", new ParseUUIDPipe()) eventId: string, + @Param("templateId", new ParseUUIDPipe()) templateId: string, + @UserId() userId: string, + @Body() body: UpdateEventStarterTemplateDto, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + return this.eventService.updateStarterTemplate(eventId, templateId, body); + } + + @UseGuards(JwtAuthGuard) + @Delete(":id/templates/:templateId") + async deleteStarterTemplate( + @Param("id", new ParseUUIDPipe()) eventId: string, + @Param("templateId", new ParseUUIDPipe()) templateId: string, + @UserId() userId: string, + ) { + if (!(await this.eventService.isEventAdmin(eventId, userId))) { + throw new UnauthorizedException("You are not an admin of this event"); + } + return this.eventService.deleteStarterTemplate(eventId, templateId); + } } diff --git a/api/src/event/event.module.ts b/api/src/event/event.module.ts index e27f80f5..799c1896 100644 --- a/api/src/event/event.module.ts +++ b/api/src/event/event.module.ts @@ -8,9 +8,15 @@ import { UserModule } from "../user/user.module"; import { UserEventPermissionEntity } from "../user/entities/user.entity"; import { CheckController } from "./check.controller"; +import { EventStarterTemplateEntity } from "./entities/event-starter-template.entity"; + @Module({ imports: [ - TypeOrmModule.forFeature([EventEntity, UserEventPermissionEntity]), + TypeOrmModule.forFeature([ + EventEntity, + UserEventPermissionEntity, + EventStarterTemplateEntity, + ]), UserModule, forwardRef(() => TeamModule), ], @@ -18,4 +24,4 @@ import { CheckController } from "./check.controller"; providers: [EventService], exports: [EventService], }) -export class EventModule { } +export class EventModule {} diff --git a/api/src/event/event.service.ts b/api/src/event/event.service.ts index 7c2fad7c..b30e8db5 100644 --- a/api/src/event/event.service.ts +++ b/api/src/event/event.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, forwardRef, Inject, Injectable, @@ -8,6 +9,7 @@ import { } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; import { EventEntity } from "./entities/event.entity"; +import { EventStarterTemplateEntity } from "./entities/event-starter-template.entity"; import { DataSource, IsNull, @@ -35,6 +37,8 @@ export class EventService { private readonly eventRepository: Repository, @InjectRepository(UserEventPermissionEntity) private readonly permissionRepository: Repository, + @InjectRepository(EventStarterTemplateEntity) + private readonly templateRepository: Repository, private readonly configService: ConfigService, @Inject(forwardRef(() => TeamService)) private readonly teamService: TeamService, @@ -261,6 +265,61 @@ export class EventService { }); } + async getStarterTemplates(eventId: string) { + return this.templateRepository.find({ + where: { event: { id: eventId } }, + }); + } + + async createStarterTemplate( + eventId: string, + name: string, + basePath: string, + myCoreBotDockerImage: string, + ) { + const event = await this.getEventById(eventId); + const template = this.templateRepository.create({ + name, + basePath, + myCoreBotDockerImage, + event, + }); + return this.templateRepository.save(template); + } + + async updateStarterTemplate( + eventId: string, + templateId: string, + data: { name?: string; basePath?: string; myCoreBotDockerImage?: string }, + ) { + const template = await this.templateRepository.findOneOrFail({ + where: { id: templateId, event: { id: eventId } }, + }); + + if (data.name) template.name = data.name; + if (data.basePath) template.basePath = data.basePath; + if (data.myCoreBotDockerImage) + template.myCoreBotDockerImage = data.myCoreBotDockerImage; + + return this.templateRepository.save(template); + } + + async deleteStarterTemplate(eventId: string, templateId: string) { + try { + await this.templateRepository.delete({ + id: templateId, + event: { id: eventId }, + }); + } catch (e: any) { + if (e.code === "23503") { + throw new ConflictException( + "Cannot delete template because it is still in use by one or more teams.", + ); + } + throw e; + } + } + increaseEventRound(eventId: string): Promise { return this.eventRepository.increment({ id: eventId }, "currentRound", 1); } diff --git a/api/src/team/dtos/createTeamDto.ts b/api/src/team/dtos/createTeamDto.ts index 344585b7..6ff3b043 100644 --- a/api/src/team/dtos/createTeamDto.ts +++ b/api/src/team/dtos/createTeamDto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString, Matches } from "class-validator"; +import { IsNotEmpty, IsOptional, IsString, Matches } from "class-validator"; import { ApiProperty } from "@nestjs/swagger"; export class CreateTeamDto { @@ -10,4 +10,9 @@ export class CreateTeamDto { "Name can only contain letters, numbers, underscores, dots, and hyphens. Must be between 5 and 30 characters.", }) name: string; + + @ApiProperty() + @IsString() + @IsOptional() + starterTemplateId?: string; } diff --git a/api/src/team/entities/team.entity.ts b/api/src/team/entities/team.entity.ts index ddfc8a5c..9547e4bc 100644 --- a/api/src/team/entities/team.entity.ts +++ b/api/src/team/entities/team.entity.ts @@ -12,6 +12,8 @@ import { import { EventEntity } from "../../event/entities/event.entity"; import { UserEntity } from "../../user/entities/user.entity"; import { MatchEntity } from "../../match/entites/match.entity"; +import { EventStarterTemplateEntity } from "../../event/entities/event-starter-template.entity"; +import { Exclude } from "class-transformer"; @Entity("teams") export class TeamEntity { @@ -27,7 +29,7 @@ export class TeamEntity { @Column({ nullable: true }) repo: string; - @Column({nullable: true, type: "timestamp" }) + @Column({ nullable: true, type: "timestamp" }) startedRepoCreationAt: Date | null; @Column({ default: 0 }) @@ -42,9 +44,13 @@ export class TeamEntity { @Column({ default: false }) inQueue: boolean; + @Exclude() @ManyToOne(() => EventEntity, (event) => event.teams) event: EventEntity; + @ManyToOne(() => EventStarterTemplateEntity, { nullable: true }) + starterTemplate: EventStarterTemplateEntity; + @JoinTable({ name: "teams_users" }) @ManyToMany(() => UserEntity, (user) => user.teams) users: UserEntity[]; diff --git a/api/src/team/team.controller.ts b/api/src/team/team.controller.ts index ff7ef50f..98fac731 100644 --- a/api/src/team/team.controller.ts +++ b/api/src/team/team.controller.ts @@ -35,7 +35,7 @@ export class TeamController { private readonly teamService: TeamService, private readonly userService: UserService, private readonly eventService: EventService, - ) { } + ) {} @Get(":id") getTeamById(@Param("id", new ParseUUIDPipe()) id: string) { @@ -84,7 +84,12 @@ export class TeamController { "A team with this name already exists for this event.", ); - return this.teamService.createTeam(createTeamDto.name, userId, eventId); + return this.teamService.createTeam( + createTeamDto.name, + userId, + eventId, + createTeamDto.starterTemplateId, + ); } @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard) @@ -134,15 +139,13 @@ export class TeamController { @EventId eventId: string, @Body() inviteUserDto: InviteUserDto, @Team() team: TeamEntity, - @UserId() userId: string + @UserId() userId: string, ) { - if(userId === inviteUserDto.userToInviteId) - throw new BadRequestException( - "You cannot invite yourself to a team.", - ) + if (userId === inviteUserDto.userToInviteId) + throw new BadRequestException("You cannot invite yourself to a team."); - if(await this.teamService.isTeamFull(team.id)) - throw new BadRequestException("This team is full."); + if (await this.teamService.isTeamFull(team.id)) + throw new BadRequestException("This team is full."); if ( await this.teamService.getTeamOfUserForEvent( eventId, @@ -175,9 +178,14 @@ export class TeamController { @EventId eventId: string, @Param("searchQuery") searchQuery: string, @Team() team: TeamEntity, - @UserId() userId: string + @UserId() userId: string, ) { - return this.userService.searchUsersForInvite(eventId, searchQuery, team.id, userId); + return this.userService.searchUsersForInvite( + eventId, + searchQuery, + team.id, + userId, + ); } @UseGuards(JwtAuthGuard) @@ -203,8 +211,8 @@ export class TeamController { if (!(await this.teamService.isUserInvitedToTeam(userId, teamId))) throw new BadRequestException("You are not invited to this team."); - if(await this.teamService.isTeamFull(teamId)) - throw new BadRequestException("This team is full."); + if (await this.teamService.isTeamFull(teamId)) + throw new BadRequestException("This team is full."); return this.teamService.acceptTeamInvite(userId, teamId); } diff --git a/api/src/team/team.service.ts b/api/src/team/team.service.ts index 4e3b7366..a9332dd2 100644 --- a/api/src/team/team.service.ts +++ b/api/src/team/team.service.ts @@ -29,7 +29,7 @@ export class TeamService { private readonly matchService: MatchService, @InjectDataSource() private readonly dataSource: DataSource, - ) { } + ) {} logger = new Logger("TeamService"); @@ -149,6 +149,7 @@ export class TeamService { relations: { users: true, event: true, + starterTemplate: true, }, }); @@ -161,6 +162,14 @@ export class TeamService { const repoName = team.event.name + "-" + team.name + "-" + team.id; + // Determine values to use: template or event default + const basePath = team.starterTemplate + ? team.starterTemplate.basePath + : team.event.basePath; + const myCoreBotDockerImage = team.starterTemplate + ? team.starterTemplate.myCoreBotDockerImage + : team.event.myCoreBotDockerImage; + await this.githubApiService.createTeamRepository( repoName, team.name, @@ -174,10 +183,10 @@ export class TeamService { team.id, team.event.monorepoUrl, team.event.monorepoVersion, - team.event.myCoreBotDockerImage, + myCoreBotDockerImage, team.event.visualizerDockerImage, team.event.id, - team.event.basePath, + basePath, team.event.gameConfig ?? "", team.event.serverConfig ?? "", ); @@ -187,7 +196,12 @@ export class TeamService { }); } - async createTeam(name: string, userId: string, eventId: string) { + async createTeam( + name: string, + userId: string, + eventId: string, + starterTemplateId?: string, + ) { return await this.dataSource.transaction(async (entityManager) => { const teamRepository = entityManager.getRepository(TeamEntity); @@ -195,6 +209,9 @@ export class TeamService { name, event: { id: eventId }, users: [{ id: userId }], + starterTemplate: starterTemplateId + ? { id: starterTemplateId } + : undefined, }); if (await this.eventService.hasEventStarted(eventId)) @@ -491,22 +508,22 @@ export class TeamService { }); } - async isTeamFull(teamId: string) { - const team = await this.teamRepository.findOne({ - where: { - id: teamId, - }, - relations: { - event: true, - users: true - } - }); - const maxUsers = team?.event.maxTeamSize; - if (!maxUsers) return true; - if (!team?.users) return true; - - return team?.users.length >= maxUsers; - } + async isTeamFull(teamId: string) { + const team = await this.teamRepository.findOne({ + where: { + id: teamId, + }, + relations: { + event: true, + users: true, + }, + }); + const maxUsers = team?.event.maxTeamSize; + if (!maxUsers) return true; + if (!team?.users) return true; + + return team?.users.length >= maxUsers; + } async leaveQueue(teamId: string) { return this.teamRepository.update(teamId, { inQueue: false }); @@ -536,12 +553,10 @@ export class TeamService { }); for (const team of teams) { - if (!team.repo || !team.event) - continue; + if (!team.repo || !team.event) continue; for (const user of team.users) { - if (!user.username) - continue; + if (!user.username) continue; await this.githubApiService.addWritePermissions( user.username, diff --git a/api/src/user/entities/user.entity.ts b/api/src/user/entities/user.entity.ts index 0458bbad..fed200f3 100644 --- a/api/src/user/entities/user.entity.ts +++ b/api/src/user/entities/user.entity.ts @@ -43,12 +43,15 @@ export class UserEntity { @UpdateDateColumn() updatedAt: Date; + @Exclude() @ManyToMany(() => TeamEntity, (team) => team.users) teams: TeamEntity[]; + @Exclude() @ManyToMany(() => TeamEntity, (team) => team.teamInvites) teamInvites: TeamEntity[]; + @Exclude() @ManyToMany(() => EventEntity, (event) => event.users) events: EventEntity[]; @@ -85,6 +88,7 @@ export class UserEventPermissionEntity { }) user: UserEntity; + @Exclude() @ManyToOne(() => EventEntity, (event: EventEntity) => event.permissions, { onDelete: "CASCADE", }) diff --git a/frontend/app/actions/event.ts b/frontend/app/actions/event.ts index f6229cf8..f0853b80 100644 --- a/frontend/app/actions/event.ts +++ b/frontend/app/actions/event.ts @@ -31,6 +31,14 @@ export interface Event { serverConfig?: string; isPrivate: boolean; githubOrgSecret?: string; + starterTemplates?: EventStarterTemplate[]; +} + +export interface EventStarterTemplate { + id: string; + name: string; + basePath: string; + myCoreBotDockerImage: string; } export async function getEventById( @@ -121,8 +129,7 @@ export async function createEvent( export async function canUserCreateEvent(): Promise { try { return (await axiosInstance.get("user/canCreateEvent")).data; - } - catch { + } catch { return false; } } @@ -170,7 +177,9 @@ export async function updateEventSettings( export async function getEventAdmins( eventId: string, -): Promise> { +): Promise< + ServerActionResponse<{ id: string; username: string; name: string }[]> +> { return await handleError(axiosInstance.get(`event/${eventId}/admins`)); } @@ -178,21 +187,58 @@ export async function addEventAdmin( eventId: string, userId: string, ): Promise> { - return await handleError(axiosInstance.post(`event/${eventId}/admins/${userId}`)); + return await handleError( + axiosInstance.post(`event/${eventId}/admins/${userId}`), + ); } export async function removeEventAdmin( eventId: string, userId: string, ): Promise> { - return await handleError(axiosInstance.delete(`event/${eventId}/admins/${userId}`)); + return await handleError( + axiosInstance.delete(`event/${eventId}/admins/${userId}`), + ); } export async function getMyEvents(): Promise { try { return (await axiosInstance.get("event/my")).data as Event[]; - } - catch { + } catch { return []; } } + +export async function getStarterTemplates( + eventId: string, +): Promise> { + return await handleError(axiosInstance.get(`event/${eventId}/templates`)); +} + +export async function createStarterTemplate( + eventId: string, + data: { name: string; basePath: string; myCoreBotDockerImage: string }, +): Promise> { + return await handleError( + axiosInstance.post(`event/${eventId}/templates`, data), + ); +} + +export async function updateStarterTemplate( + eventId: string, + templateId: string, + data: { name?: string; basePath?: string; myCoreBotDockerImage?: string }, +): Promise> { + return await handleError( + axiosInstance.put(`event/${eventId}/templates/${templateId}`, data), + ); +} + +export async function deleteStarterTemplate( + eventId: string, + templateId: string, +): Promise> { + return await handleError( + axiosInstance.delete(`event/${eventId}/templates/${templateId}`), + ); +} diff --git a/frontend/app/events/EventTable.tsx b/frontend/app/events/EventTable.tsx index 8c08170e..ea018df7 100644 --- a/frontend/app/events/EventTable.tsx +++ b/frontend/app/events/EventTable.tsx @@ -22,7 +22,6 @@ export default function EventsTable({ events }: Readonly<{ events: Event[] }>) { text: string; variant: "default" | "secondary" | "destructive"; } => { - console.log("try to format state for event", event); const hasStarted = Date.now() >= new Date(event.startDate).getTime(); if (!hasStarted) { diff --git a/frontend/app/events/[id]/dashboard/components/StarterTemplatesManagement.tsx b/frontend/app/events/[id]/dashboard/components/StarterTemplatesManagement.tsx new file mode 100644 index 00000000..d95a62cc --- /dev/null +++ b/frontend/app/events/[id]/dashboard/components/StarterTemplatesManagement.tsx @@ -0,0 +1,358 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "@tanstack/react-form"; +import { zodValidator } from "@tanstack/zod-form-adapter"; +import * as z from "zod"; +import { + type EventStarterTemplate, + createStarterTemplate, + deleteStarterTemplate, + getStarterTemplates, + updateStarterTemplate, +} from "@/app/actions/event"; +import { isActionError } from "@/app/actions/errors"; +import { + Field, + FieldDescription, + FieldError, + FieldLabel, + FieldGroup, +} from "@/components/ui/field"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "sonner"; +import { Pencil, Loader2, Plus, Trash2, Save, X, Check } from "lucide-react"; + +const formSchema = z.object({ + name: z.string().min(1, "Name is required"), + basePath: z.string().min(1, "Base path is required"), + myCoreBotDockerImage: z.string().min(1, "Bot docker image is required"), +}); + +interface StarterTemplatesManagementProps { + eventId: string; +} + +export function StarterTemplatesManagement({ + eventId, +}: StarterTemplatesManagementProps) { + const queryClient = useQueryClient(); + const [editingTemplate, setEditingTemplate] = + useState(null); + + const form = useForm({ + defaultValues: { + name: "", + basePath: "", + myCoreBotDockerImage: "", + }, + validators: { + onSubmit: formSchema, + }, + onSubmit: async ({ value }) => { + createMutation.mutate(value); + }, + }); + + const { data: templates = [], isLoading } = useQuery({ + queryKey: ["event", eventId, "templates"], + queryFn: async () => { + const result = await getStarterTemplates(eventId); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + }); + + const createMutation = useMutation({ + mutationFn: async (data: { + name: string; + basePath: string; + myCoreBotDockerImage: string; + }) => { + const result = await createStarterTemplate(eventId, data); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("Template created"); + form.reset(); + queryClient.invalidateQueries({ + queryKey: ["event", eventId, "templates"], + }); + }, + onError: (e: any) => toast.error(e.message), + }); + + const updateMutation = useMutation({ + mutationFn: async (data: { + id: string; + name: string; + basePath: string; + myCoreBotDockerImage: string; + }) => { + const result = await updateStarterTemplate(eventId, data.id, { + name: data.name, + basePath: data.basePath, + myCoreBotDockerImage: data.myCoreBotDockerImage, + }); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("Template updated"); + setEditingTemplate(null); + queryClient.invalidateQueries({ + queryKey: ["event", eventId, "templates"], + }); + }, + onError: (e: any) => toast.error(e.message), + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + const result = await deleteStarterTemplate(eventId, id); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + onSuccess: () => { + toast.success("Template deleted"); + queryClient.invalidateQueries({ + queryKey: ["event", eventId, "templates"], + }); + }, + onError: (e: any) => toast.error(e.message), + }); + + return ( + + +
+
+ Starter Templates + + Manage starter templates for teams. + +
+
+
+ + {isLoading ? ( +
+ +
+ ) : ( + + + + Name + Base Path + Bot Image + Actions + + + + {templates.length === 0 ? ( + + + No templates found. Enter details below to create your first + template. + + + ) : ( + templates.map((template) => ( + + {editingTemplate?.id === template.id ? ( + <> + + + setEditingTemplate({ + ...editingTemplate, + name: e.target.value, + }) + } + className="h-8" + /> + + + + setEditingTemplate({ + ...editingTemplate, + basePath: e.target.value, + }) + } + className="h-8" + /> + + + + setEditingTemplate({ + ...editingTemplate, + myCoreBotDockerImage: e.target.value, + }) + } + className="h-8" + /> + + +
+ + +
+
+ + ) : ( + <> + {template.name} + {template.basePath} + + {template.myCoreBotDockerImage} + + +
+ + +
+
+ + )} +
+ )) + )} + + + + ( + field.handleChange(e.target.value)} + placeholder="New Template Name..." + className="h-8 bg-background" + /> + )} + /> + + + ( + field.handleChange(e.target.value)} + placeholder="bots/c/softcore" + className="h-8 bg-background" + /> + )} + /> + + + ( + field.handleChange(e.target.value)} + placeholder="ghcr.io/..." + className="h-8 bg-background" + /> + )} + /> + + + + + +
+
+ )} +
+
+ ); +} diff --git a/frontend/app/events/[id]/dashboard/dashboard.tsx b/frontend/app/events/[id]/dashboard/dashboard.tsx index 8a9af351..2ba79d7b 100644 --- a/frontend/app/events/[id]/dashboard/dashboard.tsx +++ b/frontend/app/events/[id]/dashboard/dashboard.tsx @@ -9,6 +9,7 @@ import { getEventAdmins, getEventById, getParticipantsCountForEvent, + getStarterTemplates, getTeamsCountForEvent, isEventAdmin, removeEventAdmin, @@ -52,11 +53,21 @@ import { import { searchUsers, type UserSearchResult } from "@/app/actions/user"; import { Calendar } from "@/components/ui/calendar"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; +import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; +import { StarterTemplatesManagement } from "./components/StarterTemplatesManagement"; interface DashboardPageProps { eventId: string; @@ -65,6 +76,16 @@ interface DashboardPageProps { export function DashboardPage({ eventId }: DashboardPageProps) { const session = useSession(); const queryClient = useQueryClient(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const currentTab = searchParams.get("tab") || "overview"; + + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", value); + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }; const [teamAutoLockTime, setTeamAutoLockTime] = useState(""); const [userSearchQuery, setUserSearchQuery] = useState(""); @@ -127,6 +148,16 @@ export function DashboardPage({ eventId }: DashboardPageProps) { enabled: isAdmin, }); + const { data: starterTemplates = [] } = useQuery({ + queryKey: ["event", eventId, "templates"], + queryFn: async () => { + const result = await getStarterTemplates(eventId); + if (isActionError(result)) throw new Error(result.error); + return result; + }, + enabled: !!eventId, + }); + const lockEventMutation = useMutation({ mutationFn: async () => await lockEvent(eventId), onSuccess: async () => { @@ -406,7 +437,11 @@ export function DashboardPage({ eventId }: DashboardPageProps) { )} - + Overview Operation @@ -526,7 +561,7 @@ export function DashboardPage({ eventId }: DashboardPageProps) {

{event.myCoreBotDockerImage} @@ -541,6 +576,38 @@ export function DashboardPage({ eventId }: DashboardPageProps) {

+ + {starterTemplates && starterTemplates.length > 0 && ( +
+ + + + + Name + Path + Docker Image + + + + {starterTemplates.map((template) => ( + + + {template.name} + + + {template.basePath} + + + {template.myCoreBotDockerImage} + + + ))} + +
+
+ )} @@ -717,10 +784,8 @@ export function DashboardPage({ eventId }: DashboardPageProps) { - Event Settings - - Configure the core details of your event. - + General Information + Basic details about the event.
@@ -805,8 +870,18 @@ export function DashboardPage({ eventId }: DashboardPageProps) { />
+
+
-
+ + + Participation & Privacy + + Manage who can join and strictness of the event. + + + +
-

- Visibility & Permissions -

+

Toggles

+ + -
-

- Technical / Docker Images -

-
-
- - - setPendingSettings({ - ...pendingSettings, - monorepoUrl: e.target.value, - }) - } - /> -
-
-
- - - setPendingSettings({ - ...pendingSettings, - monorepoVersion: e.target.value, - }) - } - /> -
-
- - - setPendingSettings({ - ...pendingSettings, - basePath: e.target.value, - }) - } - /> -
-
-
- - - setPendingSettings({ - ...pendingSettings, - githubOrg: e.target.value, - }) - } - /> -
-
- - - setPendingSettings({ - ...pendingSettings, - gameServerDockerImage: e.target.value, - }) - } - /> -
+ + + Technical Configuration + + Docker images and repository settings. + + + +
+
+ + + setPendingSettings({ + ...pendingSettings, + monorepoUrl: e.target.value, + }) + } + /> +
+
- + setPendingSettings({ ...pendingSettings, - myCoreBotDockerImage: e.target.value, + monorepoVersion: e.target.value, }) } />
- + setPendingSettings({ ...pendingSettings, - visualizerDockerImage: e.target.value, + basePath: e.target.value, }) } />
-
-
-

- Secret Configurations -

- + setPendingSettings({ ...pendingSettings, - githubOrgSecret: e.target.value, + gameServerDockerImage: e.target.value, }) } />
-
- -
- - -
-
-