clan-ui: remove intermediate app directory

This commit is contained in:
Jörg Thalheim
2025-05-15 14:29:46 +02:00
parent 3f7894ef03
commit de0f78b0d2
159 changed files with 93 additions and 135 deletions

7
pkgs/clan-app/ui/.envrc Normal file
View File

@@ -0,0 +1,7 @@
# shellcheck shell=bash
source_up
watch_file flake-module.nix default.nix
# Because we depend on nixpkgs sources, uploading to builders takes a long time
use flake .#webview-ui --builders ''

4
pkgs/clan-app/ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
app/api
app/.fonts
.vite

View File

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

View File

@@ -0,0 +1,37 @@
## 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!
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge,
now, etc.)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import tailwind from "eslint-plugin-tailwindcss";
import pluginQuery from "@tanstack/eslint-plugin-query";
const config = tseslint.config(
eslint.configs.recommended,
...pluginQuery.configs["flat/recommended"],
...tseslint.configs.strict,
...tseslint.configs.stylistic,
...tailwind.configs["flat/recommended"],
{
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!");
}
});
});

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M21 4h5v30.75h5v5.125h-5V45h-5v-5.125h-5V34.75h5zM11 29.625v5.125h5v-5.125zm0 0V24.5H6v5.125zm25 0v5.125h-5v-5.125zm0 0V24.5h5v5.125z"/></svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M43.9 21.338v4.987H13.975v4.988H8.988v-4.988H4v-4.988h4.988V16.35h4.987v4.987zm-24.937-9.975h-4.988v4.987h4.987zm0 0h4.987V6.375h-4.988zm0 24.937h-4.988v-4.987h4.987zm0 0h4.987v4.988h-4.988z"/></svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M4 21.571v4.858h29.25v4.857h4.875v-4.857H43V21.57h-4.875v-4.857H33.25v4.857zm24.375-9.714h4.875v4.857h-4.875zm0 0H23.5V7h4.875zm0 24.286h4.875v-4.857h-4.875zm0 0H23.5V41h4.875z"/></svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M20.857 45h5.286V14.25h5.286V9.125h-5.286V4h-5.286v5.125h-5.286v5.125h5.286zM10.286 19.375V14.25h5.285v5.125zm0 0V24.5H5v-5.125zm26.428 0V14.25H31.43v5.125zm0 0V24.5H42v-5.125z"/></svg>

After

