UI: remove 2d-ui, its broken now since we deleted the symlinked files in #4266
This commit is contained in:
@@ -1 +0,0 @@
|
||||
../ui/.fonts
|
||||
5
pkgs/clan-app/ui-2d/.gitignore
vendored
5
pkgs/clan-app/ui-2d/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
app/api
|
||||
app/.fonts
|
||||
|
||||
.vite
|
||||
storybook-static
|
||||
@@ -1 +0,0 @@
|
||||
../ui/.storybook
|
||||
7
pkgs/clan-app/ui-2d/.vscode/settings.json
vendored
7
pkgs/clan-app/ui-2d/.vscode/settings.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
|
||||
],
|
||||
"editor.wordWrap": "on"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
../ui/api
|
||||
@@ -1 +0,0 @@
|
||||
../ui/eslint.config.mjs
|
||||
@@ -1 +0,0 @@
|
||||
../ui/gtk.webview.js
|
||||
@@ -1 +0,0 @@
|
||||
../ui/icons
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Solid App</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"ignore": [
|
||||
"gtk.webview.js",
|
||||
"stylelint.config.js",
|
||||
"util.ts",
|
||||
"src/components/v2/**",
|
||||
"api/**",
|
||||
"tailwind/**"
|
||||
]
|
||||
}
|
||||
1
pkgs/clan-app/ui-2d/package-lock.json
generated
1
pkgs/clan-app/ui-2d/package-lock.json
generated
@@ -1 +0,0 @@
|
||||
../ui/package-lock.json
|
||||
@@ -1 +0,0 @@
|
||||
../ui/package.json
|
||||
@@ -1 +0,0 @@
|
||||
../ui/postcss.config.js
|
||||
@@ -1 +0,0 @@
|
||||
../ui/prettier.config.js
|
||||
@@ -1,125 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import type {
|
||||
ComputePositionConfig,
|
||||
ComputePositionReturn,
|
||||
ReferenceElement,
|
||||
} from "@floating-ui/dom";
|
||||
import { computePosition } from "@floating-ui/dom";
|
||||
|
||||
interface UseFloatingOptions<R extends ReferenceElement, F extends HTMLElement>
|
||||
extends Partial<ComputePositionConfig> {
|
||||
whileElementsMounted?: (
|
||||
reference: R,
|
||||
floating: F,
|
||||
update: () => void,
|
||||
) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
void | (() => void);
|
||||
}
|
||||
|
||||
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
}
|
||||
|
||||
interface UseFloatingResult extends UseFloatingState {
|
||||
update(): void;
|
||||
}
|
||||
|
||||
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
||||
reference: () => R | undefined | null,
|
||||
floating: () => F | undefined | null,
|
||||
options?: UseFloatingOptions<R, F>,
|
||||
): UseFloatingResult {
|
||||
const placement = () => options?.placement ?? "bottom";
|
||||
const strategy = () => options?.strategy ?? "absolute";
|
||||
|
||||
const [data, setData] = createSignal<UseFloatingState>({
|
||||
x: null,
|
||||
y: null,
|
||||
placement: placement(),
|
||||
strategy: strategy(),
|
||||
middlewareData: {},
|
||||
});
|
||||
|
||||
const [error, setError] = createSignal<{ value: unknown } | undefined>();
|
||||
|
||||
createEffect(() => {
|
||||
const currentError = error();
|
||||
if (currentError) {
|
||||
throw currentError.value;
|
||||
}
|
||||
});
|
||||
|
||||
const version = createMemo(() => {
|
||||
reference();
|
||||
floating();
|
||||
return {};
|
||||
});
|
||||
|
||||
function update() {
|
||||
const currentReference = reference();
|
||||
const currentFloating = floating();
|
||||
|
||||
if (currentReference && currentFloating) {
|
||||
const capturedVersion = version();
|
||||
computePosition(currentReference, currentFloating, {
|
||||
middleware: options?.middleware,
|
||||
placement: placement(),
|
||||
strategy: strategy(),
|
||||
}).then(
|
||||
(currentData) => {
|
||||
// Check if it's still valid
|
||||
if (capturedVersion === version()) {
|
||||
setData(currentData);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setError(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const currentReference = reference();
|
||||
const currentFloating = floating();
|
||||
|
||||
placement();
|
||||
strategy();
|
||||
|
||||
if (currentReference && currentFloating) {
|
||||
if (options?.whileElementsMounted) {
|
||||
const cleanup = options.whileElementsMounted(
|
||||
currentReference,
|
||||
currentFloating,
|
||||
update,
|
||||
);
|
||||
|
||||
if (cleanup) {
|
||||
onCleanup(cleanup);
|
||||
}
|
||||
} else {
|
||||
update();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get x() {
|
||||
return data().x;
|
||||
},
|
||||
get y() {
|
||||
return data().y;
|
||||
},
|
||||
get placement() {
|
||||
return data().placement;
|
||||
},
|
||||
get strategy() {
|
||||
return data().strategy;
|
||||
},
|
||||
get middlewareData() {
|
||||
return data().middlewareData;
|
||||
},
|
||||
update,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { JSX } from "solid-js";
|
||||
interface LabelProps {
|
||||
label: JSX.Element;
|
||||
required?: boolean;
|
||||
}
|
||||
export const Label = (props: LabelProps) => (
|
||||
<span
|
||||
class=" block"
|
||||
classList={{
|
||||
"after:ml-0.5 after:text-primary after:content-['*']": props.required,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { JSX } from "solid-js";
|
||||
|
||||
interface FormSectionProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
const FormSection = (props: FormSectionProps) => {
|
||||
return <div class="p-2">{props.children}</div>;
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
import {
|
||||
createUniqueId,
|
||||
createSignal,
|
||||
Show,
|
||||
type JSX,
|
||||
For,
|
||||
createMemo,
|
||||
Accessor,
|
||||
} from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { useFloating } from "../base";
|
||||
import { autoUpdate, flip, hide, offset, shift, size } from "@floating-ui/dom";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import {
|
||||
InputBase,
|
||||
InputError,
|
||||
InputLabel,
|
||||
InputLabelProps,
|
||||
} from "@/src/components/inputBase";
|
||||
import { FieldLayout } from "./layout";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectInputpProps {
|
||||
value: string[] | string;
|
||||
selectProps?: JSX.InputHTMLAttributes<HTMLSelectElement>;
|
||||
options: Option[];
|
||||
label: JSX.Element;
|
||||
labelProps?: InputLabelProps;
|
||||
helperText?: JSX.Element;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
type?: string;
|
||||
inlineLabel?: JSX.Element;
|
||||
class?: string;
|
||||
adornment?: {
|
||||
position: "start" | "end";
|
||||
content: JSX.Element;
|
||||
};
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
multiple?: boolean;
|
||||
loading?: boolean;
|
||||
portalRef?: Accessor<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function SelectInput(props: SelectInputpProps) {
|
||||
const _id = createUniqueId();
|
||||
|
||||
const [reference, setReference] = createSignal<HTMLElement>();
|
||||
const [floating, setFloating] = createSignal<HTMLElement>();
|
||||
|
||||
// `position` is a reactive object.
|
||||
const position = useFloating(reference, floating, {
|
||||
placement: "bottom-start",
|
||||
|
||||
// pass options. Ensure the cleanup function is returned.
|
||||
whileElementsMounted: (reference, floating, update) =>
|
||||
autoUpdate(reference, floating, update, {
|
||||
animationFrame: true,
|
||||
}),
|
||||
middleware: [
|
||||
size({
|
||||
apply({ rects, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
minWidth: `${rects.reference.width}px`,
|
||||
});
|
||||
},
|
||||
}),
|
||||
offset({ mainAxis: 2 }),
|
||||
shift(),
|
||||
flip(),
|
||||
hide({
|
||||
strategy: "referenceHidden",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// Create values list
|
||||
const getValues = createMemo(() => {
|
||||
return Array.isArray(props.value)
|
||||
? (props.value as string[])
|
||||
: typeof props.value === "string"
|
||||
? [props.value]
|
||||
: [];
|
||||
});
|
||||
|
||||
// const getSingleValue = createMemo(() => {
|
||||
// const values = getValues();
|
||||
// return values.length > 0 ? values[0] : "";
|
||||
// });
|
||||
|
||||
const handleClickOption = (opt: Option) => {
|
||||
if (!props.multiple) {
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
props.selectProps.onInput({
|
||||
currentTarget: {
|
||||
value: opt.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
let currValues = getValues();
|
||||
|
||||
if (currValues.includes(opt.value)) {
|
||||
currValues = currValues.filter((o) => o !== opt.value);
|
||||
} else {
|
||||
currValues.push(opt.value);
|
||||
}
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
props.selectProps.onInput({
|
||||
currentTarget: {
|
||||
options: currValues.map((value) => ({
|
||||
value,
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldLayout
|
||||
error={props.error && <InputError error={props.error} />}
|
||||
label={
|
||||
<InputLabel
|
||||
description={""}
|
||||
required={props.required}
|
||||
{...props.labelProps}
|
||||
>
|
||||
{props.label}
|
||||
</InputLabel>
|
||||
}
|
||||
field={
|
||||
<InputBase
|
||||
error={!!props.error}
|
||||
disabled={props.disabled}
|
||||
required={props.required}
|
||||
class="!justify-start"
|
||||
divRef={setReference}
|
||||
inputElem={
|
||||
<button
|
||||
// TODO: Keyboard acessibililty
|
||||
// Currently the popover only opens with onClick
|
||||
// Options are not selectable with keyboard
|
||||
tabIndex={-1}
|
||||
disabled={props.disabled}
|
||||
onClick={() => {
|
||||
const popover = document.getElementById(_id);
|
||||
if (popover) {
|
||||
popover.togglePopover(); // Show or hide the popover
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2"
|
||||
formnovalidate
|
||||
// TODO: Use native popover once Webkit supports it within <form>
|
||||
// popovertarget={_id}
|
||||
// popovertargetaction="toggle"
|
||||
>
|
||||
<Show
|
||||
when={props.adornment && props.adornment.position === "start"}
|
||||
>
|
||||
{props.adornment?.content}
|
||||
</Show>
|
||||
{props.inlineLabel}
|
||||
<div class="flex cursor-default flex-row gap-2">
|
||||
<Show
|
||||
when={
|
||||
getValues() &&
|
||||
getValues.length !== 1 &&
|
||||
getValues()[0] !== ""
|
||||
}
|
||||
fallback={props.placeholder}
|
||||
>
|
||||
<For each={getValues()} fallback={"Select"}>
|
||||
{(item) => (
|
||||
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
|
||||
{item}
|
||||
<Show when={props.multiple}>
|
||||
<button
|
||||
class=""
|
||||
type="button"
|
||||
onClick={(_e) => {
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
props.selectProps.onInput({
|
||||
currentTarget: {
|
||||
options: getValues()
|
||||
.filter((o) => o !== item)
|
||||
.map((value) => ({
|
||||
value,
|
||||
selected: true,
|
||||
disabled: false,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Show
|
||||
when={props.adornment && props.adornment.position === "end"}
|
||||
>
|
||||
{props.adornment?.content}
|
||||
</Show>
|
||||
<Icon size={12} icon="CaretDown" class="ml-auto mr-2" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Portal
|
||||
mount={
|
||||
props.portalRef ? props.portalRef() || document.body : document.body
|
||||
}
|
||||
>
|
||||
<div
|
||||
id={_id}
|
||||
popover
|
||||
ref={setFloating}
|
||||
style={{
|
||||
margin: 0,
|
||||
position: position.strategy,
|
||||
top: `${position.y ?? 0}px`,
|
||||
left: `${position.x ?? 0}px`,
|
||||
}}
|
||||
class="rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll p-1">
|
||||
<Show when={!props.loading} fallback={"Loading ...."}>
|
||||
<For each={props.options}>
|
||||
{(opt) => (
|
||||
<>
|
||||
<li>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!justify-start"
|
||||
onClick={() => handleClickOption(opt)}
|
||||
disabled={opt.disabled}
|
||||
classList={{
|
||||
active:
|
||||
!opt.disabled && getValues().includes(opt.value),
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</ul>
|
||||
</div>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { splitProps, type JSX } from "solid-js";
|
||||
import {
|
||||
InputBase,
|
||||
InputError,
|
||||
InputLabel,
|
||||
InputVariant,
|
||||
} from "@/src/components/inputBase";
|
||||
import { FieldLayout } from "./layout";
|
||||
|
||||
interface TextInputProps {
|
||||
// Common
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
// Passed to input
|
||||
value: string;
|
||||
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
|
||||
placeholder?: string;
|
||||
variant?: InputVariant;
|
||||
// Passed to label
|
||||
label: JSX.Element;
|
||||
help?: string;
|
||||
// Passed to layout
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export function TextInput(props: TextInputProps) {
|
||||
const [layoutProps, rest] = splitProps(props, ["class"]);
|
||||
return (
|
||||
<FieldLayout
|
||||
label={
|
||||
<InputLabel
|
||||
class="col-span-2"
|
||||
required={props.required}
|
||||
error={!!props.error}
|
||||
help={props.help}
|
||||
>
|
||||
{props.label}
|
||||
</InputLabel>
|
||||
}
|
||||
field={
|
||||
<InputBase
|
||||
variant={props.variant}
|
||||
error={!!props.error}
|
||||
required={props.required}
|
||||
disabled={props.disabled}
|
||||
placeholder={props.placeholder}
|
||||
class="col-span-10"
|
||||
{...props.inputProps}
|
||||
value={props.value}
|
||||
/>
|
||||
}
|
||||
error={props.error && <InputError error={props.error} />}
|
||||
{...layoutProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./FormSection";
|
||||
export * from "./TextInput";
|
||||
@@ -1,26 +0,0 @@
|
||||
import { JSX, splitProps } from "solid-js";
|
||||
import cx from "classnames";
|
||||
|
||||
interface LayoutProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
||||
field?: JSX.Element;
|
||||
label?: JSX.Element;
|
||||
error?: JSX.Element;
|
||||
}
|
||||
export const FieldLayout = (props: LayoutProps) => {
|
||||
const [intern, divProps] = splitProps(props, [
|
||||
"field",
|
||||
"label",
|
||||
"error",
|
||||
"class",
|
||||
]);
|
||||
return (
|
||||
<div
|
||||
class={cx("grid grid-cols-10 items-center", intern.class)}
|
||||
{...divProps}
|
||||
>
|
||||
<div class="col-span-5 flex items-center">{props.label}</div>
|
||||
<div class="col-span-5">{props.field}</div>
|
||||
{props.error && <span class="col-span-full">{props.error}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { JSX } from "solid-js";
|
||||
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
|
||||
interface FieldsetProps {
|
||||
legend?: string;
|
||||
children: JSX.Element;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export default function Fieldset(props: FieldsetProps) {
|
||||
return (
|
||||
<fieldset class="flex flex-col gap-y-2.5">
|
||||
{props.legend && (
|
||||
<div class="px-2">
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
tag="p"
|
||||
size="s"
|
||||
color="primary"
|
||||
weight="medium"
|
||||
>
|
||||
{props.legend}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
<div class="flex flex-col gap-y-3 rounded-md border border-secondary-200 bg-secondary-50 p-5">
|
||||
{props.children}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -1,928 +0,0 @@
|
||||
import {
|
||||
createForm,
|
||||
Field,
|
||||
FieldArray,
|
||||
FieldValues,
|
||||
FormStore,
|
||||
pattern,
|
||||
ResponseData,
|
||||
setValue,
|
||||
getValues,
|
||||
insert,
|
||||
SubmitHandler,
|
||||
reset,
|
||||
remove,
|
||||
move,
|
||||
} from "@modular-forms/solid";
|
||||
import { JSONSchema7, JSONSchema7Type } from "json-schema";
|
||||
import { TextInput } from "../fields/TextInput";
|
||||
import { createEffect, For, JSX, Match, Show, Switch } from "solid-js";
|
||||
import cx from "classnames";
|
||||
import { Label } from "../base/label";
|
||||
import { SelectInput } from "../fields/Select";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
function generateDefaults(schema: JSONSchema7): unknown {
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
return ""; // Default value for string
|
||||
|
||||
case "number":
|
||||
case "integer":
|
||||
return 0; // Default value for number/integer
|
||||
|
||||
case "boolean":
|
||||
return false; // Default value for boolean
|
||||
|
||||
case "array":
|
||||
return []; // Default empty array if no items schema or items is true/false
|
||||
|
||||
case "object": {
|
||||
const obj: Record<string, unknown> = {};
|
||||
if (schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||
if (typeof propSchema === "boolean") {
|
||||
obj[key] = false;
|
||||
} else {
|
||||
// if (schema.required schema.required.includes(key))
|
||||
obj[key] = generateDefaults(propSchema);
|
||||
}
|
||||
});
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
default:
|
||||
return null; // Default for unknown types or nulls
|
||||
}
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
schema: JSONSchema7;
|
||||
initialValues?: NonNullable<unknown>;
|
||||
handleSubmit?: SubmitHandler<NonNullable<unknown>>;
|
||||
initialPath?: string[];
|
||||
components?: {
|
||||
before?: JSX.Element;
|
||||
after?: JSX.Element;
|
||||
};
|
||||
readonly?: boolean;
|
||||
formProps?: JSX.InputHTMLAttributes<HTMLFormElement>;
|
||||
errorContext?: string;
|
||||
resetOnSubmit?: boolean;
|
||||
}
|
||||
export const DynForm = (props: FormProps) => {
|
||||
const [formStore, { Field, Form: ModuleForm }] = createForm({
|
||||
initialValues: props.initialValues,
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
|
||||
values,
|
||||
event,
|
||||
) => {
|
||||
console.log("Submitting form values", values, props.errorContext);
|
||||
props.handleSubmit?.(values, event);
|
||||
// setValue(formStore, "root", null);
|
||||
if (props.resetOnSubmit) {
|
||||
console.log("Resetting form", values, props.initialValues);
|
||||
reset(formStore);
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
console.log("FormStore", formStore);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* @ts-expect-error: This happened after solidjs upgrade. TOOD: fixme */}
|
||||
<ModuleForm {...props.formProps} onSubmit={handleSubmit}>
|
||||
{props.components?.before}
|
||||
<SchemaFields
|
||||
schema={props.schema}
|
||||
Field={Field}
|
||||
formStore={formStore}
|
||||
path={props.initialPath || []}
|
||||
readonly={!!props.readonly}
|
||||
parent={props.schema}
|
||||
/>
|
||||
{props.components?.after}
|
||||
</ModuleForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface UnsupportedProps {
|
||||
schema: JSONSchema7;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Unsupported = (props: UnsupportedProps) => (
|
||||
<div>
|
||||
{props.error && <div class="font-bold text-error-700">{props.error}</div>}
|
||||
<span>
|
||||
Invalid or unsupported schema entry of type:{" "}
|
||||
<b>{JSON.stringify(props.schema.type)}</b>
|
||||
</span>
|
||||
<pre>
|
||||
<code>{JSON.stringify(props.schema, null, 2)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface SchemaFieldsProps<T extends FieldValues, R extends ResponseData> {
|
||||
formStore: FormStore<T, R>;
|
||||
Field: typeof Field<T, R, never>;
|
||||
schema: JSONSchema7;
|
||||
path: string[];
|
||||
readonly: boolean;
|
||||
parent: JSONSchema7;
|
||||
}
|
||||
function SchemaFields<T extends FieldValues, R extends ResponseData>(
|
||||
props: SchemaFieldsProps<T, R>,
|
||||
) {
|
||||
return (
|
||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||
{/* Simple types */}
|
||||
<Match when={props.schema.type === "boolean"}>bool</Match>
|
||||
|
||||
<Match when={props.schema.type === "integer"}>
|
||||
<StringField {...props} schema={props.schema} />
|
||||
</Match>
|
||||
<Match when={props.schema.type === "number"}>
|
||||
<StringField {...props} schema={props.schema} />
|
||||
</Match>
|
||||
<Match when={props.schema.type === "string"}>
|
||||
<StringField {...props} schema={props.schema} />
|
||||
</Match>
|
||||
{/* Composed types */}
|
||||
<Match when={props.schema.type === "array"}>
|
||||
<ArrayFields {...props} schema={props.schema} />
|
||||
</Match>
|
||||
<Match when={props.schema.type === "object"}>
|
||||
<ObjectFields {...props} schema={props.schema} />
|
||||
</Match>
|
||||
{/* Empty / Null */}
|
||||
<Match when={props.schema.type === "null"}>
|
||||
Dont know how to rendner InputType null
|
||||
<Unsupported schema={props.schema} />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function StringField<T extends FieldValues, R extends ResponseData>(
|
||||
props: SchemaFieldsProps<T, R>,
|
||||
) {
|
||||
if (
|
||||
props.schema.type !== "string" &&
|
||||
props.schema.type !== "number" &&
|
||||
props.schema.type !== "integer"
|
||||
) {
|
||||
return (
|
||||
<span class="text-error-700">
|
||||
Error cannot render the following as String input.
|
||||
<Unsupported schema={props.schema} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const { Field } = props;
|
||||
|
||||
const validate = props.schema.pattern
|
||||
? pattern(
|
||||
new RegExp(props.schema.pattern),
|
||||
`String should follow pattern ${props.schema.pattern}`,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const commonProps = {
|
||||
label: props.schema.title || props.path.join("."),
|
||||
required:
|
||||
props.parent.required &&
|
||||
props.parent.required.some(
|
||||
(r) => r === props.path[props.path.length - 1],
|
||||
),
|
||||
};
|
||||
const readonly = !!props.readonly;
|
||||
return (
|
||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||
<Match
|
||||
when={props.schema.type === "number" || props.schema.type === "integer"}
|
||||
>
|
||||
{(s) => (
|
||||
<Field
|
||||
// @ts-expect-error: We dont know dynamic names while type checking
|
||||
name={props.path.join(".")}
|
||||
validate={validate}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<>
|
||||
<TextInput
|
||||
inputProps={{
|
||||
...fieldProps,
|
||||
inputmode: "numeric",
|
||||
pattern: "[0-9.]*",
|
||||
readonly,
|
||||
}}
|
||||
{...commonProps}
|
||||
value={(field.value as unknown as string) || ""}
|
||||
error={field.error}
|
||||
// required
|
||||
// altLabel="Leave empty to accept the default"
|
||||
// helperText="Configure how dude connects"
|
||||
// error="Something is wrong now"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.schema.enum}>
|
||||
{(_enumSchemas) => (
|
||||
<Field
|
||||
// @ts-expect-error: We dont know dynamic names while type checking
|
||||
name={props.path.join(".")}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<OnlyStringItems itemspec={props.schema}>
|
||||
{(options) => (
|
||||
<SelectInput
|
||||
error={field.error}
|
||||
// altLabel={props.schema.title}
|
||||
label={props.path.join(".")}
|
||||
helperText={props.schema.description}
|
||||
value={field.value || []}
|
||||
options={options.map((o) => ({
|
||||
value: o,
|
||||
label: o,
|
||||
}))}
|
||||
selectProps={fieldProps}
|
||||
required={!!props.schema.minItems}
|
||||
/>
|
||||
)}
|
||||
</OnlyStringItems>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.schema.writeOnly && props.schema}>
|
||||
{(s) => (
|
||||
<Field
|
||||
// @ts-expect-error: We dont know dynamic names while type checking
|
||||
name={props.path.join(".")}
|
||||
validate={validate}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<TextInput
|
||||
inputProps={{ ...fieldProps, readonly }}
|
||||
value={field.value as unknown as string}
|
||||
// type="password"
|
||||
error={field.error}
|
||||
{...commonProps}
|
||||
// required
|
||||
// altLabel="Leave empty to accept the default"
|
||||
// helperText="Configure how dude connects"
|
||||
// error="Something is wrong now"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
{/* TODO: when is it a normal string input? */}
|
||||
<Match when={props.schema}>
|
||||
{(s) => (
|
||||
<Field
|
||||
// @ts-expect-error: We dont know dynamic names while type checking
|
||||
name={props.path.join(".")}
|
||||
validate={validate}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<TextInput
|
||||
inputProps={{ ...fieldProps, readonly }}
|
||||
value={field.value as unknown as string}
|
||||
error={field.error}
|
||||
{...commonProps}
|
||||
// placeholder="foobar"
|
||||
// inlineLabel={
|
||||
// <div class="label">
|
||||
// <span class=""></span>
|
||||
// </div>
|
||||
// }
|
||||
// required
|
||||
// altLabel="Leave empty to accept the default"
|
||||
// helperText="Configure how dude connects"
|
||||
// error="Something is wrong now"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionSchemaProps {
|
||||
itemSpec: JSONSchema7Type;
|
||||
}
|
||||
function OptionSchema(props: OptionSchemaProps) {
|
||||
return (
|
||||
<Switch
|
||||
fallback={<option class="text-error-700">Item spec unhandled</option>}
|
||||
>
|
||||
<Match when={typeof props.itemSpec === "string" && props.itemSpec}>
|
||||
{(o) => <option>{o()}</option>}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueDisplayProps<T extends FieldValues, R extends ResponseData>
|
||||
extends SchemaFieldsProps<T, R> {
|
||||
children: JSX.Element;
|
||||
listFieldName: string;
|
||||
idx: number;
|
||||
of: number;
|
||||
}
|
||||
function ListValueDisplay<T extends FieldValues, R extends ResponseData>(
|
||||
props: ValueDisplayProps<T, R>,
|
||||
) {
|
||||
const removeItem = (e: Event) => {
|
||||
e.preventDefault();
|
||||
remove(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
props.listFieldName,
|
||||
{ at: props.idx },
|
||||
);
|
||||
};
|
||||
const moveItemBy = (dir: number) => (e: Event) => {
|
||||
e.preventDefault();
|
||||
move(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
props.listFieldName,
|
||||
{ from: props.idx, to: props.idx + dir },
|
||||
);
|
||||
};
|
||||
const topMost = () => props.idx === props.of - 1;
|
||||
const bottomMost = () => props.idx === 0;
|
||||
|
||||
return (
|
||||
<div class="w-full border-b border-secondary-200 px-2 pb-4">
|
||||
<div class="flex w-full items-center gap-2">
|
||||
{props.children}
|
||||
<div class="ml-4 min-w-fit">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="s"
|
||||
type="button"
|
||||
onClick={moveItemBy(1)}
|
||||
disabled={topMost()}
|
||||
startIcon={<Icon icon="ArrowBottom" />}
|
||||
class="h-12"
|
||||
></Button>
|
||||
<Button
|
||||
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}
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const findDuplicates = (arr: unknown[]) => {
|
||||
const seen = new Set();
|
||||
const duplicates: number[] = [];
|
||||
|
||||
arr.forEach((obj, idx) => {
|
||||
const serializedObj = JSON.stringify(obj);
|
||||
|
||||
if (seen.has(serializedObj)) {
|
||||
duplicates.push(idx);
|
||||
} else {
|
||||
seen.add(serializedObj);
|
||||
}
|
||||
});
|
||||
|
||||
return duplicates;
|
||||
};
|
||||
|
||||
interface OnlyStringItems {
|
||||
children: (items: string[]) => JSX.Element;
|
||||
itemspec: JSONSchema7;
|
||||
}
|
||||
const OnlyStringItems = (props: OnlyStringItems) => {
|
||||
return (
|
||||
<Show
|
||||
when={
|
||||
Array.isArray(props.itemspec.enum) &&
|
||||
typeof props.itemspec.type === "string" &&
|
||||
props.itemspec
|
||||
}
|
||||
fallback={
|
||||
<Unsupported
|
||||
schema={props.itemspec}
|
||||
error="Unsupported array item type"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children(props.itemspec.enum as string[])}
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
function ArrayFields<T extends FieldValues, R extends ResponseData>(
|
||||
props: SchemaFieldsProps<T, R>,
|
||||
) {
|
||||
if (props.schema.type !== "array") {
|
||||
return (
|
||||
<span class="text-error-700">
|
||||
Error cannot render the following as array.
|
||||
<Unsupported schema={props.schema} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const { Field } = props;
|
||||
|
||||
const listFieldName = props.path.join(".");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||
<Match
|
||||
when={
|
||||
!Array.isArray(props.schema.items) &&
|
||||
typeof props.schema.items === "object" &&
|
||||
props.schema.items
|
||||
}
|
||||
>
|
||||
{(itemsSchema) => (
|
||||
<>
|
||||
<Switch fallback={<Unsupported schema={props.schema} />}>
|
||||
<Match when={itemsSchema().type === "array"}>
|
||||
<Unsupported
|
||||
schema={props.schema}
|
||||
error="Array of Array is not supported yet."
|
||||
/>
|
||||
</Match>
|
||||
<Match
|
||||
when={itemsSchema().type === "string" && itemsSchema().enum}
|
||||
>
|
||||
<Field
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
name={listFieldName}
|
||||
// @ts-expect-error: type is known due to schema
|
||||
type="string[]"
|
||||
validateOn="touched"
|
||||
revalidateOn="touched"
|
||||
validate={() => {
|
||||
let error = "";
|
||||
const values: unknown[] = getValues(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
listFieldName,
|
||||
// @ts-expect-error: assumption based on the behavior of selectInput
|
||||
)?.strings?.selection;
|
||||
console.log("vali", { values });
|
||||
if (props.schema.uniqueItems) {
|
||||
const duplicates = findDuplicates(values);
|
||||
if (duplicates.length) {
|
||||
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
props.schema.maxItems &&
|
||||
values.length > props.schema.maxItems
|
||||
) {
|
||||
error = `You can only select up to ${props.schema.maxItems} items`;
|
||||
}
|
||||
if (
|
||||
props.schema.minItems &&
|
||||
values.length < props.schema.minItems
|
||||
) {
|
||||
error = `Please select at least ${props.schema.minItems} items.`;
|
||||
}
|
||||
return error;
|
||||
}}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<OnlyStringItems itemspec={itemsSchema()}>
|
||||
{(options) => (
|
||||
<SelectInput
|
||||
multiple
|
||||
error={field.error}
|
||||
// altLabel={props.schema.title}
|
||||
label={listFieldName}
|
||||
helperText={props.schema.description}
|
||||
value={field.value || ""}
|
||||
options={options.map((o) => ({
|
||||
value: o,
|
||||
label: o,
|
||||
}))}
|
||||
selectProps={fieldProps}
|
||||
required={!!props.schema.minItems}
|
||||
/>
|
||||
)}
|
||||
</OnlyStringItems>
|
||||
)}
|
||||
</Field>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
itemsSchema().type === "string" ||
|
||||
itemsSchema().type === "object"
|
||||
}
|
||||
>
|
||||
{/* !Important: Register the parent field to gain access to array items*/}
|
||||
<FieldArray
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
name={listFieldName}
|
||||
of={props.formStore}
|
||||
validateOn="touched"
|
||||
revalidateOn="touched"
|
||||
validate={() => {
|
||||
let error = "";
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
const values: unknown[] = getValues(
|
||||
props.formStore,
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
listFieldName,
|
||||
);
|
||||
if (props.schema.uniqueItems) {
|
||||
const duplicates = findDuplicates(values);
|
||||
if (duplicates.length) {
|
||||
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
|
||||
}
|
||||
}
|
||||
if (
|
||||
props.schema.maxItems &&
|
||||
values.length > props.schema.maxItems
|
||||
) {
|
||||
error = `You can only add up to ${props.schema.maxItems} items`;
|
||||
}
|
||||
if (
|
||||
props.schema.minItems &&
|
||||
values.length < props.schema.minItems
|
||||
) {
|
||||
error = `Please add at least ${props.schema.minItems} items.`;
|
||||
}
|
||||
|
||||
return error;
|
||||
}}
|
||||
>
|
||||
{(fieldArray) => (
|
||||
<>
|
||||
{/* Render existing items */}
|
||||
<For
|
||||
each={fieldArray.items}
|
||||
fallback={
|
||||
// Empty list
|
||||
<span class="text-neutral-500">
|
||||
No {itemsSchema().title || "entries"} yet.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(item, idx) => (
|
||||
<ListValueDisplay
|
||||
{...props}
|
||||
listFieldName={listFieldName}
|
||||
idx={idx()}
|
||||
of={fieldArray.items.length}
|
||||
>
|
||||
<Field
|
||||
// @ts-expect-error: field names are not know ahead of time
|
||||
name={`${listFieldName}.${idx()}`}
|
||||
>
|
||||
{(f, fp) => (
|
||||
<>
|
||||
<DynForm
|
||||
formProps={{
|
||||
class: cx("w-full"),
|
||||
}}
|
||||
resetOnSubmit={true}
|
||||
schema={itemsSchema()}
|
||||
initialValues={
|
||||
itemsSchema().type === "object"
|
||||
? f.value
|
||||
: { "": f.value }
|
||||
}
|
||||
readonly={true}
|
||||
></DynForm>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</ListValueDisplay>
|
||||
)}
|
||||
</For>
|
||||
<Show when={fieldArray.error}>
|
||||
<span class="font-bold text-error-700">
|
||||
{fieldArray.error}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
{/* Add new item */}
|
||||
<DynForm
|
||||
formProps={{
|
||||
class: cx("px-2 w-full"),
|
||||
}}
|
||||
schema={{
|
||||
...itemsSchema(),
|
||||
title: itemsSchema().title || "thing",
|
||||
}}
|
||||
initialPath={["root"]}
|
||||
// Reset the input field for list items
|
||||
resetOnSubmit={true}
|
||||
initialValues={{
|
||||
root: generateDefaults(itemsSchema()),
|
||||
}}
|
||||
// Button for adding new items
|
||||
components={{
|
||||
before: (
|
||||
<div class="flex w-full justify-end pb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="submit"
|
||||
endIcon={<Icon size={14} icon={"Plus"} />}
|
||||
class="capitalize"
|
||||
>
|
||||
Add {itemsSchema().title}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
// Add the new item to the FieldArray
|
||||
handleSubmit={(values, event) => {
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
const prev: unknown[] = getValues(
|
||||
props.formStore,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
listFieldName,
|
||||
);
|
||||
if (itemsSchema().type === "object") {
|
||||
const newIdx = prev.length;
|
||||
setValue(
|
||||
props.formStore,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
`${listFieldName}.${newIdx}`,
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
values.root,
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
insert(props.formStore, listFieldName, {
|
||||
// @ts-expect-error: listFieldName is not known ahead of time
|
||||
value: values.root,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FieldArray>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ObjectFieldPropertyLabelProps {
|
||||
schema: JSONSchema7;
|
||||
fallback: JSX.Element;
|
||||
}
|
||||
function ObjectFieldPropertyLabel(props: ObjectFieldPropertyLabelProps) {
|
||||
return (
|
||||
<Switch fallback={props.fallback}>
|
||||
{/* @ts-expect-error: $exportedModuleInfo should exist since we export it */}
|
||||
<Match when={props.schema?.$exportedModuleInfo?.path}>
|
||||
{(path) => path()[path().length - 1]}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectFields<T extends FieldValues, R extends ResponseData>(
|
||||
props: SchemaFieldsProps<T, R>,
|
||||
) {
|
||||
if (props.schema.type !== "object") {
|
||||
return (
|
||||
<span class="text-error-700">
|
||||
Error cannot render the following as Object
|
||||
<Unsupported schema={props.schema} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldName = props.path.join(".");
|
||||
const { Field } = props;
|
||||
|
||||
return (
|
||||
<Switch
|
||||
fallback={
|
||||
<Unsupported
|
||||
schema={props.schema}
|
||||
error="Dont know how to render objectFields"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match
|
||||
when={!props.schema.additionalProperties && props.schema.properties}
|
||||
>
|
||||
{(properties) => (
|
||||
<For each={Object.entries(properties())}>
|
||||
{([propName, propSchema]) => (
|
||||
<div
|
||||
// eslint-disable-next-line tailwindcss/no-custom-classname
|
||||
class={cx(
|
||||
"w-full grid grid-cols-1 gap-4 justify-items-start",
|
||||
`p-${props.path.length * 2}`,
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
label={propName}
|
||||
required={props.schema.required?.some((r) => r === propName)}
|
||||
/>
|
||||
|
||||
{typeof propSchema === "object" && (
|
||||
<SchemaFields
|
||||
{...props}
|
||||
schema={propSchema}
|
||||
path={[...props.path, propName]}
|
||||
/>
|
||||
)}
|
||||
{typeof propSchema === "boolean" && (
|
||||
<span class="text-error-700">
|
||||
Schema: Object of Boolean not supported
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</Match>
|
||||
{/* Objects where people can define their own keys
|
||||
- Trivial Key-value pairs. Where the value is a string a number or a list of strings (trivial select).
|
||||
- Non-trivial Key-value pairs. Where the value is an object or a list
|
||||
*/}
|
||||
<Match
|
||||
when={
|
||||
typeof props.schema.additionalProperties === "object" &&
|
||||
props.schema.additionalProperties
|
||||
}
|
||||
>
|
||||
{(additionalPropertiesSchema) => (
|
||||
<Switch
|
||||
fallback={
|
||||
<Unsupported
|
||||
schema={additionalPropertiesSchema()}
|
||||
error="type of additionalProperties not supported yet"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Non-trivival cases */}
|
||||
<Match
|
||||
when={
|
||||
additionalPropertiesSchema().type === "object" &&
|
||||
additionalPropertiesSchema()
|
||||
}
|
||||
>
|
||||
{(itemSchema) => (
|
||||
<Field
|
||||
// Important!: Register the object field to gain access to the dynamic object properties
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
name={fieldName}
|
||||
>
|
||||
{(objectField, fp) => (
|
||||
<>
|
||||
<For
|
||||
fallback={
|
||||
<>
|
||||
<label class="">
|
||||
No{" "}
|
||||
<ObjectFieldPropertyLabel
|
||||
schema={itemSchema()}
|
||||
fallback={"No entries"}
|
||||
/>{" "}
|
||||
yet.
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
each={Object.entries(objectField.value || {})}
|
||||
>
|
||||
{([key, relatedValue]) => (
|
||||
<Field
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
name={`${fieldName}.${key}`}
|
||||
>
|
||||
{(f, fp) => (
|
||||
<div class="w-full border-l-4 border-gray-300 pl-4">
|
||||
<DynForm
|
||||
formProps={{
|
||||
class: cx("w-full"),
|
||||
}}
|
||||
schema={itemSchema()}
|
||||
initialValues={f.value}
|
||||
components={{
|
||||
before: (
|
||||
<div class="flex w-full">
|
||||
<span class="text-xl font-semibold">
|
||||
{key}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ml-auto"
|
||||
size="s"
|
||||
type="button"
|
||||
onClick={(_e) => {
|
||||
const copy = {
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
...objectField.value,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete copy[key];
|
||||
setValue(
|
||||
props.formStore,
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
`${fieldName}`,
|
||||
copy,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon icon="Trash" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</For>
|
||||
{/* Replace this with a normal input ?*/}
|
||||
<DynForm
|
||||
formProps={{
|
||||
class: cx("w-full"),
|
||||
}}
|
||||
resetOnSubmit={true}
|
||||
initialValues={{ "": "" }}
|
||||
schema={{
|
||||
type: "string",
|
||||
title: `Entry title or key`,
|
||||
}}
|
||||
handleSubmit={(values, event) => {
|
||||
setValue(
|
||||
props.formStore,
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
`${fieldName}`,
|
||||
// @ts-expect-error: fieldName is not known ahead of time
|
||||
{ ...objectField.value, [values[""]]: {} },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
additionalPropertiesSchema().type === "array" &&
|
||||
additionalPropertiesSchema()
|
||||
}
|
||||
>
|
||||
{(itemSchema) => (
|
||||
<Unsupported
|
||||
schema={itemSchema()}
|
||||
error="dynamic arrays are not implemented yet"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
{/* TODO: Trivial cases */}
|
||||
</Switch>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import { API } from "@/api/API";
|
||||
import { Schema as Inventory } from "@/api/Inventory";
|
||||
import { toast } from "solid-toast";
|
||||
import {
|
||||
ErrorToastComponent,
|
||||
CancelToastComponent,
|
||||
} from "@/src/components/toast";
|
||||
|
||||
type OperationNames = keyof API;
|
||||
type Services = NonNullable<Inventory["services"]>;
|
||||
type ServiceNames = keyof Services;
|
||||
|
||||
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
|
||||
export type OperationResponse<T extends OperationNames> = API[T]["return"];
|
||||
|
||||
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
|
||||
Services[T]
|
||||
>[string];
|
||||
|
||||
export type SuccessQuery<T extends OperationNames> = Extract<
|
||||
OperationResponse<T>,
|
||||
{ status: "success" }
|
||||
>;
|
||||
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
|
||||
|
||||
interface SendHeaderType {
|
||||
logging?: { group_path: string[] };
|
||||
}
|
||||
interface BackendSendType<K extends OperationNames> {
|
||||
body: OperationArgs<K>;
|
||||
header?: SendHeaderType;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface ReceiveHeaderType {}
|
||||
interface BackendReturnType<K extends OperationNames> {
|
||||
body: OperationResponse<K>;
|
||||
header: ReceiveHeaderType;
|
||||
}
|
||||
|
||||
const _callApi = <K extends OperationNames>(
|
||||
method: K,
|
||||
args: OperationArgs<K>,
|
||||
backendOpts?: SendHeaderType,
|
||||
): { promise: Promise<BackendReturnType<K>>; op_key: string } => {
|
||||
// if window[method] does not exist, throw an error
|
||||
if (!(method in window)) {
|
||||
console.error(`Method ${method} not found on window object`);
|
||||
// return a rejected promise
|
||||
return {
|
||||
promise: Promise.resolve({
|
||||
body: {
|
||||
status: "error",
|
||||
errors: [
|
||||
{
|
||||
message: `Method ${method} not found on window object`,
|
||||
code: "method_not_found",
|
||||
},
|
||||
],
|
||||
op_key: "noop",
|
||||
},
|
||||
header: {},
|
||||
}),
|
||||
op_key: "noop",
|
||||
};
|
||||
}
|
||||
|
||||
const message: BackendSendType<OperationNames> = {
|
||||
body: args,
|
||||
header: backendOpts,
|
||||
};
|
||||
|
||||
const promise = (
|
||||
window as unknown as Record<
|
||||
OperationNames,
|
||||
(
|
||||
args: BackendSendType<OperationNames>,
|
||||
) => Promise<BackendReturnType<OperationNames>>
|
||||
>
|
||||
)[method](message) as Promise<BackendReturnType<K>>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const op_key = (promise as any)._webviewMessageId as string;
|
||||
|
||||
return { promise, op_key };
|
||||
};
|
||||
|
||||
const handleCancel = async <K extends OperationNames>(
|
||||
ops_key: string,
|
||||
orig_task: Promise<BackendReturnType<K>>,
|
||||
) => {
|
||||
console.log("Canceling operation: ", ops_key);
|
||||
const { promise, op_key } = _callApi("delete_task", { task_id: ops_key });
|
||||
promise.catch((error) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Unexpected error: " + (error?.message || String(error))}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
console.error("Unhandled promise rejection in callApi:", error);
|
||||
});
|
||||
const resp = await promise;
|
||||
|
||||
if (resp.body.status === "error") {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Failed to cancel operation: " + ops_key}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(orig_task as any).cancelled = true;
|
||||
}
|
||||
console.log("Cancel response: ", resp);
|
||||
};
|
||||
|
||||
export const callApi = <K extends OperationNames>(
|
||||
method: K,
|
||||
args: OperationArgs<K>,
|
||||
backendOpts?: SendHeaderType,
|
||||
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
|
||||
console.log("Calling API", method, args, backendOpts);
|
||||
|
||||
const { promise, op_key } = _callApi(method, args, backendOpts);
|
||||
promise.catch((error) => {
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Unexpected error: " + (error?.message || String(error))}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
console.error("Unhandled promise rejection in callApi:", error);
|
||||
});
|
||||
|
||||
const toastId = toast.custom(
|
||||
(
|
||||
t, // t is the Toast object, t.id is the id of THIS toast instance
|
||||
) => (
|
||||
<CancelToastComponent
|
||||
t={t}
|
||||
message={"Executing " + method}
|
||||
onCancel={handleCancel.bind(null, op_key, promise)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: Infinity,
|
||||
},
|
||||
);
|
||||
|
||||
const new_promise = promise.then((response) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const cancelled = (promise as any).cancelled;
|
||||
if (cancelled) {
|
||||
console.log("Not printing toast because operation was cancelled");
|
||||
}
|
||||
|
||||
const body = response.body;
|
||||
if (body.status === "error" && !cancelled) {
|
||||
toast.remove(toastId);
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ErrorToastComponent
|
||||
t={t}
|
||||
message={"Error: " + body.errors[0].message}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: Infinity,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.remove(toastId);
|
||||
}
|
||||
return body;
|
||||
});
|
||||
|
||||
return { promise: new_promise, op_key: op_key };
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import {
|
||||
createForm,
|
||||
FieldValues,
|
||||
getValues,
|
||||
setValue,
|
||||
SubmitHandler,
|
||||
} from "@modular-forms/solid";
|
||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
||||
import { Button } from "./components/Button/Button";
|
||||
import { callApi } from "./api";
|
||||
import { API } from "@/api/API";
|
||||
import { createSignal, Match, Switch, For, Show } from "solid-js";
|
||||
import { Typography } from "./components/Typography";
|
||||
import { useQuery } from "@tanstack/solid-query";
|
||||
import { makePersisted } from "@solid-primitives/storage";
|
||||
import jsonSchema from "@/api/API.json";
|
||||
|
||||
interface APITesterForm extends FieldValues {
|
||||
endpoint: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
const ACTUAL_API_ENDPOINT_NAMES: (keyof API)[] = jsonSchema.required.map(
|
||||
(key) => key as keyof API,
|
||||
);
|
||||
|
||||
export const ApiTester = () => {
|
||||
const [persistedTestData, setPersistedTestData] = makePersisted(
|
||||
createSignal<APITesterForm>(),
|
||||
{
|
||||
name: "_test_data",
|
||||
storage: localStorage,
|
||||
},
|
||||
);
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<APITesterForm>({
|
||||
initialValues: persistedTestData() || { endpoint: "", payload: "" },
|
||||
});
|
||||
|
||||
const [endpointSearchTerm, setEndpointSearchTerm] = createSignal(
|
||||
getValues(formStore).endpoint || "",
|
||||
);
|
||||
const [showSuggestions, setShowSuggestions] = createSignal(false);
|
||||
|
||||
const filteredEndpoints = () => {
|
||||
const term = endpointSearchTerm().toLowerCase();
|
||||
if (!term) return ACTUAL_API_ENDPOINT_NAMES;
|
||||
return ACTUAL_API_ENDPOINT_NAMES.filter((ep) =>
|
||||
ep.toLowerCase().includes(term),
|
||||
);
|
||||
};
|
||||
|
||||
const query = useQuery(() => {
|
||||
const currentEndpoint = getValues(formStore).endpoint;
|
||||
const currentPayload = getValues(formStore).payload;
|
||||
const values = getValues(formStore);
|
||||
|
||||
return {
|
||||
queryKey: ["api-tester", currentEndpoint, currentPayload],
|
||||
queryFn: async () => {
|
||||
return await callApi(
|
||||
values.endpoint as keyof API,
|
||||
JSON.parse(values.payload || "{}"),
|
||||
).promise;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
enabled: false,
|
||||
};
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<APITesterForm> = (values) => {
|
||||
console.log(values);
|
||||
setPersistedTestData(values);
|
||||
setEndpointSearchTerm(values.endpoint);
|
||||
query.refetch();
|
||||
|
||||
const v = getValues(formStore);
|
||||
console.log(v);
|
||||
};
|
||||
return (
|
||||
<div class="p-2">
|
||||
<h1>API Tester</h1>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<div class="flex flex-col">
|
||||
<Field name="endpoint">
|
||||
{(field, fieldProps) => (
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
label={"endpoint"}
|
||||
value={field.value || ""}
|
||||
inputProps={{
|
||||
...fieldProps,
|
||||
onInput: (e: Event) => {
|
||||
if (fieldProps.onInput) {
|
||||
(fieldProps.onInput as (ev: Event) => void)(e);
|
||||
}
|
||||
setEndpointSearchTerm(
|
||||
(e.currentTarget as HTMLInputElement).value,
|
||||
);
|
||||
setShowSuggestions(true);
|
||||
},
|
||||
onBlur: (e: FocusEvent) => {
|
||||
if (fieldProps.onBlur) {
|
||||
(fieldProps.onBlur as (ev: FocusEvent) => void)(e);
|
||||
}
|
||||
setTimeout(() => setShowSuggestions(false), 150);
|
||||
},
|
||||
onFocus: (e: FocusEvent) => {
|
||||
setEndpointSearchTerm(field.value || "");
|
||||
setShowSuggestions(true);
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Show
|
||||
when={showSuggestions() && filteredEndpoints().length > 0}
|
||||
>
|
||||
<ul class="absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded border border-gray-300 bg-white shadow-lg">
|
||||
<For each={filteredEndpoints()}>
|
||||
{(ep) => (
|
||||
<li
|
||||
class="cursor-pointer p-2 hover:bg-gray-100"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setValue(formStore, "endpoint", ep);
|
||||
setEndpointSearchTerm(ep);
|
||||
setShowSuggestions(false);
|
||||
}}
|
||||
>
|
||||
{ep}
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="payload">
|
||||
{(field, fieldProps) => (
|
||||
<div class="my-2 flex flex-col">
|
||||
<label class="mb-1 font-medium" for="payload-textarea">
|
||||
payload
|
||||
</label>
|
||||
<textarea
|
||||
id="payload-textarea"
|
||||
class="min-h-[120px] resize-y rounded border p-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
placeholder={`{\n "key": "value"\n}`}
|
||||
value={field.value || ""}
|
||||
{...fieldProps}
|
||||
onInput={(e) => {
|
||||
fieldProps.onInput?.(e);
|
||||
}}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Button class="m-2" disabled={query.isFetching}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
<div>
|
||||
<Typography hierarchy="title" size="default">
|
||||
Result
|
||||
</Typography>
|
||||
<Switch>
|
||||
<Match when={query.isFetching}>
|
||||
<span>loading ...</span>
|
||||
</Match>
|
||||
<Match when={query.isFetched}>
|
||||
<pre>
|
||||
<code>{JSON.stringify(query.data, null, 2)}</code>
|
||||
</pre>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Button } from "./Button/Button";
|
||||
import Icon from "./icon";
|
||||
|
||||
export const BackButton = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="s"
|
||||
class="mr-2"
|
||||
onClick={() => navigate(-1)}
|
||||
startIcon={<Icon icon="CaretLeft" />}
|
||||
></Button>
|
||||
);
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
@import "Button-Light.css";
|
||||
@import "Button-Dark.css";
|
||||
@import "Button-Ghost.css";
|
||||
|
||||
.button {
|
||||
@apply inline-flex items-center flex-shrink gap-1 justify-center p-4 font-semibold;
|
||||
letter-spacing: 0.0275rem;
|
||||
}
|
||||
|
||||
/* button SIZES */
|
||||
|
||||
.button--default {
|
||||
padding: theme(padding.2) theme(padding.4);
|
||||
height: theme(height.9);
|
||||
border-radius: theme(borderRadius.DEFAULT);
|
||||
|
||||
&:has(> .button__icon--start):has(> .button__label) {
|
||||
padding-left: theme(padding[2.5]);
|
||||
}
|
||||
|
||||
&:has(> .button__icon--end):has(> .button__label) {
|
||||
padding-right: theme(padding[2.5]);
|
||||
}
|
||||
}
|
||||
|
||||
.button--small {
|
||||
padding: theme(padding[1.5]) theme(padding[3]);
|
||||
height: theme(height.8);
|
||||
border-radius: 3px;
|
||||
|
||||
&:has(> .button__icon--start):has(> .button__label) {
|
||||
padding-left: theme(padding.2);
|
||||
}
|
||||
|
||||
&:has(> .button__label):has(> .button__icon--end) {
|
||||
padding-right: theme(padding.2);
|
||||
}
|
||||
}
|
||||
|
||||
/* button group */
|
||||
|
||||
.button-group .button:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.button-group .button:first-child {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.button-group .button:last-child {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/* button DARK and states */
|
||||
|
||||
.button--dark {
|
||||
@apply border border-solid border-secondary-950 bg-primary-800 text-white;
|
||||
|
||||
box-shadow: inset 1px 1px theme(backgroundColor.secondary.700);
|
||||
|
||||
&:disabled {
|
||||
@apply disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300;
|
||||
}
|
||||
|
||||
& .button__icon {
|
||||
color: theme(textColor.secondary.200);
|
||||
}
|
||||
}
|
||||
|
||||
.button--dark-hover:hover {
|
||||
@apply hover:bg-secondary-900;
|
||||
}
|
||||
|
||||
.button--dark-focus:focus {
|
||||
@apply focus:border-secondary-900;
|
||||
}
|
||||
|
||||
.button--dark-active:active {
|
||||
@apply focus:border-secondary-900;
|
||||
}
|
||||
|
||||
.button--dark-active:active {
|
||||
@apply active:border-secondary-900;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.button--ghost-hover:hover {
|
||||
@apply hover:bg-secondary-100 hover:text-secondary-900;
|
||||
}
|
||||
|
||||
.button--ghost-focus:focus {
|
||||
@apply focus:bg-secondary-200 focus:text-secondary-900;
|
||||
}
|
||||
|
||||
.button--ghost-active:active {
|
||||
@apply active:bg-secondary-200 active:text-secondary-900;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/* button LIGHT and states */
|
||||
|
||||
.button--light {
|
||||
@apply border border-solid border-secondary-400 bg-secondary-100 text-secondary-950;
|
||||
|
||||
box-shadow: inset 1px 1px theme(backgroundColor.white);
|
||||
|
||||
&:disabled {
|
||||
@apply disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700;
|
||||
}
|
||||
|
||||
& .button__icon {
|
||||
color: theme(textColor.secondary.900);
|
||||
}
|
||||
}
|
||||
|
||||
.button--light-hover:hover {
|
||||
@apply hover:bg-secondary-200;
|
||||
}
|
||||
|
||||
.button--light-focus:focus {
|
||||
@apply focus:bg-secondary-200;
|
||||
|
||||
& .button__label {
|
||||
color: theme(textColor.secondary.900) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.button--light-active:active {
|
||||
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900;
|
||||
|
||||
box-shadow: inset 2px 2px theme(backgroundColor.secondary.300);
|
||||
|
||||
& .button__label {
|
||||
color: theme(textColor.secondary.900) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { splitProps, type JSX } from "solid-js";
|
||||
import cx from "classnames";
|
||||
import { Typography } from "../Typography";
|
||||
|
||||
import "./Button-Base.css";
|
||||
|
||||
type Variants = "dark" | "light" | "ghost";
|
||||
type Size = "default" | "s";
|
||||
|
||||
const variantColors: (
|
||||
disabled: boolean | undefined,
|
||||
) => Record<Variants, string> = (disabled) => ({
|
||||
dark: cx(
|
||||
"button--dark",
|
||||
!disabled && "button--dark-hover", // Hover state
|
||||
!disabled && "button--dark-focus", // Focus state
|
||||
!disabled && "button--dark-active", // Active state
|
||||
// Disabled
|
||||
"disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300",
|
||||
),
|
||||
light: cx(
|
||||
"button--light",
|
||||
|
||||
!disabled && "button--light-hover", // Hover state
|
||||
!disabled && "button--light-focus", // Focus state
|
||||
!disabled && "button--light-active", // Active state
|
||||
),
|
||||
ghost: cx(
|
||||
!disabled && "button--ghost-hover", // Hover state
|
||||
!disabled && "button--ghost-focus", // Focus state
|
||||
!disabled && "button--ghost-active", // Active state
|
||||
),
|
||||
});
|
||||
|
||||
const sizePaddings: Record<Size, string> = {
|
||||
default: cx("button--default"),
|
||||
s: cx("button button--small"), //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;
|
||||
children?: JSX.Element;
|
||||
startIcon?: JSX.Element;
|
||||
endIcon?: JSX.Element;
|
||||
class?: string;
|
||||
}
|
||||
export const Button = (props: ButtonProps) => {
|
||||
const [local, other] = splitProps(props, [
|
||||
"children",
|
||||
"variant",
|
||||
"size",
|
||||
"startIcon",
|
||||
"endIcon",
|
||||
"class",
|
||||
]);
|
||||
|
||||
const buttonInvertion = (variant: Variants) => {
|
||||
return !(!variant || variant === "ghost" || variant === "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
class={cx(
|
||||
local.class,
|
||||
"button", // default button class
|
||||
variantColors(props.disabled)[local.variant || "dark"], // button appereance
|
||||
sizePaddings[local.size || "default"], // button size
|
||||
)}
|
||||
{...other}
|
||||
>
|
||||
{local.startIcon && (
|
||||
<span class="button__icon--start">{local.startIcon}</span>
|
||||
)}
|
||||
{local.children && (
|
||||
<Typography
|
||||
class="button__label"
|
||||
hierarchy="label"
|
||||
size={local.size || "default"}
|
||||
color="inherit"
|
||||
inverted={buttonInvertion(local.variant || "dark")}
|
||||
weight="medium"
|
||||
tag="span"
|
||||
>
|
||||
{local.children}
|
||||
</Typography>
|
||||
)}
|
||||
{local.endIcon && <span class="button__icon--end">{local.endIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import cx from "classnames";
|
||||
import { createMemo, JSX, Show, splitProps } from "solid-js";
|
||||
|
||||
export interface FileInputProps {
|
||||
ref: (element: HTMLInputElement) => void;
|
||||
name: string;
|
||||
value?: File[] | File;
|
||||
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
|
||||
onClick: JSX.EventHandler<HTMLInputElement, Event>;
|
||||
onChange: JSX.EventHandler<HTMLInputElement, Event>;
|
||||
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
|
||||
accept?: string;
|
||||
required?: boolean;
|
||||
multiple?: boolean;
|
||||
class?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
placeholder?: JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* File input field that users can click or drag files into. Various
|
||||
* decorations can be displayed in or around the field to communicate the entry
|
||||
* requirements.
|
||||
*/
|
||||
export function FileInput(props: FileInputProps) {
|
||||
// Split input element props
|
||||
const [, inputProps] = splitProps(props, [
|
||||
"class",
|
||||
"value",
|
||||
"label",
|
||||
"error",
|
||||
"placeholder",
|
||||
]);
|
||||
|
||||
// Create file list
|
||||
const getFiles = createMemo(() =>
|
||||
props.value
|
||||
? Array.isArray(props.value)
|
||||
? props.value
|
||||
: [props.value]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div class={cx(" w-full", props.class)}>
|
||||
<div class="">
|
||||
<span
|
||||
class=" block"
|
||||
classList={{
|
||||
"after:ml-0.5 after:text-primary after:content-['*']":
|
||||
props.required,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={props.helperText}>
|
||||
<span class=" m-1">{props.helperText}</span>
|
||||
</Show>
|
||||
<div
|
||||
class={cx(
|
||||
"relative flex min-h-[96px] w-full items-center justify-center rounded-2xl border-[3px] border-dashed p-8 text-center focus-within:ring-4 md:min-h-[112px] md:text-lg lg:min-h-[128px] lg:p-10 lg:text-xl",
|
||||
!getFiles().length && "text-slate-500",
|
||||
props.error
|
||||
? "border-red-500/25 focus-within:border-red-500/50 focus-within:ring-red-500/10 hover:border-red-500/40 dark:border-red-400/25 dark:focus-within:border-red-400/50 dark:focus-within:ring-red-400/10 dark:hover:border-red-400/40"
|
||||
: "border-slate-200 focus-within:border-sky-500/50 focus-within:ring-sky-500/10 hover:border-slate-300 dark:border-slate-800 dark:focus-within:border-sky-400/50 dark:focus-within:ring-sky-400/10 dark:hover:border-slate-700",
|
||||
)}
|
||||
>
|
||||
<Show
|
||||
when={getFiles().length}
|
||||
fallback={
|
||||
props.placeholder || (
|
||||
<>Click to select file{props.multiple && "s"}</>
|
||||
)
|
||||
}
|
||||
>
|
||||
Selected file{props.multiple && "s"}:{" "}
|
||||
{getFiles()
|
||||
.map(({ name }) => name)
|
||||
.join(", ")}
|
||||
</Show>
|
||||
<input
|
||||
{...inputProps}
|
||||
// Disable drag n drop
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
class="absolute size-full cursor-pointer opacity-0"
|
||||
type="file"
|
||||
id={props.name}
|
||||
aria-invalid={!!props.error}
|
||||
aria-errormessage={`${props.name}-error`}
|
||||
/>
|
||||
{props.error && (
|
||||
<span class=" font-bold text-error-700">{props.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { type JSX } from "solid-js";
|
||||
|
||||
type sizes = "small" | "medium" | "large";
|
||||
|
||||
const gapSizes: Record<sizes, string> = {
|
||||
small: "gap-2",
|
||||
medium: "gap-4",
|
||||
large: "gap-6",
|
||||
};
|
||||
|
||||
interface List {
|
||||
children: JSX.Element;
|
||||
gapSize: sizes;
|
||||
}
|
||||
|
||||
export const List = (props: List) => {
|
||||
const { children, gapSize } = props;
|
||||
|
||||
return <ul class={`flex flex-col ${gapSizes[gapSize]}`}> {children}</ul>;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { List } from "./List";
|
||||
@@ -1,84 +0,0 @@
|
||||
import { children, createSignal, type JSX } from "solid-js";
|
||||
import { useFloating } from "@/src/floating";
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
hide,
|
||||
offset,
|
||||
Placement,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import cx from "classnames";
|
||||
import { Button } from "./Button/Button";
|
||||
|
||||
interface MenuProps {
|
||||
/**
|
||||
* Used by the html API to associate the popover with the dispatcher button
|
||||
*/
|
||||
popoverid: string;
|
||||
|
||||
label: JSX.Element;
|
||||
|
||||
children?: JSX.Element;
|
||||
buttonProps?: JSX.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
buttonClass?: string;
|
||||
/**
|
||||
* @default "bottom"
|
||||
*/
|
||||
placement?: Placement;
|
||||
}
|
||||
export const Menu = (props: MenuProps) => {
|
||||
const c = children(() => props.children);
|
||||
const [reference, setReference] = createSignal<HTMLElement>();
|
||||
const [floating, setFloating] = createSignal<HTMLElement>();
|
||||
|
||||
// `position` is a reactive object.
|
||||
const position = useFloating(reference, floating, {
|
||||
placement: "bottom",
|
||||
|
||||
// pass options. Ensure the cleanup function is returned.
|
||||
whileElementsMounted: (reference, floating, update) =>
|
||||
autoUpdate(reference, floating, update, {
|
||||
animationFrame: true,
|
||||
}),
|
||||
middleware: [
|
||||
offset(5),
|
||||
shift(),
|
||||
flip(),
|
||||
|
||||
hide({
|
||||
strategy: "referenceHidden",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="s"
|
||||
popovertarget={props.popoverid}
|
||||
popovertargetaction="toggle"
|
||||
ref={setReference}
|
||||
class={cx("", props.buttonClass)}
|
||||
{...props.buttonProps}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
<div
|
||||
popover="auto"
|
||||
id={props.popoverid}
|
||||
ref={setFloating}
|
||||
style={{
|
||||
margin: 0,
|
||||
position: position.strategy,
|
||||
top: `${position.y ?? 0}px`,
|
||||
left: `${position.x ?? 0}px`,
|
||||
}}
|
||||
class="bg-transparent"
|
||||
>
|
||||
{c()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,292 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
|
||||
import {
|
||||
RemoteForm,
|
||||
RemoteData,
|
||||
Machine,
|
||||
RemoteDataSource,
|
||||
} from "./RemoteForm";
|
||||
import { createSignal } from "solid-js";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||
|
||||
// Default values for the form
|
||||
const defaultRemoteData: RemoteData = {
|
||||
address: "",
|
||||
user: "",
|
||||
command_prefix: "sudo",
|
||||
port: undefined,
|
||||
private_key: undefined,
|
||||
password: "",
|
||||
forward_agent: true,
|
||||
host_key_check: "strict",
|
||||
verbose_ssh: false,
|
||||
ssh_options: {},
|
||||
tor_socks: false,
|
||||
};
|
||||
|
||||
// Sample data for populated form
|
||||
const sampleRemoteData: RemoteData = {
|
||||
address: "example.com",
|
||||
user: "admin",
|
||||
command_prefix: "sudo",
|
||||
port: 22,
|
||||
private_key: undefined,
|
||||
password: "",
|
||||
forward_agent: true,
|
||||
host_key_check: "ask",
|
||||
verbose_ssh: false,
|
||||
ssh_options: {
|
||||
StrictHostKeyChecking: "no",
|
||||
UserKnownHostsFile: "/dev/null",
|
||||
},
|
||||
tor_socks: false,
|
||||
};
|
||||
|
||||
// Sample machine data for testing
|
||||
const sampleMachine: Machine = {
|
||||
name: "test-machine",
|
||||
flake: {
|
||||
identifier: "git+https://git.example.com/test-repo",
|
||||
},
|
||||
};
|
||||
|
||||
// Create a query client for stories
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Interactive wrapper component for Storybook
|
||||
const RemoteFormWrapper = (props: {
|
||||
initialData: RemoteData;
|
||||
disabled?: boolean;
|
||||
machine: Machine;
|
||||
field?: "targetHost" | "buildHost";
|
||||
queryFn?: (params: {
|
||||
name: string;
|
||||
flake: { identifier: string };
|
||||
field: string;
|
||||
}) => Promise<RemoteDataSource | null>;
|
||||
onSave?: (data: RemoteData) => void | Promise<void>;
|
||||
showSave?: boolean;
|
||||
}) => {
|
||||
const [formData, setFormData] = createSignal(props.initialData);
|
||||
const [saveMessage, setSaveMessage] = createSignal("");
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div class="max-w-2xl p-6">
|
||||
<h2 class="mb-6 text-2xl font-bold">Remote Configuration</h2>
|
||||
<RemoteForm
|
||||
onInput={(newData) => {
|
||||
setFormData(newData);
|
||||
// Log changes for Storybook actions
|
||||
console.log("Form data changed:", newData);
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
machine={props.machine}
|
||||
field={props.field}
|
||||
queryFn={props.queryFn}
|
||||
onSave={props.onSave}
|
||||
showSave={props.showSave}
|
||||
/>
|
||||
|
||||
{/* Display save message if present */}
|
||||
{saveMessage() && (
|
||||
<div class="mt-4 rounded bg-green-100 p-3 text-green-800">
|
||||
{saveMessage()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display current form state */}
|
||||
<details class="mt-8">
|
||||
<summary class="cursor-pointer font-semibold">
|
||||
Current Form Data (Debug)
|
||||
</summary>
|
||||
<pre class="mt-2 overflow-auto rounded bg-gray-100 p-4 text-sm">
|
||||
{JSON.stringify(formData(), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof RemoteFormWrapper> = {
|
||||
title: "Components/RemoteForm",
|
||||
component: RemoteFormWrapper,
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A form component for configuring remote SSH connection settings. Based on the Remote Python class with fields for address, authentication, and SSH options.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Disable all form inputs",
|
||||
},
|
||||
machine: {
|
||||
control: "object",
|
||||
description: "Machine configuration for API queries",
|
||||
},
|
||||
field: {
|
||||
control: "select",
|
||||
options: ["targetHost", "buildHost"],
|
||||
description: "Field type for API queries",
|
||||
},
|
||||
showSave: {
|
||||
control: "boolean",
|
||||
description: "Show or hide the save button",
|
||||
},
|
||||
onSave: {
|
||||
action: "saved",
|
||||
description: "Custom save handler function",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof RemoteFormWrapper>;
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
initialData: defaultRemoteData,
|
||||
disabled: false,
|
||||
machine: sampleMachine,
|
||||
queryFn: async () => ({
|
||||
source: "inventory" as const,
|
||||
data: {
|
||||
address: "",
|
||||
user: "",
|
||||
command_prefix: "",
|
||||
port: undefined,
|
||||
private_key: undefined,
|
||||
password: "",
|
||||
forward_agent: false,
|
||||
host_key_check: 0,
|
||||
verbose_ssh: false,
|
||||
ssh_options: {},
|
||||
tor_socks: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Empty form with default values. All fields start empty except for boolean defaults.",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
timeout: 30000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Populated: Story = {
|
||||
args: {
|
||||
initialData: sampleRemoteData,
|
||||
disabled: false,
|
||||
machine: sampleMachine,
|
||||
queryFn: async () => ({
|
||||
source: "inventory" as const,
|
||||
data: sampleRemoteData,
|
||||
}),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Form pre-populated with sample data showing all field types in use.",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
timeout: 30000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
initialData: sampleRemoteData,
|
||||
disabled: true,
|
||||
machine: sampleMachine,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "All form fields in disabled state. Useful for read-only views.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Advanced example with custom SSH options
|
||||
const advancedRemoteData: RemoteData = {
|
||||
address: "192.168.1.100",
|
||||
user: "deploy",
|
||||
command_prefix: "doas",
|
||||
port: 2222,
|
||||
private_key: undefined,
|
||||
password: "",
|
||||
forward_agent: false,
|
||||
host_key_check: "none",
|
||||
verbose_ssh: true,
|
||||
ssh_options: {
|
||||
ConnectTimeout: "10",
|
||||
ServerAliveInterval: "60",
|
||||
ServerAliveCountMax: "3",
|
||||
Compression: "yes",
|
||||
TCPKeepAlive: "yes",
|
||||
},
|
||||
tor_socks: true,
|
||||
};
|
||||
|
||||
export const NixManaged: Story = {
|
||||
args: {
|
||||
initialData: advancedRemoteData,
|
||||
disabled: false,
|
||||
machine: sampleMachine,
|
||||
queryFn: async () => ({
|
||||
source: "nix_machine" as const,
|
||||
data: advancedRemoteData,
|
||||
}),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Configuration managed by Nix with advanced settings. Shows the locked state with unlock option.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const HiddenSaveButton: Story = {
|
||||
args: {
|
||||
initialData: sampleRemoteData,
|
||||
disabled: false,
|
||||
machine: sampleMachine,
|
||||
showSave: false,
|
||||
queryFn: async () => ({
|
||||
source: "inventory" as const,
|
||||
data: sampleRemoteData,
|
||||
}),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Form with the save button hidden. Useful when save functionality is handled externally.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,434 +0,0 @@
|
||||
import { createSignal, createEffect, JSX, Show } from "solid-js";
|
||||
import { useQuery } from "@tanstack/solid-query";
|
||||
import { callApi, SuccessQuery } from "@/src/api";
|
||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
||||
import { SelectInput } from "@/src/Form/fields/Select";
|
||||
import { FileInput } from "@/src/components/FileInput";
|
||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
||||
import { InputLabel } from "@/src/components/inputBase";
|
||||
import Icon from "@/src/components/icon";
|
||||
import { Loader } from "@/src/components/v2/Loader/Loader";
|
||||
import { Button } from "@/src/components/v2/Button/Button";
|
||||
import Accordion from "@/src/components/accordion";
|
||||
|
||||
// Export the API types for use in other components
|
||||
export type { RemoteData, Machine, RemoteDataSource };
|
||||
|
||||
type RemoteDataSource = SuccessQuery<"get_host">["data"];
|
||||
type MachineListData = SuccessQuery<"list_machines">["data"][string];
|
||||
type RemoteData = NonNullable<RemoteDataSource>["data"];
|
||||
|
||||
// Machine type with flake for API calls
|
||||
interface Machine {
|
||||
name: string;
|
||||
flake: {
|
||||
identifier: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckboxInputProps {
|
||||
label: JSX.Element;
|
||||
value: boolean;
|
||||
onInput: (value: boolean) => void;
|
||||
help?: string;
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function CheckboxInput(props: CheckboxInputProps) {
|
||||
return (
|
||||
<FieldLayout
|
||||
label={
|
||||
<InputLabel class="col-span-2" help={props.help}>
|
||||
{props.label}
|
||||
</InputLabel>
|
||||
}
|
||||
field={
|
||||
<div class="col-span-10 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.value}
|
||||
onChange={(e) => props.onInput(e.currentTarget.checked)}
|
||||
disabled={props.disabled}
|
||||
class="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
class={props.class}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeyValueInputProps {
|
||||
label: JSX.Element;
|
||||
value: Record<string, string>;
|
||||
onInput: (value: Record<string, string>) => void;
|
||||
help?: string;
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function KeyValueInput(props: KeyValueInputProps) {
|
||||
const [newKey, setNewKey] = createSignal("");
|
||||
const [newValue, setNewValue] = createSignal("");
|
||||
|
||||
const addPair = () => {
|
||||
const key = newKey().trim();
|
||||
const value = newValue().trim();
|
||||
if (key && value) {
|
||||
props.onInput({ ...props.value, [key]: value });
|
||||
setNewKey("");
|
||||
setNewValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const removePair = (key: string) => {
|
||||
const { [key]: _, ...newObj } = props.value;
|
||||
props.onInput(newObj);
|
||||
};
|
||||
|
||||
return (
|
||||
<FieldLayout
|
||||
label={
|
||||
<InputLabel class="col-span-2" help={props.help}>
|
||||
{props.label}
|
||||
</InputLabel>
|
||||
}
|
||||
field={
|
||||
<div class="col-span-10 space-y-2">
|
||||
{/* Existing pairs */}
|
||||
{Object.entries(props.value).map(([key, value]) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium">{key}:</span>
|
||||
<span class="text-sm">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePair(key)}
|
||||
class="text-red-600 hover:text-red-800"
|
||||
disabled={props.disabled}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add new pair */}
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Key"
|
||||
value={newKey()}
|
||||
onInput={(e) => setNewKey(e.currentTarget.value)}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Value"
|
||||
value={newValue()}
|
||||
onInput={(e) => setNewValue(e.currentTarget.value)}
|
||||
disabled={props.disabled}
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPair}
|
||||
disabled={
|
||||
props.disabled || !newKey().trim() || !newValue().trim()
|
||||
}
|
||||
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
class={props.class}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RemoteFormProps {
|
||||
onInput?: (value: RemoteData) => void;
|
||||
machine: Machine;
|
||||
field?: "targetHost" | "buildHost";
|
||||
disabled?: boolean;
|
||||
// Optional query function for testing/mocking
|
||||
queryFn?: (params: {
|
||||
name: string;
|
||||
flake: {
|
||||
identifier: string;
|
||||
hash?: string | null;
|
||||
store_path?: string | null;
|
||||
};
|
||||
field: string;
|
||||
}) => Promise<RemoteDataSource | null>;
|
||||
// Optional save handler for custom save behavior (e.g., in Storybook)
|
||||
onSave?: (data: RemoteData) => void | Promise<void>;
|
||||
// Show/hide save button
|
||||
showSave?: boolean;
|
||||
}
|
||||
|
||||
export function RemoteForm(props: RemoteFormProps) {
|
||||
const [isLocked, setIsLocked] = createSignal(true);
|
||||
const [source, setSource] = createSignal<"inventory" | "nix_machine" | null>(
|
||||
null,
|
||||
);
|
||||
const [privateKeyFile, setPrivateKeyFile] = createSignal<File | undefined>();
|
||||
const [formData, setFormData] = createSignal<RemoteData | null>(null);
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
|
||||
// Query host data when machine is provided
|
||||
const hostQuery = useQuery(() => ({
|
||||
queryKey: [
|
||||
"get_host",
|
||||
props.machine,
|
||||
props.queryFn,
|
||||
props.machine?.name,
|
||||
props.machine?.flake,
|
||||
props.machine?.flake.identifier,
|
||||
props.field || "targetHost",
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!props.machine) return null;
|
||||
|
||||
// Use custom query function if provided (for testing/mocking)
|
||||
if (props.queryFn) {
|
||||
return props.queryFn({
|
||||
name: props.machine.name,
|
||||
flake: props.machine.flake,
|
||||
field: props.field || "targetHost",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await callApi(
|
||||
"get_host",
|
||||
{
|
||||
name: props.machine.name,
|
||||
flake: props.machine.flake,
|
||||
field: props.field || "targetHost",
|
||||
},
|
||||
{
|
||||
logging: {
|
||||
group_path: [
|
||||
"clans",
|
||||
props.machine.flake.identifier,
|
||||
"machines",
|
||||
props.machine.name,
|
||||
],
|
||||
},
|
||||
},
|
||||
).promise;
|
||||
|
||||
if (result.status === "error")
|
||||
throw new Error("Failed to fetch host data");
|
||||
return result.data;
|
||||
},
|
||||
enabled: !!props.machine,
|
||||
}));
|
||||
|
||||
// Update form data and lock state when host data is loaded
|
||||
createEffect(() => {
|
||||
const hostData = hostQuery.data;
|
||||
if (hostData?.data) {
|
||||
setSource(hostData.source);
|
||||
setIsLocked(hostData.source === "nix_machine");
|
||||
setFormData(hostData.data);
|
||||
props.onInput?.(hostData.data);
|
||||
}
|
||||
});
|
||||
|
||||
const isFormDisabled = () =>
|
||||
props.disabled || (source() === "nix_machine" && isLocked());
|
||||
const computedDisabled = isFormDisabled();
|
||||
|
||||
const updateFormData = (updates: Partial<RemoteData>) => {
|
||||
const current = formData();
|
||||
if (current) {
|
||||
const updated = { ...current, ...updates };
|
||||
setFormData(updated);
|
||||
props.onInput?.(updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const data = formData();
|
||||
if (!data || isSaving()) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (props.onSave) {
|
||||
await props.onSave(data);
|
||||
} else {
|
||||
// Default save behavior - could be extended with API call
|
||||
console.log("Saving remote data:", data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error saving remote data:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<Show when={hostQuery.isLoading}>
|
||||
<div class="flex justify-center p-8">
|
||||
<Loader />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!hostQuery.isLoading && formData()}>
|
||||
{/* Lock header for nix_machine source */}
|
||||
<Show when={source() === "nix_machine"}>
|
||||
<div class="flex items-center justify-between rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="Warning" class="size-5 text-amber-600" />
|
||||
<span class="text-sm font-medium text-amber-800">
|
||||
Configuration managed by Nix
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLocked(!isLocked())}
|
||||
class="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-amber-700 hover:bg-amber-100"
|
||||
>
|
||||
<Icon icon={isLocked() ? "Settings" : "Edit"} class="size-3" />
|
||||
{isLocked() ? "Unlock to edit" : "Lock"}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Basic Connection Fields - Always Visible */}
|
||||
<TextInput
|
||||
label="User"
|
||||
value={formData()?.user || ""}
|
||||
inputProps={{
|
||||
onInput: (e) => updateFormData({ user: e.currentTarget.value }),
|
||||
}}
|
||||
placeholder="username"
|
||||
required
|
||||
disabled={computedDisabled}
|
||||
help="Username to connect as on the remote server"
|
||||
/>
|
||||
<TextInput
|
||||
label="Address"
|
||||
value={formData()?.address || ""}
|
||||
inputProps={{
|
||||
onInput: (e) => updateFormData({ address: e.currentTarget.value }),
|
||||
}}
|
||||
placeholder="hostname or IP address"
|
||||
required
|
||||
disabled={computedDisabled}
|
||||
help="The hostname or IP address of the remote server"
|
||||
/>
|
||||
|
||||
{/* Advanced Options - Collapsed by Default */}
|
||||
<Accordion title="Advanced Options" class="mt-6">
|
||||
<div class="space-y-4 pt-2">
|
||||
<TextInput
|
||||
label="Port"
|
||||
value={formData()?.port?.toString() || ""}
|
||||
inputProps={{
|
||||
type: "number",
|
||||
onInput: (e) => {
|
||||
const value = e.currentTarget.value;
|
||||
updateFormData({
|
||||
port: value ? parseInt(value, 10) : undefined,
|
||||
});
|
||||
},
|
||||
}}
|
||||
placeholder="22"
|
||||
disabled={computedDisabled}
|
||||
help="SSH port (defaults to 22 if not specified)"
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
label="Host Key Check"
|
||||
value={formData()?.host_key_check || "ask"}
|
||||
options={[
|
||||
{ value: "ask", label: "Ask" },
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "strict", label: "Strict" },
|
||||
{ value: "tofu", label: "Trust on First Use" },
|
||||
]}
|
||||
disabled={computedDisabled}
|
||||
helperText="How to handle host key verification"
|
||||
/>
|
||||
<Show when={typeof window !== "undefined"}>
|
||||
<FieldLayout
|
||||
label={
|
||||
<InputLabel
|
||||
class="col-span-2"
|
||||
help="SSH private key file for authentication"
|
||||
>
|
||||
Private Key
|
||||
</InputLabel>
|
||||
}
|
||||
field={
|
||||
<div class="col-span-10">
|
||||
<FileInput
|
||||
name="private_key"
|
||||
accept=".pem,.key,*"
|
||||
value={privateKeyFile()}
|
||||
onInput={(e) => {
|
||||
const file = e.currentTarget.files?.[0];
|
||||
setPrivateKeyFile(file);
|
||||
updateFormData({
|
||||
private_key: file?.name || null,
|
||||
});
|
||||
}}
|
||||
onChange={() => void 0}
|
||||
onBlur={() => void 0}
|
||||
onClick={() => void 0}
|
||||
ref={() => void 0}
|
||||
placeholder={<>Click to select private key file</>}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<CheckboxInput
|
||||
label="Forward Agent"
|
||||
value={formData()?.forward_agent || false}
|
||||
onInput={(value) => updateFormData({ forward_agent: value })}
|
||||
disabled={computedDisabled}
|
||||
help="Enable SSH agent forwarding"
|
||||
/>
|
||||
|
||||
<KeyValueInput
|
||||
label="SSH Options"
|
||||
value={formData()?.ssh_options || {}}
|
||||
onInput={(value) => updateFormData({ ssh_options: value })}
|
||||
disabled={computedDisabled}
|
||||
help="Additional SSH options as key-value pairs"
|
||||
/>
|
||||
|
||||
<CheckboxInput
|
||||
label="Tor SOCKS"
|
||||
value={formData()?.tor_socks || false}
|
||||
onInput={(value) => updateFormData({ tor_socks: value })}
|
||||
disabled={computedDisabled}
|
||||
help="Use Tor SOCKS proxy for SSH connection"
|
||||
/>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
{/* Save Button */}
|
||||
<Show when={props.showSave !== false}>
|
||||
<div class="flex justify-end pt-4">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={computedDisabled || isSaving()}
|
||||
class="min-w-24"
|
||||
>
|
||||
{isSaving() ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { List } from "@/src/components/Helpers";
|
||||
import { SidebarListItem } from "../SidebarListItem";
|
||||
|
||||
export const SidebarFlyout = () => {
|
||||
return (
|
||||
<div class="sidebar__flyout">
|
||||
<div class="sidebar__flyout__inner">
|
||||
<List gapSize="small">
|
||||
<SidebarListItem href="/clans" title="Settings" />
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { createSignal, Show } from "solid-js";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import { SidebarFlyout } from "./SidebarFlyout";
|
||||
import "./css/sidebar.css";
|
||||
import Icon from "../icon";
|
||||
|
||||
interface SidebarProps {
|
||||
clanName: string;
|
||||
showFlyout?: () => boolean;
|
||||
}
|
||||
|
||||
const ClanProfile = (props: SidebarProps) => {
|
||||
return (
|
||||
<div
|
||||
class={`sidebar__profile ${props.showFlyout?.() ? "sidebar__profile--flyout" : ""}`}
|
||||
>
|
||||
<Typography
|
||||
class="sidebar__profile__character"
|
||||
tag="span"
|
||||
hierarchy="title"
|
||||
size="m"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
inverted={true}
|
||||
>
|
||||
{props.clanName.slice(0, 1).toUpperCase()}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ClanTitle = (props: SidebarProps) => {
|
||||
return (
|
||||
<Typography
|
||||
tag="h3"
|
||||
hierarchy="body"
|
||||
size="default"
|
||||
weight="medium"
|
||||
color="primary"
|
||||
inverted={true}
|
||||
>
|
||||
{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">
|
||||
{/* <ClanProfile clanName={props.clanName} showFlyout={showFlyout} /> */}
|
||||
<div class="w-full pl-1 text-white">
|
||||
<ClanTitle clanName={props.clanName} />
|
||||
</div>
|
||||
<Show
|
||||
when={showFlyout}
|
||||
fallback={<Icon size={12} class="text-white" icon="CaretDown" />}
|
||||
>
|
||||
<Icon size={12} class="text-white" icon="CaretDown" />
|
||||
</Show>
|
||||
</div>
|
||||
{showFlyout() && <SidebarFlyout />}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import "./css/sidebar.css";
|
||||
|
||||
interface SidebarListItem {
|
||||
title: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export const SidebarListItem = (props: SidebarListItem) => {
|
||||
const { title, href } = props;
|
||||
|
||||
return (
|
||||
<li class="">
|
||||
<A class="sidebar__list__link" href={href}>
|
||||
<Typography
|
||||
class="sidebar__list__content"
|
||||
tag="span"
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="normal"
|
||||
color="primary"
|
||||
inverted={true}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
</A>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
.sidebar__flyout {
|
||||
top: 0;
|
||||
position: absolute;
|
||||
z-index: theme(zIndex.30);
|
||||
|
||||
padding: theme(padding[1]);
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.sidebar__flyout__inner {
|
||||
position: relative;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
|
||||
padding: theme(padding.12) theme(padding.3) theme(padding.3);
|
||||
background-color: var(--clr-bg-inv-4);
|
||||
/* / 0.95); */
|
||||
border: 1px solid var(--clr-border-inv-4);
|
||||
border-radius: theme(borderRadius.lg);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
.sidebar__header {
|
||||
position: relative;
|
||||
padding: 1px 1px 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--clr-bg-inv-3);
|
||||
|
||||
border-bottom: 1px solid var(--clr-border-inv-3);
|
||||
border-top-left-radius: theme(borderRadius.xl);
|
||||
border-top-right-radius: theme(borderRadius.xl);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__header__inner {
|
||||
position: relative;
|
||||
z-index: theme(zIndex.40);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 theme(gap.3);
|
||||
|
||||
padding: theme(padding.3) theme(padding.3);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
.sidebar__list__link {
|
||||
position: relative;
|
||||
cursor: theme(cursor.pointer);
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: theme(zIndex.10);
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: theme(borderRadius.md);
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.24s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover:after {
|
||||
background: var(--clr-bg-inv-acc-2);
|
||||
transform: scale(theme(scale.100));
|
||||
transition: transform 0.32s ease-in-out;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.99);
|
||||
transition: transform 0.12s ease-in-out;
|
||||
}
|
||||
|
||||
&:active:after {
|
||||
background: var(--clr-bg-inv-acc-3);
|
||||
transform: scale(theme(scale.100));
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__list__link {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
padding: theme(padding.2) theme(padding.3);
|
||||
}
|
||||
|
||||
.sidebar__list__link.active {
|
||||
&:after {
|
||||
background: var(--clr-bg-inv-acc-3);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__list__content {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
.sidebar__profile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: theme(width.8);
|
||||
height: theme(height.8);
|
||||
|
||||
background: var(--clr-bg-inv-4);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sidebar__profile--flyout {
|
||||
background: var(--clr-bg-def-2);
|
||||
}
|
||||
|
||||
.sidebar__profile--flyout > .sidebar__profile__character {
|
||||
color: var(--clr-fg-def-1) !important;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/* Sidebar Elements */
|
||||
|
||||
@import "./sidebar-header";
|
||||
@import "./sidebar-flyout";
|
||||
@import "./sidebar-list-item";
|
||||
@import "./sidebar-profile";
|
||||
|
||||
/* Sidebar Structure */
|
||||
|
||||
.sidebar {
|
||||
@apply bg-inv-2 h-full border border-solid border-inv-2 min-w-72 rounded-xl;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: theme(padding.2);
|
||||
padding: theme(padding.4) theme(padding.2);
|
||||
}
|
||||
|
||||
.sidebar__section {
|
||||
@apply bg-primary-800/90;
|
||||
|
||||
padding: theme(padding.2);
|
||||
border-radius: theme(borderRadius.md);
|
||||
|
||||
::marker {
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { For, type JSX, Show } from "solid-js";
|
||||
import { RouteSectionProps } from "@solidjs/router";
|
||||
import { AppRoute, routes } from "@/src";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
import { SidebarListItem } from "./SidebarListItem";
|
||||
import { Typography } from "../Typography";
|
||||
import "./css/sidebar.css";
|
||||
import Icon, { IconVariant } from "../icon";
|
||||
import { clanMetaQuery } from "@/src/queries/clan-meta";
|
||||
|
||||
const SidebarSection = (props: {
|
||||
title: string;
|
||||
icon: IconVariant;
|
||||
children: JSX.Element;
|
||||
}) => {
|
||||
const { title, children } = props;
|
||||
|
||||
return (
|
||||
<details class="sidebar__section accordeon" open>
|
||||
<summary style="display: contents;">
|
||||
<div class="accordeon__header">
|
||||
<Typography
|
||||
class="inline-flex w-full gap-2 uppercase !tracking-wider"
|
||||
tag="p"
|
||||
hierarchy="body"
|
||||
size="xxs"
|
||||
weight="normal"
|
||||
color="tertiary"
|
||||
inverted={true}
|
||||
>
|
||||
<Icon class="opacity-90" icon={props.icon} size={13} />
|
||||
{title}
|
||||
<Icon icon="CaretDown" class="ml-auto" size={10} />
|
||||
</Typography>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="accordeon__body">{children}</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sidebar = (props: RouteSectionProps) => {
|
||||
const query = clanMetaQuery();
|
||||
|
||||
return (
|
||||
<div class="sidebar">
|
||||
<Show
|
||||
when={query.data}
|
||||
fallback={<SidebarHeader clanName={"Untitled"} />}
|
||||
>
|
||||
{(meta) => <SidebarHeader clanName={meta().name} />}
|
||||
</Show>
|
||||
<div class="sidebar__body max-h-[calc(100vh-4rem)] overflow-scroll">
|
||||
<For each={routes.filter((r) => !r.hidden)}>
|
||||
{(route: AppRoute) => (
|
||||
<Show
|
||||
when={route.children}
|
||||
fallback={
|
||||
<SidebarListItem href={route.path} title={route.label} />
|
||||
}
|
||||
>
|
||||
{(children) => (
|
||||
<SidebarSection
|
||||
title={route.label}
|
||||
icon={route.icon || "Paperclip"}
|
||||
>
|
||||
<ul class="flex flex-col gap-y-0.5">
|
||||
<For each={children().filter((r) => !r.hidden)}>
|
||||
{(child) => (
|
||||
<SidebarListItem
|
||||
href={`${route.path}${child.path}`}
|
||||
title={child.label}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ul>
|
||||
</SidebarSection>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { JSX, Show } from "solid-js";
|
||||
|
||||
interface SimpleModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const SimpleModal = (props: SimpleModalProps) => {
|
||||
return (
|
||||
<Show when={props.open}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div class="fixed inset-0 bg-black/50" onClick={props.onClose} />
|
||||
|
||||
{/* Modal Content */}
|
||||
<div class="relative mx-4 w-full max-w-md rounded-lg bg-white shadow-lg">
|
||||
{/* Header */}
|
||||
<Show when={props.title}>
|
||||
<div class="flex items-center justify-between border-b p-4">
|
||||
<h3 class="text-lg font-semibold">{props.title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Body */}
|
||||
<div>{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
div.tag-list {
|
||||
@apply flex flex-wrap gap-2;
|
||||
|
||||
span.tag {
|
||||
@apply w-fit rounded-full px-3 py-2 bg-inv-4 fg-inv-1;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Component, For } from "solid-js";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import "./TagList.css";
|
||||
|
||||
interface TagListProps {
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export const TagList: Component<TagListProps> = (props) => {
|
||||
return (
|
||||
<div class="tag-list">
|
||||
<For each={props.values}>
|
||||
{(tag) => (
|
||||
<Typography hierarchy="label" size="s" inverted={true} class="tag">
|
||||
{tag}
|
||||
</Typography>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
.fnt-clr-primary {
|
||||
color: var(--clr-fg-def-1);
|
||||
}
|
||||
|
||||
.fnt-clr-secondary {
|
||||
color: var(--clr-fg-def-2);
|
||||
}
|
||||
|
||||
.fnt-clr-tertiary {
|
||||
color: var(--clr-fg-def-3);
|
||||
}
|
||||
|
||||
.fnt-clr-primary.fnt-clr--inverted {
|
||||
color: var(--clr-fg-inv-1);
|
||||
}
|
||||
|
||||
.fnt-clr-secondary.fnt-clr--inverted {
|
||||
color: var(--clr-fg-inv-2);
|
||||
}
|
||||
|
||||
.fnt-clr-tertiary.fnt-clr--inverted {
|
||||
color: var(--clr-fg-inv-3);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
@import "./typography-label.css";
|
||||
@import "./typography-body.css";
|
||||
@import "./typography-title.css";
|
||||
@import "./typography-headline.css";
|
||||
@@ -1,23 +0,0 @@
|
||||
.fnt-body-default {
|
||||
font-size: 1rem;
|
||||
line-height: 132%;
|
||||
letter-spacing: 3%;
|
||||
}
|
||||
|
||||
.fnt-body-s {
|
||||
font-size: 0.925rem;
|
||||
line-height: 132%;
|
||||
letter-spacing: 3%;
|
||||
}
|
||||
|
||||
.fnt-body-xs {
|
||||
font-size: 0.875rem;
|
||||
line-height: 132%;
|
||||
letter-spacing: 3%;
|
||||
}
|
||||
|
||||
.fnt-body-xxs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 132%;
|
||||
letter-spacing: 0.00688rem;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
.fnt-headline-default {
|
||||
font-size: 1.5rem;
|
||||
line-height: 116%;
|
||||
letter-spacing: 1%;
|
||||
}
|
||||
|
||||
.fnt-headline-m {
|
||||
font-size: 1.75rem;
|
||||
line-height: 116%;
|
||||
letter-spacing: 1%;
|
||||
}
|
||||
|
||||
.fnt-headline-l {
|
||||
font-size: 2rem;
|
||||
line-height: 116%;
|
||||
letter-spacing: 1%;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
.fnt-label-default {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.fnt-label-s {
|
||||
font-size: 0.75rem;
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.fnt-label-xs {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 100%;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
.fnt-title-default {
|
||||
font-size: 1.125rem;
|
||||
line-height: 124%;
|
||||
letter-spacing: 3%;
|
||||
}
|
||||
|
||||
.fnt-title-m {
|
||||
font-size: 1.25rem;
|
||||
line-height: 124%;
|
||||
letter-spacing: 3%;
|
||||
}
|
||||
|
||||
.fnt-title-l {
|
||||
font-size: 1.375rem;
|
||||
line-height: 124%;
|
||||
letter-spacing: 3%;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
@import "./typography-hierarchy/";
|
||||
@import "./typography-color.css";
|
||||
|
||||
.fnt-weight-normal {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.fnt-weight-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fnt-weight-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fnt-weight-normal.fnt-clr--inverted {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.fnt-weight-medium.fnt-clr--inverted {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.fnt-weight-bold.fnt-clr--inverted {
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { type JSX } from "solid-js";
|
||||
import { Dynamic } from "solid-js/web";
|
||||
import cx from "classnames";
|
||||
import "./css/typography.css";
|
||||
|
||||
type Hierarchy = "body" | "title" | "headline" | "label";
|
||||
type Color = "primary" | "secondary" | "tertiary";
|
||||
type Weight = "normal" | "medium" | "bold";
|
||||
type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
|
||||
|
||||
const colorMap: Record<Color, string> = {
|
||||
primary: cx("fnt-clr-primary"),
|
||||
secondary: cx("fnt-clr-secondary"),
|
||||
tertiary: cx("fnt-clr-tertiary"),
|
||||
};
|
||||
|
||||
// type Size = "default" | "xs" | "s" | "m" | "l";
|
||||
interface SizeForHierarchy {
|
||||
label: {
|
||||
default: string;
|
||||
xs: string;
|
||||
s: string;
|
||||
};
|
||||
body: {
|
||||
default: string;
|
||||
xs: string;
|
||||
xxs: string;
|
||||
s: string;
|
||||
};
|
||||
headline: {
|
||||
default: string;
|
||||
m: string;
|
||||
l: string;
|
||||
};
|
||||
title: {
|
||||
default: string;
|
||||
m: string;
|
||||
l: string;
|
||||
};
|
||||
}
|
||||
|
||||
type AllowedSizes<H extends Hierarchy> = keyof SizeForHierarchy[H];
|
||||
|
||||
const sizeHierarchyMap: SizeForHierarchy = {
|
||||
body: {
|
||||
default: cx("fnt-body-default"),
|
||||
s: cx("fnt-body-s"),
|
||||
xs: cx("fnt-body-xs"),
|
||||
xxs: cx("fnt-body-xxs"),
|
||||
},
|
||||
headline: {
|
||||
default: cx("fnt-headline-default"),
|
||||
// xs: cx("fnt-headline-xs"),
|
||||
// s: cx("fnt-headline-s"),
|
||||
m: cx("fnt-headline-m"),
|
||||
l: cx("fnt-headline-l"),
|
||||
},
|
||||
title: {
|
||||
default: cx("fnt-title-default"),
|
||||
// xs: cx("fnt-title-xs"),
|
||||
// s: cx("fnt-title-s"),
|
||||
m: cx("fnt-title-m"),
|
||||
l: cx("fnt-title-l"),
|
||||
},
|
||||
label: {
|
||||
default: cx("fnt-label-default"),
|
||||
s: cx("fnt-label-s"),
|
||||
xs: cx("fnt-label-xs"),
|
||||
},
|
||||
};
|
||||
|
||||
const weightMap: Record<Weight, string> = {
|
||||
normal: cx("fnt-weight-normal"),
|
||||
medium: cx("fnt-weight-medium"),
|
||||
bold: cx("fnt-weight-bold"),
|
||||
};
|
||||
|
||||
interface _TypographyProps<H extends Hierarchy> {
|
||||
hierarchy: H;
|
||||
size: AllowedSizes<H>;
|
||||
children: JSX.Element;
|
||||
weight?: Weight;
|
||||
color?: Color | "inherit";
|
||||
inverted?: boolean;
|
||||
tag?: Tag;
|
||||
class?: string;
|
||||
classList?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
|
||||
return (
|
||||
<Dynamic
|
||||
component={props.tag || "span"}
|
||||
class={cx(
|
||||
props.color === "inherit" && "text-inherit",
|
||||
props.color !== "inherit" && colorMap[props.color || "primary"],
|
||||
props.inverted && "fnt-clr--inverted",
|
||||
sizeHierarchyMap[props.hierarchy][props.size] as string,
|
||||
weightMap[props.weight || "normal"],
|
||||
props.class,
|
||||
)}
|
||||
classList={props.classList}
|
||||
>
|
||||
{props.children}
|
||||
</Dynamic>
|
||||
);
|
||||
};
|
||||
|
||||
export type TypographyProps = _TypographyProps<Hierarchy>;
|
||||
@@ -1,10 +0,0 @@
|
||||
.accordion {
|
||||
@apply flex flex-col gap-y-5;
|
||||
}
|
||||
|
||||
.accordion__title {
|
||||
@apply flex h-5 cursor-pointer items-center justify-end gap-x-0.5 px-1 font-medium;
|
||||
}
|
||||
|
||||
.accordion__body {
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { createSignal, JSX, Show } from "solid-js";
|
||||
import Icon from "../icon";
|
||||
import { Button } from "../Button/Button";
|
||||
import cx from "classnames";
|
||||
import "./accordion.css";
|
||||
|
||||
interface AccordionProps {
|
||||
title: string;
|
||||
children: JSX.Element;
|
||||
class?: string;
|
||||
initiallyOpen?: boolean;
|
||||
}
|
||||
|
||||
export default function Accordion(props: AccordionProps) {
|
||||
const [isOpen, setIsOpen] = createSignal(props.initiallyOpen ?? false);
|
||||
return (
|
||||
<div class={cx(`accordion`, props.class)} tabindex="0">
|
||||
<div onClick={() => setIsOpen(!isOpen())} class="accordion__title">
|
||||
<Show
|
||||
when={isOpen()}
|
||||
fallback={
|
||||
<Button
|
||||
endIcon={<Icon size={12} icon={"CaretDown"} />}
|
||||
variant="ghost"
|
||||
size="s"
|
||||
>
|
||||
{props.title}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
endIcon={<Icon size={12} icon={"CaretUp"} />}
|
||||
variant="ghost"
|
||||
size="s"
|
||||
>
|
||||
{props.title}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={isOpen()}>
|
||||
<div class="accordion__body">{props.children}</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { JSX } from "solid-js";
|
||||
import cx from "classnames";
|
||||
import Icon, { IconVariant } from "../icon";
|
||||
import { Typography } from "../Typography";
|
||||
|
||||
interface BadgeProps {
|
||||
color: keyof typeof colorMap;
|
||||
children: JSX.Element;
|
||||
icon?: IconVariant;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
primary: cx("bg-primary-800 text-primary-100"),
|
||||
secondary: cx("bg-secondary-800 text-secondary-100"),
|
||||
blue: "bg-blue-100 text-blue-800",
|
||||
gray: "bg-gray-100 text-gray-800",
|
||||
green: "bg-green-100 text-green-800",
|
||||
orange: "bg-orange-100 text-orange-800",
|
||||
red: "bg-red-100 text-red-800",
|
||||
yellow: "bg-yellow-100 text-yellow-800",
|
||||
};
|
||||
|
||||
export const Badge = (props: BadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
class={cx(
|
||||
"flex px-4 py-2 rounded-sm justify-center items-center gap-1",
|
||||
colorMap[props.color],
|
||||
props.class,
|
||||
)}
|
||||
>
|
||||
{props.icon && <Icon icon={props.icon} class="size-4" />}
|
||||
<Typography hierarchy="label" size="default" color="inherit">
|
||||
{props.children}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,193 +0,0 @@
|
||||
import { FileInput, type FileInputProps } from "@/src/components/FileInput"; // Assuming FileInput can take a ref and has onClick
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import Fieldset from "@/src/Form/fieldset";
|
||||
// For displaying file icons
|
||||
import { callApi } from "@/src/api";
|
||||
import { Show, For, type Component, type JSX } from "solid-js";
|
||||
|
||||
// Types for the file dialog options passed to callApi
|
||||
interface FileRequestFilter {
|
||||
patterns: string[];
|
||||
mime_types?: string[];
|
||||
}
|
||||
|
||||
export interface FileDialogOptions {
|
||||
title: string;
|
||||
filters?: FileRequestFilter;
|
||||
initial_folder?: string;
|
||||
}
|
||||
|
||||
// Props for the CustomFileField component
|
||||
interface FileSelectorOpts<TFieldName extends string> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Field: any; // The Field component from createForm
|
||||
name: TFieldName; // Name of the form field (e.g., "sshKeys", "profilePicture")
|
||||
label: string; // Legend for Fieldset or main label for the input
|
||||
description?: string | JSX.Element; // Optional description text
|
||||
multiple?: boolean; // True if multiple files can be selected, false for single file
|
||||
fileDialogOptions: FileDialogOptions; // Configuration for the custom file dialog
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
of: any;
|
||||
// Optional props for styling
|
||||
inputClass?: string;
|
||||
fileListClass?: string;
|
||||
// You can add more specific props like `validate` if you want to pass them to Field
|
||||
}
|
||||
|
||||
export const FileSelectorField: Component<FileSelectorOpts<string>> = (
|
||||
props,
|
||||
) => {
|
||||
const {
|
||||
Field,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
multiple = false,
|
||||
fileDialogOptions,
|
||||
inputClass,
|
||||
fileListClass,
|
||||
} = props;
|
||||
|
||||
// Ref to the underlying HTMLInputElement (assuming FileInput forwards refs or is simple)
|
||||
let actualInputElement: HTMLInputElement | undefined;
|
||||
|
||||
const openAndSetFiles = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (!actualInputElement) {
|
||||
console.error(
|
||||
"CustomFileField: Input element ref is not set. Cannot proceed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataTransfer = new DataTransfer();
|
||||
const mode = multiple ? "open_multiple_files" : "open_file";
|
||||
|
||||
try {
|
||||
const response = await callApi("open_file", {
|
||||
file_request: {
|
||||
title: fileDialogOptions.title,
|
||||
mode: mode,
|
||||
filters: fileDialogOptions.filters,
|
||||
initial_folder: fileDialogOptions.initial_folder,
|
||||
},
|
||||
}).promise;
|
||||
|
||||
if (
|
||||
response.status === "success" &&
|
||||
response.data &&
|
||||
Array.isArray(response.data)
|
||||
) {
|
||||
(response.data as string[]).forEach((filename) => {
|
||||
// Create File objects. Content is empty as we only have paths.
|
||||
// Type might be generic or derived if possible.
|
||||
dataTransfer.items.add(
|
||||
new File([], filename, { type: "application/octet-stream" }),
|
||||
);
|
||||
});
|
||||
} else if (response.status === "error") {
|
||||
// Consider using a toast or other user notification for API errors
|
||||
console.error("Error from open_file API:", response.errors);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to call open_file API:", error);
|
||||
// Consider using a toast here
|
||||
}
|
||||
|
||||
// Set the FileList on the actual input element
|
||||
Object.defineProperty(actualInputElement, "files", {
|
||||
value: dataTransfer.files,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Dispatch an 'input' event so modular-forms updates its state
|
||||
const inputEvent = new Event("input", { bubbles: true, cancelable: true });
|
||||
actualInputElement.dispatchEvent(inputEvent);
|
||||
|
||||
// Optionally, dispatch 'change' if your forms setup relies more on it
|
||||
// const changeEvent = new Event("change", { bubbles: true, cancelable: true });
|
||||
// actualInputElement.dispatchEvent(changeEvent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Fieldset legend={label}>
|
||||
{description &&
|
||||
(typeof description === "string" ? (
|
||||
<Typography hierarchy="body" size="s" weight="medium" class="mb-2">
|
||||
{description}
|
||||
</Typography>
|
||||
) : (
|
||||
description
|
||||
))}
|
||||
|
||||
<Field name={name} type={multiple ? "File[]" : "File"}>
|
||||
{(
|
||||
field: { value: File | File[]; error?: string },
|
||||
fieldProps: Record<string, unknown>,
|
||||
) => (
|
||||
<>
|
||||
{/*
|
||||
This FileInput component should be clickable.
|
||||
Its 'ref' needs to point to the actual <input type="file"> element.
|
||||
If FileInput is complex, it might need an 'inputRef' prop or similar.
|
||||
*/}
|
||||
<FileInput
|
||||
{...(fieldProps as unknown as FileInputProps)} // Spread modular-forms props
|
||||
ref={(el: HTMLInputElement) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fieldProps as any).ref(el); // Pass ref to modular-forms
|
||||
actualInputElement = el; // Capture for local use
|
||||
}}
|
||||
class={inputClass}
|
||||
multiple={multiple}
|
||||
// The onClick here triggers our custom dialog logic
|
||||
onClick={openAndSetFiles}
|
||||
// The 'value' prop for a file input is not for displaying selected files directly.
|
||||
// We'll display them below. FileInput might show placeholder text.
|
||||
// value={undefined} // Explicitly not setting value from field.value here
|
||||
error={field.error} // Display error from modular-forms
|
||||
/>
|
||||
{field.error && (
|
||||
<Typography hierarchy="body" size="xs" class="mt-1">
|
||||
{field.error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Display the list of selected files */}
|
||||
<Show
|
||||
when={
|
||||
field.value &&
|
||||
(multiple
|
||||
? (field.value as File[]).length > 0
|
||||
: field.value instanceof File)
|
||||
}
|
||||
>
|
||||
<div class={`mt-2 space-y-1 ${fileListClass || ""}`}>
|
||||
<For
|
||||
each={
|
||||
multiple
|
||||
? (field.value as File[])
|
||||
: field.value instanceof File
|
||||
? [field.value as File]
|
||||
: []
|
||||
}
|
||||
>
|
||||
{(file) => (
|
||||
<div class="flex items-center justify-between rounded border p-2 text-sm border-def-1">
|
||||
<span class="truncate" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
{/* A remove button per file is complex with FileList & modular-forms.
|
||||
For now, clearing all files is simpler (e.g., via FileInput's own clear).
|
||||
Or, the user re-selects files to change the selection. */}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</Fieldset>
|
||||
);
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import cx from "classnames";
|
||||
import { JSX } from "solid-js";
|
||||
import Icon, { IconVariant } from "../icon";
|
||||
|
||||
interface GroupProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const Group = (props: GroupProps) => (
|
||||
<div class="flex flex-col gap-8 rounded-md border px-4 py-5 bg-def-2 border-def-2">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
type SectionVariant = "attention" | "danger";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
variant: SectionVariant;
|
||||
headline: JSX.Element;
|
||||
}
|
||||
const variantColorsMap: Record<SectionVariant, string> = {
|
||||
attention: cx("bg-[#9BD8F2] fg-def-1"),
|
||||
danger: cx("bg-semantic-2 fg-semantic-2"),
|
||||
};
|
||||
|
||||
const variantIconColorsMap: Record<SectionVariant, string> = {
|
||||
attention: cx("fg-def-1"),
|
||||
danger: cx("fg-semantic-3"),
|
||||
};
|
||||
|
||||
const variantIconMap: Record<SectionVariant, IconVariant> = {
|
||||
attention: "Attention",
|
||||
danger: "Warning",
|
||||
};
|
||||
|
||||
// SectionHeader component
|
||||
export const SectionHeader = (props: SectionHeaderProps) => (
|
||||
<div
|
||||
class={cx(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2",
|
||||
variantColorsMap[props.variant],
|
||||
)}
|
||||
>
|
||||
{
|
||||
<Icon
|
||||
icon={variantIconMap[props.variant]}
|
||||
class={cx("size-5", variantIconColorsMap[props.variant])}
|
||||
/>
|
||||
}
|
||||
{props.headline}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Section component
|
||||
interface SectionProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
export const Section = (props: SectionProps) => (
|
||||
<div class="flex flex-col gap-3">{props.children}</div>
|
||||
);
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Component, JSX, splitProps } from "solid-js";
|
||||
import ArrowBottom from "@/icons/arrow-bottom.svg";
|
||||
import ArrowLeft from "@/icons/arrow-left.svg";
|
||||
import ArrowRight from "@/icons/arrow-right.svg";
|
||||
import ArrowTop from "@/icons/arrow-top.svg";
|
||||
import Attention from "@/icons/attention.svg";
|
||||
import CaretDown from "@/icons/caret-down.svg";
|
||||
import CaretLeft from "@/icons/caret-left.svg";
|
||||
import CaretRight from "@/icons/caret-right.svg";
|
||||
import CaretUp from "@/icons/caret-up.svg";
|
||||
import Checkmark from "@/icons/checkmark.svg";
|
||||
import ClanIcon from "@/icons/clan-icon.svg";
|
||||
import ClanLogo from "@/icons/clan-logo.svg";
|
||||
import Close from "@/icons/close.svg";
|
||||
import Download from "@/icons/download.svg";
|
||||
import Edit from "@/icons/edit.svg";
|
||||
import Expand from "@/icons/expand.svg";
|
||||
import EyeClose from "@/icons/eye-close.svg";
|
||||
import EyeOpen from "@/icons/eye-open.svg";
|
||||
import Filter from "@/icons/filter.svg";
|
||||
import Flash from "@/icons/flash.svg";
|
||||
import Folder from "@/icons/folder.svg";
|
||||
import Grid from "@/icons/grid.svg";
|
||||
import Info from "@/icons/info.svg";
|
||||
import List from "@/icons/list.svg";
|
||||
import Load from "@/icons/load.svg";
|
||||
import More from "@/icons/more.svg";
|
||||
import Paperclip from "@/icons/paperclip.svg";
|
||||
import Plus from "@/icons/plus.svg";
|
||||
import Reload from "@/icons/reload.svg";
|
||||
import Report from "@/icons/report.svg";
|
||||
import Search from "@/icons/search.svg";
|
||||
import Settings from "@/icons/settings.svg";
|
||||
import Trash from "@/icons/trash.svg";
|
||||
import Update from "@/icons/update.svg";
|
||||
import Warning from "@/icons/warning-filled.svg";
|
||||
|
||||
const icons = {
|
||||
ArrowBottom,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowTop,
|
||||
Attention,
|
||||
CaretDown,
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
CaretUp,
|
||||
Checkmark,
|
||||
ClanIcon,
|
||||
ClanLogo,
|
||||
Close,
|
||||
Download,
|
||||
Edit,
|
||||
Expand,
|
||||
EyeClose,
|
||||
EyeOpen,
|
||||
Filter,
|
||||
Flash,
|
||||
Folder,
|
||||
Grid,
|
||||
Info,
|
||||
List,
|
||||
Load,
|
||||
More,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Reload,
|
||||
Report,
|
||||
Search,
|
||||
Settings,
|
||||
Trash,
|
||||
Update,
|
||||
Warning,
|
||||
};
|
||||
|
||||
export type IconVariant = keyof typeof icons;
|
||||
|
||||
interface IconProps extends JSX.SvgSVGAttributes<SVGElement> {
|
||||
icon: IconVariant;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const Icon: Component<IconProps> = (props) => {
|
||||
const [local, iconProps] = splitProps(props, ["icon"]);
|
||||
|
||||
const IconComponent = icons[local.icon];
|
||||
return IconComponent ? (
|
||||
<IconComponent
|
||||
width={iconProps.size || 16}
|
||||
height={iconProps.size || 16}
|
||||
viewBox="0 0 48 48"
|
||||
ref={iconProps.ref}
|
||||
{...iconProps}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
@@ -1,194 +0,0 @@
|
||||
import cx from "classnames";
|
||||
import { JSX, Ref, Show, splitProps } from "solid-js";
|
||||
import Icon, { IconVariant } from "../icon";
|
||||
import { Typography, TypographyProps } from "../Typography";
|
||||
|
||||
export type InputVariant = "outlined" | "ghost";
|
||||
interface InputBaseProps {
|
||||
variant?: InputVariant;
|
||||
value?: string;
|
||||
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
|
||||
required?: boolean;
|
||||
type?: string;
|
||||
inlineLabel?: JSX.Element;
|
||||
class?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
error?: boolean;
|
||||
icon?: IconVariant;
|
||||
/** Overrides the input element */
|
||||
inputElem?: JSX.Element;
|
||||
divRef?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const variantBorder: Record<InputVariant, string> = {
|
||||
outlined: "border border-inv-3",
|
||||
ghost: "",
|
||||
};
|
||||
|
||||
const fgStateClasses = cx("aria-disabled:fg-def-4 aria-readonly:fg-def-3");
|
||||
|
||||
export const InputBase = (props: InputBaseProps) => {
|
||||
const [internal, inputProps] = splitProps(props, ["class", "divRef"]);
|
||||
return (
|
||||
<div
|
||||
// eslint-disable-next-line tailwindcss/no-custom-classname
|
||||
class={cx(
|
||||
// Layout
|
||||
"flex px-2 py-[0.375rem] flex-shrink-0 items-center justify-center gap-2 text-sm leading-6",
|
||||
|
||||
// Background
|
||||
"bg-def-1 hover:bg-def-acc-1",
|
||||
|
||||
// Text
|
||||
"fg-def-1",
|
||||
fgStateClasses,
|
||||
// Border
|
||||
variantBorder[props.variant || "outlined"],
|
||||
"rounded-sm",
|
||||
"hover:border-inv-4",
|
||||
"aria-disabled:border-def-2 aria-disabled:border",
|
||||
// Outline
|
||||
"outline-offset-1 outline-1",
|
||||
"active:outline active:outline-inv-3",
|
||||
"focus-visible:outline-double focus-visible:outline-int-1",
|
||||
|
||||
// Cursor
|
||||
"aria-readonly:cursor-no-drop",
|
||||
props.class,
|
||||
)}
|
||||
classList={{
|
||||
// eslint-disable-next-line tailwindcss/no-custom-classname
|
||||
[cx("!border !border-semantic-1 !outline-semantic-1")]: !!props.error,
|
||||
}}
|
||||
aria-invalid={props.error}
|
||||
aria-disabled={props.disabled}
|
||||
aria-readonly={props.readonly}
|
||||
tabIndex={0}
|
||||
role="textbox"
|
||||
ref={internal.divRef}
|
||||
>
|
||||
{props.icon && (
|
||||
<i
|
||||
class={cx("inline-flex fg-def-2", fgStateClasses)}
|
||||
aria-invalid={props.error}
|
||||
aria-disabled={props.disabled}
|
||||
aria-readonly={props.readonly}
|
||||
>
|
||||
<Icon icon={props.icon} font-size="inherit" color="inherit" />
|
||||
</i>
|
||||
)}
|
||||
<Show when={!props.inputElem} fallback={props.inputElem}>
|
||||
<input
|
||||
tabIndex={-1}
|
||||
class="w-full bg-transparent outline-none"
|
||||
value={props.value}
|
||||
type={props.type ? props.type : "text"}
|
||||
readOnly={props.readonly}
|
||||
placeholder={`${props.placeholder || ""}`}
|
||||
required={props.required}
|
||||
disabled={props.disabled}
|
||||
aria-invalid={props.error}
|
||||
aria-disabled={props.disabled}
|
||||
aria-readonly={props.readonly}
|
||||
{...inputProps}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface InputLabelProps
|
||||
extends JSX.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
error?: boolean;
|
||||
help?: string;
|
||||
labelAction?: JSX.Element;
|
||||
}
|
||||
export const InputLabel = (props: InputLabelProps) => {
|
||||
const [labelProps, forwardProps] = splitProps(props, [
|
||||
"class",
|
||||
"labelAction",
|
||||
]);
|
||||
return (
|
||||
<label
|
||||
class={cx("flex items-center gap-1", labelProps.class)}
|
||||
{...forwardProps}
|
||||
>
|
||||
<span class="flex flex-col justify-center">
|
||||
<span>
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="default"
|
||||
weight="bold"
|
||||
class="inline-flex gap-1 align-middle !fg-def-1"
|
||||
classList={{
|
||||
[cx("!text-red-600")]: !!props.error,
|
||||
}}
|
||||
aria-invalid={props.error}
|
||||
>
|
||||
{props.children}
|
||||
</Typography>
|
||||
{props.required && (
|
||||
<Typography
|
||||
class="inline-flex px-1 align-text-top leading-[0.5] fg-def-4"
|
||||
color="inherit"
|
||||
hierarchy="label"
|
||||
weight="bold"
|
||||
size="xs"
|
||||
>
|
||||
<>*</>
|
||||
</Typography>
|
||||
)}
|
||||
{props.help && (
|
||||
<span
|
||||
class=" inline px-2"
|
||||
data-tip={props.help}
|
||||
style={{
|
||||
"--tooltip-color": "#EFFFFF",
|
||||
"--tooltip-text-color": "#0D1416",
|
||||
"--tooltip-tail": "0.8125rem",
|
||||
}}
|
||||
>
|
||||
<Icon class="inline fg-def-3" icon={"Info"} width={"0.8125rem"} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="normal"
|
||||
color="secondary"
|
||||
>
|
||||
{props.description}
|
||||
</Typography>
|
||||
</span>
|
||||
{props.labelAction}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
interface InputErrorProps {
|
||||
error: string;
|
||||
typographyProps?: TypographyProps;
|
||||
}
|
||||
export const InputError = (props: InputErrorProps) => {
|
||||
const [typoClasses, rest] = splitProps(
|
||||
props.typographyProps || { class: "" },
|
||||
["class"],
|
||||
);
|
||||
return (
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
// @ts-expect-error: Dependent type is to complex to check how it is coupled to the override for now
|
||||
size="xxs"
|
||||
weight="medium"
|
||||
class={cx("col-span-full px-1 !text-red-500", typoClasses)}
|
||||
{...rest}
|
||||
>
|
||||
{props.error}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
.machine-item {
|
||||
@apply col-span-1 flex flex-col items-center;
|
||||
|
||||
position: relative;
|
||||
padding: theme(padding.2);
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.machine-item__thumb-wrapper {
|
||||
position: relative;
|
||||
padding: theme(padding.4);
|
||||
|
||||
border-radius: theme(borderRadius.md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.machine-item__thumb {
|
||||
@apply rounded-md bg-secondary-100 border border-secondary-200;
|
||||
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
|
||||
transition: transform 0.24s ease-in-out;
|
||||
}
|
||||
|
||||
.machine-item__header {
|
||||
@apply flex flex-col justify-center items-center;
|
||||
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
|
||||
transition: transform 0.18s 0.04s ease-in-out;
|
||||
}
|
||||
|
||||
.machine-item__pseudo {
|
||||
@apply bg-secondary-50;
|
||||
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid theme(borderColor.secondary.100);
|
||||
border-radius: theme(borderRadius.md);
|
||||
|
||||
transition:
|
||||
transform 0.16s ease-in-out,
|
||||
opacity 0.08s ease-in-out;
|
||||
}
|
||||
|
||||
.machine-item:hover {
|
||||
& .machine-item__pseudo {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& .machine-item__thumb {
|
||||
transform: scale(1.02);
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
0 8px 20px rgba(0, 0, 0, 0.15),
|
||||
0 12px 40px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
& .machine-item__header {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.machine-item:not(:hover) .machine-item__pseudo {
|
||||
transform: scale(0.94);
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { createSignal, Setter } from "solid-js";
|
||||
import { callApi, SuccessQuery } from "../../api";
|
||||
|
||||
import { A, useNavigate } from "@solidjs/router";
|
||||
import { RndThumbnail } from "../noiseThumbnail";
|
||||
|
||||
import { Filter } from "../../routes/machines";
|
||||
import { Typography } from "../Typography";
|
||||
import "./css/index.css";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
|
||||
type MachineDetails = SuccessQuery<"list_machines">["data"][string];
|
||||
|
||||
interface MachineListItemProps {
|
||||
name: string;
|
||||
info?: MachineDetails;
|
||||
nixOnly?: boolean;
|
||||
setFilter: Setter<Filter>;
|
||||
}
|
||||
|
||||
export const MachineListItem = (props: MachineListItemProps) => {
|
||||
const { name, info, nixOnly } = props;
|
||||
|
||||
// Bootstrapping
|
||||
const [installing, setInstalling] = createSignal<boolean>(false);
|
||||
|
||||
// Later only updates
|
||||
const [updating, setUpdating] = createSignal<boolean>(false);
|
||||
|
||||
const { activeClanURI } = useClanContext();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!info?.deploy?.targetHost || installing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active_clan = activeClanURI();
|
||||
if (!active_clan) {
|
||||
console.error("No active clan selected");
|
||||
return;
|
||||
}
|
||||
if (!info?.deploy?.targetHost) {
|
||||
console.error(
|
||||
"Machine does not have a target host. Specify where the machine should be deployed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const target_host = await callApi(
|
||||
"get_host",
|
||||
{
|
||||
field: "targetHost",
|
||||
flake: { identifier: active_clan },
|
||||
name: name,
|
||||
},
|
||||
{
|
||||
logging: { group_path: ["clans", active_clan, "machines", name] },
|
||||
},
|
||||
).promise;
|
||||
|
||||
if (target_host.status == "error") {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
if (target_host.data === null) {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target_host.data!.data) {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
setInstalling(true);
|
||||
await callApi("run_machine_install", {
|
||||
opts: {
|
||||
machine: {
|
||||
name: name,
|
||||
flake: {
|
||||
identifier: active_clan,
|
||||
},
|
||||
},
|
||||
no_reboot: true,
|
||||
debug: true,
|
||||
password: null,
|
||||
},
|
||||
target_host: target_host.data!.data,
|
||||
}).promise.finally(() => setInstalling(false));
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!info?.deploy?.targetHost || installing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active_clan = activeClanURI();
|
||||
if (!active_clan) {
|
||||
console.error("No active clan selected");
|
||||
return;
|
||||
}
|
||||
if (!info?.deploy.targetHost) {
|
||||
console.error(
|
||||
"Machine does not have a target host. Specify where the machine should be deployed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUpdating(true);
|
||||
|
||||
const target_host = await callApi(
|
||||
"get_host",
|
||||
{
|
||||
field: "targetHost",
|
||||
flake: { identifier: active_clan },
|
||||
name: name,
|
||||
},
|
||||
{
|
||||
logging: {
|
||||
group_path: ["clans", active_clan, "machines", name],
|
||||
},
|
||||
},
|
||||
).promise;
|
||||
|
||||
if (target_host.status == "error") {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
if (target_host.data === null) {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target_host.data!.data) {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
const build_host = await callApi(
|
||||
"get_host",
|
||||
{
|
||||
field: "buildHost",
|
||||
flake: { identifier: active_clan },
|
||||
name: name,
|
||||
},
|
||||
{
|
||||
logging: {
|
||||
group_path: ["clans", active_clan, "machines", name],
|
||||
},
|
||||
},
|
||||
).promise;
|
||||
|
||||
if (build_host.status == "error") {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
if (build_host.data === null) {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
await callApi(
|
||||
"run_machine_deploy",
|
||||
{
|
||||
machine: {
|
||||
name: name,
|
||||
flake: {
|
||||
identifier: active_clan,
|
||||
},
|
||||
},
|
||||
target_host: target_host.data!.data,
|
||||
build_host: build_host.data?.data || null,
|
||||
},
|
||||
{
|
||||
logging: {
|
||||
group_path: ["clans", active_clan, "machines", name],
|
||||
},
|
||||
},
|
||||
).promise;
|
||||
|
||||
setUpdating(false);
|
||||
};
|
||||
return (
|
||||
<div class="machine-item">
|
||||
<A href={`/machines/${name}`}>
|
||||
<div class="machine-item__thumb-wrapper">
|
||||
<div class="machine-item__thumb">
|
||||
<RndThumbnail name={name} width={100} height={100} />
|
||||
</div>
|
||||
<div class="machine-item__pseudo" />
|
||||
</div>
|
||||
<header class="machine-item__header">
|
||||
<Typography
|
||||
class="text-center"
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="bold"
|
||||
color="primary"
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
</header>
|
||||
</A>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
import { For } from "solid-js";
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
return function () {
|
||||
let t = (seed += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), 1 | t);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function calculateCellColor(
|
||||
x: number,
|
||||
y: number,
|
||||
grid: number[][],
|
||||
rand: () => number,
|
||||
) {
|
||||
const rows = grid.length;
|
||||
const cols = grid[0].length;
|
||||
|
||||
// Get the values of neighboring cells
|
||||
const neighbors = [
|
||||
grid[y][(x - 1 + cols) % cols], // Left
|
||||
grid[y][(x + 1) % cols], // Right
|
||||
grid[(y - 1 + rows) % rows][x], // Top
|
||||
grid[(y + 1) % rows][x], // Bottom
|
||||
];
|
||||
|
||||
const makeValue = (threshold: number, wanted: number) =>
|
||||
rand() > threshold ? Math.floor(rand() * 50) : wanted;
|
||||
|
||||
// Calculate the sum of neighbors
|
||||
const neighborSum = neighbors.reduce((sum, val) => sum + val, 0);
|
||||
// Introduce a hard cutoff for fewer intermediate values
|
||||
if (neighborSum < 1) {
|
||||
// Mostly dark squares
|
||||
// return Math.floor(rand() * 50); // Darker square
|
||||
return makeValue(0.9, Math.floor(rand() * 50));
|
||||
} else if (neighborSum >= 3) {
|
||||
// Mostly bright squares
|
||||
// return Math.floor(200 + rand() * 55); // Bright square
|
||||
return makeValue(0.9, Math.floor(200 + rand() * 55));
|
||||
} else {
|
||||
// Rare intermediate values
|
||||
return makeValue(0.4, Math.floor(100 + rand() * 50));
|
||||
}
|
||||
}
|
||||
|
||||
function generatePatternedImage(seed: number, width = 300, height = 150) {
|
||||
const rand = mulberry32(seed);
|
||||
const rowSize = 1 + Math.floor((rand() * width) / 10);
|
||||
const colSize = 1 + Math.floor((rand() * height) / 10);
|
||||
const cols = Math.floor(width / colSize);
|
||||
const rows = Math.floor(height / rowSize);
|
||||
|
||||
// Initialize a 2D grid with random values (0 or 1)
|
||||
const grid = Array.from({ length: rows }, () =>
|
||||
Array.from({ length: cols }, () => (rand() > 0.5 ? 1 : 0)),
|
||||
);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("Could not get 2d context");
|
||||
}
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const totalCells = rows * cols;
|
||||
for (let i = 0; i < totalCells; i++) {
|
||||
// Calculate polar coordinates
|
||||
const angle = (i / totalCells) * Math.PI * 2 * rand() * 360; // Increase the spiral's density
|
||||
const radius = Math.sqrt(i) * rand() * 4; // Controls how tightly the spiral is packed
|
||||
|
||||
// Convert polar to Cartesian coordinates
|
||||
const x = Math.floor(centerX + radius * Math.cos(angle));
|
||||
const y = Math.floor(centerY + radius * Math.sin(angle));
|
||||
|
||||
// Find grid coordinates
|
||||
const col = Math.floor(x / colSize);
|
||||
const row = Math.floor(y / rowSize);
|
||||
|
||||
// Ensure the cell is within bounds
|
||||
if (col >= 0 && col < cols && row >= 0 && row < rows) {
|
||||
const colorValue = calculateCellColor(col, row, grid, rand);
|
||||
ctx.fillStyle = `rgb(${colorValue}, ${colorValue}, ${colorValue})`;
|
||||
ctx.fillRect(x, y, colSize, rowSize);
|
||||
}
|
||||
}
|
||||
|
||||
return canvas.toDataURL();
|
||||
}
|
||||
|
||||
interface RndThumbnailProps {
|
||||
name: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
export const RndThumbnail = (props: RndThumbnailProps) => {
|
||||
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} />;
|
||||
};
|
||||
|
||||
const RndThumbnailShow = () => {
|
||||
const names = ["hsjobeki", "mic92", "lassulus", "D", "A", "D", "B", "C"];
|
||||
|
||||
return (
|
||||
<div class="grid grid-cols-4">
|
||||
<For each={names}>{(name) => <RndThumbnail name={name} />}</For>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,301 +0,0 @@
|
||||
import { toast, Toast } from "solid-toast"; // Make sure to import Toast type
|
||||
import { Component, JSX, createSignal, onCleanup } from "solid-js";
|
||||
|
||||
// --- Icon Components ---
|
||||
|
||||
const ErrorIcon: Component = () => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ "margin-right": "10px", "flex-shrink": "0" }}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="#FF4D4F" />
|
||||
<path
|
||||
d="M12 7V13"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="16.5" r="1.5" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const InfoIcon: Component = () => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ "margin-right": "10px", "flex-shrink": "0" }}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="#2196F3" />
|
||||
<path
|
||||
d="M12 11V17"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="8.5" r="1.5" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WarningIcon: Component = () => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ "margin-right": "10px", "flex-shrink": "0" }}
|
||||
>
|
||||
<path d="M12 2L22 21H2L12 2Z" fill="#FFC107" />
|
||||
<path
|
||||
d="M12 9V14"
|
||||
stroke="#424242"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="16.5" r="1" fill="#424242" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// --- Base Props and Styles ---
|
||||
|
||||
interface BaseToastProps {
|
||||
t: Toast;
|
||||
message: string;
|
||||
onCancel?: () => void; // Optional custom function on X click
|
||||
}
|
||||
|
||||
const baseToastStyle: JSX.CSSProperties = {
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between", // To push X to the right
|
||||
gap: "10px", // Space between content and close button
|
||||
background: "#FFFFFF",
|
||||
color: "#333333",
|
||||
padding: "12px 16px",
|
||||
"border-radius": "6px",
|
||||
"box-shadow": "0 2px 8px rgba(0, 0, 0, 0.12)",
|
||||
"font-family":
|
||||
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||
"font-size": "14px",
|
||||
"line-height": "1.4",
|
||||
"min-width": "280px",
|
||||
"max-width": "450px",
|
||||
};
|
||||
|
||||
const closeButtonStyle: JSX.CSSProperties = {
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "red", // As per original example's X button
|
||||
"font-size": "1.5em",
|
||||
"font-weight": "bold",
|
||||
cursor: "pointer",
|
||||
padding: "0 0 0 10px", // Space to its left
|
||||
"line-height": "1",
|
||||
"align-self": "center", // Ensure vertical alignment
|
||||
};
|
||||
|
||||
// --- Toast Component Definitions ---
|
||||
|
||||
// Error Toast
|
||||
export const ErrorToastComponent: Component<BaseToastProps> = (props) => {
|
||||
// Local state for click feedback and exit animation
|
||||
let timeoutId: number | undefined;
|
||||
const [clicked, setClicked] = createSignal(false);
|
||||
const [exiting, setExiting] = createSignal(false);
|
||||
|
||||
const handleToastClick = () => {
|
||||
setClicked(true);
|
||||
setTimeout(() => {
|
||||
setExiting(true);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
toast.dismiss(props.t.id);
|
||||
}, 300); // Match exit animation duration
|
||||
}, 100); // Brief color feedback before animating out
|
||||
};
|
||||
|
||||
// Cleanup timeout if unmounted early
|
||||
onCleanup(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...baseToastStyle,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
|
||||
background: clicked()
|
||||
? "#ffeaea"
|
||||
: exiting()
|
||||
? "#fff"
|
||||
: baseToastStyle.background,
|
||||
opacity: exiting() ? 0 : 1,
|
||||
transform: exiting() ? "translateY(-20px)" : "none",
|
||||
}}
|
||||
onClick={handleToastClick}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||
>
|
||||
<ErrorIcon />
|
||||
<span>{props.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Info Toast
|
||||
export const CancelToastComponent: Component<BaseToastProps> = (props) => {
|
||||
let timeoutId: number | undefined;
|
||||
const [clicked, setClicked] = createSignal(false);
|
||||
const [exiting, setExiting] = createSignal(false);
|
||||
|
||||
// Cleanup timeout if unmounted early
|
||||
onCleanup(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
const handleButtonClick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (props.onCancel) props.onCancel();
|
||||
toast.dismiss(props.t.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...baseToastStyle,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
|
||||
background: clicked()
|
||||
? "#eaf4ff"
|
||||
: exiting()
|
||||
? "#fff"
|
||||
: baseToastStyle.background,
|
||||
opacity: exiting() ? 0 : 1,
|
||||
transform: exiting() ? "translateY(-20px)" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||
>
|
||||
<InfoIcon />
|
||||
<span>{props.message}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
setClicked(true);
|
||||
handleButtonClick(e);
|
||||
}}
|
||||
style={{
|
||||
...closeButtonStyle,
|
||||
color: "#2196F3",
|
||||
"font-size": "1em",
|
||||
"font-weight": "normal",
|
||||
padding: "4px 12px",
|
||||
border: "1px solid #2196F3",
|
||||
"border-radius": "4px",
|
||||
background: clicked() ? "#bbdefb" : "#eaf4ff",
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s",
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
width: "70px",
|
||||
height: "32px",
|
||||
}}
|
||||
aria-label="Cancel"
|
||||
disabled={clicked()}
|
||||
>
|
||||
{clicked() ? (
|
||||
// Simple spinner SVG
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 50 50"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<circle
|
||||
cx="25"
|
||||
cy="25"
|
||||
r="20"
|
||||
fill="none"
|
||||
stroke="#2196F3"
|
||||
stroke-width="4"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="31.415, 31.415"
|
||||
transform="rotate(72 25 25)"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 25 25"
|
||||
to="360 25 25"
|
||||
dur="0.8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
) : (
|
||||
"Cancel"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Warning Toast
|
||||
const WarningToastComponent: Component<BaseToastProps> = (props) => {
|
||||
let timeoutId: number | undefined;
|
||||
const [clicked, setClicked] = createSignal(false);
|
||||
const [exiting, setExiting] = createSignal(false);
|
||||
|
||||
const handleToastClick = () => {
|
||||
setClicked(true);
|
||||
setTimeout(() => {
|
||||
setExiting(true);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
toast.dismiss(props.t.id);
|
||||
}, 300);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Cleanup timeout if unmounted early
|
||||
onCleanup(() => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...baseToastStyle,
|
||||
cursor: "pointer",
|
||||
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
|
||||
background: clicked()
|
||||
? "#fff8e1"
|
||||
: exiting()
|
||||
? "#fff"
|
||||
: baseToastStyle.background,
|
||||
opacity: exiting() ? 0 : 1,
|
||||
transform: exiting() ? "translateY(-20px)" : "none",
|
||||
}}
|
||||
onClick={handleToastClick}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
|
||||
>
|
||||
<WarningIcon />
|
||||
<span>{props.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
../../../ui/src/components/v2
|
||||
@@ -1,64 +0,0 @@
|
||||
import { createContext, createEffect, JSX, useContext } from "solid-js";
|
||||
import {
|
||||
activeClanURI,
|
||||
addClanURI,
|
||||
clanURIs,
|
||||
removeClanURI,
|
||||
setActiveClanURI,
|
||||
store,
|
||||
} from "@/src/stores/clan";
|
||||
import { redirect } from "@solidjs/router";
|
||||
|
||||
// Create the context
|
||||
interface ClanContextType {
|
||||
activeClanURI: typeof activeClanURI;
|
||||
setActiveClanURI: typeof setActiveClanURI;
|
||||
clanURIs: typeof clanURIs;
|
||||
addClanURI: typeof addClanURI;
|
||||
removeClanURI: typeof removeClanURI;
|
||||
}
|
||||
|
||||
const ClanContext = createContext<ClanContextType>({
|
||||
activeClanURI,
|
||||
setActiveClanURI,
|
||||
clanURIs,
|
||||
addClanURI,
|
||||
removeClanURI,
|
||||
});
|
||||
|
||||
interface ClanProviderProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export function ClanProvider(props: ClanProviderProps) {
|
||||
// redirect to welcome if there's no active clan and no clan URIs
|
||||
createEffect(async () => {
|
||||
if (!store.activeClanURI && store.clanURIs.length == 0) {
|
||||
redirect("/welcome");
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ClanContext.Provider
|
||||
value={{
|
||||
activeClanURI,
|
||||
setActiveClanURI,
|
||||
clanURIs,
|
||||
addClanURI,
|
||||
removeClanURI,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</ClanContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Export a hook that provides access to the context
|
||||
export function useClanContext() {
|
||||
const context = useContext(ClanContext);
|
||||
if (!context) {
|
||||
throw new Error("useClanContext must be used within a ClanProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
|
||||
import type {
|
||||
ComputePositionConfig,
|
||||
ComputePositionReturn,
|
||||
ReferenceElement,
|
||||
} from "@floating-ui/dom";
|
||||
import { computePosition } from "@floating-ui/dom";
|
||||
|
||||
interface UseFloatingOptions<R extends ReferenceElement, F extends HTMLElement>
|
||||
extends Partial<ComputePositionConfig> {
|
||||
whileElementsMounted?: (
|
||||
reference: R,
|
||||
floating: F,
|
||||
update: () => void,
|
||||
) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
void | (() => void);
|
||||
}
|
||||
|
||||
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
}
|
||||
|
||||
interface UseFloatingResult extends UseFloatingState {
|
||||
update(): void;
|
||||
}
|
||||
|
||||
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
|
||||
reference: () => R | undefined | null,
|
||||
floating: () => F | undefined | null,
|
||||
options?: UseFloatingOptions<R, F>,
|
||||
): UseFloatingResult {
|
||||
const placement = () => options?.placement ?? "bottom";
|
||||
const strategy = () => options?.strategy ?? "absolute";
|
||||
|
||||
const [data, setData] = createSignal<UseFloatingState>({
|
||||
x: null,
|
||||
y: null,
|
||||
placement: placement(),
|
||||
strategy: strategy(),
|
||||
middlewareData: {},
|
||||
});
|
||||
|
||||
const [error, setError] = createSignal<{ value: unknown } | undefined>();
|
||||
|
||||
createEffect(() => {
|
||||
const currentError = error();
|
||||
if (currentError) {
|
||||
throw currentError.value;
|
||||
}
|
||||
});
|
||||
|
||||
const version = createMemo(() => {
|
||||
reference();
|
||||
floating();
|
||||
return {};
|
||||
});
|
||||
|
||||
function update() {
|
||||
const currentReference = reference();
|
||||
const currentFloating = floating();
|
||||
|
||||
if (currentReference && currentFloating) {
|
||||
const capturedVersion = version();
|
||||
computePosition(currentReference, currentFloating, {
|
||||
middleware: options?.middleware,
|
||||
placement: placement(),
|
||||
strategy: strategy(),
|
||||
}).then(
|
||||
(currentData) => {
|
||||
// Check if it's still valid
|
||||
if (capturedVersion === version()) {
|
||||
setData(currentData);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
setError(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const currentReference = reference();
|
||||
const currentFloating = floating();
|
||||
|
||||
placement();
|
||||
strategy();
|
||||
|
||||
if (currentReference && currentFloating) {
|
||||
if (options?.whileElementsMounted) {
|
||||
const cleanup = options.whileElementsMounted(
|
||||
currentReference,
|
||||
currentFloating,
|
||||
update,
|
||||
);
|
||||
|
||||
if (cleanup) {
|
||||
onCleanup(cleanup);
|
||||
}
|
||||
} else {
|
||||
update();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
get x() {
|
||||
return data().x;
|
||||
},
|
||||
get y() {
|
||||
return data().y;
|
||||
},
|
||||
get placement() {
|
||||
return data().placement;
|
||||
},
|
||||
get strategy() {
|
||||
return data().strategy;
|
||||
},
|
||||
get middlewareData() {
|
||||
return data().middlewareData;
|
||||
},
|
||||
update,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { callApi } from "../api";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
|
||||
export const registerClan = async () => {
|
||||
const { setActiveClanURI, addClanURI } = useClanContext();
|
||||
|
||||
try {
|
||||
const loc = await callApi("open_file", {
|
||||
file_request: { mode: "select_folder" },
|
||||
}).promise;
|
||||
if (loc.status === "success" && loc.data) {
|
||||
const data = loc.data[0];
|
||||
addClanURI(data);
|
||||
setActiveClanURI(data);
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the custom file dialog
|
||||
* Returns a native FileList to allow interaction with the native input type="file"
|
||||
*/
|
||||
const selectSshKeys = async (): Promise<FileList> => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
const response = await callApi("open_file", {
|
||||
file_request: {
|
||||
title: "Select SSH Key",
|
||||
mode: "open_file",
|
||||
initial_folder: "~/.ssh",
|
||||
},
|
||||
}).promise;
|
||||
if (response.status === "success" && response.data) {
|
||||
// Add synthetic files to the DataTransfer object
|
||||
// FileList cannot be instantiated directly.
|
||||
response.data.forEach((filename) => {
|
||||
dataTransfer.items.add(new File([], filename));
|
||||
});
|
||||
}
|
||||
return dataTransfer.files;
|
||||
};
|
||||
@@ -1,157 +0,0 @@
|
||||
/* @import "material-icons/iconfont/filled.css"; */
|
||||
/* List of icons: https://marella.me/material-icons/demo/ */
|
||||
/* @import url(./components/Typography/css/typography.css); */
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Archivo";
|
||||
font-weight: 400;
|
||||
src: url(../.fonts/ArchivoSemiCondensed-Regular.woff2) format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Archivo";
|
||||
font-weight: 500;
|
||||
src: url(../.fonts/ArchivoSemiCondensed-Medium.woff2) format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Archivo";
|
||||
font-weight: 600;
|
||||
src: url(../.fonts/ArchivoSemiCondensed-SemiBold.woff2) format("woff2");
|
||||
}
|
||||
|
||||
@keyframes slide {
|
||||
to {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
|
||||
-webkit-user-select: none;
|
||||
/* Safari */
|
||||
-moz-user-select: none;
|
||||
/* Firefox */
|
||||
-ms-user-select: none;
|
||||
/* Internet Explorer/Edge */
|
||||
user-select: none;
|
||||
/* Standard */
|
||||
}
|
||||
|
||||
.accordeon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: theme(gap.3);
|
||||
}
|
||||
|
||||
.accordeon__header {
|
||||
padding: theme(padding.2) theme(padding[1.5]) theme(padding.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
summary::marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.accordeon__body {
|
||||
}
|
||||
|
||||
.machine-item-loader {
|
||||
@apply col-span-1 flex flex-col items-center;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
padding: theme(padding.2);
|
||||
border-radius: theme(borderRadius.md);
|
||||
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.machine-item-loader__thumb-wrapper {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
padding: theme(padding.4);
|
||||
|
||||
border-radius: theme(borderRadius.md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.machine-item-loader__thumb {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: theme(backgroundColor.secondary.100);
|
||||
border-radius: theme(borderRadius.md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.machine-item-loader__headline {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
width: 90%;
|
||||
height: 20px;
|
||||
|
||||
background: theme(backgroundColor.secondary.100);
|
||||
border-radius: theme(borderRadius.sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.machine-item-loader__cover {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.machine-item-loader__loader {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 20%,
|
||||
theme(backgroundColor.secondary.200) 50%,
|
||||
transparent 80%
|
||||
);
|
||||
background-size: 400px 100%;
|
||||
|
||||
animation: loader 4s linear infinite;
|
||||
transition: all 0.56s ease;
|
||||
}
|
||||
|
||||
.machine-item-loader__cover .machine-item-loader__loader {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent 20%,
|
||||
theme(backgroundColor.secondary.50) 50%,
|
||||
transparent 80%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes loader {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
/* @refresh reload */
|
||||
import { Portal, render } from "solid-js/web";
|
||||
import { Navigate, RouteDefinition, Router } from "@solidjs/router";
|
||||
|
||||
import "./index.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
|
||||
import {
|
||||
CreateMachine,
|
||||
MachineDetails,
|
||||
MachineListView,
|
||||
MachineInstall,
|
||||
} from "./routes/machines";
|
||||
import { Layout } from "./layout/layout";
|
||||
import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
|
||||
import { Flash } from "./routes/flash/view";
|
||||
import { Welcome } from "./routes/welcome";
|
||||
import { Toaster } from "solid-toast";
|
||||
import { ModuleList } from "./routes/modules/list";
|
||||
import { ModuleDetails } from "./routes/modules/details";
|
||||
import { ModuleDetails as AddModule } from "./routes/modules/add";
|
||||
import { ApiTester } from "./api_test";
|
||||
import { IconVariant } from "./components/icon";
|
||||
import { VarsPage } from "./routes/machines/install/vars-step";
|
||||
import { ClanProvider } from "./contexts/clan";
|
||||
|
||||
export const client = new QueryClient();
|
||||
|
||||
const root = document.getElementById("app");
|
||||
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
|
||||
);
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("Development mode");
|
||||
// Load the debugger in development mode
|
||||
await import("solid-devtools");
|
||||
}
|
||||
|
||||
export type AppRoute = Omit<RouteDefinition, "children"> & {
|
||||
label: string;
|
||||
icon?: IconVariant;
|
||||
children?: AppRoute[];
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export const routes: AppRoute[] = [
|
||||
{
|
||||
path: "/",
|
||||
label: "",
|
||||
hidden: true,
|
||||
component: () => <Navigate href="/machines" />,
|
||||
},
|
||||
{
|
||||
path: "/machines",
|
||||
label: "Machines",
|
||||
icon: "Grid",
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
label: "Overview",
|
||||
component: () => <MachineListView />,
|
||||
},
|
||||
{
|
||||
path: "/create",
|
||||
label: "Create",
|
||||
component: () => <CreateMachine />,
|
||||
},
|
||||
{
|
||||
path: "/:id",
|
||||
label: "Details",
|
||||
hidden: true,
|
||||
component: () => <MachineDetails />,
|
||||
},
|
||||
{
|
||||
path: "/:id/vars",
|
||||
label: "Vars",
|
||||
hidden: true,
|
||||
component: () => <VarsPage />,
|
||||
},
|
||||
{
|
||||
path: "/:id/install",
|
||||
label: "Install",
|
||||
hidden: true,
|
||||
component: () => <MachineInstall />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/clans",
|
||||
label: "Clans",
|
||||
hidden: true,
|
||||
icon: "List",
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
label: "Overview",
|
||||
component: () => <ClanList />,
|
||||
},
|
||||
{
|
||||
path: "/create",
|
||||
label: "Create",
|
||||
component: () => <CreateClan />,
|
||||
},
|
||||
{
|
||||
path: "/:id",
|
||||
label: "Details",
|
||||
hidden: true,
|
||||
component: () => <ClanDetails />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/modules",
|
||||
label: "Modules",
|
||||
icon: "Search",
|
||||
children: [
|
||||
{
|
||||
path: "/",
|
||||
label: "App Store",
|
||||
component: () => <ModuleList />,
|
||||
},
|
||||
{
|
||||
path: "details/:id",
|
||||
label: "Details",
|
||||
hidden: true,
|
||||
component: () => <ModuleDetails />,
|
||||
},
|
||||
{
|
||||
path: "/add/:id",
|
||||
label: "Details",
|
||||
hidden: true,
|
||||
component: () => <AddModule />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/tools",
|
||||
label: "Tools",
|
||||
icon: "Folder",
|
||||
children: [
|
||||
{
|
||||
path: "/flash",
|
||||
label: "Flash Installer",
|
||||
component: () => <Flash />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/welcome",
|
||||
label: "",
|
||||
hidden: true,
|
||||
component: () => <Welcome />,
|
||||
},
|
||||
{
|
||||
path: "/internal-dev",
|
||||
label: "Internal (Only visible in dev mode)",
|
||||
children: [
|
||||
{
|
||||
path: "/api_testing",
|
||||
label: "api_testing",
|
||||
hidden: false,
|
||||
component: () => <ApiTester />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
() => (
|
||||
<>
|
||||
<Portal mount={document.body}>
|
||||
<Toaster position="top-right" containerClassName="z-[9999]" />
|
||||
</Portal>
|
||||
<QueryClientProvider client={client}>
|
||||
<ClanProvider>
|
||||
<Router root={Layout}>{routes}</Router>
|
||||
</ClanProvider>
|
||||
</QueryClientProvider>
|
||||
</>
|
||||
),
|
||||
root!,
|
||||
);
|
||||
@@ -1,29 +0,0 @@
|
||||
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="sticky top-0 z-50 flex items-center border-b bg-white/80 px-6 py-4 backdrop-blur-md border-def-3">
|
||||
<div class="flex-none">
|
||||
{props.showBack && <BackButton />}
|
||||
<span class=" lg:hidden" data-tip="Menu">
|
||||
<label class=" " for="toplevel-drawer">
|
||||
<span class="material-icons">menu</span>
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Typography hierarchy="title" size="m" weight="medium" class="">
|
||||
{props.title}
|
||||
</Typography>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-3">{props.toolbar}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Component, createEffect } from "solid-js";
|
||||
import { Sidebar } from "@/src/components/Sidebar";
|
||||
import { RouteSectionProps, useNavigate } from "@solidjs/router";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
|
||||
export const Layout: Component<RouteSectionProps> = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const { clanURIs } = useClanContext();
|
||||
createEffect(() => {
|
||||
console.log(
|
||||
"empty ClanList, redirect to welcome page",
|
||||
clanURIs().length === 0,
|
||||
);
|
||||
if (clanURIs().length === 0) {
|
||||
navigate("/welcome");
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="h-screen w-full p-4 bg-def-2">
|
||||
<div class="flex size-full flex-row-reverse">
|
||||
<div class="my-2 ml-8 flex-1 overflow-x-hidden overflow-y-scroll rounded-lg border bg-def-1 border-def-3">
|
||||
{props.children}
|
||||
</div>
|
||||
<Sidebar {...props} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useQuery } from "@tanstack/solid-query";
|
||||
import { callApi } from "@/src/api";
|
||||
import { activeClanURI, removeClanURI } from "@/src/stores/clan";
|
||||
|
||||
export const clanMetaQuery = (uri: string | undefined = undefined) =>
|
||||
useQuery(() => {
|
||||
const clanURI = uri || activeClanURI();
|
||||
const enabled = !!clanURI;
|
||||
|
||||
return {
|
||||
enabled,
|
||||
queryKey: [clanURI, "meta"],
|
||||
queryFn: async () => {
|
||||
console.log("fetching clan meta", clanURI);
|
||||
|
||||
const result = await callApi("get_clan_details", {
|
||||
flake: { identifier: clanURI! },
|
||||
}).promise;
|
||||
|
||||
console.log("result", result);
|
||||
|
||||
if (result.status === "error") {
|
||||
// check if the clan directory no longer exists
|
||||
// remove from the clan list if not
|
||||
result.errors.forEach((error) => {
|
||||
if (error.description === "clan directory does not exist") {
|
||||
removeClanURI(clanURI!);
|
||||
}
|
||||
});
|
||||
|
||||
throw new Error("Failed to fetch data");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useQuery } from "@tanstack/solid-query";
|
||||
import { callApi } from "../api";
|
||||
|
||||
interface ModulesFilter {
|
||||
features: string[];
|
||||
}
|
||||
export const createModulesQuery = (
|
||||
uri: string | undefined,
|
||||
filter?: ModulesFilter,
|
||||
) =>
|
||||
useQuery(() => ({
|
||||
queryKey: [uri, "list_modules"],
|
||||
placeholderData: {
|
||||
localModules: {},
|
||||
modulesPerSource: {},
|
||||
},
|
||||
enabled: !!uri,
|
||||
queryFn: async () => {
|
||||
if (uri) {
|
||||
const response = await callApi("list_modules", {
|
||||
base_path: uri,
|
||||
}).promise;
|
||||
if (response.status === "error") {
|
||||
console.error("Failed to fetch data");
|
||||
} else {
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
return {
|
||||
localModules: {},
|
||||
modulesPerSource: {},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
export const machinesQuery = (uri: string | undefined) =>
|
||||
useQuery<string[]>(() => ({
|
||||
queryKey: [uri, "machines"],
|
||||
placeholderData: [],
|
||||
queryFn: async () => {
|
||||
if (!uri) return [];
|
||||
|
||||
const response = await callApi("list_machines", {
|
||||
flake: { identifier: uri },
|
||||
}).promise;
|
||||
if (response.status === "error") {
|
||||
console.error("Failed to fetch data");
|
||||
} else {
|
||||
const machines = response.data.machines || {};
|
||||
return Object.keys(machines);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
@@ -1,208 +0,0 @@
|
||||
import { callApi, OperationResponse } from "@/src/api";
|
||||
import { Show } from "solid-js";
|
||||
import {
|
||||
createForm,
|
||||
required,
|
||||
reset,
|
||||
SubmitHandler,
|
||||
ResponseData,
|
||||
} from "@modular-forms/solid";
|
||||
import toast from "solid-toast";
|
||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
|
||||
type CreateForm = Meta & {
|
||||
template: string;
|
||||
};
|
||||
|
||||
export const CreateClan = () => {
|
||||
const [formStore, { Form, Field }] = createForm<CreateForm, ResponseData>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
template: "minimal",
|
||||
},
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { setActiveClanURI, addClanURI } = useClanContext();
|
||||
|
||||
const handleSubmit: SubmitHandler<CreateForm> = async (values, event) => {
|
||||
const { template, ...meta } = values;
|
||||
const response = await callApi("open_file", {
|
||||
file_request: { mode: "save" },
|
||||
}).promise;
|
||||
|
||||
if (response.status !== "success") {
|
||||
toast.error("Cannot select clan directory");
|
||||
return;
|
||||
}
|
||||
const target_dir = response?.data;
|
||||
if (!target_dir) {
|
||||
toast.error("Cannot select clan directory");
|
||||
return;
|
||||
}
|
||||
|
||||
const loading_toast = toast.loading("Creating Clan....");
|
||||
const r = await callApi("create_clan", {
|
||||
opts: {
|
||||
dest: target_dir[0],
|
||||
template_name: template,
|
||||
initial: {
|
||||
meta,
|
||||
services: {},
|
||||
machines: {},
|
||||
},
|
||||
},
|
||||
}).promise;
|
||||
toast.dismiss(loading_toast);
|
||||
|
||||
if (r.status === "error") {
|
||||
toast.error("Failed to create clan");
|
||||
return;
|
||||
}
|
||||
|
||||
// Will generate a key if it doesn't exist, and add a user to the clan
|
||||
const k = await callApi("create_secrets_user", {
|
||||
flake_dir: target_dir[0],
|
||||
}).promise;
|
||||
|
||||
if (k.status === "error") {
|
||||
toast.error("Failed to generate key");
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.status === "success") {
|
||||
toast.success("Clan Successfully Created");
|
||||
|
||||
addClanURI(target_dir[0]);
|
||||
setActiveClanURI(target_dir[0]);
|
||||
|
||||
navigate("/machines");
|
||||
reset(formStore);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="">
|
||||
<Form onSubmit={handleSubmit} shouldActive>
|
||||
<Field name="icon">
|
||||
{(field, props) => (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<div class="">
|
||||
<Field
|
||||
name="name"
|
||||
validate={[required("Please enter a unique name for the clan.")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<label class=" w-full">
|
||||
<div class="">
|
||||
<span class=" block after:ml-0.5 after:text-primary-800 after:content-['*']">
|
||||
Name
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
{...props}
|
||||
disabled={formStore.submitting}
|
||||
required
|
||||
placeholder="Give your Clan a legendary name"
|
||||
classList={{ "": !!field.error }}
|
||||
value={field.value}
|
||||
/>
|
||||
<div class="">
|
||||
{field.error && <span class="">{field.error}</span>}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="description">
|
||||
{(field, props) => (
|
||||
<label class=" w-full">
|
||||
<div class="">
|
||||
<span class="">Description</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
{...props}
|
||||
disabled={formStore.submitting}
|
||||
required
|
||||
type="text"
|
||||
placeholder="Tell us what makes your Clan legendary"
|
||||
classList={{ "": !!field.error }}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
<div class="">
|
||||
{field.error && <span class="">{field.error}</span>}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="template" validate={[required("This is required")]}>
|
||||
{(field, props) => (
|
||||
<div class=" " tabindex="0">
|
||||
<input type="checkbox" />
|
||||
<div class=" font-medium ">Advanced</div>
|
||||
<div>
|
||||
<TextInput
|
||||
// adornment={{
|
||||
// content: (
|
||||
// <span class="-mr-1 text-neutral-500">clan-core #</span>
|
||||
// ),
|
||||
// position: "start",
|
||||
// }}
|
||||
inputProps={props}
|
||||
label="Template to use"
|
||||
value={field.value ?? ""}
|
||||
error={field.error}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
{
|
||||
<div class=" justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formStore.submitting}
|
||||
endIcon={<Icon icon="Plus" />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Meta = Extract<
|
||||
OperationResponse<"get_clan_details">,
|
||||
{ status: "success" }
|
||||
>["data"];
|
||||
@@ -1,151 +0,0 @@
|
||||
import { callApi, SuccessQuery } from "@/src/api";
|
||||
import { useParams } from "@solidjs/router";
|
||||
import { useQueryClient } from "@tanstack/solid-query";
|
||||
import { Match, Switch } from "solid-js";
|
||||
import { createForm, required, SubmitHandler } from "@modular-forms/solid";
|
||||
import toast from "solid-toast";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
import { Header } from "@/src/layout/header";
|
||||
import { clanMetaQuery } from "@/src/queries/clan-meta";
|
||||
|
||||
interface EditClanFormProps {
|
||||
initial: GeneralData;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
const EditClanForm = (props: EditClanFormProps) => {
|
||||
const [formStore, { Form, Field }] = createForm<GeneralData>({
|
||||
initialValues: props.initial,
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleSubmit: SubmitHandler<GeneralData> = async (values, event) => {
|
||||
await toast.promise(
|
||||
(async () => {
|
||||
await callApi("set_clan_details", {
|
||||
options: {
|
||||
flake: { identifier: props.directory },
|
||||
meta: values,
|
||||
},
|
||||
}).promise;
|
||||
})(),
|
||||
{
|
||||
loading: "Updating clan...",
|
||||
success: "Clan Successfully updated",
|
||||
error: "Failed to update clan",
|
||||
},
|
||||
);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [props.directory, "meta"],
|
||||
});
|
||||
};
|
||||
const curr_name = () => props.initial.name;
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} shouldActive>
|
||||
<Field name="icon">
|
||||
{(field) => (
|
||||
<>
|
||||
<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>
|
||||
<div class="">
|
||||
<span class="text-xl text-primary-800">General</span>
|
||||
<Field
|
||||
name="name"
|
||||
validate={[required("Please enter a unique name for the clan.")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<label class="w-full">
|
||||
<div class="">
|
||||
<span class=" block after:ml-0.5 after:text-primary-800 after:content-['*']">
|
||||
Name
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
{...props}
|
||||
disabled={formStore.submitting}
|
||||
required
|
||||
placeholder="Clan Name"
|
||||
class=""
|
||||
classList={{ "": !!field.error }}
|
||||
value={field.value}
|
||||
/>
|
||||
<div class="">
|
||||
{field.error && <span class="">{field.error}</span>}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="description">
|
||||
{(field, props) => (
|
||||
<label class="w-full">
|
||||
<div class="">
|
||||
<span class="">Description</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
{...props}
|
||||
disabled={formStore.submitting}
|
||||
required
|
||||
type="text"
|
||||
placeholder="Some words about your clan"
|
||||
class=""
|
||||
classList={{ "": !!field.error }}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
<div class="">
|
||||
{field.error && <span class="">{field.error}</span>}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
{
|
||||
<div class="justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formStore.submitting || !formStore.dirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
type GeneralData = SuccessQuery<"get_clan_details">["data"];
|
||||
|
||||
export const ClanDetails = () => {
|
||||
const params = useParams();
|
||||
const clan_dir = window.atob(params.id);
|
||||
// Fetch general meta data
|
||||
const clanQuery = clanMetaQuery(clan_dir);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./list";
|
||||
export * from "./create";
|
||||
export * from "./details";
|
||||
@@ -1,160 +0,0 @@
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { useFloating } from "@/src/floating";
|
||||
import { autoUpdate, flip, hide, offset, shift } from "@floating-ui/dom";
|
||||
import { A, useNavigate } from "@solidjs/router";
|
||||
import { registerClan } from "@/src/hooks";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
import { clanURIs, setActiveClanURI } from "@/src/stores/clan";
|
||||
import { clanMetaQuery } from "@/src/queries/clan-meta";
|
||||
|
||||
interface ClanItemProps {
|
||||
clan_dir: string;
|
||||
}
|
||||
const ClanItem = (props: ClanItemProps) => {
|
||||
const { clan_dir } = props;
|
||||
|
||||
const details = clanMetaQuery(clan_dir);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [reference, setReference] = createSignal<HTMLElement>();
|
||||
const [floating, setFloating] = createSignal<HTMLElement>();
|
||||
|
||||
const { activeClanURI, removeClanURI } = useClanContext();
|
||||
|
||||
// `position` is a reactive object.
|
||||
const position = useFloating(reference, floating, {
|
||||
placement: "top",
|
||||
|
||||
// pass options. Ensure the cleanup function is returned.
|
||||
whileElementsMounted: (reference, floating, update) =>
|
||||
autoUpdate(reference, floating, update, {
|
||||
animationFrame: true,
|
||||
}),
|
||||
middleware: [
|
||||
offset(5),
|
||||
shift(),
|
||||
flip(),
|
||||
|
||||
hide({
|
||||
strategy: "referenceHidden",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const handleRemove = () => {
|
||||
removeClanURI(clan_dir);
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="">
|
||||
<div class=" text-primary-800">
|
||||
<div class="">
|
||||
<Button
|
||||
size="s"
|
||||
variant="light"
|
||||
class=""
|
||||
onClick={() => navigate(`/clans/${window.btoa(clan_dir)}`)}
|
||||
endIcon={<Icon icon="Settings" />}
|
||||
></Button>
|
||||
<Button
|
||||
size="s"
|
||||
variant="light"
|
||||
class=" "
|
||||
onClick={() => {
|
||||
setActiveClanURI(clan_dir);
|
||||
}}
|
||||
>
|
||||
{activeClanURI() === clan_dir ? "active" : "select"}
|
||||
</Button>
|
||||
<Button
|
||||
size="s"
|
||||
variant="light"
|
||||
popovertarget={`clan-delete-popover-${clan_dir}`}
|
||||
popovertargetaction="toggle"
|
||||
ref={setReference}
|
||||
class=" "
|
||||
endIcon={<Icon icon="Trash" />}
|
||||
></Button>
|
||||
<div
|
||||
popover="auto"
|
||||
role="tooltip"
|
||||
id={`clan-delete-popover-${clan_dir}`}
|
||||
ref={setFloating}
|
||||
style={{
|
||||
position: position.strategy,
|
||||
top: `${position.y ?? 0}px`,
|
||||
left: `${position.x ?? 0}px`,
|
||||
}}
|
||||
class="m-0 bg-transparent"
|
||||
>
|
||||
<Button
|
||||
size="s"
|
||||
onClick={handleRemove}
|
||||
variant="dark"
|
||||
endIcon={<Icon icon="Trash" />}
|
||||
>
|
||||
Remove from App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class=""
|
||||
classList={{
|
||||
"": activeClanURI() === clan_dir,
|
||||
}}
|
||||
>
|
||||
{clan_dir}
|
||||
</div>
|
||||
|
||||
<Show when={details.isLoading}>
|
||||
<div class=" h-12 w-80" />
|
||||
</Show>
|
||||
<Show when={details.isSuccess}>
|
||||
<A href={`/clans/${window.btoa(clan_dir)}`}>
|
||||
<div class=" underline">{details.data?.name}</div>
|
||||
</A>
|
||||
</Show>
|
||||
<Show when={details.isSuccess && details.data?.description}>
|
||||
<div class=" text-lg">{details.data?.description}</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ClanList = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div class="">
|
||||
<div class="">
|
||||
<div class="">
|
||||
<div class=" text-2xl">Registered Clans</div>
|
||||
<div class="flex gap-2">
|
||||
<span class="" data-tip="Register clan">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={registerClan}
|
||||
startIcon={<Icon icon="List" />}
|
||||
></Button>
|
||||
</span>
|
||||
<span class="" data-tip="Create new clan">
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate("create");
|
||||
}}
|
||||
startIcon={<Icon icon="Plus" />}
|
||||
></Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class=" shadow">
|
||||
<For each={clanURIs()}>
|
||||
{(value) => <ClanItem clan_dir={value} />}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import { InputBase, InputLabel } from "@/src/components/inputBase";
|
||||
import { TextInput } from "@/src/Form/fields";
|
||||
import { Header } from "@/src/layout/header";
|
||||
import { createForm, required } from "@modular-forms/solid";
|
||||
|
||||
const disabled = [false, true];
|
||||
const readOnly = [false, true];
|
||||
const error = [false, true];
|
||||
|
||||
export const Components = () => {
|
||||
const [formStore, { Form, Field }] = createForm<{ ef: string }>({});
|
||||
return (
|
||||
<>
|
||||
<Header title="Components" />
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 p-4">
|
||||
<span class="col-span-2">Input </span>
|
||||
|
||||
<span>Default</span>
|
||||
<span>Size S</span>
|
||||
|
||||
{disabled.map((disabled) =>
|
||||
readOnly.map((readOnly) =>
|
||||
error.map((hasError) => (
|
||||
<>
|
||||
<span>
|
||||
{[
|
||||
disabled ? "Disabled" : "(default)",
|
||||
readOnly ? "ReadOnly" : "",
|
||||
hasError ? "Error" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" + ")}
|
||||
</span>
|
||||
<InputBase
|
||||
variant="outlined"
|
||||
value="The Fox jumps!"
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
readonly={readOnly}
|
||||
/>
|
||||
</>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
<span class="col-span-2">Input Ghost</span>
|
||||
{disabled.map((disabled) =>
|
||||
readOnly.map((readOnly) =>
|
||||
error.map((hasError) => (
|
||||
<>
|
||||
<span>
|
||||
{[
|
||||
disabled ? "Disabled" : "(default)",
|
||||
readOnly ? "ReadOnly" : "",
|
||||
hasError ? "Error" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" + ")}
|
||||
</span>
|
||||
<InputBase
|
||||
variant="ghost"
|
||||
value="The Fox jumps!"
|
||||
disabled={disabled}
|
||||
error={hasError}
|
||||
readonly={readOnly}
|
||||
/>
|
||||
</>
|
||||
)),
|
||||
),
|
||||
)}
|
||||
<span class="col-span-2">Input Label</span>
|
||||
<span>Default</span>
|
||||
<InputLabel>Labeltext</InputLabel>
|
||||
<span>Required</span>
|
||||
<InputLabel required>Labeltext</InputLabel>
|
||||
<span>Error</span>
|
||||
<InputLabel error>Labeltext</InputLabel>
|
||||
<span>Error + Reuired</span>
|
||||
<InputLabel error required>
|
||||
Labeltext
|
||||
</InputLabel>
|
||||
<span>Icon</span>
|
||||
<InputLabel help="Some Info">Labeltext</InputLabel>
|
||||
<span>Description</span>
|
||||
<InputLabel description="Some more words">Labeltext</InputLabel>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="col-span-full gap-4">Form Layout</span>
|
||||
<TextInput label="Label" value="Value" />
|
||||
<Form
|
||||
onSubmit={() => {
|
||||
console.log("Nothing");
|
||||
}}
|
||||
>
|
||||
<Field
|
||||
name="ef"
|
||||
validate={required(
|
||||
"This field is required very long descriptive error message",
|
||||
)}
|
||||
>
|
||||
{(field, inputProps) => (
|
||||
<TextInput
|
||||
label="Write something"
|
||||
error={field.error}
|
||||
required
|
||||
value={field.value || ""}
|
||||
inputProps={inputProps}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Button>Submit</Button>
|
||||
</Form>
|
||||
<TextInput label="Label" required value="Value" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,439 +0,0 @@
|
||||
import { callApi } from "@/src/api";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
// Icon is used in CustomFileField, ensure it's available or remove if not needed there
|
||||
import Icon from "@/src/components/icon";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import { Header } from "@/src/layout/header";
|
||||
import { SelectInput } from "@/src/Form/fields/Select";
|
||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
||||
import {
|
||||
createForm,
|
||||
required,
|
||||
FieldValues,
|
||||
setValue,
|
||||
getValue,
|
||||
getValues,
|
||||
} from "@modular-forms/solid";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { createEffect, createSignal } from "solid-js"; // For, Show might not be needed directly here now
|
||||
import toast from "solid-toast";
|
||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
||||
import { InputLabel } from "@/src/components/inputBase";
|
||||
|
||||
import Fieldset from "@/src/Form/fieldset"; // Still used for other fieldsets
|
||||
import Accordion from "@/src/components/accordion";
|
||||
|
||||
import { SimpleModal } from "@/src/components/SimpleModal";
|
||||
|
||||
// Import the new generic component
|
||||
import {
|
||||
FileSelectorField,
|
||||
type FileDialogOptions,
|
||||
} from "@/src/components/fileSelect"; // Adjust path
|
||||
|
||||
interface Wifi extends FieldValues {
|
||||
ssid: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface FlashFormValues extends FieldValues {
|
||||
machine: {
|
||||
devicePath: string;
|
||||
flake: string;
|
||||
};
|
||||
disk: string;
|
||||
language: string;
|
||||
keymap: string;
|
||||
wifi: Wifi[];
|
||||
sshKeys: File[]; // This field will use CustomFileField
|
||||
}
|
||||
|
||||
export const Flash = () => {
|
||||
const [formStore, { Form, Field }] = createForm<FlashFormValues>({
|
||||
initialValues: {
|
||||
machine: {
|
||||
flake: "git+https://git.clan.lol/clan/clan-core",
|
||||
devicePath: "flash-installer",
|
||||
},
|
||||
language: "en_US.UTF-8",
|
||||
keymap: "en",
|
||||
// sshKeys: [] // Initial value for sshKeys (optional, modular-forms handles undefined)
|
||||
},
|
||||
});
|
||||
|
||||
/* ==== WIFI NETWORK (logic remains the same) ==== */
|
||||
const [wifiNetworks, setWifiNetworks] = createSignal<Wifi[]>([]);
|
||||
const [passwordVisibility, setPasswordVisibility] = createSignal<boolean[]>(
|
||||
[],
|
||||
);
|
||||
createEffect(() => {
|
||||
const formWifi = getValue(formStore, "wifi");
|
||||
if (formWifi !== undefined) {
|
||||
setWifiNetworks(formWifi as Wifi[]);
|
||||
setPasswordVisibility(new Array(formWifi.length).fill(false));
|
||||
}
|
||||
});
|
||||
const addWifiNetwork = () => {
|
||||
setWifiNetworks((c) => {
|
||||
const res = [...c, { ssid: "", password: "" }];
|
||||
setValue(formStore, "wifi", res);
|
||||
return res;
|
||||
});
|
||||
setPasswordVisibility((c) => [...c, false]);
|
||||
};
|
||||
const removeWifiNetwork = (index: number) => {
|
||||
const updatedNetworks = wifiNetworks().filter((_, i) => i !== index);
|
||||
setWifiNetworks(updatedNetworks);
|
||||
const updatedVisibility = passwordVisibility().filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
setPasswordVisibility(updatedVisibility);
|
||||
setValue(formStore, "wifi", updatedNetworks);
|
||||
};
|
||||
const togglePasswordVisibility = (index: number) => {
|
||||
const updatedVisibility = [...passwordVisibility()];
|
||||
updatedVisibility[index] = !updatedVisibility[index];
|
||||
setPasswordVisibility(updatedVisibility);
|
||||
};
|
||||
/* ==== END OF WIFI NETWORK ==== */
|
||||
|
||||
const deviceQuery = createQuery(() => ({
|
||||
queryKey: ["block_devices"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("list_block_devices", {}).promise;
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
staleTime: 1000 * 60 * 1, // 1 minutes
|
||||
}));
|
||||
|
||||
const keymapQuery = createQuery(() => ({
|
||||
queryKey: ["list_keymaps"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("list_keymaps", {}).promise;
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
}));
|
||||
|
||||
const langQuery = createQuery(() => ({
|
||||
queryKey: ["list_languages"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("list_languages", {}).promise;
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
}));
|
||||
// Define the options for the SSH key file dialog
|
||||
const sshKeyDialogOptions: FileDialogOptions = {
|
||||
title: "Select SSH Public Key(s)",
|
||||
filters: { patterns: ["*.pub"] },
|
||||
initial_folder: "~/.ssh",
|
||||
};
|
||||
|
||||
const [confirmOpen, setConfirmOpen] = createSignal(false);
|
||||
const [isFlashing, setFlashing] = createSignal(false);
|
||||
|
||||
const handleSubmit = (values: FlashFormValues) => {
|
||||
// Basic check for sshKeys, could add to modular-forms validation
|
||||
if (!values.sshKeys || values.sshKeys.length === 0) {
|
||||
toast.error("Please select at least one SSH key.");
|
||||
return;
|
||||
}
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const values = getValues(formStore) as FlashFormValues;
|
||||
// Additional check, though handleSubmit should catch it
|
||||
if (!values.sshKeys || values.sshKeys.length === 0) {
|
||||
toast.error("SSH keys are missing. Cannot proceed with flash.");
|
||||
setConfirmOpen(false);
|
||||
return;
|
||||
}
|
||||
setFlashing(true);
|
||||
console.log("Confirmed flash:", values);
|
||||
try {
|
||||
await toast.promise(
|
||||
callApi("run_machine_flash", {
|
||||
machine: {
|
||||
name: values.machine.devicePath,
|
||||
flake: {
|
||||
identifier: values.machine.flake,
|
||||
},
|
||||
},
|
||||
mode: "format",
|
||||
disks: [{ name: "main", device: values.disk }],
|
||||
system_config: {
|
||||
language: values.language,
|
||||
keymap: values.keymap,
|
||||
// Ensure sshKeys is correctly mapped (File[] to string[])
|
||||
ssh_keys_path: values.sshKeys.map((file) => file.name),
|
||||
},
|
||||
dry_run: false,
|
||||
write_efi_boot_entries: false,
|
||||
debug: false,
|
||||
graphical: true,
|
||||
}).promise,
|
||||
{
|
||||
error: (errors) => `Error flashing disk: ${errors}`,
|
||||
loading: "Flashing ... This may take up to 15minutes.",
|
||||
success: "Disk flashed successfully",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(`Error could not flash disk: ${error}`);
|
||||
} finally {
|
||||
setFlashing(false);
|
||||
}
|
||||
setConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Flash installer" />
|
||||
<SimpleModal
|
||||
open={confirmOpen() || isFlashing()}
|
||||
onClose={() => !isFlashing() && setConfirmOpen(false)}
|
||||
title="Confirm"
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex flex-col justify-between rounded-sm border p-4 align-middle text-red-900 border-def-2">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
weight="medium"
|
||||
size="default"
|
||||
class="flex-wrap break-words pr-4"
|
||||
>
|
||||
Warning: All data will be lost.
|
||||
</Typography>
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
weight="bold"
|
||||
size="default"
|
||||
class="flex-wrap break-words pr-4"
|
||||
>
|
||||
Selected disk: '{getValue(formStore, "disk")}'
|
||||
</Typography>
|
||||
</div>
|
||||
<div class="flex w-full justify-between">
|
||||
<Button
|
||||
disabled={isFlashing()}
|
||||
variant="light"
|
||||
onClick={() => setConfirmOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isFlashing()} onClick={handleConfirm}>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SimpleModal>
|
||||
<div class="w-full self-stretch p-8">
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
|
||||
>
|
||||
<FileSelectorField
|
||||
Field={Field}
|
||||
name="sshKeys" // Corresponds to FlashFormValues.sshKeys
|
||||
label="Authorized SSH Keys"
|
||||
description="Provide your SSH public key(s) for secure, passwordless connections. (.pub files)"
|
||||
multiple={true} // Allow multiple SSH keys
|
||||
fileDialogOptions={sshKeyDialogOptions}
|
||||
of={Array<File>}
|
||||
// You could add custom validation via modular-forms 'validate' prop on CustomFileField if needed
|
||||
// e.g. validate={[required("At least one SSH key is required.")]}
|
||||
// This would require CustomFileField to accept and pass `validate` to its internal `Field`.
|
||||
/>
|
||||
|
||||
<Fieldset legend="General">
|
||||
<Field name="disk" validate={[required("This field is required")]}>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<SelectInput
|
||||
loading={deviceQuery.isFetching}
|
||||
selectProps={props}
|
||||
label="Flash Disk"
|
||||
labelProps={{
|
||||
labelAction: (
|
||||
<Button
|
||||
disabled={isFlashing()}
|
||||
class="ml-auto"
|
||||
variant="ghost"
|
||||
size="s"
|
||||
type="button"
|
||||
startIcon={<Icon icon="Update" />}
|
||||
onClick={() => deviceQuery.refetch()}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
value={field.value || ""}
|
||||
error={field.error}
|
||||
required
|
||||
placeholder="Select a drive"
|
||||
options={
|
||||
deviceQuery.data?.blockdevices.map((d) => ({
|
||||
value: d.path,
|
||||
label: `${d.path} -- ${d.size} bytes`,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</Fieldset>
|
||||
|
||||
<Fieldset legend="Network Settings">
|
||||
{/* ... Network settings as before ... */}
|
||||
<FieldLayout
|
||||
label={<InputLabel>Networks</InputLabel>}
|
||||
field={
|
||||
<div class="flex w-full justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="s"
|
||||
variant="light"
|
||||
onClick={addWifiNetwork}
|
||||
startIcon={<Icon size={12} icon="Plus" />}
|
||||
>
|
||||
WiFi Network
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{/* TODO: You would render the actual WiFi input fields here using a <For> loop over wifiNetworks() signal */}
|
||||
</Fieldset>
|
||||
|
||||
<Accordion title="Advanced">
|
||||
<Fieldset>
|
||||
<Field
|
||||
name="machine.flake"
|
||||
validate={[required("This field is required")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<TextInput
|
||||
inputProps={props}
|
||||
label="Source (flake URL)"
|
||||
value={String(field.value)}
|
||||
error={field.error}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field
|
||||
name="machine.devicePath"
|
||||
validate={[required("This field is required")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<TextInput
|
||||
inputProps={props}
|
||||
label="Image Name (attribute name)"
|
||||
value={String(field.value)}
|
||||
error={field.error}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<FieldLayout
|
||||
label={
|
||||
<InputLabel help="Computed reference">Source Url</InputLabel>
|
||||
}
|
||||
field={
|
||||
<InputLabel>
|
||||
{getValue(formStore, "machine.flake") +
|
||||
"#" +
|
||||
getValue(formStore, "machine.devicePath")}
|
||||
</InputLabel>
|
||||
}
|
||||
/>
|
||||
<hr class="mb-6"></hr>
|
||||
|
||||
<Field
|
||||
name="language"
|
||||
validate={[required("This field is required")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<SelectInput
|
||||
selectProps={props}
|
||||
label="Language"
|
||||
value={String(field.value)}
|
||||
error={field.error}
|
||||
required
|
||||
loading={langQuery.isLoading}
|
||||
options={[
|
||||
{
|
||||
label: "en_US.UTF-8",
|
||||
value: "en_US.UTF-8",
|
||||
},
|
||||
...(langQuery.data?.map((lang) => ({
|
||||
label: lang,
|
||||
value: lang,
|
||||
})) || []),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
name="keymap"
|
||||
validate={[required("This field is required")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<SelectInput
|
||||
selectProps={props}
|
||||
label="Keymap"
|
||||
value={String(field.value)}
|
||||
error={field.error}
|
||||
required
|
||||
loading={keymapQuery.isLoading}
|
||||
options={[
|
||||
{
|
||||
label: "en",
|
||||
value: "en",
|
||||
},
|
||||
...(keymapQuery.data?.map((keymap) => ({
|
||||
label: keymap,
|
||||
value: keymap,
|
||||
})) || []),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</Fieldset>
|
||||
</Accordion>
|
||||
|
||||
<div class="mt-2 flex justify-end pt-2">
|
||||
<Button
|
||||
class="self-end"
|
||||
type="submit"
|
||||
disabled={formStore.submitting || isFlashing()}
|
||||
startIcon={
|
||||
formStore.submitting || isFlashing() ? (
|
||||
<Icon icon="Load" />
|
||||
) : (
|
||||
<Icon icon="Flash" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{formStore.submitting || isFlashing()
|
||||
? "Flashing..."
|
||||
: "Flash Installer"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
import { type Component, createSignal, For, Show } from "solid-js";
|
||||
import { OperationResponse, callApi } from "@/src/api";
|
||||
import { Button } from "../../components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
type ServiceModel = Extract<
|
||||
OperationResponse<"list_mdns_services">,
|
||||
{ status: "success" }
|
||||
>["data"]["services"];
|
||||
|
||||
export const HostList: Component = () => {
|
||||
const [services, setServices] = createSignal<ServiceModel>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div class="" data-tip="Refresh install targets">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => callApi("list_mdns_services", {})}
|
||||
startIcon={<Icon icon="Update" />}
|
||||
></Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Show when={services()}>
|
||||
{(services) => (
|
||||
<For each={Object.values(services())}>
|
||||
{(service) => (
|
||||
<div class="w-[30rem] rounded-lg bg-white p-5 shadow-lg">
|
||||
<div class=" flex flex-col shadow">
|
||||
<div class="">
|
||||
<div class="">Host</div>
|
||||
<div class="">{service.host}</div>
|
||||
<div class=""></div>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<div class="">IP</div>
|
||||
<div class="">{service.ip}</div>
|
||||
<div class=""></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" w-full px-0">
|
||||
<div class=" ">
|
||||
<input type="radio" name="my-accordion-4" />
|
||||
<div class=" text-xl font-medium">Details</div>
|
||||
<div class="">
|
||||
<p>
|
||||
<span class="font-bold">Interface</span>
|
||||
{service.interface}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-bold">Protocol</span>
|
||||
{service.protocol}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="font-bold">Type</span>
|
||||
{service.type_}
|
||||
</p>
|
||||
<p>
|
||||
<span class="font-bold">Domain</span>
|
||||
{service.domain}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,263 +0,0 @@
|
||||
import { callApi, SuccessData } from "@/src/api";
|
||||
import {
|
||||
createForm,
|
||||
getValue,
|
||||
getValues,
|
||||
setValue,
|
||||
} from "@modular-forms/solid";
|
||||
import { createSignal, Match, Switch } from "solid-js";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
import { HWStep } from "../install/hardware-step";
|
||||
import { DiskStep } from "../install/disk-step";
|
||||
import { VarsStep } from "../install/vars-step";
|
||||
import { SummaryStep } from "../install/summary-step";
|
||||
import { InstallStepper } from "./InstallStepper";
|
||||
import { InstallStepNavigation } from "./InstallStepNavigation";
|
||||
import { InstallProgress } from "./InstallProgress";
|
||||
import { DiskValues } from "../install/disk-step";
|
||||
import { AllStepsValues } from "../types";
|
||||
import { ResponseData } from "@modular-forms/solid";
|
||||
|
||||
type MachineData = SuccessData<"get_machine_details">;
|
||||
type StepIdx = "1" | "2" | "3" | "4";
|
||||
|
||||
const INSTALL_STEPS = {
|
||||
HARDWARE: "1" as StepIdx,
|
||||
DISK: "2" as StepIdx,
|
||||
VARS: "3" as StepIdx,
|
||||
SUMMARY: "4" as StepIdx,
|
||||
} as const;
|
||||
|
||||
const PROGRESS_DELAYS = {
|
||||
INITIAL: 10 * 1000,
|
||||
BUILD: 10 * 1000,
|
||||
FORMAT: 10 * 1000,
|
||||
COPY: 20 * 1000,
|
||||
REBOOT: 10 * 1000,
|
||||
} as const;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
interface InstallMachineProps {
|
||||
name?: string;
|
||||
machine: MachineData;
|
||||
}
|
||||
|
||||
export function InstallMachine(props: InstallMachineProps) {
|
||||
const { activeClanURI } = useClanContext();
|
||||
const curr = activeClanURI();
|
||||
const { name } = props;
|
||||
|
||||
if (!curr || !name) {
|
||||
return <span>No Clan selected</span>;
|
||||
}
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<
|
||||
AllStepsValues,
|
||||
ResponseData
|
||||
>();
|
||||
const [isDone, setIsDone] = createSignal<boolean>(false);
|
||||
const [isInstalling, setIsInstalling] = createSignal<boolean>(false);
|
||||
const [progressText, setProgressText] = createSignal<string>();
|
||||
const [step, setStep] = createSignal<StepIdx>(INSTALL_STEPS.HARDWARE);
|
||||
|
||||
const nextStep = () => {
|
||||
const currentStepNum = parseInt(step());
|
||||
const nextStepNum = Math.min(currentStepNum + 1, 4);
|
||||
setStep(nextStepNum.toString() as StepIdx);
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
const currentStepNum = parseInt(step());
|
||||
const prevStepNum = Math.max(currentStepNum - 1, 1);
|
||||
setStep(prevStepNum.toString() as StepIdx);
|
||||
};
|
||||
|
||||
const isFirstStep = () => step() === INSTALL_STEPS.HARDWARE;
|
||||
const isLastStep = () => step() === INSTALL_STEPS.SUMMARY;
|
||||
|
||||
const handleInstall = async (values: AllStepsValues) => {
|
||||
const curr_uri = activeClanURI();
|
||||
const diskValues = values["2"];
|
||||
|
||||
if (!curr_uri || !props.name) {
|
||||
console.error("Missing clan URI or machine name");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsInstalling(true);
|
||||
|
||||
const shouldUpdateDisk =
|
||||
JSON.stringify(props.machine.disk_schema?.placeholders) !==
|
||||
JSON.stringify(diskValues.placeholders);
|
||||
|
||||
if (shouldUpdateDisk) {
|
||||
setProgressText("Setting up disk ... (1/5)");
|
||||
await callApi("set_machine_disk_schema", {
|
||||
machine: {
|
||||
flake: { identifier: curr_uri },
|
||||
name: props.name,
|
||||
},
|
||||
placeholders: diskValues.placeholders,
|
||||
schema_name: diskValues.schema,
|
||||
force: true,
|
||||
}).promise;
|
||||
}
|
||||
|
||||
setProgressText("Installing machine ... (2/5)");
|
||||
|
||||
const targetHostResponse = await callApi("get_host", {
|
||||
field: "targetHost",
|
||||
flake: { identifier: curr_uri },
|
||||
name: props.name,
|
||||
}).promise;
|
||||
|
||||
if (
|
||||
targetHostResponse.status === "error" ||
|
||||
!targetHostResponse.data?.data
|
||||
) {
|
||||
throw new Error("No target host found for the machine");
|
||||
}
|
||||
|
||||
const installPromise = callApi("run_machine_install", {
|
||||
opts: {
|
||||
machine: {
|
||||
name: props.name,
|
||||
flake: { identifier: curr_uri },
|
||||
},
|
||||
},
|
||||
target_host: targetHostResponse.data.data,
|
||||
});
|
||||
|
||||
await sleep(PROGRESS_DELAYS.INITIAL);
|
||||
setProgressText("Building machine ... (3/5)");
|
||||
await sleep(PROGRESS_DELAYS.BUILD);
|
||||
setProgressText("Formatting remote disk ... (4/5)");
|
||||
await sleep(PROGRESS_DELAYS.FORMAT);
|
||||
setProgressText("Copying system ... (5/5)");
|
||||
await sleep(PROGRESS_DELAYS.COPY);
|
||||
setProgressText("Rebooting remote system ...");
|
||||
await sleep(PROGRESS_DELAYS.REBOOT);
|
||||
|
||||
const installResponse = await installPromise;
|
||||
setIsDone(true);
|
||||
} catch (error) {
|
||||
console.error("Installation failed:", error);
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch
|
||||
fallback={
|
||||
<div class="flex min-h-screen flex-col gap-0">
|
||||
<InstallStepper currentStep={step()} />
|
||||
<Switch fallback={<div>Step not found</div>}>
|
||||
<Match when={step() === INSTALL_STEPS.HARDWARE}>
|
||||
<HWStep
|
||||
machine_id={props.name || ""}
|
||||
dir={activeClanURI() || ""}
|
||||
handleNext={(data) => {
|
||||
const prev = getValue(formStore, "1");
|
||||
setValue(formStore, "1", { ...prev, ...data });
|
||||
nextStep();
|
||||
}}
|
||||
initial={getValue(formStore, "1")}
|
||||
footer={
|
||||
<InstallStepNavigation
|
||||
currentStep={step()}
|
||||
isFirstStep={isFirstStep()}
|
||||
isLastStep={isLastStep()}
|
||||
onPrevious={prevStep}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match when={step() === INSTALL_STEPS.DISK}>
|
||||
<DiskStep
|
||||
machine_id={props.name || ""}
|
||||
dir={activeClanURI() || ""}
|
||||
footer={
|
||||
<InstallStepNavigation
|
||||
currentStep={step()}
|
||||
isFirstStep={isFirstStep()}
|
||||
isLastStep={isLastStep()}
|
||||
onPrevious={prevStep}
|
||||
/>
|
||||
}
|
||||
handleNext={(data) => {
|
||||
const prev = getValue(formStore, "2");
|
||||
setValue(formStore, "2", { ...prev, ...data });
|
||||
nextStep();
|
||||
}}
|
||||
initial={
|
||||
{
|
||||
placeholders: props.machine.disk_schema?.placeholders || {
|
||||
mainDisk: "",
|
||||
},
|
||||
schema: props.machine.disk_schema?.schema_name || "",
|
||||
schema_name: props.machine.disk_schema?.schema_name || "",
|
||||
...getValue(formStore, "2"),
|
||||
initialized: !!props.machine.disk_schema,
|
||||
} as DiskValues
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match when={step() === INSTALL_STEPS.VARS}>
|
||||
<VarsStep
|
||||
machine_id={props.name || ""}
|
||||
dir={activeClanURI() || ""}
|
||||
handleNext={(data) => {
|
||||
const prev = getValue(formStore, "3");
|
||||
setValue(formStore, "3", { ...prev, ...data });
|
||||
nextStep();
|
||||
}}
|
||||
initial={getValue(formStore, "3") || {}}
|
||||
footer={
|
||||
<InstallStepNavigation
|
||||
currentStep={step()}
|
||||
isFirstStep={isFirstStep()}
|
||||
isLastStep={isLastStep()}
|
||||
onPrevious={prevStep}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
<Match when={step() === INSTALL_STEPS.SUMMARY}>
|
||||
<SummaryStep
|
||||
machine_id={props.name || ""}
|
||||
dir={activeClanURI() || ""}
|
||||
handleNext={() => nextStep()}
|
||||
initial={getValues(formStore) as AllStepsValues}
|
||||
footer={
|
||||
<InstallStepNavigation
|
||||
currentStep={step()}
|
||||
isFirstStep={isFirstStep()}
|
||||
isLastStep={isLastStep()}
|
||||
onPrevious={prevStep}
|
||||
onInstall={() =>
|
||||
handleInstall(getValues(formStore) as AllStepsValues)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Match when={isInstalling()}>
|
||||
<InstallProgress
|
||||
machineName={props.name || ""}
|
||||
progressText={progressText()}
|
||||
isDone={isDone()}
|
||||
onCancel={() => setIsInstalling(false)}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
|
||||
const LoadingBar = () => (
|
||||
<div
|
||||
class="h-3 w-80 overflow-hidden rounded-[3px] border-2 border-def-1"
|
||||
style={{
|
||||
background: `repeating-linear-gradient(
|
||||
45deg,
|
||||
#ccc,
|
||||
#ccc 8px,
|
||||
#eee 8px,
|
||||
#eee 16px
|
||||
)`,
|
||||
animation: "slide 25s linear infinite",
|
||||
"background-size": "200% 100%",
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
|
||||
interface InstallProgressProps {
|
||||
machineName: string;
|
||||
progressText?: string;
|
||||
isDone: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function InstallProgress(props: InstallProgressProps) {
|
||||
return (
|
||||
<div class="flex h-96 w-[40rem] flex-col fg-inv-1">
|
||||
<div class="flex w-full gap-1 p-4 bg-inv-4">
|
||||
<Typography
|
||||
color="inherit"
|
||||
hierarchy="label"
|
||||
size="default"
|
||||
weight="medium"
|
||||
>
|
||||
Install:
|
||||
</Typography>
|
||||
<Typography
|
||||
color="inherit"
|
||||
hierarchy="label"
|
||||
size="default"
|
||||
weight="bold"
|
||||
>
|
||||
{props.machineName}
|
||||
</Typography>
|
||||
</div>
|
||||
<div class="flex h-full flex-col items-center gap-3 px-4 py-8 bg-inv-4 fg-inv-1">
|
||||
<Icon icon="ClanIcon" viewBox="0 0 72 89" class="size-20" />
|
||||
{props.isDone && <LoadingBar />}
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="default"
|
||||
weight="medium"
|
||||
color="inherit"
|
||||
>
|
||||
{props.progressText}
|
||||
</Typography>
|
||||
<Button onClick={props.onCancel}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
interface InstallStepNavigationProps {
|
||||
currentStep: string;
|
||||
isFirstStep: boolean;
|
||||
isLastStep: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext?: () => void;
|
||||
onInstall?: () => void;
|
||||
}
|
||||
|
||||
export function InstallStepNavigation(props: InstallStepNavigationProps) {
|
||||
return (
|
||||
<div class="flex justify-between p-4">
|
||||
<Button
|
||||
startIcon={<Icon icon="ArrowLeft" />}
|
||||
variant="light"
|
||||
type="button"
|
||||
onClick={props.onPrevious}
|
||||
disabled={props.isFirstStep}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{props.isLastStep ? (
|
||||
<Button startIcon={<Icon icon="Flash" />} onClick={props.onInstall}>
|
||||
Install
|
||||
</Button>
|
||||
) : (
|
||||
<Button endIcon={<Icon icon="ArrowRight" />} type="submit">
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { For, Show } from "solid-js";
|
||||
import cx from "classnames";
|
||||
import Icon from "@/src/components/icon";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
|
||||
const steps: Record<string, string> = {
|
||||
"1": "Hardware detection",
|
||||
"2": "Disk schema",
|
||||
"3": "Credentials & Data",
|
||||
"4": "Installation",
|
||||
};
|
||||
|
||||
interface InstallStepperProps {
|
||||
currentStep: string;
|
||||
}
|
||||
|
||||
export function InstallStepper(props: InstallStepperProps) {
|
||||
return (
|
||||
<div class="flex items-center justify-evenly gap-2 border py-3 bg-def-3 border-def-2">
|
||||
<For each={Object.entries(steps)}>
|
||||
{([idx, label]) => (
|
||||
<div class="flex flex-col items-center gap-3 fg-def-1">
|
||||
<Typography
|
||||
classList={{
|
||||
[cx("bg-inv-4 fg-inv-1")]: idx === props.currentStep,
|
||||
[cx("bg-def-4 fg-def-1")]: idx < props.currentStep,
|
||||
}}
|
||||
color="inherit"
|
||||
hierarchy="label"
|
||||
size="default"
|
||||
weight="bold"
|
||||
class="flex size-6 items-center justify-center rounded-full text-center align-middle bg-def-1"
|
||||
>
|
||||
<Show
|
||||
when={idx >= props.currentStep}
|
||||
fallback={<Icon icon="Checkmark" class="size-5" />}
|
||||
>
|
||||
{idx}
|
||||
</Show>
|
||||
</Typography>
|
||||
<Typography
|
||||
color="inherit"
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
class="text-center align-top fg-def-3"
|
||||
classList={{
|
||||
[cx("!fg-def-1")]: idx == props.currentStep,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
interface MachineActionsBarProps {
|
||||
machineName: string;
|
||||
onInstall: () => void;
|
||||
onUpdate: () => void;
|
||||
onCredentials: () => void;
|
||||
}
|
||||
|
||||
export function MachineActionsBar(props: MachineActionsBarProps) {
|
||||
return (
|
||||
<div class="sticky top-0 flex items-center justify-end gap-2 border-b border-secondary-100 bg-secondary-50 px-4 py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="button-group flex min-w-0 shrink-0">
|
||||
<Button
|
||||
variant="light"
|
||||
class="min-w-0 flex-1"
|
||||
size="s"
|
||||
onClick={props.onInstall}
|
||||
endIcon={<Icon size={14} icon="Flash" />}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
class="min-w-0 flex-1"
|
||||
size="s"
|
||||
onClick={props.onUpdate}
|
||||
endIcon={<Icon size={14} icon="Update" />}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
class="min-w-0 flex-1"
|
||||
size="s"
|
||||
onClick={props.onCredentials}
|
||||
endIcon={<Icon size={14} icon="Folder" />}
|
||||
>
|
||||
Vars
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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="">
|
||||
<div
|
||||
class={cx(
|
||||
"rounded-lg border p-2 bg-def-1 border-def-3 h-fit",
|
||||
props.class,
|
||||
)}
|
||||
>
|
||||
<RndThumbnail name={props.name || ""} height={120} width={220} />
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
@@ -1,214 +0,0 @@
|
||||
import { callApi, OperationResponse } from "@/src/api";
|
||||
import { createForm, ResponseData } from "@modular-forms/solid";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/solid-query";
|
||||
import { createSignal } from "solid-js";
|
||||
import { Button } from "@/src/components/Button/Button";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
import { MachineAvatar } from "./MachineAvatar";
|
||||
import toast from "solid-toast";
|
||||
import { MachineActionsBar } from "./MachineActionsBar";
|
||||
import { MachineGeneralFields } from "./MachineGeneralFields";
|
||||
import { MachineHardwareInfo } from "./MachineHardwareInfo";
|
||||
|
||||
type DetailedMachineType = Extract<
|
||||
OperationResponse<"get_machine_details">,
|
||||
{ status: "success" }
|
||||
>["data"];
|
||||
|
||||
interface MachineFormProps {
|
||||
detailed: DetailedMachineType;
|
||||
}
|
||||
|
||||
export function MachineForm(props: MachineFormProps) {
|
||||
const { detailed } = props;
|
||||
|
||||
const [formStore, { Form, Field }] = createForm<
|
||||
DetailedMachineType,
|
||||
ResponseData
|
||||
>({
|
||||
initialValues: detailed,
|
||||
});
|
||||
|
||||
const [isUpdating, setIsUpdating] = createSignal(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { activeClanURI } = useClanContext();
|
||||
|
||||
const handleSubmit = async (values: DetailedMachineType) => {
|
||||
console.log("submitting", values);
|
||||
|
||||
const curr_uri = activeClanURI();
|
||||
if (!curr_uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
await callApi("set_machine", {
|
||||
machine: {
|
||||
name: detailed.machine.name || "My machine",
|
||||
flake: {
|
||||
identifier: curr_uri,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
...values.machine,
|
||||
tags: Array.from(values.machine.tags || detailed.machine.tags || []),
|
||||
},
|
||||
}).promise;
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [
|
||||
curr_uri,
|
||||
"machine",
|
||||
detailed.machine.name,
|
||||
"get_machine_details",
|
||||
],
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const generatorsQuery = useQuery(() => ({
|
||||
queryKey: [activeClanURI(), detailed.machine.name, "generators"],
|
||||
queryFn: async () => {
|
||||
const machine_name = detailed.machine.name;
|
||||
const base_dir = activeClanURI();
|
||||
if (!machine_name || !base_dir) {
|
||||
return [];
|
||||
}
|
||||
const result = await callApi(
|
||||
"get_generators_closure",
|
||||
{
|
||||
base_dir: base_dir,
|
||||
machine_name: machine_name,
|
||||
},
|
||||
{
|
||||
logging: {
|
||||
group_path: ["clans", base_dir, "machines", machine_name],
|
||||
},
|
||||
},
|
||||
).promise;
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
|
||||
const handleUpdateButton = async () => {
|
||||
await generatorsQuery.refetch();
|
||||
|
||||
if (
|
||||
generatorsQuery.data?.some((generator) => generator.prompts?.length !== 0)
|
||||
) {
|
||||
navigate(`/machines/${detailed.machine.name || ""}/vars?action=update`);
|
||||
} else {
|
||||
handleUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (isUpdating()) {
|
||||
return;
|
||||
}
|
||||
const curr_uri = activeClanURI();
|
||||
if (!curr_uri) {
|
||||
return;
|
||||
}
|
||||
const machine = detailed.machine.name;
|
||||
if (!machine) {
|
||||
toast.error("Machine is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const target = await callApi(
|
||||
"get_host",
|
||||
{
|
||||
field: "targetHost",
|
||||
name: machine,
|
||||
flake: {
|
||||
identifier: curr_uri,
|
||||
},
|
||||
},
|
||||
{
|
||||
logging: {
|
||||
group_path: ["clans", curr_uri, "machines", machine],
|
||||
},
|
||||
},
|
||||
).promise;
|
||||
|
||||
if (target.status === "error") {
|
||||
toast.error("Failed to get target host");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.data) {
|
||||
toast.error("Target host is required");
|
||||
return;
|
||||
}
|
||||
const target_host = target.data.data;
|
||||
|
||||
setIsUpdating(true);
|
||||
const r = await callApi(
|
||||
"run_machine_deploy",
|
||||
{
|
||||
machine: {
|
||||
name: machine,
|
||||
flake: {
|
||||
identifier: curr_uri,
|
||||
},
|
||||
},
|
||||
target_host: {
|
||||
...target_host,
|
||||
},
|
||||
build_host: null,
|
||||
},
|
||||
{
|
||||
logging: {
|
||||
group_path: ["clans", curr_uri, "machines", machine],
|
||||
},
|
||||
},
|
||||
).promise.finally(() => {
|
||||
setIsUpdating(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="flex flex-col gap-6">
|
||||
<MachineActionsBar
|
||||
machineName={detailed.machine.name || ""}
|
||||
onInstall={() =>
|
||||
navigate(`/machines/${detailed.machine.name || ""}/install`)
|
||||
}
|
||||
onUpdate={handleUpdateButton}
|
||||
onCredentials={() =>
|
||||
navigate(`/machines/${detailed.machine.name || ""}/vars`)
|
||||
}
|
||||
/>
|
||||
|
||||
<div class="p-4">
|
||||
<span class="mb-2 flex w-full justify-center">
|
||||
<MachineAvatar name={detailed.machine.name || ""} />
|
||||
</span>
|
||||
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
|
||||
>
|
||||
<MachineGeneralFields formStore={formStore} />
|
||||
<MachineHardwareInfo formStore={formStore} />
|
||||
|
||||
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formStore.submitting || !formStore.dirty}
|
||||
>
|
||||
Update edits
|
||||
</Button>
|
||||
</footer>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
Field,
|
||||
FormStore,
|
||||
ResponseData,
|
||||
FieldStore,
|
||||
FieldElementProps,
|
||||
} from "@modular-forms/solid";
|
||||
import { TextInput } from "@/src/Form/fields/TextInput";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import { TagList } from "@/src/components/TagList/TagList";
|
||||
import Fieldset from "@/src/Form/fieldset";
|
||||
import { SuccessData } from "@/src/api";
|
||||
|
||||
type MachineData = SuccessData<"get_machine_details">;
|
||||
|
||||
interface MachineGeneralFieldsProps {
|
||||
formStore: FormStore<MachineData, ResponseData>;
|
||||
}
|
||||
|
||||
export function MachineGeneralFields(props: MachineGeneralFieldsProps) {
|
||||
const { formStore } = props;
|
||||
|
||||
return (
|
||||
<Fieldset legend="General">
|
||||
<Field name="machine.name" of={formStore}>
|
||||
{(
|
||||
field: FieldStore<MachineData, "machine.name">,
|
||||
fieldProps: FieldElementProps<MachineData, "machine.name">,
|
||||
) => {
|
||||
return (
|
||||
<TextInput
|
||||
inputProps={fieldProps}
|
||||
label="Name"
|
||||
value={field.value ?? ""}
|
||||
error={field.error}
|
||||
class="col-span-2"
|
||||
required
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Field>
|
||||
|
||||
<Field name="machine.description" of={formStore}>
|
||||
{(
|
||||
field: FieldStore<MachineData, "machine.description">,
|
||||
fieldProps: FieldElementProps<MachineData, "machine.description">,
|
||||
) => (
|
||||
<TextInput
|
||||
inputProps={fieldProps}
|
||||
label="Description"
|
||||
value={field.value ?? ""}
|
||||
error={field.error}
|
||||
class="col-span-2"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="machine.tags" of={formStore} type="string[]">
|
||||
{(
|
||||
field: FieldStore<MachineData, "machine.tags">,
|
||||
fieldProps: FieldElementProps<MachineData, "machine.tags">,
|
||||
) => (
|
||||
<div class="grid grid-cols-10 items-center">
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="default"
|
||||
weight="bold"
|
||||
class="col-span-5"
|
||||
>
|
||||
Tags{" "}
|
||||
</Typography>
|
||||
<div class="col-span-5 justify-self-end">
|
||||
<TagList values={[...(field.value || [])].sort()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
Field,
|
||||
FormStore,
|
||||
ResponseData,
|
||||
FieldStore,
|
||||
FieldElementProps,
|
||||
} from "@modular-forms/solid";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import { InputLabel } from "@/src/components/inputBase";
|
||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
||||
import Fieldset from "@/src/Form/fieldset";
|
||||
import { SuccessData } from "@/src/api";
|
||||
|
||||
type MachineData = SuccessData<"get_machine_details">;
|
||||
|
||||
interface MachineHardwareInfoProps {
|
||||
formStore: FormStore<MachineData, ResponseData>;
|
||||
}
|
||||
export function MachineHardwareInfo(props: MachineHardwareInfoProps) {
|
||||
const { formStore } = props;
|
||||
|
||||
return (
|
||||
<Typography hierarchy={"body"} size={"s"}>
|
||||
<Fieldset>
|
||||
<Field name="hw_config" of={formStore}>
|
||||
{(
|
||||
field: FieldStore<MachineData, "hw_config">,
|
||||
fieldProps: FieldElementProps<MachineData, "hw_config">,
|
||||
) => (
|
||||
<FieldLayout
|
||||
label={<InputLabel>Hardware Configuration</InputLabel>}
|
||||
field={<span>{field.value || "None"}</span>}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<hr />
|
||||
<Field name="disk_schema.schema_name" of={formStore}>
|
||||
{(
|
||||
field: FieldStore<MachineData, "disk_schema.schema_name">,
|
||||
fieldProps: FieldElementProps<
|
||||
MachineData,
|
||||
"disk_schema.schema_name"
|
||||
>,
|
||||
) => (
|
||||
<FieldLayout
|
||||
label={<InputLabel>Disk schema</InputLabel>}
|
||||
field={<span>{field.value || "None"}</span>}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Fieldset>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { InstallMachine } from "./InstallMachine";
|
||||
export { MachineAvatar } from "./MachineAvatar";
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./machine-details";
|
||||
export * from "./machine-create";
|
||||
export * from "./machines-list";
|
||||
export * from "./machine-install";
|
||||
export * from "./types";
|
||||
@@ -1,126 +0,0 @@
|
||||
import { callApi } from "@/src/api";
|
||||
import {
|
||||
createForm,
|
||||
SubmitHandler,
|
||||
validate,
|
||||
required,
|
||||
FieldValues,
|
||||
} from "@modular-forms/solid";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { StepProps } from "./hardware-step";
|
||||
import { SelectInput } from "@/src/Form/fields/Select";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import { Group } from "@/src/components/group";
|
||||
|
||||
export interface DiskValues extends FieldValues {
|
||||
placeholders: {
|
||||
mainDisk: string;
|
||||
};
|
||||
schema: string;
|
||||
initialized: boolean;
|
||||
}
|
||||
export const DiskStep = (props: StepProps<DiskValues>) => {
|
||||
const [formStore, { Form, Field }] = createForm<DiskValues>({
|
||||
initialValues: { ...props.initial, schema: "single-disk" },
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<DiskValues> = async (values, event) => {
|
||||
console.log("Submit Disk", { values });
|
||||
const valid = await validate(formStore);
|
||||
console.log("Valid", valid);
|
||||
if (!valid) return;
|
||||
props.handleNext(values);
|
||||
};
|
||||
|
||||
const diskSchemaQuery = createQuery(() => ({
|
||||
queryKey: [props.dir, props.machine_id, "disk_schemas"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("get_disk_schemas", {
|
||||
machine: {
|
||||
flake: {
|
||||
identifier: props.dir,
|
||||
},
|
||||
name: props.machine_id,
|
||||
},
|
||||
}).promise;
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
class="flex flex-col gap-6"
|
||||
noValidate={false}
|
||||
>
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
||||
<div class="flex h-full flex-col gap-6 p-4">
|
||||
<span class="flex flex-col gap-4">
|
||||
<Field
|
||||
name="schema"
|
||||
validate={required("Schema must be provided")}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="default"
|
||||
weight="bold"
|
||||
class="capitalize"
|
||||
>
|
||||
{(field.value || "No schema selected")
|
||||
.split("-")
|
||||
.join(" ")}
|
||||
</Typography>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
class="underline"
|
||||
>
|
||||
Change schema
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</span>
|
||||
<Group>
|
||||
{props.initial?.initialized &&
|
||||
"Disk has been initialized already"}
|
||||
<Field
|
||||
name="placeholders.mainDisk"
|
||||
validate={
|
||||
!props.initial?.initialized
|
||||
? required("Disk must be provided")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<SelectInput
|
||||
loading={diskSchemaQuery.isFetching}
|
||||
options={
|
||||
diskSchemaQuery.data?.["single-disk"].placeholders[
|
||||
"mainDisk"
|
||||
].options?.map((o) => ({ label: o, value: o })) || [
|
||||
{ label: "No options", value: "" },
|
||||
]
|
||||
}
|
||||
error={field.error}
|
||||
label="Main Disk"
|
||||
value={field.value || ""}
|
||||
placeholder="Select a disk"
|
||||
selectProps={fieldProps}
|
||||
required={!props.initial?.initialized}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
{props.footer}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,261 +0,0 @@
|
||||
import { callApi } from "@/src/api";
|
||||
import { Button } from "../../../components/Button/Button";
|
||||
import Icon from "@/src/components/icon";
|
||||
import { InputError, InputLabel } from "@/src/components/inputBase";
|
||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
||||
import {
|
||||
createForm,
|
||||
FieldValues,
|
||||
required,
|
||||
setValue,
|
||||
submit,
|
||||
SubmitHandler,
|
||||
validate,
|
||||
} from "@modular-forms/solid";
|
||||
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
|
||||
import { useQuery } from "@tanstack/solid-query";
|
||||
import { Badge } from "@/src/components/badge";
|
||||
import { Group } from "@/src/components/group";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
import { RemoteForm, type RemoteData } from "@/src/components/RemoteForm";
|
||||
|
||||
export type HardwareValues = FieldValues & {
|
||||
report: boolean;
|
||||
target: string;
|
||||
remoteData: RemoteData;
|
||||
};
|
||||
|
||||
export interface StepProps<T> {
|
||||
machine_id: string;
|
||||
dir: string;
|
||||
handleNext: (data: T) => void;
|
||||
footer: JSX.Element;
|
||||
initial?: T;
|
||||
}
|
||||
export const HWStep = (props: StepProps<HardwareValues>) => {
|
||||
const [formStore, { Form, Field }] = createForm<HardwareValues>({
|
||||
initialValues: (props.initial as HardwareValues) || {},
|
||||
});
|
||||
|
||||
// Initialize remote data from existing target or create new default
|
||||
const [remoteData, setRemoteData] = createSignal<RemoteData>({
|
||||
address: props.initial?.target || "",
|
||||
user: "root",
|
||||
command_prefix: "sudo",
|
||||
port: 22,
|
||||
forward_agent: false,
|
||||
host_key_check: "ask", // 0 = ASK
|
||||
verbose_ssh: false,
|
||||
ssh_options: {},
|
||||
tor_socks: false,
|
||||
});
|
||||
|
||||
const handleSubmit: SubmitHandler<HardwareValues> = async (values, event) => {
|
||||
console.log("Submit Hardware", { values });
|
||||
const valid = await validate(formStore);
|
||||
console.log("Valid", valid);
|
||||
if (!valid) return;
|
||||
|
||||
// Include remote data in the values
|
||||
const submitValues = {
|
||||
...values,
|
||||
remoteData: remoteData(),
|
||||
target: remoteData().address, // Keep target for backward compatibility
|
||||
};
|
||||
|
||||
props.handleNext(submitValues);
|
||||
};
|
||||
|
||||
const [isGenerating, setIsGenerating] = createSignal(false);
|
||||
|
||||
const hwReportQuery = useQuery(() => ({
|
||||
queryKey: [props.dir, props.machine_id, "hw_report"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("get_machine_hardware_summary", {
|
||||
machine: {
|
||||
flake: {
|
||||
identifier: props.dir,
|
||||
},
|
||||
name: props.machine_id,
|
||||
},
|
||||
}).promise;
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
// Workaround to set the form state
|
||||
createEffect(() => {
|
||||
const report = hwReportQuery.data;
|
||||
if (report === "nixos-facter" || report === "nixos-generate-config") {
|
||||
setValue(formStore, "report", true);
|
||||
}
|
||||
});
|
||||
|
||||
const { activeClanURI } = useClanContext();
|
||||
|
||||
const generateReport = async (e: Event) => {
|
||||
const currentRemoteData = remoteData();
|
||||
if (!currentRemoteData.address) {
|
||||
console.error("Target address is not set");
|
||||
return;
|
||||
}
|
||||
|
||||
const active_clan = activeClanURI();
|
||||
if (!active_clan) {
|
||||
console.error("No active clan selected");
|
||||
return;
|
||||
}
|
||||
|
||||
const target_host = await callApi("get_host", {
|
||||
field: "targetHost",
|
||||
flake: { identifier: active_clan },
|
||||
name: props.machine_id,
|
||||
}).promise;
|
||||
|
||||
if (target_host.status == "error") {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
if (target_host.data === null) {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target_host.data!.data) {
|
||||
console.error("No target host found for the machine");
|
||||
return;
|
||||
}
|
||||
|
||||
const r = await callApi("run_machine_hardware_info", {
|
||||
opts: {
|
||||
machine: {
|
||||
name: props.machine_id,
|
||||
flake: {
|
||||
identifier: active_clan,
|
||||
},
|
||||
},
|
||||
backend: "nixos-facter",
|
||||
},
|
||||
target_host: target_host.data!.data,
|
||||
});
|
||||
|
||||
// TODO: refresh the machine details
|
||||
|
||||
hwReportQuery.refetch();
|
||||
submit(formStore);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} class="flex flex-col gap-6">
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
||||
<div class="flex h-full flex-col gap-6 p-4">
|
||||
<Group>
|
||||
<RemoteForm
|
||||
showSave={false}
|
||||
machine={{
|
||||
name: props.machine_id,
|
||||
flake: {
|
||||
identifier: props.dir,
|
||||
},
|
||||
}}
|
||||
field="targetHost"
|
||||
/>
|
||||
{/* Hidden field for form validation */}
|
||||
<Field name="target">
|
||||
{(field, fieldProps) => (
|
||||
<input
|
||||
{...fieldProps}
|
||||
type="hidden"
|
||||
value={remoteData().address}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Group>
|
||||
<Group>
|
||||
<Field
|
||||
name="report"
|
||||
type="boolean"
|
||||
validate={required("Report must be generated")}
|
||||
>
|
||||
{(field, fieldProps) => (
|
||||
<FieldLayout
|
||||
error={field.error && <InputError error={field.error} />}
|
||||
label={
|
||||
<InputLabel
|
||||
required
|
||||
help="Detect hardware specific drivers from target ip"
|
||||
>
|
||||
Hardware report
|
||||
</InputLabel>
|
||||
}
|
||||
field={
|
||||
<Switch>
|
||||
<Match when={hwReportQuery.isLoading}>
|
||||
<div>Loading...</div>
|
||||
</Match>
|
||||
<Match when={hwReportQuery.error}>
|
||||
<div>Error...</div>
|
||||
</Match>
|
||||
<Match when={hwReportQuery.data}>
|
||||
{(data) => (
|
||||
<>
|
||||
<Switch>
|
||||
<Match when={data() === "none"}>
|
||||
<Badge color="red" icon="Attention">
|
||||
No report
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isGenerating()}
|
||||
startIcon={<Icon icon="Report" />}
|
||||
class="w-full"
|
||||
onClick={generateReport}
|
||||
>
|
||||
Run hardware detection
|
||||
</Button>
|
||||
</Match>
|
||||
<Match when={data() === "nixos-facter"}>
|
||||
<Badge color="primary" icon="Checkmark">
|
||||
Report detected
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isGenerating()}
|
||||
startIcon={<Icon icon="Report" />}
|
||||
class="w-full"
|
||||
onClick={generateReport}
|
||||
>
|
||||
Re-run hardware detection
|
||||
</Button>
|
||||
</Match>
|
||||
<Match when={data() === "nixos-generate-config"}>
|
||||
<Badge color="primary" icon="Checkmark">
|
||||
Legacy Report detected
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isGenerating()}
|
||||
startIcon={<Icon icon="Report" />}
|
||||
class="w-full"
|
||||
onClick={generateReport}
|
||||
>
|
||||
Replace hardware detection
|
||||
</Button>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</Group>
|
||||
</div>
|
||||
</div>
|
||||
{props.footer}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
import { StepProps } from "./hardware-step";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import { FieldLayout } from "@/src/Form/fields/layout";
|
||||
import { InputLabel } from "@/src/components/inputBase";
|
||||
import { Group, Section, SectionHeader } from "@/src/components/group";
|
||||
import { AllStepsValues } from "../types";
|
||||
import { Badge } from "@/src/components/badge";
|
||||
import Icon from "@/src/components/icon";
|
||||
|
||||
export const SummaryStep = (props: StepProps<AllStepsValues>) => {
|
||||
const hwValues = () => props.initial?.["1"];
|
||||
const diskValues = () => props.initial?.["2"];
|
||||
return (
|
||||
<>
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
||||
<div class="flex h-full flex-col gap-6 p-4">
|
||||
<Section>
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
class="uppercase"
|
||||
>
|
||||
Hardware Report
|
||||
</Typography>
|
||||
<Group>
|
||||
<FieldLayout
|
||||
label={<InputLabel>Detected</InputLabel>}
|
||||
field={
|
||||
hwValues()?.report ? (
|
||||
<Badge color="green" class="w-fit">
|
||||
<Icon icon="Checkmark" color="inherit" />
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="red" class="w-fit">
|
||||
<Icon icon="Warning" color="inherit" />
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
></FieldLayout>
|
||||
<FieldLayout
|
||||
label={<InputLabel>Target</InputLabel>}
|
||||
field={
|
||||
<Typography hierarchy="body" size="xs" weight="bold">
|
||||
{hwValues()?.target}
|
||||
</Typography>
|
||||
}
|
||||
></FieldLayout>
|
||||
</Group>
|
||||
</Section>
|
||||
<Section>
|
||||
<Typography
|
||||
hierarchy="label"
|
||||
size="xs"
|
||||
weight="medium"
|
||||
class="uppercase"
|
||||
>
|
||||
Disk Configuration
|
||||
</Typography>
|
||||
<Group>
|
||||
<FieldLayout
|
||||
label={<InputLabel>Disk Layout</InputLabel>}
|
||||
field={
|
||||
<Typography hierarchy="body" size="xs" weight="bold">
|
||||
{diskValues()?.schema}
|
||||
</Typography>
|
||||
}
|
||||
></FieldLayout>
|
||||
<hr class="h-px w-full border-none bg-def-acc-3"></hr>
|
||||
<FieldLayout
|
||||
label={<InputLabel>Main Disk</InputLabel>}
|
||||
field={
|
||||
<Typography hierarchy="body" size="xs" weight="bold">
|
||||
{diskValues()?.placeholders.mainDisk}
|
||||
</Typography>
|
||||
}
|
||||
></FieldLayout>
|
||||
</Group>
|
||||
</Section>
|
||||
<SectionHeader
|
||||
variant="danger"
|
||||
headline={
|
||||
<span>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="bold"
|
||||
color="inherit"
|
||||
>
|
||||
Setup your device.
|
||||
</Typography>
|
||||
<Typography
|
||||
hierarchy="body"
|
||||
size="s"
|
||||
weight="medium"
|
||||
color="inherit"
|
||||
>
|
||||
This will erase the disk and bootstrap fresh.
|
||||
</Typography>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.footer}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,259 +0,0 @@
|
||||
import { callApi, SuccessData } from "@/src/api";
|
||||
import {
|
||||
createForm,
|
||||
FieldValues,
|
||||
SubmitHandler,
|
||||
validate,
|
||||
} from "@modular-forms/solid";
|
||||
import { createQuery, useQueryClient } from "@tanstack/solid-query";
|
||||
import { Typography } from "@/src/components/Typography";
|
||||
import { Group } from "@/src/components/group";
|
||||
import { For, JSX, Match, Show, Switch } from "solid-js";
|
||||
import { TextInput } from "@/src/Form/fields";
|
||||
import toast from "solid-toast";
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
|
||||
import { StepProps } from "./hardware-step";
|
||||
import { BackButton } from "@/src/components/BackButton";
|
||||
import { Button } from "../../../components/Button/Button";
|
||||
import { useClanContext } from "@/src/contexts/clan";
|
||||
|
||||
export type VarsValues = FieldValues & Record<string, Record<string, string>>;
|
||||
|
||||
interface VarsFormProps {
|
||||
machine_id: string;
|
||||
dir: string;
|
||||
handleSubmit: SubmitHandler<VarsValues>;
|
||||
generators: SuccessData<"get_generators_closure">;
|
||||
footer: JSX.Element;
|
||||
}
|
||||
|
||||
const VarsForm = (props: VarsFormProps) => {
|
||||
const [formStore, { Form, Field }] = createForm<VarsValues>({});
|
||||
|
||||
const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => {
|
||||
console.log("Submit Vars", { values });
|
||||
// sanitize the values back (replace __dot__)
|
||||
// This hack is needed because we are using "." in the keys of the form
|
||||
const sanitizedValues = Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => [
|
||||
key.replaceAll("__dot__", "."),
|
||||
Object.fromEntries(
|
||||
Object.entries(value).map(([k, v]) => [
|
||||
k.replaceAll("__dot__", "."),
|
||||
v,
|
||||
]),
|
||||
),
|
||||
]),
|
||||
) as VarsValues;
|
||||
const valid = await validate(formStore);
|
||||
if (!valid) {
|
||||
toast.error("Please fill all required fields");
|
||||
return;
|
||||
}
|
||||
props.handleSubmit(sanitizedValues, event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
class="flex h-full flex-col gap-6"
|
||||
noValidate={false}
|
||||
>
|
||||
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
|
||||
<div class="flex h-full flex-col gap-6 p-4">
|
||||
<Show
|
||||
when={props.generators.length > 0}
|
||||
fallback="No credentials needed"
|
||||
>
|
||||
<For each={props.generators}>
|
||||
{(generator) => (
|
||||
<Group>
|
||||
<Typography hierarchy="label" size="default">
|
||||
{generator.name}
|
||||
</Typography>
|
||||
<div>
|
||||
Bound to module (shared):{" "}
|
||||
{generator.share ? "True" : "False"}
|
||||
</div>
|
||||
<For each={generator.prompts}>
|
||||
{(prompt) => (
|
||||
<Group>
|
||||
<Typography hierarchy="label" size="s">
|
||||
{!prompt.previous_value ? "Required" : "Optional"}
|
||||
</Typography>
|
||||
<Typography hierarchy="label" size="s">
|
||||
{prompt.name}
|
||||
</Typography>
|
||||
<Field
|
||||
// Avoid nesting issue in case of a "."
|
||||
name={`${generator.name.replaceAll(
|
||||
".",
|
||||
"__dot__",
|
||||
)}.${prompt.name.replaceAll(".", "__dot__")}`}
|
||||
>
|
||||
{(field, props) => (
|
||||
<Switch
|
||||
fallback={
|
||||
<TextInput
|
||||
inputProps={{
|
||||
...props,
|
||||
type:
|
||||
prompt.prompt_type === "hidden"
|
||||
? "password"
|
||||
: "text",
|
||||
}}
|
||||
label={prompt.description}
|
||||
value={prompt.previous_value ?? ""}
|
||||
error={field.error}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Match
|
||||
when={
|
||||
prompt.prompt_type === "multiline" ||
|
||||
prompt.prompt_type === "multiline-hidden"
|
||||
}
|
||||
>
|
||||
<textarea
|
||||
{...props}
|
||||
class="h-32 w-full rounded-md border border-gray-300 p-2"
|
||||
placeholder={prompt.description}
|
||||
value={prompt.previous_value ?? ""}
|
||||
name={prompt.description}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Field>
|
||||
</Group>
|
||||
)}
|
||||
</For>
|
||||
</Group>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
{props.footer}
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
type VarsStepProps = StepProps<VarsValues> & {
|
||||
fullClosure?: boolean;
|
||||
};
|
||||
|
||||
export const VarsStep = (props: VarsStepProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const generatorsQuery = createQuery(() => ({
|
||||
queryKey: [props.dir, props.machine_id, "generators", props.fullClosure],
|
||||
queryFn: async () => {
|
||||
const result = await callApi(
|
||||
"get_generators_closure",
|
||||
{
|
||||
base_dir: props.dir,
|
||||
machine_name: props.machine_id,
|
||||
full_closure: props.fullClosure,
|
||||
},
|
||||
{
|
||||
logging: {
|
||||
group_path: ["clans", props.dir, "machines", props.machine_id],
|
||||
},
|
||||
},
|
||||
).promise;
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
}));
|
||||
|
||||
const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => {
|
||||
const loading_toast = toast.loading("Generating vars...");
|
||||
if (generatorsQuery.data === undefined) {
|
||||
toast.error("Error fetching data");
|
||||
return;
|
||||
}
|
||||
const result = await callApi("run_generators", {
|
||||
machine_name: props.machine_id,
|
||||
base_dir: props.dir,
|
||||
generators: generatorsQuery.data.map((generator) => generator.name),
|
||||
all_prompt_values: values,
|
||||
}).promise;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [props.dir, props.machine_id, "generators"],
|
||||
});
|
||||
toast.dismiss(loading_toast);
|
||||
if (result.status === "error") {
|
||||
toast.error(result.errors[0].message);
|
||||
return;
|
||||
}
|
||||
if (result.status === "success") {
|
||||
toast.success("Vars saved successfully");
|
||||
}
|
||||
props.handleNext(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={generatorsQuery.isLoading}>Loading ...</Match>
|
||||
<Match when={generatorsQuery.data}>
|
||||
{(generators) => (
|
||||
<VarsForm
|
||||
machine_id={props.machine_id}
|
||||
dir={props.dir}
|
||||
handleSubmit={handleSubmit}
|
||||
generators={generators()}
|
||||
footer={props.footer}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export const VarsPage = () => {
|
||||
const params = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { activeClanURI } = useClanContext();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const handleNext = (values: VarsValues) => {
|
||||
if (searchParams?.action === "update") {
|
||||
navigate(`/machines/${params.id}?action=update`);
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
const fullClosure = searchParams?.full_closure !== undefined;
|
||||
const footer = (
|
||||
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
|
||||
<Button
|
||||
type="submit"
|
||||
// disabled={formStore.submitting || !formStore.dirty}
|
||||
>
|
||||
Update edits
|
||||
</Button>
|
||||
</footer>
|
||||
);
|
||||
return (
|
||||
<div class="p-1">
|
||||
{/* BackButton and header */}
|
||||
<BackButton />
|
||||
<div class="p-2">
|
||||
<h3 class="text-2xl">{params.id}</h3>
|
||||
</div>
|
||||
|
||||
{/* VarsStep component */}
|
||||
<Show when={activeClanURI()}>
|
||||
{(uri) => (
|
||||
<VarsStep
|
||||
machine_id={params.id}
|
||||
dir={uri()}
|
||||
handleNext={handleNext}
|
||||
footer={footer}
|
||||
fullClosure={fullClosure}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user