Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions packages/frontend/src/lib/remotes/bot.remote.ts
Original file line number Diff line number Diff line change
@@ -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");

Expand All @@ -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");
}
Comment on lines +52 to +55
});
113 changes: 113 additions & 0 deletions packages/frontend/src/lib/utils/LayoutManager.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<GetClusterDto, "status">[]) {
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);
}
Comment on lines +76 to +85

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<UpdateBotDto["layout"]> {
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),
}));
}
}
9 changes: 9 additions & 0 deletions packages/frontend/src/routes/(app)/layout/+layout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script lang="ts">
import type { LayoutProps } from "./$types";

let { children }: LayoutProps = $props();
</script>

<main class="flex flex-col gap-4 max-w-6xl mx-auto">
{@render children()}
</main>
123 changes: 123 additions & 0 deletions packages/frontend/src/routes/(app)/layout/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<script lang="ts">
import { updateBotLayout } from "$lib/remotes/bot.remote";
import { getClusters } from "$lib/remotes/clusters.remote";
import { LayoutManager } from "$lib/utils/LayoutManager.svelte";
import toaster from "$lib/utils/toaster";
import {
BoxesIcon,
LoaderCircleIcon,
PlusIcon,
SaveIcon,
XIcon,
} from "@lucide/svelte";

let data = $derived(
(await getClusters()).map(({ id, shardIds }) => ({
id,
shardIds,
})),
);

let layout = $derived(new LayoutManager(data));
</script>

<div class="flex justify-end items-center">
<button
class="btn-icon bg-surface-50-950 text-primary-700-300"
onclick={() => {
updateBotLayout({ layout: layout.export() }).catch((error) => {
toaster.create({
type: "error",
title: "Error",
description: JSON.parse(error).message,
Copy link
Copy Markdown
Member

@a9ex a9ex May 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it okay to parse the Error class directly?
That shouldn't be JSON.parse(error.message).message or smth like that

});
});
}}
>
{#if updateBotLayout.pending}
<LoaderCircleIcon class="animate-spin" strokeWidth={1.5} />
{:else}
<SaveIcon strokeWidth={1.5} />
{/if}
</button>
Comment on lines +25 to +42
</div>

<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{#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)}
<div
class={{
"aspect-video flex flex-col p-2 rounded-lg gap-2 border bg-surface-100-900": true,
"border-surface-200-800": cluster.mutation === "unchanged",
"border-error-600-400": cluster.mutation === "deleted",
"border-success-600-400": cluster.mutation === "added",
}}
>
<div class="flex justify-between">
<div class="flex gap-1 items-center pl-2">
<BoxesIcon size={18} class="text-primary-800-200" />
<h3 class="font-bold text-surface-800-200">
Cluster {String(index).padStart(2, "0")}
<span class="text-surface-500 text-sm"
>({cluster.shards.length})</span
>
Comment on lines +62 to +66
</h3>
</div>

<button
class="btn-icon btn-icon-sm hover:text-error-800-200 hover:bg-surface-100-900 transition-colors"
onclick={() => layout.remove(index)}
>
<XIcon />
</button>
Comment on lines +70 to +75
</div>

<div
class={[
"grow grid gap-1.5",
"*:@container *:rounded-lg *:flex *:justify-center *:items-center",
]}
style:grid-template-columns={`repeat(${columns}, 1fr)`}
style:grid-template-rows={`repeat(${rows}, 1fr)`}
>
{#each cluster.shards as shard}
<button
class={{
"btn border border-surface-300-700 bg-surface-200-800 hover:text-error-700-300": true,
"text-surface-700-300": shard.mutation === "unchanged",
"text-error-700-300": shard.mutation === "deleted",
"text-success-700-300": shard.mutation === "added",
"text-primary-700-300": shard.mutation === "moved",
}}
onclick={() => cluster.remove(shard.id)}
>
<p
class="truncate font-bold"
style:font-size="clamp(0px, 40cqw, 2em)"
>
{String(shard.id).padStart(2, "0")}
</p>
</button>
{/each}

<button
class="btn text-surface-700-300 hover:text-primary-700-300 hover:bg-surface-100-900"
style:font-size="clamp(0px, 40cqw, 2em)"
onclick={() => cluster.add()}
>
<PlusIcon />
</button>
</div>
</div>
{/each}

<button
class="btn btn-lg aspect-video flex rounded-lg bg-surface-50-950 text-surface-700-300 hover:text-primary-700-300"
onclick={() => layout.add()}
>
<PlusIcon />
</button>
</div>
Loading