clan-app: Move changes for 2D view to separate ui-2d folder

This commit is contained in:
Qubasa
2025-06-18 17:58:49 +02:00
parent 1b23d5dcf3
commit e785a6be4e
161 changed files with 30069 additions and 74 deletions

View File

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

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

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

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

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

View File

@@ -0,0 +1,34 @@
import { createRequire } from "module";
import { dirname, join } from "path";
import { mergeConfig } from "vite";
import type { StorybookConfig } from "@kachurun/storybook-solid-vite";
const require = createRequire(import.meta.url);
const getAbsolutePath = (pkg: string) =>
dirname(require.resolve(join(pkg, "package.json")));
const config: StorybookConfig = {
stories: ["../src/components/**/*.mdx", "../src/components/**/*.stories.tsx"],
addons: [
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@storybook/addon-interactions"),
],
framework: {
name: "@kachurun/storybook-solid-vite",
options: {},
},
async viteFinal(config) {
return mergeConfig(config, {
define: { "process.env": {} },
});
},
docs: {
autodocs: "tag",
},
core: {
disableTelemetry: true,
},
};
export default config;

View File

@@ -0,0 +1,5 @@
html {
/* revert this which only makes sense when rendering inside the clan app webview */
overflow-x: revert;
overflow-y: revert;
}

View File