Width:  |  Height:  |  Size: 277 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M25.6 4h-4.1v8.2h4.1zm-8.2 12.3h12.3v4.1h4.1v8.2h-4.1v8.2H17.4v-8.2h-4.1v-8.2h4.1zm0 24.6h12.3V45H17.4zm28.7-18.45v4.1h-8.2v-4.1zm-36.9 4.1v-4.1H1v4.1zM33.8 12.2h4.1v4.1h-4.1zm4.1 0H42V8.1h-4.1zm-28.7 0h4.1v4.1H9.2zm0 0V8.1H5.1v4.1z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 373 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M40.25 15H7v4.75h4.75v4.75h4.75v4.75h4.75V34H26v-4.75h4.75V24.5h4.75v-4.75h4.75z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M32 40.25V7h-4.75v4.75H22.5v4.75h-4.75v4.75H13V26h4.75v4.75h4.75v4.75h4.75v4.75z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M15 40.25V7h4.75v4.75h4.75v4.75h4.75v4.75H34V26h-4.75v4.75H24.5v4.75h-4.75v4.75z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M40.25 34H7v-4.75h4.75V24.5h4.75v-4.75h4.75V15H26v4.75h4.75v4.75h4.75v4.75h4.75z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 221 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M36.888 11H41.3v4.413h-4.412zm-4.413 8.825v-4.412h4.413v4.412zm-4.413 4.413v-4.413h4.413v4.413zM23.65 28.65h4.413v-4.412H23.65zm-4.412 4.413h4.412V28.65h-4.412zm-4.413 0v4.412h4.413v-4.413zm-4.412-4.413h4.412v4.413h-4.412zm0 0H6v-4.412h4.413z"/></svg>

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="89" fill="currentColor"><g clip-path="url(#a)"><path d="M57.709 20.105H68.62c1.157 0 2.099-.94 2.099-2.095V9.632a2.1 2.1 0 0 0-2.099-2.095h-3.439c-1.111 0-2.014-.9-2.014-2.01V2.095A2.1 2.1 0 0 0 61.07 0H30.02a2.1 2.1 0 0 0-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01H22.47c-1.157 0-2.098.94-2.098 2.095v3.432c0 1.11-.903 2.01-2.014 2.01h-3.439c-1.157 0-2.099.94-2.099 2.094 0 0-.503-1.272-.503 22.493 0 21.247.503 19.38.503 19.38 0 1.156.942 2.096 2.1 2.096h3.438c1.111 0 2.014.9 2.014 2.01v3.517c0 1.109.902 2.01 2.013 2.01h3.524c1.111 0 2.014.9 2.014 2.01v3.432a2.1 2.1 0 0 0 2.098 2.094h30.211c1.157 0 2.099-.94 2.099-2.094v-3.433c0-1.11.902-2.01 2.013-2.01h5.557c1.158 0 2.099-.94 2.099-2.094v-9.984a2.1 2.1 0 0 0-2.099-2.095h-13.03c-1.157 0-2.098.94-2.098 2.095v5.044c0 1.11-.902 2.01-2.014 2.01H37.488c-1.111 0-2.013-.9-2.013-2.01v-5.11a2.1 2.1 0 0 0-2.099-2.094h-5.119c-1.111 0-1.739.163-2.014-2.01-.085-.698-.13-1.553-.196-2.695-.163-2.878-.307-1.723-.307-10.369 0-12.085.314-15.563.503-17.24.19-1.677.903-2.01 2.014-2.01h5.12c1.156 0 2.098-.94 2.098-2.094v-3.433c0-1.109.902-2.01 2.013-2.01h16.116c1.111 0 2.014.901 2.014 2.01v3.433c0 1.155.94 2.094 2.098 2.094zM18.626 73.757h-2.478a.87.87 0 0 1-.87-.868v-2.473c0-.96-.777-1.743-1.745-1.743H6.838c-.96 0-1.745.777-1.745 1.743v2.473a.87.87 0 0 1-.87.868H1.746c-.961 0-1.746.776-1.746 1.742v6.682c0 .96.778 1.742 1.746 1.742h2.477c.484 0 .87.392.87.868v2.473c0 .96.778 1.743 1.745 1.743h6.695c.961 0 1.746-.777 1.746-1.743v-2.473c0-.483.392-.868.87-.868h2.477c.961 0 1.746-.776 1.746-1.742v-6.682c0-.96-.778-1.742-1.746-1.742"/></g><defs><clipPath id="a"><path d="M0 0h72v89H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="223" height="89" fill="currentColor"><g clip-path="url(#a)"><path d="M55.503 18.696h10.104a1.946 1.946 0 0 0 1.943-1.948v-7.79c0-1.075-.87-1.947-1.943-1.947h-3.186a1.863 1.863 0 0 1-1.866-1.87V1.947C60.555.872 59.685 0 58.612 0h-27.98a1.946 1.946 0 0 0-1.944 1.947v3.194c0 1.036-.832 1.87-1.865 1.87h-3.187a1.946 1.946 0 0 0-1.943 1.947v3.194c0 1.036-.832 1.87-1.866 1.87h-3.186a1.946 1.946 0 0 0-1.943 1.947s-.467 1.153-.467 23.253c0 19.763.467 21.913.467 21.913 0 1.075.87 1.948 1.943 1.948h3.186c1.034 0 1.866.833 1.866 1.87v3.271c0 1.036.831 1.87 1.865 1.87h3.265c1.033 0 1.865.833 1.865 1.87v3.193c0 1.075.87 1.948 1.943 1.948h27.981a1.946 1.946 0 0 0 1.943-1.948v-3.194c0-1.036.832-1.87 1.866-1.87h5.145a1.946 1.946 0 0 0 1.943-1.947v-9.285c0-1.075-.87-1.948-1.943-1.948H55.503a1.946 1.946 0 0 0-1.943 1.948v4.69c0 1.035-.832 1.869-1.866 1.869H37.55a1.863 1.863 0 0 1-1.866-1.87v-4.752c0-1.075-.87-1.947-1.943-1.947H29c-1.034 0-1.609.148-1.865-1.87-.078-.646-.125-1.44-.18-2.508-.147-2.68-.287-5.5-.287-13.539 0-11.24.288-16.81.466-18.369.18-1.558.832-1.87 1.866-1.87h4.741a1.946 1.946 0 0 0 1.943-1.947v-3.193c0-1.037.832-1.87 1.866-1.87h14.145c1.034 0 1.866.833 1.866 1.87v3.193c0 1.075.87 1.948 1.943 1.948M20.247 74.822h-2.293a.814.814 0 0 1-.808-.81v-2.298c0-.896-.723-1.62-1.617-1.62H9.327c-.894 0-1.617.724-1.617 1.62v2.298c0 .444-.365.81-.808.81H4.609c-.894 0-1.617.725-1.617 1.62v6.217c0 .896.723 1.62 1.617 1.62h2.293c.443 0 .808.366.808.81v2.299c0 .895.723 1.62 1.617 1.62h6.202c.894 0 1.617-.725 1.617-1.62v-2.299c0-.444.365-.81.808-.81h2.293c.894 0 1.617-.724 1.617-1.62v-6.216c0-.896-.723-1.62-1.617-1.62M221.135 35.04h-1.71a1.863 1.863 0 0 1-1.866-1.87v-3.272c0-1.036-.831-1.87-1.865-1.87h-3.265a1.863 1.863 0 0 1-1.865-1.87v-3.271c0-1.036-.832-1.87-1.865-1.87h-20.971a1.863 1.863 0 0 0-1.865 1.87v3.965c0 .514-.42.935-.933.935h-3.559c-.513 0-.84-.32-.933-.935l-.622-3.918c-.148-1.099-.676-1.777-1.788-1.777l-3.653-.14h-2.052a3.736 3.736 0 0 0-3.73 3.74V61.68a3.736 3.736 0 0 1-3.731 3.739h-8.394a1.863 1.863 0 0 1-1.866-1.87V36.714c0-11.825-7.461-18.813-22.556-18.813-13.718 0-20.325 5.04-21.203 14.443-.109 1.153.552 1.815 1.702 1.815l7.757.569c1.143.1 1.594-.554 1.811-1.652.77-3.74 4.174-5.827 9.933-5.827 7.081 0 10.042 3.358 10.042 9.076v3.014c0 1.036-.831 1.87-1.865 1.87l-.342-.024h-9.715c-15.421 0-22.984 5.983-22.984 17.956 0 3.802.778 7.058 2.254 9.738h-.59c-1.765-1.27-2.457-2.236-3.055-2.93-.256-.295-.653-.537-1.345-.537h-1.717l-5.993.008h-3.264a3.736 3.736 0 0 1-3.731-3.74V1.769C89.74.654 89.072 0 87.969 0H79.55c-1.034 0-1.865.732-1.865 1.768l-.024 54.304v13.554c0 4.13 3.343 7.479 7.462 7.479h50.84c8.448-.429 8.604-3.42 9.436-4.542.645 3.56 1.865 4.347 4.71 4.518 8.137.117 18.343.032 18.49.024h4.975c4.119 0 6.684-3.35 6.684-7.479l.777-27.264c0-1.036.832-1.87 1.866-1.87h2.021a1.56 1.56 0 0 0 1.554-1.558v-3.583c0-1.036.832-1.87 1.866-1.87h11.868a3.37 3.37 0 0 1 3.366 3.373v3.249c0 1.075.87 1.947 1.943 1.947h4.119c.513 0 .933.42.933.935v32.25c0 1.036.831 1.87 1.865 1.87h6.84a3.736 3.736 0 0 0 3.731-3.74V36.91c0-1.036-.832-1.87-1.866-1.87zM142.64 54.225c0 8.927-6.132 14.715-15.335 14.715-6.606 0-9.793-2.953-9.793-8.748 0-6.442 3.832-9.636 11.62-9.636h13.508v3.669"/></g><defs><clipPath id="a"><path d="M0 0h223v89H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M5 6h5.286v5.286H5zm10.571 10.571h-5.285v-5.285h5.285zm5.286 5.286h-5.286v-5.286h5.286zm5.286 0h-5.286v5.286h-5.286v5.286h-5.285v5.285H5V43h5.286v-5.286h5.285V32.43h5.286v-5.286h5.286v5.286h5.286v5.285h5.285V43H42v-5.286h-5.286V32.43H31.43v-5.286h-5.286zm5.286-5.286v5.286h-5.286v-5.286zm5.285-5.285v5.285H31.43v-5.285zm0 0V6H42v5.286z"/></svg>

After

Width:  |  Height:  |  Size: 436 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M14.069 23.667v4.37h4.034v4.37h2.017v2.186h2.017v2.185h4.034v-2.185h2.017v-2.186h2.017v-4.37h4.034v-4.37h4.035V17.11h-4.035v2.185h-4.034v4.37h-4.034V4h-4.034v19.667h-4.034v-4.37h-4.035V17.11h-4.034v6.556z"/><path d="M38.274 34.593v4.37h-28.24v-4.37H6v8.74h36.308v-8.74z"/></svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M35.8 4h-4.1v4.1h-4.1v4.1h-4.1v4.1h-4.1v4.1h-4.1v4.1h-4.1v4.1H7.1v4.1H3V45h12.3v-4.1h4.1v-4.1h4.1v-4.1h4.1v-4.1h4.1v-4.1h4.1v-4.1h4.1v-4.1H44v-4.1h-4.1V8.1h-4.1zm0 16.4h-4.1v4.1h-4.1v4.1h-4.1v4.1h-4.1v4.1h-4.1v-4.1h-4.1v-4.1h4.1v-4.1h4.1v-4.1h4.1v-4.1h4.1v-4.1h4.1v4.1h4.1zM11.2 32.7H7.1v8.2h8.2v-4.1h-4.1z"/></svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M21.8 8.444h4.4v4.445h4.4v4.444H35V12.89h-4.4V8.444h-4.4V4h-4.4zm-4.4 4.445V8.444h4.4v4.445zm0 0v4.444H13V12.89zm8.8 26.667h-4.4V35.11h-4.4v-4.444H13v4.444h4.4v4.445h4.4V44h4.4zm4.4-4.445h-4.4v4.445h4.4zm0 0H35v-4.444h-4.4z"/></svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M2 15h3.583v3.6H2zm7.167 7.2H5.583v-3.6h3.584zm7.166 3.6v-3.6H9.167v3.6H5.583v3.6h3.584v-3.6zm14.334 0H16.333v3.6H12.75V33h3.583v-3.6h14.334V33h3.583v-3.6h-3.583zm7.166-3.6h-7.166v3.6h7.166v3.6h3.584v-3.6h-3.584zm3.584-3.6v3.6h-3.584v-3.6zm0 0V15H45v3.6z"/></svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M16.667 11h14.666v22h-22V14.667h7.334zm14.666 3.667h7.334v3.666h3.666v7.334h-3.666 3.666v3.666h-3.666V33h-7.334V14.667m-14.666 22h14.666V33H16.667zm-11-18.334h3.666v7.334H2V22h3.667zM42.333 22H46v3.667h-3.667zM5.667 25.667h3.666v3.666H5.667zM28 19.833h-8v8h8z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M8.7 9h11.1v5.625H42v3.75H19.8V24H8.7v-5.625H5v-3.75h3.7zm3.7 3.75v7.5h3.7v-7.5zM27.2 24h11.1v5.625H42v3.75h-3.7V39H27.2v-5.625H5v-3.75h22.2zm3.7 3.75v7.5h3.7v-7.5z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M24 6.4h3.2v12.8H40v6.4h-3.2v3.2h-3.2V32h-3.2v3.2h-3.2v3.2H24v3.2h-3.2V28.8H8v-6.4h3.2v-3.2h3.2V16h3.2v-3.2h3.2V9.6H24z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M41 12.667v-1.89H20.222V7H5.112v32.111H7V41h34v-1.889h1.889V12.667z"/></svg>

After

Width:  |  Height:  |  Size: 168 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M4 4h39v39H4zm3.9 3.9v7.8h7.8V7.9zm11.7 0v7.8h7.8V7.9zm11.7 0v7.8h7.8V7.9zm7.8 11.7h-7.8v7.8h7.8zm0 11.7h-7.8v7.8h7.8zm-11.7 7.8v-7.8h-7.8v7.8zm-11.7 0v-7.8H7.9v7.8zM7.9 27.4h7.8v-7.8H7.9zm11.7-7.8v7.8h7.8v-7.8z"/></svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M8.35 4h.094L8.36 39.652h31.312V8.442H8.444V4h31.228v4.348H44v31.304h-4.328v4.328L8.35 44v-4.348H4V8.348h4.35zm13.428 13.326h4.444v-4.442h-4.444zm4.444 17.768h-4.444V21.768h4.444z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 320 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M4 5h39v38H4zm3.9 4.571v6.572h31.2V9.57zm31.2 11.143H7.9v6.572h31.2zm0 11.143H7.9v6.572h31.2z"/></svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M26.165 2h-4.393v13.181h4.393zm0 30.756h-4.393v13.181h4.393zm19.772-10.984v4.393H32.756v-4.393zm-30.756 4.393v-4.393H2v4.393zm15.378-13.18h4.394v4.393h-4.394zm8.787-4.394h-4.393v4.393h4.393zm-21.968 4.393h-4.394v4.394h4.394zM8.591 8.591h4.393v4.393H8.591zm21.968 26.362h4.394v4.393h4.393v-4.393h-4.393v-4.394h-4.394zm-17.575 0v-4.394h4.394v4.394zv4.393H8.591v-4.393z"/></svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M19 6h9v9h-9zM19 20h9v9h-9zM19 34h9v9h-9z"/></svg>

After

Width:  |  Height:  |  Size: 142 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M12.573 7.573v32.01H8V3h32.01v41.155H17.146v-32.01h13.718V35.01h-4.573V16.718h-4.573v22.864h13.719V7.572z"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M22.004 8h4.001v14.004H40.01v4.001H26.005V40.01h-4V26.005H8v-4h14.004z"/></svg>

After

Width:  |  Height:  |  Size: 171 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M33.106 6h-4.518v4.518h4.518v4.517H10.518v4.518H6v18.07h4.518v4.519H24.07v-4.518H10.518v-18.07h22.588v4.517h-4.518v4.517h4.518v-4.517h4.518v-4.518h4.517v-4.518h-4.517v-4.517h-4.518z"/></svg>

After

Width:  |  Height:  |  Size: 282 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M10.8 5H7v38h34.2V5h-3.8v3.8h-3.8V5h-3.8v3.8H26V5h-3.8v3.8h-3.8V5h-3.8v3.8h-3.8zm3.8 3.8h3.8v3.8h-3.8zm7.6 0H26v3.8h-3.8zm7.6 0h3.8v3.8h-3.8zm3.8 7.6h-19v3.8h19zm0 7.6h-19v3.8h19zM26 35.4v-3.8h7.6v3.8z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 342 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M14.2 4h16.4v4.1H14.2zm-4.1 8.2V8.1h4.1v4.1zm0 16.4H6V12.2h4.1zm4.1 4.1h-4.1v-4.1h4.1zm16.4 0v4.1H14.2v-4.1zm4.1-4.1h-4.1v4.1h4.1v4.1h4.1v4.1h4.1V45H47v-4.1h-4.1v-4.1h-4.1v-4.1h-4.1zm0-16.4h4.1v16.4h-4.1zm0 0V8.1h-4.1v4.1z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M9.714 9.714h8.572V6.857h2.857V4h5.714v2.857h2.857v2.857h8.572v8.572h2.857v2.857H44v5.714h-2.857v2.857h-2.857v8.572h-8.572v2.857h-2.857V44h-5.714v-2.857h-2.857v-2.857H9.714v-8.572H6.857v-2.857H4v-5.714h2.857v-2.857h2.857zm8.572 5.715v2.857h-2.857v11.428h2.857v2.857h11.428v-2.857h2.857V18.286h-2.857v-2.857z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M28 4H16v8H4v4h4v28h32V16h4v-4H32V4zm0 4h-8v4h8zM12 40V16h.001v24" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 206 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path d="M32.4 3h-4.2v4.2h4.2v4.2H7.2v4.2H3v10.5h4.2V15.6h25.2v4.2h-4.2V24h4.2v-4.2h4.2v-4.2h4.2v-4.2h-4.2V7.2h-4.2zm-21 37.8h4.2V45h4.2v-4.2h-4.2v-4.2h25.2v-4.2H45V21.9h-4.2v10.5H15.6v-4.2h4.2V24h-4.2v4.2h-4.2v4.2H7.2v4.2h4.2z"/></svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor"><path fill-rule="evenodd" d="M42 6H5v37h37zm-20.555 8.222h4.111v12.334h-4.111zm0 16.445h4.111v4.11h-4.111z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 218 B

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>

8186
pkgs/clan-app/ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
{
"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"
},
"license": "MIT",
"devDependencies": {
"@babel/plugin-syntax-import-attributes": "^7.27.1",
"@eslint/js": "^9.3.0",
"@tailwindcss/typography": "^0.5.13",
"@types/json-schema": "^7.0.15",
"@types/node": "^20.12.12",
"@types/three": "^0.176.0",
"@typescript-eslint/parser": "^7.10.0",
"autoprefixer": "^10.4.19",
"classnames": "^2.5.1",
"eslint": "^8.57.0",
"eslint-plugin-tailwindcss": "^3.17.0",
"jsdom": "^24.1.0",
"postcss": "^8.4.38",
"postcss-url": "^10.1.3",
"prettier": "^3.2.5",
"solid-devtools": "^0.29.2",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"typescript-eslint": "^7.10.0",
"vite": "^5.0.11",
"vite-plugin-solid": "^2.8.2",
"vite-plugin-solid-svg": "^0.8.1",
"vitest": "^1.6.0"
},
"dependencies": {
"@floating-ui/dom": "^1.6.8",
"@modular-forms/solid": "^0.21.0",
"@solid-primitives/storage": "^3.7.1",
"@solidjs/router": "^0.14.2",
"@tanstack/eslint-plugin-query": "^5.51.12",
"@tanstack/solid-query": "^5.51.2",
"corvu": "^0.7.1",
"material-icons": "^1.13.12",
"nanoid": "^5.0.7",
"solid-js": "^1.8.11",
"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,41 @@
import { createSignal } from "solid-js";
import { makePersisted } from "@solid-primitives/storage";
import { callApi } from "./api";
const [activeURI, setActiveURI] = makePersisted(
createSignal<string | null>(null),
{
name: "activeURI",
storage: localStorage,
},
);
export { activeURI, setActiveURI };
const [clanList, setClanList] = makePersisted(createSignal<string[]>([]), {
name: "clanList",
storage: localStorage,
});
export { clanList, setClanList };
(async function () {
const curr = activeURI();
if (curr) {
const result = await callApi("show_clan_meta", {
flake: { identifier: curr },
});
console.log("refetched meta for ", curr);
if (result.status === "error") {
result.errors.forEach((error) => {
if (error.description === "clan directory does not exist") {
setActiveURI(null);
setClanList((clans) => clans.filter((clan) => clan !== curr));
}
});
}
}
})();
// ensure to null out activeURI on startup if the clan was deleted
// => throws user back to the view for selecting a clan

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

View File

@@ -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 "@/src/components/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,138 @@
import schema from "@/api/API.json" with { type: "json" };
import { API, Error } 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;
}
| Error;
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 } => {
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 });
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 = async <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
): Promise<OperationResponse<K>> => {
console.log("Calling API", method, args);
const { promise, op_key } = _callApi(method, args);
const toastId = toast.custom(
(
t, // t is the Toast object, t.id is the id of THIS toast instance
) => (
<CancelToastComponent
t={t}
message={"Exectuting " + method}
onCancel={handleCancel.bind(null, op_key, promise)}
/>
),
{
duration: Infinity,
},
);
const response = await promise;
// 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 as OperationResponse<K>;
};

