mirror of
https://github.com/ReaJason/MemShellParty.git
synced 2026-09-23 23:41:52 +08:00
feat: support packer config
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import type { NormalizedPackerCategory } from "@/lib/packer-schema";
|
||||
|
||||
type PackerComboboxProps = {
|
||||
categories: NormalizedPackerCategory[];
|
||||
value?: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
emptyText?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type PreparedPacker = {
|
||||
name: string;
|
||||
label: string;
|
||||
searchText: string;
|
||||
};
|
||||
|
||||
type PreparedCategory = {
|
||||
name: string;
|
||||
label: string;
|
||||
packers: PreparedPacker[];
|
||||
};
|
||||
|
||||
export function PackerCombobox({
|
||||
categories,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
emptyText,
|
||||
disabled,
|
||||
}: Readonly<PackerComboboxProps>) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const preparedCategories = useMemo<PreparedCategory[]>(
|
||||
() =>
|
||||
categories.map((category) => {
|
||||
const categoryLabel = t(category.name, { defaultValue: category.name });
|
||||
return {
|
||||
name: category.name,
|
||||
label: categoryLabel,
|
||||
packers: category.packers.map((packer) => {
|
||||
const packerLabel = t(packer.name, { defaultValue: packer.name });
|
||||
return {
|
||||
name: packer.name,
|
||||
label: packerLabel,
|
||||
searchText:
|
||||
`${packer.name} ${packerLabel} ${category.name} ${categoryLabel}`.toLowerCase(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
[categories, t],
|
||||
);
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
for (const category of preparedCategories) {
|
||||
const found = category.packers.find((packer) => packer.name === value);
|
||||
if (found) {
|
||||
return found.label;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}, [preparedCategories, value]);
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return preparedCategories;
|
||||
}
|
||||
return preparedCategories
|
||||
.map((category) => ({
|
||||
...category,
|
||||
packers: category.packers.filter((packer) =>
|
||||
packer.searchText.includes(normalizedQuery),
|
||||
),
|
||||
}))
|
||||
.filter((category) => category.packers.length > 0);
|
||||
}, [preparedCategories, query]);
|
||||
|
||||
const resolvedPlaceholder =
|
||||
placeholder ?? t("selectPacker", { defaultValue: "Select packer" });
|
||||
return (
|
||||
<Combobox
|
||||
value={value ?? null}
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
setQuery("");
|
||||
}}
|
||||
inputValue={open ? query : selectedLabel}
|
||||
onInputValueChange={(nextValue) => {
|
||||
if (open) {
|
||||
setQuery(nextValue);
|
||||
}
|
||||
}}
|
||||
onValueChange={(nextValue) => {
|
||||
if (typeof nextValue === "string") {
|
||||
onValueChange(nextValue);
|
||||
}
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}}
|
||||
autoComplete="none"
|
||||
autoHighlight={true}
|
||||
>
|
||||
<ComboboxInput
|
||||
className="w-full"
|
||||
placeholder={resolvedPlaceholder}
|
||||
disabled={disabled}
|
||||
showClear={false}
|
||||
/>
|
||||
<ComboboxContent>
|
||||
<ComboboxList>
|
||||
{filteredCategories.map((category) => (
|
||||
<ComboboxGroup key={category.name}>
|
||||
<ComboboxLabel>{category.label}</ComboboxLabel>
|
||||
{category.packers.map((packer) => (
|
||||
<ComboboxItem key={packer.name} value={packer.name}>
|
||||
<span className="truncate" title={packer.label}>
|
||||
{packer.label}
|
||||
</span>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxGroup>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
Controller,
|
||||
type FieldValues,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSet,
|
||||
} from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { PackerSchemaField } from "@/types/memshell";
|
||||
|
||||
type Props<T extends FieldValues> = {
|
||||
form: UseFormReturn<T>;
|
||||
fields: PackerSchemaField[];
|
||||
baseName?: string;
|
||||
};
|
||||
|
||||
export function PackerCustomConfigFields<T extends FieldValues>({
|
||||
form,
|
||||
fields,
|
||||
baseName = "packerCustomConfig",
|
||||
}: Readonly<Props<T>>) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supportedFields = fields.filter((field) =>
|
||||
["BOOLEAN", "STRING", "ENUM", "INTEGER"].includes(field.type),
|
||||
);
|
||||
|
||||
if (supportedFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getFieldDescription = (schemaField: PackerSchemaField) => {
|
||||
if (!schemaField.description && !schemaField.descriptionI18nKey) {
|
||||
return undefined;
|
||||
}
|
||||
if (!schemaField.descriptionI18nKey) {
|
||||
return schemaField.description;
|
||||
}
|
||||
return t(schemaField.descriptionI18nKey, {
|
||||
defaultValue: schemaField.description ?? schemaField.descriptionI18nKey,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Field className="mt-2 gap-1">
|
||||
<FieldLabel>
|
||||
{t("packerParams", { defaultValue: "Packer Params" })}
|
||||
</FieldLabel>
|
||||
{supportedFields.map((schemaField) => {
|
||||
const fieldName = `${baseName}.${schemaField.key}` as any;
|
||||
const fieldDescription = getFieldDescription(schemaField);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
key={schemaField.key}
|
||||
control={form.control}
|
||||
name={fieldName}
|
||||
render={({ field }) => {
|
||||
switch (schemaField.type) {
|
||||
case "BOOLEAN":
|
||||
return (
|
||||
<Field orientation="horizontal">
|
||||
<Switch
|
||||
id={fieldName}
|
||||
checked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={fieldName}>
|
||||
{schemaField.key}
|
||||
</FieldLabel>
|
||||
{fieldDescription ? (
|
||||
<FieldDescription>
|
||||
{fieldDescription}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
);
|
||||
case "ENUM":
|
||||
return (
|
||||
<Field orientation="vertical">
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={fieldName}>
|
||||
{schemaField.key}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={
|
||||
typeof field.value === "string"
|
||||
? field.value
|
||||
: undefined
|
||||
}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger id={fieldName}>
|
||||
<SelectValue
|
||||
data-placeholder={t("placeholders.select")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(schemaField.options ?? []).map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label || option.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldDescription ? (
|
||||
<FieldDescription>
|
||||
{fieldDescription}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
);
|
||||
case "INTEGER":
|
||||
return (
|
||||
<Field orientation="vertical">
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={fieldName}>
|
||||
{schemaField.key}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldName}
|
||||
type="number"
|
||||
step={1}
|
||||
value={
|
||||
typeof field.value === "number"
|
||||
? String(field.value)
|
||||
: ""
|
||||
}
|
||||
onChange={(event) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === "") {
|
||||
field.onChange(undefined);
|
||||
return;
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
field.onChange(
|
||||
Number.isFinite(parsed) ? parsed : undefined,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{fieldDescription ? (
|
||||
<FieldDescription>
|
||||
{fieldDescription}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
);
|
||||
case "STRING":
|
||||
return (
|
||||
<Field orientation="vertical">
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor={fieldName}>
|
||||
{schemaField.key}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldName}
|
||||
type="text"
|
||||
value={
|
||||
typeof field.value === "string" ? field.value : ""
|
||||
}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
{fieldDescription ? (
|
||||
<FieldDescription>
|
||||
{fieldDescription}
|
||||
</FieldDescription>
|
||||
) : null}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user