All checks were successful
Deploy to GitHub Pages / build_site (push) Successful in 3m33s
212 lines
4.7 KiB
Svelte
212 lines
4.7 KiB
Svelte
<script lang="ts">
|
|
import NestedSettings from "./NestedSettings.svelte";
|
|
import { localState } from "$lib/helpers/localState.svelte";
|
|
import type { NodeInput } from "@nodarium/types";
|
|
import Input from "@nodarium/ui";
|
|
|
|
type Button = { type: "button"; callback: () => void; label?: string };
|
|
|
|
type InputType = NodeInput | Button;
|
|
|
|
type SettingsNode = InputType | SettingsGroup;
|
|
|
|
interface SettingsGroup {
|
|
title?: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
type SettingsType = Record<string, SettingsNode>;
|
|
|
|
type SettingsValue = Record<
|
|
string,
|
|
Record<string, unknown> | string | number | boolean | number[]
|
|
>;
|
|
|
|
type Props = {
|
|
id: string;
|
|
key?: string;
|
|
value: SettingsValue;
|
|
type: SettingsType;
|
|
depth?: number;
|
|
};
|
|
|
|
// Local persistent state for <details> sections
|
|
const openSections = localState<Record<string, boolean>>("open-details", {});
|
|
|
|
let { id, key = "", value = $bindable(), type, depth = 0 }: Props = $props();
|
|
|
|
function isNodeInput(v: SettingsNode | undefined): v is InputType {
|
|
return !!v && typeof v === "object" && "type" in v;
|
|
}
|
|
|
|
function getDefaultValue(): unknown {
|
|
if (key === "" || key === "title") return;
|
|
|
|
const node = type[key];
|
|
|
|
if (!isNodeInput(node)) return;
|
|
|
|
const anyNode = node as any;
|
|
|
|
// select input: use index into options
|
|
if (Array.isArray(anyNode.options)) {
|
|
if (value?.[key] !== undefined) {
|
|
return anyNode.options.indexOf(value[key]);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (value?.[key] !== undefined) return value[key];
|
|
|
|
if ("value" in node && anyNode.value !== undefined) {
|
|
return anyNode.value;
|
|
}
|
|
|
|
switch (node.type) {
|
|
case "boolean":
|
|
return 0;
|
|
case "float":
|
|
return 0.5;
|
|
case "integer":
|
|
case "select":
|
|
return 0;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
let internalValue = $state(getDefaultValue());
|
|
|
|
let open = $state(openSections.value[id]);
|
|
|
|
// Persist <details> open/closed state for groups
|
|
if (depth > 0 && !isNodeInput(type[key!])) {
|
|
$effect(() => {
|
|
if (open !== undefined) {
|
|
openSections.value[id] = open;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Sync internalValue back into `value`
|
|
$effect(() => {
|
|
if (key === "" || internalValue === undefined) return;
|
|
|
|
const node = type[key];
|
|
|
|
if (
|
|
isNodeInput(node) &&
|
|
Array.isArray((node as any).options) &&
|
|
typeof internalValue === "number"
|
|
) {
|
|
value[key] = (node as any)?.options?.[internalValue] as any;
|
|
} else {
|
|
value[key] = internalValue as any;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
{#if key && isNodeInput(type?.[key])}
|
|
<!-- Leaf input -->
|
|
<div class="input input-{type[key].type}" class:first-level={depth === 1}>
|
|
{#if type[key].type === "button"}
|
|
<button onclick={() => "callback" in type[key] && type[key].callback()}>
|
|
{type[key].label || key}
|
|
</button>
|
|
{:else}
|
|
{#if type[key].label !== ""}
|
|
<label for={id}>{type[key].label || key}</label>
|
|
{/if}
|
|
<Input {id} input={type[key]} bind:value={internalValue} />
|
|
{/if}
|
|
</div>
|
|
{:else if depth === 0}
|
|
<!-- Root: iterate over top-level keys -->
|
|
{#each Object.keys(type ?? {}).filter((k) => k !== "title") as childKey}
|
|
<NestedSettings
|
|
id={`${id}.${childKey}`}
|
|
key={childKey}
|
|
bind:value
|
|
{type}
|
|
depth={depth + 1}
|
|
/>
|
|
{/each}
|
|
<hr />
|
|
{:else if key && type?.[key]}
|
|
<!-- Group -->
|
|
{#if depth > 0}
|
|
<hr />
|
|
{/if}
|
|
<details bind:open>
|
|
<summary><p>{(type[key] as SettingsGroup).title || key}</p></summary>
|
|
<div class="content">
|
|
{#each Object.keys(type[key] as SettingsGroup).filter((k) => k !== "title") as childKey}
|
|
<NestedSettings
|
|
id={`${id}.${childKey}`}
|
|
key={childKey}
|
|
bind:value={value[key] as SettingsValue}
|
|
type={type[key] as unknown as SettingsType}
|
|
depth={depth + 1}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
</details>
|
|
{/if}
|
|
|
|
<style>
|
|
summary {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
margin-bottom: 1em;
|
|
}
|
|
|
|
summary > p {
|
|
display: inline;
|
|
padding-left: 6px;
|
|
}
|
|
|
|
details {
|
|
padding: 1em;
|
|
padding-bottom: 0;
|
|
padding-left: 21px;
|
|
}
|
|
|
|
.input {
|
|
margin-top: 15px;
|
|
margin-bottom: 15px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.input-boolean {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
}
|
|
|
|
.input-boolean > label {
|
|
order: 2;
|
|
}
|
|
|
|
.first-level.input {
|
|
padding-left: 1em;
|
|
padding-right: 1em;
|
|
padding-bottom: 1px;
|
|
}
|
|
|
|
button {
|
|
cursor: pointer;
|
|
}
|
|
|
|
hr {
|
|
position: absolute;
|
|
margin: 0;
|
|
left: 0;
|
|
right: 0;
|
|
border: none;
|
|
border-bottom: solid thin var(--outline);
|
|
}
|
|
</style>
|