@@ -0,0 +1,29 @@
import type { Preview } from "@kachurun/storybook-solid";
import "@/src/components/v2/index.css";
import "../src/index.css";
import "./preview.css";
export const preview: Preview = {
tags: ["autodocs"],
parameters: {
docs: { toc: true },
backgrounds: {
values: [
{ name: "Dark", value: "#333" },
{ name: "Light", value: "#ffffff" },
],
default: "Light",
},
// automatically create action args for all props that start with "on"
actions: { argTypesRegex: "^on.*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};
export default preview;

View File

@@ -0,0 +1,12 @@
import type { TestRunnerConfig } from "@storybook/test-runner";
const config: TestRunnerConfig = {
async postVisit(page, context) {
// the #storybook-root element wraps the story. In Storybook 6.x, the selector is #root
const elementHandler = await page.$("#storybook-root");
const innerHTML = await elementHandler.innerHTML();
expect(innerHTML).toMatchSnapshot();
},
};
export default config;

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

14253
pkgs/clan-app/ui-2d/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,78 @@
{
"name": "@clan/ui",
"version": "0.0.1",
"description": "",
"type": "module",
"scripts": {
"start": "vite",
"dev": "vite",
"build": "npm run check && npm run test && vite build && npm run convert-html",
"convert-html": "node gtk.webview.js",
"serve": "vite preview",
"check": "tsc --noEmit --skipLibCheck && eslint ./src",
"test": "vitest run --typecheck",
"storybook": "storybook",
"storybook-build": "storybook build",
"storybook-dev": "storybook dev -p 6006",
"test-storybook": "test-storybook --browsers chromium --ci",
"test-storybook-update-snapshots": "npm run test-storybook -- --updateSnapshot",
"test-storybook-static": "npm run storybook-build && concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'http-server storybook-static --port 6006 --silent' 'wait-on tcp:127.0.0.1:6006 && npm run test-storybook'"
},
"license": "MIT",
"devDependencies": {
"@babel/plugin-syntax-import-attributes": "^7.27.1",
"@eslint/js": "^9.3.0",
"@kachurun/storybook-solid": "^8.6.7",
"@kachurun/storybook-solid-vite": "^8.6.7",
"@storybook/addon-essentials": "^8.6.14",
"@storybook/addon-interactions": "^8.6.14",
"@storybook/addon-links": "^8.6.14",
"@storybook/addon-viewport": "^8.6.14",
"@storybook/builder-vite": "^8.6.14",
"@storybook/test-runner": "^0.22.0",
"@tailwindcss/typography": "^0.5.13",
"@types/json-schema": "^7.0.15",
"@types/node": "^22.15.19",
"@types/three": "^0.176.0",
"@typescript-eslint/parser": "^8.32.1",
"autoprefixer": "^10.4.19",
"classnames": "^2.5.1",
"concurrently": "^9.1.2",
"eslint": "^9.27.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"http-server": "^14.1.1",
"jsdom": "^26.1.0",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",
"solid-devtools": "^0.34.0",
"storybook": "^8.6.14",
"storybook-addon-pseudo-states": "^4.0.3",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"typescript-eslint": "^8.32.1",
"vite": "^6.3.5",
"vite-plugin-solid": "^2.8.2",
"vite-plugin-solid-svg": "^0.8.1",
"vitest": "^3.1.4",
"wait-on": "^8.0.3"
},
"dependencies": {
"@floating-ui/dom": "^1.6.8",
"@kobalte/core": "^0.13.10",
"@kobalte/tailwindcss": "^0.9.0",
"@modular-forms/solid": "^0.25.1",
"@solid-primitives/storage": "^4.3.2",
"@solidjs/router": "^0.15.3",
"@storybook/test": "^8.6.14",
"@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/solid-query": "^5.76.0",
"corvu": "^0.7.1",
"material-icons": "^1.13.12",
"nanoid": "^5.0.7",
"solid-js": "^1.9.7",
"solid-markdown": "^2.0.13",
"solid-toast": "^0.5.0",
"three": "^0.176.0"
}
}

View File

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

View File

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

View File

@@ -0,0 +1,127 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import type {
ComputePositionConfig,
ComputePositionReturn,
ReferenceElement,
} from "@floating-ui/dom";
import { computePosition } from "@floating-ui/dom";
export interface UseFloatingOptions<
R extends ReferenceElement,
F extends HTMLElement,
> extends Partial<ComputePositionConfig> {
whileElementsMounted?: (
reference: R,
floating: F,
update: () => void,
) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
void | (() => void);
}
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
x?: number | null;
y?: number | null;
}
export interface UseFloatingResult extends UseFloatingState {
update(): void;
}
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
reference: () => R | undefined | null,
floating: () => F | undefined | null,
options?: UseFloatingOptions<R, F>,
): UseFloatingResult {
const placement = () => options?.placement ?? "bottom";
const strategy = () => options?.strategy ?? "absolute";
const [data, setData] = createSignal<UseFloatingState>({
x: null,
y: null,
placement: placement(),
strategy: strategy(),
middlewareData: {},
});
const [error, setError] = createSignal<{ value: unknown } | undefined>();
createEffect(() => {
const currentError = error();
if (currentError) {
throw currentError.value;
}
});
const version = createMemo(() => {
reference();
floating();
return {};
});
function update() {
const currentReference = reference();
const currentFloating = floating();
if (currentReference && currentFloating) {
const capturedVersion = version();
computePosition(currentReference, currentFloating, {
middleware: options?.middleware,
placement: placement(),
strategy: strategy(),
}).then(
(currentData) => {
// Check if it's still valid
if (capturedVersion === version()) {
setData(currentData);
}
},
(err) => {
setError(err);
},
);
}
}
createEffect(() => {
const currentReference = reference();
const currentFloating = floating();
placement();
strategy();
if (currentReference && currentFloating) {
if (options?.whileElementsMounted) {
const cleanup = options.whileElementsMounted(
currentReference,
currentFloating,
update,
);
if (cleanup) {
onCleanup(cleanup);
}
} else {
update();
}
}
});
return {
get x() {
return data().x;
},
get y() {
return data().y;
},
get placement() {
return data().placement;
},
get strategy() {
return data().strategy;
},
get middlewareData() {
return data().middlewareData;
},
update,
};
}

View File

@@ -0,0 +1,15 @@
import type { JSX } from "solid-js";
interface LabelProps {
label: JSX.Element;
required?: boolean;
}
export const Label = (props: LabelProps) => (
<span
class=" block"
classList={{
"after:ml-0.5 after:text-primary after:content-['*']": props.required,
}}
>
{props.label}
</span>
);

View File

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

View File

@@ -0,0 +1,274 @@
import {
createUniqueId,
createSignal,
Show,
type JSX,
For,
createMemo,
Accessor,
} from "solid-js";
import { Portal } from "solid-js/web";
import { useFloating } from "../base";
import { autoUpdate, flip, hide, offset, shift, size } from "@floating-ui/dom";
import { Button } from "../../components/Button/Button";
import {
InputBase,
InputError,
InputLabel,
InputLabelProps,
} from "@/src/components/inputBase";
import { FieldLayout } from "./layout";
import Icon from "@/src/components/icon";
import { useContext } from "corvu/dialog";
export interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface SelectInputpProps {
value: string[] | string;
selectProps?: JSX.InputHTMLAttributes<HTMLSelectElement>;
options: Option[];
label: JSX.Element;
labelProps?: InputLabelProps;
helperText?: JSX.Element;
error?: string;
required?: boolean;
type?: string;
inlineLabel?: JSX.Element;
class?: string;
adornment?: {
position: "start" | "end";
content: JSX.Element;
};
disabled?: boolean;
placeholder?: string;
multiple?: boolean;
loading?: boolean;
portalRef?: Accessor<HTMLElement | null>;
}
export function SelectInput(props: SelectInputpProps) {
const dialogContext = (dialogContextId?: string) =>
useContext(dialogContextId);
const _id = createUniqueId();
const [reference, setReference] = createSignal<HTMLElement>();
const [floating, setFloating] = createSignal<HTMLElement>();
// `position` is a reactive object.
const position = useFloating(reference, floating, {
placement: "bottom-start",
// pass options. Ensure the cleanup function is returned.
whileElementsMounted: (reference, floating, update) =>
autoUpdate(reference, floating, update, {
animationFrame: true,
}),
middleware: [
size({
apply({ rects, elements }) {
Object.assign(elements.floating.style, {
minWidth: `${rects.reference.width}px`,
});
},
}),
offset({ mainAxis: 2 }),
shift(),
flip(),
hide({
strategy: "referenceHidden",
}),
],
});
// Create values list
const getValues = createMemo(() => {
return Array.isArray(props.value)
? (props.value as string[])
: typeof props.value === "string"
? [props.value]
: [];
});
// const getSingleValue = createMemo(() => {
// const values = getValues();
// return values.length > 0 ? values[0] : "";
// });
const handleClickOption = (opt: Option) => {
if (!props.multiple) {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
value: opt.value,
},
});
return;
}
let currValues = getValues();
if (currValues.includes(opt.value)) {
currValues = currValues.filter((o) => o !== opt.value);
} else {
currValues.push(opt.value);
}
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: currValues.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
};
return (
<>
<FieldLayout
error={props.error && <InputError error={props.error} />}
label={
<InputLabel
description={""}
required={props.required}
{...props.labelProps}
>
{props.label}
</InputLabel>
}
field={
<InputBase
error={!!props.error}
disabled={props.disabled}
required={props.required}
class="!justify-start"
divRef={setReference}
inputElem={
<button
// TODO: Keyboard acessibililty
// Currently the popover only opens with onClick
// Options are not selectable with keyboard
tabIndex={-1}
disabled={props.disabled}
onClick={() => {
const popover = document.getElementById(_id);
if (popover) {
popover.togglePopover(); // Show or hide the popover
}
}}
type="button"
class="flex w-full items-center gap-2"
formnovalidate
// TODO: Use native popover once Webkit supports it within <form>
// popovertarget={_id}
// popovertargetaction="toggle"
>
<Show
when={props.adornment && props.adornment.position === "start"}
>
{props.adornment?.content}
</Show>
{props.inlineLabel}
<div class="flex cursor-default flex-row gap-2">
<Show
when={
getValues() &&
getValues.length !== 1 &&
getValues()[0] !== ""
}
fallback={props.placeholder}
>
<For each={getValues()} fallback={"Select"}>
{(item) => (
<div class="rounded-xl bg-slate-800 px-4 py-1 text-sm text-white">
{item}
<Show when={props.multiple}>
<button
class=""
type="button"
onClick={(_e) => {
// @ts-expect-error: fieldName is not known ahead of time
props.selectProps.onInput({
currentTarget: {
options: getValues()
.filter((o) => o !== item)
.map((value) => ({
value,
selected: true,
disabled: false,
})),
},
});
}}
>
X
</button>
</Show>
</div>
)}
</For>
</Show>
</div>
<Show
when={props.adornment && props.adornment.position === "end"}
>
{props.adornment?.content}
</Show>
<Icon size={12} icon="CaretDown" class="ml-auto mr-2" />
</button>
}
/>
}
/>
<Portal
mount={
props.portalRef ? props.portalRef() || document.body : document.body
}
>
<div
id={_id}
popover
ref={setFloating}
style={{
margin: 0,
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}}
class="rounded-md border border-gray-200 bg-white shadow-lg"
>
<ul class="flex max-h-96 flex-col gap-1 overflow-x-hidden overflow-y-scroll p-1">
<Show when={!props.loading} fallback={"Loading ...."}>
<For each={props.options}>
{(opt) => (
<>
<li>
<Button
variant="ghost"
class="!justify-start"
onClick={() => handleClickOption(opt)}
disabled={opt.disabled}
classList={{
active:
!opt.disabled && getValues().includes(opt.value),
}}
>
{opt.label}
</Button>
</li>
</>
)}
</For>
</Show>
</ul>
</div>
</Portal>
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
import { JSX } from "solid-js";
import { Typography } from "@/src/components/Typography";
interface FieldsetProps {
legend?: string;
children: JSX.Element;
class?: string;
}
export default function Fieldset(props: FieldsetProps) {
return (
<fieldset class="flex flex-col gap-y-2.5">
{props.legend && (
<div class="px-2">
<Typography
hierarchy="body"
tag="p"
size="s"
color="primary"
weight="medium"
>
{props.legend}
</Typography>
</div>
)}
<div class="flex flex-col gap-y-3 rounded-md border border-secondary-200 bg-secondary-50 p-5">
{props.children}
</div>
</fieldset>
);
}

View File

@@ -0,0 +1,928 @@
import {
createForm,
Field,
FieldArray,
FieldValues,
FormStore,
pattern,
ResponseData,
setValue,
getValues,
insert,
SubmitHandler,
reset,
remove,
move,
} from "@modular-forms/solid";
import { JSONSchema7, JSONSchema7Type } from "json-schema";
import { TextInput } from "../fields/TextInput";
import { createEffect, For, JSX, Match, Show, Switch } from "solid-js";
import cx from "classnames";
import { Label } from "../base/label";
import { SelectInput } from "../fields/Select";
import { Button } from "../../components/Button/Button";
import Icon from "@/src/components/icon";
function generateDefaults(schema: JSONSchema7): unknown {
switch (schema.type) {
case "string":
return ""; // Default value for string
case "number":
case "integer":
return 0; // Default value for number/integer
case "boolean":
return false; // Default value for boolean
case "array":
return []; // Default empty array if no items schema or items is true/false
case "object": {
const obj: Record<string, unknown> = {};
if (schema.properties) {
Object.entries(schema.properties).forEach(([key, propSchema]) => {
if (typeof propSchema === "boolean") {
obj[key] = false;
} else {
// if (schema.required schema.required.includes(key))
obj[key] = generateDefaults(propSchema);
}
});
}
return obj;
}
default:
return null; // Default for unknown types or nulls
}
}
interface FormProps {
schema: JSONSchema7;
initialValues?: NonNullable<unknown>;
handleSubmit?: SubmitHandler<NonNullable<unknown>>;
initialPath?: string[];
components?: {
before?: JSX.Element;
after?: JSX.Element;
};
readonly?: boolean;
formProps?: JSX.InputHTMLAttributes<HTMLFormElement>;
errorContext?: string;
resetOnSubmit?: boolean;
}
export const DynForm = (props: FormProps) => {
const [formStore, { Field, Form: ModuleForm }] = createForm({
initialValues: props.initialValues,
});
const handleSubmit: SubmitHandler<NonNullable<unknown>> = async (
values,
event,
) => {
console.log("Submitting form values", values, props.errorContext);
props.handleSubmit?.(values, event);
// setValue(formStore, "root", null);
if (props.resetOnSubmit) {
console.log("Resetting form", values, props.initialValues);
reset(formStore);
}
};
createEffect(() => {
console.log("FormStore", formStore);
});
return (
<>
{/* @ts-expect-error: This happened after solidjs upgrade. TOOD: fixme */}
<ModuleForm {...props.formProps} onSubmit={handleSubmit}>
{props.components?.before}
<SchemaFields
schema={props.schema}
Field={Field}
formStore={formStore}
path={props.initialPath || []}
readonly={!!props.readonly}
parent={props.schema}
/>
{props.components?.after}
</ModuleForm>
</>
);
};
interface UnsupportedProps {
schema: JSONSchema7;
error?: string;
}
const Unsupported = (props: UnsupportedProps) => (
<div>
{props.error && <div class="font-bold text-error-700">{props.error}</div>}
<span>
Invalid or unsupported schema entry of type:{" "}
<b>{JSON.stringify(props.schema.type)}</b>
</span>
<pre>
<code>{JSON.stringify(props.schema, null, 2)}</code>
</pre>
</div>
);
interface SchemaFieldsProps<T extends FieldValues, R extends ResponseData> {
formStore: FormStore<T, R>;
Field: typeof Field<T, R, never>;
schema: JSONSchema7;
path: string[];
readonly: boolean;
parent: JSONSchema7;
}
export function SchemaFields<T extends FieldValues, R extends ResponseData>(
props: SchemaFieldsProps<T, R>,
) {
return (
<Switch fallback={<Unsupported schema={props.schema} />}>
{/* Simple types */}
<Match when={props.schema.type === "boolean"}>bool</Match>
<Match when={props.schema.type === "integer"}>
<StringField {...props} schema={props.schema} />
</Match>
<Match when={props.schema.type === "number"}>
<StringField {...props} schema={props.schema} />
</Match>
<Match when={props.schema.type === "string"}>
<StringField {...props} schema={props.schema} />
</Match>
{/* Composed types */}
<Match when={props.schema.type === "array"}>
<ArrayFields {...props} schema={props.schema} />
</Match>
<Match when={props.schema.type === "object"}>
<ObjectFields {...props} schema={props.schema} />
</Match>
{/* Empty / Null */}
<Match when={props.schema.type === "null"}>
Dont know how to rendner InputType null
<Unsupported schema={props.schema} />
</Match>
</Switch>
);
}
export function StringField<T extends FieldValues, R extends ResponseData>(
props: SchemaFieldsProps<T, R>,
) {
if (
props.schema.type !== "string" &&
props.schema.type !== "number" &&
props.schema.type !== "integer"
) {
return (
<span class="text-error-700">
Error cannot render the following as String input.
<Unsupported schema={props.schema} />
</span>
);
}
const { Field } = props;
const validate = props.schema.pattern
? pattern(
new RegExp(props.schema.pattern),
`String should follow pattern ${props.schema.pattern}`,
)
: undefined;
const commonProps = {
label: props.schema.title || props.path.join("."),
required:
props.parent.required &&
props.parent.required.some(
(r) => r === props.path[props.path.length - 1],
),
};
const readonly = !!props.readonly;
return (
<Switch fallback={<Unsupported schema={props.schema} />}>
<Match
when={props.schema.type === "number" || props.schema.type === "integer"}
>
{(s) => (
<Field
// @ts-expect-error: We dont know dynamic names while type checking
name={props.path.join(".")}
validate={validate}
>
{(field, fieldProps) => (
<>
<TextInput
inputProps={{
...fieldProps,
inputmode: "numeric",
pattern: "[0-9.]*",
readonly,
}}
{...commonProps}
value={(field.value as unknown as string) || ""}
error={field.error}
// required
// altLabel="Leave empty to accept the default"
// helperText="Configure how dude connects"
// error="Something is wrong now"
/>
</>
)}
</Field>
)}
</Match>
<Match when={props.schema.enum}>
{(_enumSchemas) => (
<Field
// @ts-expect-error: We dont know dynamic names while type checking
name={props.path.join(".")}
>
{(field, fieldProps) => (
<OnlyStringItems itemspec={props.schema}>
{(options) => (
<SelectInput
error={field.error}
// altLabel={props.schema.title}
label={props.path.join(".")}
helperText={props.schema.description}
value={field.value || []}
options={options.map((o) => ({
value: o,
label: o,
}))}
selectProps={fieldProps}
required={!!props.schema.minItems}
/>
)}
</OnlyStringItems>
)}
</Field>
)}
</Match>
<Match when={props.schema.writeOnly && props.schema}>
{(s) => (
<Field
// @ts-expect-error: We dont know dynamic names while type checking
name={props.path.join(".")}
validate={validate}
>
{(field, fieldProps) => (
<TextInput
inputProps={{ ...fieldProps, readonly }}
value={field.value as unknown as string}
// type="password"
error={field.error}
{...commonProps}
// required
// altLabel="Leave empty to accept the default"
// helperText="Configure how dude connects"
// error="Something is wrong now"
/>
)}
</Field>
)}
</Match>
{/* TODO: when is it a normal string input? */}
<Match when={props.schema}>
{(s) => (
<Field
// @ts-expect-error: We dont know dynamic names while type checking
name={props.path.join(".")}
validate={validate}
>
{(field, fieldProps) => (
<TextInput
inputProps={{ ...fieldProps, readonly }}
value={field.value as unknown as string}
error={field.error}
{...commonProps}
// placeholder="foobar"
// inlineLabel={
// <div class="label">
// <span class=""></span>
// </div>
// }
// required
// altLabel="Leave empty to accept the default"
// helperText="Configure how dude connects"
// error="Something is wrong now"
/>
)}
</Field>
)}
</Match>
</Switch>
);
}
interface OptionSchemaProps {
itemSpec: JSONSchema7Type;
}
export function OptionSchema(props: OptionSchemaProps) {
return (
<Switch
fallback={<option class="text-error-700">Item spec unhandled</option>}
>
<Match when={typeof props.itemSpec === "string" && props.itemSpec}>
{(o) => <option>{o()}</option>}
</Match>
</Switch>
);
}
interface ValueDisplayProps<T extends FieldValues, R extends ResponseData>
extends SchemaFieldsProps<T, R> {
children: JSX.Element;
listFieldName: string;
idx: number;
of: number;
}
export function ListValueDisplay<T extends FieldValues, R extends ResponseData>(
props: ValueDisplayProps<T, R>,
) {
const removeItem = (e: Event) => {
e.preventDefault();
remove(
props.formStore,
// @ts-expect-error: listFieldName is not known ahead of time
props.listFieldName,
{ at: props.idx },
);
};
const moveItemBy = (dir: number) => (e: Event) => {
e.preventDefault();
move(
props.formStore,
// @ts-expect-error: listFieldName is not known ahead of time
props.listFieldName,
{ from: props.idx, to: props.idx + dir },
);
};
const topMost = () => props.idx === props.of - 1;
const bottomMost = () => props.idx === 0;
return (
<div class="w-full border-b border-secondary-200 px-2 pb-4">
<div class="flex w-full items-center gap-2">
{props.children}
<div class="ml-4 min-w-fit">
<Button
variant="ghost"
size="s"
type="button"
onClick={moveItemBy(1)}
disabled={topMost()}
startIcon={<Icon icon="ArrowBottom" />}
class="h-12"
></Button>
<Button
type="button"
variant="ghost"
size="s"
onClick={moveItemBy(-1)}
disabled={bottomMost()}
class="h-12"
startIcon={<Icon icon="ArrowTop" />}
></Button>
<Button
type="button"
variant="ghost"
size="s"
class="h-12"
startIcon={<Icon icon="Trash" />}
onClick={removeItem}
></Button>
</div>
</div>
</div>
);
}
const findDuplicates = (arr: unknown[]) => {
const seen = new Set();
const duplicates: number[] = [];
arr.forEach((obj, idx) => {
const serializedObj = JSON.stringify(obj);
if (seen.has(serializedObj)) {
duplicates.push(idx);
} else {
seen.add(serializedObj);
}
});
return duplicates;
};
interface OnlyStringItems {
children: (items: string[]) => JSX.Element;
itemspec: JSONSchema7;
}
const OnlyStringItems = (props: OnlyStringItems) => {
return (
<Show
when={
Array.isArray(props.itemspec.enum) &&
typeof props.itemspec.type === "string" &&
props.itemspec
}
fallback={
<Unsupported
schema={props.itemspec}
error="Unsupported array item type"
/>
}
>
{props.children(props.itemspec.enum as string[])}
</Show>
);
};
export function ArrayFields<T extends FieldValues, R extends ResponseData>(
props: SchemaFieldsProps<T, R>,
) {
if (props.schema.type !== "array") {
return (
<span class="text-error-700">
Error cannot render the following as array.
<Unsupported schema={props.schema} />
</span>
);
}
const { Field } = props;
const listFieldName = props.path.join(".");
return (
<>
<Switch fallback={<Unsupported schema={props.schema} />}>
<Match
when={
!Array.isArray(props.schema.items) &&
typeof props.schema.items === "object" &&
props.schema.items
}
>
{(itemsSchema) => (
<>
<Switch fallback={<Unsupported schema={props.schema} />}>
<Match when={itemsSchema().type === "array"}>
<Unsupported
schema={props.schema}
error="Array of Array is not supported yet."
/>
</Match>
<Match
when={itemsSchema().type === "string" && itemsSchema().enum}
>
<Field
// @ts-expect-error: listFieldName is not known ahead of time
name={listFieldName}
// @ts-expect-error: type is known due to schema
type="string[]"
validateOn="touched"
revalidateOn="touched"
validate={() => {
let error = "";
const values: unknown[] = getValues(
props.formStore,
// @ts-expect-error: listFieldName is not known ahead of time
listFieldName,
// @ts-expect-error: assumption based on the behavior of selectInput
)?.strings?.selection;
console.log("vali", { values });
if (props.schema.uniqueItems) {
const duplicates = findDuplicates(values);
if (duplicates.length) {
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
}
}
if (
props.schema.maxItems &&
values.length > props.schema.maxItems
) {
error = `You can only select up to ${props.schema.maxItems} items`;
}
if (
props.schema.minItems &&
values.length < props.schema.minItems
) {
error = `Please select at least ${props.schema.minItems} items.`;
}
return error;
}}
>
{(field, fieldProps) => (
<OnlyStringItems itemspec={itemsSchema()}>
{(options) => (
<SelectInput
multiple
error={field.error}
// altLabel={props.schema.title}
label={listFieldName}
helperText={props.schema.description}
value={field.value || ""}
options={options.map((o) => ({
value: o,
label: o,
}))}
selectProps={fieldProps}
required={!!props.schema.minItems}
/>
)}
</OnlyStringItems>
)}
</Field>
</Match>
<Match
when={
itemsSchema().type === "string" ||
itemsSchema().type === "object"
}
>
{/* !Important: Register the parent field to gain access to array items*/}
<FieldArray
// @ts-expect-error: listFieldName is not known ahead of time
name={listFieldName}
of={props.formStore}
validateOn="touched"
revalidateOn="touched"
validate={() => {
let error = "";
// @ts-expect-error: listFieldName is not known ahead of time
const values: unknown[] = getValues(
props.formStore,
// @ts-expect-error: listFieldName is not known ahead of time
listFieldName,
);
if (props.schema.uniqueItems) {
const duplicates = findDuplicates(values);
if (duplicates.length) {
error = `Duplicate entries are not allowed. Please make sure each entry is unique.`;
}
}
if (
props.schema.maxItems &&
values.length > props.schema.maxItems
) {
error = `You can only add up to ${props.schema.maxItems} items`;
}
if (
props.schema.minItems &&
values.length < props.schema.minItems
) {
error = `Please add at least ${props.schema.minItems} items.`;
}
return error;
}}
>
{(fieldArray) => (
<>
{/* Render existing items */}
<For
each={fieldArray.items}
fallback={
// Empty list
<span class="text-neutral-500">
No {itemsSchema().title || "entries"} yet.
</span>
}
>
{(item, idx) => (
<ListValueDisplay
{...props}
listFieldName={listFieldName}
idx={idx()}
of={fieldArray.items.length}
>
<Field
// @ts-expect-error: field names are not know ahead of time
name={`${listFieldName}.${idx()}`}
>
{(f, fp) => (
<>
<DynForm
formProps={{
class: cx("w-full"),
}}
resetOnSubmit={true}
schema={itemsSchema()}
initialValues={
itemsSchema().type === "object"
? f.value
: { "": f.value }
}
readonly={true}
></DynForm>
</>
)}
</Field>
</ListValueDisplay>
)}
</For>
<Show when={fieldArray.error}>
<span class="font-bold text-error-700">
{fieldArray.error}
</span>
</Show>
{/* Add new item */}
<DynForm
formProps={{
class: cx("px-2 w-full"),
}}
schema={{
...itemsSchema(),
title: itemsSchema().title || "thing",
}}
initialPath={["root"]}
// Reset the input field for list items
resetOnSubmit={true}
initialValues={{
root: generateDefaults(itemsSchema()),
}}
// Button for adding new items
components={{
before: (
<div class="flex w-full justify-end pb-2">
<Button
variant="ghost"
type="submit"
endIcon={<Icon size={14} icon={"Plus"} />}
class="capitalize"
>
Add {itemsSchema().title}
</Button>
</div>
),
}}
// Add the new item to the FieldArray
handleSubmit={(values, event) => {
// @ts-expect-error: listFieldName is not known ahead of time
const prev: unknown[] = getValues(
props.formStore,
// @ts-expect-error: listFieldName is not known ahead of time
listFieldName,
);
if (itemsSchema().type === "object") {
const newIdx = prev.length;
setValue(
props.formStore,
// @ts-expect-error: listFieldName is not known ahead of time
`${listFieldName}.${newIdx}`,
// @ts-expect-error: listFieldName is not known ahead of time
values.root,
);
}
// @ts-expect-error: listFieldName is not known ahead of time
insert(props.formStore, listFieldName, {
// @ts-expect-error: listFieldName is not known ahead of time
value: values.root,
});
}}
/>
</>
)}
</FieldArray>
</Match>
</Switch>
</>
)}
</Match>
</Switch>
</>
);
}
interface ObjectFieldPropertyLabelProps {
schema: JSONSchema7;
fallback: JSX.Element;
}
export function ObjectFieldPropertyLabel(props: ObjectFieldPropertyLabelProps) {
return (
<Switch fallback={props.fallback}>
{/* @ts-expect-error: $exportedModuleInfo should exist since we export it */}
<Match when={props.schema?.$exportedModuleInfo?.path}>
{(path) => path()[path().length - 1]}
</Match>
</Switch>
);
}
export function ObjectFields<T extends FieldValues, R extends ResponseData>(
props: SchemaFieldsProps<T, R>,
) {
if (props.schema.type !== "object") {
return (
<span class="text-error-700">
Error cannot render the following as Object
<Unsupported schema={props.schema} />
</span>
);
}
const fieldName = props.path.join(".");
const { Field } = props;
return (
<Switch
fallback={
<Unsupported
schema={props.schema}
error="Dont know how to render objectFields"
/>
}
>
<Match
when={!props.schema.additionalProperties && props.schema.properties}
>
{(properties) => (
<For each={Object.entries(properties())}>
{([propName, propSchema]) => (
<div
// eslint-disable-next-line tailwindcss/no-custom-classname
class={cx(
"w-full grid grid-cols-1 gap-4 justify-items-start",
`p-${props.path.length * 2}`,
)}
>
<Label
label={propName}
required={props.schema.required?.some((r) => r === propName)}
/>
{typeof propSchema === "object" && (
<SchemaFields
{...props}
schema={propSchema}
path={[...props.path, propName]}
/>
)}
{typeof propSchema === "boolean" && (
<span class="text-error-700">
Schema: Object of Boolean not supported
</span>
)}
</div>
)}
</For>
)}
</Match>
{/* Objects where people can define their own keys
- Trivial Key-value pairs. Where the value is a string a number or a list of strings (trivial select).
- Non-trivial Key-value pairs. Where the value is an object or a list
*/}
<Match
when={
typeof props.schema.additionalProperties === "object" &&
props.schema.additionalProperties
}
>
{(additionalPropertiesSchema) => (
<Switch
fallback={
<Unsupported
schema={additionalPropertiesSchema()}
error="type of additionalProperties not supported yet"
/>
}
>
{/* Non-trivival cases */}
<Match
when={
additionalPropertiesSchema().type === "object" &&
additionalPropertiesSchema()
}
>
{(itemSchema) => (
<Field
// Important!: Register the object field to gain access to the dynamic object properties
// @ts-expect-error: fieldName is not known ahead of time
name={fieldName}
>
{(objectField, fp) => (
<>
<For
fallback={
<>
<label class="">
No{" "}
<ObjectFieldPropertyLabel
schema={itemSchema()}
fallback={"No entries"}
/>{" "}
yet.
</label>
</>
}
each={Object.entries(objectField.value || {})}
>
{([key, relatedValue]) => (
<Field
// @ts-expect-error: fieldName is not known ahead of time
name={`${fieldName}.${key}`}
>
{(f, fp) => (
<div class="w-full border-l-4 border-gray-300 pl-4">
<DynForm
formProps={{
class: cx("w-full"),
}}
schema={itemSchema()}
initialValues={f.value}
components={{
before: (
<div class="flex w-full">
<span class="text-xl font-semibold">
{key}
</span>
<Button
variant="ghost"
class="ml-auto"
size="s"
type="button"
onClick={(_e) => {
const copy = {
// @ts-expect-error: fieldName is not known ahead of time
...objectField.value,
};
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete copy[key];
setValue(
props.formStore,
// @ts-expect-error: fieldName is not known ahead of time
`${fieldName}`,
copy,
);
}}
>
<Icon icon="Trash" />
</Button>
</div>
),
}}
/>
</div>
)}
</Field>
)}
</For>
{/* Replace this with a normal input ?*/}
<DynForm
formProps={{
class: cx("w-full"),
}}
resetOnSubmit={true}
initialValues={{ "": "" }}
schema={{
type: "string",
title: `Entry title or key`,
}}
handleSubmit={(values, event) => {
setValue(
props.formStore,
// @ts-expect-error: fieldName is not known ahead of time
`${fieldName}`,
// @ts-expect-error: fieldName is not known ahead of time
{ ...objectField.value, [values[""]]: {} },
);
}}
/>
</>
)}
</Field>
)}
</Match>
<Match
when={
additionalPropertiesSchema().type === "array" &&
additionalPropertiesSchema()
}
>
{(itemSchema) => (
<Unsupported
schema={itemSchema()}
error="dynamic arrays are not implemented yet"
/>
)}
</Match>
{/* TODO: Trivial cases */}
</Switch>
)}
</Match>
</Switch>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
import cx from "classnames";
import { createMemo, JSX, Show, splitProps } from "solid-js";
export interface FileInputProps {
ref: (element: HTMLInputElement) => void;
name: string;
value?: File[] | File;
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
onClick: JSX.EventHandler<HTMLInputElement, Event>;
onChange: JSX.EventHandler<HTMLInputElement, Event>;
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
accept?: string;
required?: boolean;
multiple?: boolean;
class?: string;
label?: string;
error?: string;
helperText?: string;
placeholder?: JSX.Element;
}
/**
* File input field that users can click or drag files into. Various
* decorations can be displayed in or around the field to communicate the entry
* requirements.
*/
export function FileInput(props: FileInputProps) {
// Split input element props
const [, inputProps] = splitProps(props, [
"class",
"value",
"label",
"error",
"placeholder",
]);
// Create file list
const getFiles = createMemo(() =>
props.value
? Array.isArray(props.value)
? props.value
: [props.value]
: [],
);
return (
<div class={cx(" w-full", props.class)}>
<div class="">
<span
class=" block"
classList={{
"after:ml-0.5 after:text-primary after:content-['*']":
props.required,
}}
>
{props.label}
</span>
</div>
<Show when={props.helperText}>
<span class=" m-1">{props.helperText}</span>
</Show>
<div
class={cx(
"relative flex min-h-[96px] w-full items-center justify-center rounded-2xl border-[3px] border-dashed p-8 text-center focus-within:ring-4 md:min-h-[112px] md:text-lg lg:min-h-[128px] lg:p-10 lg:text-xl",
!getFiles().length && "text-slate-500",
props.error
? "border-red-500/25 focus-within:border-red-500/50 focus-within:ring-red-500/10 hover:border-red-500/40 dark:border-red-400/25 dark:focus-within:border-red-400/50 dark:focus-within:ring-red-400/10 dark:hover:border-red-400/40"
: "border-slate-200 focus-within:border-sky-500/50 focus-within:ring-sky-500/10 hover:border-slate-300 dark:border-slate-800 dark:focus-within:border-sky-400/50 dark:focus-within:ring-sky-400/10 dark:hover:border-slate-700",
)}
>
<Show
when={getFiles().length}
fallback={
props.placeholder || (
<>Click to select file{props.multiple && "s"}</>
)
}
>
Selected file{props.multiple && "s"}:{" "}
{getFiles()
.map(({ name }) => name)
.join(", ")}
</Show>
<input
{...inputProps}
// Disable drag n drop
onDrop={(e) => e.preventDefault()}
class="absolute size-full cursor-pointer opacity-0"
type="file"
id={props.name}
aria-invalid={!!props.error}
aria-errormessage={`${props.name}-error`}
/>
{props.error && (
<span class=" font-bold text-error-700">{props.error}</span>
)}
</div>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
import { children, createSignal, type JSX } from "solid-js";
import { useFloating } from "@/src/floating";
import {
autoUpdate,
flip,
hide,
offset,
Placement,
shift,
} from "@floating-ui/dom";
import cx from "classnames";
import { Button } from "./Button/Button";
interface MenuProps {
/**
* Used by the html API to associate the popover with the dispatcher button
*/
popoverid: string;
label: JSX.Element;
children?: JSX.Element;
buttonProps?: JSX.ButtonHTMLAttributes<HTMLButtonElement>;
buttonClass?: string;
/**
* @default "bottom"
*/
placement?: Placement;
}
export const Menu = (props: MenuProps) => {
const c = children(() => props.children);
const [reference, setReference] = createSignal<HTMLElement>();
const [floating, setFloating] = createSignal<HTMLElement>();
// `position` is a reactive object.
const position = useFloating(reference, floating, {
placement: "bottom",
// pass options. Ensure the cleanup function is returned.
whileElementsMounted: (reference, floating, update) =>
autoUpdate(reference, floating, update, {
animationFrame: true,
}),
middleware: [
offset(5),
shift(),
flip(),
hide({
strategy: "referenceHidden",
}),
],
});
return (
<div>
<Button
variant="ghost"
size="s"
popovertarget={props.popoverid}
popovertargetaction="toggle"
ref={setReference}
class={cx("", props.buttonClass)}
{...props.buttonProps}
>
{props.label}
</Button>
<div
popover="auto"
id={props.popoverid}
ref={setFloating}
style={{
margin: 0,
position: position.strategy,
top: `${position.y ?? 0}px`,
left: `${position.x ?? 0}px`,
}}
class="bg-transparent"
>
{c()}
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
div.tag-list {
@apply flex flex-wrap gap-2;
span.tag {
@apply w-fit rounded-full px-3 py-2 bg-inv-4 fg-inv-1;
}
}

View File

@@ -0,0 +1,21 @@
import { Component, For } from "solid-js";
import { Typography } from "@/src/components/Typography";
import "./TagList.css";
export interface TagListProps {
values: string[];
}
export const TagList: Component<TagListProps> = (props) => {
return (
<div class="tag-list">
<For each={props.values}>
{(tag) => (
<Typography hierarchy="label" size="s" inverted={true} class="tag">
{tag}
</Typography>
)}
</For>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
.accordion {
@apply flex flex-col gap-y-5;
}
.accordion__title {
@apply flex h-5 cursor-pointer items-center justify-end gap-x-0.5 px-1 font-medium;
}
.accordion__body {
}

View File

@@ -0,0 +1,45 @@
import { createSignal, JSX, Show } from "solid-js";
import Icon from "../icon";
import { Button } from "../Button/Button";
import cx from "classnames";
import "./accordion.css";
interface AccordionProps {
title: string;
children: JSX.Element;
class?: string;
initiallyOpen?: boolean;
}
export default function Accordion(props: AccordionProps) {
const [isOpen, setIsOpen] = createSignal(props.initiallyOpen ?? false);
return (
<div class={cx(`accordion`, props.class)} tabindex="0">
<div onClick={() => setIsOpen(!isOpen())} class="accordion__title">
<Show
when={isOpen()}
fallback={
<Button
endIcon={<Icon size={12} icon={"CaretDown"} />}
variant="ghost"
size="s"
>
{props.title}
</Button>
}
>
<Button
endIcon={<Icon size={12} icon={"CaretUp"} />}
variant="ghost"
size="s"
>
{props.title}
</Button>
</Show>
</div>
<Show when={isOpen()}>
<div class="accordion__body">{props.children}</div>
</Show>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { JSX } from "solid-js";
import cx from "classnames";
import Icon, { IconVariant } from "../icon";
import { Typography } from "../Typography";
interface BadgeProps {
color: keyof typeof colorMap;
children: JSX.Element;
icon?: IconVariant;
class?: string;
}
const colorMap = {
primary: cx("bg-primary-800 text-primary-100"),
secondary: cx("bg-secondary-800 text-secondary-100"),
blue: "bg-blue-100 text-blue-800",
gray: "bg-gray-100 text-gray-800",
green: "bg-green-100 text-green-800",
orange: "bg-orange-100 text-orange-800",
red: "bg-red-100 text-red-800",
yellow: "bg-yellow-100 text-yellow-800",
};
export const Badge = (props: BadgeProps) => {
return (
<div
class={cx(
"flex px-4 py-2 rounded-sm justify-center items-center gap-1",
colorMap[props.color],
props.class,
)}
>
{props.icon && <Icon icon={props.icon} class="size-4" />}
<Typography hierarchy="label" size="default" color="inherit">
{props.children}
</Typography>
</div>
);
};

View File

@@ -0,0 +1,194 @@
import { FileInput, type FileInputProps } from "@/src/components/FileInput"; // Assuming FileInput can take a ref and has onClick
import { Typography } from "@/src/components/Typography";
import Fieldset from "@/src/Form/fieldset";
import Icon from "@/src/components/icon"; // For displaying file icons
import { callApi } from "@/src/api";
import type { FieldValues } from "@modular-forms/solid";
import { Show, For, type Component, type JSX } from "solid-js";
// Types for the file dialog options passed to callApi
interface FileRequestFilter {
patterns: string[];
mime_types?: string[];
}
export interface FileDialogOptions {
title: string;
filters?: FileRequestFilter;
initial_folder?: string;
}
// Props for the CustomFileField component
interface FileSelectorOpts<TFieldName extends string> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Field: any; // The Field component from createForm
name: TFieldName; // Name of the form field (e.g., "sshKeys", "profilePicture")
label: string; // Legend for Fieldset or main label for the input
description?: string | JSX.Element; // Optional description text
multiple?: boolean; // True if multiple files can be selected, false for single file
fileDialogOptions: FileDialogOptions; // Configuration for the custom file dialog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
of: any;
// Optional props for styling
inputClass?: string;
fileListClass?: string;
// You can add more specific props like `validate` if you want to pass them to Field
}
export const FileSelectorField: Component<FileSelectorOpts<string>> = (
props,
) => {
const {
Field,
name,
label,
description,
multiple = false,
fileDialogOptions,
inputClass,
fileListClass,
} = props;
// Ref to the underlying HTMLInputElement (assuming FileInput forwards refs or is simple)
let actualInputElement: HTMLInputElement | undefined;
const openAndSetFiles = async (event: Event) => {
event.preventDefault();
if (!actualInputElement) {
console.error(
"CustomFileField: Input element ref is not set. Cannot proceed.",
);
return;
}
const dataTransfer = new DataTransfer();
const mode = multiple ? "open_multiple_files" : "open_file";
try {
const response = await callApi("open_file", {
file_request: {
title: fileDialogOptions.title,
mode: mode,
filters: fileDialogOptions.filters,
initial_folder: fileDialogOptions.initial_folder,
},
}).promise;
if (
response.status === "success" &&
response.data &&
Array.isArray(response.data)
) {
(response.data as string[]).forEach((filename) => {
// Create File objects. Content is empty as we only have paths.
// Type might be generic or derived if possible.
dataTransfer.items.add(
new File([], filename, { type: "application/octet-stream" }),
);
});
} else if (response.status === "error") {
// Consider using a toast or other user notification for API errors
console.error("Error from open_file API:", response.errors);
}
} catch (error) {
console.error("Failed to call open_file API:", error);
// Consider using a toast here
}
// Set the FileList on the actual input element
Object.defineProperty(actualInputElement, "files", {
value: dataTransfer.files,
writable: true,
});
// Dispatch an 'input' event so modular-forms updates its state
const inputEvent = new Event("input", { bubbles: true, cancelable: true });
actualInputElement.dispatchEvent(inputEvent);
// Optionally, dispatch 'change' if your forms setup relies more on it
// const changeEvent = new Event("change", { bubbles: true, cancelable: true });
// actualInputElement.dispatchEvent(changeEvent);
};
return (
<Fieldset legend={label}>
{description &&
(typeof description === "string" ? (
<Typography hierarchy="body" size="s" weight="medium" class="mb-2">
{description}
</Typography>
) : (
description
))}
<Field name={name} type={multiple ? "File[]" : "File"}>
{(
field: { value: File | File[]; error?: string },
fieldProps: Record<string, unknown>,
) => (
<>
{/*
This FileInput component should be clickable.
Its 'ref' needs to point to the actual <input type="file"> element.
If FileInput is complex, it might need an 'inputRef' prop or similar.
*/}
<FileInput
{...(fieldProps as unknown as FileInputProps)} // Spread modular-forms props
ref={(el: HTMLInputElement) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fieldProps as any).ref(el); // Pass ref to modular-forms
actualInputElement = el; // Capture for local use
}}
class={inputClass}
multiple={multiple}
// The onClick here triggers our custom dialog logic
onClick={openAndSetFiles}
// The 'value' prop for a file input is not for displaying selected files directly.
// We'll display them below. FileInput might show placeholder text.
// value={undefined} // Explicitly not setting value from field.value here
error={field.error} // Display error from modular-forms
/>
{field.error && (
<Typography hierarchy="body" size="xs" class="mt-1">
{field.error}
</Typography>
)}
{/* Display the list of selected files */}
<Show
when={
field.value &&
(multiple
? (field.value as File[]).length > 0
: field.value instanceof File)
}
>
<div class={`mt-2 space-y-1 ${fileListClass || ""}`}>
<For
each={
multiple
? (field.value as File[])
: field.value instanceof File
? [field.value as File]
: []
}
>
{(file) => (
<div class="flex items-center justify-between rounded border p-2 text-sm border-def-1">
<span class="truncate" title={file.name}>
{file.name}
</span>
{/* A remove button per file is complex with FileList & modular-forms.
For now, clearing all files is simpler (e.g., via FileInput's own clear).
Or, the user re-selects files to change the selection. */}
</div>
)}
</For>
</div>
</Show>
</>
)}
</Field>
</Fieldset>
);
};

View File

@@ -0,0 +1,60 @@
import cx from "classnames";
import { JSX } from "solid-js";
import Icon, { IconVariant } from "../icon";
interface GroupProps {
children: JSX.Element;
}
export const Group = (props: GroupProps) => (
<div class="flex flex-col gap-8 rounded-md border px-4 py-5 bg-def-2 border-def-2">
{props.children}
</div>
);
export type SectionVariant = "attention" | "danger";
interface SectionHeaderProps {
variant: SectionVariant;
headline: JSX.Element;
}
const variantColorsMap: Record<SectionVariant, string> = {
attention: cx("bg-[#9BD8F2] fg-def-1"),
danger: cx("bg-semantic-2 fg-semantic-2"),
};
const variantIconColorsMap: Record<SectionVariant, string> = {
attention: cx("fg-def-1"),
danger: cx("fg-semantic-3"),
};
const variantIconMap: Record<SectionVariant, IconVariant> = {
attention: "Attention",
danger: "Warning",
};
// SectionHeader component
export const SectionHeader = (props: SectionHeaderProps) => (
<div
class={cx(
"flex items-center gap-3 rounded-md px-3 py-2",
variantColorsMap[props.variant],
)}
>
{
<Icon
icon={variantIconMap[props.variant]}
class={cx("size-5", variantIconColorsMap[props.variant])}
/>
}
{props.headline}
</div>
);
// Section component
interface SectionProps {
children: JSX.Element;
}
export const Section = (props: SectionProps) => (
<div class="flex flex-col gap-3">{props.children}</div>
);

View File

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

View File

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

View File

@@ -0,0 +1,76 @@
.machine-item {
@apply col-span-1 flex flex-col items-center;
position: relative;
padding: theme(padding.2);
cursor: pointer;
}
.machine-item__thumb-wrapper {
position: relative;
padding: theme(padding.4);
border-radius: theme(borderRadius.md);
overflow: hidden;
}
.machine-item__thumb {
@apply rounded-md bg-secondary-100 border border-secondary-200;
position: relative;
z-index: 20;
overflow: hidden;
transition: transform 0.24s ease-in-out;
}
.machine-item__header {
@apply flex flex-col justify-center items-center;
position: relative;
z-index: 20;
transition: transform 0.18s 0.04s ease-in-out;
}
.machine-item__pseudo {
@apply bg-secondary-50;
position: absolute;
z-index: 10;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 1px solid theme(borderColor.secondary.100);
border-radius: theme(borderRadius.md);
transition:
transform 0.16s ease-in-out,
opacity 0.08s ease-in-out;
}
.machine-item:hover {
& .machine-item__pseudo {
transform: scale(1);
opacity: 1;
}
& .machine-item__thumb {
transform: scale(1.02);
box-shadow:
0 4px 6px rgba(0, 0, 0, 0.1),
0 8px 20px rgba(0, 0, 0, 0.15),
0 12px 40px rgba(0, 0, 0, 0.08);
}
& .machine-item__header {
transform: translateY(4px);
}
}
.machine-item:not(:hover) .machine-item__pseudo {
transform: scale(0.94);
opacity: 0;
}

View File

@@ -0,0 +1,180 @@
import { createSignal, Setter } from "solid-js";
import { callApi, SuccessQuery } from "../../api";
import { A, useNavigate } from "@solidjs/router";
import { RndThumbnail } from "../noiseThumbnail";
import { Filter } from "../../routes/machines";
import { Typography } from "../Typography";
import "./css/index.css";
import { useClanContext } from "@/src/contexts/clan";
type MachineDetails = SuccessQuery<"list_machines">["data"][string];
interface MachineListItemProps {
name: string;
info?: MachineDetails;
nixOnly?: boolean;
setFilter: Setter<Filter>;
}
export const MachineListItem = (props: MachineListItemProps) => {
const { name, info, nixOnly } = props;
// Bootstrapping
const [installing, setInstalling] = createSignal<boolean>(false);
// Later only updates
const [updating, setUpdating] = createSignal<boolean>(false);
const { activeClanURI } = useClanContext();
const navigate = useNavigate();
const handleInstall = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeClanURI();
if (!active_clan) {
console.error("No active clan selected");
return;
}
if (!info?.deploy?.targetHost) {
console.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
return;
}
if (target_host.data === null) {
console.error("No target host found for the machine");
return;
}
if (!target_host.data!.data) {
console.error("No target host found for the machine");
return;
}
setInstalling(true);
await callApi("install_machine", {
opts: {
machine: {
name: name,
flake: {
identifier: active_clan,
},
},
no_reboot: true,
debug: true,
nix_options: [],
password: null,
},
target_host: target_host.data!.data,
}).promise.finally(() => setInstalling(false));
};
const handleUpdate = async () => {
if (!info?.deploy?.targetHost || installing()) {
return;
}
const active_clan = activeClanURI();
if (!active_clan) {
console.error("No active clan selected");
return;
}
if (!info?.deploy.targetHost) {
console.error(
"Machine does not have a target host. Specify where the machine should be deployed.",
);
return;
}
setUpdating(true);
const target_host = await callApi("get_host", {
field: "targetHost",
flake: { identifier: active_clan },
name: name,
}).promise;
if (target_host.status == "error") {
console.error("No target host found for the machine");
return;
}
if (target_host.data === null) {
console.error("No target host found for the machine");
return;
}
if (!target_host.data!.data) {
console.error("No target host found for the machine");
return;
}
const build_host = await callApi("get_host", {
field: "buildHost",
flake: { identifier: active_clan },
name: name,
}).promise;
if (build_host.status == "error") {
console.error("No target host found for the machine");
return;
}
if (build_host.data === null) {
console.error("No target host found for the machine");
return;
}
await callApi("deploy_machine", {
machine: {
name: name,
flake: {
identifier: active_clan,
},
},
target_host: target_host.data!.data,
build_host: build_host.data?.data || null,
}).promise;
setUpdating(false);
};
return (
<div class="machine-item">
<A href={`/machines/${name}`}>
<div class="machine-item__thumb-wrapper">
<div class="machine-item__thumb">
<RndThumbnail name={name} width={100} height={100} />
</div>
<div class="machine-item__pseudo" />
</div>
<header class="machine-item__header">
<Typography
class="text-center"
hierarchy="body"
size="s"
weight="bold"
color="primary"
>
{name}
</Typography>
</header>
</A>
</div>
);
};

View File

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

View File

@@ -0,0 +1,119 @@
import { For } from "solid-js";
function mulberry32(seed: number) {
return function () {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), 1 | t);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function calculateCellColor(
x: number,
y: number,
grid: number[][],
rand: () => number,
) {
const rows = grid.length;
const cols = grid[0].length;
// Get the values of neighboring cells
const neighbors = [
grid[y][(x - 1 + cols) % cols], // Left
grid[y][(x + 1) % cols], // Right
grid[(y - 1 + rows) % rows][x], // Top
grid[(y + 1) % rows][x], // Bottom
];
const makeValue = (threshold: number, wanted: number) =>
rand() > threshold ? Math.floor(rand() * 50) : wanted;
// Calculate the sum of neighbors
const neighborSum = neighbors.reduce((sum, val) => sum + val, 0);
// Introduce a hard cutoff for fewer intermediate values
if (neighborSum < 1) {
// Mostly dark squares
// return Math.floor(rand() * 50); // Darker square
return makeValue(0.9, Math.floor(rand() * 50));
} else if (neighborSum >= 3) {
// Mostly bright squares
// return Math.floor(200 + rand() * 55); // Bright square
return makeValue(0.9, Math.floor(200 + rand() * 55));
} else {
// Rare intermediate values
return makeValue(0.4, Math.floor(100 + rand() * 50));
}
}
function generatePatternedImage(seed: number, width = 300, height = 150) {
const rand = mulberry32(seed);
const rowSize = 1 + Math.floor((rand() * width) / 10);
const colSize = 1 + Math.floor((rand() * height) / 10);
const cols = Math.floor(width / colSize);
const rows = Math.floor(height / rowSize);
// Initialize a 2D grid with random values (0 or 1)
const grid = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => (rand() > 0.5 ? 1 : 0)),
);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
throw new Error("Could not get 2d context");
}
const centerX = width / 2;
const centerY = height / 2;
const totalCells = rows * cols;
for (let i = 0; i < totalCells; i++) {
// Calculate polar coordinates
const angle = (i / totalCells) * Math.PI * 2 * rand() * 360; // Increase the spiral's density
const radius = Math.sqrt(i) * rand() * 4; // Controls how tightly the spiral is packed
// Convert polar to Cartesian coordinates
const x = Math.floor(centerX + radius * Math.cos(angle));
const y = Math.floor(centerY + radius * Math.sin(angle));
// Find grid coordinates
const col = Math.floor(x / colSize);
const row = Math.floor(y / rowSize);
// Ensure the cell is within bounds
if (col >= 0 && col < cols && row >= 0 && row < rows) {
const colorValue = calculateCellColor(col, row, grid, rand);
ctx.fillStyle = `rgb(${colorValue}, ${colorValue}, ${colorValue})`;
ctx.fillRect(x, y, colSize, rowSize);
}
}
return canvas.toDataURL();
}
interface RndThumbnailProps {
name: string;
width?: number;
height?: number;
}
export const RndThumbnail = (props: RndThumbnailProps) => {
const seed = () =>
Array.from(props.name).reduce((acc, char) => acc + char.charCodeAt(0), 0); // Seed from name
const imageSrc = () =>
generatePatternedImage(seed(), props.width, props.height);
return <img src={imageSrc()} alt={props.name} />;
};
export const RndThumbnailShow = () => {
const names = ["hsjobeki", "mic92", "lassulus", "D", "A", "D", "B", "C"];
return (
<div class="grid grid-cols-4">
<For each={names}>{(name) => <RndThumbnail name={name} />}</For>
</div>
);
};

View File

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

View File

@@ -0,0 +1,134 @@
.button {
@apply flex gap-2 shrink-0 items-center justify-center;
@apply px-4 py-2;
height: theme(height.9);
border-radius: 3px;
/* Add transition for smooth width animation */
transition: width 0.5s ease 0.1s;
&.s {
@apply px-3 py-1.5;
height: theme(height.7);
border-radius: 2px;
&:has(> .icon-start):has(> .label) {
@apply pl-2;
}
&:has(> .icon-end):has(> .label) {
@apply pr-2;
}
}
&.primary {
@apply bg-inv-acc-4 fg-inv-1;
@apply border border-solid border-inv-4;
@apply shadow-button-primary;
&.ghost {
@apply bg-transparent border-none shadow-none;
}
&:hover {
@apply bg-inv-acc-3 border-solid shadow-button-primary-hover;
border-color: theme(backgroundColor.secondary.700);
}
&:active {
@apply bg-inv-acc-4 border-solid border-inv-3 shadow-button-primary-active;
}
&:focus-visible {
@apply bg-inv-acc-4 border-solid border-inv-3 shadow-button-primary-focus;
}
&:disabled {
@apply bg-def-acc-3 border-solid border-def-3 fg-def-3 shadow-none;
}
& > .icon {
@apply fg-inv-1;
}
}
&.secondary {
@apply bg-def-acc-2 fg-def-1;
@apply border border-solid border-inv-2;
@apply shadow-button-secondary;
&.ghost {
@apply bg-transparent border-none shadow-none;
}
&:hover {
@apply bg-def-acc-3 border-solid shadow-button-secondary-hover;
border-color: theme(backgroundColor.secondary.700);
}
&:focus-visible {
@apply bg-def-acc-3 border-solid border-inv-3 shadow-button-secondary-focus;
}
&:active {
@apply bg-def-acc-3 border-solid border-inv-4 shadow-button-secondary-active;
}
&:disabled {
@apply bg-def-2 border-solid border-def-2 fg-def-3 shadow-none;
}
& > .icon {
@apply fg-def-1;
&.icon-loading {
color: #0051ff;
}
}
}
&.icon {
@apply p-2;
}
&:has(> .icon-start):has(> .label) {
@apply pl-3.5;
}
&:has(> .icon-end):has(> .label) {
@apply pr-3.5;
}
& > div.loader {
@apply w-0 opacity-0;
@apply top-0 left-0 -mr-2;
transition: all 0.5s ease;
}
&.loading {
@apply cursor-wait;
& > div.loader {
@apply w-4 opacity-100;
margin-right: revert;
transition: all 0.5s ease;
}
}
}
/* button group */
.button-group .button:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.button-group .button:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.button-group .button:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View File

@@ -0,0 +1,43 @@
import { DocsStory, Meta, Canvas } from "@storybook/blocks";
import * as ButtonStories from "./Button.stories";
<Meta of={ButtonStories} />
# Button
Buttons have a simple hierarchy, `primary` or `secondary`, and two sizes, `default` and `s`.
A `Button` can also have a label with a `startIcon`, an `endIcon` or both:
```tsx
<Button hierarchy="primary">Label</>
<Button hierarchy="secondary" size="s">Label</>
<Button hierarchy="primary" startIcon="Flash">Label</>
<Button hierarchy="primary" size="s" endIcon="Flash">Label</>
<Button hierarchy="primary" startIcon="Flash" endIcon="Flash">Label</>
```
To create a `Button` which is just an icon:
```tsx
<Button icon="Flash"/>
<Button icon="User" size="s"/>
```
<DocsStory of={ButtonStories.Primary} />
<DocsStory of={ButtonStories.Secondary} />
## Ghost Mode
Buttons in ghost mode have all the same states as normal buttons, except for their initial state has no background,
outline or box shadow.
```tsx
<Button hierarchy="primary" ghost={true}>Label</>
```
<DocsStory of={ButtonStories.GhostPrimary} />
<DocsStory of={ButtonStories.GhostSecondary} />

View File

@@ -0,0 +1,251 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Button, ButtonProps } from "./Button";
import { Component } from "solid-js";
import { expect, fn, userEvent, waitFor, within } from "@storybook/test";
const getCursorStyle = (el: Element) => window.getComputedStyle(el).cursor;
const ButtonExamples: Component<ButtonProps> = (props) => (
<>
<div class="grid w-fit grid-cols-4 gap-8">
<div>
<Button data-testid="default" {...props}>
Label
</Button>
</div>
<div>
<Button data-testid="small" size="s" {...props}>
Label
</Button>
</div>
<div>
<Button data-testid="default-disabled" {...props} disabled={true}>
Disabled
</Button>
</div>
<div>
<Button
data-testid="small-disabled"
{...props}
disabled={true}
size="s"
>
Disabled
</Button>
</div>
<div>
<Button data-testid="default-start-icon" {...props} startIcon="Flash">
Label
</Button>
</div>
<div>
<Button
data-testid="small-start-icon"
{...props}
startIcon="Flash"
size="s"
>
Label
</Button>
</div>
<div>
<Button
data-testid="default-disabled-start-icon"
{...props}
startIcon="Flash"
disabled={true}
>
Disabled
</Button>
</div>
<div>
<Button
data-testid="small-disabled-start-icon"
{...props}
startIcon="Flash"
size="s"
disabled={true}
>
Disabled
</Button>
</div>
<div>
<Button data-testid="default-end-icon" {...props} endIcon="Flash">
Label
</Button>
</div>
<div>
<Button
data-testid="small-end-icon"
{...props}
endIcon="Flash"
size="s"
>
Label
</Button>
</div>
<div>
<Button
data-testid="default-disabled-end-icon"
{...props}
endIcon="Flash"
disabled={true}
>
Disabled
</Button>
</div>
<div>
<Button
data-testid="small-disabled-end-icon"
{...props}
endIcon="Flash"
size="s"
disabled={true}
>
Disabled
</Button>
</div>
<div>
<Button data-testid="default-icon" {...props} icon="Flash" />
</div>
<div>
<Button data-testid="small-icon" {...props} icon="Flash" size="s" />
</div>
<div>
<Button
data-testid="default-disabled-icon"
{...props}
icon="Flash"
disabled={true}
/>
</div>
<div>
<Button
data-testid="small-disabled-icon"
{...props}
icon="Flash"
disabled={true}
size="s"
/>
</div>
</div>
</>
);
const meta: Meta<ButtonProps> = {
title: "Components/Button",
component: ButtonExamples,
};
export default meta;
type Story = StoryObj<ButtonProps>;
export const Primary: Story = {
args: {
hierarchy: "primary",
onAction: fn(async () => {
// wait 500 ms to simulate an action
await new Promise((resolve) => setTimeout(resolve, 500));
// randomly fail to check that the loading state still returns to normal
if (Math.random() > 0.5) {
throw new Error("Action failure");
}
}),
},
parameters: {
test: {
mockTimers: true,
},
},
play: async ({ args, canvasElement, step }) => {
const canvas = within(canvasElement);
const buttons = await canvas.findAllByRole("button");
for (const button of buttons) {
const testID = button.getAttribute("data-testid");
// skip disabled buttons
if (button.hasAttribute("disabled")) {
continue;
}
await step(`Click on ${testID}`, async () => {
// check for the loader
const loaders = button.getElementsByClassName("loader");
await expect(loaders.length).toEqual(1);
// assert its width is 0 before we click
const [loader] = loaders;
await expect(loader.clientWidth).toEqual(0);
// move the mouse over the button
await userEvent.hover(button);
// the pointer should be normal
await expect(getCursorStyle(button)).toEqual("pointer");
// click the button
await userEvent.click(button);
// check the button has changed
await waitFor(async () => {
// the action handler should have been called
await expect(args.onAction).toHaveBeenCalled();
// the button should have a loading class
await expect(button).toHaveClass("loading");
// the loader should be visible
await expect(loader.clientWidth).toBeGreaterThan(0);
// the pointer should have changed to wait
await expect(getCursorStyle(button)).toEqual("wait");
});
// wait for the action handler to finish
await waitFor(async () => {
// the loading class should be removed
await expect(button).not.toHaveClass("loading");
// the loader should be hidden
await expect(loader.clientWidth).toEqual(0);
// the pointer should be normal
await expect(getCursorStyle(button)).toEqual("pointer");
});
});
}
},
};
export const Secondary: Story = {
args: {
...Primary.args,
hierarchy: "secondary",
},
play: Primary.play,
};
export const GhostPrimary: Story = {
args: {
...Primary.args,
hierarchy: "primary",
ghost: true,
},
play: Primary.play,
decorators: [
(Story) => (
<div class="p-10 bg-def-3">
<Story />
</div>
),
],
};
export const GhostSecondary: Story = {
args: {
...Primary.args,
hierarchy: "secondary",
ghost: true,
},
play: Primary.play,
};

View File

@@ -0,0 +1,116 @@
import { splitProps, type JSX, createSignal, Show } from "solid-js";
import cx from "classnames";
import { Typography } from "../Typography/Typography";
import { Button as KobalteButton } from "@kobalte/core/button";
import "./Button.css";
import Icon, { IconVariant } from "@/src/components/v2/Icon/Icon";
import { Loader } from "@/src/components/v2/Loader/Loader";
export type Size = "default" | "s";
export type Hierarchy = "primary" | "secondary";
export type Action = () => Promise<void>;
export interface ButtonProps
extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
hierarchy?: Hierarchy;
size?: Size;
ghost?: boolean;
children?: JSX.Element;
icon?: IconVariant;
startIcon?: IconVariant;
endIcon?: IconVariant;
class?: string;
onAction?: Action;
}
const iconSizes: Record<Size, string> = {
default: "1rem",
s: "0.8125rem",
};
export const Button = (props: ButtonProps) => {
const [local, other] = splitProps(props, [
"children",
"hierarchy",
"size",
"ghost",
"icon",
"startIcon",
"endIcon",
"class",
"onAction",
]);
const size = local.size || "default";
const hierarchy = local.hierarchy || "primary";
const [loading, setLoading] = createSignal(false);
const onClick = async () => {
if (!local.onAction) {
console.error("this should not be possible");
return;
}
setLoading(true);
try {
await local.onAction();
} catch (error) {
console.error("Error while executing action", error);
}
setLoading(false);
};
const iconSize = iconSizes[local.size || "default"];
return (
<KobalteButton
class={cx(
local.class,
"button", // default button class
size,
hierarchy,
{
icon: local.icon,
loading: loading(),
ghost: local.ghost,
},
)}
onClick={local.onAction ? onClick : undefined}
{...other}
>
<Loader hierarchy={hierarchy} />
{local.startIcon && (
<Icon icon={local.startIcon} class="icon-start" size={iconSize} />
)}
{local.icon && !local.children && (
<Icon icon={local.icon} class="icon" size={iconSize} />
)}
{local.children && !local.icon && (
<Typography
class="label"
hierarchy="label"
family="mono"
size={local.size || "default"}
color="inherit"
inverted={local.hierarchy === "secondary"}
weight="bold"
tag="span"
>
{local.children}
</Typography>
)}
{local.endIcon && (
<Icon icon={local.endIcon} class="icon-end" size={iconSize} />
)}
</KobalteButton>
);
};

View File

@@ -0,0 +1,75 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Component, For } from "solid-js";
import Icon, { IconProps, IconVariant } from "./Icon";
const iconVariants: IconVariant[] = [
"ClanIcon",
"Checkmark",
"Paperclip",
"Expand",
"Plus",
"Trash",
"Folder",
"CaretRight",
"CaretLeft",
"CaretUp",
"CaretDown",
"Close",
"Flash",
"EyeClose",
"EyeOpen",
"Settings",
"Grid",
"List",
"Edit",
"Load",
"ArrowRight",
"ArrowLeft",
"ArrowTop",
"ArrowBottom",
"Info",
"Update",
"Reload",
"Search",
"Report",
"Cursor",
"More",
"Filter",
"Download",
"Attention",
"User",
"WarningFilled",
"Modules",
"NewMachine",
"AI",
"Heart",
"SearchFilled",
"Offline",
"Switch",
"Tag",
"Machine",
];
const IconExamples: Component<IconProps> = (props) => (
<div class="inline-flex flex-wrap gap-2">
<For each={iconVariants}>{(item) => <Icon {...props} icon={item} />}</For>
</div>
);
const meta: Meta<IconProps> = {
title: "Components/Icon",
component: IconExamples,
};
export default meta;
type Story = StoryObj<IconProps>;
export const Default: Story = {};
export const Large: Story = {
args: {
width: "2rem",
height: "2rem",
},
};

View File

@@ -0,0 +1,126 @@
import cx from "classnames";
import { Component, JSX, Show, 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 Cursor from "@/icons/cursor.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 WarningFilled from "@/icons/warning-filled.svg";
import Modules from "@/icons/modules.svg";
import NewMachine from "@/icons/new-machine.svg";
import AI from "@/icons/ai.svg";
import User from "@/icons/user.svg";
import Heart from "@/icons/heart.svg";
import SearchFilled from "@/icons/search-filled.svg";
import Offline from "@/icons/offline.svg";
import Switch from "@/icons/switch.svg";
import Tag from "@/icons/tag.svg";
import Machine from "@/icons/machine.svg";
import Loader from "@/icons/loader.svg";
import { Dynamic } from "solid-js/web";
const icons = {
AI,
ArrowBottom,
ArrowLeft,
ArrowRight,
ArrowTop,
Attention,
CaretDown,
CaretLeft,
CaretRight,
CaretUp,
Checkmark,
ClanIcon,
Close,
Cursor,
Download,
Edit,
Expand,
EyeClose,
EyeOpen,
Filter,
Flash,
Folder,
Grid,
Heart,
Info,
List,
Load,
Machine,
Modules,
More,
NewMachine,
Offline,
Paperclip,
Plus,
Reload,
Report,
Search,
SearchFilled,
Settings,
Switch,
Tag,
Trash,
Update,
User,
WarningFilled,
};
export type IconVariant = keyof typeof icons;
export interface IconProps extends JSX.SvgSVGAttributes<SVGElement> {
icon: IconVariant;
class?: string;
size?: number | string;
}
const Icon: Component<IconProps> = (props) => {
const [local, iconProps] = splitProps(props, ["icon", "class"]);
const IconComponent = () => icons[local.icon];
return IconComponent() ? (
<Dynamic
component={IconComponent()}
class={cx("icon", local.class)}
width={iconProps.size || "1em"}
height={iconProps.size || "1em"}
viewBox="0 0 48 48"
// @ts-expect-error: dont know, fix this type nit later
ref={iconProps.ref}
{...iconProps}
/>
) : undefined;
};
export default Icon;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
.loader {
@apply relative;
@apply w-4 h-4;
&.primary {
& > div.wrapper > div.parent,
& > div.child {
background: #00ff57;
}
}
&.secondary {
& > div.wrapper > div.parent,
& > div.child {
background: #0051ff;
}
}
& > div.wrapper {
@apply absolute top-0 left-0 w-full h-full;
transform: translate(0%, 0%) rotate(-45deg);
animation: moveLoaderWrapper 1.8s ease-in-out infinite;
& > div.parent {
@apply absolute top-1/2 left-1/2;
@apply w-2/3 h-2/3;
border-radius: 50%;
animation: moveLoaderParent 1.8s ease-in-out infinite;
transform: translateX(-50%) translateY(-50%);
}
}
& > .child {
@apply absolute z-10 top-1/2 left-1/2 w-1/2 h-1/2;
border-radius: 50%;
transform: translate(-50%, -50%);
animation: moveLoaderChild 1.8s ease-in-out infinite;
}
}
@keyframes moveLoaderWrapper {
0% {
transform: translate(0%, 0%) rotate(-45deg);
}
35% {
transform: translate(-25%, 0%) rotate(-45deg);
}
50% {
transform: translate(0%, 0%) rotate(-45deg);
}
85% {
transform: translate(25%, 0%) rotate(-45deg);
}
}
@keyframes moveLoaderParent {
0% {
animation-timing-function: ease-in-out;
transform: translateX(-50%) translateY(-50%);
}
35% {
animation-timing-function: cubic-bezier(0.7, -0.9, 0.3, 3.2);
transform: translateX(-50%) translateY(-50%) skew(20deg, 20deg);
}
50% {
animation-timing-function: ease-in-out;
transform: translateX(-50%) translateY(-50%) skew(0deg, 0deg);
}
85% {
animation-timing-function: cubic-bezier(0.7, -0.9, 0.3, 3.2);
transform: translateX(-50%) translateY(-50%) skew(20deg, 20deg);
}
}
@keyframes moveLoaderChild {
0% {
animation-timing-function: ease-in-out;
transform: translateX(-50%) translateY(-50%);
}
35% {
animation-timing-function: cubic-bezier(0.7, -0.9, 0.3, 3.2);
transform: translateX(50%) translateY(-50%) scale(0.56);
}
50% {
animation-timing-function: ease-in-out;
transform: translateX(-50%) translateY(-50%);
}
85% {
animation-timing-function: cubic-bezier(0.7, -0.9, 0.3, 3.2);
transform: translateX(-150%) translateY(-50%) scale(0.56);
}
}

View File

@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import { Loader, LoaderProps } from "@/src/components/v2/Loader/Loader";
const meta: Meta<LoaderProps> = {
title: "Components/Loader",
component: Loader,
};
export default meta;
type Story = StoryObj<LoaderProps>;
export const Primary: Story = {
args: {
hierarchy: "primary",
},
};
export const Secondary: Story = {
args: {
hierarchy: "secondary",
},
};

View File

@@ -0,0 +1,19 @@
import "./Loader.css";
import cx from "classnames";
export type Hierarchy = "primary" | "secondary";
export interface LoaderProps {
hierarchy?: Hierarchy;
}
export const Loader = (props: LoaderProps) => {
return (
<div class={cx("loader", props.hierarchy || "primary")}>
<div class="wrapper">
<div class="parent"></div>
</div>
<div class="child"></div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Components/Loader Primary smoke-test 1`] = `
<div class="loader primary">
<div class="wrapper">
<div class="parent">
</div>
</div>
<div class="child">
</div>
</div>
`;
exports[`Components/Loader Secondary smoke-test 1`] = `
<div class="loader secondary">
<div class="wrapper">
<div class="parent">
</div>
</div>
<div class="child">
</div>
</div>
`;

View File

@@ -0,0 +1,7 @@
## Overview
We will be updating existing components and developing new components in line with the latest designs inside this
folder. As they become ready, they can be copied into the root `components` folder, replacing any existing components as
necessary.
This is to avoid merge hell and allow us to rapidly match the latest designs without the burden of integration.

View File

@@ -0,0 +1,154 @@
/* Body */
.typography {
&.font-weight-normal {
font-weight: 400;
}
&.font-weight-medium {
font-weight: 500;
}
&.font-weight-bold {
font-weight: 600;
}
&.font-body {
&.font-family-regular {
font-family: "Archivo", sans-serif;
}
&.font-family-condensed {
font-family: "Archivo SemiCondensed", sans-serif;
}
&.font-size-default {
font-size: 1rem;
line-height: 1.32;
letter-spacing: 0.02rem;
}
&.font-size-s {
font-size: 0.875rem;
line-height: 1.32;
letter-spacing: 0.0175rem;
}
&.font-size-xs {
font-size: 0.75rem;
line-height: 1.32;
letter-spacing: 0.0225rem;
}
&.font-size-xxs {
font-size: 0.6875rem;
line-height: 1.32;
letter-spacing: 0.00688rem;
}
}
&.font-label {
&.font-family-condensed {
font-family: "Archivo SemiCondensed", sans-serif;
&.font-size-default {
font-size: 0.875rem;
line-height: 1.32;
letter-spacing: 0.0175rem;
}
&.font-size-s {
font-size: 0.8125rem;
line-height: 1.32;
letter-spacing: 0.0175rem;
}
&.font-size-xs {
font-size: 0.75rem;
line-height: 1.32;
letter-spacing: 0.0075rem;
}
}
&.font-family-mono {
font-family: "Commit Mono", monospace;
&.font-size-default {
font-size: 0.8125rem;
line-height: 0;
letter-spacing: normal;
}
&.font-size-s {
font-size: 0.75rem;
line-height: 0;
letter-spacing: normal;
}
&.font-size-xs {
font-size: 0.6875rem;
line-height: 0;
letter-spacing: normal;
}
}
}
&.font-title {
&.font-family-regular {
font-family: "Archivo", sans-serif;
}
&.font-size-default {
font-size: 1.125rem;
line-height: 124%;
letter-spacing: 0.03375rem;
}
&.font-size-m {
font-size: 1.25rem;
line-height: 124%;
letter-spacing: 0.0375rem;
}
&.font-size-l {
font-size: 1.375rem;
line-height: 124%;
letter-spacing: 0.04125rem;
}
}
&.font-headline {
&.font-family-regular {
font-family: "Archivo", sans-serif;
}
&.font-size-default {
font-size: 1.5rem;
line-height: 116%;
letter-spacing: 0.015rem;
}
&.font-size-m {
font-size: 1.75rem;
line-height: 116%;
letter-spacing: 0.0175rem;
}
&.font-size-l {
font-size: 2rem;
line-height: 116%;
letter-spacing: 0.06rem;
}
}
&.font-teaser {
&.font-family-regular {
font-family: "Archivo", sans-serif;
}
&.font-size-default {
font-size: 3rem;
line-height: normal;
letter-spacing: -0.06rem;
}
}
}

View File

@@ -0,0 +1,52 @@
import { DocsStory, Meta, Canvas } from "@storybook/blocks";
import * as TypographyStories from "./Typography.stories";
<Meta of={TypographyStories} />
# Typography
There are two fonts being used within our typography system:
- [Archivo] in its _Standard_ and _Semi Condensed_ forms
- [Fira Code] for mono-spaced content
## UI Components
When creating UI components that a user will interact with,
you must use the condensed form of `Body`, `Label` and `Label Mono`.
<DocsStory of={TypographyStories.BodyCondensed} />
<DocsStory of={TypographyStories.LabelCondensed} />
<DocsStory of={TypographyStories.LabelMono} />
## Layout & Prose
For layout components and general prose, you must use the non-condensed forms of `Body`, `Title`, `Headline` and
`Teaser`.
<DocsStory of={TypographyStories.Body} />
<DocsStory of={TypographyStories.Title} />
<DocsStory of={TypographyStories.Headline} />
<DocsStory of={TypographyStories.Teaser} />
## Colors
Text colors come in one of five varieties:
- `inherit` (which will inherit the text color from the parent)
- `primary`
- `secondary`
- `tertiary`
- `quaternary`
<DocsStory of={TypographyStories.Colors} />
Each color also has an inverted counterpart.
<DocsStory of={TypographyStories.ColorsInverted} />
[Archivo]: https://github.com/Omnibus-Type/Archivo
[Fira Code]: https://github.com/tonsky/FiraCode

View File

@@ -0,0 +1,166 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import {
AllowedSizes,
Color,
Family,
Hierarchy,
Typography,
Weight,
} from "./Typography";
import { Component, For, Show } from "solid-js";
interface TypographyExamplesProps {
weights: Weight[];
sizes: ("default" | "s" | "xs" | "xxs" | "m" | "l")[];
hierarchy: Hierarchy;
colors?: boolean;
family?: Family;
inverted?: boolean;
}
const colors: (Color | "inherit")[] = [
"inherit",
"primary",
"secondary",
"tertiary",
"quaternary",
];
const TypographyExamples: Component<TypographyExamplesProps> = (props) => (
<table
class="w-full min-w-max table-auto text-left"
classList={{
"text-white bg-inv-1": props.inverted,
}}
>
<tbody>
<For each={props.sizes}>
{(size) => (
<tr
class="border-b border-def-3 even:bg-def-2"
classList={{
"border-inv-3 even:bg-inv-2": props.inverted,
"border-def-3 even:bg-def-2": !props.inverted,
}}
>
<For each={props.weights}>
{(weight) => (
<td class="px-6 py-2 ">
<Show when={!props.colors}>
<Typography
hierarchy={props.hierarchy}
//@ts-expect-error: difficult to generify for the story
size={size}
weight={weight}
family={props.family}
>
{props.hierarchy} / {size} / {weight}
</Typography>
</Show>
<Show when={props.colors}>
<For each={colors}>
{(color) => (
<>
<Typography
hierarchy={props.hierarchy}
//@ts-expect-error: difficult to generify for the story
size={size}
weight={weight}
color={color}
family={props.family}
inverted={props.inverted}
>
{props.hierarchy} / {size} / {weight} / {color}
</Typography>
<br />
</>
)}
</For>
</Show>
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
);
const meta: Meta<TypographyExamplesProps> = {
title: "Components/Typography",
component: TypographyExamples,
};
export default meta;
type Story = StoryObj<TypographyExamplesProps>;
export const BodyCondensed: Story = {
args: {
hierarchy: "body",
sizes: ["default", "s", "xs", "xxs"],
weights: ["normal", "medium", "bold"],
},
};
export const Body: Story = {
args: {
...BodyCondensed.args,
family: "regular",
},
};
export const LabelCondensed: Story = {
args: {
hierarchy: "label",
sizes: ["default", "s", "xs"],
weights: ["normal", "medium", "bold"],
},
};
export const LabelMono: Story = {
args: {
...LabelCondensed.args,
family: "mono",
},
};
export const Title: Story = {
args: {
hierarchy: "title",
sizes: ["default", "m", "l"],
weights: ["normal", "medium", "bold"],
},
};
export const Headline: Story = {
args: {
hierarchy: "headline",
sizes: ["default", "m", "l"],
weights: ["normal", "medium", "bold"],
},
};
export const Teaser: Story = {
args: {
hierarchy: "teaser",
sizes: ["default"],
weights: ["bold"],
},
};
export const Colors: Story = {
args: {
...BodyCondensed.args,
colors: true,
},
};
export const ColorsInverted: Story = {
args: {
...Colors.args,
inverted: true,
},
};

View File

@@ -0,0 +1,152 @@
import { type JSX, mergeProps } from "solid-js";
import { Dynamic } from "solid-js/web";
import cx from "classnames";
import "./Typography.css";
export type Tag = "span" | "p" | "h1" | "h2" | "h3" | "h4" | "div";
export type Color = "primary" | "secondary" | "tertiary" | "quaternary";
export type Hierarchy = "body" | "title" | "headline" | "label" | "teaser";
export type Weight = "normal" | "medium" | "bold";
export type Family = "regular" | "condensed" | "mono";
const colorMap: Record<Color, string> = {
primary: cx("fg-def-1"),
secondary: cx("fg-def-2"),
tertiary: cx("fg-def-3"),
quaternary: cx("fg-def-4"),
};
const invertedColorMap: Record<Color, string> = {
primary: cx("fg-inv-1"),
secondary: cx("fg-inv-2"),
tertiary: cx("fg-inv-3"),
quaternary: cx("fg-inv-4"),
};
const colorFor = (color: Color | "inherit" = "primary", inverted = false) => {
if (color === "inherit") {
return "text-inherit";
}
return inverted ? invertedColorMap[color] : colorMap[color];
};
// type Size = "default" | "xs" | "s" | "m" | "l";
interface SizeForHierarchy {
body: {
default: string;
s: string;
xs: string;
xxs: string;
};
label: {
default: string;
s: string;
xs: string;
};
headline: {
default: string;
m: string;
l: string;
};
title: {
default: string;
m: string;
l: string;
};
teaser: {
default: string;
};
}
export type AllowedSizes<H extends Hierarchy> = keyof SizeForHierarchy[H];
const sizeHierarchyMap: SizeForHierarchy = {
body: {
default: cx("font-size-default"),
s: cx("font-size-s"),
xs: cx("font-size-xs"),
xxs: cx("font-size-xxs"),
},
headline: {
default: cx("font-size-default"),
m: cx("font-size-m"),
l: cx("font-size-l"),
},
title: {
default: cx("font-size-default"),
// xs: cx("font-size-xs"),
// s: cx("font-size-s"),
m: cx("font-size-m"),
l: cx("font-size-l"),
},
label: {
default: cx("font-size-default"),
s: cx("font-size-s"),
xs: cx("font-size-xs"),
},
teaser: {
default: cx("font-size-default"),
},
};
const defaultFamilyMap: Record<Hierarchy, Family> = {
body: "condensed",
label: "condensed",
title: "regular",
headline: "regular",
teaser: "regular",
};
const weightMap: Record<Weight, string> = {
normal: cx("font-weight-normal"),
medium: cx("font-weight-medium"),
bold: cx("font-weight-bold"),
};
interface _TypographyProps<H extends Hierarchy> {
hierarchy: H;
size: AllowedSizes<H>;
color?: Color | "inherit";
children: JSX.Element;
weight?: Weight;
family?: Family;
inverted?: boolean;
tag?: Tag;
class?: string;
classList?: Record<string, boolean>;
}
export const Typography = <H extends Hierarchy>(props: _TypographyProps<H>) => {
const family = () =>
`font-family-${props.family || defaultFamilyMap[props.hierarchy]}`;
const color = () => colorFor(props.color, props.inverted);
const classList = mergeProps(props.classList, {
"font-body": props.hierarchy === "body" || !props.hierarchy,
"font-label": props.hierarchy === "label",
"font-title": props.hierarchy === "title",
"font-headline": props.hierarchy === "headline",
"font-teaser": props.hierarchy === "teaser",
});
return (
<Dynamic
class={cx(
"typography",
color(),
family(),
weightMap[props.weight || "normal"],
sizeHierarchyMap[props.hierarchy][props.size] as string,
props.class,
)}
component={props.tag || "span"}
classList={classList}
>
{props.children}
</Dynamic>
);
};
export type TypographyProps = _TypographyProps<Hierarchy>;

View File

@@ -0,0 +1,955 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Components/Typography Body smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-default font-body">
body / default / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-default font-body">
body / default / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-default font-body">
body / default / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-s font-body">
body / s / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-s font-body">
body / s / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-s font-body">
body / s / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-xs font-body">
body / xs / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-xs font-body">
body / xs / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-xs font-body">
body / xs / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-xxs font-body">
body / xxs / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-xxs font-body">
body / xxs / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-xxs font-body">
body / xxs / bold
</span>
</td>
</tr>
</tbody>
</table>
`;
exports[`Components/Typography BodyCondensed smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold
</span>
</td>
</tr>
</tbody>
</table>
`;
exports[`Components/Typography Colors smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / quaternary
</span>
<br>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / quaternary
</span>
<br>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / quaternary
</span>
<br>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / inherit
</span>
<br>
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / primary
</span>
<br>
<span class="typography fg-def-2 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / secondary
</span>
<br>
<span class="typography fg-def-3 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / tertiary
</span>
<br>
<span class="typography fg-def-4 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / quaternary
</span>
<br>
</td>
</tr>
</tbody>
</table>
`;
exports[`Components/Typography ColorsInverted smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left text-white bg-inv-1">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2 border-inv-3 even:bg-inv-2">
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-normal font-size-default font-body">
body / default / normal / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-medium font-size-default font-body">
body / default / medium / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-bold font-size-default font-body">
body / default / bold / quaternary
</span>
<br>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2 border-inv-3 even:bg-inv-2">
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-normal font-size-s font-body">
body / s / normal / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-medium font-size-s font-body">
body / s / medium / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-bold font-size-s font-body">
body / s / bold / quaternary
</span>
<br>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2 border-inv-3 even:bg-inv-2">
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-normal font-size-xs font-body">
body / xs / normal / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-medium font-size-xs font-body">
body / xs / medium / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-bold font-size-xs font-body">
body / xs / bold / quaternary
</span>
<br>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2 border-inv-3 even:bg-inv-2">
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-normal font-size-xxs font-body">
body / xxs / normal / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-medium font-size-xxs font-body">
body / xxs / medium / quaternary
</span>
<br>
</td>
<td class="px-6 py-2 ">
<span class="typography text-inherit font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / inherit
</span>
<br>
<span class="typography fg-inv-1 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / primary
</span>
<br>
<span class="typography fg-inv-2 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / secondary
</span>
<br>
<span class="typography fg-inv-3 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / tertiary
</span>
<br>
<span class="typography fg-inv-4 font-family-condensed font-weight-bold font-size-xxs font-body">
body / xxs / bold / quaternary
</span>
<br>
</td>
</tr>
</tbody>
</table>
`;
exports[`Components/Typography Headline smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-default font-headline">
headline / default / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-default font-headline">
headline / default / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-default font-headline">
headline / default / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-m font-headline">
headline / m / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-m font-headline">
headline / m / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-m font-headline">
headline / m / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-l font-headline">
headline / l / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-l font-headline">
headline / l / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-l font-headline">
headline / l / bold
</span>
</td>
</tr>
</tbody>
</table>
`;
exports[`Components/Typography LabelCondensed smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-default font-label">
label / default / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-default font-label">
label / default / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-default font-label">
label / default / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-s font-label">
label / s / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-s font-label">
label / s / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-s font-label">
label / s / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-normal font-size-xs font-label">
label / xs / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-medium font-size-xs font-label">
label / xs / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-condensed font-weight-bold font-size-xs font-label">
label / xs / bold
</span>
</td>
</tr>
</tbody>
</table>
`;
exports[`Components/Typography LabelMono smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-normal font-size-default font-label">
label / default / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-medium font-size-default font-label">
label / default / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-bold font-size-default font-label">
label / default / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-normal font-size-s font-label">
label / s / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-medium font-size-s font-label">
label / s / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-bold font-size-s font-label">
label / s / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-normal font-size-xs font-label">
label / xs / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-medium font-size-xs font-label">
label / xs / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-mono font-weight-bold font-size-xs font-label">
label / xs / bold
</span>
</td>
</tr>
</tbody>
</table>
`;
exports[`Components/Typography Teaser smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-default font-teaser">
teaser / default / bold
</span>
</td>
</tr>
</tbody>
</table>
`;
exports[`Components/Typography Title smoke-test 1`] = `
<table class="w-full min-w-max table-auto text-left">
<tbody>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-default font-title">
title / default / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-default font-title">
title / default / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-default font-title">
title / default / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-m font-title">
title / m / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-m font-title">
title / m / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-m font-title">
title / m / bold
</span>
</td>
</tr>
<tr class="border-b border-def-3 even:bg-def-2">
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-normal font-size-l font-title">
title / l / normal
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-medium font-size-l font-title">
title / l / medium
</span>
</td>
<td class="px-6 py-2 ">
<span class="typography fg-def-1 font-family-regular font-weight-bold font-size-l font-title">
title / l / bold
</span>
</td>
</tr>
</tbody>
</table>
`;

View File

@@ -0,0 +1,105 @@
@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/Archivo-Regular.woff2) format("woff2");
}
@font-face {
font-family: "Archivo";
font-weight: 500;
src: url(../../../.fonts/Archivo-Medium.woff2) format("woff2");
}
@font-face {
font-family: "Archivo";
font-weight: 600;
src: url(../../../.fonts/Archivo-SemiBold.woff2) format("woff2");
}
@font-face {
font-family: "Archivo SemiCondensed";
font-weight: 400;
src: url(../../../.fonts/ArchivoSemiCondensed-Regular.woff2) format("woff2");
}
@font-face {
font-family: "Archivo SemiCondensed";
font-weight: 500;
src: url(../../../.fonts/ArchivoSemiCondensed-Medium.woff2) format("woff2");
}
@font-face {
font-family: "Archivo SemiCondensed";
font-weight: 600;
src: url(../../../.fonts/ArchivoSemiCondensed-SemiBold.woff2) format("woff2");
}
@font-face {
font-family: "Commit Mono";
font-weight: 400;
src: url(../../../.fonts/CommitMono-400-Regular.otf) format("otf");
}
:root {
--clr-bg-def-1: theme(colors.white);
--clr-bg-def-2: theme(colors.primary.50);
--clr-bg-def-3: theme(colors.secondary.100);
--clr-bg-def-4: theme(colors.secondary.200);
--clr-border-def-1: theme(colors.secondary.50);
--clr-border-def-2: theme(colors.secondary.100);
--clr-border-def-3: theme(colors.secondary.200);
--clr-border-def-4: theme(colors.secondary.300);
--clr-border-def-sem-inf-1: theme(colors.info.500);
--clr-border-def-sem-inf-2: theme(colors.info.600);
--clr-border-def-sem-inf-3: theme(colors.info.700);
--clr-border-def-sem-inf-4: theme(colors.info.800);
--clr-bg-inv-1: theme(colors.primary.600);
--clr-bg-inv-2: theme(colors.primary.700);
--clr-bg-inv-3: theme(colors.primary.800);
--clr-bg-inv-4: theme(colors.primary.900);
--clr-border-inv-1: theme(colors.secondary.700);
--clr-border-inv-2: theme(colors.secondary.800);
--clr-border-inv-3: theme(colors.secondary.900);
--clr-border-inv-4: theme(colors.secondary.950);
--clr-bg-inv-acc-1: theme(colors.secondary.500);
--clr-bg-inv-acc-2: theme(colors.secondary.600);
--clr-bg-inv-acc-3: theme(colors.secondary.700);
--clr-fg-def-1: theme(colors.secondary.950);
--clr-fg-def-2: theme(colors.secondary.900);
--clr-fg-def-3: theme(colors.secondary.700);
--clr-fg-def-4: theme(colors.secondary.400);
--clr-fg-inv-1: theme(colors.white);
--clr-fg-inv-2: theme(colors.secondary.100);
--clr-fg-inv-3: theme(colors.secondary.300);
--clr-fg-inv-4: theme(colors.secondary.400);
}
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 */
}

View File

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

View File

@@ -0,0 +1,127 @@
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import type {
ComputePositionConfig,
ComputePositionReturn,
ReferenceElement,
} from "@floating-ui/dom";
import { computePosition } from "@floating-ui/dom";
export interface UseFloatingOptions<
R extends ReferenceElement,
F extends HTMLElement,
> extends Partial<ComputePositionConfig> {
whileElementsMounted?: (
reference: R,
floating: F,
update: () => void,
) => // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
void | (() => void);
}
interface UseFloatingState extends Omit<ComputePositionReturn, "x" | "y"> {
x?: number | null;
y?: number | null;
}
export interface UseFloatingResult extends UseFloatingState {
update(): void;
}
export function useFloating<R extends ReferenceElement, F extends HTMLElement>(
reference: () => R | undefined | null,
floating: () => F | undefined | null,
options?: UseFloatingOptions<R, F>,
): UseFloatingResult {
const placement = () => options?.placement ?? "bottom";
const strategy = () => options?.strategy ?? "absolute";
const [data, setData] = createSignal<UseFloatingState>({
x: null,
y: null,
placement: placement(),
strategy: strategy(),
middlewareData: {},
});
const [error, setError] = createSignal<{ value: unknown } | undefined>();
createEffect(() => {
const currentError = error();
if (currentError) {
throw currentError.value;
}
});
const version = createMemo(() => {
reference();
floating();
return {};
});
function update() {
const currentReference = reference();
const currentFloating = floating();
if (currentReference && currentFloating) {
const capturedVersion = version();
computePosition(currentReference, currentFloating, {
middleware: options?.middleware,
placement: placement(),
strategy: strategy(),
}).then(
(currentData) => {
// Check if it's still valid
if (capturedVersion === version()) {
setData(currentData);
}
},
(err) => {
setError(err);
},
);
}
}
createEffect(() => {
const currentReference = reference();
const currentFloating = floating();
placement();
strategy();
if (currentReference && currentFloating) {
if (options?.whileElementsMounted) {
const cleanup = options.whileElementsMounted(
currentReference,
currentFloating,
update,
);
if (cleanup) {
onCleanup(cleanup);
}
} else {
update();
}
}
});
return {
get x() {
return data().x;
},
get y() {
return data().y;
},
get placement() {
return data().placement;
},
get strategy() {
return data().strategy;
},
get middlewareData() {
return data().middlewareData;
},
update,
};
}

View File

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

View File

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

View File

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

View File

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

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