Merge pull request 'UI: general improvments on layout and responsiveness' (#2595) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-12-11 11:00:13 +00:00
14 changed files with 642 additions and 526 deletions

View File

@@ -1,3 +1,12 @@
/**
* This script generates a custom index.html file for the webview UI.
* It reads the manifest.json file generated by Vite and uses it to generate the HTML file.
* It also processes the CSS files to rewrite the URLs in the CSS files to match the new location of the assets.
* The script is run after the Vite build is complete.
*
* This is necessary because the webview UI is loaded from the local file system and the URLs in the CSS files need to be rewritten to match the new location of the assets.
* The generated index.html file is then used as the entry point for the webview UI.
*/
import fs from "node:fs";
import postcss from "postcss";
import path from "node:path";

View File

@@ -6,10 +6,11 @@ export const BackButton = () => {
const navigate = useNavigate();
return (
<Button
variant="light"
class="w-fit"
variant="ghost"
size="s"
class="mr-2"
onClick={() => navigate(-1)}
startIcon={<Icon icon="CaretRight" />}
startIcon={<Icon icon="CaretLeft" />}
></Button>
);
};

View File

@@ -106,109 +106,104 @@ export const MachineListItem = (props: MachineListItemProps) => {
setUpdating(false);
};
return (
<li>
<div class="card card-side m-2">
<figure class="h-fit rounded-xl border bg-def-2 border-def-5">
<RndThumbnail name={name} width={220} height={120} />
</figure>
<div class="card-body flex-row justify-between ">
<div class="flex flex-col">
<A href={`/machines/${name}`}>
<h2
class="card-title underline"
classList={{
"text-neutral-500": nixOnly,
}}
>
{name}
</h2>
</A>
<div class="text-slate-600">
<Show when={info}>{(d) => d()?.description}</Show>
</div>
<div class="text-slate-600">
<Show when={info}>
{(d) => (
<>
<Show when={d().tags}>
{(tags) => (
<span class="flex gap-1">
<For each={tags()}>
{(tag) => (
<button
type="button"
onClick={() =>
props.setFilter((prev) => {
if (prev.tags.includes(tag)) {
return prev;
}
return {
...prev,
tags: [...prev.tags, tag],
};
})
}
>
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
</span>
</button>
)}
</For>
</span>
)}
</Show>
{d()?.deploy?.targetHost}
</>
)}
</Show>
</div>
<div class="flex flex-row flex-wrap gap-4 py-2"></div>
</div>
<div>
<Menu
popoverid={`menu-${props.name}`}
label={<Icon icon={"More"} />}
<div class="card card-side m-2">
<figure class="h-fit rounded-xl border bg-def-2 border-def-5">
<RndThumbnail name={name} width={220} height={120} />
</figure>
<div class="card-body flex-row justify-between ">
<div class="flex flex-col">
<A href={`/machines/${name}`}>
<h2
class="card-title underline"
classList={{
"text-neutral-500": nixOnly,
}}
>
<ul class="menu z-[1] w-52 rounded-box bg-base-100 p-2 shadow">
<li>
<a
onClick={() => {
navigate("/machines/" + name);
}}
>
Details
</a>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || installing(),
}}
onClick={handleInstall}
>
<a>
<Show when={info?.deploy?.targetHost} fallback={"Deploy"}>
{(d) => `Install to ${d()}`}
</Show>
</a>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || updating(),
}}
onClick={handleUpdate}
>
<a>
<Show when={info?.deploy?.targetHost} fallback={"Deploy"}>
{(d) => `Update (${d()})`}
</Show>
</a>
</li>
</ul>
</Menu>
{name}
</h2>
</A>
<div class="text-slate-600">
<Show when={info}>{(d) => d()?.description}</Show>
</div>
<div class="text-slate-600">
<Show when={info}>
{(d) => (
<>
<Show when={d().tags}>
{(tags) => (
<span class="flex gap-1">
<For each={tags()}>
{(tag) => (
<button
type="button"
onClick={() =>
props.setFilter((prev) => {
if (prev.tags.includes(tag)) {
return prev;
}
return {
...prev,
tags: [...prev.tags, tag],
};
})
}
>
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
</span>
</button>
)}
</For>
</span>
)}
</Show>
{d()?.deploy?.targetHost}
</>
)}
</Show>
</div>
<div class="flex flex-row flex-wrap gap-4 py-2"></div>
</div>
<div>
<Menu popoverid={`menu-${props.name}`} label={<Icon icon={"More"} />}>
<ul class="menu z-[1] w-52 rounded-box bg-base-100 p-2 shadow">
<li>
<a
onClick={() => {
navigate("/machines/" + name);
}}
>
Details
</a>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || installing(),
}}
onClick={handleInstall}
>
<a>
<Show when={info?.deploy?.targetHost} fallback={"Deploy"}>
{(d) => `Install to ${d()}`}
</Show>
</a>
</li>
<li
classList={{
disabled: !info?.deploy?.targetHost || updating(),
}}
onClick={handleUpdate}
>
<a>
<Show when={info?.deploy?.targetHost} fallback={"Deploy"}>
{(d) => `Update (${d()})`}
</Show>
</a>
</li>
</ul>
</Menu>
</div>
</div>
</li>
</div>
);
};

