Merge pull request 'UI create a separate ui-2d folder' (#3998) from Qubasa/clan-core:ui_overhaul into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3998
This commit is contained in:
Luis Hebendanz
2025-06-18 18:11:28 +00:00
124 changed files with 11022 additions and 1 deletions

View File

@@ -0,0 +1,39 @@
version: "0.5"
processes:
# App Dev
clan-app-ui:
namespace: "app"
command: |
cd $(git rev-parse --show-toplevel)/pkgs/clan-app/ui-2d
npm install
vite
ready_log_line: "VITE"
clan-app:
namespace: "app"
command: |
cd $(git rev-parse --show-toplevel)/pkgs/clan-app
./bin/clan-app --debug --content-uri http://localhost:3000
depends_on:
clan-app-ui:
condition: "process_log_ready"
is_foreground: true
ready_log_line: "Debug mode enabled"
# Storybook Dev
storybook:
namespace: "storybook"
command: |
cd $(git rev-parse --show-toplevel)/pkgs/clan-app/ui-2d
npm run storybook-dev -- --ci
ready_log_line: "started"
luakit:
namespace: "storybook"
command: "luakit http://localhost:6006"
depends_on:
storybook:
condition: "process_log_ready"

1
pkgs/clan-app/ui-2d/.fonts Symbolic link
View File

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

5
pkgs/clan-app/ui-2d/.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via
`pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package
manager will work. This file can be safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev` or `npm start`
Runs the app in the development mode.<br> Open
[http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
### `npm run build`
Builds the app for production to the `dist` folder.<br> It correctly bundles
Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br> Your app is
ready to be deployed!
### `npm run storybook`
Starts an instance of [storybook](https://storybook.js.org/).
For more info on how to write stories, please [see here](https://storybook.js.org/docs).
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge,
now, etc.)

1
pkgs/clan-app/ui-2d/api Symbolic link
View File

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

View File

@@ -0,0 +1,37 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import tailwind from "eslint-plugin-tailwindcss";
import pluginQuery from "@tanstack/eslint-plugin-query";
import { globalIgnores } from "eslint/config";
const config = tseslint.config(
eslint.configs.recommended,
...pluginQuery.configs["flat/recommended"],
...tseslint.configs.strict,
...tseslint.configs.stylistic,
...tailwind.configs["flat/recommended"],
globalIgnores(["src/types/index.d.ts"]),
{
rules: {
"tailwindcss/no-contradicting-classname": [
"error",
{
callees: ["cx"],
},
],
"tailwindcss/no-custom-classname": [
"error",
{
callees: ["cx"],
whitelist: ["material-icons"],
},
],
// TODO: make this more strict by removing later
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-non-null-assertion": "off",
},
},
);
export default config;

View File

@@ -0,0 +1,97 @@
/**
* This script generates a custom index.html file for the webview UI.
* It reads the manifest.json file generated by Vite and uses it to generate the HTML file.
* It also processes the CSS files to rewrite the URLs in the CSS files to match the new location of the assets.
* The script is run after the Vite build is complete.
*
* This is necessary because the webview UI is loaded from the local file system and the URLs in the CSS files need to be rewritten to match the new location of the assets.
* The generated index.html file is then used as the entry point for the webview UI.
*/
import fs from "node:fs";
import postcss from "postcss";
import path from "node:path";
import css_url from "postcss-url";
const distPath = path.resolve("dist");
const manifestPath = path.join(distPath, ".vite/manifest.json");
const outputPath = path.join(distPath, "index.html");
fs.readFile(manifestPath, { encoding: "utf8" }, (err, data) => {
if (err) {
return console.error("Failed to read manifest:", err);
}
const manifest = JSON.parse(data);
/** @type {{ file: string; name: string; src: string; isEntry: bool; css: string[]; } []} */
const assets = Object.values(manifest);
console.log(`Generate custom index.html from ${manifestPath} ...`);
// Start with a basic HTML structure
let htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Webview UI</title>`;
// Add linked stylesheets
assets.forEach((asset) => {
// console.log(asset);
if (asset.src === "index.html") {
asset.css.forEach((cssEntry) => {
// css to be processed
const css = fs.readFileSync(`dist/${cssEntry}`, "utf8");
// process css
postcss()
.use(
css_url({
url: (asset, dir) => {
const res = path.basename(asset.url);
console.log(`Rewriting CSS url(): ${asset.url} to ${res}`);
return res;
},
}),
)
.process(css, {
from: `dist/${cssEntry}`,
to: `dist/${cssEntry}`,
})
.then((result) => {
fs.writeFileSync(`dist/${cssEntry}`, result.css, "utf8");
});
// Extend the HTML content with the linked stylesheet
console.log(`Relinking html css stylesheet: ${cssEntry}`);
htmlContent += `\n <link rel="stylesheet" href="${cssEntry}">`;
});
}
});
htmlContent += `
</head>
<body>
<div id="app"></div>
`;
// Add scripts
assets.forEach((asset) => {
if (asset.file.endsWith(".js")) {
console.log(`Relinking js script: ${asset.file}`);
htmlContent += `\n <script src="${asset.file}"></script>`;
}
});
htmlContent += `
</body>
</html>`;
// Write the HTML file
fs.writeFile(outputPath, htmlContent, (err) => {
if (err) {
console.error("Failed to write custom index.html:", err);
} else {
console.log("Custom index.html generated successfully!");
}
});
});

1
pkgs/clan-app/ui-2d/icons Symbolic link
View File

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

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<title>Solid App</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
</head>
<body>
<div id="app"></div>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;

View File

@@ -0,0 +1,9 @@
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
const config = {
trailingComma: "all",
};
export default config;

View File

@@ -0,0 +1,127 @@
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

@@ -0,0 +1,15 @@
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

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

View File

@@ -0,0 +1,274 @@
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="rounded-md border border-gray-200 bg-white shadow-lg"
>
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll p-1">
<Show when={!props.loading} fallback={"Loading ...."}>
<For each={props.options}>
{(opt) => (
<>
<li>
<Button
variant="ghost"
class="!justify-start"
onClick={() => handleClickOption(opt)}
disabled={opt.disabled}
classList={{
active:
!opt.disabled && getValues().includes(opt.value),
}}
>
{opt.label}
</Button>
</li>
</>
)}
</For>
</Show>
</ul>
</div>
</Portal>
</>
);
}

View File

@@ -0,0 +1,58 @@
import { splitProps, type JSX } from "solid-js";
import {
InputBase,
InputError,
InputLabel,
InputVariant,
} from "@/src/components/inputBase";
import { Typography } from "@/src/components/Typography";
import { FieldLayout } from "./layout";
interface TextInputProps {
// Common
error?: string;
required?: boolean;
disabled?: boolean;
// Passed to input
value: string;
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
placeholder?: string;
variant?: InputVariant;
// Passed to label
label: JSX.Element;
help?: string;
// Passed to layout
class?: string;
}
export function TextInput(props: TextInputProps) {
const [layoutProps, rest] = splitProps(props, ["class"]);
return (
<FieldLayout
label={
<InputLabel
class="col-span-2"
required={props.required}
error={!!props.error}
help={props.help}
>
{props.label}
</InputLabel>
}
field={
<InputBase
variant={props.variant}
error={!!props.error}
required={props.required}
disabled={props.disabled}
placeholder={props.placeholder}
class="col-span-10"
{...props.inputProps}
value={props.value}
/>
}
error={props.error && <InputError error={props.error} />}
{...layoutProps}
/>
);
}

View File

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

View File

@@ -0,0 +1,26 @@
import { JSX, splitProps } from "solid-js";
import cx from "classnames";
interface LayoutProps extends JSX.HTMLAttributes<HTMLDivElement> {
field?: JSX.Element;
label?: JSX.Element;
error?: JSX.Element;
}
export const FieldLayout = (props: LayoutProps) => {
const [intern, divProps] = splitProps(props, [
"field",
"label",
"error",
"class",
]);
return (
<div
class={cx("grid grid-cols-10 items-center", intern.class)}
{...divProps}
>
<div class="col-span-5 flex items-center">{props.label}</div>
<div class="col-span-5">{props.field}</div>
{props.error && <span class="col-span-full">{props.error}</span>}
</div>
);
};

View File

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,928 @@
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

@@ -0,0 +1,189 @@
import schema from "@/api/API.json" with { type: "json" };
import { API, Error as ApiError } from "@/api/API";
import { nanoid } from "nanoid";
import { Schema as Inventory } from "@/api/Inventory";
import { toast, Toast } from "solid-toast";
import {
ErrorToastComponent,
CancelToastComponent,
} from "@/src/components/toast";
export type OperationNames = keyof API;
export type OperationArgs<T extends OperationNames> = API[T]["arguments"];
export type OperationResponse<T extends OperationNames> = API[T]["return"];
export type ApiEnvelope<T> =
| {
status: "success";
data: T;
op_key: string;
}
| ApiError;
export type Services = NonNullable<Inventory["services"]>;
export type ServiceNames = keyof Services;
export type ClanService<T extends ServiceNames> = Services[T];
export type ClanServiceInstance<T extends ServiceNames> = NonNullable<
Services[T]
>[string];
export type SuccessQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "success" }
>;
export type SuccessData<T extends OperationNames> = SuccessQuery<T>["data"];
export type ErrorQuery<T extends OperationNames> = Extract<
OperationResponse<T>,
{ status: "error" }
>;
export type ErrorData<T extends OperationNames> = ErrorQuery<T>["errors"];
export type ClanOperations = Record<OperationNames, (str: string) => void>;
export interface GtkResponse<T> {
result: T;
op_key: string;
}
const _callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
// if window[method] does not exist, throw an error
if (!(method in window)) {
console.error(`Method ${method} not found on window object`);
// return a rejected promise
return {
promise: Promise.resolve({
status: "error",
errors: [
{
message: `Method ${method} not found on window object`,
code: "method_not_found",
},
],
op_key: "noop",
}),
op_key: "noop",
};
}
const promise = (
window as unknown as Record<
OperationNames,
(
args: OperationArgs<OperationNames>,
) => Promise<OperationResponse<OperationNames>>
>
)[method](args) as Promise<OperationResponse<K>>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const op_key = (promise as any)._webviewMessageId as string;
return { promise, op_key };
};
const handleCancel = async <K extends OperationNames>(
ops_key: string,
orig_task: Promise<OperationResponse<K>>,
) => {
console.log("Canceling operation: ", ops_key);
const { promise, op_key } = _callApi("cancel_task", { task_id: ops_key });
promise.catch((error) => {
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Unexpected error: " + (error?.message || String(error))}
/>
),
{
duration: 5000,
},
);
console.error("Unhandled promise rejection in callApi:", error);
});
const resp = await promise;
if (resp.status === "error") {
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Failed to cancel operation: " + ops_key}
/>
),
{
duration: 5000,
},
);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(orig_task as any).cancelled = true;
}
console.log("Cancel response: ", resp);
};
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): { promise: Promise<OperationResponse<K>>; op_key: string } => {
console.log("Calling API", method, args);
const { promise, op_key } = _callApi(method, args);
promise.catch((error) => {
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Unexpected error: " + (error?.message || String(error))}
/>
),
{
duration: 5000,
},
);
console.error("Unhandled promise rejection in callApi:", error);
});
const toastId = toast.custom(
(
t, // t is the Toast object, t.id is the id of THIS toast instance
) => (
<CancelToastComponent
t={t}
message={"Executing " + method}
onCancel={handleCancel.bind(null, op_key, promise)}
/>
),
{
duration: Infinity,
},
);
const new_promise = promise.then((response) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cancelled = (promise as any).cancelled;
if (cancelled) {
console.log("Not printing toast because operation was cancelled");
}
if (response.status === "error" && !cancelled) {
toast.remove(toastId);
toast.custom(
(t) => (
<ErrorToastComponent
t={t}
message={"Error: " + response.errors[0].message}
/>
),
{
duration: Infinity,
},
);
} else {
toast.remove(toastId);
}
return response;
});
return { promise: new_promise, op_key: op_key };
};

View File

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,188 @@
import {
createForm,
FieldValues,
getValues,
setValue,
SubmitHandler,
} from "@modular-forms/solid";
import { TextInput } from "@/src/Form/fields/TextInput";
import { Button } from "./components/Button/Button";
import { callApi } from "./api";
import { API } from "@/api/API";
import { createSignal, Match, Switch, For, Show } from "solid-js";
import { Typography } from "./components/Typography";
import { useQuery } from "@tanstack/solid-query";
import { makePersisted } from "@solid-primitives/storage";
import jsonSchema from "@/api/API.json";
interface APITesterForm extends FieldValues {
endpoint: string;
payload: string;
}
const ACTUAL_API_ENDPOINT_NAMES: (keyof API)[] = jsonSchema.required.map(
(key) => key as keyof API,
);
export const ApiTester = () => {
const [persistedTestData, setPersistedTestData] = makePersisted(
createSignal<APITesterForm>(),
{
name: "_test_data",
storage: localStorage,
},
);
const [formStore, { Form, Field }] = createForm<APITesterForm>({
initialValues: persistedTestData() || { endpoint: "", payload: "" },
});
const [endpointSearchTerm, setEndpointSearchTerm] = createSignal(
getValues(formStore).endpoint || "",
);
const [showSuggestions, setShowSuggestions] = createSignal(false);
const filteredEndpoints = () => {
const term = endpointSearchTerm().toLowerCase();
if (!term) return ACTUAL_API_ENDPOINT_NAMES;
return ACTUAL_API_ENDPOINT_NAMES.filter((ep) =>
ep.toLowerCase().includes(term),
);
};
const query = useQuery(() => {
const currentEndpoint = getValues(formStore).endpoint;
const currentPayload = getValues(formStore).payload;
const values = getValues(formStore);
return {
queryKey: ["api-tester", currentEndpoint, currentPayload],
queryFn: async () => {
return await callApi(
values.endpoint as keyof API,
JSON.parse(values.payload || "{}"),
);
},
staleTime: Infinity,
enabled: false,
};
});
const handleSubmit: SubmitHandler<APITesterForm> = (values) => {
console.log(values);
setPersistedTestData(values);
setEndpointSearchTerm(values.endpoint);
query.refetch();
const v = getValues(formStore);
console.log(v);
};
return (
<div class="p-2">
<h1>API Tester</h1>
<Form onSubmit={handleSubmit}>
<div class="flex flex-col">
<Field name="endpoint">
{(field, fieldProps) => (
<div class="relative">
<TextInput
label={"endpoint"}
value={field.value || ""}
inputProps={{
...fieldProps,
onInput: (e: Event) => {
if (fieldProps.onInput) {
(fieldProps.onInput as (ev: Event) => void)(e);
}
setEndpointSearchTerm(
(e.currentTarget as HTMLInputElement).value,
);
setShowSuggestions(true);
},
onBlur: (e: FocusEvent) => {
if (fieldProps.onBlur) {
(fieldProps.onBlur as (ev: FocusEvent) => void)(e);
}
setTimeout(() => setShowSuggestions(false), 150);
},
onFocus: (e: FocusEvent) => {
setEndpointSearchTerm(field.value || "");
setShowSuggestions(true);
},
onKeyDown: (e: KeyboardEvent) => {
if (e.key === "Escape") {
setShowSuggestions(false);
}
},
}}
/>
<Show
when={showSuggestions() && filteredEndpoints().length > 0}
>
<ul class="absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded border border-gray-300 bg-white shadow-lg">
<For each={filteredEndpoints()}>
{(ep) => (
<li
class="cursor-pointer p-2 hover:bg-gray-100"
onMouseDown={(e) => {
e.preventDefault();
setValue(formStore, "endpoint", ep);
setEndpointSearchTerm(ep);
setShowSuggestions(false);
}}
>
{ep}
</li>
)}
</For>
</ul>
</Show>
</div>
)}
</Field>
<Field name="payload">
{(field, fieldProps) => (
<div class="my-2 flex flex-col">
<label class="mb-1 font-medium" for="payload-textarea">
payload
</label>
<textarea
id="payload-textarea"
class="min-h-[120px] resize-y rounded border p-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
placeholder={`{\n "key": "value"\n}`}
value={field.value || ""}
{...fieldProps}
onInput={(e) => {
fieldProps.onInput?.(e);
}}
spellcheck={false}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
</div>
)}
</Field>
<Button class="m-2" disabled={query.isFetching}>
Send
</Button>
</div>
</Form>
<div>
<Typography hierarchy="title" size="default">
Result
</Typography>
<Switch>
<Match when={query.isFetching}>
<span>loading ...</span>
</Match>
<Match when={query.isFetched}>
<pre>
<code>{JSON.stringify(query.data, null, 2)}</code>
</pre>
</Match>
</Switch>
</div>
</div>
);
};

View File

@@ -0,0 +1,16 @@
import { useNavigate } from "@solidjs/router";
import { Button } from "./Button/Button";
import Icon from "./icon";
export const BackButton = () => {
const navigate = useNavigate();
return (
<Button
variant="ghost"
size="s"
class="mr-2"
onClick={() => navigate(-1)}
startIcon={<Icon icon="CaretLeft" />}
></Button>
);
};

View File

@@ -0,0 +1,55 @@
@import "Button-Light.css";
@import "Button-Dark.css";
@import "Button-Ghost.css";
.button {
@apply inline-flex items-center flex-shrink gap-1 justify-center p-4 font-semibold;
letter-spacing: 0.0275rem;
}
/* button SIZES */
.button--default {
padding: theme(padding.2) theme(padding.4);
height: theme(height.9);
border-radius: theme(borderRadius.DEFAULT);
&:has(> .button__icon--start):has(> .button__label) {
padding-left: theme(padding[2.5]);
}
&:has(> .button__icon--end):has(> .button__label) {
padding-right: theme(padding[2.5]);
}
}
.button--small {
padding: theme(padding[1.5]) theme(padding[3]);
height: theme(height.8);
border-radius: 3px;
&:has(> .button__icon--start):has(> .button__label) {
padding-left: theme(padding.2);
}
&:has(> .button__label):has(> .button__icon--end) {
padding-right: theme(padding.2);
}
}
/* button group */
.button-group .button:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.button-group .button:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.button-group .button:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View File

@@ -0,0 +1,31 @@
/* button DARK and states */
.button--dark {
@apply border border-solid border-secondary-950 bg-primary-800 text-white;
box-shadow: inset 1px 1px theme(backgroundColor.secondary.700);
&:disabled {
@apply disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300;
}
& .button__icon {
color: theme(textColor.secondary.200);
}
}
.button--dark-hover:hover {
@apply hover:bg-secondary-900;
}
.button--dark-focus:focus {
@apply focus:border-secondary-900;
}
.button--dark-active:active {
@apply focus:border-secondary-900;
}
.button--dark-active:active {
@apply active:border-secondary-900 active:shadow-button-primary-active;
}

View File

@@ -0,0 +1,11 @@
.button--ghost-hover:hover {
@apply hover:bg-secondary-100 hover:text-secondary-900;
}
.button--ghost-focus:focus {
@apply focus:bg-secondary-200 focus:text-secondary-900;
}
.button--ghost-active:active {
@apply active:bg-secondary-200 active:text-secondary-900 active:shadow-button-primary-active;
}

View File

@@ -0,0 +1,37 @@
/* button LIGHT and states */
.button--light {
@apply border border-solid border-secondary-400 bg-secondary-100 text-secondary-950;
box-shadow: inset 1px 1px theme(backgroundColor.white);
&:disabled {
@apply disabled:bg-secondary-50 disabled:text-secondary-200 disabled:border-secondary-700;
}
& .button__icon {
color: theme(textColor.secondary.900);
}
}
.button--light-hover:hover {
@apply hover:bg-secondary-200;
}
.button--light-focus:focus {
@apply focus:bg-secondary-200;
& .button__label {
color: theme(textColor.secondary.900) !important;
}
}
.button--light-active:active {
@apply active:bg-secondary-200 border-secondary-600 active:text-secondary-900 active:shadow-button-primary-active;
box-shadow: inset 2px 2px theme(backgroundColor.secondary.300);
& .button__label {
color: theme(textColor.secondary.900) !important;
}
}

View File

@@ -0,0 +1,97 @@
import { splitProps, type JSX } from "solid-js";
import cx from "classnames";
import { Typography } from "../Typography";
import "./Button-Base.css";
type Variants = "dark" | "light" | "ghost";
type Size = "default" | "s";
const variantColors: (
disabled: boolean | undefined,
) => Record<Variants, string> = (disabled) => ({
dark: cx(
"button--dark",
!disabled && "button--dark-hover", // Hover state
!disabled && "button--dark-focus", // Focus state
!disabled && "button--dark-active", // Active state
// Disabled
"disabled:bg-secondary-200 disabled:text-secondary-700 disabled:border-secondary-300",
),
light: cx(
"button--light",
!disabled && "button--light-hover", // Hover state
!disabled && "button--light-focus", // Focus state
!disabled && "button--light-active", // Active state
),
ghost: cx(
!disabled && "button--ghost-hover", // Hover state
!disabled && "button--ghost-focus", // Focus state
!disabled && "button--ghost-active", // Active state
),
});
const sizePaddings: Record<Size, string> = {
default: cx("button--default"),
s: cx("button button--small"), //cx("rounded-sm py-[0.375rem] px-3"),
};
const sizeFont: Record<Size, string> = {
default: cx("text-[0.8125rem]"),
s: cx("text-[0.75rem]"),
};
export interface ButtonProps
extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variants;
size?: Size;
children?: JSX.Element;
startIcon?: JSX.Element;
endIcon?: JSX.Element;
class?: string;
}
export const Button = (props: ButtonProps) => {
const [local, other] = splitProps(props, [
"children",
"variant",
"size",
"startIcon",
"endIcon",
"class",
]);
const buttonInvertion = (variant: Variants) => {
return !(!variant || variant === "ghost" || variant === "light");
};
return (
<button
class={cx(
local.class,
"button", // default button class
variantColors(props.disabled)[local.variant || "dark"], // button appereance
sizePaddings[local.size || "default"], // button size
)}
{...other}
>
{local.startIcon && (
<span class="button__icon--start">{local.startIcon}</span>
)}
{local.children && (
<Typography
class="button__label"
hierarchy="label"
size={local.size || "default"}
color="inherit"
inverted={buttonInvertion(local.variant || "dark")}
weight="medium"
tag="span"
>
{local.children}
</Typography>
)}
{local.endIcon && <span class="button__icon--end">{local.endIcon}</span>}
</button>
);
};

View File

@@ -0,0 +1,100 @@
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

@@ -0,0 +1,20 @@
import { type JSX } from "solid-js";
type sizes = "small" | "medium" | "large";
const gapSizes: Record<sizes, string> = {
small: "gap-2",
medium: "gap-4",
large: "gap-6",
};
interface List {
children: JSX.Element;
gapSize: sizes;
}
export const List = (props: List) => {
const { children, gapSize } = props;
return <ul class={`flex flex-col ${gapSizes[gapSize]}`}> {children}</ul>;
};

View File

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

View File

@@ -0,0 +1,84 @@
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

@@ -0,0 +1,293 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import {
RemoteForm,
RemoteData,
Machine,
RemoteDataSource,
} from "./RemoteForm";
import { createSignal } from "solid-js";
import { fn } from "@storybook/test";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
// Default values for the form
const defaultRemoteData: RemoteData = {
address: "",
user: "",
command_prefix: "sudo",
port: undefined,
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: 0,
verbose_ssh: false,
ssh_options: {},
tor_socks: false,
};
// Sample data for populated form
const sampleRemoteData: RemoteData = {
address: "example.com",
user: "admin",
command_prefix: "sudo",
port: 22,
private_key: undefined,
password: "",
forward_agent: true,
host_key_check: 1,
verbose_ssh: false,
ssh_options: {
StrictHostKeyChecking: "no",
UserKnownHostsFile: "/dev/null",
},
tor_socks: false,
};
// Sample machine data for testing
const sampleMachine: Machine = {
name: "test-machine",
flake: {
identifier: "git+https://git.example.com/test-repo",
},
};
// Create a query client for stories
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
// Interactive wrapper component for Storybook
const RemoteFormWrapper = (props: {
initialData: RemoteData;
disabled?: boolean;
machine: Machine;
field?: "targetHost" | "buildHost";
queryFn?: (params: {
name: string;
flake: { identifier: string };
field: string;
}) => Promise<RemoteDataSource | null>;
onSave?: (data: RemoteData) => void | Promise<void>;
showSave?: boolean;
}) => {
const [formData, setFormData] = createSignal(props.initialData);
const [saveMessage, setSaveMessage] = createSignal("");
return (
<QueryClientProvider client={queryClient}>
<div class="max-w-2xl p-6">
<h2 class="mb-6 text-2xl font-bold">Remote Configuration</h2>
<RemoteForm
onInput={(newData) => {
setFormData(newData);
// Log changes for Storybook actions
console.log("Form data changed:", newData);
}}
disabled={props.disabled}
machine={props.machine}
field={props.field}
queryFn={props.queryFn}
onSave={props.onSave}
showSave={props.showSave}
/>
{/* Display save message if present */}
{saveMessage() && (
<div class="mt-4 rounded bg-green-100 p-3 text-green-800">
{saveMessage()}
</div>
)}
{/* Display current form state */}
<details class="mt-8">
<summary class="cursor-pointer font-semibold">
Current Form Data (Debug)
</summary>
<pre class="mt-2 overflow-auto rounded bg-gray-100 p-4 text-sm">
{JSON.stringify(formData(), null, 2)}
</pre>
</details>
</div>
</QueryClientProvider>
);
};
const meta: Meta<typeof RemoteFormWrapper> = {
title: "Components/RemoteForm",
component: RemoteFormWrapper,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"A form component for configuring remote SSH connection settings. Based on the Remote Python class with fields for address, authentication, and SSH options.",
},
},
},
argTypes: {
disabled: {
control: "boolean",
description: "Disable all form inputs",
},
machine: {
control: "object",
description: "Machine configuration for API queries",
},
field: {
control: "select",
options: ["targetHost", "buildHost"],
description: "Field type for API queries",
},
showSave: {
control: "boolean",
description: "Show or hide the save button",
},
onSave: {
action: "saved",
description: "Custom save handler function",
},
},
};
export default meta;
type Story = StoryObj<typeof RemoteFormWrapper>;
export const Empty: Story = {
args: {
initialData: defaultRemoteData,
disabled: false,
machine: sampleMachine,
queryFn: async () => ({
source: "inventory" as const,
data: {
address: "",
user: "",
command_prefix: "",
port: undefined,
private_key: undefined,
password: "",
forward_agent: false,
host_key_check: 0,
verbose_ssh: false,
ssh_options: {},
tor_socks: false,
},
}),
},
parameters: {
docs: {
description: {
story:
"Empty form with default values. All fields start empty except for boolean defaults.",
},
},
test: {
timeout: 30000,
},
},
};
export const Populated: Story = {
args: {
initialData: sampleRemoteData,
disabled: false,
machine: sampleMachine,
queryFn: async () => ({
source: "inventory" as const,
data: sampleRemoteData,
}),
},
parameters: {
docs: {
description: {
story:
"Form pre-populated with sample data showing all field types in use.",
},
},
test: {
timeout: 30000,
},
},
};
export const Disabled: Story = {
args: {
initialData: sampleRemoteData,
disabled: true,
machine: sampleMachine,
},
parameters: {
docs: {
description: {
story: "All form fields in disabled state. Useful for read-only views.",
},
},
},
};
// Advanced example with custom SSH options
const advancedRemoteData: RemoteData = {
address: "192.168.1.100",
user: "deploy",
command_prefix: "doas",
port: 2222,
private_key: undefined,
password: "",
forward_agent: false,
host_key_check: 2,
verbose_ssh: true,
ssh_options: {
ConnectTimeout: "10",
ServerAliveInterval: "60",
ServerAliveCountMax: "3",
Compression: "yes",
TCPKeepAlive: "yes",
},
tor_socks: true,
};
export const NixManaged: Story = {
args: {
initialData: advancedRemoteData,
disabled: false,
machine: sampleMachine,
queryFn: async () => ({
source: "nix_machine" as const,
data: advancedRemoteData,
}),
},
parameters: {
docs: {
description: {
story:
"Configuration managed by Nix with advanced settings. Shows the locked state with unlock option.",
},
},
},
};
export const HiddenSaveButton: Story = {
args: {
initialData: sampleRemoteData,
disabled: false,
machine: sampleMachine,
showSave: false,
queryFn: async () => ({
source: "inventory" as const,
data: sampleRemoteData,
}),
},
parameters: {
docs: {
description: {
story:
"Form with the save button hidden. Useful when save functionality is handled externally.",
},
},
},
};

View File

@@ -0,0 +1,464 @@
import { createSignal, createEffect, JSX, Show } from "solid-js";
import { useQuery } from "@tanstack/solid-query";
import { callApi, SuccessQuery } from "@/src/api";
import { TextInput } from "@/src/Form/fields/TextInput";
import { SelectInput } from "@/src/Form/fields/Select";
import { FileInput } from "@/src/components/FileInput";
import { FieldLayout } from "@/src/Form/fields/layout";
import { InputLabel, InputBase } from "@/src/components/inputBase";
import Icon from "@/src/components/icon";
import { Loader } from "@/src/components/v2/Loader/Loader";
import { Button } from "@/src/components/v2/Button/Button";
import Accordion from "@/src/components/accordion";
// Define the HostKeyCheck enum values with proper API mapping
export enum HostKeyCheck {
ASK = 0,
TOFU = 1,
IGNORE = 2,
}
// Export the API types for use in other components
export type { RemoteData, Machine, RemoteDataSource };
type RemoteDataSource = SuccessQuery<"get_host">["data"];
type MachineListData = SuccessQuery<"list_machines">["data"][string];
type RemoteData = NonNullable<RemoteDataSource>["data"];
// Machine type with flake for API calls
interface Machine {
name: string;
flake: {
identifier: string;
};
}
interface CheckboxInputProps {
label: JSX.Element;
value: boolean;
onInput: (value: boolean) => void;
help?: string;
class?: string;
disabled?: boolean;
}
function CheckboxInput(props: CheckboxInputProps) {
return (
<FieldLayout
label={
<InputLabel class="col-span-2" help={props.help}>
{props.label}
</InputLabel>
}
field={
<div class="col-span-10 flex items-center">
<input
type="checkbox"
checked={props.value}
onChange={(e) => props.onInput(e.currentTarget.checked)}
disabled={props.disabled}
class="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</div>
}
class={props.class}
/>
);
}
interface KeyValueInputProps {
label: JSX.Element;
value: Record<string, string>;
onInput: (value: Record<string, string>) => void;
help?: string;
class?: string;
disabled?: boolean;
}
function KeyValueInput(props: KeyValueInputProps) {
const [newKey, setNewKey] = createSignal("");
const [newValue, setNewValue] = createSignal("");
const addPair = () => {
const key = newKey().trim();
const value = newValue().trim();
if (key && value) {
props.onInput({ ...props.value, [key]: value });
setNewKey("");
setNewValue("");
}
};
const removePair = (key: string) => {
const { [key]: _, ...newObj } = props.value;
props.onInput(newObj);
};
return (
<FieldLayout
label={
<InputLabel class="col-span-2" help={props.help}>
{props.label}
</InputLabel>
}
field={
<div class="col-span-10 space-y-2">
{/* Existing pairs */}
{Object.entries(props.value).map(([key, value]) => (
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{key}:</span>
<span class="text-sm">{value}</span>
<button
type="button"
onClick={() => removePair(key)}
class="text-red-600 hover:text-red-800"
disabled={props.disabled}
>
×
</button>
</div>
))}
{/* Add new pair */}
<div class="flex gap-2">
<input
type="text"
placeholder="Key"
value={newKey()}
onInput={(e) => setNewKey(e.currentTarget.value)}
disabled={props.disabled}
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<input
type="text"
placeholder="Value"
value={newValue()}
onInput={(e) => setNewValue(e.currentTarget.value)}
disabled={props.disabled}
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
/>
<button
type="button"
onClick={addPair}
disabled={
props.disabled || !newKey().trim() || !newValue().trim()
}
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
</div>
}
class={props.class}
/>
);
}
interface RemoteFormProps {
onInput?: (value: RemoteData) => void;
machine: Machine;
field?: "targetHost" | "buildHost";
disabled?: boolean;
// Optional query function for testing/mocking
queryFn?: (params: {
name: string;
flake: {
identifier: string;
hash?: string | null;
store_path?: string | null;
};
field: string;
}) => Promise<RemoteDataSource | null>;
// Optional save handler for custom save behavior (e.g., in Storybook)
onSave?: (data: RemoteData) => void | Promise<void>;
// Show/hide save button
showSave?: boolean;
}
export function RemoteForm(props: RemoteFormProps) {
const [isLocked, setIsLocked] = createSignal(true);
const [source, setSource] = createSignal<"inventory" | "nix_machine" | null>(
null,
);
const [privateKeyFile, setPrivateKeyFile] = createSignal<File | undefined>();
const [formData, setFormData] = createSignal<RemoteData | null>(null);
const [isSaving, setIsSaving] = createSignal(false);
const hostKeyCheckOptions = [
{ value: "ASK", label: "Ask" },
{ value: "TOFU", label: "TOFU (Trust On First Use)" },
{ value: "IGNORE", label: "Ignore" },
];
// Helper function to convert enum name to numeric value
const getHostKeyCheckValue = (name: string): number => {
switch (name) {
case "ASK":
return HostKeyCheck.ASK;
case "TOFU":
return HostKeyCheck.TOFU;
case "IGNORE":
return HostKeyCheck.IGNORE;
default:
return HostKeyCheck.ASK;
}
};
// Helper function to convert numeric value to enum name
const getHostKeyCheckName = (value: number | undefined): string => {
switch (value) {
case HostKeyCheck.ASK:
return "ASK";
case HostKeyCheck.TOFU:
return "TOFU";
case HostKeyCheck.IGNORE:
return "IGNORE";
default:
return "ASK";
}
};
// Query host data when machine is provided
const hostQuery = useQuery(() => ({
queryKey: [
"get_host",
props.machine,
props.queryFn,
props.machine?.name,
props.machine?.flake,
props.field || "targetHost",
],
queryFn: async () => {
if (!props.machine) return null;
// Use custom query function if provided (for testing/mocking)
if (props.queryFn) {
return props.queryFn({
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
});
}
const result = await callApi("get_host", {
name: props.machine.name,
flake: props.machine.flake,
field: props.field || "targetHost",
}).promise;
if (result.status === "error")
throw new Error("Failed to fetch host data");
return result.data;
},
enabled: !!props.machine,
}));
// Update form data and lock state when host data is loaded
createEffect(() => {
const hostData = hostQuery.data;
if (hostData?.data) {
setSource(hostData.source);
setIsLocked(hostData.source === "nix_machine");
setFormData(hostData.data);
props.onInput?.(hostData.data);
}
});
const isFormDisabled = () =>
props.disabled || (source() === "nix_machine" && isLocked());
const computedDisabled = isFormDisabled();
const updateFormData = (updates: Partial<RemoteData>) => {
const current = formData();
if (current) {
const updated = { ...current, ...updates };
setFormData(updated);
props.onInput?.(updated);
}
};
const handleSave = async () => {
const data = formData();
if (!data || isSaving()) return;
setIsSaving(true);
try {
if (props.onSave) {
await props.onSave(data);
} else {
// Default save behavior - could be extended with API call
console.log("Saving remote data:", data);
}
} catch (error) {
console.error("Error saving remote data:", error);
} finally {
setIsSaving(false);
}
};
return (
<div class="space-y-4">
<Show when={hostQuery.isLoading}>
<div class="flex justify-center p-8">
<Loader />
</div>
</Show>
<Show when={!hostQuery.isLoading && formData()}>
{/* Lock header for nix_machine source */}
<Show when={source() === "nix_machine"}>
<div class="flex items-center justify-between rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="flex items-center gap-2">
<Icon icon="Warning" class="size-5 text-amber-600" />
<span class="text-sm font-medium text-amber-800">
Configuration managed by Nix
</span>
</div>
<button
type="button"
onClick={() => setIsLocked(!isLocked())}
class="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-amber-700 hover:bg-amber-100"
>
<Icon icon={isLocked() ? "Settings" : "Edit"} class="size-3" />
{isLocked() ? "Unlock to edit" : "Lock"}
</button>
</div>
</Show>
{/* Basic Connection Fields - Always Visible */}
<TextInput
label="User"
value={formData()?.user || ""}
inputProps={{
onInput: (e) => updateFormData({ user: e.currentTarget.value }),
}}
placeholder="username"
required
disabled={computedDisabled}
help="Username to connect as on the remote server"
/>
<TextInput
label="Address"
value={formData()?.address || ""}
inputProps={{
onInput: (e) => updateFormData({ address: e.currentTarget.value }),
}}
placeholder="hostname or IP address"
required
disabled={computedDisabled}
help="The hostname or IP address of the remote server"
/>
{/* Advanced Options - Collapsed by Default */}
<Accordion title="Advanced Options" class="mt-6">
<div class="space-y-4 pt-2">
<TextInput
label="Port"
value={formData()?.port?.toString() || ""}
inputProps={{
type: "number",
onInput: (e) => {
const value = e.currentTarget.value;
updateFormData({
port: value ? parseInt(value, 10) : undefined,
});
},
}}
placeholder="22"
disabled={computedDisabled}
help="SSH port (defaults to 22 if not specified)"
/>
<SelectInput
label="Host Key Check"
value={getHostKeyCheckName(formData()?.host_key_check)}
options={hostKeyCheckOptions}
selectProps={{
onInput: (e) =>
updateFormData({
host_key_check: getHostKeyCheckValue(
e.currentTarget.value,
) as 0 | 1 | 2 | 3,
}),
}}
disabled={computedDisabled}
helperText="How to handle host key verification"
/>
<Show when={typeof window !== "undefined"}>
<FieldLayout
label={
<InputLabel
class="col-span-2"
help="SSH private key file for authentication"
>
Private Key
</InputLabel>
}
field={
<div class="col-span-10">
<FileInput
name="private_key"
accept=".pem,.key,*"
value={privateKeyFile()}
onInput={(e) => {
const file = e.currentTarget.files?.[0];
setPrivateKeyFile(file);
updateFormData({
private_key: file?.name || null,
});
}}
onChange={() => void 0}
onBlur={() => void 0}
onClick={() => void 0}
ref={() => void 0}
placeholder={<>Click to select private key file</>}
class="w-full"
/>
</div>
}
/>
</Show>
<CheckboxInput
label="Forward Agent"
value={formData()?.forward_agent || false}
onInput={(value) => updateFormData({ forward_agent: value })}
disabled={computedDisabled}
help="Enable SSH agent forwarding"
/>
<KeyValueInput
label="SSH Options"
value={formData()?.ssh_options || {}}
onInput={(value) => updateFormData({ ssh_options: value })}
disabled={computedDisabled}
help="Additional SSH options as key-value pairs"
/>
<CheckboxInput
label="Tor SOCKS"
value={formData()?.tor_socks || false}
onInput={(value) => updateFormData({ tor_socks: value })}
disabled={computedDisabled}
help="Use Tor SOCKS proxy for SSH connection"
/>
</div>
</Accordion>
{/* Save Button */}
<Show when={props.showSave !== false}>
<div class="flex justify-end pt-4">
<Button
onClick={handleSave}
disabled={computedDisabled || isSaving()}
class="min-w-24"
>
{isSaving() ? "Saving..." : "Save"}
</Button>
</div>
</Show>
</Show>
</div>
);
}

View File

@@ -0,0 +1,54 @@
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

@@ -0,0 +1,14 @@
import { List } from "@/src/components/Helpers";
import { SidebarListItem } from "../SidebarListItem";
export const SidebarFlyout = () => {
return (
<div class="sidebar__flyout">
<div class="sidebar__flyout__inner">
<List gapSize="small">
<SidebarListItem href="/clans" title="Settings" />
</List>
</div>
</div>
);
};

View File

@@ -0,0 +1,71 @@
import { createSignal, Show } from "solid-js";
import { Typography } from "@/src/components/Typography";
import { SidebarFlyout } from "./SidebarFlyout";
import "./css/sidebar.css";
import Icon from "../icon";
interface SidebarProps {
clanName: string;
showFlyout?: () => boolean;
}
const ClanProfile = (props: SidebarProps) => {
return (
<div
class={`sidebar__profile ${props.showFlyout?.() ? "sidebar__profile--flyout" : ""}`}
>
<Typography
class="sidebar__profile__character"
tag="span"
hierarchy="title"
size="m"
weight="bold"
color="primary"
inverted={true}
>
{props.clanName.slice(0, 1).toUpperCase()}
</Typography>
</div>
);
};
const ClanTitle = (props: SidebarProps) => {
return (
<Typography
tag="h3"
hierarchy="body"
size="default"
weight="medium"
color="primary"
inverted={true}
>
{props.clanName}
</Typography>
);
};
export const SidebarHeader = (props: SidebarProps) => {
const [showFlyout, toggleFlyout] = createSignal(false);
function handleClick() {
toggleFlyout(!showFlyout());
}
return (
<header class="sidebar__header">
<div onClick={handleClick} class="sidebar__header__inner">
{/* <ClanProfile clanName={props.clanName} showFlyout={showFlyout} /> */}
<div class="w-full pl-1 text-white">
<ClanTitle clanName={props.clanName} />
</div>
<Show
when={showFlyout}
fallback={<Icon size={12} class="text-white" icon="CaretDown" />}
>
<Icon size={12} class="text-white" icon="CaretDown" />
</Show>
</div>
{showFlyout() && <SidebarFlyout />}
</header>
);
};

View File

@@ -0,0 +1,30 @@
import { A } from "@solidjs/router";
import { Typography } from "@/src/components/Typography";
import "./css/sidebar.css";
interface SidebarListItem {
title: string;
href: string;
}
export const SidebarListItem = (props: SidebarListItem) => {
const { title, href } = props;
return (
<li class="">
<A class="sidebar__list__link" href={href}>
<Typography
class="sidebar__list__content"
tag="span"
hierarchy="body"
size="xs"
weight="normal"
color="primary"
inverted={true}
>
{title}
</Typography>
</A>
</li>
);
};

View File

@@ -0,0 +1,21 @@
.sidebar__flyout {
top: 0;
position: absolute;
z-index: theme(zIndex.30);
padding: theme(padding[1]);
width: 100%;
height: auto;
}
.sidebar__flyout__inner {
position: relative;
width: inherit;
height: inherit;
padding: theme(padding.12) theme(padding.3) theme(padding.3);
background-color: var(--clr-bg-inv-4);
/* / 0.95); */
border: 1px solid var(--clr-border-inv-4);
border-radius: theme(borderRadius.lg);
}

View File

@@ -0,0 +1,30 @@
.sidebar__header {
position: relative;
padding: 1px 1px 0;
cursor: pointer;
&:after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--clr-bg-inv-3);
border-bottom: 1px solid var(--clr-border-inv-3);
border-top-left-radius: theme(borderRadius.xl);
border-top-right-radius: theme(borderRadius.xl);
}
}
.sidebar__header__inner {
position: relative;
z-index: theme(zIndex.40);
display: flex;
align-items: center;
gap: 0 theme(gap.3);
padding: theme(padding.3) theme(padding.3);
}

View File

@@ -0,0 +1,52 @@
.sidebar__list__link {
position: relative;
cursor: theme(cursor.pointer);
&:after {
content: "";
position: absolute;
z-index: theme(zIndex.10);
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: theme(borderRadius.md);
transform: scale(0.98);
transition: transform 0.24s ease-in-out;
}
&:hover:after {
background: var(--clr-bg-inv-acc-2);
transform: scale(theme(scale.100));
transition: transform 0.32s ease-in-out;
}
&:active {
transform: scale(0.99);
transition: transform 0.12s ease-in-out;
}
&:active:after {
background: var(--clr-bg-inv-acc-3);
transform: scale(theme(scale.100));
}
}
.sidebar__list__link {
position: relative;
z-index: 20;
display: block;
padding: theme(padding.2) theme(padding.3);
}
.sidebar__list__link.active {
&:after {
background: var(--clr-bg-inv-acc-3);
}
}
.sidebar__list__content {
position: relative;
z-index: 20;
}

View File

@@ -0,0 +1,19 @@
.sidebar__profile {
display: flex;
justify-content: center;
align-items: center;
width: theme(width.8);
height: theme(height.8);
background: var(--clr-bg-inv-4);
border-radius: 50%;
}
.sidebar__profile--flyout {
background: var(--clr-bg-def-2);
}
.sidebar__profile--flyout > .sidebar__profile__character {
color: var(--clr-fg-def-1) !important;
}

View File

@@ -0,0 +1,32 @@
/* Sidebar Elements */
@import "./sidebar-header";
@import "./sidebar-flyout";
@import "./sidebar-list-item";
@import "./sidebar-profile";
/* Sidebar Structure */
.sidebar {
@apply bg-inv-2 h-full border border-solid border-inv-2 min-w-72 rounded-xl;
display: flex;
flex-direction: column;
}
.sidebar__body {
display: flex;
flex-direction: column;
gap: theme(padding.2);
padding: theme(padding.4) theme(padding.2);
}
.sidebar__section {
@apply bg-primary-800/90;
padding: theme(padding.2);
border-radius: theme(borderRadius.md);
::marker {
content: "";
}
}

View File

@@ -0,0 +1,85 @@
import { For, type JSX, Show } from "solid-js";
import { RouteSectionProps } from "@solidjs/router";
import { AppRoute, routes } from "@/src";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarListItem } from "./SidebarListItem";
import { Typography } from "../Typography";
import "./css/sidebar.css";
import Icon, { IconVariant } from "../icon";
import { clanMetaQuery } from "@/src/queries/clan-meta";
export const SidebarSection = (props: {
title: string;
icon: IconVariant;
children: JSX.Element;
}) => {
const { title, children } = props;
return (
<details class="sidebar__section accordeon" open>
<summary style="display: contents;">
<div class="accordeon__header">
<Typography
class="inline-flex w-full gap-2 uppercase !tracking-wider"
tag="p"
hierarchy="body"
size="xxs"
weight="normal"
color="tertiary"
inverted={true}
>
<Icon class="opacity-90" icon={props.icon} size={13} />
{title}
<Icon icon="CaretDown" class="ml-auto" size={10} />
</Typography>
</div>
</summary>
<div class="accordeon__body">{children}</div>
</details>
);
};
export const Sidebar = (props: RouteSectionProps) => {
const query = clanMetaQuery();
return (
<div class="sidebar">
<Show
when={query.data}
fallback={<SidebarHeader clanName={"Untitled"} />}
>
{(meta) => <SidebarHeader clanName={meta().name} />}
</Show>
<div class="sidebar__body max-h-[calc(100vh-4rem)] overflow-scroll">
<For each={routes.filter((r) => !r.hidden)}>
{(route: AppRoute) => (
<Show
when={route.children}
fallback={
<SidebarListItem href={route.path} title={route.label} />
}
>
{(children) => (
<SidebarSection
title={route.label}
icon={route.icon || "Paperclip"}
>
<ul class="flex flex-col gap-y-0.5">
<For each={children().filter((r) => !r.hidden)}>
{(child) => (
<SidebarListItem
href={`${route.path}${child.path}`}
title={child.label}
/>
)}
</For>
</ul>
</SidebarSection>
)}
</Show>
)}
</For>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,23 @@
.fnt-clr-primary {
color: var(--clr-fg-def-1);
}
.fnt-clr-secondary {
color: var(--clr-fg-def-2);
}
.fnt-clr-tertiary {
color: var(--clr-fg-def-3);
}
.fnt-clr-primary.fnt-clr--inverted {
color: var(--clr-fg-inv-1);
}
.fnt-clr-secondary.fnt-clr--inverted {
color: var(--clr-fg-inv-2);
}
.fnt-clr-tertiary.fnt-clr--inverted {
color: var(--clr-fg-inv-3);
}

View File

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

View File

@@ -0,0 +1,23 @@
.fnt-body-default {
font-size: 1rem;
line-height: 132%;
letter-spacing: 3%;
}
.fnt-body-s {
font-size: 0.925rem;
line-height: 132%;
letter-spacing: 3%;
}
.fnt-body-xs {
font-size: 0.875rem;
line-height: 132%;
letter-spacing: 3%;
}
.fnt-body-xxs {
font-size: 0.75rem;
line-height: 132%;
letter-spacing: 0.00688rem;
}

View File

@@ -0,0 +1,17 @@
.fnt-headline-default {
font-size: 1.5rem;
line-height: 116%;
letter-spacing: 1%;
}
.fnt-headline-m {
font-size: 1.75rem;
line-height: 116%;
letter-spacing: 1%;
}
.fnt-headline-l {
font-size: 2rem;
line-height: 116%;
letter-spacing: 1%;
}

View File

@@ -0,0 +1,14 @@
.fnt-label-default {
font-size: 0.8125rem;
line-height: 100%;
}
.fnt-label-s {
font-size: 0.75rem;
line-height: 100%;
}
.fnt-label-xs {
font-size: 0.6875rem;
line-height: 100%;
}

View File

@@ -0,0 +1,17 @@
.fnt-title-default {
font-size: 1.125rem;
line-height: 124%;
letter-spacing: 3%;
}
.fnt-title-m {
font-size: 1.25rem;
line-height: 124%;
letter-spacing: 3%;
}
.fnt-title-l {
font-size: 1.375rem;
line-height: 124%;
letter-spacing: 3%;
}

View File

@@ -0,0 +1,26 @@
@import "./typography-hierarchy/";
@import "./typography-color.css";
.fnt-weight-normal {
font-weight: 300;
}
.fnt-weight-medium {
font-weight: 500;
}
.fnt-weight-bold {
font-weight: 700;
}
.fnt-weight-normal.fnt-clr--inverted {
font-weight: 300;
}
.fnt-weight-medium.fnt-clr--inverted {
font-weight: 400;
}
.fnt-weight-bold.fnt-clr--inverted {
font-weight: 700;
}

View File

@@ -0,0 +1,109 @@
import { type JSX } from "solid-js";
import { Dynamic } from "solid-js/web";
import cx from "classnames";
import "./css/typography.css";
export type Hierarchy = "body" | "title" | "headline" | "label";
type Color = "primary" | "secondary" | "tertiary";
type Weight = "normal" | "medium" | "bold";
type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
const colorMap: Record<Color, string> = {
primary: cx("fnt-clr-primary"),
secondary: cx("fnt-clr-secondary"),
tertiary: cx("fnt-clr-tertiary"),
};
// type Size = "default" | "xs" | "s" | "m" | "l";
interface SizeForHierarchy {
label: {
default: string;
xs: string;
s: string;
};
body: {
default: string;
xs: string;
xxs: string;
s: string;
};
headline: {
default: string;
m: string;
l: string;
};
title: {
default: string;
m: string;
l: string;
};
}
type AllowedSizes<H extends Hierarchy> = keyof SizeForHierarchy[H];
const sizeHierarchyMap: SizeForHierarchy = {
body: {
default: cx("fnt-body-default"),
s: cx("fnt-body-s"),
xs: cx("fnt-body-xs"),
xxs: cx("fnt-body-xxs"),
},
headline: {
default: cx("fnt-headline-default"),
// xs: cx("fnt-headline-xs"),
// s: cx("fnt-headline-s"),
m: cx("fnt-headline-m"),
l: cx("fnt-headline-l"),
},
title: {
default: cx("fnt-title-default"),
// xs: cx("fnt-title-xs"),
// s: cx("fnt-title-s"),
m: cx("fnt-title-m"),
l: cx("fnt-title-l"),
},
label: {
default: cx("fnt-label-default"),
s: cx("fnt-label-s"),
xs: cx("fnt-label-xs"),
},
};
const weightMap: Record<Weight, string> = {
normal: cx("fnt-weight-normal"),
medium: cx("fnt-weight-medium"),
bold: cx("fnt-weight-bold"),
};
interface _TypographyProps<H extends Hierarchy> {
hierarchy: H;
size: AllowedSizes<H>;
children: JSX.Element;
weight?: Weight;
color?: Color | "inherit";
inverted?: boolean;
tag?: Tag;
class?: string;
classList?: Record<string, boolean>;
}
export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
return (
<Dynamic
component={props.tag || "span"}
class={cx(
props.color === "inherit" && "text-inherit",
props.color !== "inherit" && colorMap[props.color || "primary"],
props.inverted && "fnt-clr--inverted",
sizeHierarchyMap[props.hierarchy][props.size] as string,
weightMap[props.weight || "normal"],
props.class,
)}
classList={props.classList}
>
{props.children}
</Dynamic>
);
};
export type TypographyProps = _TypographyProps<Hierarchy>;

View File

@@ -0,0 +1,10 @@
.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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,39 @@
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

@@ -0,0 +1,194 @@
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

@@ -0,0 +1,60 @@
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

@@ -0,0 +1,99 @@
import { Component, JSX, splitProps } from "solid-js";
import ArrowBottom from "@/icons/arrow-bottom.svg";
import ArrowLeft from "@/icons/arrow-left.svg";
import ArrowRight from "@/icons/arrow-right.svg";
import ArrowTop from "@/icons/arrow-top.svg";
import Attention from "@/icons/attention.svg";
import CaretDown from "@/icons/caret-down.svg";
import CaretLeft from "@/icons/caret-left.svg";
import CaretRight from "@/icons/caret-right.svg";
import CaretUp from "@/icons/caret-up.svg";
import Checkmark from "@/icons/checkmark.svg";
import ClanIcon from "@/icons/clan-icon.svg";
import ClanLogo from "@/icons/clan-logo.svg";
import Close from "@/icons/close.svg";
import Download from "@/icons/download.svg";
import Edit from "@/icons/edit.svg";
import Expand from "@/icons/expand.svg";
import EyeClose from "@/icons/eye-close.svg";
import EyeOpen from "@/icons/eye-open.svg";
import Filter from "@/icons/filter.svg";
import Flash from "@/icons/flash.svg";
import Folder from "@/icons/folder.svg";
import Grid from "@/icons/grid.svg";
import Info from "@/icons/info.svg";
import List from "@/icons/list.svg";
import Load from "@/icons/load.svg";
import More from "@/icons/more.svg";
import Paperclip from "@/icons/paperclip.svg";
import Plus from "@/icons/plus.svg";
import Reload from "@/icons/reload.svg";
import Report from "@/icons/report.svg";
import Search from "@/icons/search.svg";
import Settings from "@/icons/settings.svg";
import Trash from "@/icons/trash.svg";
import Update from "@/icons/update.svg";
import Warning from "@/icons/warning-filled.svg";
const icons = {
ArrowBottom,
ArrowLeft,
ArrowRight,
ArrowTop,
Attention,
CaretDown,
CaretLeft,
CaretRight,
CaretUp,
Checkmark,
ClanIcon,
ClanLogo,
Close,
Download,
Edit,
Expand,
EyeClose,
EyeOpen,
Filter,
Flash,
Folder,
Grid,
Info,
List,
Load,
More,
Paperclip,
Plus,
Reload,
Report,
Search,
Settings,
Trash,
Update,
Warning,
};
export type IconVariant = keyof typeof icons;
interface IconProps extends JSX.SvgSVGAttributes<SVGElement> {
icon: IconVariant;
size?: number;
}
const Icon: Component<IconProps> = (props) => {
const [local, iconProps] = splitProps(props, ["icon"]);
const IconComponent = icons[local.icon];
return IconComponent ? (
<IconComponent
width={iconProps.size || 16}
height={iconProps.size || 16}
viewBox="0 0 48 48"
// @ts-expect-error: dont know, fix this type nit later
ref={iconProps.ref}
{...iconProps}
/>
) : null;
};
export default Icon;

View File

@@ -0,0 +1,194 @@
import cx from "classnames";
import { JSX, Ref, Show, splitProps } from "solid-js";
import Icon, { IconVariant } from "../icon";
import { Typography, TypographyProps } from "../Typography";
export type InputVariant = "outlined" | "ghost";
interface InputBaseProps {
variant?: InputVariant;
value?: string;
inputProps?: JSX.InputHTMLAttributes<HTMLInputElement>;
required?: boolean;
type?: string;
inlineLabel?: JSX.Element;
class?: string;
placeholder?: string;
disabled?: boolean;
readonly?: boolean;
error?: boolean;
icon?: IconVariant;
/** Overrides the input element */
inputElem?: JSX.Element;
divRef?: Ref<HTMLDivElement>;
}
const variantBorder: Record<InputVariant, string> = {
outlined: "border border-inv-3",
ghost: "",
};
const fgStateClasses = cx("aria-disabled:fg-def-4 aria-readonly:fg-def-3");
export const InputBase = (props: InputBaseProps) => {
const [internal, inputProps] = splitProps(props, ["class", "divRef"]);
return (
<div
// eslint-disable-next-line tailwindcss/no-custom-classname
class={cx(
// Layout
"flex px-2 py-[0.375rem] flex-shrink-0 items-center justify-center gap-2 text-sm leading-6",
// Background
"bg-def-1 hover:bg-def-acc-1",
// Text
"fg-def-1",
fgStateClasses,
// Border
variantBorder[props.variant || "outlined"],
"rounded-sm",
"hover:border-inv-4",
"aria-disabled:border-def-2 aria-disabled:border",
// Outline
"outline-offset-1 outline-1",
"active:outline active:outline-inv-3",
"focus-visible:outline-double focus-visible:outline-int-1",
// Cursor
"aria-readonly:cursor-no-drop",
props.class,
)}
classList={{
// eslint-disable-next-line tailwindcss/no-custom-classname
[cx("!border !border-semantic-1 !outline-semantic-1")]: !!props.error,
}}
aria-invalid={props.error}
aria-disabled={props.disabled}
aria-readonly={props.readonly}
tabIndex={0}
role="textbox"
ref={internal.divRef}
>
{props.icon && (
<i
class={cx("inline-flex fg-def-2", fgStateClasses)}
aria-invalid={props.error}
aria-disabled={props.disabled}
aria-readonly={props.readonly}
>
<Icon icon={props.icon} font-size="inherit" color="inherit" />
</i>
)}
<Show when={!props.inputElem} fallback={props.inputElem}>
<input
tabIndex={-1}
class="w-full bg-transparent outline-none"
value={props.value}
type={props.type ? props.type : "text"}
readOnly={props.readonly}
placeholder={`${props.placeholder || ""}`}
required={props.required}
disabled={props.disabled}
aria-invalid={props.error}
aria-disabled={props.disabled}
aria-readonly={props.readonly}
{...inputProps}
/>
</Show>
</div>
);
};
export interface InputLabelProps
extends JSX.LabelHTMLAttributes<HTMLLabelElement> {
description?: string;
required?: boolean;
error?: boolean;
help?: string;
labelAction?: JSX.Element;
}
export const InputLabel = (props: InputLabelProps) => {
const [labelProps, forwardProps] = splitProps(props, [
"class",
"labelAction",
]);
return (
<label
class={cx("flex items-center gap-1", labelProps.class)}
{...forwardProps}
>
<span class="flex flex-col justify-center">
<span>
<Typography
hierarchy="label"
size="default"
weight="bold"
class="inline-flex gap-1 align-middle !fg-def-1"
classList={{
[cx("!fg-semantic-1")]: !!props.error,
}}
aria-invalid={props.error}
>
{props.children}
</Typography>
{props.required && (
<Typography
class="inline-flex px-1 align-text-top leading-[0.5] fg-def-4"
color="inherit"
hierarchy="label"
weight="bold"
size="xs"
>
<>&#42;</>
</Typography>
)}
{props.help && (
<span
class=" inline px-2"
data-tip={props.help}
style={{
"--tooltip-color": "#EFFFFF",
"--tooltip-text-color": "#0D1416",
"--tooltip-tail": "0.8125rem",
}}
>
<Icon class="inline fg-def-3" icon={"Info"} width={"0.8125rem"} />
</span>
)}
</span>
<Typography
hierarchy="body"
size="xs"
weight="normal"
color="secondary"
>
{props.description}
</Typography>
</span>
{props.labelAction}
</label>
);
};
interface InputErrorProps {
error: string;
typographyProps?: TypographyProps;
}
export const InputError = (props: InputErrorProps) => {
const [typoClasses, rest] = splitProps(
props.typographyProps || { class: "" },
["class"],
);
return (
<Typography
hierarchy="body"
// @ts-expect-error: Dependent type is to complex to check how it is coupled to the override for now
size="xxs"
weight="medium"
class={cx("col-span-full px-1 !fg-semantic-4", typoClasses)}
{...rest}
>
{props.error}
</Typography>
);
};

View File

@@ -0,0 +1,76 @@
.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

@@ -0,0 +1,180 @@
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

@@ -0,0 +1,134 @@
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

@@ -0,0 +1,119 @@
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

@@ -0,0 +1,301 @@
import { toast, Toast } from "solid-toast"; // Make sure to import Toast type
import { Component, JSX, createSignal, onCleanup } from "solid-js";
// --- Icon Components ---
const ErrorIcon: Component = () => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ "margin-right": "10px", "flex-shrink": "0" }}
>
<circle cx="12" cy="12" r="10" fill="#FF4D4F" />
<path
d="M12 7V13"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="16.5" r="1.5" fill="white" />
</svg>
);
const InfoIcon: Component = () => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ "margin-right": "10px", "flex-shrink": "0" }}
>
<circle cx="12" cy="12" r="10" fill="#2196F3" />
<path
d="M12 11V17"
stroke="white"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="8.5" r="1.5" fill="white" />
</svg>
);
const WarningIcon: Component = () => (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ "margin-right": "10px", "flex-shrink": "0" }}
>
<path d="M12 2L22 21H2L12 2Z" fill="#FFC107" />
<path
d="M12 9V14"
stroke="#424242"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="16.5" r="1" fill="#424242" />
</svg>
);
// --- Base Props and Styles ---
export interface BaseToastProps {
t: Toast;
message: string;
onCancel?: () => void; // Optional custom function on X click
}
const baseToastStyle: JSX.CSSProperties = {
display: "flex",
"align-items": "center",
"justify-content": "space-between", // To push X to the right
gap: "10px", // Space between content and close button
background: "#FFFFFF",
color: "#333333",
padding: "12px 16px",
"border-radius": "6px",
"box-shadow": "0 2px 8px rgba(0, 0, 0, 0.12)",
"font-family":
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
"font-size": "14px",
"line-height": "1.4",
"min-width": "280px",
"max-width": "450px",
};
const closeButtonStyle: JSX.CSSProperties = {
background: "none",
border: "none",
color: "red", // As per original example's X button
"font-size": "1.5em",
"font-weight": "bold",
cursor: "pointer",
padding: "0 0 0 10px", // Space to its left
"line-height": "1",
"align-self": "center", // Ensure vertical alignment
};
// --- Toast Component Definitions ---
// Error Toast
export const ErrorToastComponent: Component<BaseToastProps> = (props) => {
// Local state for click feedback and exit animation
let timeoutId: number | undefined;
const [clicked, setClicked] = createSignal(false);
const [exiting, setExiting] = createSignal(false);
const handleToastClick = () => {
setClicked(true);
setTimeout(() => {
setExiting(true);
timeoutId = window.setTimeout(() => {
toast.dismiss(props.t.id);
}, 300); // Match exit animation duration
}, 100); // Brief color feedback before animating out
};
// Cleanup timeout if unmounted early
onCleanup(() => {
if (timeoutId) clearTimeout(timeoutId);
});
return (
<div
style={{
...baseToastStyle,
cursor: "pointer",
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
background: clicked()
? "#ffeaea"
: exiting()
? "#fff"
: baseToastStyle.background,
opacity: exiting() ? 0 : 1,
transform: exiting() ? "translateY(-20px)" : "none",
}}
onClick={handleToastClick}
>
<div
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
>
<ErrorIcon />
<span>{props.message}</span>
</div>
</div>
);
};
// Info Toast
export const CancelToastComponent: Component<BaseToastProps> = (props) => {
let timeoutId: number | undefined;
const [clicked, setClicked] = createSignal(false);
const [exiting, setExiting] = createSignal(false);
// Cleanup timeout if unmounted early
onCleanup(() => {
if (timeoutId) clearTimeout(timeoutId);
});
const handleButtonClick = (e: MouseEvent) => {
e.stopPropagation();
if (props.onCancel) props.onCancel();
toast.dismiss(props.t.id);
};
return (
<div
style={{
...baseToastStyle,
cursor: "pointer",
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
background: clicked()
? "#eaf4ff"
: exiting()
? "#fff"
: baseToastStyle.background,
opacity: exiting() ? 0 : 1,
transform: exiting() ? "translateY(-20px)" : "none",
}}
>
<div
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
>
<InfoIcon />
<span>{props.message}</span>
</div>
<button
onClick={(e) => {
setClicked(true);
handleButtonClick(e);
}}
style={{
...closeButtonStyle,
color: "#2196F3",
"font-size": "1em",
"font-weight": "normal",
padding: "4px 12px",
border: "1px solid #2196F3",
"border-radius": "4px",
background: clicked() ? "#bbdefb" : "#eaf4ff",
cursor: "pointer",
transition: "background 0.15s",
display: "flex",
"align-items": "center",
"justify-content": "center",
width: "70px",
height: "32px",
}}
aria-label="Cancel"
disabled={clicked()}
>
{clicked() ? (
// Simple spinner SVG
<svg
width="18"
height="18"
viewBox="0 0 50 50"
style={{ display: "block" }}
>
<circle
cx="25"
cy="25"
r="20"
fill="none"
stroke="#2196F3"
stroke-width="4"
stroke-linecap="round"
stroke-dasharray="31.415, 31.415"
transform="rotate(72 25 25)"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 25 25"
to="360 25 25"
dur="0.8s"
repeatCount="indefinite"
/>
</circle>
</svg>
) : (
"Cancel"
)}
</button>
</div>
);
};
// Warning Toast
export const WarningToastComponent: Component<BaseToastProps> = (props) => {
let timeoutId: number | undefined;
const [clicked, setClicked] = createSignal(false);
const [exiting, setExiting] = createSignal(false);
const handleToastClick = () => {
setClicked(true);
setTimeout(() => {
setExiting(true);
timeoutId = window.setTimeout(() => {
toast.dismiss(props.t.id);
}, 300);
}, 100);
};
// Cleanup timeout if unmounted early
onCleanup(() => {
if (timeoutId) clearTimeout(timeoutId);
});
return (
<div
style={{
...baseToastStyle,
cursor: "pointer",
transition: "background 0.15s, opacity 0.3s, transform 0.3s",
background: clicked()
? "#fff8e1"
: exiting()
? "#fff"
: baseToastStyle.background,
opacity: exiting() ? 0 : 1,
transform: exiting() ? "translateY(-20px)" : "none",
}}
onClick={handleToastClick}
>
<div
style={{ display: "flex", "align-items": "center", "flex-grow": "1" }}
>
<WarningIcon />
<span>{props.message}</span>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,65 @@
import { createContext, createEffect, JSX, useContext } from "solid-js";
import { callApi } from "@/src/api";
import {
activeClanURI,
addClanURI,
clanURIs,
removeClanURI,
setActiveClanURI,
store,
} from "@/src/stores/clan";
import { redirect } from "@solidjs/router";
// Create the context
interface ClanContextType {
activeClanURI: typeof activeClanURI;
setActiveClanURI: typeof setActiveClanURI;
clanURIs: typeof clanURIs;
addClanURI: typeof addClanURI;
removeClanURI: typeof removeClanURI;
}
const ClanContext = createContext<ClanContextType>({
activeClanURI,
setActiveClanURI,
clanURIs,
addClanURI,
removeClanURI,
});
interface ClanProviderProps {
children: JSX.Element;
}
export function ClanProvider(props: ClanProviderProps) {
// redirect to welcome if there's no active clan and no clan URIs
createEffect(async () => {
if (!store.activeClanURI && store.clanURIs.length == 0) {
redirect("/welcome");
return;
}
});
return (
<ClanContext.Provider
value={{
activeClanURI,
setActiveClanURI,
clanURIs,
addClanURI,
removeClanURI,
}}
>
{props.children}
</ClanContext.Provider>
);
}
// Export a hook that provides access to the context
export function useClanContext() {
const context = useContext(ClanContext);
if (!context) {
throw new Error("useClanContext must be used within a ClanProvider");
}
return context;
}

View File

@@ -0,0 +1,127 @@
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

@@ -0,0 +1,44 @@
import { callApi } from "../api";
import { useClanContext } from "@/src/contexts/clan";
export const registerClan = async () => {
const { setActiveClanURI, addClanURI } = useClanContext();
try {
const loc = await callApi("open_file", {
file_request: { mode: "select_folder" },
}).promise;
if (loc.status === "success" && loc.data) {
const data = loc.data[0];
addClanURI(data);
setActiveClanURI(data);
return data;
}
} catch (e) {
//
}
};
/**
* Opens the custom file dialog
* Returns a native FileList to allow interaction with the native input type="file"
*/
export const selectSshKeys = async (): Promise<FileList> => {
const dataTransfer = new DataTransfer();
const response = await callApi("open_file", {
file_request: {
title: "Select SSH Key",
mode: "open_file",
initial_folder: "~/.ssh",
},
}).promise;
if (response.status === "success" && response.data) {
// Add synthetic files to the DataTransfer object
// FileList cannot be instantiated directly.
response.data.forEach((filename) => {
dataTransfer.items.add(new File([], filename));
});
}
return dataTransfer.files;
};

View File

@@ -0,0 +1,157 @@
@import "material-icons/iconfont/filled.css";
/* List of icons: https://marella.me/material-icons/demo/ */
/* @import url(./components/Typography/css/typography.css); */
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Archivo";
font-weight: 400;
src: url(../.fonts/ArchivoSemiCondensed-Regular.woff2) format("woff2");
}
@font-face {
font-family: "Archivo";
font-weight: 500;
src: url(../.fonts/ArchivoSemiCondensed-Medium.woff2) format("woff2");
}
@font-face {
font-family: "Archivo";
font-weight: 600;
src: url(../.fonts/ArchivoSemiCondensed-SemiBold.woff2) format("woff2");
}
@keyframes slide {
to {
background-position: 200% 0;
}
}
html {
@apply font-sans;
overflow-x: hidden;
overflow-y: hidden;
-webkit-user-select: none;
/* Safari */
-moz-user-select: none;
/* Firefox */
-ms-user-select: none;
/* Internet Explorer/Edge */
user-select: none;
/* Standard */
}
.accordeon {
display: flex;
flex-direction: column;
gap: theme(gap.3);
}
.accordeon__header {
padding: theme(padding.2) theme(padding[1.5]) theme(padding.1);
cursor: pointer;
}
summary {
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::marker {
display: none;
}
.accordeon__body {
}
.machine-item-loader {
@apply col-span-1 flex flex-col items-center;
display: flex;
justify-content: center;
position: relative;
padding: theme(padding.2);
border-radius: theme(borderRadius.md);
overflow: hidden;
cursor: pointer;
}
.machine-item-loader__thumb-wrapper {
position: relative;
z-index: 20;
padding: theme(padding.4);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item-loader__thumb {
position: relative;
width: 100px;
height: 100px;
background: theme(backgroundColor.secondary.100);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item-loader__headline {
position: relative;
z-index: 20;
width: 90%;
height: 20px;
background: theme(backgroundColor.secondary.100);
border-radius: theme(borderRadius.sm);
overflow: hidden;
}
.machine-item-loader__cover {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.machine-item-loader__loader {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(
to right,
transparent 20%,
theme(backgroundColor.secondary.200) 50%,
transparent 80%
);
background-size: 400px 100%;
animation: loader 4s linear infinite;
transition: all 0.56s ease;
}
.machine-item-loader__cover .machine-item-loader__loader {
background: linear-gradient(
to right,
transparent 20%,
theme(backgroundColor.secondary.50) 50%,
transparent 80%
);
}
@keyframes loader {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}

View File

@@ -0,0 +1,203 @@
/* @refresh reload */
import { Portal, render } from "solid-js/web";
import { Navigate, RouteDefinition, Router } from "@solidjs/router";
import "./index.css";
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";
import {
CreateMachine,
MachineDetails,
MachineListView,
MachineInstall,
} from "./routes/machines";
import { Layout } from "./layout/layout";
import { ClanDetails, ClanList, CreateClan } from "./routes/clans";
import { Flash } from "./routes/flash/view";
import { HostList } from "./routes/hosts/view";
import { Welcome } from "./routes/welcome";
import { Toaster } from "solid-toast";
import { ModuleList } from "./routes/modules/list";
import { ModuleDetails } from "./routes/modules/details";
import { ModuleDetails as AddModule } from "./routes/modules/add";
import { ApiTester } from "./api_test";
import { IconVariant } from "./components/icon";
import { Components } from "./routes/components";
import { VarsPage } from "./routes/machines/install/vars-step";
import { ThreePlayground } from "./three";
import { ClanProvider } from "./contexts/clan";
export const client = new QueryClient();
const root = document.getElementById("app");
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
);
}
if (import.meta.env.DEV) {
console.log("Development mode");
// Load the debugger in development mode
await import("solid-devtools");
}
export type AppRoute = Omit<RouteDefinition, "children"> & {
label: string;
icon?: IconVariant;
children?: AppRoute[];
hidden?: boolean;
};
export const routes: AppRoute[] = [
{
path: "/",
label: "",
hidden: true,
component: () => <Navigate href="/machines" />,
},
{
path: "/machines",
label: "Machines",
icon: "Grid",
children: [
{
path: "/",
label: "Overview",
component: () => <MachineListView />,
},
{
path: "/create",
label: "Create",
component: () => <CreateMachine />,
},
{
path: "/:id",
label: "Details",
hidden: true,
component: () => <MachineDetails />,
},
{
path: "/:id/vars",
label: "Vars",
hidden: true,
component: () => <VarsPage />,
},
{
path: "/:id/install",
label: "Install",
hidden: true,
component: () => <MachineInstall />,
},
],
},
{
path: "/clans",
label: "Clans",
hidden: true,
icon: "List",
children: [
{
path: "/",
label: "Overview",
component: () => <ClanList />,
},
{
path: "/create",
label: "Create",
component: () => <CreateClan />,
},
{
path: "/:id",
label: "Details",
hidden: true,
component: () => <ClanDetails />,
},
],
},
{
path: "/modules",
label: "Modules",
icon: "Search",
children: [
{
path: "/",
label: "App Store",
component: () => <ModuleList />,
},
{
path: "details/:id",
label: "Details",
hidden: true,
component: () => <ModuleDetails />,
},
{
path: "/add/:id",
label: "Details",
hidden: true,
component: () => <AddModule />,
},
],
},
{
path: "/tools",
label: "Tools",
icon: "Folder",
children: [
{
path: "/flash",
label: "Flash Installer",
component: () => <Flash />,
},
],
},
{
path: "/welcome",
label: "",
hidden: true,
component: () => <Welcome />,
},
{
path: "/internal-dev",
label: "Internal (Only visible in dev mode)",
children: [
{
path: "/hosts",
label: "Local Hosts",
component: () => <HostList />,
},
{
path: "/3d",
label: "3D-Playground",
component: () => <ThreePlayground />,
},
{
path: "/api_testing",
label: "api_testing",
hidden: false,
component: () => <ApiTester />,
},
{
path: "/components",
label: "Components",
hidden: false,
component: () => <Components />,
},
],
},
];
render(
() => (
<>
<Portal mount={document.body}>
<Toaster position="top-right" containerClassName="z-[9999]" />
</Portal>
<QueryClientProvider client={client}>
<ClanProvider>
<Router root={Layout}>{routes}</Router>
</ClanProvider>
</QueryClientProvider>
</>
),
root!,
);

View File

@@ -0,0 +1,29 @@
import { JSX } from "solid-js";
import { Typography } from "../components/Typography";
import { BackButton } from "../components/BackButton";
interface HeaderProps {
title: string;
toolbar?: JSX.Element;
showBack?: boolean;
}
export const Header = (props: HeaderProps) => {
return (
<div class="sticky top-0 z-50 flex items-center border-b bg-white/80 px-6 py-4 backdrop-blur-md border-def-3">
<div class="flex-none">
{props.showBack && <BackButton />}
<span class=" lg:hidden" data-tip="Menu">
<label class=" " for="toplevel-drawer">
<span class="material-icons">menu</span>
</label>
</span>
</div>
<div class="flex-1">
<Typography hierarchy="title" size="m" weight="medium" class="">
{props.title}
</Typography>
</div>
<div class="flex items-center justify-center gap-3">{props.toolbar}</div>
</div>
);
};

View File

@@ -0,0 +1,29 @@
import { Component, createEffect } from "solid-js";
import { Sidebar } from "@/src/components/Sidebar";
import { RouteSectionProps, useNavigate } from "@solidjs/router";
import { useClanContext } from "@/src/contexts/clan";
export const Layout: Component<RouteSectionProps> = (props) => {
const navigate = useNavigate();
const { clanURIs } = useClanContext();
createEffect(() => {
console.log(
"empty ClanList, redirect to welcome page",
clanURIs().length === 0,
);
if (clanURIs().length === 0) {
navigate("/welcome");
}
});
return (
<div class="h-screen w-full p-4 bg-def-2">
<div class="flex size-full flex-row-reverse">
<div class="my-2 ml-8 flex-1 overflow-x-hidden overflow-y-scroll rounded-lg border bg-def-1 border-def-3">
{props.children}
</div>
<Sidebar {...props} />
</div>
</div>
);
};

View File

@@ -0,0 +1,37 @@
import { useQuery } from "@tanstack/solid-query";
import { callApi } from "@/src/api";
import { activeClanURI, removeClanURI } from "@/src/stores/clan";
export const clanMetaQuery = (uri: string | undefined = undefined) =>
useQuery(() => {
const clanURI = uri || activeClanURI();
const enabled = !!clanURI;
return {
enabled,
queryKey: [clanURI, "meta"],
queryFn: async () => {
console.log("fetching clan meta", clanURI);
const result = await callApi("show_clan_meta", {
flake: { identifier: clanURI! },
}).promise;
console.log("result", result);
if (result.status === "error") {
// check if the clan directory no longer exists
// remove from the clan list if not
result.errors.forEach((error) => {
if (error.description === "clan directory does not exist") {
removeClanURI(clanURI!);
}
});
throw new Error("Failed to fetch data");
}
return result.data;
},
};
});

View File

@@ -0,0 +1,76 @@
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

@@ -0,0 +1,208 @@
import { callApi, OperationResponse } from "@/src/api";
import { Show } from "solid-js";
import {
createForm,
required,
reset,
SubmitHandler,
ResponseData,
} from "@modular-forms/solid";
import toast from "solid-toast";
import { TextInput } from "@/src/Form/fields/TextInput";
import { useNavigate } from "@solidjs/router";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
import { useClanContext } from "@/src/contexts/clan";
type CreateForm = Meta & {
template: string;
};
export const CreateClan = () => {
const [formStore, { Form, Field }] = createForm<CreateForm, ResponseData>({
initialValues: {
name: "",
description: "",
template: "minimal",
},
});
const navigate = useNavigate();
const { setActiveClanURI, addClanURI } = useClanContext();
const handleSubmit: SubmitHandler<CreateForm> = async (values, event) => {
const { template, ...meta } = values;
const response = await callApi("open_file", {
file_request: { mode: "save" },
}).promise;
if (response.status !== "success") {
toast.error("Cannot select clan directory");
return;
}
const target_dir = response?.data;
if (!target_dir) {
toast.error("Cannot select clan directory");
return;
}
const loading_toast = toast.loading("Creating Clan....");
const r = await callApi("create_clan", {
opts: {
dest: target_dir[0],
template_name: template,
initial: {
meta,
services: {},
machines: {},
},
},
}).promise;
toast.dismiss(loading_toast);
if (r.status === "error") {
toast.error("Failed to create clan");
return;
}
// Will generate a key if it doesn't exist, and add a user to the clan
const k = await callApi("keygen", {
flake_dir: target_dir[0],
}).promise;
if (k.status === "error") {
toast.error("Failed to generate key");
return;
}
if (r.status === "success") {
toast.success("Clan Successfully Created");
addClanURI(target_dir[0]);
setActiveClanURI(target_dir[0]);
navigate("/machines");
reset(formStore);
}
};
return (
<div class="">
<Form onSubmit={handleSubmit} shouldActive>
<Field name="icon">
{(field, props) => (
<>
<figure>
<Show
when={field.value}
fallback={
<span class="material-icons aspect-square size-60 rounded-lg text-[18rem]">
group
</span>
}
>
{(icon) => (
<img
class="aspect-square size-60 rounded-lg"
src={icon()}
alt="Clan Logo"
/>
)}
</Show>
</figure>
</>
)}
</Field>
<div class="">
<Field
name="name"
validate={[required("Please enter a unique name for the clan.")]}
>
{(field, props) => (
<label class=" w-full">
<div class="">
<span class=" block after:ml-0.5 after:text-primary-800 after:content-['*']">
Name
</span>
</div>
<input
{...props}
disabled={formStore.submitting}
required
placeholder="Give your Clan a legendary name"
classList={{ "": !!field.error }}
value={field.value}
/>
<div class="">
{field.error && <span class="">{field.error}</span>}
</div>
</label>
)}
</Field>
<Field name="description">
{(field, props) => (
<label class=" w-full">
<div class="">
<span class="">Description</span>
</div>
<input
{...props}
disabled={formStore.submitting}
required
type="text"
placeholder="Tell us what makes your Clan legendary"
classList={{ "": !!field.error }}
value={field.value || ""}
/>
<div class="">
{field.error && <span class="">{field.error}</span>}
</div>
</label>
)}
</Field>
<Field name="template" validate={[required("This is required")]}>
{(field, props) => (
<div class=" " tabindex="0">
<input type="checkbox" />
<div class=" font-medium ">Advanced</div>
<div>
<TextInput
// adornment={{
// content: (
// <span class="-mr-1 text-neutral-500">clan-core #</span>
// ),
// position: "start",
// }}
inputProps={props}
label="Template to use"
value={field.value ?? ""}
error={field.error}
required
/>
</div>
</div>
)}
</Field>
{
<div class=" justify-end">
<Button
type="submit"
disabled={formStore.submitting}
endIcon={<Icon icon="Plus" />}
>
Create
</Button>
</div>
}
</div>
</Form>
</div>
);
};
type Meta = Extract<
OperationResponse<"show_clan_meta">,
{ status: "success" }
>["data"];

View File

@@ -0,0 +1,160 @@
import { callApi, ClanServiceInstance, SuccessQuery } from "@/src/api";
import { useParams } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createSignal, For, Match, Switch } from "solid-js";
import {
createForm,
FieldValues,
getValue,
getValues,
required,
setValue,
SubmitHandler,
} from "@modular-forms/solid";
import { TextInput } from "@/src/Form/fields/TextInput";
import toast from "solid-toast";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
import { Header } from "@/src/layout/header";
import { clanMetaQuery } from "@/src/queries/clan-meta";
interface EditClanFormProps {
initial: GeneralData;
directory: string;
}
const EditClanForm = (props: EditClanFormProps) => {
const [formStore, { Form, Field }] = createForm<GeneralData>({
initialValues: props.initial,
});
const queryClient = useQueryClient();
const handleSubmit: SubmitHandler<GeneralData> = async (values, event) => {
await toast.promise(
(async () => {
await callApi("update_clan_meta", {
options: {
flake: { identifier: props.directory },
meta: values,
},
}).promise;
})(),
{
loading: "Updating clan...",
success: "Clan Successfully updated",
error: "Failed to update clan",
},
);
await queryClient.invalidateQueries({
queryKey: [props.directory, "meta"],
});
};
const curr_name = () => props.initial.name;
return (
<Form onSubmit={handleSubmit} shouldActive>
<Field name="icon">
{(field) => (
<>
<div class="flex flex-col items-center">
<div class="text-3xl text-primary-800">{curr_name()}</div>
<div class="text-secondary-800">Wide settings</div>
<Icon
class="mt-4"
icon="ClanIcon"
viewBox="0 0 72 89"
width={96}
height={96}
/>
</div>
</>
)}
</Field>
<div class="">
<span class="text-xl text-primary-800">General</span>
<Field
name="name"
validate={[required("Please enter a unique name for the clan.")]}
>
{(field, props) => (
<label class="w-full">
<div class="">
<span class=" block after:ml-0.5 after:text-primary-800 after:content-['*']">
Name
</span>
</div>
<input
{...props}
disabled={formStore.submitting}
required
placeholder="Clan Name"
class=""
classList={{ "": !!field.error }}
value={field.value}
/>
<div class="">
{field.error && <span class="">{field.error}</span>}
</div>
</label>
)}
</Field>
<Field name="description">
{(field, props) => (
<label class="w-full">
<div class="">
<span class="">Description</span>
</div>
<input
{...props}
disabled={formStore.submitting}
required
type="text"
placeholder="Some words about your clan"
class=""
classList={{ "": !!field.error }}
value={field.value || ""}
/>
<div class="">
{field.error && <span class="">{field.error}</span>}
</div>
</label>
)}
</Field>
{
<div class="justify-end">
<Button
type="submit"
disabled={formStore.submitting || !formStore.dirty}
>
Save
</Button>
</div>
}
</div>
</Form>
);
};
type GeneralData = SuccessQuery<"show_clan_meta">["data"];
export const ClanDetails = () => {
const params = useParams();
const clan_dir = window.atob(params.id);
// Fetch general meta data
const clanQuery = clanMetaQuery(clan_dir);
return (
<>
<Header title={clan_dir} showBack />
<div class="flex flex-col justify-center">
<Switch fallback={<>General data not available</>}>
<Match when={clanQuery.data}>
{(d) => <EditClanForm initial={d()} directory={clan_dir} />}
</Match>
</Switch>
</div>
</>
);
};

View File

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

View File

@@ -0,0 +1,160 @@
import { createSignal, For, Show } from "solid-js";
import { useFloating } from "@/src/floating";
import { autoUpdate, flip, hide, offset, shift } from "@floating-ui/dom";
import { A, useNavigate } from "@solidjs/router";
import { registerClan } from "@/src/hooks";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
import { useClanContext } from "@/src/contexts/clan";
import { clanURIs, setActiveClanURI } from "@/src/stores/clan";
import { clanMetaQuery } from "@/src/queries/clan-meta";
interface ClanItemProps {
clan_dir: string;
}
const ClanItem = (props: ClanItemProps) => {
const { clan_dir } = props;
const details = clanMetaQuery(clan_dir);
const navigate = useNavigate();
const [reference, setReference] = createSignal<HTMLElement>();
const [floating, setFloating] = createSignal<HTMLElement>();
const { activeClanURI, removeClanURI } = useClanContext();
// `position` is a reactive object.
const position = useFloating(reference, floating, {
placement: "top",
// pass options. Ensure the cleanup function is returned.
whileElementsMounted: (reference, floating, update) =>
autoUpdate(reference, floating, update, {
animationFrame: true,
}),
middleware: [
offset(5),
shift(),
flip(),
hide({
strategy: "referenceHidden",
}),
],
});
const handleRemove = () => {
removeClanURI(clan_dir);
};
return (
<div class="">
<div class=" text-primary-800">
<div class="">
<Button
size="s"
variant="light"
class=""
onClick={() => navigate(`/clans/${window.btoa(clan_dir)}`)}
endIcon={<Icon icon="Settings" />}
></Button>
<Button
size="s"
variant="light"
class=" "
onClick={() => {
setActiveClanURI(clan_dir);
}}
>
{activeClanURI() === clan_dir ? "active" : "select"}
</Button>
<Button
size="s"
variant="light"
popovertarget={`clan-delete-popover-${clan_dir}`}
popovertargetaction="toggle"
ref={setReference}
class=" "
endIcon={<Icon icon="Trash" />}
></Button>
<div
popover="auto"
role="tooltip"
id={`clan-delete-popover-${clan_dir}`}
ref={setFloating}
style={{
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}}
class="m-0 bg-transparent"
>
<Button
size="s"
onClick={handleRemove}
variant="dark"
endIcon={<Icon icon="Trash" />}
>
Remove from App
</Button>
</div>
</div>
</div>
<div
class=""
classList={{
"": activeClanURI() === clan_dir,
}}
>
{clan_dir}
</div>
<Show when={details.isLoading}>
<div class=" h-12 w-80" />
</Show>
<Show when={details.isSuccess}>
<A href={`/clans/${window.btoa(clan_dir)}`}>
<div class=" underline">{details.data?.name}</div>
</A>
</Show>
<Show when={details.isSuccess && details.data?.description}>
<div class=" text-lg">{details.data?.description}</div>
</Show>
</div>
);
};
export const ClanList = () => {
const navigate = useNavigate();
return (
<div class="">
<div class="">
<div class="">
<div class=" text-2xl">Registered Clans</div>
<div class="flex gap-2">
<span class="" data-tip="Register clan">
<Button
variant="light"
onClick={registerClan}
startIcon={<Icon icon="List" />}
></Button>
</span>
<span class="" data-tip="Create new clan">
<Button
onClick={() => {
navigate("create");
}}
startIcon={<Icon icon="Plus" />}
></Button>
</span>
</div>
</div>
<div class=" shadow">
<For each={clanURIs()}>
{(value) => <ClanItem clan_dir={value} />}
</For>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,118 @@
import { Button } from "../../components/Button/Button";
import { InputBase, InputLabel } from "@/src/components/inputBase";
import { TextInput } from "@/src/Form/fields";
import { Header } from "@/src/layout/header";
import { createForm, required } from "@modular-forms/solid";
const disabled = [false, true];
const readOnly = [false, true];
const error = [false, true];
export const Components = () => {
const [formStore, { Form, Field }] = createForm<{ ef: string }>({});
return (
<>
<Header title="Components" />
<div class="grid grid-cols-2 gap-4 p-4">
<span class="col-span-2">Input </span>
<span>Default</span>
<span>Size S</span>
{disabled.map((disabled) =>
readOnly.map((readOnly) =>
error.map((hasError) => (
<>
<span>
{[
disabled ? "Disabled" : "(default)",
readOnly ? "ReadOnly" : "",
hasError ? "Error" : "",
]
.filter(Boolean)
.join(" + ")}
</span>
<InputBase
variant="outlined"
value="The Fox jumps!"
disabled={disabled}
error={hasError}
readonly={readOnly}
/>
</>
)),
),
)}
<span class="col-span-2">Input Ghost</span>
{disabled.map((disabled) =>
readOnly.map((readOnly) =>
error.map((hasError) => (
<>
<span>
{[
disabled ? "Disabled" : "(default)",
readOnly ? "ReadOnly" : "",
hasError ? "Error" : "",
]
.filter(Boolean)
.join(" + ")}
</span>
<InputBase
variant="ghost"
value="The Fox jumps!"
disabled={disabled}
error={hasError}
readonly={readOnly}
/>
</>
)),
),
)}
<span class="col-span-2">Input Label</span>
<span>Default</span>
<InputLabel>Labeltext</InputLabel>
<span>Required</span>
<InputLabel required>Labeltext</InputLabel>
<span>Error</span>
<InputLabel error>Labeltext</InputLabel>
<span>Error + Reuired</span>
<InputLabel error required>
Labeltext
</InputLabel>
<span>Icon</span>
<InputLabel help="Some Info">Labeltext</InputLabel>
<span>Description</span>
<InputLabel description="Some more words">Labeltext</InputLabel>
</div>
<div class="flex flex-col gap-2">
<span class="col-span-full gap-4">Form Layout</span>
<TextInput label="Label" value="Value" />
<Form
onSubmit={() => {
console.log("Nothing");
}}
>
<Field
name="ef"
validate={required(
"This field is required very long descriptive error message",
)}
>
{(field, inputProps) => (
<TextInput
label="Write something"
error={field.error}
required
value={field.value || ""}
inputProps={inputProps}
/>
)}
</Field>
<Button>Submit</Button>
</Form>
<TextInput label="Label" required value="Value" />
</div>
</>
);
};

View File

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

View File

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,438 @@
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

@@ -0,0 +1,76 @@
import { type Component, createSignal, For, Show } from "solid-js";
import { OperationResponse, callApi } from "@/src/api";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
type ServiceModel = Extract<
OperationResponse<"show_mdns">,
{ status: "success" }
>["data"]["services"];
export const HostList: Component = () => {
const [services, setServices] = createSignal<ServiceModel>();
return (
<div>
<div class="" data-tip="Refresh install targets">
<Button
variant="light"
onClick={() => callApi("show_mdns", {})}
startIcon={<Icon icon="Update" />}
></Button>
</div>
<div class="flex flex-wrap gap-2">
<Show when={services()}>
{(services) => (
<For each={Object.values(services())}>
{(service) => (
<div class="w-[30rem] rounded-lg bg-white p-5 shadow-lg">
<div class=" flex flex-col shadow">
<div class="">
<div class="">Host</div>
<div class="">{service.host}</div>
<div class=""></div>
</div>
<div class="">
<div class="">IP</div>
<div class="">{service.ip}</div>
<div class=""></div>
</div>
</div>
<div class=" w-full px-0">
<div class=" ">
<input type="radio" name="my-accordion-4" />
<div class=" text-xl font-medium">Details</div>
<div class="">
<p>
<span class="font-bold">Interface</span>
{service.interface}
</p>
<p>
<span class="font-bold">Protocol</span>
{service.protocol}
</p>
<p>
<span class="font-bold">Type</span>
{service.type_}
</p>
<p>
<span class="font-bold">Domain</span>
{service.domain}
</p>
</div>
</div>
</div>
</div>
)}
</For>
)}
</Show>
</div>
</div>
);
};

View File

@@ -0,0 +1,264 @@
import { callApi, SuccessData } from "@/src/api";
import {
createForm,
getValue,
getValues,
setValue,
} from "@modular-forms/solid";
import { createSignal, Match, Switch } from "solid-js";
import { useClanContext } from "@/src/contexts/clan";
import { HWStep } from "../install/hardware-step";
import { DiskStep } from "../install/disk-step";
import { VarsStep } from "../install/vars-step";
import { SummaryStep } from "../install/summary-step";
import { InstallStepper } from "./InstallStepper";
import { InstallStepNavigation } from "./InstallStepNavigation";
import { InstallProgress } from "./InstallProgress";
import { DiskValues } from "../install/disk-step";
import { AllStepsValues } from "../types";
import { ResponseData } from "@modular-forms/solid";
type MachineData = SuccessData<"get_machine_details">;
type StepIdx = "1" | "2" | "3" | "4";
const INSTALL_STEPS = {
HARDWARE: "1" as StepIdx,
DISK: "2" as StepIdx,
VARS: "3" as StepIdx,
SUMMARY: "4" as StepIdx,
} as const;
const PROGRESS_DELAYS = {
INITIAL: 10 * 1000,
BUILD: 10 * 1000,
FORMAT: 10 * 1000,
COPY: 20 * 1000,
REBOOT: 10 * 1000,
} as const;
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
interface InstallMachineProps {
name?: string;
machine: MachineData;
}
export function InstallMachine(props: InstallMachineProps) {
const { activeClanURI } = useClanContext();
const curr = activeClanURI();
const { name } = props;
if (!curr || !name) {
return <span>No Clan selected</span>;
}
const [formStore, { Form, Field }] = createForm<
AllStepsValues,
ResponseData
>();
const [isDone, setIsDone] = createSignal<boolean>(false);
const [isInstalling, setIsInstalling] = createSignal<boolean>(false);
const [progressText, setProgressText] = createSignal<string>();
const [step, setStep] = createSignal<StepIdx>(INSTALL_STEPS.HARDWARE);
const nextStep = () => {
const currentStepNum = parseInt(step());
const nextStepNum = Math.min(currentStepNum + 1, 4);
setStep(nextStepNum.toString() as StepIdx);
};
const prevStep = () => {
const currentStepNum = parseInt(step());
const prevStepNum = Math.max(currentStepNum - 1, 1);
setStep(prevStepNum.toString() as StepIdx);
};
const isFirstStep = () => step() === INSTALL_STEPS.HARDWARE;
const isLastStep = () => step() === INSTALL_STEPS.SUMMARY;
const handleInstall = async (values: AllStepsValues) => {
const curr_uri = activeClanURI();
const diskValues = values["2"];
if (!curr_uri || !props.name) {
console.error("Missing clan URI or machine name");
return;
}
try {
setIsInstalling(true);
const shouldUpdateDisk =
JSON.stringify(props.machine.disk_schema?.placeholders) !==
JSON.stringify(diskValues.placeholders);
if (shouldUpdateDisk) {
setProgressText("Setting up disk ... (1/5)");
await callApi("set_machine_disk_schema", {
machine: {
flake: { identifier: curr_uri },
name: props.name,
},
placeholders: diskValues.placeholders,
schema_name: diskValues.schema,
force: true,
}).promise;
}
setProgressText("Installing machine ... (2/5)");
const targetHostResponse = await callApi("get_host", {
field: "targetHost",
flake: { identifier: curr_uri },
name: props.name,
}).promise;
if (
targetHostResponse.status === "error" ||
!targetHostResponse.data?.data
) {
throw new Error("No target host found for the machine");
}
const installPromise = callApi("install_machine", {
opts: {
machine: {
name: props.name,
flake: { identifier: curr_uri },
private_key: values.sshKey?.name,
},
},
target_host: targetHostResponse.data.data,
});
await sleep(PROGRESS_DELAYS.INITIAL);
setProgressText("Building machine ... (3/5)");
await sleep(PROGRESS_DELAYS.BUILD);
setProgressText("Formatting remote disk ... (4/5)");
await sleep(PROGRESS_DELAYS.FORMAT);
setProgressText("Copying system ... (5/5)");
await sleep(PROGRESS_DELAYS.COPY);
setProgressText("Rebooting remote system ...");
await sleep(PROGRESS_DELAYS.REBOOT);
const installResponse = await installPromise;
setIsDone(true);
} catch (error) {
console.error("Installation failed:", error);
setIsInstalling(false);
}
};
return (
<Switch
fallback={
<div class="flex min-h-screen flex-col gap-0">
<InstallStepper currentStep={step()} />
<Switch fallback={<div>Step not found</div>}>
<Match when={step() === INSTALL_STEPS.HARDWARE}>
<HWStep
machine_id={props.name || ""}
dir={activeClanURI() || ""}
handleNext={(data) => {
const prev = getValue(formStore, "1");
setValue(formStore, "1", { ...prev, ...data });
nextStep();
}}
initial={getValue(formStore, "1")}
footer={
<InstallStepNavigation
currentStep={step()}
isFirstStep={isFirstStep()}
isLastStep={isLastStep()}
onPrevious={prevStep}
/>
}
/>
</Match>
<Match when={step() === INSTALL_STEPS.DISK}>
<DiskStep
machine_id={props.name || ""}
dir={activeClanURI() || ""}
footer={
<InstallStepNavigation
currentStep={step()}
isFirstStep={isFirstStep()}
isLastStep={isLastStep()}
onPrevious={prevStep}
/>
}
handleNext={(data) => {
const prev = getValue(formStore, "2");
setValue(formStore, "2", { ...prev, ...data });
nextStep();
}}
initial={
{
placeholders: props.machine.disk_schema?.placeholders || {
mainDisk: "",
},
schema: props.machine.disk_schema?.schema_name || "",
schema_name: props.machine.disk_schema?.schema_name || "",
...getValue(formStore, "2"),
initialized: !!props.machine.disk_schema,
} as DiskValues
}
/>
</Match>
<Match when={step() === INSTALL_STEPS.VARS}>
<VarsStep
machine_id={props.name || ""}
dir={activeClanURI() || ""}
handleNext={(data) => {
const prev = getValue(formStore, "3");
setValue(formStore, "3", { ...prev, ...data });
nextStep();
}}
initial={getValue(formStore, "3") || {}}
footer={
<InstallStepNavigation
currentStep={step()}
isFirstStep={isFirstStep()}
isLastStep={isLastStep()}
onPrevious={prevStep}
/>
}
/>
</Match>
<Match when={step() === INSTALL_STEPS.SUMMARY}>
<SummaryStep
machine_id={props.name || ""}
dir={activeClanURI() || ""}
handleNext={() => nextStep()}
initial={getValues(formStore) as AllStepsValues}
footer={
<InstallStepNavigation
currentStep={step()}
isFirstStep={isFirstStep()}
isLastStep={isLastStep()}
onPrevious={prevStep}
onInstall={() =>
handleInstall(getValues(formStore) as AllStepsValues)
}
/>
}
/>
</Match>
</Switch>
</div>
}
>
<Match when={isInstalling()}>
<InstallProgress
machineName={props.name || ""}
progressText={progressText()}
isDone={isDone()}
onCancel={() => setIsInstalling(false)}
/>
</Match>
</Switch>
);
}

View File

@@ -0,0 +1,65 @@
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/icon";
import { Typography } from "@/src/components/Typography";
const LoadingBar = () => (
<div
class="h-3 w-80 overflow-hidden rounded-[3px] border-2 border-def-1"
style={{
background: `repeating-linear-gradient(
45deg,
#ccc,
#ccc 8px,
#eee 8px,
#eee 16px
)`,
animation: "slide 25s linear infinite",
"background-size": "200% 100%",
}}
></div>
);
interface InstallProgressProps {
machineName: string;
progressText?: string;
isDone: boolean;
onCancel: () => void;
}
export function InstallProgress(props: InstallProgressProps) {
return (
<div class="flex h-96 w-[40rem] flex-col fg-inv-1">
<div class="flex w-full gap-1 p-4 bg-inv-4">
<Typography
color="inherit"
hierarchy="label"
size="default"
weight="medium"
>
Install:
</Typography>
<Typography
color="inherit"
hierarchy="label"
size="default"
weight="bold"
>
{props.machineName}
</Typography>
</div>
<div class="flex h-full flex-col items-center gap-3 px-4 py-8 bg-inv-4 fg-inv-1">
<Icon icon="ClanIcon" viewBox="0 0 72 89" class="size-20" />
{props.isDone && <LoadingBar />}
<Typography
hierarchy="label"
size="default"
weight="medium"
color="inherit"
>
{props.progressText}
</Typography>
<Button onClick={props.onCancel}>Cancel</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/icon";
interface InstallStepNavigationProps {
currentStep: string;
isFirstStep: boolean;
isLastStep: boolean;
onPrevious: () => void;
onNext?: () => void;
onInstall?: () => void;
}
export function InstallStepNavigation(props: InstallStepNavigationProps) {
return (
<div class="flex justify-between p-4">
<Button
startIcon={<Icon icon="ArrowLeft" />}
variant="light"
type="button"
onClick={props.onPrevious}
disabled={props.isFirstStep}
>
Previous
</Button>
{props.isLastStep ? (
<Button startIcon={<Icon icon="Flash" />} onClick={props.onInstall}>
Install
</Button>
) : (
<Button endIcon={<Icon icon="ArrowRight" />} type="submit">
Next
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { For, Show } from "solid-js";
import cx from "classnames";
import Icon from "@/src/components/icon";
import { Typography } from "@/src/components/Typography";
const steps: Record<string, string> = {
"1": "Hardware detection",
"2": "Disk schema",
"3": "Credentials & Data",
"4": "Installation",
};
interface InstallStepperProps {
currentStep: string;
}
export function InstallStepper(props: InstallStepperProps) {
return (
<div class="flex items-center justify-evenly gap-2 border py-3 bg-def-3 border-def-2">
<For each={Object.entries(steps)}>
{([idx, label]) => (
<div class="flex flex-col items-center gap-3 fg-def-1">
<Typography
classList={{
[cx("bg-inv-4 fg-inv-1")]: idx === props.currentStep,
[cx("bg-def-4 fg-def-1")]: idx < props.currentStep,
}}
color="inherit"
hierarchy="label"
size="default"
weight="bold"
class="flex size-6 items-center justify-center rounded-full text-center align-middle bg-def-1"
>
<Show
when={idx >= props.currentStep}
fallback={<Icon icon="Checkmark" class="size-5" />}
>
{idx}
</Show>
</Typography>
<Typography
color="inherit"
hierarchy="label"
size="xs"
weight="medium"
class="text-center align-top fg-def-3"
classList={{
[cx("!fg-def-1")]: idx == props.currentStep,
}}
>
{label}
</Typography>
</div>
)}
</For>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/icon";
interface MachineActionsBarProps {
machineName: string;
onInstall: () => void;
onUpdate: () => void;
onCredentials: () => void;
}
export function MachineActionsBar(props: MachineActionsBarProps) {
return (
<div class="sticky top-0 flex items-center justify-end gap-2 border-b border-secondary-100 bg-secondary-50 px-4 py-2">
<div class="flex items-center gap-3">
<div class="button-group flex flex-shrink-0 min-w-0">
<Button
variant="light"
class="flex-1 min-w-0"
size="s"
onClick={props.onInstall}
endIcon={<Icon size={14} icon="Flash" />}
>
Install
</Button>
<Button
variant="light"
class="flex-1 min-w-0"
size="s"
onClick={props.onUpdate}
endIcon={<Icon size={14} icon="Update" />}
>
Update
</Button>
<Button
variant="light"
class="flex-1 min-w-0"
size="s"
onClick={props.onCredentials}
endIcon={<Icon size={14} icon="Folder" />}
>
Vars
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { RndThumbnail } from "@/src/components/noiseThumbnail";
import cx from "classnames";
interface AvatarProps {
name?: string;
class?: string;
}
export const MachineAvatar = (props: AvatarProps) => {
return (
<figure>
<div class="">
<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

@@ -0,0 +1,192 @@
import { callApi, SuccessData, OperationResponse } from "@/src/api";
import { createForm, getValue, ResponseData } from "@modular-forms/solid";
import { useNavigate } from "@solidjs/router";
import { useQuery, useQueryClient } from "@tanstack/solid-query";
import { createEffect, createSignal } from "solid-js";
import { Button } from "@/src/components/Button/Button";
import { useClanContext } from "@/src/contexts/clan";
import { MachineAvatar } from "./MachineAvatar";
import toast from "solid-toast";
import { MachineActionsBar } from "./MachineActionsBar";
import { MachineGeneralFields } from "./MachineGeneralFields";
import { MachineHardwareInfo } from "./MachineHardwareInfo";
import { InstallMachine } from "./InstallMachine";
import { debug } from "console";
type DetailedMachineType = Extract<
OperationResponse<"get_machine_details">,
{ status: "success" }
>["data"];
interface MachineFormProps {
detailed: DetailedMachineType;
}
export function MachineForm(props: MachineFormProps) {
const { detailed } = props;
const [formStore, { Form, Field }] = createForm<
DetailedMachineType,
ResponseData
>({
initialValues: detailed,
});
const [isUpdating, setIsUpdating] = createSignal(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { activeClanURI } = useClanContext();
const handleSubmit = async (values: DetailedMachineType) => {
console.log("submitting", values);
const curr_uri = activeClanURI();
if (!curr_uri) {
return;
}
await callApi("set_machine", {
machine: {
name: detailed.machine.name || "My machine",
flake: {
identifier: curr_uri,
},
},
update: {
...values.machine,
tags: Array.from(values.machine.tags || detailed.machine.tags || []),
},
}).promise;
await queryClient.invalidateQueries({
queryKey: [
curr_uri,
"machine",
detailed.machine.name,
"get_machine_details",
],
});
return null;
};
const generatorsQuery = useQuery(() => ({
queryKey: [activeClanURI(), detailed.machine.name, "generators"],
queryFn: async () => {
const machine_name = detailed.machine.name;
const base_dir = activeClanURI();
if (!machine_name || !base_dir) {
return [];
}
const result = await callApi("get_generators_closure", {
base_dir: base_dir,
machine_name: machine_name,
}).promise;
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
}));
const handleUpdateButton = async () => {
await generatorsQuery.refetch();
if (
generatorsQuery.data?.some((generator) => generator.prompts?.length !== 0)
) {
navigate(`/machines/${detailed.machine.name || ""}/vars?action=update`);
} else {
handleUpdate();
}
};
const handleUpdate = async () => {
if (isUpdating()) {
return;
}
const curr_uri = activeClanURI();
if (!curr_uri) {
return;
}
const machine = detailed.machine.name;
if (!machine) {
toast.error("Machine is required");
return;
}
const target = await callApi("get_host", {
field: "targetHost",
name: machine,
flake: {
identifier: curr_uri,
},
}).promise;
if (target.status === "error") {
toast.error("Failed to get target host");
return;
}
if (!target.data) {
toast.error("Target host is required");
return;
}
const target_host = target.data.data;
setIsUpdating(true);
const r = await callApi("deploy_machine", {
machine: {
name: machine,
flake: {
identifier: curr_uri,
},
},
target_host: {
...target_host,
},
build_host: null,
}).promise.finally(() => {
setIsUpdating(false);
});
};
return (
<>
<div class="flex flex-col gap-6">
<MachineActionsBar
machineName={detailed.machine.name || ""}
onInstall={() =>
navigate(`/machines/${detailed.machine.name || ""}/install`)
}
onUpdate={handleUpdateButton}
onCredentials={() =>
navigate(`/machines/${detailed.machine.name || ""}/vars`)
}
/>
<div class="p-4">
<span class="mb-2 flex w-full justify-center">
<MachineAvatar name={detailed.machine.name || ""} />
</span>
<Form
onSubmit={handleSubmit}
class="mx-auto flex w-full max-w-2xl flex-col gap-y-6"
>
<MachineGeneralFields formStore={formStore} />
<MachineHardwareInfo formStore={formStore} />
<footer class="flex justify-end gap-y-3 border-t border-secondary-200 pt-5">
<Button
type="submit"
disabled={formStore.submitting || !formStore.dirty}
>
Update edits
</Button>
</footer>
</Form>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,82 @@
import {
Field,
FieldValues,
FormStore,
ResponseData,
FieldStore,
FieldElementProps,
FieldPath,
} from "@modular-forms/solid";
import { TextInput } from "@/src/Form/fields/TextInput";
import { Typography } from "@/src/components/Typography";
import { TagList } from "@/src/components/TagList/TagList";
import Fieldset from "@/src/Form/fieldset";
import { SuccessData } from "@/src/api";
type MachineData = SuccessData<"get_machine_details">;
interface MachineGeneralFieldsProps {
formStore: FormStore<MachineData, ResponseData>;
}
export function MachineGeneralFields(props: MachineGeneralFieldsProps) {
const { formStore } = props;
return (
<Fieldset legend="General">
<Field name="machine.name" of={formStore}>
{(
field: FieldStore<MachineData, "machine.name">,
fieldProps: FieldElementProps<MachineData, "machine.name">,
) => {
return (
<TextInput
inputProps={fieldProps}
label="Name"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
);
}}
</Field>
<Field name="machine.description" of={formStore}>
{(
field: FieldStore<MachineData, "machine.description">,
fieldProps: FieldElementProps<MachineData, "machine.description">,
) => (
<TextInput
inputProps={fieldProps}
label="Description"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
/>
)}
</Field>
<Field name="machine.tags" of={formStore} type="string[]">
{(
field: FieldStore<MachineData, "machine.tags">,
fieldProps: FieldElementProps<MachineData, "machine.tags">,
) => (
<div class="grid grid-cols-10 items-center">
<Typography
hierarchy="label"
size="default"
weight="bold"
class="col-span-5"
>
Tags{" "}
</Typography>
<div class="col-span-5 justify-self-end">
<TagList values={[...(field.value || [])].sort()} />
</div>
</div>
)}
</Field>
</Fieldset>
);
}

View File

@@ -0,0 +1,55 @@
import {
Field,
FieldValues,
FormStore,
ResponseData,
FieldStore,
FieldElementProps,
} from "@modular-forms/solid";
import { Typography } from "@/src/components/Typography";
import { InputLabel } from "@/src/components/inputBase";
import { FieldLayout } from "@/src/Form/fields/layout";
import Fieldset from "@/src/Form/fieldset";
import { SuccessData } from "@/src/api";
type MachineData = SuccessData<"get_machine_details">;
interface MachineHardwareInfoProps {
formStore: FormStore<MachineData, ResponseData>;
}
export function MachineHardwareInfo(props: MachineHardwareInfoProps) {
const { formStore } = props;
return (
<Typography hierarchy={"body"} size={"s"}>
<Fieldset>
<Field name="hw_config" of={formStore}>
{(
field: FieldStore<MachineData, "hw_config">,
fieldProps: FieldElementProps<MachineData, "hw_config">,
) => (
<FieldLayout
label={<InputLabel>Hardware Configuration</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
)}
</Field>
<hr />
<Field name="disk_schema.schema_name" of={formStore}>
{(
field: FieldStore<MachineData, "disk_schema.schema_name">,
fieldProps: FieldElementProps<
MachineData,
"disk_schema.schema_name"
>,
) => (
<FieldLayout
label={<InputLabel>Disk schema</InputLabel>}
field={<span>{field.value || "None"}</span>}
/>
)}
</Field>
</Fieldset>
</Typography>
);
}

View File

@@ -0,0 +1,9 @@
export { MachineActionsBar } from "./MachineActionsBar";
export { MachineAvatar } from "./MachineAvatar";
export { MachineForm } from "./MachineForm";
export { MachineGeneralFields } from "./MachineGeneralFields";
export { MachineHardwareInfo } from "./MachineHardwareInfo";
export { InstallMachine } from "./InstallMachine";
export { InstallProgress } from "./InstallProgress";
export { InstallStepper } from "./InstallStepper";
export { InstallStepNavigation } from "./InstallStepNavigation";

View File

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

Some files were not shown because too many files have changed in this diff Show More