View File

@@ -0,0 +1,19 @@
import { QueryClient } from "@tanstack/solid-query";
import { ApiEnvelope, callApi } from ".";
import { Schema as Inventory } from "@/api/Inventory";
export async function get_inventory(client: QueryClient, base_path: string) {
const data = await client.ensureQueryData({
queryKey: [base_path, "inventory"],
queryFn: () => {
console.log("Refreshing inventory");
return callApi("get_inventory", {
flake: { identifier: base_path },
}) as Promise<ApiEnvelope<Inventory>>;
},
revalidateIfStale: true,
staleTime: 60 * 1000,
});
return data;
}

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 },
});
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,105 @@
import {
createForm,
FieldValues,
getValues,
SubmitHandler,
} from "@modular-forms/solid";
import { TextInput } from "@/src/Form/fields/TextInput";
import { Button } from "./components/button";
import { callApi } from "./api";
import { API } from "@/api/API";
import { createSignal, Match, Switch } from "solid-js";
import { Typography } from "./components/Typography";
import { createQuery } from "@tanstack/solid-query";
import { makePersisted } from "@solid-primitives/storage";
interface APITesterForm extends FieldValues {
endpoint: string;
payload: string;
}
export const ApiTester = () => {
const [persistedTestData, setPersistedTestData] = makePersisted(
createSignal<APITesterForm>(),
{
name: "_test_data",
storage: localStorage,
},
);
const [formStore, { Form, Field }] = createForm<APITesterForm>({
initialValues: persistedTestData(),
});
const query = createQuery(() => ({
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: [],
queryFn: async () => {
const values = getValues(formStore);
return await callApi(
values.endpoint as keyof API,
JSON.parse(values.payload || ""),
);
},
staleTime: Infinity,
}));
const handleSubmit: SubmitHandler<APITesterForm> = (values) => {
console.log(values);
setPersistedTestData(values);
query.refetch();
const v = getValues(formStore);
console.log(v);
// const result = callApi(
// values.endpoint as keyof API,
// JSON.parse(values.payload)
// );
// setResult(result);
};
return (
<div class="p-2">
<h1>API Tester</h1>
<Form onSubmit={handleSubmit}>
<div class="flex flex-col">
<Field name="endpoint">
{(field, fieldProps) => (
<TextInput
label={"endpoint"}
value={field.value || ""}
inputProps={fieldProps}
/>
)}
</Field>
<Field name="payload">
{(field, fieldProps) => (
<TextInput
label={"payload"}
value={field.value || ""}
inputProps={fieldProps}
/>
)}
</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";
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,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";
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,106 @@
import { For, createEffect, Show, type JSX, children } from "solid-js";
import { A, RouteSectionProps } from "@solidjs/router";
import { activeURI } from "@/src/App";
import { createQuery } from "@tanstack/solid-query";
import { callApi } from "@/src/api";
import { AppRoute, routes } from "@/src/index";
import { SidebarHeader } from "./SidebarHeader";
import { SidebarListItem } from "./SidebarListItem";
import { Typography } from "../Typography";
import "./css/sidebar.css";
import Icon, { IconVariant } from "../icon";
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) => {
createEffect(() => {
console.log("machines");
console.log(routes);
});
const query = createQuery(() => ({
queryKey: [activeURI(), "meta"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("show_clan_meta", {
flake: { identifier: curr },
});
console.log("refetched meta for ", curr);
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
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,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";
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,27 @@
import { Button } from ".";
import FlashIcon from "@/icons/flash.svg";
export const Test = () => {
<div class="p-8">
<Button>Label</Button>
<Button
startIcon={<FlashIcon width={16} height={16} viewBox="0 0 48 48" />}
>
Label
</Button>
<Button
variant="light"
endIcon={<FlashIcon width={16} height={16} viewBox="0 0 48 48" />}
>
Label
</Button>
<Button size="s">Label</Button>
<Button
variant="light"
size="s"
endIcon={<FlashIcon width={13} height={13} viewBox="0 0 48 48" />}
>
Label
</Button>
</div>;
};

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-inner-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-inner-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-inner-primary-active;
box-shadow: inset 2px 2px theme(backgroundColor.secondary.300);
& .button__label {
color: theme(textColor.secondary.900) !important;
}
}

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

View File

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

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