Merge pull request 'UI: Layout improvements & serde fix.' (#2585) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-12-10 20:35:24 +00:00
13 changed files with 326 additions and 255 deletions

View File

@@ -2,36 +2,21 @@ import {
createForm,
Field,
FieldArray,
FieldElement,
FieldValues,
FormStore,
getValue,
minLength,
pattern,
ResponseData,
setValue,
getValues,
insert,
SubmitHandler,
swap,
reset,
remove,
move,
setError,
setValues,
} from "@modular-forms/solid";
import { JSONSchema7, JSONSchema7Type, validate } from "json-schema";
import { JSONSchema7, JSONSchema7Type } from "json-schema";
import { TextInput } from "../fields/TextInput";
import {
children,
Component,
createEffect,
For,
JSX,
Match,
Show,
Switch,
} from "solid-js";
import { createEffect, For, JSX, Match, Show, Switch } from "solid-js";
import cx from "classnames";
import { Label } from "../base/label";
import { SelectInput } from "../fields/Select";
@@ -218,7 +203,7 @@ export function StringField<T extends FieldValues, R extends ResponseData>(
(r) => r === props.path[props.path.length - 1],
),
};
const readonly = props.readonly;
const readonly = !!props.readonly;
return (
<Switch fallback={<Unsupported schema={props.schema} />}>
<Match
@@ -388,20 +373,27 @@ export function ListValueDisplay<T extends FieldValues, R extends ResponseData>(
{props.children}
<div class="ml-4 min-w-fit pb-4">
<Button
variant="light"
variant="ghost"
size="s"
type="button"
onClick={moveItemBy(1)}
disabled={topMost()}
startIcon={<Icon icon="ArrowBottom" />}
class="h-12"
></Button>
<Button
variant="light"
type="button"
variant="ghost"
size="s"
onClick={moveItemBy(-1)}
disabled={bottomMost()}
class="h-12"
startIcon={<Icon icon="ArrowTop" />}
></Button>
<Button
type="button"
variant="ghost"
size="s"
class="h-12"
startIcon={<Icon icon="Trash" />}
onClick={removeItem}
@@ -600,7 +592,9 @@ export function ArrayFields<T extends FieldValues, R extends ResponseData>(
each={fieldArray.items}
fallback={
// Empty list
<span class="text-neutral-500">No items</span>
<span class="text-neutral-500">
No {itemsSchema().title || "entries"} yet.
</span>
}
>
{(item, idx) => (
@@ -644,7 +638,10 @@ export function ArrayFields<T extends FieldValues, R extends ResponseData>(
formProps={{
class: cx("px-2 w-full"),
}}
schema={{ ...itemsSchema(), title: "Add entry" }}
schema={{
...itemsSchema(),
title: itemsSchema().title || "thing",
}}
initialPath={["root"]}
// Reset the input field for list items
resetOnSubmit={true}
@@ -655,10 +652,12 @@ export function ArrayFields<T extends FieldValues, R extends ResponseData>(
components={{
before: (
<Button
variant="light"
variant="ghost"
type="submit"
endIcon={<Icon icon={"Plus"} />}
class="capitalize"
>
Add
Add {itemsSchema().title}
</Button>
),
}}
@@ -847,7 +846,7 @@ export function ObjectFields<T extends FieldValues, R extends ResponseData>(
{key}
</span>
<Button
variant="light"
variant="ghost"
class="ml-auto"
size="s"
type="button"

View File

@@ -168,7 +168,7 @@ export const MachineListItem = (props: MachineListItemProps) => {
<div>
<Menu
popoverid={`menu-${props.name}`}
label={<Icon icon={"Expand"} />}
label={<Icon icon={"More"} />}
>
<ul class="menu z-[1] w-52 rounded-box bg-base-100 p-2 shadow">
<li>

View File

@@ -55,14 +55,12 @@ export const Menu = (props: MenuProps) => {
return (
<div>
<Button
variant="light"
variant="ghost"
size="s"
popovertarget={props.popoverid}
popovertargetaction="toggle"
ref={setReference}
class={cx(
"btn btn-ghost btn-outline join-item btn-sm",
props.buttonClass,
)}
class={cx("join-item", props.buttonClass)}
{...props.buttonProps}
>
{props.label}

View File

@@ -82,7 +82,7 @@ export const Typography = <H extends Hierarchy>(props: TypographyProps<H>) => {
inverted,
hierarchy,
weight = "normal",
tag,
tag = "span",
children,
classes,
} = props;

View File

@@ -1,11 +1,13 @@
import { splitProps, type JSX } from "solid-js";
import cx from "classnames";
import { Typography } from "../Typography";
type Variants = "dark" | "light";
type Variants = "dark" | "light" | "ghost";
type Size = "default" | "s";
const variantColors: Record<Variants, string> = {
dark: cx(
"border border-solid",
"border-secondary-950 bg-primary-900 text-white",
"shadow-inner-primary",
// Hover state
@@ -18,6 +20,7 @@ const variantColors: Record<Variants, string> = {
"disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300",
),
light: cx(
"border border-solid",
"border-secondary-800 bg-secondary-100 text-secondary-800",
"shadow-inner-secondary",
// Hover state
@@ -29,6 +32,17 @@ const variantColors: Record<Variants, string> = {
// Disabled
"disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700",
),
ghost: cx(
// "shadow-inner-secondary",
// Hover state
// Focus state
// Active state
"hover:bg-secondary-200 hover:text-secondary-900",
"focus:bg-secondary-200 focus:text-secondary-900",
"active:bg-secondary-200 active:text-secondary-950 active:shadow-inner-secondary-active",
// Disabled
"disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700",
),
};
const sizePaddings: Record<Size, string> = {
@@ -36,6 +50,11 @@ const sizePaddings: Record<Size, string> = {
s: cx("rounded-sm py-[0.375rem] px-3"),
};
const sizeFont: Record<Size, string> = {
default: cx("text-[0.8125rem]"),
s: cx("text-[0.75rem]"),
};
interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variants;
size?: Size;
@@ -60,11 +79,13 @@ export const Button = (props: ButtonProps) => {
// Layout
"inline-flex items-center flex-shrink gap-2 justify-center",
// Styles
"border border-solid",
"p-4",
sizePaddings[local.size || "default"],
// Colors
variantColors[local.variant || "dark"],
//Font
"leading-none font-semibold",
sizeFont[local.size || "default"],
)}
{...other}
>

View File

@@ -100,13 +100,12 @@ interface RndThumbnailProps {
height?: number;
}
export const RndThumbnail = (props: RndThumbnailProps) => {
const { name } = props;
const seed = Array.from(name).reduce(
(acc, char) => acc + char.charCodeAt(0),
0,
); // Seed from name
const imageSrc = generatePatternedImage(seed, props.width, props.height);
return <img src={imageSrc} alt={name} />;
const seed = () =>
Array.from(props.name).reduce((acc, char) => acc + char.charCodeAt(0), 0); // Seed from name
const imageSrc = () =>
generatePatternedImage(seed(), props.width, props.height);
return <img src={imageSrc()} alt={props.name} />;
};
export const RndThumbnailShow = () => {

View File

@@ -1,6 +1,6 @@
/* @refresh reload */
import { Portal, render } from "solid-js/web";
import { RouteDefinition, Router } from "@solidjs/router";
import { Navigate, RouteDefinition, Router } from "@solidjs/router";
import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
@@ -46,6 +46,12 @@ export type AppRoute = Omit<RouteDefinition, "children"> & {
};
export const routes: AppRoute[] = [
{
path: "/",
label: "",
hidden: true,
component: () => <Navigate href="/machines" />,
},
{
path: "/machines",
label: "Machines",
@@ -139,7 +145,6 @@ export const routes: AppRoute[] = [
hidden: false,
component: () => <Welcome />,
},
{
path: "/api_testing",
label: "api_testing",

View File

@@ -1,33 +1,13 @@
import { createQuery } from "@tanstack/solid-query";
import { activeURI } from "../App";
import { callApi } from "../api";
import { Accessor, Show } from "solid-js";
import { useNavigate } from "@solidjs/router";
import Icon from "../components/icon";
import { Button } from "../components/button";
import { JSX } from "solid-js";
import { Typography } from "../components/Typography";
interface HeaderProps {
clan_dir: Accessor<string | null>;
title: string;
toolbar?: JSX.Element;
}
export const Header = (props: HeaderProps) => {
const { clan_dir } = props;
const navigate = useNavigate();
const query = createQuery(() => ({
queryKey: [clan_dir(), "meta"],
queryFn: async () => {
const curr = clan_dir();
if (curr) {
const result = await callApi("show_clan_meta", { uri: curr });
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
return (
<div class="navbar">
<div class="navbar border-b px-6 py-4 border-def-3">
<div class="flex-none">
<span class="tooltip tooltip-bottom lg:hidden" data-tip="Menu">
<label
@@ -38,19 +18,13 @@ export const Header = (props: HeaderProps) => {
</label>
</span>
</div>
<div class="flex-1"></div>
<div class="flex-none">
<Show when={activeURI()}>
{(d) => (
<span class="tooltip tooltip-bottom" data-tip="Clan Settings">
<Button
variant="light"
onClick={() => navigate(`/clans/${window.btoa(d())}`)}
startIcon={<Icon icon="Settings" />}
></Button>
</span>
)}
</Show>
<div class="flex-1">
<Typography hierarchy="title" size="m" weight="medium">
{props.title}
</Typography>
</div>
<div class="flex-none items-center justify-center gap-3">
{props.toolbar}
</div>
</div>
);

View File

@@ -1,13 +1,11 @@
import { Component, createEffect, Show } from "solid-js";
import { Header } from "./header";
import { Component, createEffect } from "solid-js";
import { Sidebar } from "@/src/components/Sidebar";
import { activeURI, clanList } from "../App";
import { clanList } from "../App";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
export const Layout: Component<RouteSectionProps> = (props) => {
const navigate = useNavigate();
createEffect(() => {
console.log("Layout props", props.location);
console.log(
"empty ClanList, redirect to welcome page",
clanList().length === 0,
@@ -25,10 +23,7 @@ export const Layout: Component<RouteSectionProps> = (props) => {
type="checkbox"
class="drawer-toggle hidden"
/>
<div class="drawer-content overflow-x-hidden overflow-y-scroll p-2">
<Show when={props.location.pathname !== "welcome"}>
<Header clan_dir={activeURI} />
</Show>
<div class="drawer-content my-2 ml-8 overflow-x-hidden overflow-y-scroll rounded-lg border bg-def-1 border-def-3">
{props.children}
</div>
<div

View File

@@ -0,0 +1,22 @@
import { RndThumbnail } from "@/src/components/noiseThumbnail";
import cx from "classnames";
interface AvatarProps {
name?: string;
class?: string;
}
export const MachineAvatar = (props: AvatarProps) => {
return (
<figure>
<div class="avatar placeholder">
<div
class={cx(
"rounded-lg border p-2 bg-def-1 border-def-3 size-36",
props.class,
)}
>
<RndThumbnail name={props.name || ""} />
</div>
</div>
</figure>
);
};

View File

@@ -3,11 +3,14 @@ import { activeURI } from "@/src/App";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { TextInput } from "@/src/components/TextInput";
import { Header } from "@/src/layout/header";
import { createForm, required, reset } from "@modular-forms/solid";
import { useNavigate } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { useQueryClient } from "@tanstack/solid-query";
import { Match, Switch } from "solid-js";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { DynForm } from "@/src/Form/form";
type CreateMachineForm = OperationArgs<"create_machine">;
@@ -66,68 +69,111 @@ export function CreateMachine() {
}
};
return (
<div class="flex w-full justify-center">
<div class="mt-4 w-full max-w-3xl self-stretch px-2">
<span class="px-2">Create new Machine</span>
<Form onSubmit={handleSubmit}>
<Field
name="opts.machine.name"
validate={[required("This field is required")]}
>
{(field, props) => (
<TextInput
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"name"}
error={field.error}
required
/>
)}
</Field>
<Field name="opts.machine.description">
{(field, props) => (
<TextInput
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"description"}
error={field.error}
/>
)}
</Field>
<Field name="opts.machine.deploy.targetHost">
{(field, props) => (
<>
<>
<Header title="Create Machine" />
<div class="flex w-full p-4">
<div class="mt-4 w-full self-stretch px-2">
<Form onSubmit={handleSubmit}>
<Field
name="opts.machine.name"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<div class="flex justify-center">
<MachineAvatar name={field.value} />
</div>
<TextInput
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"name"}
error={field.error}
required
placeholder="New_machine"
/>
</>
)}
</Field>
<Field name="opts.machine.description">
{(field, props) => (
<TextInput
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"Deployment target"}
label={"description"}
error={field.error}
placeholder="My awesome machine"
/>
</>
)}
</Field>
<div class="mt-12 flex justify-end">
<Button
type="submit"
disabled={formStore.submitting}
startIcon={
formStore.submitting ? (
<Icon icon="Load" />
) : (
<Icon icon="Plus" />
)
}
>
<Switch fallback={<>Creating</>}>
<Match when={!formStore.submitting}>Create</Match>
</Switch>
</Button>
</div>
</Form>
)}
</Field>
<Field name="opts.machine.tags" type="string[]">
{(field, props) => (
<div class="p-2">
<DynForm
initialValues={{ tags: ["all"] }}
components={{
before: <div>Tags</div>,
}}
schema={{
type: "object",
properties: {
tags: {
type: "array",
items: {
title: "Tag",
type: "string",
},
uniqueItems: true,
},
},
}}
/>
</div>
)}
</Field>
<div class="collapse collapse-arrow" tabindex="0">
<input type="checkbox" />
<div class="collapse-title link font-medium ">
Deployment Settings
</div>
<div class="collapse-content">
<Field name="opts.machine.deploy.targetHost">
{(field, props) => (
<>
<TextInput
inputProps={props}
formStore={formStore}
value={`${field.value}`}
label={"Target"}
error={field.error}
placeholder="e.g. 192.168.188.64"
/>
</>
)}
</Field>
</div>
</div>
<div class="mt-12 flex justify-end">
<Button
type="submit"
disabled={formStore.submitting}
startIcon={
formStore.submitting ? (
<Icon icon="Load" />
) : (
<Icon icon="Plus" />
)
}
>
<Switch fallback={<>Creating</>}>
<Match when={!formStore.submitting}>Create</Match>
</Switch>
</Button>
</div>
</Form>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,12 +1,9 @@
import { callApi, ClanService, SuccessData, SuccessQuery } from "@/src/api";
import { get_iwd_service } from "@/src/api/wifi";
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";
import { RndThumbnail } from "@/src/components/noiseThumbnail";
import { SelectInput } from "@/src/components/SelectInput";
import { TextInput } from "@/src/components/TextInput";
import { selectSshKeys } from "@/src/hooks";
import {
@@ -16,9 +13,10 @@ import {
setValue,
} from "@modular-forms/solid";
import { useParams } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createSignal, For, Show, Switch, Match, JSXElement } from "solid-js";
import { createQuery } from "@tanstack/solid-query";
import { createSignal, For, Show } from "solid-js";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
type MachineFormInterface = MachineData & {
sshKey?: File;
@@ -305,24 +303,9 @@ const MachineForm = (props: MachineDetailsProps) => {
return (
<>
<Form onSubmit={handleSubmit}>
<figure>
<div
class="avatar placeholder"
classList={
{
// online: onlineStatusQuery.data === "Online",
// offline: onlineStatusQuery.data === "Offline",
}
}
>
<div class="w-32 rounded-lg border p-2 bg-def-4 border-inv-3">
<RndThumbnail name={machineName() || "M"} />
</div>
</div>
</figure>
<div class="card-body">
<span class="text-xl text-primary-800">General</span>
<MachineAvatar name={machineName()} />
<Field name="machine.name">
{(field, props) => (
<TextInput

View File

@@ -14,6 +14,7 @@ import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { useNavigate } from "@solidjs/router";
import { Button } from "@/src/components/button";
import Icon from "@/src/components/icon";
import { Header } from "@/src/layout/header";
type MachinesModel = Extract<
OperationResponse<"list_inventory_machines">,
@@ -65,96 +66,124 @@ export const MachineListView: Component = () => {
const navigate = useNavigate();
return (
<div>
<div class="tooltip tooltip-bottom" data-tip="Open Clan"></div>
<div class="tooltip tooltip-bottom" data-tip="Refresh">
<Button
variant="light"
onClick={() => refresh()}
startIcon={<Icon icon="Reload" />}
></Button>
</div>
<>
<Header
title="Your Machines"
toolbar={
<>
<span class="tooltip tooltip-bottom" data-tip="Reload">
<Button
variant="light"
size="s"
onClick={() => refresh()}
startIcon={<Icon icon="Reload" />}
></Button>
</span>
<div class="tooltip tooltip-bottom" data-tip="Create machine">
<Button
variant="light"
onClick={() => navigate("create")}
startIcon={<Icon icon="Plus" />}
></Button>
</div>
{/* <Show when={filter()}> */}
<div class="my-1 flex w-full gap-2 p-2">
<div class="size-6 p-1">
<Icon icon="Filter" />
</div>
<For each={filter().tags.sort()}>
{(tag) => (
<button
type="button"
onClick={() =>
setFilter((prev) => {
return {
...prev,
tags: prev.tags.filter((t) => t !== tag),
};
})
}
>
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
<div class="border border-def-3">
<span class="tooltip tooltip-bottom" data-tip="List View">
<Button
variant="dark"
size="s"
startIcon={<Icon icon="List" />}
></Button>
</span>
</button>
)}
</For>
</div>
{/* </Show> */}
<Switch>
<Match when={inventoryQuery.isLoading}>
{/* Loading skeleton */}
<div>
<div class="card card-side m-2 bg-base-100 shadow-lg">
<figure class="pl-2">
<div class="skeleton size-12"></div>
</figure>
<div class="card-body">
<h2 class="card-title">
<div class="skeleton h-12 w-80"></div>
</h2>
<div class="skeleton h-8 w-72"></div>
<span class="tooltip tooltip-bottom" data-tip="Grid View">
<Button
variant="light"
size="s"
startIcon={<Icon icon="Grid" />}
></Button>
</span>
</div>
<span class="tooltip tooltip-bottom" data-tip="New Machine">
<Button
onClick={() => navigate("create")}
size="s"
variant="light"
startIcon={<Icon icon="Plus" />}
>
New Machine
</Button>
</span>
</>
}
/>
<div>
{/* <Show when={filter()}> */}
<div class="my-1 flex w-full gap-2 p-2">
<div class="size-6 p-1">
<Icon icon="Filter" />
</div>
<For each={filter().tags.sort()}>
{(tag) => (
<button
type="button"
onClick={() =>
setFilter((prev) => {
return {
...prev,
tags: prev.tags.filter((t) => t !== tag),
};
})
}
>
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
</span>
</button>
)}
</For>
</div>
{/* </Show> */}
<Switch>
<Match when={inventoryQuery.isLoading}>
{/* Loading skeleton */}
<div>
<div class="card card-side m-2 bg-base-100 shadow-lg">
<figure class="pl-2">
<div class="skeleton size-12"></div>
</figure>
<div class="card-body">
<h2 class="card-title">
<div class="skeleton h-12 w-80"></div>
</h2>
<div class="skeleton h-8 w-72"></div>
</div>
</div>
</div>
</div>
</Match>
<Match
when={!inventoryQuery.isLoading && inventoryMachines().length === 0}
>
<div class="mt-8 flex w-full flex-col items-center justify-center gap-2">
<span class="text-lg text-neutral">
No machines defined yet. Click below to define one.
</span>
<Button
variant="light"
class="size-28 overflow-hidden p-2"
onClick={() => navigate("/machines/create")}
>
<span class="material-icons text-6xl font-light">add</span>
</Button>
</div>
</Match>
<Match when={!inventoryQuery.isLoading}>
<ul>
<For each={inventoryMachines()}>
{([name, info]) => (
<MachineListItem
name={name}
info={info}
setFilter={setFilter}
/>
)}
</For>
</ul>
</Match>
</Switch>
</div>
</Match>
<Match
when={!inventoryQuery.isLoading && inventoryMachines().length === 0}
>
<div class="mt-8 flex w-full flex-col items-center justify-center gap-2">
<span class="text-lg text-neutral">
No machines defined yet. Click below to define one.
</span>
<Button
variant="light"
class="size-28 overflow-hidden p-2"
onClick={() => navigate("/machines/create")}
>
<span class="material-icons text-6xl font-light">add</span>
</Button>
</div>
</Match>
<Match when={!inventoryQuery.isLoading}>
<ul>
<For each={inventoryMachines()}>
{([name, info]) => (
<MachineListItem
name={name}
info={info}
setFilter={setFilter}
/>
)}
</For>
</ul>
</Match>
</Switch>
</div>
</>
);
};