Merge pull request 'clan-app: 3d UI Remove unused files and exports' (#4029) from Qubasa/clan-core:ui_minimize-3d into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4029
This commit is contained in:
Luis Hebendanz
2025-06-19 14:48:51 +00:00
59 changed files with 92 additions and 6920 deletions

View File

@@ -3,6 +3,7 @@ import tseslint from "typescript-eslint";
import tailwind from "eslint-plugin-tailwindcss"; import tailwind from "eslint-plugin-tailwindcss";
import pluginQuery from "@tanstack/eslint-plugin-query"; import pluginQuery from "@tanstack/eslint-plugin-query";
import { globalIgnores } from "eslint/config"; import { globalIgnores } from "eslint/config";
import unusedImports from "eslint-plugin-unused-imports";
const config = tseslint.config( const config = tseslint.config(
eslint.configs.recommended, eslint.configs.recommended,
@@ -12,6 +13,9 @@ const config = tseslint.config(
...tailwind.configs["flat/recommended"], ...tailwind.configs["flat/recommended"],
globalIgnores(["src/types/index.d.ts"]), globalIgnores(["src/types/index.d.ts"]),
{ {
plugins: {
"unused-imports": unusedImports,
},
rules: { rules: {
"tailwindcss/no-contradicting-classname": [ "tailwindcss/no-contradicting-classname": [
"error", "error",
@@ -29,6 +33,7 @@ const config = tseslint.config(
// TODO: make this more strict by removing later // TODO: make this more strict by removing later
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "warn",
"@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-non-null-assertion": "off",
}, },
}, },

View File

@@ -0,0 +1,19 @@
{
"ignore": [
"gtk.webview.js",
"stylelint.config.js",
"util.ts",
"src/components/v2/**",
"api/**",
"tailwind/**"
],
"ignoreDependencies": [
"@babel/plugin-syntax-import-attributes",
"@storybook/addon-viewport",
"@typescript-eslint/parser",
"@vitest/coverage-v8",
"http-server",
"playwright",
"wait-on"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,10 @@
"build": "npm run check && npm run test && vite build && npm run convert-html", "build": "npm run check && npm run test && vite build && npm run convert-html",
"convert-html": "node gtk.webview.js", "convert-html": "node gtk.webview.js",
"serve": "vite preview", "serve": "vite preview",
"check": "tsc --noEmit --skipLibCheck && eslint ./src", "check": "tsc --noEmit --skipLibCheck && eslint ./src --fix",
"test": "vitest run --project unit --typecheck", "test": "vitest run --project unit --typecheck",
"storybook": "storybook", "storybook": "storybook",
"knip": "knip --fix",
"storybook-build": "storybook build", "storybook-build": "storybook build",
"storybook-dev": "storybook dev -p 6006", "storybook-dev": "storybook dev -p 6006",
"test-storybook": "vitest run --project storybook", "test-storybook": "vitest run --project storybook",
@@ -30,7 +31,6 @@
"@storybook/addon-viewport": "^9.0.8", "@storybook/addon-viewport": "^9.0.8",
"@storybook/addon-vitest": "^9.0.8", "@storybook/addon-vitest": "^9.0.8",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.15.19", "@types/node": "^22.15.19",
"@types/three": "^0.176.0", "@types/three": "^0.176.0",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.32.1",
@@ -41,8 +41,10 @@
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"eslint": "^9.27.0", "eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0", "eslint-plugin-tailwindcss": "^3.17.0",
"eslint-plugin-unused-imports": "^4.1.4",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"knip": "^5.61.2",
"playwright": "~1.52.0", "playwright": "~1.52.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"postcss-url": "^10.1.3", "postcss-url": "^10.1.3",
@@ -67,11 +69,7 @@
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"@tanstack/eslint-plugin-query": "^5.51.12", "@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/solid-query": "^5.76.0", "@tanstack/solid-query": "^5.76.0",
"corvu": "^0.7.1",
"material-icons": "^1.13.12",
"nanoid": "^5.0.7",
"solid-js": "^1.9.7", "solid-js": "^1.9.7",
"solid-markdown": "^2.0.13",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"three": "^0.176.0" "three": "^0.176.0"
}, },

View File

@@ -1,127 +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";
export 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;
}
export 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

@@ -3,6 +3,6 @@ import { JSX } from "solid-js";
interface FormSectionProps { interface FormSectionProps {
children: JSX.Element; children: JSX.Element;
} }
export const FormSection = (props: FormSectionProps) => { const FormSection = (props: FormSectionProps) => {
return <div class="p-2">{props.children}</div>; return <div class="p-2">{props.children}</div>;
}; };

View File

@@ -1,274 +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";
import { useContext } from "corvu/dialog";
export 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 dialogContext = (dialogContextId?: string) =>
useContext(dialogContextId);
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="z-[1000] shadow"
>
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll">
<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

@@ -5,7 +5,6 @@ import {
InputLabel, InputLabel,
InputVariant, InputVariant,
} from "@/src/components/inputBase"; } from "@/src/components/inputBase";
import { Typography } from "@/src/components/Typography";
import { FieldLayout } from "./layout"; import { FieldLayout } from "./layout";
interface TextInputProps { interface TextInputProps {

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;
}
export 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>
);
}
export 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;
}
export 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;
}
export 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>
);
};
export 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;
}
export 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>
);
}
export 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,17 +1,15 @@
import schema from "@/api/API.json" with { type: "json" };
import { API, Error as ApiError } from "@/api/API"; import { API, Error as ApiError } from "@/api/API";
import { nanoid } from "nanoid";
import { Schema as Inventory } from "@/api/Inventory"; import { Schema as Inventory } from "@/api/Inventory";
import { toast, Toast } from "solid-toast"; import { toast } from "solid-toast";
import { import {
ErrorToastComponent, ErrorToastComponent,
CancelToastComponent, CancelToastComponent,
} from "@/src/components/toast"; } from "@/src/components/toast";
export type OperationNames = keyof API; type OperationNames = keyof API;
export type OperationArgs<T extends OperationNames> = API[T]["arguments"]; type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"]; export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ApiEnvelope<T> = type ApiEnvelope<T> =
| { | {
status: "success"; status: "success";
data: T; data: T;
@@ -19,10 +17,10 @@ export type ApiEnvelope<T> =
} }
| ApiError; | ApiError;
export type Services = NonNullable<Inventory["services"]>; type Services = NonNullable<Inventory["services"]>;
export type ServiceNames = keyof Services; type ServiceNames = keyof Services;
export type ClanService<T extends ServiceNames> = Services[T]; type ClanService<T extends ServiceNames> = Services[T];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable< type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T] Services[T]
>[string]; >[string];
@@ -30,17 +28,17 @@ export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>, OperationResponse<T>,
{ status: "success" } { status: "success" }
>; >;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"]; type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
export type ErrorQuery<T extends OperationNames> = Extract< type ErrorQuery<T extends OperationNames> = Extract<
OperationResponse<T>, OperationResponse<T>,
{ status: "error" } { status: "error" }
>; >;
export type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"]; type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
export type ClanOperations = Record<OperationNames, (str: string) => void>; type ClanOperations = Record<OperationNames, (str: string) => void>;
export interface GtkResponse<T> { interface GtkResponse<T> {
result: T; result: T;
op_key: string; op_key: string;
} }

View File

@@ -1,20 +0,0 @@
import { callApi } from ".";
import { Schema as Inventory } from "@/api/Inventory";
export const instance_name = (machine_name: string) =>
`${machine_name}_wifi_0` as const;
export async function get_iwd_service(base_path: string, machine_name: string) {
const r = await callApi("get_inventory", {
flake: { identifier: base_path },
}).promise;
if (r.status == "error") {
return null;
}
// @FIXME: Clean this up once we implement the feature
// @ts-expect-error: This doesn't check currently
const inventory: Inventory = r.data;
const instance_key = instance_name(machine_name);
return inventory.services?.iwd?.[instance_key] || null;
}

