Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/backend/src/bot/dto/update-bot.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
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 occurred");
}
Comment thread
zowks marked this conversation as resolved.
});
115 changes: 115 additions & 0 deletions packages/frontend/src/lib/utils/LayoutManager.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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() {
if (this.mutation === "deleted") return;

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 thread
zowks marked this conversation as resolved.

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" && cluster.shards.length !== 0)
.map((cluster) => ({
id: cluster.id,
shardIds: cluster.shards
.filter((shard) => shard.mutation !== "deleted")
.map((shard) => shard.id),
}));
Comment thread
zowks marked this conversation as resolved.
}
}
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>
126 changes: 126 additions & 0 deletions packages/frontend/src/routes/(app)/layout/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<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,
Comment thread
zowks marked this conversation as resolved.
});
});
}}
disabled={!!updateBotLayout.pending}
>
{#if updateBotLayout.pending}
<LoaderCircleIcon class="animate-spin" strokeWidth={1.5} />
{:else}
<SaveIcon strokeWidth={1.5} />
{/if}
</button>
Comment thread
zowks marked this conversation as resolved.
</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 +63 to +67
</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 +71 to +76
</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}

{#if cluster.mutation !== "deleted"}
<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>
{/if}
</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