View File

@@ -1,27 +1,20 @@
import { createSignal, Show } from "solid-js";
import { createSignal } from "solid-js";
import { Typography } from "@/src/components/Typography";
import { SidebarFlyout } from "./SidebarFlyout";
import "./css/sidebar.css";
interface SidebarHeader {
interface SidebarProps {
clanName: string;
showFlyout?: () => boolean;
}
export const SidebarHeader = (props: SidebarHeader) => {
const { clanName } = props;
const [showFlyout, toggleFlyout] = createSignal(false);
function handleClick() {
toggleFlyout(!showFlyout());
}
const renderClanProfile = () => (
const ClanProfile = (props: SidebarProps) => {
return (
<div
class={`sidebar__profile ${showFlyout() ? "sidebar__profile--flyout" : ""}`}
class={`sidebar__profile ${props.showFlyout?.() ? "sidebar__profile--flyout" : ""}`}
>
<Typography
classes="sidebar__profile__character"
class="sidebar__profile__character"
tag="span"
hierarchy="title"
size="m"
@@ -29,14 +22,15 @@ export const SidebarHeader = (props: SidebarHeader) => {
color="primary"
inverted={true}
>
{clanName.slice(0, 1).toUpperCase()}
{props.clanName.slice(0, 1).toUpperCase()}
</Typography>
</div>
);
};
const renderClanTitle = () => (
const ClanTitle = (props: SidebarProps) => {
return (
<Typography
classes="sidebar__title"
tag="h3"
hierarchy="body"
size="default"
@@ -44,15 +38,23 @@ export const SidebarHeader = (props: SidebarHeader) => {
color="primary"
inverted={true}
>
{clanName}
{props.clanName}
</Typography>
);
};
export const SidebarHeader = (props: SidebarProps) => {
const [showFlyout, toggleFlyout] = createSignal(false);
function handleClick() {
toggleFlyout(!showFlyout());
}
return (
<header class="sidebar__header">
<div onClick={handleClick} class="sidebar__header__inner">
{renderClanProfile()}
{renderClanTitle()}
<ClanProfile clanName={props.clanName} showFlyout={showFlyout} />
<ClanTitle clanName={props.clanName} />
</div>
{showFlyout() && <SidebarFlyout />}
</header>

View File

@@ -14,7 +14,7 @@ export const SidebarListItem = (props: SidebarListItem) => {
<li class="sidebar__list__item">
<A class="sidebar__list__link" href={href}>
<Typography
classes="sidebar__list__content"
class="sidebar__list__content"
tag="span"
hierarchy="body"
size="s"

View File

@@ -19,7 +19,7 @@ export const SidebarSection = (props: {
<details class="sidebar__section accordeon" open>
<summary class="accordeon__header">
<Typography
classes="uppercase"
class="uppercase"
tag="p"
hierarchy="body"
size="xs"
@@ -47,7 +47,7 @@ export const Sidebar = (props: RouteSectionProps) => {
const curr = activeURI();
if (curr) {
const result = await callApi("show_clan_meta", { uri: curr });
console.log("refetched meta for ", curr);
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;

View File

@@ -67,38 +67,27 @@ const weightMap: Record<Weight, string> = {
interface TypographyProps<H extends Hierarchy> {
hierarchy: H;
size: AllowedSizes<H>;
children: JSX.Element;
weight?: Weight;
color?: Color;
inverted?: boolean;
size: AllowedSizes<H>;
tag?: Tag;
children: JSX.Element;
classes?: string;
class?: string;
}
export const Typography = <H extends Hierarchy>(props: TypographyProps<H>) => {
const {
size,
color = "primary",
inverted,
hierarchy,
weight = "normal",
tag = "span",
children,
classes,
} = props;
return (
<Dynamic
component={tag}
component={props.tag || "span"}
class={cx(
classes,
colorMap[color],
inverted && "fnt-clr--inverted",
sizeHierarchyMap[hierarchy][size] as string,
weightMap[weight],
props.class,
colorMap[props.color || "primary"],
props.inverted && "fnt-clr--inverted",
sizeHierarchyMap[props.hierarchy][props.size] as string,
weightMap[props.weight || "normal"],
)}
>
{children}
{props.children}
</Dynamic>
);
};

View File

@@ -1,14 +1,17 @@
import { JSX } from "solid-js";
import { Typography } from "../components/Typography";
import { BackButton } from "../components/BackButton";
interface HeaderProps {
title: string;
toolbar?: JSX.Element;
showBack?: boolean;
}
export const Header = (props: HeaderProps) => {
return (
<div class="navbar border-b px-6 py-4 border-def-3">
<div class="flex-none">
{props.showBack && <BackButton />}
<span class="tooltip tooltip-bottom lg:hidden" data-tip="Menu">
<label
class="btn btn-square btn-ghost drawer-button"
@@ -19,7 +22,7 @@ export const Header = (props: HeaderProps) => {
</span>
</div>
<div class="flex-1">
<Typography hierarchy="title" size="m" weight="medium">
<Typography hierarchy="title" size="m" weight="medium" class="">
{props.title}
</Typography>
</div>

View File

@@ -26,7 +26,7 @@ export const BlockDevicesView: Component = () => {
<div class="tooltip tooltip-bottom" data-tip="Refresh">
<Button
onClick={() => loadDevices()}
startIcon={<Icon icon="Reload" />}
startIcon={<Icon icon="Update" />}
></Button>
</div>
<div class="flex max-w-screen-lg flex-col gap-4">

View File

@@ -1,9 +1,7 @@
import { callApi, ClanServiceInstance, SuccessQuery } from "@/src/api";
import { BackButton } from "@/src/components/BackButton";
import { useParams } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createSignal, For, Match, Switch } from "solid-js";
import { Show } from "solid-js";
import {
createForm,
FieldValues,
@@ -15,9 +13,10 @@ import {
} from "@modular-forms/solid";
import { TextInput } from "@/src/components/TextInput";
import toast from "solid-toast";
import { get_single_service, set_single_service } from "@/src/api/inventory";
import { set_single_service } from "@/src/api/inventory";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { Header } from "@/src/layout/header";
interface AdminModuleFormProps {
admin: AdminData;
@@ -65,30 +64,17 @@ const EditClanForm = (props: EditClanFormProps) => {
<Field name="icon">
{(field) => (
<>
<figure class="p-1">
<div class="flex flex-col items-center">
<div class="text-3xl text-primary-800">{curr_name()}</div>
<div class="text-secondary-800">Wide settings</div>
</div>
</figure>
<figure>
<Show
when={field.value}
fallback={
<span class="material-icons aspect-square size-60 rounded-lg text-[18rem]">
group
</span>
}
>
{(icon) => (
<img
class="aspect-square size-60 rounded-lg"
src={icon()}
alt="Clan Logo"
/>
)}
</Show>
</figure>
<div class="flex flex-col items-center">
<div class="text-3xl text-primary-800">{curr_name()}</div>
<div class="text-secondary-800">Wide settings</div>
<Icon
class="mt-4"
icon="ClanIcon"
viewBox="0 0 72 89"
width={96}
height={96}
/>
</div>
</>
)}
</Field>
@@ -344,7 +330,6 @@ type AdminData = ClanServiceInstance<"admin">;
export const ClanDetails = () => {
const params = useParams();
const queryClient = useQueryClient();
const clan_dir = window.atob(params.id);
// Fetch general meta data
const clanQuery = createQuery(() => ({
@@ -355,48 +340,17 @@ export const ClanDetails = () => {
return result.data;
},
}));
// Fetch admin settings
const adminQuery = createQuery(() => ({
queryKey: [clan_dir, "inventory", "services", "admin"],
queryFn: async () => {
const result = await get_single_service(queryClient, clan_dir, "admin");
if (!result) throw new Error("Failed to fetch data");
return result;
},
}));
return (
<div class="card card-normal">
<BackButton />
<Show
when={!adminQuery.isLoading}
fallback={
<div>
<span class="loading loading-lg"></span>
</div>
}
>
<>
<Header title={clan_dir} showBack />
<div class="flex flex-col justify-center">
<Switch fallback={<>General data not available</>}>
<Match when={clanQuery.data}>
{(d) => <EditClanForm initial={d()} directory={clan_dir} />}
</Match>
</Switch>
</Show>
<div class="divider"></div>
<Show
when={!adminQuery.isLoading}
fallback={
<div>
<span class="loading loading-lg"></span>
</div>
}
>
<Switch fallback={<>Admin data not available</>}>
<Match when={adminQuery.data}>
{(d) => <AdminModuleForm admin={d()} base_url={clan_dir} />}
</Match>
</Switch>
</Show>
</div>
</div>
</>
);
};

View File

@@ -4,6 +4,8 @@ import { FileInput } from "@/src/components/FileInput";
import Icon from "@/src/components/icon";
import { SelectInput } from "@/src/components/SelectInput";
import { TextInput } from "@/src/components/TextInput";
import { Typography } from "@/src/components/Typography";
import { Header } from "@/src/layout/header";
import {
createForm,
required,
@@ -169,292 +171,308 @@ export const Flash = () => {
};
return (
<div class="m-4 rounded-lg bg-slate-50 p-4 pt-8 shadow-sm shadow-slate-400">
<Form onSubmit={handleSubmit}>
<div class="my-4">
<Field name="sshKeys" type="File[]">
{(field, props) => (
<>
<FileInput
{...props}
onClick={async (event) => {
event.preventDefault(); // Prevent the native file dialog from opening
const input = event.target;
const files = await selectSshKeys();
<>
<Header title="Flash installer" />
<div class="p-4">
<Typography tag="p" hierarchy="body" size="default" color="secondary">
USB Utility image.
</Typography>
<Typography tag="p" hierarchy="body" size="default" color="secondary">
This will make bootstrapping a new machine easier by providing secure
remote connection to any machine when plugged in.
</Typography>
<Form onSubmit={handleSubmit}>
<div class="my-4">
<Field name="sshKeys" type="File[]">
{(field, props) => (
<>
<FileInput
{...props}
onClick={async (event) => {
event.preventDefault(); // Prevent the native file dialog from opening
const input = event.target;
const files = await selectSshKeys();
// Set the files
Object.defineProperty(input, "files", {
value: files,
writable: true,
});
// Define the files property on the input element
const changeEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
input.dispatchEvent(changeEvent);
}}
value={field.value}
error={field.error}
helperText="Provide your SSH public key. For secure and passwordless SSH connections."
label="Authorized SSH Keys"
multiple
required
/>
</>
// Set the files
Object.defineProperty(input, "files", {
value: files,
writable: true,
});
// Define the files property on the input element
const changeEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
input.dispatchEvent(changeEvent);
}}
value={field.value}
error={field.error}
helperText="Provide your SSH public key. For secure and passwordless SSH connections."
label="Authorized SSH Keys"
multiple
required
/>
</>
)}
</Field>
</div>
<Field name="disk" validate={[required("This field is required")]}>
{(field, props) => (
<SelectInput
topRightLabel={
<Button
size="s"
variant="light"
onClick={(e) => {
e.preventDefault();
deviceQuery.refetch();
}}
startIcon={<Icon icon="Update" />}
></Button>
}
formStore={formStore}
selectProps={props}
label="Flash Disk"
value={String(field.value)}
error={field.error}
required
options={
<>
<option value="" disabled>
Select a disk where the installer will be flashed to
</option>
<For each={deviceQuery.data?.blockdevices}>
{(device) => (
<option value={device.path}>
{device.path} -- {device.size} bytes
</option>
)}
</For>
</>
}
/>
)}
</Field>
</div>
<Field name="disk" validate={[required("This field is required")]}>
{(field, props) => (
<SelectInput
topRightLabel={
<Button
size="s"
variant="light"
onClick={(e) => {
e.preventDefault();
deviceQuery.refetch();
}}
startIcon={<Icon icon="Reload" />}
></Button>
}
formStore={formStore}
selectProps={props}
label="Flash Disk"
value={String(field.value)}
error={field.error}
required
options={
<>
<option value="" disabled>
Select a disk where the installer will be flashed to
</option>
<For each={deviceQuery.data?.blockdevices}>
{(device) => (
<option value={device.path}>
{device.path} -- {device.size} bytes
</option>
)}
</For>
</>
}
/>
)}
</Field>
{/* WiFi Networks */}
<div class="my-4 py-2">
<h3 class="mb-2 text-lg font-semibold">WiFi Networks</h3>
<span class="mb-2 text-sm">Add preconfigured networks</span>
<For each={wifiNetworks()}>
{(network, index) => (
<div class="mb-2 grid grid-cols-7 gap-2">
<Field
name={`wifi.${index()}.ssid`}
validate={[required("SSID is required")]}
>
{(field, props) => (
<TextInput
formStore={formStore}
inputProps={props}
label="SSID"
value={field.value ?? ""}
error={field.error}
class="col-span-3"
required
/>
)}
</Field>
<Field
name={`wifi.${index()}.password`}
validate={[required("Password is required")]}
>
{(field, props) => (
<div class="relative col-span-3 w-full">
{/* WiFi Networks */}
<div class="my-4 py-2">
<h3 class="mb-2 text-lg font-semibold">WiFi Networks</h3>
<span class="mb-2 text-sm">Add preconfigured networks</span>
<For each={wifiNetworks()}>
{(network, index) => (
<div class="mb-2 grid grid-cols-7 gap-2">
<Field
name={`wifi.${index()}.ssid`}
validate={[required("SSID is required")]}
>
{(field, props) => (
<TextInput
formStore={formStore}
inputProps={props}
type={
passwordVisibility()[index()] ? "text" : "password"
}
label="Password"
label="SSID"
value={field.value ?? ""}
error={field.error}
adornment={{
position: "end",
content: (
<Button
variant="light"
type="button"
class="flex justify-center opacity-70"
onClick={() => togglePasswordVisibility(index())}
startIcon={
passwordVisibility()[index()] ? (
<Icon icon="EyeClose" />
) : (
<Icon icon="EyeOpen" />
)
}
></Button>
),
}}
class="col-span-3"
required
/>
</div>
)}
</Field>
<div class="col-span-1 self-end">
<Button
type="button"
variant="light"
class="h-12"
onClick={() => removeWifiNetwork(index())}
startIcon={<Icon icon="Trash" />}
></Button>
)}
</Field>
<Field
name={`wifi.${index()}.password`}
validate={[required("Password is required")]}
>
{(field, props) => (
<div class="relative col-span-3 w-full">
<TextInput
formStore={formStore}
inputProps={props}
type={
passwordVisibility()[index()] ? "text" : "password"
}
label="Password"
value={field.value ?? ""}
error={field.error}
adornment={{
position: "end",
content: (
<Button
variant="light"
type="button"
class="flex justify-center opacity-70"
onClick={() =>
togglePasswordVisibility(index())
}
startIcon={
passwordVisibility()[index()] ? (
<Icon icon="EyeClose" />
) : (
<Icon icon="EyeOpen" />
)
}
></Button>
),
}}
required
/>
</div>
)}
</Field>
<div class="col-span-1 self-end">
<Button
type="button"
variant="light"
class="h-12"
onClick={() => removeWifiNetwork(index())}
startIcon={<Icon icon="Trash" />}
></Button>
</div>
</div>
)}
</For>
<div class="">
<Button
type="button"
size="s"
variant="light"
onClick={addWifiNetwork}
startIcon={<Icon icon="Plus" />}
>
Add WiFi Network
</Button>
</div>
</div>
<div class="collapse collapse-arrow" tabindex="0">
<input type="checkbox" />
<div class="collapse-title link font-medium ">
Advanced Settings
</div>
<div class="collapse-content">
<Field
name="machine.flake"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<TextInput
formStore={formStore}
inputProps={props}
label="Source (flake URL)"
value={String(field.value)}
inlineLabel={
<span class="material-icons">file_download</span>
}
error={field.error}
required
/>
</>
)}
</Field>
<Field
name="machine.devicePath"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<TextInput
formStore={formStore}
inputProps={props}
label="Image Name (attribute name)"
value={String(field.value)}
inlineLabel={<span class="material-icons">devices</span>}
error={field.error}
required
/>
</>
)}
</Field>
<div class="my-2 py-2">
<span class="text-sm text-neutral-600">Source URL: </span>
<span class="text-sm text-neutral-600">
{getValue(formStore, "machine.flake") +
"#" +
getValue(formStore, "machine.devicePath")}
</span>
</div>
)}
</For>
<div class="">
<Field
name="language"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<SelectInput
formStore={formStore}
selectProps={props}
label="Language"
value={String(field.value)}
error={field.error}
required
options={
<>
<option value={"en_US.UTF-8"}>{"en_US.UTF-8"}</option>
<For each={langQuery.data}>
{(language) => (
<option value={language}>{language}</option>
)}
</For>
</>
}
/>
</>
)}
</Field>
<Field
name="keymap"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<SelectInput
formStore={formStore}
selectProps={props}
label="Keymap"
value={String(field.value)}
error={field.error}
required
options={
<>
<option value={"en"}>{"en"}</option>
<For each={keymapQuery.data}>
{(keymap) => (
<option value={keymap}>{keymap}</option>
)}
</For>
</>
}
/>
</>
)}
</Field>
</div>
</div>
<hr></hr>
<div class="mt-2 flex justify-end pt-2">
<Button
type="button"
size="s"
variant="light"
onClick={addWifiNetwork}
startIcon={<Icon icon="Plus" />}
class="self-end"
type="submit"
disabled={formStore.submitting}
startIcon={
formStore.submitting ? (
<Icon icon="Load" />
) : (
<Icon icon="Flash" />
)
}
>
Add WiFi Network
{formStore.submitting ? "Flashing..." : "Flash Installer"}
</Button>
</div>
</div>
<div class="collapse collapse-arrow" tabindex="0">
<input type="checkbox" />
<div class="collapse-title link font-medium ">Advanced Settings</div>
<div class="collapse-content">
<Field
name="machine.flake"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<TextInput
formStore={formStore}
inputProps={props}
label="Source (flake URL)"
value={String(field.value)}
inlineLabel={
<span class="material-icons">file_download</span>
}
error={field.error}
required
/>
</>
)}
</Field>
<Field
name="machine.devicePath"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<TextInput
formStore={formStore}
inputProps={props}
label="Image Name (attribute name)"
value={String(field.value)}
inlineLabel={<span class="material-icons">devices</span>}
error={field.error}
required
/>
</>
)}
</Field>
<div class="my-2 py-2">
<span class="text-sm text-neutral-600">Source URL: </span>
<span class="text-sm text-neutral-600">
{getValue(formStore, "machine.flake") +
"#" +
getValue(formStore, "machine.devicePath")}
</span>
</div>
<Field
name="language"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<SelectInput
formStore={formStore}
selectProps={props}
label="Language"
value={String(field.value)}
error={field.error}
required
options={
<>
<option value={"en_US.UTF-8"}>{"en_US.UTF-8"}</option>
<For each={langQuery.data}>
{(language) => (
<option value={language}>{language}</option>
)}
</For>
</>
}
/>
</>
)}
</Field>
<Field
name="keymap"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<SelectInput
formStore={formStore}
selectProps={props}
label="Keymap"
value={String(field.value)}
error={field.error}
required
options={
<>
<option value={"en"}>{"en"}</option>
<For each={keymapQuery.data}>
{(keymap) => <option value={keymap}>{keymap}</option>}
</For>
</>
}
/>
</>
)}
</Field>
</div>
</div>
<hr></hr>
<div class="mt-2 flex justify-end pt-2">
<Button
class="self-end"
type="submit"
disabled={formStore.submitting}
startIcon={
formStore.submitting ? (
<Icon icon="Load" />
) : (
<Icon icon="Flash" />
)
}
>
{formStore.submitting ? "Flashing..." : "Flash Installer"}
</Button>
</div>
</Form>
</div>
</Form>
</div>
</>
);
};

View File

@@ -1,6 +1,5 @@
import { callApi, SuccessData, SuccessQuery } from "@/src/api";
import { activeURI } from "@/src/App";
import { BackButton } from "@/src/components/BackButton";
import { Button } from "@/src/components/button";
import { FileInput } from "@/src/components/FileInput";
import Icon from "@/src/components/icon";
@@ -17,6 +16,7 @@ import { createQuery } from "@tanstack/solid-query";
import { createSignal, For, Show } from "solid-js";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { Header } from "@/src/layout/header";
type MachineFormInterface = MachineData & {
sshKey?: File;
@@ -502,19 +502,21 @@ export const MachineDetails = () => {
}));
return (
<div class="card">
<BackButton />
<Show
when={genericQuery.data}
fallback={<span class="loading loading-lg"></span>}
>
{(data) => (
<>
<MachineForm initialData={data()} />
</>
)}
</Show>
</div>
<>
<Header title={`${params.id} machine`} showBack />
<div class="card">
<Show
when={genericQuery.data}
fallback={<span class="loading loading-lg"></span>}
>
{(data) => (
<>
<MachineForm initialData={data()} />
</>
)}
</Show>
</div>
</>
);
};

View File

@@ -15,6 +15,7 @@ import { useNavigate } from "@solidjs/router";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { Header } from "@/src/layout/header";
import { makePersisted } from "@solid-primitives/storage";
type MachinesModel = Extract<
OperationResponse<"list_inventory_machines">,
@@ -31,7 +32,7 @@ export const MachineListView: Component = () => {
const [filter, setFilter] = createSignal<Filter>({ tags: [] });
const inventoryQuery = createQuery<MachinesModel>(() => ({
queryKey: [activeURI(), "list_machines", "inventory"],
queryKey: [activeURI(), "list_inventory_machines"],
placeholderData: {},
enabled: !!activeURI(),
queryFn: async () => {
@@ -65,6 +66,10 @@ export const MachineListView: Component = () => {
});
const navigate = useNavigate();
const [view, setView] = makePersisted(createSignal<"list" | "grid">("list"), {
name: "machines_view",
storage: localStorage,
});
return (
<>
<Header
@@ -76,21 +81,23 @@ export const MachineListView: Component = () => {
variant="light"
size="s"
onClick={() => refresh()}
startIcon={<Icon icon="Reload" />}
startIcon={<Icon icon="Update" />}
></Button>
</span>
<div class="border border-def-3">
<span class="tooltip tooltip-bottom" data-tip="List View">
<Button
variant="dark"
onclick={() => setView("list")}
variant={view() == "list" ? "dark" : "light"}
size="s"
startIcon={<Icon icon="List" />}
></Button>
</span>
<span class="tooltip tooltip-bottom" data-tip="Grid View">
<Button
variant="light"
onclick={() => setView("grid")}
variant={view() == "grid" ? "dark" : "light"}
size="s"
startIcon={<Icon icon="Grid" />}
></Button>
@@ -110,7 +117,6 @@ export const MachineListView: Component = () => {
}
/>
<div>
{/* <Show when={filter()}> */}
<div class="my-1 flex w-full gap-2 p-2">
<div class="size-6 p-1">
<Icon icon="Filter" />
@@ -170,7 +176,13 @@ export const MachineListView: Component = () => {
</div>
</Match>
<Match when={!inventoryQuery.isLoading}>
<ul>
<div
class="my-4 flex flex-wrap gap-6 px-3 py-2"
classList={{
"flex-col": view() === "list",
"": view() === "grid",
}}
>
<For each={inventoryMachines()}>
{([name, info]) => (
<MachineListItem
@@ -180,7 +192,7 @@ export const MachineListView: Component = () => {
/>
)}
</For>
</ul>
</div>
</Match>
</Switch>
</div>

View File

@@ -1,48 +1,179 @@
import { callApi, SuccessData } from "@/src/api";
import { SuccessData } from "@/src/api";
import { activeURI } from "@/src/App";
import { Button } from "@/src/components/button";
import { Header } from "@/src/layout/header";
import { createModulesQuery } from "@/src/queries";
import { A, useNavigate } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createEffect, For, Match, Switch } from "solid-js";
import { SolidMarkdown } from "solid-markdown";
import { createSignal, For, Match, Switch } from "solid-js";
import { Typography } from "@/src/components/Typography";
import { Menu } from "@/src/components/Menu";
import { makePersisted } from "@solid-primitives/storage";
import { useQueryClient } from "@tanstack/solid-query";
import cx from "classnames";
import Icon from "@/src/components/icon";
export type ModuleInfo = SuccessData<"list_modules">[string];
const ModuleListItem = (props: { name: string; info: ModuleInfo }) => {
interface CategoryProps {
categories: string[];
}
const Categories = (props: CategoryProps) => {
return (
<span class="ml-6 inline-flex h-full align-middle">
{props.categories.map((category) => (
<span class="badge badge-secondary">{category}</span>
))}
</span>
);
};
interface RolesProps {
roles: string[];
}
const Roles = (props: RolesProps) => {
return (
<div>
<span>
<Typography hierarchy="body" size="xs">
Service Typography{" "}
</Typography>
</span>
{props.roles.map((role) => (
<span class="badge badge-ghost">{role}</span>
))}
</div>
);
};
const ModuleItem = (props: {
name: string;
info: ModuleInfo;
class?: string;
}) => {
const { name, info } = props;
const navigate = useNavigate();
return (
<div class="stat">
<div class={cx("stat rounded-lg shadow-md", props.class)}>
<div class="stat-figure text-primary-800">
<div class="join">more</div>
<div class="join">
<Menu popoverid={`menu-${props.name}`} label={<Icon icon={"More"} />}>
<ul class="menu z-[1] w-52 rounded-box bg-base-100 p-2 shadow">
<li>
<a
onClick={() => {
navigate(`/modules/details/${name}`);
}}
>
Configure
</a>
</li>
</ul>
</Menu>
</div>
</div>
<A href={`/modules/details/${name}`}>
<div class="stat-value underline">{name}</div>
<div class="stat-value underline">
{name}
<Categories categories={info.categories} />
</div>
</A>
<div>{info.description}</div>
<div>{JSON.stringify(info.constraints)}</div>
<div class="w-full">
<Typography hierarchy="body" size="default">
{info.description}
</Typography>
</div>
<Roles roles={info.roles || []} />
</div>
);
};
export const ModuleList = () => {
const queryClient = useQueryClient();
const modulesQuery = createModulesQuery(activeURI(), {
features: ["inventory"],
});
const [view, setView] = makePersisted(createSignal<"list" | "grid">("list"), {
name: "modules_view",
storage: localStorage,
});
const refresh = async () => {
queryClient.invalidateQueries({
// Invalidates the cache for of all types of machine list at once
queryKey: [activeURI(), "list_modules"],
});
};
return (
<Switch fallback="Shit">
<Match when={modulesQuery.isLoading}>Loading....</Match>
<Match when={modulesQuery.data}>
<div>
Show Modules
<For each={modulesQuery.data}>
{([k, v]) => <ModuleListItem info={v} name={k} />}
</For>
</div>
</Match>
</Switch>
<>
<Header
title="Modules"
toolbar={
<>
<span class="tooltip tooltip-bottom" data-tip="Reload">
<Button
variant="light"
size="s"
onClick={() => refresh()}
startIcon={<Icon icon="Update" />}
></Button>
</span>
<div class="border border-def-3">
<span class="tooltip tooltip-bottom" data-tip="List View">
<Button
onclick={() => setView("list")}
variant={view() == "list" ? "dark" : "light"}
size="s"
startIcon={<Icon icon="List" />}
></Button>
</span>
<span class="tooltip tooltip-bottom" data-tip="Grid View">
<Button
onclick={() => setView("grid")}
variant={view() == "grid" ? "dark" : "light"}
size="s"
startIcon={<Icon icon="Grid" />}
></Button>
</span>
</div>
<span class="tooltip tooltip-bottom" data-tip="New Machine">
<Button
size="s"
variant="light"
startIcon={<Icon icon="CaretUp" />}
>
Import Module
</Button>
</span>
</>
}
/>
<Switch fallback="Error">
<Match when={modulesQuery.isFetching}>Loading....</Match>
<Match when={modulesQuery.data}>
<div
class="my-4 flex flex-wrap gap-6 px-3 py-2"
classList={{
"flex-col": view() === "list",
"": view() === "grid",
}}
>
<For each={modulesQuery.data}>
{([k, v]) => (
<ModuleItem
info={v}
name={k}
class={view() == "grid" ? cx("max-w-md") : ""}
/>
)}
</For>
</div>
</Match>
</Switch>
</>
);
};