Finish implementing all prototype features
diff --git a/packages/sdk/src/conduit.ts b/packages/sdk/src/conduit.ts
index ac735b2..e223540 100644
--- a/packages/sdk/src/conduit.ts
+++ b/packages/sdk/src/conduit.ts
@@ -17,7 +17,7 @@
client: {
plugin: () => Promise<PluginType>;
panels: {
- [Property in keyof PluginType["panels"]]: (args: IrisPluginConduitPanelArgs<PluginType["panels"][Property]["requiredArgs"]>) => Promise<IrisPluginConduitPanelLayout<PluginType>>;
+ [Property in keyof PluginType["panels"]]: (args: IrisPluginConduitPanelArgs<PluginType["panels"][Property]["requiredArgs"]>, state: IrisPluginConduitPanelState<PluginType>) => Promise<IrisPluginConduitPanelLayout<PluginType>>;
};
forms: {
[Property in keyof PluginType["forms"]]: (data: IrisPluginConduitFieldData<PluginType["forms"][Property]["data"]>) => Promise<string>;
@@ -53,8 +53,19 @@
export type IrisPluginConduitPanelArgs<RequiredArgsType extends string[]> = {
[Property in RequiredArgsType[number]]: string;
-} & {
- [arg: string]: string;
+};
+
+export type IrisPluginConduitPanelState<PluginType extends IrisPluginConduit> = {
+ sections: {
+ [id: string]: IrisPluginConduitPanelSectionState<PluginType> | undefined;
+ };
+};
+
+export type IrisPluginConduitPanelSectionState<PluginType extends IrisPluginConduit> = {
+ table?: {
+ search?: string;
+ offset?: number;
+ };
};
export type IrisPluginConduitForm = {
@@ -83,15 +94,19 @@
};
});
-export type _IrisPluginConduitFieldDeepOptional<FieldSpec extends IrisPluginConduitField | undefined> = FieldSpec extends { type: "string" | "date" | "datetime" | "number" | "boolean" | "dropdown" } ? (
+export type _IrisPluginConduitFieldDeepOptional<FieldSpec extends IrisPluginConduitField | undefined, ArrayOfOptional extends boolean> = FieldSpec extends { type: "string" | "date" | "datetime" | "number" | "boolean" | "dropdown" } ? (
Omit<FieldSpec, "optional"> & { optional: true; }
) : FieldSpec extends { type: "array" } ? (
- Omit<FieldSpec, "of"> & { of: _IrisPluginConduitFieldDeepOptional<FieldSpec["of"]>; }
+ Omit<FieldSpec, "of" | "optional"> & {
+ of: ArrayOfOptional extends true ? _IrisPluginConduitFieldDeepOptional<FieldSpec["of"], ArrayOfOptional> : FieldSpec["of"];
+ optional: true;
+ }
) : FieldSpec extends { type: "object" } ? (
- Omit<FieldSpec, "struct"> & {
+ Omit<FieldSpec, "struct" | "optional"> & {
struct: {
- [Property in keyof FieldSpec["struct"]]: _IrisPluginConduitFieldDeepOptional<FieldSpec["struct"][Property]>;
+ [Property in keyof FieldSpec["struct"]]: _IrisPluginConduitFieldDeepOptional<FieldSpec["struct"][Property], ArrayOfOptional>;
};
+ optional: true;
}
) : FieldSpec extends undefined ? (
undefined
@@ -116,7 +131,7 @@
) : FieldSpec extends { type: "boolean" } ? (
boolean
) : FieldSpec extends { type: "dropdown" } ? (
- FieldSpec["options"][number]
+ FieldSpec["options"][number]["value"]
) : FieldSpec extends { type: "array" } ? (
IrisPluginConduitFieldData<FieldSpec["of"]>[]
) : FieldSpec extends { type: "object" } ? (
@@ -141,7 +156,7 @@
sections?: IrisPluginConduitPanelLayoutSection<PluginType>[];
};
-export type IrisPluginConduitPanelLayoutSection<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionProperties<PluginType> | IrisPluginConduitPanelLayoutSectionTable<PluginType> | IrisPluginConduitPanelLayoutSectionRef<PluginType>;
+export type IrisPluginConduitPanelLayoutSection<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionProperties<PluginType> | IrisPluginConduitPanelLayoutSectionTable<PluginType> | IrisPluginConduitPanelLayoutSectionPanel<PluginType> | IrisPluginConduitPanelLayoutSectionForm<PluginType>;
export type IrisPluginConduitPanelLayoutSectionCommon = {
id: string;
@@ -168,16 +183,25 @@
links?: {
[key: string]: IrisPluginConduitPanelRef<PluginType>;
};
+ forms?: IrisPluginConduitFormRef<PluginType>[];
}[];
- firstRow: number;
- totalRows: number;
+ canSearch?: boolean;
+ pagination?: {
+ rowsPerPage: number;
+ totalRows: number;
+ };
};
-export type IrisPluginConduitPanelLayoutSectionRef<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
- type: "ref";
+export type IrisPluginConduitPanelLayoutSectionPanel<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
+ type: "panel";
panel: IrisPluginConduitPanelRef<PluginType>;
};
+export type IrisPluginConduitPanelLayoutSectionForm<PluginType extends IrisPluginConduit> = IrisPluginConduitPanelLayoutSectionCommon & {
+ type: "form";
+ form: IrisPluginConduitFormRef<PluginType>;
+};
+
export type IrisPluginConduitPanelRef<PluginType extends IrisPluginConduit> = {
id: string;
args?: unknown;
@@ -186,6 +210,7 @@
export type IrisPluginConduitFormRef<PluginType extends IrisPluginConduit> = {
id: string;
data?: unknown;
+ defaults?: unknown;
};
export type IrisPluginConduitPanelLayoutField = {
@@ -198,8 +223,11 @@
* Helper utility for implementing an Iris Conduit server with strong type safety.
*/
export function createIrisConduitServerImpl<PluginType extends IrisPluginConduit>(): {
- panelRef: <PanelId extends keyof PluginType["panels"]>(id: PanelId, args?: Partial<IrisPluginConduitPanelArgs<PluginType["panels"][PanelId]["requiredArgs"]>>) => IrisPluginConduitPanelRef<PluginType>;
- formRef: <FormId extends keyof PluginType["forms"]>(id: FormId, args?: IrisPluginConduitFieldData<_IrisPluginConduitFieldDeepOptional<PluginType["forms"][FormId]["data"]>>) => IrisPluginConduitFormRef<PluginType>;
+ panelRef: <PanelId extends keyof PluginType["panels"]>(id: PanelId, args: IrisPluginConduitPanelArgs<PluginType["panels"][PanelId]["requiredArgs"]>) => IrisPluginConduitPanelRef<PluginType>;
+ formRef: <FormId extends keyof PluginType["forms"]>(id: FormId, data?: {
+ data?: IrisPluginConduitFieldData<_IrisPluginConduitFieldDeepOptional<PluginType["forms"][FormId]["data"], false>>;
+ defaults?: IrisPluginConduitFieldData<_IrisPluginConduitFieldDeepOptional<PluginType["forms"][FormId]["data"], true>>;
+ }) => IrisPluginConduitFormRef<PluginType>;
} {
return {
panelRef(id, args) {
@@ -208,10 +236,11 @@
args
}
},
- formRef(id, data) {
+ formRef(id, { data, defaults } = {}) {
return {
id: id as string,
- data
+ data,
+ defaults
}
}
};
diff --git a/packages/webui/src/app/app.css b/packages/webui/src/app/app.css
index f5f776b..8da88a0 100644
--- a/packages/webui/src/app/app.css
+++ b/packages/webui/src/app/app.css
@@ -2,10 +2,26 @@
@config "../../tailwind.config.js";
@source "../../../../node_modules/@ffxp/react-dom/lib";
-body, html {
+body,
+html {
height: 100%;
}
-button,[role=button] {
+button,
+[role=button] {
cursor: pointer
+}
+
+@keyframes loadingImgAnimation {
+ from {
+ opacity: 0.9;
+ }
+
+ to {
+ opacity: 0.6;
+ }
+}
+
+#loadingImg {
+ animation: loadingImgAnimation 0.35s ease-in alternate infinite;
}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/ErrorScreen.tsx b/packages/webui/src/app/components/ErrorScreen.tsx
new file mode 100644
index 0000000..d9e5cba
--- /dev/null
+++ b/packages/webui/src/app/components/ErrorScreen.tsx
@@ -0,0 +1,40 @@
+// Iris
+// Copyright (c) jottocraft 2025. All rights reserved.
+
+import { Icon } from "@ffxp/react-dom";
+import { YggdrasilStatus } from "@jottocraft/yggdrasil";
+
+import { useError } from "~/app/data/error";
+
+export function ErrorBox({ error, big }: { error: string | YggdrasilStatus<unknown>; big?: boolean; }) {
+ const message = error instanceof YggdrasilStatus ? error.statusMessage : error;
+ return (
+ <div className="p-4 roundness-xl bg-red-200 dark:bg-red-950 space-y-4">
+ <div className="flex flex-row items-center space-x-4">
+ <Icon className="text-2xl flex-shrink-0">error</Icon>
+ {error ? (
+ <div className="space-y-2">
+ {message ? <p className="text-lg font-medium">{message}</p> : null}
+ {error instanceof YggdrasilStatus ? <p className="text-type-30">Error {error.status} {error.statusCode}</p> : null}
+ </div>
+ ) : null}
+ </div>
+ {(error instanceof YggdrasilStatus) && error.statusCtx ? (
+ <pre className="text-type-60 text-xs font-mono">{JSON.stringify(error.statusCtx, undefined, 2)}</pre>
+ ) : null}
+ </div>
+ );
+}
+
+export default function ErrorScreen({ children }: { children?: React.ReactNode; }) {
+ const error = useError();
+ if (!error) return children ?? null;
+
+ return (
+ <div className="relative w-full h-full min-h-128 p-8">
+ <div className="w-full h-full roundness-3xl bg-linear-to-b from-red-500/5 to-ui-0 flex items-center justify-center">
+ <ErrorBox error={error} big />
+ </div>
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Form/Field.tsx b/packages/webui/src/app/components/Form/Field.tsx
index 122654c..75a60a2 100644
--- a/packages/webui/src/app/components/Form/Field.tsx
+++ b/packages/webui/src/app/components/Form/Field.tsx
@@ -4,40 +4,42 @@
import { Button, Dropdown, DropdownItem, Icon, Switch, TextInput } from "@ffxp/react-dom";
import type { IrisPluginConduitField } from "@jottocraft/iris-sdk";
-export function createDefaultFieldData(spec: IrisPluginConduitField, refData?: any, forceRequired?: boolean) {
- if (spec.optional && !forceRequired) return undefined;
+export function createInitialFieldData(spec: IrisPluginConduitField, refData?: any, refDefaults?: any, forceRequired?: boolean) {
+ const refInitialData = refData ?? refDefaults;
+
+ if (spec.optional && !forceRequired && !refInitialData) return undefined;
if (spec.type === "string") {
- return refData ?? "";
+ return refInitialData ?? "";
} else if (spec.type === "number") {
- return refData ?? 0;
+ return refInitialData ?? 0;
} else if (spec.type === "date") {
- return refData ?? new Date().toISOString().split("T")[0];
+ return refInitialData ?? new Date().toISOString().split("T")[0];
} else if (spec.type === "datetime") {
- return refData ?? new Date().toISOString();
+ return refInitialData ?? new Date().toISOString();
} else if (spec.type === "boolean") {
- return refData ?? false;
+ return refInitialData ?? false;
} else if (spec.type === "dropdown") {
- return refData ?? spec.options[0].value;
+ return refInitialData ?? spec.options[0].value;
} else if (spec.type === "array") {
- return refData ? refData.map((refItem: any) => createDefaultFieldData(spec.of, refItem)) : [];
+ return refInitialData ? refInitialData.map((refItem: any) => createInitialFieldData(spec.of, refData ? refItem : undefined, refData ? undefined : refItem, false)) : [];
} else if (spec.type === "object") {
- const defaultData: any = {};
+ const initialData: any = {};
for (const key in spec.struct) {
- defaultData[key] = createDefaultFieldData(spec.struct[key], refData ? refData[key] : undefined);
+ initialData[key] = createInitialFieldData(spec.struct[key], refData ? refData[key] : undefined, refDefaults ? refDefaults[key] : undefined, false);
}
- return defaultData;
+ return initialData;
}
}
-export default function Field({ path, data, refData, spec, patchFormData, controls }: { path: string; data: any; refData: any; spec: IrisPluginConduitField; patchFormData: (path: string, value: unknown) => void; controls?: React.ReactNode; }) {
+export default function Field({ path, data, refData, refDefaults, spec, patchFormData, controls }: { path: string; data: any; refData: any; refDefaults: any; spec: IrisPluginConduitField; patchFormData: (path: string, value: unknown) => void; controls?: React.ReactNode; }) {
let fieldInput: React.ReactNode = null;
if ((refData !== undefined) && (spec.type !== "object")) return null;
if (data === undefined) {
fieldInput = (
- <Button onClick={() => patchFormData(path, createDefaultFieldData(spec, undefined, true))} iconLeading="add">Set value</Button>
+ <Button onClick={() => patchFormData(path, createInitialFieldData(spec, undefined, undefined, true))} iconLeading="add">Set value</Button>
);
} else if ((spec.type === "string") || (spec.type === "number") || (spec.type === "date") || (spec.type === "datetime")) {
fieldInput = (
@@ -109,10 +111,10 @@
patchFormData(path, newValue);
}} iconLeading="delete" size="small" />
</div>
- )} path={`${path}[${i}]`} data={value} refData={refData !== undefined ? refData[i] : undefined} spec={spec.of} patchFormData={patchFormData} />
+ )} path={`${path}[${i}]`} data={value} refData={refData !== undefined ? refData[i] : undefined} refDefaults={refDefaults !== undefined ? refDefaults[i] : undefined} spec={spec.of} patchFormData={patchFormData} />
);
})}
- <Button iconLeading="add" onClick={() => patchFormData(path, [...data, createDefaultFieldData(spec.of)])}>Add item</Button>
+ <Button iconLeading="add" onClick={() => patchFormData(path, [...data, createInitialFieldData(spec.of)])}>Add item</Button>
</div>
);
} else if (spec.type === "object") {
@@ -120,7 +122,7 @@
<div className="space-y-6 border border-ui-20 p-4 roundness-xl">
{Object.keys(spec.struct).map(key => {
return (
- <Field path={`${path}.${key}`} data={data[key]} refData={refData !== undefined ? refData[key] : undefined} spec={spec.struct[key]} patchFormData={patchFormData} />
+ <Field path={`${path}.${key}`} data={data[key]} refData={refData !== undefined ? refData[key] : undefined} refDefaults={refDefaults !== undefined ? refDefaults[key] : undefined} spec={spec.struct[key]} patchFormData={patchFormData} />
);
})}
</div>
@@ -142,12 +144,14 @@
{spec.displayName ? <div>{spec.displayName}</div> : null}
</div>
<div className="flex flex-row items-center space-x-8">
- {spec.optional && (data !== undefined) ? (
- <Button onClick={() => patchFormData(path, undefined)} iconLeading="delete" size="small">Clear value</Button>
- ) : spec.optional ? (
- <div className="text-xs px-2 py-1 bg-ui-10 roundness">
- Optional
- </div>
+ {spec.optional && (refData === undefined) ? (
+ data !== undefined ? (
+ <Button onClick={() => patchFormData(path, undefined)} iconLeading="delete" size="small">Clear value</Button>
+ ) : (
+ <div className="text-xs px-2 py-1 bg-ui-10 roundness">
+ Optional
+ </div>
+ )
) : null}
{controls}
</div>
diff --git a/packages/webui/src/app/components/Form/index.tsx b/packages/webui/src/app/components/Form/index.tsx
index b323de0..0b54300 100644
--- a/packages/webui/src/app/components/Form/index.tsx
+++ b/packages/webui/src/app/components/Form/index.tsx
@@ -1,27 +1,27 @@
// Iris
// Copyright (c) jottocraft 2025
-import type { IrisPluginConduitField } from "@jottocraft/iris-sdk";
+import { YggdrasilStatus } from "@jottocraft/yggdrasil";
import { Button, Icon } from "@ffxp/react-dom";
import { useCallback, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import set from "lodash/set";
import clone from "lodash/clone";
-import Field, { createDefaultFieldData } from "~/app/components/Form/Field";
+import Field, { createInitialFieldData } from "~/app/components/Form/Field";
import { usePanelNavigation } from "~/app/data/panels";
import { usePlugins } from "~/app/data/root";
-import { YggdrasilStatus } from "@jottocraft/yggdrasil";
+import { ErrorBox } from "~/app/components/ErrorScreen";
-export default function Form({ ref, onFinished }: { ref: IrisWebuiFormRef; onFinished: () => void; }) {
+export default function Form({ inline, ref, onFinished }: { inline?: boolean; ref: IrisWebuiFormRef; onFinished: () => void; }) {
const plugins = usePlugins()!;
const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
const form = plugins[panelNavigation.pluginId].plugin.forms[ref.id];
- const defaultData = useMemo(() => form.data ? createDefaultFieldData(form.data, ref.data) : undefined, [form, ref]);
+ const initialData = useMemo(() => form.data ? createInitialFieldData(form.data, ref.data, ref.defaults, false) : undefined, [form, ref]);
const [formData, setFormData] = useState<{ data: unknown; }>({
- data: clone(defaultData)
+ data: clone(initialData)
});
const patchFormData = useCallback((path: string, value: unknown) => {
@@ -38,13 +38,51 @@
try {
const data = await plugins[panelNavigation.pluginId].client().forms[ref.id](formData.data as any);
setFormResult(data);
- } catch(e) {
+ } catch (e) {
if (!(e instanceof YggdrasilStatus)) throw e;
setFormResult(e);
}
})();
}, [panelNavigation, ref, formData]);
+ console.log(ref, initialData, formData);
+
+ const formJsx = (
+ <div className="space-y-8">
+ {formResult ? (
+ <>
+ <div>
+ {formResult instanceof YggdrasilStatus ? (
+ <ErrorBox error={formResult} />
+ ) : (
+ <p>{formResult}</p>
+ )}
+ </div>
+ <div>
+ <Button onClick={() => onFinished()}>Close</Button>
+ </div>
+ </>
+ ) : (
+ <>
+ <div>
+ {form.data ? (
+ <Field path="data" data={formData.data} refData={ref.data} refDefaults={ref.defaults} spec={form.data} patchFormData={patchFormData} />
+ ) : (
+ <p>This form doesn't take any input.</p>
+ )}
+ </div>
+ <div>
+ <Button onClick={submitForm} variant="primary">Submit</Button>
+ </div>
+ </>
+ )}
+ </div>
+ );
+
+ if (inline) {
+ return formJsx;
+ }
+
return createPortal((
<div className="w-full h-full py-10 absolute top-0 left-0 z-99 bg-black/90 flex items-center justify-center">
<div className="max-h-full overflow-auto w-2xl bg-ui-0 p-4 roundness-xl shadow-xl space-y-8">
@@ -57,25 +95,8 @@
<Button variant="borderless" onClick={() => onFinished()} iconLeading="close" size="small" />
</div>
</div>
- {formResult ? (
- <div>
- <p>{formResult instanceof YggdrasilStatus ? formResult.statusCode : formResult}</p>
- </div>
- ) : (
- <>
- <div>
- {form.data ? (
- <Field path="data" data={formData.data} refData={ref.data} spec={form.data} patchFormData={patchFormData} />
- ) : (
- <p>This form doesn't take any input.</p>
- )}
- </div>
- <div>
- <Button onClick={submitForm} variant="primary">Submit</Button>
- </div>
- </>
- )}
- </div >
+ {formJsx}
+ </div>
</div >
), document.body);
}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Loading.tsx b/packages/webui/src/app/components/Loading.tsx
new file mode 100644
index 0000000..cc40064
--- /dev/null
+++ b/packages/webui/src/app/components/Loading.tsx
@@ -0,0 +1,31 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import React, { useEffect, useState } from "react";
+
+function LoadingTextDots() {
+ let [numDots, setNumDots] = useState(0);
+ useEffect(() => {
+ const dotInt = setInterval(() => {
+ numDots++;
+ if (numDots > 3) numDots = 0;
+ setNumDots(numDots);
+ }, 500);
+ return () => clearInterval(dotInt);
+ }, []);
+
+ return <pre className="inline-block">{numDots === 1 ? ". " : numDots === 2 ? ".. " : numDots === 3 ? "..." : " "}</pre>;
+}
+
+export default function Loading({ children }: { children: string; }) {
+ return (
+ <div className="relative w-full h-full min-h-128 p-8">
+ <div className="w-full h-full roundness-3xl bg-linear-to-b from-ui-5 to-ui-0">
+ <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 w-32 h-32">
+ <img id="loadingImg" className="w-full h-full" src="/webui/assets/favicon.png" />
+ </div>
+ {children ? <div className="flex flex-row items-center font-medium text-2xl absolute top-3/4 left-1/2 -translate-x-1/2 z-30"><span>{children}</span><LoadingTextDots /></div> : null}
+ </div>
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Panel/index.tsx b/packages/webui/src/app/components/Panel/index.tsx
index bafc717..4a847d0 100644
--- a/packages/webui/src/app/components/Panel/index.tsx
+++ b/packages/webui/src/app/components/Panel/index.tsx
@@ -1,46 +1,69 @@
// Iris
// Copyright (c) jottocraft 2025
-import { Button, Icon } from "@ffxp/react-dom";
-import type { IrisPluginConduit, IrisPluginConduitPanelArgs, IrisPluginConduitPanelLayout } from "@jottocraft/iris-sdk";
+import { Button, Icon, Pivot } from "@ffxp/react-dom";
+import type { IrisPluginConduit, IrisPluginConduitPanelArgs, IrisPluginConduitPanelLayout, IrisPluginConduitPanelLayoutSection } from "@jottocraft/iris-sdk";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
+import { YggdrasilStatus } from "@jottocraft/yggdrasil";
import { usePlugins } from "~/app/data/root";
import PanelSection from "~/app/components/PanelSection";
import { usePanelNavigation } from "~/app/data/panels";
import Form from "~/app/components/Form";
+import Loading from "~/app/components/Loading";
+import { useError } from "~/app/data/error";
+import ErrorScreen from "~/app/components/ErrorScreen";
export default function Panel() {
const panelNavigation = usePanelNavigation()!;
const plugins = usePlugins()!;
const [activeForm, setActiveForm] = useState<IrisWebuiFormRef | null>(null);
+ const [selectedSection, setSelectedSection] = useState<string | null>(null);
+ const error = useError();
+
+ useEffect(() => {
+ setActiveForm(null);
+ setSelectedSection(null);
+ }, [panelNavigation.activeRef]);
+
+ if (error) {
+ return (
+ <ErrorScreen />
+ );
+ }
if (!panelNavigation.activeRef) {
- return (
- <p>Please select a panel to open from the sidebar.</p>
- );
+ return null;
}
if (!panelNavigation.layout) {
return (
- <p>Loading panel {panelNavigation.activeRef.id}...</p>
+ <Loading>
+ {`Loading panel ${panelNavigation.activeRef.id}`}
+ </Loading>
);
}
const plugin = plugins[panelNavigation.pluginId];
const layout = panelNavigation.layout;
+ const activeSection = layout.sections?.length === 1 ? layout.sections[0] : (layout.sections?.find(section => (section.id === selectedSection)) ?? null);
return (
<>
{activeForm ? (
- <Form ref={activeForm} onFinished={() => setActiveForm(null)} />
+ <Form ref={activeForm} onFinished={() => {
+ setActiveForm(null);
+ panelNavigation.refreshLayout();
+ }} />
) : null}
- <div className="space-y-8">
- <div className="flex flex-row items-center space-x-3 text-4xl font-semibold">
- {layout.icon ? <Icon>{layout.icon}</Icon> : null}
- <h1>{layout.displayName}</h1>
- </div>
+ <div className={panelNavigation.root ? "space-y-8" : "space-y-4"}>
+ {panelNavigation.root ? (
+ <div className="flex flex-row items-center space-x-3 text-4xl font-semibold">
+ {layout.icon ? <Icon>{layout.icon}</Icon> : null}
+ <h1>{layout.displayName}</h1>
+ </div>
+ ) : null}
{layout.forms ? (
<div>
{layout.forms?.map((ref) => {
@@ -48,32 +71,44 @@
return (
<div className="inline-block m-1">
- <Button onClick={() => setActiveForm(ref)} iconLeading={form.icon}>{form.displayName}</Button>
+ <Button size={panelNavigation.root ? "default" : "small"} onClick={() => setActiveForm(ref)} iconLeading={form.icon}>{form.displayName}</Button>
</div>
);
})}
</div>
) : null}
{layout.main ? (
- <div>
+ <div className="flex flex-row items-center flex-wrap">
{layout.main.map(item => (
- <div className="inline-block space-y-2 m-1 p-4 bg-ui-5 roundness-xl">
- <div className="flex flex-row items-center space-x-2 text-xl font-medium">
+ <div className={"min-h-24 m-1 bg-ui-5 " + (panelNavigation.root ? "p-4 space-y-2 roundness-xl" : "p-3 space-y-1 roundness-lg")}>
+ <div className={"flex flex-row items-center space-x-2 font-medium " + (panelNavigation.root ? "text-xl" : "text-lg")}>
{item.icon ? <Icon>{item.icon}</Icon> : null}
<div>{item.displayName}</div>
</div>
- <div>
- <div>{item.value}</div>
- </div>
+ <div className={panelNavigation.root ? "text-base" : "text-sm"}>{item.value}</div>
</div>
))}
</div>
) : null}
{layout.sections ? (
<div>
- {layout.sections.map(section => (
- <PanelSection section={section} />
- ))}
+ {layout.sections.length > 1 ? (
+ <Pivot tabs={layout.sections.map(section => ({
+ title: section.displayName,
+ icon: section.icon,
+ active: selectedSection === section.id,
+ onClick: () => {
+ if (selectedSection === section.id) {
+ setSelectedSection(null);
+ } else {
+ setSelectedSection(section.id);
+ }
+ }
+ }))} />
+ ) : null}
+ {activeSection ? (
+ <PanelSection section={activeSection} />
+ ) : null}
</div>
) : null}
</div>
diff --git a/packages/webui/src/app/components/PanelNavigator.tsx b/packages/webui/src/app/components/PanelNavigator.tsx
index 16bcb25..d42f820 100644
--- a/packages/webui/src/app/components/PanelNavigator.tsx
+++ b/packages/webui/src/app/components/PanelNavigator.tsx
@@ -1,33 +1,50 @@
// Iris
// Copyright (c) jottocraft 2025
-import type { IrisPluginConduit, IrisPluginConduitPanelLayout } from "@jottocraft/iris-sdk";
+import type { IrisPluginConduitPanelState, IrisPluginConduit, IrisPluginConduitPanelLayout, IrisPluginConduitPanelSectionState } from "@jottocraft/iris-sdk";
+import { YggdrasilStatus } from "@jottocraft/yggdrasil";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { panelNavigationCtx, panelRefHref } from "~/app/data/panels";
import { usePlugins } from "~/app/data/root";
+import { ErrorProvider } from "../data/error";
-export default function PanelNavigator({ children }: { children?: React.ReactNode; }) {
+export default function PanelNavigator({ root, initialPluginId, initialRef, children }: { initialPluginId?: string; initialRef?: IrisWebuiPanelRef; root: boolean; children?: React.ReactNode; }) {
const routerNavigate = useNavigate();
const plugins = usePlugins()!;
- const [pluginId, setPluginId] = useState<string | null>(null);
- const [activeRef, setActiveRef] = useState<IrisWebuiPanelRef | null>(null);
+ const [pluginId, setPluginId] = useState<string | null>(initialPluginId ?? null);
+ const [activeRef, setActiveRef] = useState<IrisWebuiPanelRef | null>(initialRef ?? null);
+ const [activeState, setActiveState] = useState<IrisPluginConduitPanelState<IrisPluginConduit> | null>(null);
const [layout, setLayout] = useState<IrisPluginConduitPanelLayout<IrisPluginConduit> | null>(null);
const [breadcrumbs, setBreadcrumbs] = useState<IrisWebuiPanelBreadcrumb[]>([]);
+ const [error, setError] = useState<null | string | YggdrasilStatus<unknown>>(null);
useEffect(() => {
- if (!activeRef) return;
+ if (!activeRef || !root) return;
routerNavigate(panelRefHref(pluginId!, activeRef));
- }, [pluginId, activeRef, routerNavigate]);
+ }, [pluginId, activeRef, routerNavigate, root]);
const fetchLayout = useCallback(() => {
+ setLayout(null);
+ setError(null);
+
(async () => {
- const data = await plugins[pluginId!].client().panels[activeRef!.id]((activeRef!.args ?? {}) as { [key: string]: string; });
- setLayout(data);
+ try {
+ if (!(pluginId! in plugins)) {
+ setError("Unknown plugin " + pluginId);
+ return;
+ }
+
+ const data = await plugins[pluginId!].client().panels[activeRef!.id]((activeRef!.args ?? {}) as { [key: string]: string; }, activeState!);
+ setLayout(data);
+ } catch (e) {
+ if (!(e instanceof YggdrasilStatus)) throw e;
+ setError(e);
+ }
})();
- }, [plugins, pluginId, activeRef]);
+ }, [plugins, pluginId, activeRef, activeState]);
useEffect(() => {
if (!activeRef) return;
@@ -38,12 +55,13 @@
const navigate = useCallback((ref: IrisWebuiPanelRef, e?: React.MouseEvent) => {
e?.preventDefault();
+ setActiveRef(ref);
+ setActiveState({ sections: {} });
setBreadcrumbs([...breadcrumbs, {
displayName: layout?.displayName ?? activeRef!.id,
icon: layout?.icon ?? undefined,
ref: activeRef!
}]);
- setActiveRef(ref);
}, [activeRef, layout, breadcrumbs]);
const back = useCallback((e?: React.MouseEvent) => {
@@ -51,6 +69,7 @@
if (!breadcrumbs.length) return;
setActiveRef(breadcrumbs[breadcrumbs.length - 1].ref);
+ setActiveState({ sections: {} });
setBreadcrumbs(breadcrumbs.slice(0, -1));
}, [breadcrumbs]);
@@ -59,36 +78,59 @@
if (!breadcrumbs.length) return;
setActiveRef(breadcrumbs[index].ref);
+ setActiveState({ sections: {} });
setBreadcrumbs(breadcrumbs.slice(0, index));
}, [breadcrumbs]);
const startNavigation = useCallback((pluginId: string, ref: IrisWebuiPanelRef, e?: React.MouseEvent) => {
setPluginId(pluginId);
+
setActiveRef(ref);
+ setActiveState({ sections: {} });
setBreadcrumbs([]);
}, []);
- const refreshLayout = useCallback(() => {
- setLayout(null);
- fetchLayout();
- }, [fetchLayout]);
+ useEffect(() => {
+ if (!initialPluginId || !initialRef) return;
+ startNavigation(initialPluginId, initialRef);
+ }, [initialPluginId, initialRef, startNavigation]);
+
+ const patchActiveSectionState = useCallback((sectionId: string, state: IrisPluginConduitPanelSectionState<IrisPluginConduit>) => {
+ setActiveState({
+ ...activeState,
+ sections: {
+ ...activeState?.sections,
+ [sectionId]: {
+ ...activeState?.sections?.[sectionId],
+ ...state
+ }
+ }
+ });
+ }, [activeState]);
return (
- <panelNavigationCtx.Provider value={activeRef ? {
- activeRef,
- layout,
- pluginId: pluginId!,
- breadcrumbs,
- navigate,
- back,
- goToBreadcrumb,
- startNavigation,
- refreshLayout
- } : {
- activeRef,
- startNavigation
- }}>
- {children}
- </panelNavigationCtx.Provider>
+ <ErrorProvider error={error}>
+ <panelNavigationCtx.Provider value={activeRef ? {
+ activeRef,
+ activeState: activeState!,
+ layout,
+ pluginId: pluginId!,
+ breadcrumbs,
+ navigate,
+ back,
+ goToBreadcrumb,
+ startNavigation,
+ refreshLayout: fetchLayout,
+ setActiveState,
+ patchActiveSectionState,
+ root
+ } : {
+ activeRef,
+ startNavigation,
+ root
+ }}>
+ {children}
+ </panelNavigationCtx.Provider>
+ </ErrorProvider>
);
}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionForm.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionForm.tsx
new file mode 100644
index 0000000..c851c5b
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionForm.tsx
@@ -0,0 +1,19 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionForm } from "@jottocraft/iris-sdk";
+
+import Form from "~/app/components/Form";
+import { usePanelNavigation } from "~/app/data/panels";
+
+export default function PanelSectionForm({ section }: { section: IrisPluginConduitPanelLayoutSectionForm<IrisPluginConduit>; }) {
+ const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
+
+ return (
+ <div className="py-8">
+ <Form inline ref={section.form} onFinished={() => {
+ panelNavigation.refreshLayout();
+ }} />
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionPanel.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionPanel.tsx
new file mode 100644
index 0000000..a998bdd
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionPanel.tsx
@@ -0,0 +1,33 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionPanel } from "@jottocraft/iris-sdk";
+import { useEffect } from "react";
+
+import { usePanelNavigation } from "~/app/data/panels";
+import PanelNavigator from "~/app/components/PanelNavigator";
+import Panel from "~/app/components/Panel";
+import PanelBreadcrumbs from "~/app/components/PanelBreadcrumbs";
+
+export default function PanelSectionPanel({ section }: { section: IrisPluginConduitPanelLayoutSectionPanel<IrisPluginConduit>; }) {
+ const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
+
+ useEffect(() => {
+ if (!panelNavigation.root) {
+ panelNavigation.navigate(section.panel);
+ }
+ }, [panelNavigation.root, section.panel]);
+
+ if (!panelNavigation.root) {
+ return null;
+ }
+
+ return (
+ <PanelNavigator root={false} initialPluginId={panelNavigation.pluginId} initialRef={section.panel}>
+ <div className="py-2 space-y-4">
+ <PanelBreadcrumbs />
+ <Panel />
+ </div>
+ </PanelNavigator>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx
index 1668d1c..4f05e9c 100644
--- a/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx
@@ -1,10 +1,29 @@
// Iris
// Copyright (c) jottocraft 2025
+import { Icon } from "@ffxp/react-dom";
import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionProperties } from "@jottocraft/iris-sdk";
export default function PanelSectionProperties({ section }: { section: IrisPluginConduitPanelLayoutSectionProperties<IrisPluginConduit>; }) {
return (
- <p>TODO</p>
+ <div className="w-full overflow-auto">
+ <table>
+ <tbody>
+ {section.properties.map(property => (
+ <tr>
+ <td className="p-2">
+ <div className="flex flex-row flex-shrink-0 items-center space-x-1 text-type-10 font-medium">
+ {property.icon ? <Icon>{property.icon}</Icon> : null}
+ <div className="whitespace-nowrap">{property.displayName}</div>
+ </div>
+ </td>
+ <td className="p-2">
+ <div className="text-sm text-type-20">{property.value}</div>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
);
}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx
deleted file mode 100644
index c889178..0000000
--- a/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionRef } from "@jottocraft/iris-sdk";
-
-export default function PanelSectionRef({ section }: { section: IrisPluginConduitPanelLayoutSectionRef<IrisPluginConduit>; }) {
- return (
- <p>TODO</p>
- );
-}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx b/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx
index 26229e4..50c40d1 100644
--- a/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx
@@ -1,46 +1,165 @@
// Iris
// Copyright (c) jottocraft 2025
+import { Button, Icon, Link, TextInput } from "@ffxp/react-dom";
import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionTable } from "@jottocraft/iris-sdk";
-import { Link, useParams } from "react-router";
+import { useEffect, useState } from "react";
+import { Link as RouterLink } from "react-router";
import { panelRefHref, usePanelNavigation } from "~/app/data/panels";
+import { usePlugins } from "~/app/data/root";
+import Form from "~/app/components/Form";
export default function PanelSectionTable({ section }: { section: IrisPluginConduitPanelLayoutSectionTable<IrisPluginConduit>; }) {
- const params = useParams();
const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
+ const plugins = usePlugins()!;
+ const [activeForm, setActiveForm] = useState<IrisWebuiFormRef | null>(null);
+
+ useEffect(() => {
+ setActiveForm(null);
+ }, [section]);
+
+ const plugin = plugins[panelNavigation.pluginId];
+
+ let pagination;
+ if (section.pagination && section.rows.length) {
+ const offset = panelNavigation.activeState.sections[section.id]?.table?.offset ?? 0;
+ const totalPages = Math.ceil(section.pagination.totalRows / section.pagination.rowsPerPage);
+ const activePage = (offset / section.pagination.rowsPerPage) + 1;
+ const firstVisiblePage = Math.max(activePage - 2, 1);
+ const lastVisiblePage = Math.min(activePage + 2, totalPages);
+ const visiblePages: number[] = [];
+ for (let i = firstVisiblePage; i <= lastVisiblePage; ++i) visiblePages.push(i);
+
+ pagination = {
+ firstRow: offset,
+ rowsPerPage: section.pagination.rowsPerPage,
+ totalRows: section.pagination.totalRows,
+ totalPages,
+ activePage,
+ visiblePages
+ };
+ } else {
+ pagination = null;
+ }
return (
- <div>
- <table>
- <thead>
- <tr>
- {section.columns.map(column => (
- <th>
- {column.displayName}
- </th>
- ))}
- </tr>
- </thead>
- <tbody>
- {section.rows.map(row => (
- <tr>
- {section.columns.map(column => {
- const link = row.links?.[column.key];
- return (
- <td>
- {link ? (
- <Link to={panelRefHref(panelNavigation.pluginId, link)} onClick={(e) => panelNavigation.navigate(link, e)}>{row.vals[column.key] ?? null}</Link>
- ) : (
- row.vals[column.key] ?? null
- )}
- </td>
- );
- })}
- </tr>
- ))}
- </tbody>
- </table>
- </div>
+ <>
+ {activeForm ? (
+ <Form ref={activeForm} onFinished={() => {
+ setActiveForm(null);
+ panelNavigation.refreshLayout();
+ }} />
+ ) : null}
+ <div className="space-y-4">
+ {section.canSearch ? (
+ <div className="flex flex-row items-center justify-end">
+ <TextInput defaultValue={panelNavigation.activeState.sections[section.id]?.table?.search ?? ""} onKeyDown={e => {
+ const search = e.currentTarget.value || undefined;
+ if (e.key === "Enter") {
+ panelNavigation.patchActiveSectionState(section.id, {
+ table: {
+ ...panelNavigation.activeState.sections[section.id]?.table,
+ search,
+ offset: 0
+ }
+ });
+ }
+ }} placeholder="Search" icon="search" />
+ </div>
+ ) : null}
+ <div className="w-full overflow-auto">
+ <table className="w-full">
+ <thead>
+ <tr className="border-b border-ui-30 text-left">
+ {section.columns.map(column => (
+ <th className="p-2 whitespace-nowrap font-medium">
+ <div className="flex flex-row items-center flex-nowrap space-x-1">
+ {column.icon ? <Icon>{column.icon}</Icon> : null}
+ <div>{column.displayName}</div>
+ </div>
+ </th>
+ ))}
+ </tr>
+ </thead>
+ <tbody>
+ {section.rows.map((row, i) => (
+ <tr className={i % 2 === 0 ? undefined : "bg-ui-5"}>
+ {section.columns.map(column => {
+ const link = row.links?.[column.key];
+ return (
+ <td className="p-2 whitespace-nowrap text-sm text-type-20">
+ {link ? (
+ <RouterLink to={panelRefHref(panelNavigation.pluginId, link)} onClick={(e) => panelNavigation.navigate(link, e)}>
+ <Link>{row.vals[column.key] ?? null}</Link>
+ </RouterLink>
+ ) : (
+ row.vals[column.key] ?? null
+ )}
+ </td>
+ );
+ })}
+ {row.forms ? (
+ <td>
+ {row.forms.map(ref => {
+ const form = plugin.plugin.forms[ref.id];
+
+ return (
+ <div className="inline-block m-1">
+ <Button size="small" onClick={() => setActiveForm(ref)} iconLeading={form.icon}>{form.displayName}</Button>
+ </div>
+ );
+ })}
+ </td>
+ ) : null}
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ {pagination ? (
+ <div className="flex flex-row items-center justify-between">
+ <div className="text-sm text-type-30">Showing rows {pagination.firstRow + 1}-{pagination.firstRow + section.rows.length} of {pagination.totalRows}</div>
+ <div className="flex flex-row items-center">
+ {(pagination.visiblePages[0] !== 1) ? (
+ <>
+ <Icon className="text-type-60">more_horiz</Icon>
+ <div className="px-1" />
+ </>
+ ) : null}
+ {pagination.visiblePages.map(page => (
+ <Button onClick={() => {
+ panelNavigation.patchActiveSectionState(section.id, {
+ table: {
+ ...panelNavigation.activeState.sections[section.id]?.table,
+ offset: (page - 1) * pagination.rowsPerPage
+ }
+ });
+ }} variant={page === pagination.activePage ? "primary" : "borderless"} size="small">{String(page)}</Button>
+ ))}
+ {(pagination.visiblePages[pagination.visiblePages.length - 1] !== pagination.totalPages) ? (
+ <>
+ <div className="px-1" />
+ <Icon className="text-type-60">more_horiz</Icon>
+ </>
+ ) : null}
+ <div className="px-4" />
+ <TextInput onKeyDown={e => {
+ const val = Number(e.currentTarget.value);
+ if ((e.key === "Enter") && !isNaN(val) && (val >= 1) && (val <= pagination.totalPages)) {
+ panelNavigation.patchActiveSectionState(section.id, {
+ table: {
+ ...panelNavigation.activeState.sections[section.id]?.table,
+ offset: (val - 1) * pagination.rowsPerPage
+ }
+ });
+ e.currentTarget.value = "";
+ }
+ }} type="number" placeholder="Jump to" width={100} />
+ </div>
+ </div>
+ ) : null}
+ </div>
+ </>
);
}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelSection/index.tsx b/packages/webui/src/app/components/PanelSection/index.tsx
index af483f0..f79aaae 100644
--- a/packages/webui/src/app/components/PanelSection/index.tsx
+++ b/packages/webui/src/app/components/PanelSection/index.tsx
@@ -5,7 +5,8 @@
import PanelSectionProperties from "~/app/components/PanelSection/PanelSectionProperties";
import PanelSectionTable from "~/app/components/PanelSection/PanelSectionTable";
-import PanelSectionRef from "~/app/components/PanelSection/PanelSectionRef";
+import PanelSectionPanel from "~/app/components/PanelSection/PanelSectionPanel";
+import PanelSectionForm from "~/app/components/PanelSection/PanelSectionForm";
export default function PanelSection({ section }: { section: IrisPluginConduitPanelLayoutSection<IrisPluginConduit>; }) {
if (section.type === "properties") {
@@ -16,9 +17,13 @@
return (
<PanelSectionTable section={section} />
);
- } else if (section.type === "ref") {
+ } else if (section.type === "panel") {
return (
- <PanelSectionRef section={section} />
+ <PanelSectionPanel section={section} />
+ );
+ } else if (section.type === "form") {
+ return (
+ <PanelSectionForm section={section} />
);
} else {
throw new Error("Unrecognized panel layout section type " + (section as any).type);
diff --git a/packages/webui/src/app/components/Shell.tsx b/packages/webui/src/app/components/Shell.tsx
index 62267db..f2e04ce 100644
--- a/packages/webui/src/app/components/Shell.tsx
+++ b/packages/webui/src/app/components/Shell.tsx
@@ -2,7 +2,7 @@
// Copyright (c) jottocraft 2025
import { Button, NavbarLayout, NavbarTitle, SidebarHeader, SidebarItem, SidebarItemGroup, SidebarItems, SidebarLayout, SidebarTitle, useThemeEngine } from "@ffxp/react-dom";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
import { Link, Outlet, useLocation, useParams, useSearchParams } from "react-router";
import IRIS_SITE from "~/IRIS_SITE";
@@ -10,26 +10,15 @@
import { isAtPanel, panelRefHref, usePanelNavigation } from "~/app/data/panels";
import PanelNavigator from "~/app/components/PanelNavigator";
import PanelBreadcrumbs from "~/app/components/PanelBreadcrumbs";
+import Loading from "~/app/components/Loading";
+import { useError } from "~/app/data/error";
+import ErrorScreen from "~/app/components/ErrorScreen";
function ShellLayout({ children }: { children?: React.ReactNode; }) {
const theme = useThemeEngine();
const plugins = usePlugins()!;
const auth = useAuth();
const panelNavigation = usePanelNavigation()!;
- const routerParams = useParams();
- const [searchParams] = useSearchParams();
-
- useEffect(() => {
- if (!panelNavigation.activeRef && (routerParams.pluginId && routerParams.panelId)) {
- const args: { [key: string]: string } = {};
- for (const [k, v] of searchParams.entries()) args[k] = v;
-
- panelNavigation.startNavigation(routerParams.pluginId, {
- id: routerParams.panelId,
- args
- });
- }
- }, [routerParams, searchParams, panelNavigation]);
return (
<NavbarLayout left={(
@@ -44,7 +33,7 @@
)}
right={(
<>
- <Button onClick={() => {
+ <Button variant="borderless" onClick={() => {
if (window.confirm("Do you want to sign out?")) {
window.localStorage.removeItem("jwt");
window.location.reload();
@@ -88,6 +77,34 @@
export default function Shell({ children }: { children?: React.ReactNode; }) {
const location = useLocation();
const plugins = usePlugins();
+ const error = useError();
+
+ const routerParams = useParams();
+ const [searchParams] = useSearchParams();
+
+ const { initialPluginId, initialRef } = useMemo((): {
+ initialPluginId?: string;
+ initialRef?: IrisWebuiPanelRef;
+ } => {
+ if (!routerParams.pluginId || !routerParams.panelId) return {};
+
+ const args: { [key: string]: string } = {};
+ for (const [k, v] of searchParams.entries()) args[k] = v;
+
+ return {
+ initialPluginId: routerParams.pluginId,
+ initialRef: {
+ id: routerParams.panelId,
+ args
+ }
+ }
+ }, []);
+
+ if (error) {
+ return (
+ <ErrorScreen />
+ );
+ }
if (location.pathname === "/login") {
return (
@@ -97,12 +114,14 @@
if (!plugins) {
return (
- <p>Loading plugins...</p>
+ <Loading>
+ Loading plugins
+ </Loading>
);
}
return (
- <PanelNavigator>
+ <PanelNavigator initialPluginId={initialPluginId} initialRef={initialRef} root={true}>
<ShellLayout>
{children}
</ShellLayout>
diff --git a/packages/webui/src/app/data/error.tsx b/packages/webui/src/app/data/error.tsx
new file mode 100644
index 0000000..94e06db
--- /dev/null
+++ b/packages/webui/src/app/data/error.tsx
@@ -0,0 +1,19 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { YggdrasilStatus } from "@jottocraft/yggdrasil";
+import { createContext, useContext } from "react";
+
+export const errorCtx = createContext<YggdrasilStatus<unknown> | string | null>(null);
+export function useError() {
+ return useContext(errorCtx);
+}
+
+export function ErrorProvider({ error, children }: { error?: YggdrasilStatus<unknown> | string | null; children?: React.ReactNode; }) {
+ if (!error) return children;
+ return (
+ <errorCtx.Provider value={error}>
+ {children}
+ </errorCtx.Provider>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/data/panels.ts b/packages/webui/src/app/data/panels.ts
index 26e99f0..1f03d36 100644
--- a/packages/webui/src/app/data/panels.ts
+++ b/packages/webui/src/app/data/panels.ts
@@ -14,7 +14,7 @@
if (ref.args) {
const linkParams = new URLSearchParams();
for (const arg in ref.args) {
- if (ref.args[arg]) linkParams.set(arg, ref.args[arg]);
+ if ((ref.args as { [key: string]: string; })[arg]) linkParams.set(arg, (ref.args as { [key: string]: string; })[arg]);
}
linkHref = linkHref + "?" + linkParams.toString();
}
diff --git a/packages/webui/src/app/root.tsx b/packages/webui/src/app/root.tsx
index c96602f..a157f47 100644
--- a/packages/webui/src/app/root.tsx
+++ b/packages/webui/src/app/root.tsx
@@ -3,7 +3,7 @@
import { BodyWrapper, Container, HeadWrapper, HTMLWrapper, Link, Root, ThemeEngine } from "@ffxp/react-dom";
import type { IrisAuthCtx, IrisPluginConduit, IrisPluginConduitConduitServerTypes, IrisPluginCtx } from "@jottocraft/iris-sdk";
-import { createConduitClientSideClient } from "@jottocraft/yggdrasil";
+import { createConduitClientSideClient, YggdrasilStatus } from "@jottocraft/yggdrasil";
import { useEffect, useState } from "react";
import {
isRouteErrorResponse,
@@ -20,6 +20,7 @@
import Shell from "~/app/components/Shell";
import { authContext, pluginContext } from "~/app/data/root";
import IRIS_SITE from "~/IRIS_SITE";
+import { ErrorProvider } from "./data/error";
export default function App() {
const location = useLocation();
@@ -67,6 +68,7 @@
}, [auth, location.pathname]);
const [plugins, setPlugins] = useState<null | IrisPluginCtx>(null);
+ const [error, setError] = useState<null | YggdrasilStatus<unknown>>(null);
useEffect(() => {
if (!auth) return;
@@ -91,7 +93,12 @@
}
})).then(() => {
setPlugins(plugins);
- }).catch(e => { throw e; });
+ setError(null);
+ }).catch(e => {
+ if (!(e instanceof YggdrasilStatus)) throw e;
+ setPlugins(null);
+ setError(e);
+ });
}, [auth]);
return (
@@ -132,18 +139,20 @@
<Links />
</HeadWrapper>
<BodyWrapper>
- <authContext.Provider value={auth}>
- <pluginContext.Provider value={plugins}>
- <Shell>
- <Outlet />
- </Shell>
- </pluginContext.Provider>
- </authContext.Provider>
+ <ErrorProvider error={error}>
+ <authContext.Provider value={auth}>
+ <pluginContext.Provider value={plugins}>
+ <Shell>
+ <Outlet />
+ </Shell>
+ </pluginContext.Provider>
+ </authContext.Provider>
+ </ErrorProvider>
<ScrollRestoration />
<Scripts />
</BodyWrapper>
</HTMLWrapper>
</ThemeEngine>
- </Root>
+ </Root >
);
}
\ No newline at end of file
diff --git a/packages/webui/src/app/routes/index.tsx b/packages/webui/src/app/routes/index.tsx
index 89f6ddd..85e5dd5 100644
--- a/packages/webui/src/app/routes/index.tsx
+++ b/packages/webui/src/app/routes/index.tsx
@@ -2,9 +2,5 @@
// Copyright (c) jottocraft 2025
export default function Home() {
- return (
- <div>
- <p>Hello world!</p>
- </div>
- );
+ return null;
}
\ No newline at end of file
diff --git a/packages/webui/src/app/routes/login.tsx b/packages/webui/src/app/routes/login.tsx
index f63ce03..79359ec 100644
--- a/packages/webui/src/app/routes/login.tsx
+++ b/packages/webui/src/app/routes/login.tsx
@@ -5,6 +5,9 @@
import { useEffect, useState } from "react";
import IRIS_SITE from "~/IRIS_SITE";
+import ErrorScreen from "~/app/components/ErrorScreen";
+import Loading from "~/app/components/Loading";
+import { ErrorProvider } from "~/app/data/error";
export default function Login() {
const [error, setError] = useState<null | string>(null);
@@ -54,15 +57,14 @@
}, []);
return (
- <div>
+ <>
{error ? (
- <div>
- <h1>Authentication error</h1>
- <p>{error}</p>
- </div>
+ <ErrorProvider error={"Authentication error: " + error}>
+ <ErrorScreen />
+ </ErrorProvider>
) : (
- <p>Signing in...</p>
+ <Loading>Signing in</Loading>
)}
- </div>
+ </>
);
}
\ No newline at end of file
diff --git a/packages/webui/src/app/routes/p/$.tsx b/packages/webui/src/app/routes/p/$.tsx
deleted file mode 100644
index 1837210..0000000
--- a/packages/webui/src/app/routes/p/$.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-// Iris
-// Copyright (c) jottocraft 2025
-
-export default function Home() {
- return (
- <div>
- <p>Hello plugin!</p>
- </div>
- );
-}
\ No newline at end of file
diff --git a/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx b/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx
index 76e2f75..fdcc298 100644
--- a/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx
+++ b/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx
@@ -8,7 +8,7 @@
export default function PanelRoute() {
return (
- <div className="py-12">
+ <div className="h-full py-12">
<Panel />
</div >
);
diff --git a/packages/webui/src/webui.d.ts b/packages/webui/src/webui.d.ts
index 869b1fc..d17ec39 100644
--- a/packages/webui/src/webui.d.ts
+++ b/packages/webui/src/webui.d.ts
@@ -17,11 +17,13 @@
declare type IrisWebuiPanelNavigationCtx = IrisWebuiPanelNavigationCtxActive | IrisWebuiPanelNavigationCtxInactive;
declare type IrisWebuiPanelNavigationCtxCommon = {
+ root: boolean;
startNavigation: (pluginId: string, ref: IrisWebuiPanelRef, e?: React.MouseEvent) => void;
};
declare type IrisWebuiPanelNavigationCtxActive = IrisWebuiPanelNavigationCtxCommon & {
activeRef: IrisWebuiPanelRef;
+ activeState: import("@jottocraft/iris-sdk").IrisPluginConduitPanelState<import("@jottocraft/iris-sdk").IrisPluginConduit>;
layout: null | import("@jottocraft/iris-sdk").IrisPluginConduitPanelLayout<import("@jottocraft/iris-sdk").IrisPluginConduit>;
pluginId: string;
breadcrumbs: IrisWebuiPanelBreadcrumb[];
@@ -29,6 +31,8 @@
back: (e?: React.MouseEvent) => void;
goToBreadcrumb: (index: number, e?: React.MouseEvent) => void;
refreshLayout: () => void;
+ setActiveState: (state: import("@jottocraft/iris-sdk").IrisPluginConduitPanelState<import("@jottocraft/iris-sdk").IrisPluginConduit>) => void;
+ patchActiveSectionState: (sectionId: string, state: import("@jottocraft/iris-sdk").IrisPluginConduitPanelSectionState<import("@jottocraft/iris-sdk").IrisPluginConduit>) => void;
};
declare type IrisWebuiPanelNavigationCtxInactive = IrisWebuiPanelNavigationCtxCommon & {