From 9a1f9137b91952153738d9ecdd7722a77b004228 Mon Sep 17 00:00:00 2001 From: zowks <67444066+zowks@users.noreply.github.com> Date: Fri, 15 May 2026 23:09:21 +0000 Subject: [PATCH 1/3] feat(frontend): add `/layout` page --- .../frontend/src/lib/remotes/bot.remote.ts | 32 ++++- .../src/lib/utils/LayoutManager.svelte.ts | 113 ++++++++++++++++ .../src/routes/(app)/layout/+layout.svelte | 9 ++ .../src/routes/(app)/layout/+page.svelte | 123 ++++++++++++++++++ 4 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/src/lib/utils/LayoutManager.svelte.ts create mode 100644 packages/frontend/src/routes/(app)/layout/+layout.svelte create mode 100644 packages/frontend/src/routes/(app)/layout/+page.svelte diff --git a/packages/frontend/src/lib/remotes/bot.remote.ts b/packages/frontend/src/lib/remotes/bot.remote.ts index 6e77731..1aaa8d7 100644 --- a/packages/frontend/src/lib/remotes/bot.remote.ts +++ b/packages/frontend/src/lib/remotes/bot.remote.ts @@ -1,8 +1,10 @@ -import { form, getRequestEvent } from "$app/server"; +import { command, form, getRequestEvent } from "$app/server"; import { env } from "$env/dynamic/private"; -import { CreateBotSchema } from "@hallmaster/backend/dto"; +import { CreateBotSchema, UpdateBotSchema } from "@hallmaster/backend/dto"; import { error, redirect } from "@sveltejs/kit"; +import { getClusters } from "./clusters.remote"; + export const createBot = form(CreateBotSchema, async (data) => { const token = getRequestEvent().cookies.get("token"); @@ -26,3 +28,29 @@ export const createBot = form(CreateBotSchema, async (data) => { return error(500, "An error occured"); } }); + +export const updateBotLayout = command(UpdateBotSchema.pick({ layout: true }), async (layout) => { + const token = getRequestEvent().cookies.get("token"); + + const response = await fetch(new URL("/bot", env.API_URL), { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(layout), + }); + + switch (response.status) { + case 202: + await getClusters().refresh(); + return; + case 401: + return error(401, "Unauthorized"); + case 404: + return error(404, "Bot not found"); + + default: + return error(500, "An error occured"); + } +}); diff --git a/packages/frontend/src/lib/utils/LayoutManager.svelte.ts b/packages/frontend/src/lib/utils/LayoutManager.svelte.ts new file mode 100644 index 0000000..e93d38d --- /dev/null +++ b/packages/frontend/src/lib/utils/LayoutManager.svelte.ts @@ -0,0 +1,113 @@ +import type { GetClusterDto, UpdateBotDto } from "@hallmaster/backend/dto"; + +class Shard { + public id: GetClusterDto["shardIds"][number]; + public mutation: "unchanged" | "added" | "deleted" | "moved"; + + constructor(id: typeof this.id, mutation: typeof this.mutation = "unchanged") { + this.id = $state(id); + this.mutation = $state(mutation); + } +} + +class Cluster { + public readonly shards: Shard[]; + public mutation: "unchanged" | "added" | "deleted" = $state("unchanged"); + + constructor( + private readonly manager: LayoutManager, + shards: Shard[], + public readonly id?: number, + ) { + this.shards = $state(shards); + } + + private findById(id: GetClusterDto["shardIds"][number]) { + const index = this.shards.findIndex((shard) => shard.id === id && shard.mutation !== "deleted"); + + return { + index, + shard: index !== -1 ? this.shards[index] : undefined, + }; + } + + public add() { + this.shards.push(new Shard(this.manager.maxShardId + 1, "added")); + } + + public remove(id: GetClusterDto["shardIds"][number]) { + const { shard, index } = this.findById(id); + if (!shard) return; + + if (shard.mutation === "added") this.shards.splice(index, 1); + else { + shard.id = 0; + shard.mutation = "deleted"; + } + + for (const shard of this.manager.shards) if (shard.id > id) shard.id--; + } + + public move(id: GetClusterDto["shardIds"][number], cluster: number) { + const { shard, index } = this.findById(id); + if (!shard) return; + + shard.mutation = "moved"; + this.manager.clusters[cluster].shards.push(this.shards.splice(index, 1)[0]); + } +} + +export class LayoutManager { + public clusters: Cluster[] = $state([]); + + constructor(data: Omit[]) { + this.clusters = data + .map( + (cluster) => + new Cluster( + this, + cluster.shardIds.map((id) => new Shard(id)), + cluster.id, + ), + ) + .sort((a, b) => (a.id ?? 0) - (b.id ?? 0)); + } + + public get maxShardId(): number { + return Math.max( + ...this.clusters.flatMap((cluster) => cluster.shards.map((shard) => shard.id)), + -1, + ); + } + + public get shards(): Shard[] { + return this.clusters.flatMap((cluster) => cluster.shards); + } + + public add() { + const cluster = new Cluster(this, [new Shard(this.maxShardId + 1, "added")]); + cluster.mutation = "added"; + + this.clusters.push(cluster); + } + + public remove(index: number) { + const cluster = this.clusters[index]; + + for (const { id } of cluster.shards.toReversed()) cluster.remove(id); + + if (cluster.mutation === "added") this.clusters.splice(index, 1); + else cluster.mutation = "deleted"; + } + + public export(): NonNullable { + return this.clusters + .filter((cluster) => cluster.mutation !== "deleted") + .map((cluster) => ({ + id: cluster.id, + shardIds: cluster.shards + .filter((shard) => shard.mutation !== "deleted") + .map((shard) => shard.id), + })); + } +} diff --git a/packages/frontend/src/routes/(app)/layout/+layout.svelte b/packages/frontend/src/routes/(app)/layout/+layout.svelte new file mode 100644 index 0000000..abcf864 --- /dev/null +++ b/packages/frontend/src/routes/(app)/layout/+layout.svelte @@ -0,0 +1,9 @@ + + +
+ {@render children()} +
diff --git a/packages/frontend/src/routes/(app)/layout/+page.svelte b/packages/frontend/src/routes/(app)/layout/+page.svelte new file mode 100644 index 0000000..0a8d5e4 --- /dev/null +++ b/packages/frontend/src/routes/(app)/layout/+page.svelte @@ -0,0 +1,123 @@ + + +
+ +
+ +
+ {#each layout.clusters as cluster, index} + {@const columns = Math.round( + Math.sqrt((cluster.shards.length + 1) * (16 / 8)), + )} + {@const rows = Math.ceil((cluster.shards.length + 1) / columns)} +
+
+
+ +

+ Cluster {String(index).padStart(2, "0")} + ({cluster.shards.length}) +

+
+ + +
+ +
+ {#each cluster.shards as shard} + + {/each} + + +
+
+ {/each} + + +
From 101145036827945f504a2db75505bd0d55b9262d Mon Sep 17 00:00:00 2001 From: zowks <67444066+zowks@users.noreply.github.com> Date: Sat, 16 May 2026 04:42:58 +0000 Subject: [PATCH 2/3] fix: update LayoutClusterSchema to use nonnegative for cluster ID validation --- packages/backend/src/bot/dto/update-bot.dto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/bot/dto/update-bot.dto.ts b/packages/backend/src/bot/dto/update-bot.dto.ts index 550d464..57fca7a 100644 --- a/packages/backend/src/bot/dto/update-bot.dto.ts +++ b/packages/backend/src/bot/dto/update-bot.dto.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { CreateBotDockerImageSchema, CreateBotSchema } from './create-bot.dto.js'; export const LayoutClusterSchema = z.object({ - id: z.number().int().positive().optional().meta({ + id: z.number().int().nonnegative().optional().meta({ description: 'The cluster ID. Omit to create a new cluster (next available ID will be assigned).', }), shardIds: z.array(z.number().nonnegative()).min(1).meta({ From 3fdcd20a17e2b3202f2c1494b913e93cef7a9773 Mon Sep 17 00:00:00 2001 From: zowks <67444066+zowks@users.noreply.github.com> Date: Sat, 16 May 2026 04:43:50 +0000 Subject: [PATCH 3/3] fix(frontend): fixed review comments --- packages/frontend/src/lib/remotes/bot.remote.ts | 2 +- .../src/lib/utils/LayoutManager.svelte.ts | 4 +++- .../src/routes/(app)/layout/+page.svelte | 17 ++++++++++------- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/frontend/src/lib/remotes/bot.remote.ts b/packages/frontend/src/lib/remotes/bot.remote.ts index 1aaa8d7..a14a0c4 100644 --- a/packages/frontend/src/lib/remotes/bot.remote.ts +++ b/packages/frontend/src/lib/remotes/bot.remote.ts @@ -51,6 +51,6 @@ export const updateBotLayout = command(UpdateBotSchema.pick({ layout: true }), a return error(404, "Bot not found"); default: - return error(500, "An error occured"); + return error(500, "An error occurred"); } }); diff --git a/packages/frontend/src/lib/utils/LayoutManager.svelte.ts b/packages/frontend/src/lib/utils/LayoutManager.svelte.ts index e93d38d..71a4e11 100644 --- a/packages/frontend/src/lib/utils/LayoutManager.svelte.ts +++ b/packages/frontend/src/lib/utils/LayoutManager.svelte.ts @@ -32,6 +32,8 @@ class Cluster { } public add() { + if (this.mutation === "deleted") return; + this.shards.push(new Shard(this.manager.maxShardId + 1, "added")); } @@ -102,7 +104,7 @@ export class LayoutManager { public export(): NonNullable { return this.clusters - .filter((cluster) => cluster.mutation !== "deleted") + .filter((cluster) => cluster.mutation !== "deleted" && cluster.shards.length !== 0) .map((cluster) => ({ id: cluster.id, shardIds: cluster.shards diff --git a/packages/frontend/src/routes/(app)/layout/+page.svelte b/packages/frontend/src/routes/(app)/layout/+page.svelte index 0a8d5e4..2620a21 100644 --- a/packages/frontend/src/routes/(app)/layout/+page.svelte +++ b/packages/frontend/src/routes/(app)/layout/+page.svelte @@ -33,6 +33,7 @@ }); }); }} + disabled={!!updateBotLayout.pending} > {#if updateBotLayout.pending} @@ -103,13 +104,15 @@ {/each} - + {#if cluster.mutation !== "deleted"} + + {/if} {/each}