View File

@@ -119,11 +119,11 @@ export const ApiTester = () => {
<Show <Show
when={showSuggestions() && filteredEndpoints().length > 0} when={showSuggestions() && filteredEndpoints().length > 0}
> >
<ul class="absolute z-10 w-full bg-white border border-gray-300 rounded mt-1 max-h-60 overflow-y-auto shadow-lg"> <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()}> <For each={filteredEndpoints()}>
{(ep) => ( {(ep) => (
<li <li
class="p-2 hover:bg-gray-100 cursor-pointer" class="cursor-pointer p-2 hover:bg-gray-100"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
setValue(formStore, "endpoint", ep); setValue(formStore, "endpoint", ep);
@@ -142,13 +142,13 @@ export const ApiTester = () => {
</Field> </Field>
<Field name="payload"> <Field name="payload">
{(field, fieldProps) => ( {(field, fieldProps) => (
<div class="flex flex-col my-2"> <div class="my-2 flex flex-col">
<label class="mb-1 font-medium" for="payload-textarea"> <label class="mb-1 font-medium" for="payload-textarea">
payload payload
</label> </label>
<textarea <textarea
id="payload-textarea" id="payload-textarea"
class="border rounded p-2 text-sm min-h-[120px] resize-y focus:outline-none focus:ring-2 focus:ring-blue-400" 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}`} placeholder={`{\n "key": "value"\n}`}
value={field.value || ""} value={field.value || ""}
{...fieldProps} {...fieldProps}

View File

@@ -42,8 +42,7 @@ const sizeFont: Record<Size, string> = {
s: cx("text-[0.75rem]"), s: cx("text-[0.75rem]"),
}; };
export interface ButtonProps interface ButtonProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variants; variant?: Variants;
size?: Size; size?: Size;
children?: JSX.Element; children?: JSX.Element;

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,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,54 +0,0 @@
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
import { Show } from "solid-js";
import { type JSX } from "solid-js";
import cx from "classnames";
interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
formStore: FormStore<T, R>;
value: string;
options: JSX.Element;
selectProps: JSX.HTMLAttributes<HTMLSelectElement>;
label: JSX.Element;
error?: string;
required?: boolean;
topRightLabel?: JSX.Element;
class?: string;
}
export function SelectInput<T extends FieldValues, R extends ResponseData>(
props: SelectInputProps<T, R>,
) {
return (
<label
class={cx(" w-full", props.class)}
aria-disabled={props.formStore.submitting}
>
<div class="">
<span
class=" block"
classList={{
"after:ml-0.5 after:text-primary after:content-['*']":
props.required,
}}
>
{props.label}
</span>
<Show when={props.topRightLabel}>
<span class="">{props.topRightLabel}</span>
</Show>
</div>
<select
{...props.selectProps}
required={props.required}
class="w-full"
value={props.value}
>
{props.options}
</select>
{props.error && (
<span class=" font-bold text-error-700">{props.error}</span>
)}
</label>
);
}

View File

@@ -8,7 +8,7 @@ import "./css/sidebar.css";
import Icon, { IconVariant } from "../icon"; import Icon, { IconVariant } from "../icon";
import { clanMetaQuery } from "@/src/queries/clan-meta"; import { clanMetaQuery } from "@/src/queries/clan-meta";
export const SidebarSection = (props: { const SidebarSection = (props: {
title: string; title: string;
icon: IconVariant; icon: IconVariant;
children: JSX.Element; children: JSX.Element;

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";
export 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

@@ -3,7 +3,7 @@ import { Dynamic } from "solid-js/web";
import cx from "classnames"; import cx from "classnames";
import "./css/typography.css"; import "./css/typography.css";
export type Hierarchy = "body" | "title" | "headline" | "label"; type Hierarchy = "body" | "title" | "headline" | "label";
type Color = "primary" | "secondary" | "tertiary"; type Color = "primary" | "secondary" | "tertiary";
type Weight = "normal" | "medium" | "bold"; type Weight = "normal" | "medium" | "bold";
type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div"; type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";

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,194 +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";
import Icon from "@/src/components/icon"; // For displaying file icons
import { callApi } from "@/src/api";
import type { FieldValues } from "@modular-forms/solid";
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>
);
export 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

@@ -99,8 +99,7 @@ export const InputBase = (props: InputBaseProps) => {
); );
}; };
export interface InputLabelProps interface InputLabelProps extends JSX.LabelHTMLAttributes<HTMLLabelElement> {
extends JSX.LabelHTMLAttributes<HTMLLabelElement> {
description?: string; description?: string;
required?: boolean; required?: boolean;
error?: boolean; error?: boolean;

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,180 +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,
}).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("install_machine", {
opts: {
machine: {
name: name,
flake: {
identifier: active_clan,
},
},
no_reboot: true,
debug: true,
nix_options: [],
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,
}).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,
}).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("deploy_machine", {
machine: {
name: name,
flake: {
identifier: active_clan,
},
},
target_host: target_host.data!.data,
build_host: build_host.data?.data || null,
}).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,134 +0,0 @@
import Dialog from "corvu/dialog";
import { createSignal, JSX } from "solid-js";
import { Button } from "../Button/Button";
import Icon from "../icon";
import cx from "classnames";
interface ModalProps {
open: boolean | undefined;
handleClose: () => void;
title: string;
children: JSX.Element;
class?: string;
}
export const Modal = (props: ModalProps) => {
const [dragging, setDragging] = createSignal(false);
const [startOffset, setStartOffset] = createSignal({ x: 0, y: 0 });
let dialogRef: HTMLDivElement;
const handleMouseDown = (e: MouseEvent) => {
setDragging(true);
const rect = dialogRef.getBoundingClientRect();
setStartOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
};
const handleMouseMove = (e: MouseEvent) => {
if (dragging()) {
let newTop = e.clientY - startOffset().y;
let newLeft = e.clientX - startOffset().x;
if (newTop < 0) {
newTop = 0;
}
if (newLeft < 0) {
newLeft = 0;
}
dialogRef.style.top = `${newTop}px`;
dialogRef.style.left = `${newLeft}px`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
}
};
const handleMouseUp = () => setDragging(false);
return (
<Dialog open={props.open} trapFocus={true}>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-50 bg-black/50"
onMouseMove={handleMouseMove}
/>
<Dialog.Content
class={cx(
"overflow-hidden absolute left-1/3 top-1/3 z-50 min-w-[560px] rounded-md border border-def-4 focus-visible:outline-none",
props.class,
)}
classList={{
"!cursor-grabbing": dragging(),
[cx("scale-[101%] transition-transform")]: dragging(),
}}
ref={(el) => {
dialogRef = el;
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseDown={(e: MouseEvent) => {
e.stopPropagation(); // Prevent backdrop drag conflict
}}
onClick={(e: MouseEvent) => e.stopPropagation()} // Prevent backdrop click closing
>
<Dialog.Label
as="div"
class="flex w-full justify-center border-b-2 px-4 py-2 align-middle bg-def-3 border-def-4"
onMouseDown={handleMouseDown}
>
<div
class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{
"!cursor-grabbing": dragging(),
}}
>
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
</div>
<span class="mx-2 select-none whitespace-nowrap">
{props.title}
</span>
<div
class="flex w-full cursor-move flex-col gap-px py-1 "
classList={{
"!cursor-grabbing": dragging(),
}}
>
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
<hr class="h-px w-full border-none bg-secondary-300" />
</div>
<div class="absolute right-1 top-2 pl-1 bg-def-3">
<Button
onMouseDown={(e) => e.stopPropagation()}
tabIndex={-1}
class="size-4"
variant="ghost"
onClick={() => props.handleClose()}
size="s"
startIcon={<Icon icon={"Close"} />}
/>
</div>
</Dialog.Label>
<Dialog.Description
class="flex max-h-[90vh] flex-col overflow-y-hidden bg-def-1"
as="div"
>
{props.children}
</Dialog.Description>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
);
};

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} />;
};
export 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

@@ -68,7 +68,7 @@ const WarningIcon: Component = () => (
// --- Base Props and Styles --- // --- Base Props and Styles ---
export interface BaseToastProps { interface BaseToastProps {
t: Toast; t: Toast;
message: string; message: string;
onCancel?: () => void; // Optional custom function on X click onCancel?: () => void; // Optional custom function on X click
@@ -254,7 +254,7 @@ export const CancelToastComponent: Component<BaseToastProps> = (props) => {
}; };
// Warning Toast // Warning Toast
export const WarningToastComponent: Component<BaseToastProps> = (props) => { const WarningToastComponent: Component<BaseToastProps> = (props) => {
let timeoutId: number | undefined; let timeoutId: number | undefined;
const [clicked, setClicked] = createSignal(false); const [clicked, setClicked] = createSignal(false);
const [exiting, setExiting] = createSignal(false); const [exiting, setExiting] = createSignal(false);

View File

@@ -1,5 +1,4 @@
@import "material-icons/iconfont/filled.css"; /* Material icons removed - using custom icons */
/* List of icons: https://marella.me/material-icons/demo/ */
/* @import url(./components/Typography/css/typography.css); */ /* @import url(./components/Typography/css/typography.css); */
@tailwind base; @tailwind base;

View File

@@ -1,5 +1,4 @@
import { createContext, createEffect, JSX, useContext } from "solid-js"; import { createContext, createEffect, JSX, useContext } from "solid-js";
import { callApi } from "@/src/api";
import { import {
activeClanURI, activeClanURI,
addClanURI, addClanURI,

View File

@@ -6,10 +6,8 @@ import type {
} from "@floating-ui/dom"; } from "@floating-ui/dom";
import { computePosition } from "@floating-ui/dom"; import { computePosition } from "@floating-ui/dom";
export interface UseFloatingOptions< interface UseFloatingOptions<R extends ReferenceElement, F extends HTMLElement>
R extends ReferenceElement, extends Partial<ComputePositionConfig> {
F extends HTMLElement,
> extends Partial<ComputePositionConfig> {
whileElementsMounted?: ( whileElementsMounted?: (
reference: R, reference: R,
floating: F, floating: F,
@@ -23,7 +21,7 @@ interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
y?: number | null; y?: number | null;
} }
export interface UseFloatingResult extends UseFloatingState { interface UseFloatingResult extends UseFloatingState {
update(): void; update(): void;
} }

View File

@@ -23,7 +23,7 @@ export const registerClan = async () => {
* Opens the custom file dialog * Opens the custom file dialog
* Returns a native FileList to allow interaction with the native input type="file" * Returns a native FileList to allow interaction with the native input type="file"
*/ */
export const selectSshKeys = async (): Promise<FileList> => { const selectSshKeys = async (): Promise<FileList> => {
const dataTransfer = new DataTransfer(); const dataTransfer = new DataTransfer();
const response = await callApi("open_file", { const response = await callApi("open_file", {

View File

@@ -1,5 +1,4 @@
@import "material-icons/iconfont/filled.css"; /* Material icons removed - using custom icons */
/* List of icons: https://marella.me/material-icons/demo/ */
/* @import url(./components/Typography/css/typography.css); */ /* @import url(./components/Typography/css/typography.css); */
@tailwind base; @tailwind base;

View File

@@ -4,24 +4,15 @@ import { Navigate, RouteDefinition, Router } from "@solidjs/router";
import "./index.css"; import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import {
CreateMachine,
MachineDetails,
MachineListView,
} from "./routes/machines";
import { Layout } from "./layout/layout"; import { Layout } from "./layout/layout";
import { ClanDetails, ClanList, CreateClan } from "./routes/clans"; import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
import { Flash } from "./routes/flash/view";
import { HostList } from "./routes/hosts/view"; import { HostList } from "./routes/hosts/view";
import { Welcome } from "./routes/welcome"; import { Welcome } from "./routes/welcome";
import { Toaster } from "solid-toast"; 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 { ApiTester } from "./api_test";
import { IconVariant } from "./components/icon"; import { IconVariant } from "./components/icon";
import { Components } from "./routes/components"; import { Components } from "./routes/components";
import { VarsPage } from "./routes/machines/install/vars-step";
import { ThreePlayground } from "./three"; import { ThreePlayground } from "./three";
import { ClanProvider } from "./contexts/clan"; import { ClanProvider } from "./contexts/clan";
@@ -54,35 +45,7 @@ export const routes: AppRoute[] = [
hidden: true, hidden: true,
component: () => <Navigate href="/machines" />, 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: "/clans", path: "/clans",
label: "Clans", label: "Clans",
@@ -107,42 +70,7 @@ export const routes: AppRoute[] = [
}, },
], ],
}, },
{
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", path: "/welcome",
label: "", label: "",

View File

@@ -1,76 +0,0 @@
import { useQuery } from "@tanstack/solid-query";
import { callApi } from "../api";
import toast from "solid-toast";
export 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 tagsQuery = (uri: string | undefined) =>
useQuery<string[]>(() => ({
queryKey: [uri, "tags"],
placeholderData: [],
queryFn: async () => {
if (!uri) return [];
const response = await callApi("get_inventory", {
flake: { identifier: uri },
}).promise;
if (response.status === "error") {
console.error("Failed to fetch data");
} else {
const machines = response.data.machines || {};
const tags = Object.values(machines).flatMap((m) => m.tags || []);
return tags;
}
return [];
},
}));
export const machinesQuery = (uri: string | undefined) =>
useQuery<string[]>(() => ({
queryKey: [uri, "machines"],
placeholderData: [],
queryFn: async () => {
if (!uri) return [];
const response = await callApi("get_inventory", {
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,17 +1,8 @@
import { callApi, ClanServiceInstance, SuccessQuery } from "@/src/api"; import { callApi, SuccessQuery } from "@/src/api";
import { useParams } from "@solidjs/router"; import { useParams } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query"; import { useQueryClient } from "@tanstack/solid-query";
import { createSignal, For, Match, Switch } from "solid-js"; import { Match, Switch } from "solid-js";
import { import { createForm, required, SubmitHandler } from "@modular-forms/solid";
createForm,
FieldValues,
getValue,
getValues,
required,
setValue,
SubmitHandler,
} from "@modular-forms/solid";
import { TextInput } from "@/src/Form/fields/TextInput";
import toast from "solid-toast"; import toast from "solid-toast";
import { Button } from "../../components/Button/Button"; import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon"; import Icon from "@/src/components/icon";

View File

@@ -1,13 +0,0 @@
export const colors = () => {
return (
<div class="grid grid-cols-3 gap-2">
<div class="h-10 w-20 bg-red-500">red</div>
<div class="h-10 w-20 bg-green-500">green</div>
<div class="h-10 w-20 bg-blue-500">blue</div>
<div class="h-10 w-20 bg-yellow-500">yellow</div>
<div class="h-10 w-20 bg-purple-500">purple</div>
<div class="h-10 w-20 bg-cyan-500">cyan</div>
<div class="h-10 w-20 bg-pink-500">pink</div>
</div>
);
};

View File

@@ -1,6 +0,0 @@
import { callApi } from "@/src/api";
import { createQuery } from "@tanstack/solid-query";
export const Deploy = () => {
return <div>Deloy view</div>;
};

View File

@@ -1,31 +0,0 @@
import { callApi } from "@/src/api";
import { useQuery } from "@tanstack/solid-query";
import { useClanContext } from "@/src/contexts/clan";
export function DiskView() {
const { activeClanURI } = useClanContext();
const query = useQuery(() => ({
queryKey: ["disk", activeClanURI()],
queryFn: async () => {
const currUri = activeClanURI();
if (currUri) {
// Example of calling an API
const result = await callApi("get_inventory", {
flake: { identifier: currUri },
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
return (
<div>
<h1>Configure Disk</h1>
<p>
Select machine then configure the disk. Required before installing for
the first time.
</p>
</div>
);
}

View File

@@ -1,438 +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, For, Show } 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 { Modal } from "@/src/components/modal";
import Fieldset from "@/src/Form/fieldset"; // Still used for other fieldsets
import Accordion from "@/src/components/accordion";
// Import the new generic component
import {
FileSelectorField,
type FileDialogOptions,
} from "@/src/components/fileSelect"; // Adjust path
interface Wifi extends FieldValues {
ssid: string;
password: string;
}
export 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("show_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_possible_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_possible_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("flash_machine", {
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" />
<Modal
open={confirmOpen() || isFlashing()}
handleClose={() => !isFlashing() && setConfirmOpen(false)}
title="Confirm"
>
{/* ... Modal content as before ... */}
<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>
</Modal>
<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,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,192 +0,0 @@
import { callApi, OperationArgs } from "@/src/api";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
import { TextInput } from "@/src/Form/fields/TextInput";
import { Header } from "@/src/layout/header";
import { createForm, required, reset } from "@modular-forms/solid";
import { useNavigate } from "@solidjs/router";
import { useQueryClient } from "@tanstack/solid-query";
import { Match, Switch } from "solid-js";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { DynForm } from "@/src/Form/form";
import Fieldset from "@/src/Form/fieldset";
import Accordion from "@/src/components/accordion";
import { useClanContext } from "@/src/contexts/clan";
type CreateMachineForm = OperationArgs<"create_machine">;
export function CreateMachine() {
const navigate = useNavigate();
const { activeClanURI } = useClanContext();
const [formStore, { Form, Field }] = createForm<CreateMachineForm>({
initialValues: {
opts: {
clan_dir: {
identifier: activeClanURI() || "",
},
machine: {
tags: ["all"],
deploy: {
targetHost: "",
},
name: "",
description: "",
},
},
},
});
const queryClient = useQueryClient();
const handleSubmit = async (values: CreateMachineForm) => {
const active_dir = activeClanURI();
if (!active_dir) {
toast.error("Open a clan to create the machine within");
return;
}
console.log("submitting", values);
const response = await callApi("create_machine", {
opts: {
...values.opts,
clan_dir: {
identifier: active_dir,
},
},
}).promise;
if (response.status === "success") {
toast.success(`Successfully created ${values.opts.machine.name}`);
reset(formStore);
await queryClient.invalidateQueries({
queryKey: [active_dir, "list_machines"],
});
navigate("/machines");
} else {
toast.error(
`Error: ${response.errors[0].message}. Machine ${values.opts.machine.name} could not be created`,
);
}
};
return (
<>
<Header title="Create Machine" />
<div class="flex w-full p-4">
<div class="mt-4 w-full self-stretch px-8">
<Form
onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<Field
name="opts.machine.name"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<div class="mb-4 flex justify-center border-b pb-4">
<MachineAvatar name={field.value} />
</div>
</>
)}
</Field>
<Fieldset legend="General">
<Field
name="opts.machine.name"
validate={[required("This field is required")]}
>
{(field, props) => (
<>
<TextInput
inputProps={props}
value={`${field.value}`}
label={"name"}
error={field.error}
required
placeholder="New_machine"
/>
</>
)}
</Field>
<Field name="opts.machine.description">
{(field, props) => (
<TextInput
inputProps={props}
value={`${field.value}`}
label={"description"}
error={field.error}
placeholder="My awesome machine"
/>
)}
</Field>
</Fieldset>
<Fieldset legend="Tags">
<Field name="opts.machine.tags" type="string[]">
{(field, props) => (
<div class="p-2">
<DynForm
initialValues={{ tags: ["all"] }}
schema={{
type: "object",
properties: {
tags: {
type: "array",
items: {
title: "Tag",
type: "string",
},
uniqueItems: true,
},
},
}}
/>
</div>
)}
</Field>
</Fieldset>
<Accordion title="Advanced">
<Fieldset>
<Field name="opts.machine.deploy.targetHost">
{(field, props) => (
<>
<TextInput
inputProps={props}
value={`${field.value}`}
label={"Target"}
error={field.error}
placeholder="e.g. 192.168.188.64"
/>
</>
)}
</Field>
</Fieldset>
</Accordion>
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
<Button
type="submit"
disabled={formStore.submitting}
startIcon={
formStore.submitting ? (
<Icon icon="Load" />
) : (
<Icon icon="Plus" />
)
}
>
<Switch fallback={<>Creating</>}>
<Match when={!formStore.submitting}>Create</Match>
</Switch>
</Button>
</footer>
</Form>
</div>
</div>
</>
);
}

View File

@@ -1,804 +0,0 @@
import { callApi, SuccessData } from "@/src/api";
import {
createForm,
FieldValues,
getValue,
getValues,
setValue,
} from "@modular-forms/solid";
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { useQuery, useQueryClient } from "@tanstack/solid-query";
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
import { TextInput } from "@/src/Form/fields/TextInput";
import Accordion from "@/src/components/accordion";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { Header } from "@/src/layout/header";
import { InputLabel } from "@/src/components/inputBase";
import { FieldLayout } from "@/src/Form/fields/layout";
import { Modal } from "@/src/components/modal";
import { Typography } from "@/src/components/Typography";
import { HardwareValues, HWStep } from "./install/hardware-step";
import { DiskStep, DiskValues } from "./install/disk-step";
import { SummaryStep } from "./install/summary-step";
import cx from "classnames";
import { VarsStep, VarsValues } from "./install/vars-step";
import Fieldset from "@/src/Form/fieldset";
import {
type FileDialogOptions,
FileSelectorField,
} from "@/src/components/fileSelect";
import { useClanContext } from "@/src/contexts/clan";
import { TagList } from "@/src/components/TagList/TagList";
type MachineFormInterface = MachineData & {
sshKey?: File;
disk?: string;
};
type MachineData = SuccessData<"get_machine_details">;
const steps: Record<StepIdx, string> = {
"1": "Hardware detection",
"2": "Disk schema",
"3": "Credentials & Data",
"4": "Installation",
};
type StepIdx = keyof AllStepsValues;
export interface AllStepsValues extends FieldValues {
"1": HardwareValues;
"2": DiskValues;
"3": VarsValues;
"4": NonNullable<unknown>;
sshKey?: File;
}
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>
);
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
interface InstallMachineProps {
name?: string;
machine: MachineData;
}
const 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>();
const [isDone, setIsDone] = createSignal<boolean>(false);
const [isInstalling, setIsInstalling] = createSignal<boolean>(false);
const [progressText, setProgressText] = createSignal<string>();
const handleInstall = async (values: AllStepsValues) => {
console.log("Installing", values);
const curr_uri = activeClanURI();
const target = values["1"].target;
const diskValues = values["2"];
if (!curr_uri) {
return;
}
if (!props.name) {
return;
}
setIsInstalling(true);
// props.machine.disk_
const shouldRunDisk =
JSON.stringify(props.machine.disk_schema?.placeholders) !==
JSON.stringify(diskValues.placeholders);
if (shouldRunDisk) {
setProgressText("Setting up disk ... (1/5)");
const disk_response = 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 target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: curr_uri },
name: props.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 installPromise = callApi("install_machine", {
opts: {
machine: {
name: props.name,
flake: {
identifier: curr_uri,
},
private_key: values.sshKey?.name,
},
password: "",
},
target_host: target_host.data!.data,
});
// Next step
await sleep(10 * 1000);
setProgressText("Building machine ... (3/5)");
await sleep(10 * 1000);
setProgressText("Formatting remote disk ... (4/5)");
await sleep(10 * 1000);
setProgressText("Copying system ... (5/5)");
await sleep(20 * 1000);
setProgressText("Rebooting remote system ... ");
await sleep(10 * 1000);
const installResponse = await installPromise;
};
const [step, setStep] = createSignal<StepIdx>("1");
const handleNext = () => {
console.log("Next");
setStep((c) => `${+c + 1}` as StepIdx);
};
const handlePrev = () => {
console.log("Next");
setStep((c) => `${+c - 1}` as StepIdx);
};
const Footer = () => (
<div class="flex justify-between p-4">
<Button
startIcon={<Icon icon="ArrowLeft" />}
variant="light"
type="button"
onClick={handlePrev}
disabled={step() === "1"}
>
Previous
</Button>
<Button
endIcon={<Icon icon="ArrowRight" />}
type="submit"
// IMPORTANT: The step itself will try to submit and call the next step
// onClick={(e: Event) => handleNext()}
>
Next
</Button>
</div>
);
return (
<Switch
fallback={
<Form
onSubmit={handleInstall}
class="relative top-0 flex h-full flex-col gap-0"
>
{/* Register each step as form field */}
{/* @ts-expect-error: object type is not statically supported */}
<Field name="1">{(field, fieldProps) => <></>}</Field>
{/* @ts-expect-error: object type is not statically supported */}
<Field name="2">{(field, fieldProps) => <></>}</Field>
{/* Modal Header */}
<div class="select-none px-6 py-2">
<Typography hierarchy="label" size="default">
Install:{" "}
</Typography>
<Typography hierarchy="label" size="default" weight="bold">
{props.name}
</Typography>
</div>
{/* Stepper header */}
<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 === step(),
[cx("bg-def-4 fg-def-1")]: idx < step(),
}}
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 >= step()}
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 == step(),
}}
>
{label}
</Typography>
</div>
)}
</For>
</div>
<Switch fallback={"Undefined content. This Step seems to not exist."}>
<Match when={step() === "1"}>
<HWStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeClanURI()}
handleNext={(data) => {
const prev = getValue(formStore, "1");
setValue(formStore, "1", { ...prev, ...data });
handleNext();
}}
initial={
getValue(formStore, "1") || {
target: props.machine.machine.deploy?.targetHost || "",
report: false,
}
}
footer={<Footer />}
/>
</Match>
<Match when={step() === "2"}>
<DiskStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeClanURI()}
footer={<Footer />}
handleNext={(data) => {
const prev = getValue(formStore, "2");
setValue(formStore, "2", { ...prev, ...data });
handleNext();
}}
// @ts-expect-error: The placeholder type is to wide
initial={{
...props.machine.disk_schema,
...getValue(formStore, "2"),
initialized: !!props.machine.disk_schema,
}}
/>
</Match>
<Match when={step() === "3"}>
<VarsStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeClanURI()}
handleNext={(data) => {
const prev = getValue(formStore, "3");
setValue(formStore, "3", { ...prev, ...data });
handleNext();
}}
initial={getValue(formStore, "3") || {}}
footer={<Footer />}
/>
</Match>
<Match when={step() === "4"}>
<SummaryStep
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeClanURI()}
handleNext={() => handleNext()}
// @ts-expect-error: This cannot be known.
initial={getValues(formStore)}
footer={
<div class="flex justify-between p-4">
<Button
startIcon={<Icon icon="ArrowLeft" />}
variant="light"
type="button"
onClick={handlePrev}
disabled={step() === "1"}
>
Previous
</Button>
<Button startIcon={<Icon icon="Flash" />}>Install</Button>
</div>
}
/>
</Match>
</Switch>
</Form>
}
>
<Match when={isInstalling()}>
<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.name}
</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" />
{isDone() && <LoadingBar />}
<Typography
hierarchy="label"
size="default"
weight="medium"
color="inherit"
>
{progressText()}
</Typography>
<Button onClick={() => setIsInstalling(false)}>Cancel</Button>
</div>
</div>
</Match>
</Switch>
);
};
interface MachineDetailsProps {
initialData: MachineData;
}
const MachineForm = (props: MachineDetailsProps) => {
const [formStore, { Form, Field }] =
// TODO: retrieve the correct initial values from API
createForm<MachineFormInterface>({
initialValues: props.initialData,
});
const targetHost = () => getValue(formStore, "machine.deploy.targetHost");
const machineName = () =>
getValue(formStore, "machine.name") || props.initialData.machine.name;
const [installModalOpen, setInstallModalOpen] = createSignal(false);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const { activeClanURI } = useClanContext();
const handleSubmit = async (values: MachineFormInterface) => {
console.log("submitting", values);
const curr_uri = activeClanURI();
if (!curr_uri) {
return;
}
const machine_response = await callApi("set_machine", {
machine: {
name: props.initialData.machine.name || "My machine",
flake: {
identifier: curr_uri,
},
},
update: {
...values.machine,
// TODO: Remove this workaround
tags: Array.from(
values.machine.tags || props.initialData.machine.tags || [],
),
},
}).promise;
await queryClient.invalidateQueries({
queryKey: [curr_uri, "machine", machineName(), "get_machine_details"],
});
return null;
};
const generatorsQuery = useQuery(() => ({
queryKey: [activeClanURI(), machineName(), "generators"],
queryFn: async () => {
const machine_name = machineName();
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,
}).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/${machineName()}/vars?action=update`);
} else {
handleUpdate();
}
};
const [isUpdating, setIsUpdating] = createSignal(false);
const handleUpdate = async () => {
if (isUpdating()) {
return;
}
const curr_uri = activeClanURI();
if (!curr_uri) {
return;
}
const machine = machineName();
if (!machine) {
toast.error("Machine is required");
return;
}
const target = targetHost();
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: machine,
}).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: machine,
}).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;
}
setIsUpdating(true);
const r = await callApi("deploy_machine", {
machine: {
name: machine,
flake: {
identifier: curr_uri,
},
},
target_host: target_host.data!.data,
build_host: build_host.data!.data,
}).promise;
};
createEffect(() => {
const action = searchParams.action;
console.log({ action });
if (action === "update") {
setSearchParams({ action: undefined });
handleUpdate();
}
});
return (
<>
<div class="flex flex-col gap-6">
<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="w-fit" data-tip="Machine must be online">
{/* <Button
class="w-full"
size="s"
// disabled={!online()}
onClick={() => {
setInstallModalOpen(true);
}}
endIcon={<Icon icon="Flash" />}
>
Install
</Button> */}
</div>
{/* <Typography hierarchy="label" size="default">
Installs the system for the first time. Used to bootstrap the
remote device.
</Typography> */}
</div>
<div class="flex items-center gap-3">
<div class="button-group flex">
<Button
variant="light"
class="w-full"
size="s"
onClick={() => {
setInstallModalOpen(true);
}}
endIcon={<Icon size={14} icon="Flash" />}
>
Install
</Button>
<Button
variant="light"
class="w-full"
size="s"
onClick={() => handleUpdateButton()}
endIcon={<Icon size={12} icon="Update" />}
>
Update
</Button>
<Button
variant="light"
class="w-full"
size="s"
onClick={() => {
navigate(`/machines/${machineName()}/vars`);
}}
endIcon={<Icon size={12} icon="Folder" />}
>
Credentials
</Button>
</div>
<div class=" w-fit" data-tip="Machine must be online"></div>
{/* <Typography hierarchy="label" size="default">
Update the system if changes should be synced after the
installation process.
</Typography> */}
</div>
</div>
<div class="p-4">
<span class="mb-2 flex w-full justify-center">
<MachineAvatar name={machineName()} />
</span>
<Form
onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<Fieldset legend="General">
<Field name="machine.name">
{(field, props) => (
<TextInput
inputProps={props}
label="Name"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<Field name="machine.description">
{(field, props) => (
<TextInput
inputProps={props}
label="Description"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
/>
)}
</Field>
<Field name="machine.tags" type="string[]">
{(field, props) => (
<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">
{/* alphabetically sort the tags */}
<TagList values={[...(field.value || [])].sort()} />
</div>
</div>
)}
</Field>
</Fieldset>
<Typography hierarchy={"body"} size={"s"}>
<Fieldset legend="Hardware">
<Field name="hw_config">
{(field, props) => (
<FieldLayout
label={<InputLabel>Hardware Configuration</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
)}
</Field>
<hr />
<Field name="disk_schema.schema_name">
{(field, props) => (
<>
<FieldLayout
label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
</>
)}
</Field>
</Fieldset>
</Typography>
<Accordion title="Connection Settings">
<Fieldset>
<Field name="machine.deploy.targetHost">
{(field, props) => (
<TextInput
inputProps={props}
label="Target Host"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<FileSelectorField
Field={Field}
of={Array<File>}
multiple={true}
name="sshKeys" // Corresponds to FlashFormValues.sshKeys
label="SSH Private Key"
description="Provide your SSH private key for secure, passwordless connections."
fileDialogOptions={
{
title: "Select SSH Keys",
initial_folder: "~/.ssh",
} as FileDialogOptions
}
// 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>
</Accordion>
{
<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>
<Modal
title={`Install machine`}
open={installModalOpen()}
handleClose={() => setInstallModalOpen(false)}
class="min-w-[600px]"
>
<InstallMachine name={machineName()} machine={props.initialData} />
</Modal>
</>
);
};
export const MachineDetails = () => {
const params = useParams();
const { activeClanURI } = useClanContext();
const genericQuery = useQuery(() => ({
queryKey: [activeClanURI(), "machine", params.id, "get_machine_details"],
queryFn: async () => {
const curr = activeClanURI();
if (curr) {
const result = await callApi("get_machine_details", {
machine: {
flake: {
identifier: curr,
},
name: params.id,
},
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
return (
<>
<Header title={`${params.id} machine`} showBack />
<Show when={genericQuery.data} fallback={<span class=""></span>}>
{(data) => (
<>
<MachineForm initialData={data()} />
</>
)}
</Show>
</>
);
};

View File

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

View File

@@ -1,130 +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";
import { useContext } from "corvu/dialog";
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;
},
}));
const modalContext = useContext();
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}
portalRef={modalContext.contentRef}
/>
)}
</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,
getValue,
required,
setValue,
submit,
SubmitHandler,
validate,
} from "@modular-forms/solid";
import { createEffect, createSignal, JSX, Match, Switch } from "solid-js";
import { TextInput } from "@/src/Form/fields";
import { createQuery } from "@tanstack/solid-query";
import { Badge } from "@/src/components/badge";
import { Group } from "@/src/components/group";
import {
type FileDialogOptions,
FileSelectorField,
} from "@/src/components/fileSelect";
import { useClanContext } from "@/src/contexts/clan";
export type HardwareValues = FieldValues & {
report: boolean;
target: string;
sshKey?: File;
};
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) || {},
});
const handleSubmit: SubmitHandler<HardwareValues> = async (values, event) => {
console.log("Submit Hardware", { values });
const valid = await validate(formStore);
console.log("Valid", valid);
if (!valid) return;
props.handleNext(values);
};
const [isGenerating, setIsGenerating] = createSignal(false);
const hwReportQuery = createQuery(() => ({
queryKey: [props.dir, props.machine_id, "hw_report"],
queryFn: async () => {
const result = await callApi("show_machine_hardware_config", {
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 curr_uri = activeClanURI();
if (!curr_uri) return;
await validate(formStore, "target");
const target = getValue(formStore, "target");
const sshFile = getValue(formStore, "sshKey") as File | undefined;
if (!target) {
console.error("Target 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("generate_machine_hardware_info", {
opts: {
machine: {
name: props.machine_id,
private_key: sshFile?.name,
flake: {
identifier: curr_uri,
},
},
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>
<Field name="target" validate={required("Target must be provided")}>
{(field, fieldProps) => (
<TextInput
error={field.error}
variant="ghost"
label="Target ip"
value={field.value || ""}
inputProps={fieldProps}
required
/>
)}
</Field>
<FileSelectorField
Field={Field}
of={File}
multiple={false}
name="sshKey" // Corresponds to FlashFormValues.sshKeys
label="SSH Private Key"
description="Provide your SSH private key for secure, passwordless connections."
fileDialogOptions={
{
title: "Select SSH Keys",
initial_folder: "~/.ssh",
} as FileDialogOptions
}
// 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`.
/>
</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 "../details";
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,251 +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>>;
export interface VarsFormProps {
machine_id: string;
dir: string;
handleSubmit: SubmitHandler<VarsValues>;
generators: SuccessData<"get_generators_closure">;
footer: JSX.Element;
}
export 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,
}).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("generate_vars_for_machine", {
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>
);
};

View File

@@ -1,215 +0,0 @@
import { type Component, createSignal, For, Match, Switch } from "solid-js";
import { callApi, OperationResponse } from "@/src/api";
import { MachineListItem } from "@/src/components/machine-list-item";
import { useQuery, useQueryClient } from "@tanstack/solid-query";
import { useNavigate } from "@solidjs/router";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
import { Header } from "@/src/layout/header";
import { makePersisted } from "@solid-primitives/storage";
import { useClanContext } from "@/src/contexts/clan";
type MachinesModel = Extract<
OperationResponse<"list_machines">,
{ status: "success" }
>["data"];
export interface Filter {
tags: string[];
}
export const MachineListView: Component = () => {
const queryClient = useQueryClient();
const [filter, setFilter] = createSignal<Filter>({ tags: [] });
const { activeClanURI } = useClanContext();
const inventoryQuery = useQuery<MachinesModel>(() => ({
queryKey: [activeClanURI(), "list_machines"],
placeholderData: {},
enabled: !!activeClanURI(),
queryFn: async () => {
console.log("fetching inventory", activeClanURI());
const uri = activeClanURI();
if (uri) {
const response = await callApi("list_machines", {
flake: {
identifier: uri,
},
}).promise;
console.log("response", response);
if (response.status === "error") {
console.error("Failed to fetch data");
} else {
return response.data;
}
}
return {};
},
}));
const refresh = async () => {
const clanURI = activeClanURI();
// do nothing if there is no active URI
if (!clanURI) {
return;
}
console.log("refreshing", clanURI);
await queryClient.invalidateQueries({
// Invalidates the cache for of all types of machine list at once
queryKey: [clanURI, "list_machines"],
});
};
const inventoryMachines = () =>
Object.entries(inventoryQuery.data || {}).filter((e) => {
const hasAllTags = filter().tags.every((tag) => e[1].tags?.includes(tag));
return hasAllTags;
});
const navigate = useNavigate();
const [view, setView] = makePersisted(createSignal<"list" | "grid">("grid"), {
name: "machines_view",
storage: localStorage,
});
return (
<>
<Header
title="Your Machines"
toolbar={
<>
<span class="" data-tip="Reload">
<Button
variant="light"
size="s"
onClick={() => refresh()}
startIcon={<Icon icon="Update" />}
/>
</span>
<div class="button-group">
<Button
onclick={() => setView("list")}
variant={view() == "list" ? "dark" : "light"}
size="s"
startIcon={<Icon icon="List" />}
/>
<Button
onclick={() => setView("grid")}
variant={view() == "grid" ? "dark" : "light"}
size="s"
startIcon={<Icon icon="Grid" />}
/>
</div>
<Button
onClick={() => navigate("create")}
size="s"
variant="light"
startIcon={<Icon size={14} icon="Plus" />}
>
New Machine
</Button>
</>
}
/>
<div>
<div class="my-1 flex w-full gap-2 p-2">
<For each={filter().tags.sort()}>
{(tag) => (
<button
type="button"
onClick={() =>
setFilter((prev) => {
return {
...prev,
tags: prev.tags.filter((t) => t !== tag),
};
})
}
>
<span class="rounded-full px-3 py-1 bg-inv-4 fg-inv-1">
{tag}
</span>
</button>
)}
</For>
</div>
<Switch>
<Match when={inventoryQuery.isLoading}>
{/* Loading skeleton */}
<div class="grid grid-cols-4"></div>
<div class="machine-item-loader">
<div class="machine-item-loader__thumb-wrapper">
<div class="machine-item-loader__thumb">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader">
<div class="machine-item-loader__thumb-wrapper">
<div class="machine-item-loader__thumb">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader">
<div class="machine-item-loader__thumb-wrapper">
<div class="machine-item-loader__thumb">
<div class="machine-item-loader__loader" />
</div>
</div>
<div class="machine-item-loader__headline">
<div class="machine-item-loader__loader" />
</div>
</div>
</Match>
<Match
when={!inventoryQuery.isLoading && inventoryMachines().length === 0}
>
<div class="mt-8 flex w-full flex-col items-center justify-center gap-2">
<span class="text-lg">
No machines defined yet. Click below to define one.
</span>
<Button
variant="light"
class="size-28 overflow-hidden p-2"
onClick={() => navigate("/machines/create")}
>
<span class="material-icons text-6xl font-light">add</span>
</Button>
</div>
</Match>
<Match when={!inventoryQuery.isLoading}>
<div
class="my-4 grid gap-6 p-6"
classList={{
"grid-cols-1": view() === "list",
"grid-cols-4": view() === "grid",
}}
>
<For each={inventoryMachines()}>
{([name, info]) => (
<MachineListItem
name={name}
info={info}
setFilter={setFilter}
/>
)}
</For>
</div>
</Match>
</Switch>
</div>
</>
);
};

View File

@@ -1,118 +0,0 @@
import { BackButton } from "@/src/components/BackButton";
import { createModulesQuery, machinesQuery, tagsQuery } from "@/src/queries";
import { useParams } from "@solidjs/router";
import { For, Match, Switch } from "solid-js";
import { ModuleInfo } from "./list";
import { createForm, FieldValues, SubmitHandler } from "@modular-forms/solid";
import { SelectInput } from "@/src/Form/fields/Select";
import { useClanContext } from "@/src/contexts/clan";
export const ModuleDetails = () => {
const params = useParams();
const { activeClanURI } = useClanContext();
const modulesQuery = createModulesQuery(activeClanURI());
return (
<div class="p-1">
<BackButton />
<div class="p-2">
<h3 class="text-2xl">{params.id}</h3>
{/* <Switch>
<Match when={modulesQuery.data?.find((i) => i[0] === params.id)}>
{(d) => <AddModule data={d()[1]} id={d()[0]} />}
</Match>
</Switch> */}
</div>
</div>
);
};
interface AddModuleProps {
data: ModuleInfo;
id: string;
}
export const AddModule = (props: AddModuleProps) => {
const { activeClanURI } = useClanContext();
const tags = tagsQuery(activeClanURI());
const machines = machinesQuery(activeClanURI());
return (
<div>
<div>Add to your clan</div>
<Switch fallback="loading">
<Match when={tags.data}>
{(tags) => (
<For each={Object.keys(props.data.roles)}>
{(role) => (
<>
<div class="text-neutral-600">{role}s</div>
<RoleForm
avilableTags={tags()}
availableMachines={machines.data || []}
/>
</>
)}
</For>
)}
</Match>
</Switch>
</div>
);
};
interface RoleFormData extends FieldValues {
machines: string[];
tags: string[];
test: string;
}
interface RoleFormProps {
avilableTags: string[];
availableMachines: string[];
}
const RoleForm = (props: RoleFormProps) => {
const [formStore, { Field, Form }] = createForm<RoleFormData>({
// initialValues: {
// machines: ["hugo", "bruno"],
// tags: ["network", "backup"],
// },
});
const handleSubmit: SubmitHandler<RoleFormData> = (values) => {
console.log(values);
};
return (
<Form onSubmit={handleSubmit}>
<Field name="machines" type="string[]">
{(field, fieldProps) => (
<SelectInput
error={field.error}
label={"Machines"}
value={field.value || []}
options={props.availableMachines.map((o) => ({
value: o,
label: o,
}))}
multiple
selectProps={fieldProps}
/>
)}
</Field>
<Field name="tags" type="string[]">
{(field, fieldProps) => (
<SelectInput
error={field.error}
label={"Tags"}
value={field.value || []}
options={props.avilableTags.map((o) => ({
value: o,
label: o,
}))}
multiple
selectProps={fieldProps}
/>
)}
</Field>
</Form>
);
};

View File

@@ -1,196 +0,0 @@
import { BackButton } from "@/src/components/BackButton";
import { createModulesQuery } from "@/src/queries";
import { useNavigate, useParams } from "@solidjs/router";
import { createEffect, For, Match, Switch } from "solid-js";
import { ModuleInfo } from "./list";
import { createQuery } from "@tanstack/solid-query";
import { JSONSchema7 } from "json-schema";
import { SubmitHandler } from "@modular-forms/solid";
import { DynForm } from "@/src/Form/form";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
import { useClanContext } from "@/src/contexts/clan";
import { activeClanURI } from "@/src/stores/clan";
export const ModuleDetails = () => {
const params = useParams();
const { activeClanURI } = useClanContext();
const modulesQuery = createModulesQuery(activeClanURI());
return (
<div class="p-1">
<BackButton />
<div class="p-2">
<h3 class="text-2xl">{params.id}</h3>
{/* <Switch>
<Match when={modulesQuery.data?.find((i) => i[0] === params.id)}>
{(d) => <Details data={d()[1]} id={d()[0]} />}
</Match>
</Switch> */}
</div>
</div>
);
};
function deepMerge(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
obj1: Record<string, any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
obj2: Record<string, any>,
) {
const result = { ...obj1 };
for (const key in obj2) {
if (Object.prototype.hasOwnProperty.call(obj2, key)) {
if (obj2[key] instanceof Object && obj1[key] instanceof Object) {
result[key] = deepMerge(obj1[key], obj2[key]);
} else {
result[key] = obj2[key];
}
}
}
return result;
}
interface DetailsProps {
data: ModuleInfo;
id: string;
}
const Details = (props: DetailsProps) => {
const navigate = useNavigate();
const add = async () => {
navigate(`/modules/add/${props.id}`);
// const uri = activeURI();
// if (!uri) return;
// const res = await callApi("get_inventory", { base_path: uri });
// if (res.status === "error") {
// toast.error("Failed to fetch inventory");
// return;
// }
// const inventory = res.data;
// const newInventory = deepMerge(inventory, {
// services: {
// [props.id]: {
// default: {
// enabled: false,
// },
// },
// },
// });
// callApi("set_inventory", {
// flake_dir: uri,
// inventory: newInventory,
// message: `Add module: ${props.id} in 'default' instance`,
// });
};
return (
<div class="flex w-full flex-col gap-2">
{/* TODO: bring this feature back */}
{/* <article class="prose">{props.data.description}</article> */}
{/* <span class="">Categories</span> */}
<div>
{/* TODO: bring this feature back */}
{/* <For each={props.data.categories}>
{(c) => <div class=" m-1">{c}</div>}
</For> */}
</div>
<span class="">Roles</span>
<div>
<For each={Object.keys(props.data.roles)}>
{(r) => <div class=" m-1">{r}</div>}
</For>
</div>
<div class="p-2">
{/* TODO: bring this feature back */}
{/* <SolidMarkdown>{props.data.readme}</SolidMarkdown> */}
</div>
<div class="my-2 flex w-full gap-2">
<Button variant="light" onClick={add} startIcon={<Icon icon="Plus" />}>
Add to Clan
</Button>
{/* Add -> Select (required) roles, assign Machine */}
</div>
<ModuleForm id={props.id} />
</div>
);
};
type ModuleSchemasType = Record<string, Record<string, JSONSchema7>>;
const Unsupported = (props: { schema: JSONSchema7; what: string }) => (
<div>
Cannot render {props.what}
<pre>
<code>{JSON.stringify(props.schema, null, 2)}</code>
</pre>
</div>
);
function removeTrailingS(str: string) {
// Check if the last character is "s" or "S"
if (str.endsWith("s") || str.endsWith("S")) {
return str.slice(0, -1); // Remove the last character
}
return str; // Return unchanged if no trailing "s"
}
interface SchemaFormProps {
title: string;
schema: JSONSchema7;
path: string[];
}
export const ModuleForm = (props: { id: string }) => {
// TODO: Fetch the synced schema for all the modules at runtime
// We use static schema file at build time for now. (Different versions might have different schema at runtime)
const schemaQuery = createQuery(() => ({
queryKey: [activeClanURI(), "modules_schema"],
queryFn: async () => {
const moduleSchema = await import(
"../../../api/modules_schemas.json"
).then((m) => m.default as ModuleSchemasType);
return moduleSchema;
},
}));
createEffect(() => {
console.log("Schema Query", schemaQuery.data?.[props.id]);
});
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
values,
event,
) => {
console.log("Submitted form values", values);
};
return (
<div id="ModuleForm">
<Switch fallback={"No Schema found"}>
<Match when={schemaQuery.isLoading}>Loading...</Match>
<Match when={schemaQuery.data?.[props.id]}>
{(rolesSchemas) => (
<>
Configure this module
<For each={Object.entries(rolesSchemas())}>
{([role, schema]) => (
<div class="my-2">
<h4 class="text-xl">{role}</h4>
<DynForm
handleSubmit={handleSubmit}
schema={schema}
components={{
after: <Button>Submit</Button>,
}}
/>
</div>
)}
</For>
</>
)}
</Match>
</Switch>
</div>
);
};

View File

@@ -1,216 +0,0 @@
import { SuccessData } from "@/src/api";
import { Button } from "../../components/Button/Button";
import { Header } from "@/src/layout/header";
import { createModulesQuery } from "@/src/queries";
import { A, useNavigate } from "@solidjs/router";
import { createSignal, For, Match, Switch } from "solid-js";
import { Typography } from "@/src/components/Typography";
import { Menu } from "@/src/components/Menu";
import { makePersisted } from "@solid-primitives/storage";
import { useQueryClient } from "@tanstack/solid-query";
import cx from "classnames";
import Icon from "@/src/components/icon";
import { useClanContext } from "@/src/contexts/clan";
export type ModuleInfo = SuccessData<"list_modules">["localModules"][string];
interface CategoryProps {
categories: string[];
}
const Categories = (props: CategoryProps) => {
return (
<span class="inline-flex h-full align-middle">
<Typography hierarchy="label" size="default" class="w-16" tag="div">
Categories:
</Typography>
{props.categories.map((category) => (
<Typography hierarchy="label" size="default">
{category}
</Typography>
))}
</span>
);
};
interface RolesProps {
roles: Record<string, null>;
}
const Roles = (props: RolesProps) => {
return (
<div class="inline-flex h-full align-middle">
<Typography hierarchy="label" size="default" class="w-16" tag="div">
Type:
</Typography>
{Object.keys(props.roles).map((role) => (
<Typography hierarchy="label" size="default">
{role}
</Typography>
))}
</div>
);
};
const ModuleItem = (props: {
name: string;
info: ModuleInfo;
source: string;
class?: string;
}) => {
const { name, info } = props;
const navigate = useNavigate();
return (
<div
class={cx(
"col-span-1 flex flex-col border-b border-secondary-200 pb-4 gap-2",
props.class,
)}
>
<header class="flex flex-row items-center justify-between">
<div class="flex flex-col gap-0">
<A href={`/modules/details/${props.source}/${info.manifest.name}`}>
<div class="">
<div class="flex flex-col">
{/* <Categories categories={info.categories} /> */}
<Typography hierarchy="title" size="m" weight="medium">
{info.manifest.name}
</Typography>
</div>
</div>
</A>
<div class="w-full">
<Typography hierarchy="body" size="xs">
{info.manifest.description}
</Typography>
</div>
</div>
<Menu popoverid={`menu-${props.name}`} label={<Icon icon={"More"} />}>
<ul class="z-[1] w-52 bg-slate-100 p-2 shadow">
<li>
<a
onClick={() => {
navigate(`/modules/details/${name}`);
}}
>
Configure
</a>
</li>
</ul>
</Menu>
</header>
<Roles roles={info.roles || {}} />
<div class="w-full">
<Categories categories={info.manifest.categories} />
</div>
</div>
);
};
export const ModuleList = () => {
const queryClient = useQueryClient();
const { activeClanURI } = useClanContext();
const modulesQuery = createModulesQuery(activeClanURI(), {
features: ["inventory"],
});
const [view, setView] = makePersisted(createSignal<"list" | "grid">("list"), {
name: "modules_view",
storage: localStorage,
});
const refresh = async () => {
const clanURI = activeClanURI();
// do nothing if there is no active URI
if (!clanURI) {
return;
}
await queryClient.invalidateQueries({
// Invalidates the cache for of all types of machine list at once
queryKey: [clanURI, "list_modules"],
});
};
return (
<>
<Header
title="App Store"
toolbar={
<>
<Button
variant="light"
size="s"
onClick={() => refresh()}
startIcon={<Icon icon="Update" />}
/>
<div class="button-group">
<Button
onclick={() => setView("list")}
variant={view() == "list" ? "dark" : "light"}
size="s"
startIcon={<Icon icon="List" />}
/>
<Button
onclick={() => setView("grid")}
variant={view() == "grid" ? "dark" : "light"}
size="s"
startIcon={<Icon icon="Grid" />}
/>
</div>
</>
}
/>
<Switch fallback="Error">
<Match when={modulesQuery.isFetching}>Loading....</Match>
<Match when={modulesQuery.data}>
{(modules) => (
<div
class="grid gap-6 p-6"
classList={{
"grid-cols-1": view() === "list",
"grid-cols-2": view() === "grid",
}}
>
<For each={Object.entries(modules().modulesPerSource)}>
{([sourceName, v]) => (
<>
<div>
<Typography size="default" hierarchy="label">
{sourceName}
</Typography>
</div>
<For each={Object.entries(v)}>
{([moduleName, moduleInfo]) => (
<ModuleItem
source={sourceName}
info={moduleInfo}
name={moduleName}
class={view() == "grid" ? cx("max-w-md") : ""}
/>
)}
</For>
</>
)}
</For>
<div>{"localModules"}</div>
<For each={Object.entries(modules().localModules)}>
{([moduleName, moduleInfo]) => (
<ModuleItem
source={"localModules"}
info={moduleInfo}
name={moduleName}
class={view() == "grid" ? cx("max-w-md") : ""}
/>
)}
</For>
</div>
)}
</Match>
</Switch>
</>
);
};

View File

@@ -80,7 +80,6 @@ const removeClanURI = (uri: string) => {
export { export {
store, store,
setStore,
activeClanURI, activeClanURI,
setActiveClanURI, setActiveClanURI,
clanURIs, clanURIs,

View File

@@ -1,4 +1,4 @@
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"; import { createSignal, onCleanup, onMount, Show } from "solid-js";
import * as THREE from "three"; import * as THREE from "three";
import { Button } from "./components/Button/Button"; import { Button } from "./components/Button/Button";
import Icon from "./components/icon"; import Icon from "./components/icon";