Skip to content
Draft
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
35 changes: 20 additions & 15 deletions packages/frontend/src/lib/components/LabeledInput.svelte
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
<script lang="ts">
import { type Icon, TriangleAlertIcon } from "@lucide/svelte";
import type { RemoteFormIssue } from "@sveltejs/kit";
import type { HTMLInputAttributes } from "svelte/elements";
import { type Icon, TriangleAlertIcon } from "@lucide/svelte";
import type { RemoteFormIssue } from "@sveltejs/kit";
import type { Snippet } from "svelte";
import type { HTMLInputAttributes } from "svelte/elements";

interface Props extends HTMLInputAttributes {
label: string;
icon?: typeof Icon;
issues?: RemoteFormIssue[];
}
interface Props extends HTMLInputAttributes {
label: string;
icon?: typeof Icon;
issues?: RemoteFormIssue[];
suffix?: Snippet;
}

let { label, icon, issues, ...props }: Props = $props();
let { label, icon, issues, suffix, ...props }: Props = $props();
</script>

<label class="label">
<div class={[
"ml-1 flex gap-1",
issues && "text-error-500"
]}>
<div class={["ml-1 flex gap-1", issues && "text-error-500"]}>
{#if issues}
<TriangleAlertIcon size={16} />
{:else if icon}
Expand All @@ -25,9 +24,15 @@ let { label, icon, issues, ...props }: Props = $props();
{/if}

<span class="label-text">
{label}<span class="text-primary-500">{props.required && "*"}</span>
{label}
{#if props.required}
<span class="text-primary-500">*</span>
{/if}
</span>
</div>

<input class="input" {...props} />
<div class="input flex items-center gap-1">
<input class="grow" {...props} />
{@render suffix?.()}
</div>
</label>
85 changes: 83 additions & 2 deletions packages/frontend/src/lib/remotes/bot.remote.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { form, getRequestEvent } from "$app/server";
import { form, getRequestEvent, query } from "$app/server";
import { env } from "$env/dynamic/private";
import { CreateBotSchema } from "@hallmaster/backend/dto";
import { CreateBotSchema, UpdateBotSchema, type GetBotDto } from "@hallmaster/backend/dto";
import { error, redirect } from "@sveltejs/kit";

export const createBot = form(CreateBotSchema, async (data) => {
Expand All @@ -26,3 +26,84 @@ export const createBot = form(CreateBotSchema, async (data) => {
return error(500, "An error occured");
}
});

export const getBot = query<GetBotDto>(async () => {
const token = getRequestEvent().cookies.get("token");

const response = await fetch(new URL("/bot", env.API_URL), {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});

switch (response.status) {
case 200:
return response.json();
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Bot not found");

default:
return error(500, "An error occured");
}
});

export const updateBotToken = form(UpdateBotSchema.pick({ token: true }), async (token) => {
const userToken = getRequestEvent().cookies.get("token");

const response = await fetch(new URL("/bot", env.API_URL), {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${userToken}`,
},
body: JSON.stringify(token),
});

switch (response.status) {
case 202:
getBot().set(await response.json());
return;
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Bot not found");

default:
console.error(await response.text());
return error(500, "An error occured");
}
});

export const updateBotImage = form(
UpdateBotSchema.pick({ dockerImage: true }),
async (dockerImage) => {
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(dockerImage),
});

switch (response.status) {
case 202:
getBot().set(await response.json());
return;
case 401:
return error(401, "Unauthorized");
case 404:
return error(404, "Bot not found");

default:
console.error(await response.text());
return error(500, "An error occured");
}
},
);
9 changes: 9 additions & 0 deletions packages/frontend/src/routes/(app)/settings/+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="max-w-2xl flex flex-col gap-4 mx-auto">
{@render children()}
</main>
106 changes: 106 additions & 0 deletions packages/frontend/src/routes/(app)/settings/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<script lang="ts">
import LabeledInput from "$lib/components/LabeledInput.svelte";
import {
getBot,
updateBotImage,
updateBotToken,
} from "$lib/remotes/bot.remote";
import {
BracesIcon,
ContainerIcon,
LoaderCircleIcon,
RectangleEllipsisIcon,
SaveIcon,
TextCursorIcon,
} from "@lucide/svelte";
import { Switch } from "@skeletonlabs/skeleton-svelte";
import { slide } from "svelte/transition";

const { dockerImage: defaultValues } = await getBot();
updateBotImage.fields.dockerImage.username.set(
defaultValues.username ?? undefined,
);
updateBotImage.fields.dockerImage.image.set(defaultValues.image);

let authentication: boolean = $state(!!defaultValues.username);
</script>

<h6 class="h6 font-normal">Deployment</h6>

<div class="flex flex-col gap-4">
<form {...updateBotImage} class="flex flex-col gap-3">
<LabeledInput
label="Container image url"
icon={ContainerIcon}
placeholder="ghcr.io/image:tag"
required
{...updateBotImage.fields.dockerImage.image.as("text")}
issues={updateBotImage.fields.dockerImage.image.issues()}
>
{#snippet suffix()}
<button class="btn-icon btn-icon-sm text-primary-700-300">
{#if updateBotImage.pending}
<LoaderCircleIcon class="animate-spin" />
{:else}
<SaveIcon />
{/if}
</button>
{/snippet}
</LabeledInput>

<Switch
defaultChecked={authentication}
checked={authentication}
onCheckedChange={(details) => (authentication = details.checked)}
>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Registry authentication</Switch.Label>
<Switch.HiddenInput />
</Switch>

{#if authentication}
<div class="grid grid-cols-2 gap-2" transition:slide={{ duration: 150 }}>
<LabeledInput
label="Username"
icon={TextCursorIcon}
required
{...updateBotImage.fields.dockerImage.username.as("text")}
issues={updateBotImage.fields.dockerImage.username.issues()}
/>

<LabeledInput
label="Password"
icon={RectangleEllipsisIcon}
required
{...updateBotImage.fields.dockerImage.password.as("password")}
issues={updateBotImage.fields.dockerImage.password.issues()}
/>
</div>
{/if}
</form>

<hr class="hr" />

<form {...updateBotToken}>
<LabeledInput
label="Bot token"
icon={BracesIcon}
placeholder="8f0a2f30b4e738362ee19e8e572edb8a"
required
{...updateBotToken.fields.token.as("password")}
issues={updateBotToken.fields.token.issues()}
>
{#snippet suffix()}
<button class="btn-icon btn-icon-sm text-primary-700-300">
{#if updateBotToken.pending}
<LoaderCircleIcon class="animate-spin" />
{:else}
<SaveIcon />
{/if}
</button>
{/snippet}
</LabeledInput>
</form>
</div>
1 change: 0 additions & 1 deletion packages/frontend/src/routes/(form)/login/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import toaster from "$lib/utils/toaster";
</script>

<form
class="card preset-filled-surface-100-900"
{...login.enhance(({ submit }) =>
submit().catch((error) => {
toaster.create({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
</Switch>

{#if authentication}
<div class="flex gap-2" transition:slide={{ duration: 150 }}>
<div class="grid grid-cols-2 gap-2" transition:slide={{ duration: 150 }}>
<LabeledInput
label="Username"
icon={TextCursorIcon}
Expand Down
Loading