UI: remove 2d-ui, its broken now since we deleted the symlinked files in #4266

This commit is contained in:
Johannes Kirschbauer
2025-07-08 16:23:30 +02:00
parent b8fa4b4677
commit 90495d4157
120 changed files with 0 additions and 9898 deletions

View File

@@ -1 +0,0 @@
../ui/.fonts

View File

@@ -1,5 +0,0 @@
app/api
app/.fonts
.vite
storybook-static

View File

@@ -1 +0,0 @@
../ui/.storybook

View File

@@ -1,7 +0,0 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [
["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
],
"editor.wordWrap": "on"
}

View File

@@ -1 +0,0 @@
../ui/api

View File

@@ -1 +0,0 @@
../ui/eslint.config.mjs

View File

@@ -1 +0,0 @@
../ui/gtk.webview.js

View File

@@ -1 +0,0 @@
../ui/icons

View File

@@ -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>

View File

@@ -1,10 +0,0 @@
{
"ignore": [
"gtk.webview.js",
"stylelint.config.js",
"util.ts",
"src/components/v2/**",
"api/**",
"tailwind/**"
]
}

View File

@@ -1 +0,0 @@
../ui/package-lock.json

View File

@@ -1 +0,0 @@
../ui/package.json

View File

@@ -1 +0,0 @@
../ui/postcss.config.js

View File

@@ -1 +0,0 @@
../ui/prettier.config.js

View File

@@ -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,
};
}

View File

@@ -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>
);

View File

@@ -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>;
};

View File

@@ -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>
</>
);
}

View File

@@ -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}
/>
);
}

View File

@@ -1,2 +0,0 @@
export * from "./FormSection";
export * from "./TextInput";

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>;
};

View File

@@ -1 +0,0 @@
export { List } from "./List";

View File

@@ -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>
);
};

View File

@@ -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.",
},
},
},
};

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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: "";
}
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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);
}

View File

@@ -1,4 +0,0 @@
@import "./typography-label.css";
@import "./typography-body.css";
@import "./typography-title.css";
@import "./typography-headline.css";

View File

@@ -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;
}

View File

@@ -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%;
}

View File

@@ -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%;
}

View File

@@ -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%;
}

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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 {
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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"
>
<>&#42;</>
</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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1 +0,0 @@
../../../ui/src/components/v2

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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;
};

View File

@@ -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;
}
}

View File

@@ -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!,
);

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
},
};
});

View File

@@ -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 [];
},
}));

View File

@@ -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"];

View File

@@ -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>
</>
);
};

View File

@@ -1,3 +0,0 @@
export * from "./list";
export * from "./create";
export * from "./details";

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,2 +0,0 @@
export { InstallMachine } from "./InstallMachine";
export { MachineAvatar } from "./MachineAvatar";

View File

@@ -1,5 +0,0 @@
export * from "./machine-details";
export * from "./machine-create";
export * from "./machines-list";
export * from "./machine-install";
export * from "./types";

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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}
</>
);
};

View File

@@ -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