ui/services: workflow init
This commit is contained in:
35
pkgs/clan-app/ui/src/workflows/Service/Service.module.css
Normal file
35
pkgs/clan-app/ui/src/workflows/Service/Service.module.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
.content {
|
||||||
|
@apply px-3 flex flex-col gap-5 py-6;
|
||||||
|
border: 1px solid #2e4a4b;
|
||||||
|
background:
|
||||||
|
linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%),
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
theme(colors.bg.inv.2) 0%,
|
||||||
|
theme(colors.bg.inv.3) 100%
|
||||||
|
);
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
@apply py-2 pl-3 pr-2 flex gap-2.5 w-full items-center;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
border-bottom: 1px solid #2e4a4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
@apply py-3 px-4 flex justify-end w-full;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
border-top: 1px solid #2e4a4b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backgroundAlt {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
theme(colors.bg.inv.3) 0%,
|
||||||
|
theme(colors.bg.inv.4) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,23 +26,37 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
|||||||
const resultData: Partial<ResultDataMap> = {
|
const resultData: Partial<ResultDataMap> = {
|
||||||
list_service_modules: [
|
list_service_modules: [
|
||||||
{
|
{
|
||||||
module: { name: "Module A", input: "Input A" },
|
module: { name: "Borgbackup", input: "clan-core" },
|
||||||
info: {
|
info: {
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "Module A",
|
name: "Borgbackup",
|
||||||
description: "This is module A",
|
description: "This is module A",
|
||||||
},
|
},
|
||||||
roles: {
|
roles: {
|
||||||
peer: null,
|
client: null,
|
||||||
server: null,
|
server: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: { name: "Module B", input: "Input B" },
|
module: { name: "Zerotier", input: "clan-core" },
|
||||||
info: {
|
info: {
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "Module B",
|
name: "Zerotier",
|
||||||
|
description: "This is module B",
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
peer: null,
|
||||||
|
moon: null,
|
||||||
|
controller: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
module: { name: "Admin", input: "clan-core" },
|
||||||
|
info: {
|
||||||
|
manifest: {
|
||||||
|
name: "Admin",
|
||||||
description: "This is module B",
|
description: "This is module B",
|
||||||
},
|
},
|
||||||
roles: {
|
roles: {
|
||||||
@@ -51,22 +65,10 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
module: { name: "Module C", input: "Input B" },
|
module: { name: "Garage", input: "lo-l" },
|
||||||
info: {
|
info: {
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "Module B",
|
name: "Garage",
|
||||||
description: "This is module B",
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
module: { name: "Module B", input: "Input A" },
|
|
||||||
info: {
|
|
||||||
manifest: {
|
|
||||||
name: "Module B",
|
|
||||||
description: "This is module B",
|
description: "This is module B",
|
||||||
},
|
},
|
||||||
roles: {
|
roles: {
|
||||||
@@ -75,6 +77,28 @@ const mockFetcher: Fetcher = <K extends OperationNames>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
list_machines: {
|
||||||
|
jon: {
|
||||||
|
name: "jon",
|
||||||
|
tags: ["all", "nixos", "tag1"],
|
||||||
|
},
|
||||||
|
sara: {
|
||||||
|
name: "sara",
|
||||||
|
tags: ["all", "darwin", "tag2"],
|
||||||
|
},
|
||||||
|
kyra: {
|
||||||
|
name: "kyra",
|
||||||
|
tags: ["all", "darwin", "tag2"],
|
||||||
|
},
|
||||||
|
leila: {
|
||||||
|
name: "leila",
|
||||||
|
tags: ["all", "darwin", "tag2"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list_tags: {
|
||||||
|
options: ["desktop", "server", "full", "only", "streaming", "backup"],
|
||||||
|
special: ["all", "nixos", "darwin"],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -138,3 +162,14 @@ type Story = StoryObj<typeof ServiceWorkflow>;
|
|||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
args: {},
|
args: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SelectRoleMembers: Story = {
|
||||||
|
render: () => (
|
||||||
|
<ServiceWorkflow
|
||||||
|
initialStep="select:members"
|
||||||
|
initialStore={{
|
||||||
|
currentRole: "peer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,14 +4,31 @@ import {
|
|||||||
StepperProvider,
|
StepperProvider,
|
||||||
useStepper,
|
useStepper,
|
||||||
} from "@/src/hooks/stepper";
|
} from "@/src/hooks/stepper";
|
||||||
import { BackButton, NextButton } from "../Steps";
|
|
||||||
import { useClanURI } from "@/src/hooks/clan";
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
import { ServiceModules, useServiceModules } from "@/src/hooks/queries";
|
import {
|
||||||
import { createEffect, createSignal } from "solid-js";
|
MachinesQuery,
|
||||||
|
ServiceModules,
|
||||||
|
TagsQuery,
|
||||||
|
useMachinesQuery,
|
||||||
|
useServiceModules,
|
||||||
|
useTags,
|
||||||
|
} from "@/src/hooks/queries";
|
||||||
|
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
|
||||||
import { Search } from "@/src/components/Search/Search";
|
import { Search } from "@/src/components/Search/Search";
|
||||||
import Icon from "@/src/components/Icon/Icon";
|
import Icon from "@/src/components/Icon/Icon";
|
||||||
import { Combobox } from "@kobalte/core/combobox";
|
import { Combobox } from "@kobalte/core/combobox";
|
||||||
import { Typography } from "@/src/components/Typography/Typography";
|
import { Typography } from "@/src/components/Typography/Typography";
|
||||||
|
import { Toolbar } from "@/src/components/Toolbar/Toolbar";
|
||||||
|
import { ToolbarButton } from "@/src/components/Toolbar/ToolbarButton";
|
||||||
|
import { TagSelect } from "@/src/components/Search/TagSelect";
|
||||||
|
import { Tag } from "@/src/components/Tag/Tag";
|
||||||
|
import { createForm, FieldValues, setValue } from "@modular-forms/solid";
|
||||||
|
import styles from "./Service.module.css";
|
||||||
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
|
import { Button } from "@/src/components/Button/Button";
|
||||||
|
import cx from "classnames";
|
||||||
|
import { BackButton } from "../Steps";
|
||||||
|
import { SearchMultiple } from "@/src/components/Search/MultipleSearch";
|
||||||
|
|
||||||
type ModuleItem = ServiceModules[number];
|
type ModuleItem = ServiceModules[number];
|
||||||
|
|
||||||
@@ -48,20 +65,21 @@ const SelectService = () => {
|
|||||||
return (
|
return (
|
||||||
<Search<Module>
|
<Search<Module>
|
||||||
loading={serviceModulesQuery.isLoading}
|
loading={serviceModulesQuery.isLoading}
|
||||||
|
height="13rem"
|
||||||
onChange={(module) => {
|
onChange={(module) => {
|
||||||
if (!module) return;
|
if (!module) return;
|
||||||
|
|
||||||
console.log("Module selected");
|
|
||||||
set("module", {
|
set("module", {
|
||||||
name: module.raw.module.name,
|
name: module.raw.module.name,
|
||||||
input: module.raw.module.input,
|
input: module.raw.module.input,
|
||||||
|
raw: module.raw,
|
||||||
});
|
});
|
||||||
stepper.next();
|
stepper.next();
|
||||||
}}
|
}}
|
||||||
options={moduleOptions()}
|
options={moduleOptions()}
|
||||||
renderItem={(item) => {
|
renderItem={(item) => {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1 pr-4">
|
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
|
||||||
<div class="flex size-8 items-center justify-center rounded-md bg-white">
|
<div class="flex size-8 items-center justify-center rounded-md bg-white">
|
||||||
<Icon icon="Code" />
|
<Icon icon="Code" />
|
||||||
</div>
|
</div>
|
||||||
@@ -90,14 +108,273 @@ const SelectService = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
|
||||||
|
createMemo<TagType[]>(() => {
|
||||||
|
const tags = tagsQuery.data;
|
||||||
|
const machines = machinesQuery.data;
|
||||||
|
if (!tags || !machines) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const machineOptions = Object.keys(machines).map((m) => ({
|
||||||
|
label: m,
|
||||||
|
value: "m_" + m,
|
||||||
|
type: "machine" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tagOptions = [...tags.options, ...tags.special].map((tag) => ({
|
||||||
|
type: "tag" as const,
|
||||||
|
label: tag,
|
||||||
|
value: "t_" + tag,
|
||||||
|
members: Object.entries(machines)
|
||||||
|
.filter(([_, v]) => v.tags?.includes(tag))
|
||||||
|
.map(([k]) => k),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...machineOptions, ...tagOptions].sort((a, b) =>
|
||||||
|
a.label.localeCompare(b.label),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RolesForm extends FieldValues {
|
||||||
|
roles: Record<string, string[]>;
|
||||||
|
instanceName: string;
|
||||||
|
}
|
||||||
|
const ConfigureService = () => {
|
||||||
|
const stepper = useStepper<ServiceSteps>();
|
||||||
|
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<RolesForm>({
|
||||||
|
// initialValues: props.initialValues,
|
||||||
|
initialValues: {
|
||||||
|
instanceName: "backup-instance-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const machinesQuery = useMachinesQuery(useClanURI());
|
||||||
|
const tagsQuery = useTags(useClanURI());
|
||||||
|
|
||||||
|
const options = useOptions(tagsQuery, machinesQuery);
|
||||||
|
|
||||||
|
const handleSubmit = (values: RolesForm) => {
|
||||||
|
console.log("Create service submitted with values:", values);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<div class={cx(styles.header, styles.backgroundAlt)}>
|
||||||
|
<div class="overflow-hidden rounded-sm">
|
||||||
|
<Icon icon="Services" size={36} inverted />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||||
|
{store.module.name}
|
||||||
|
</Typography>
|
||||||
|
<Field name="instanceName">
|
||||||
|
{(field, input) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
value={field.value}
|
||||||
|
size="s"
|
||||||
|
inverted
|
||||||
|
required
|
||||||
|
readOnly={true}
|
||||||
|
orientation="horizontal"
|
||||||
|
input={input}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Button icon="Close" color="primary" ghost size="s" class="ml-auto" />
|
||||||
|
</div>
|
||||||
|
<div class={styles.content}>
|
||||||
|
<For each={Object.keys(store.module.raw?.info.roles || {})}>
|
||||||
|
{(role) => {
|
||||||
|
const values = store.roles?.[role] || [];
|
||||||
|
console.log("Role members:", role, values, "from", options());
|
||||||
|
return (
|
||||||
|
<TagSelect<TagType>
|
||||||
|
label={role}
|
||||||
|
renderItem={(item: TagType) => (
|
||||||
|
<Tag
|
||||||
|
inverted
|
||||||
|
icon={(tag) => (
|
||||||
|
<Icon
|
||||||
|
icon={item.type === "machine" ? "Machine" : "Tag"}
|
||||||
|
size="0.5rem"
|
||||||
|
inverted={tag.inverted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
values={values}
|
||||||
|
options={options()}
|
||||||
|
onClick={() => {
|
||||||
|
set("currentRole", role);
|
||||||
|
stepper.next();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
||||||
|
<Button hierarchy="secondary">Add Service</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type TagType =
|
||||||
|
| {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
type: "machine";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
type: "tag";
|
||||||
|
members: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RoleMembers extends FieldValues {
|
||||||
|
members: string[];
|
||||||
|
}
|
||||||
|
const ConfigureRole = () => {
|
||||||
|
const stepper = useStepper<ServiceSteps>();
|
||||||
|
const [store, set] = getStepStore<ServiceStoreType>(stepper);
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<RoleMembers>({
|
||||||
|
initialValues: {
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const machinesQuery = useMachinesQuery(useClanURI());
|
||||||
|
const tagsQuery = useTags(useClanURI());
|
||||||
|
|
||||||
|
const options = useOptions(tagsQuery, machinesQuery);
|
||||||
|
|
||||||
|
const handleSubmit = (values: RoleMembers) => {
|
||||||
|
if (!store.currentRole) return;
|
||||||
|
|
||||||
|
const members: TagType[] = values.members.map(
|
||||||
|
(m) => options().find((o) => o.value === m)!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!store.roles) {
|
||||||
|
set("roles", {});
|
||||||
|
}
|
||||||
|
set("roles", (r) => ({ ...r, [store.currentRole as string]: members }));
|
||||||
|
console.log("Roles form submitted ", members);
|
||||||
|
|
||||||
|
stepper.setActiveStep("view:members");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<div class={cx(styles.backgroundAlt, "rounded-md")}>
|
||||||
|
<div class="flex w-full flex-col ">
|
||||||
|
<Field name="members" type="string[]">
|
||||||
|
{(field, input) => (
|
||||||
|
<SearchMultiple<TagType>
|
||||||
|
initialValues={store.roles?.[store.currentRole || ""] || []}
|
||||||
|
options={options()}
|
||||||
|
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
|
||||||
|
headerChildren={
|
||||||
|
<div class="flex w-full gap-2.5">
|
||||||
|
<BackButton ghost size="xs" hierarchy="primary" />
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="s"
|
||||||
|
weight="medium"
|
||||||
|
inverted
|
||||||
|
class="capitalize"
|
||||||
|
>
|
||||||
|
Select {store.currentRole}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placeholder={"Search for Machine or Tags"}
|
||||||
|
renderItem={(item, opts) => (
|
||||||
|
<div class={cx("flex w-full items-center gap-2 px-3 py-2")}>
|
||||||
|
<Combobox.ItemIndicator>
|
||||||
|
<Show
|
||||||
|
when={opts.selected}
|
||||||
|
fallback={<Icon icon="Code" />}
|
||||||
|
>
|
||||||
|
<Icon icon="Checkmark" color="primary" inverted />
|
||||||
|
</Show>
|
||||||
|
</Combobox.ItemIndicator>
|
||||||
|
<Combobox.ItemLabel class="flex items-center gap-2">
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="s"
|
||||||
|
weight="medium"
|
||||||
|
inverted
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Typography>
|
||||||
|
<Show when={item.type === "tag" && item}>
|
||||||
|
{(tag) => (
|
||||||
|
<Typography
|
||||||
|
hierarchy="body"
|
||||||
|
size="xs"
|
||||||
|
weight="medium"
|
||||||
|
inverted
|
||||||
|
color="secondary"
|
||||||
|
tag="div"
|
||||||
|
>
|
||||||
|
{tag().members.length}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Combobox.ItemLabel>
|
||||||
|
<Icon
|
||||||
|
class="ml-auto"
|
||||||
|
icon={item.type === "machine" ? "Machine" : "Tag"}
|
||||||
|
color="quaternary"
|
||||||
|
inverted
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
height="20rem"
|
||||||
|
virtualizerOptions={{
|
||||||
|
estimateSize: () => 38,
|
||||||
|
}}
|
||||||
|
onChange={(selection) => {
|
||||||
|
const newval = selection.map((s) => s.value);
|
||||||
|
setValue(formStore, field.name, newval);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div class={cx(styles.footer, styles.backgroundAlt)}>
|
||||||
|
<Button hierarchy="secondary" type="submit">
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
id: "select:service",
|
id: "select:service",
|
||||||
content: SelectService,
|
content: SelectService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "view:members",
|
||||||
|
content: ConfigureService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "select:members",
|
id: "select:members",
|
||||||
content: () => <div>Configure your service here.</div>,
|
content: ConfigureRole,
|
||||||
},
|
},
|
||||||
{ id: "settings", content: () => <div>Adjust settings here.</div> },
|
{ id: "settings", content: () => <div>Adjust settings here.</div> },
|
||||||
] as const;
|
] as const;
|
||||||
@@ -108,17 +385,50 @@ export interface ServiceStoreType {
|
|||||||
module: {
|
module: {
|
||||||
name: string;
|
name: string;
|
||||||
input: string;
|
input: string;
|
||||||
|
raw?: ModuleItem;
|
||||||
};
|
};
|
||||||
|
roles: Record<string, TagType[]>;
|
||||||
|
currentRole?: string;
|
||||||
|
close: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServiceWorkflow = () => {
|
interface ServiceWorkflowProps {
|
||||||
const stepper = createStepper({ steps }, { initialStep: "select:service" });
|
initialStep?: ServiceSteps[number]["id"];
|
||||||
|
initialStore?: Partial<ServiceStoreType>;
|
||||||
|
}
|
||||||
|
export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
|
||||||
|
const [show, setShow] = createSignal(false);
|
||||||
|
const stepper = createStepper(
|
||||||
|
{ steps },
|
||||||
|
{
|
||||||
|
initialStep: props.initialStep || "select:service",
|
||||||
|
initialStoreData: {
|
||||||
|
...props.initialStore,
|
||||||
|
close: () => setShow(false),
|
||||||
|
} satisfies Partial<ServiceStoreType>,
|
||||||
|
},
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<StepperProvider stepper={stepper}>
|
<>
|
||||||
<BackButton />
|
<div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 flex-col items-center">
|
||||||
{stepper.currentStep().content()}
|
<Show when={show()}>
|
||||||
<NextButton onClick={() => stepper.next()} />
|
<div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
|
||||||
</StepperProvider>
|
<StepperProvider stepper={stepper}>
|
||||||
|
<div class="w-[30rem]">{stepper.currentStep().content()}</div>
|
||||||
|
</StepperProvider>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="flex justify-center space-x-4">
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarButton
|
||||||
|
onClick={() => setShow(!show())}
|
||||||
|
description="Add new Service"
|
||||||
|
name="modules"
|
||||||
|
icon="Modules"
|
||||||
|
/>
|
||||||
|
</Toolbar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user