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:
39
pkgs/clan-app/process-compose-2d.yaml
Normal file
39
pkgs/clan-app/process-compose-2d.yaml
Normal 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
1
pkgs/clan-app/ui-2d/.fonts
Symbolic link
@@ -0,0 +1 @@
|
||||
../ui/.fonts
|
||||
5
pkgs/clan-app/ui-2d/.gitignore
vendored
Normal file
5
pkgs/clan-app/ui-2d/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
app/api
|
||||
app/.fonts
|
||||
|
||||
.vite
|
||||
storybook-static
|
||||
1
pkgs/clan-app/ui-2d/.storybook
Symbolic link
1
pkgs/clan-app/ui-2d/.storybook
Symbolic link
@@ -0,0 +1 @@
|
||||
../ui/.storybook
|
||||
7
pkgs/clan-app/ui-2d/.vscode/settings.json
vendored
Normal file
7
pkgs/clan-app/ui-2d/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cx\\(([^)]*)\\)", "[\"'`]([^\"'`]*)[\"'`]"]
|
||||
],
|
||||
"editor.wordWrap": "on"
|
||||
}
|
||||
43
pkgs/clan-app/ui-2d/README.md
Normal file
43
pkgs/clan-app/ui-2d/README.md
Normal 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
1
pkgs/clan-app/ui-2d/api
Symbolic link
@@ -0,0 +1 @@
|
||||
../ui/api
|
||||
37
pkgs/clan-app/ui-2d/eslint.config.mjs
Normal file
37
pkgs/clan-app/ui-2d/eslint.config.mjs
Normal 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;
|
||||
97
pkgs/clan-app/ui-2d/gtk.webview.js
Normal file
97
pkgs/clan-app/ui-2d/gtk.webview.js
Normal 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
1
pkgs/clan-app/ui-2d/icons
Symbolic link
@@ -0,0 +1 @@
|
||||
../ui/icons
|
||||
14
pkgs/clan-app/ui-2d/index.html
Normal file
14
pkgs/clan-app/ui-2d/index.html
Normal 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>
|
||||
8
pkgs/clan-app/ui-2d/postcss.config.js
Normal file
8
pkgs/clan-app/ui-2d/postcss.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
pkgs/clan-app/ui-2d/prettier.config.js
Normal file
9
pkgs/clan-app/ui-2d/prettier.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @see https://prettier.io/docs/en/configuration.html
|
||||
* @type {import("prettier").Config}
|
||||
*/
|
||||
const config = {
|
||||
trailingComma: "all",
|
||||
};
|
||||
|
||||
export default config;
|
||||
127
pkgs/clan-app/ui-2d/src/Form/base/index.tsx
Normal file
127
pkgs/clan-app/ui-2d/src/Form/base/index.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
15
pkgs/clan-app/ui-2d/src/Form/base/label.tsx
Normal file
15
pkgs/clan-app/ui-2d/src/Form/base/label.tsx
Normal 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>
|
||||
);
|
||||
8
pkgs/clan-app/ui-2d/src/Form/fields/FormSection.tsx
Normal file
8
pkgs/clan-app/ui-2d/src/Form/fields/FormSection.tsx
Normal 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>;
|
||||
};
|
||||
274
pkgs/clan-app/ui-2d/src/Form/fields/Select.tsx
Normal file
274
pkgs/clan-app/ui-2d/src/Form/fields/Select.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
pkgs/clan-app/ui-2d/src/Form/fields/TextInput.tsx
Normal file
58
pkgs/clan-app/ui-2d/src/Form/fields/TextInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
pkgs/clan-app/ui-2d/src/Form/fields/index.ts
Normal file
2
pkgs/clan-app/ui-2d/src/Form/fields/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./FormSection";
|
||||
export * from "./TextInput";
|
||||
26
pkgs/clan-app/ui-2d/src/Form/fields/layout.tsx
Normal file
26
pkgs/clan-app/ui-2d/src/Form/fields/layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
pkgs/clan-app/ui-2d/src/Form/fieldset/index.tsx
Normal file
32
pkgs/clan-app/ui-2d/src/Form/fieldset/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
928
pkgs/clan-app/ui-2d/src/Form/form/index.tsx
Normal file
928
pkgs/clan-app/ui-2d/src/Form/form/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
pkgs/clan-app/ui-2d/src/api/index.tsx
Normal file
189
pkgs/clan-app/ui-2d/src/api/index.tsx
Normal 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 };
|
||||
};
|
||||
20
pkgs/clan-app/ui-2d/src/api/wifi.ts
Normal file
20
pkgs/clan-app/ui-2d/src/api/wifi.ts
Normal 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;
|
||||
}
|
||||
188
pkgs/clan-app/ui-2d/src/api_test.tsx
Normal file
188
pkgs/clan-app/ui-2d/src/api_test.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
pkgs/clan-app/ui-2d/src/components/BackButton.tsx
Normal file
16
pkgs/clan-app/ui-2d/src/components/BackButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
pkgs/clan-app/ui-2d/src/components/Button/Button-Base.css
Normal file
55
pkgs/clan-app/ui-2d/src/components/Button/Button-Base.css
Normal 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;
|
||||
}
|
||||
31
pkgs/clan-app/ui-2d/src/components/Button/Button-Dark.css
Normal file
31
pkgs/clan-app/ui-2d/src/components/Button/Button-Dark.css
Normal 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;
|
||||
}
|
||||
11
pkgs/clan-app/ui-2d/src/components/Button/Button-Ghost.css
Normal file
11
pkgs/clan-app/ui-2d/src/components/Button/Button-Ghost.css
Normal 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;
|
||||
}
|
||||
37
pkgs/clan-app/ui-2d/src/components/Button/Button-Light.css
Normal file
37
pkgs/clan-app/ui-2d/src/components/Button/Button-Light.css
Normal 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;
|
||||
}
|
||||
}
|
||||
97
pkgs/clan-app/ui-2d/src/components/Button/Button.tsx
Normal file
97
pkgs/clan-app/ui-2d/src/components/Button/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
100
pkgs/clan-app/ui-2d/src/components/FileInput.tsx
Normal file
100
pkgs/clan-app/ui-2d/src/components/FileInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
pkgs/clan-app/ui-2d/src/components/Helpers/List.tsx
Normal file
20
pkgs/clan-app/ui-2d/src/components/Helpers/List.tsx
Normal 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>;
|
||||
};
|
||||
1
pkgs/clan-app/ui-2d/src/components/Helpers/index.tsx
Normal file
1
pkgs/clan-app/ui-2d/src/components/Helpers/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { List } from "./List";
|
||||
84
pkgs/clan-app/ui-2d/src/components/Menu.tsx
Normal file
84
pkgs/clan-app/ui-2d/src/components/Menu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
293
pkgs/clan-app/ui-2d/src/components/RemoteForm.stories.tsx
Normal file
293
pkgs/clan-app/ui-2d/src/components/RemoteForm.stories.tsx
Normal 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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
464
pkgs/clan-app/ui-2d/src/components/RemoteForm.tsx
Normal file
464
pkgs/clan-app/ui-2d/src/components/RemoteForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
pkgs/clan-app/ui-2d/src/components/SelectInput.tsx
Normal file
54
pkgs/clan-app/ui-2d/src/components/SelectInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
71
pkgs/clan-app/ui-2d/src/components/Sidebar/SidebarHeader.tsx
Normal file
71
pkgs/clan-app/ui-2d/src/components/Sidebar/SidebarHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
32
pkgs/clan-app/ui-2d/src/components/Sidebar/css/sidebar.css
Normal file
32
pkgs/clan-app/ui-2d/src/components/Sidebar/css/sidebar.css
Normal 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: "";
|
||||
}
|
||||
}
|
||||
85
pkgs/clan-app/ui-2d/src/components/Sidebar/index.tsx
Normal file
85
pkgs/clan-app/ui-2d/src/components/Sidebar/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
7
pkgs/clan-app/ui-2d/src/components/TagList/TagList.css
Normal file
7
pkgs/clan-app/ui-2d/src/components/TagList/TagList.css
Normal 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;
|
||||
}
|
||||
}
|
||||
21
pkgs/clan-app/ui-2d/src/components/TagList/TagList.tsx
Normal file
21
pkgs/clan-app/ui-2d/src/components/TagList/TagList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@import "./typography-label.css";
|
||||
@import "./typography-body.css";
|
||||
@import "./typography-title.css";
|
||||
@import "./typography-headline.css";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
109
pkgs/clan-app/ui-2d/src/components/Typography/index.tsx
Normal file
109
pkgs/clan-app/ui-2d/src/components/Typography/index.tsx
Normal 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>;
|
||||
10
pkgs/clan-app/ui-2d/src/components/accordion/accordion.css
Normal file
10
pkgs/clan-app/ui-2d/src/components/accordion/accordion.css
Normal 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 {
|
||||
}
|
||||
45
pkgs/clan-app/ui-2d/src/components/accordion/index.tsx
Normal file
45
pkgs/clan-app/ui-2d/src/components/accordion/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
pkgs/clan-app/ui-2d/src/components/badge/index.tsx
Normal file
39
pkgs/clan-app/ui-2d/src/components/badge/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
194
pkgs/clan-app/ui-2d/src/components/fileSelect/index.tsx
Normal file
194
pkgs/clan-app/ui-2d/src/components/fileSelect/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
pkgs/clan-app/ui-2d/src/components/group/index.tsx
Normal file
60
pkgs/clan-app/ui-2d/src/components/group/index.tsx
Normal 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>
|
||||
);
|
||||
99
pkgs/clan-app/ui-2d/src/components/icon/index.tsx
Normal file
99
pkgs/clan-app/ui-2d/src/components/icon/index.tsx
Normal 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;
|
||||
194
pkgs/clan-app/ui-2d/src/components/inputBase/index.tsx
Normal file
194
pkgs/clan-app/ui-2d/src/components/inputBase/index.tsx
Normal 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"
|
||||
>
|
||||
<>*</>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
180
pkgs/clan-app/ui-2d/src/components/machine-list-item/index.tsx
Normal file
180
pkgs/clan-app/ui-2d/src/components/machine-list-item/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
134
pkgs/clan-app/ui-2d/src/components/modal/index.tsx
Normal file
134
pkgs/clan-app/ui-2d/src/components/modal/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119
pkgs/clan-app/ui-2d/src/components/noiseThumbnail/index.tsx
Normal file
119
pkgs/clan-app/ui-2d/src/components/noiseThumbnail/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
301
pkgs/clan-app/ui-2d/src/components/toast/index.tsx
Normal file
301
pkgs/clan-app/ui-2d/src/components/toast/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
pkgs/clan-app/ui-2d/src/components/v2
Symbolic link
1
pkgs/clan-app/ui-2d/src/components/v2
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ui/src/components/v2
|
||||
65
pkgs/clan-app/ui-2d/src/contexts/clan.tsx
Normal file
65
pkgs/clan-app/ui-2d/src/contexts/clan.tsx
Normal 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;
|
||||
}
|
||||
127
pkgs/clan-app/ui-2d/src/floating/index.tsx
Normal file
127
pkgs/clan-app/ui-2d/src/floating/index.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
44
pkgs/clan-app/ui-2d/src/hooks/index.ts
Normal file
44
pkgs/clan-app/ui-2d/src/hooks/index.ts
Normal 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;
|
||||
};
|
||||
157
pkgs/clan-app/ui-2d/src/index.css
Normal file
157
pkgs/clan-app/ui-2d/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
203
pkgs/clan-app/ui-2d/src/index.tsx
Normal file
203
pkgs/clan-app/ui-2d/src/index.tsx
Normal 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!,
|
||||
);
|
||||
29
pkgs/clan-app/ui-2d/src/layout/header.tsx
Normal file
29
pkgs/clan-app/ui-2d/src/layout/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
pkgs/clan-app/ui-2d/src/layout/layout.tsx
Normal file
29
pkgs/clan-app/ui-2d/src/layout/layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
pkgs/clan-app/ui-2d/src/queries/clan-meta.ts
Normal file
37
pkgs/clan-app/ui-2d/src/queries/clan-meta.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
});
|
||||
76
pkgs/clan-app/ui-2d/src/queries/index.ts
Normal file
76
pkgs/clan-app/ui-2d/src/queries/index.ts
Normal 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 [];
|
||||
},
|
||||
}));
|
||||
208
pkgs/clan-app/ui-2d/src/routes/clans/create.tsx
Normal file
208
pkgs/clan-app/ui-2d/src/routes/clans/create.tsx
Normal 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"];
|
||||
160
pkgs/clan-app/ui-2d/src/routes/clans/details.tsx
Normal file
160
pkgs/clan-app/ui-2d/src/routes/clans/details.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
3
pkgs/clan-app/ui-2d/src/routes/clans/index.ts
Normal file
3
pkgs/clan-app/ui-2d/src/routes/clans/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./list";
|
||||
export * from "./create";
|
||||
export * from "./details";
|
||||
160
pkgs/clan-app/ui-2d/src/routes/clans/list.tsx
Normal file
160
pkgs/clan-app/ui-2d/src/routes/clans/list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
pkgs/clan-app/ui-2d/src/routes/colors/view.tsx
Normal file
13
pkgs/clan-app/ui-2d/src/routes/colors/view.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
118
pkgs/clan-app/ui-2d/src/routes/components/index.tsx
Normal file
118
pkgs/clan-app/ui-2d/src/routes/components/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
6
pkgs/clan-app/ui-2d/src/routes/deploy/index.tsx
Normal file
6
pkgs/clan-app/ui-2d/src/routes/deploy/index.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { callApi } from "@/src/api";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
|
||||
export const Deploy = () => {
|
||||
return <div>Deloy view</div>;
|
||||
};
|
||||
31
pkgs/clan-app/ui-2d/src/routes/disk/view.tsx
Normal file
31
pkgs/clan-app/ui-2d/src/routes/disk/view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
438
pkgs/clan-app/ui-2d/src/routes/flash/view.tsx
Normal file
438
pkgs/clan-app/ui-2d/src/routes/flash/view.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
76
pkgs/clan-app/ui-2d/src/routes/hosts/view.tsx
Normal file
76
pkgs/clan-app/ui-2d/src/routes/hosts/view.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
5
pkgs/clan-app/ui-2d/src/routes/machines/index.ts
Normal file
5
pkgs/clan-app/ui-2d/src/routes/machines/index.ts
Normal 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
Reference in New Issue
Block a user