Restructure plugin API, implement basic minimal shell prototype
diff --git a/packages/webui/.gitignore b/packages/webui/.gitignore
index 36cd0db..7bd7f37 100644
--- a/packages/webui/.gitignore
+++ b/packages/webui/.gitignore
@@ -11,3 +11,4 @@
.wrangler
.dev.vars*
+src/IRIS_SITE.ts
\ No newline at end of file
diff --git a/packages/webui/package.json b/packages/webui/package.json
index b7f06b6..74f6b9e 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -13,7 +13,6 @@
"validate": "tsc --noEmit"
},
"dependencies": {
- "@jottocraft/iris-sdk": "workspace:*",
- "iris-plugins": "workspace:*"
+ "@jottocraft/iris-sdk": "workspace:*"
}
}
diff --git a/packages/webui/src/app/components/Form/Field.tsx b/packages/webui/src/app/components/Form/Field.tsx
new file mode 100644
index 0000000..122654c
--- /dev/null
+++ b/packages/webui/src/app/components/Form/Field.tsx
@@ -0,0 +1,162 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+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;
+
+ if (spec.type === "string") {
+ return refData ?? "";
+ } else if (spec.type === "number") {
+ return refData ?? 0;
+ } else if (spec.type === "date") {
+ return refData ?? new Date().toISOString().split("T")[0];
+ } else if (spec.type === "datetime") {
+ return refData ?? new Date().toISOString();
+ } else if (spec.type === "boolean") {
+ return refData ?? false;
+ } else if (spec.type === "dropdown") {
+ return refData ?? spec.options[0].value;
+ } else if (spec.type === "array") {
+ return refData ? refData.map((refItem: any) => createDefaultFieldData(spec.of, refItem)) : [];
+ } else if (spec.type === "object") {
+ const defaultData: any = {};
+ for (const key in spec.struct) {
+ defaultData[key] = createDefaultFieldData(spec.struct[key], refData ? refData[key] : undefined);
+ }
+ return defaultData;
+ }
+}
+
+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; }) {
+ 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>
+ );
+ } else if ((spec.type === "string") || (spec.type === "number") || (spec.type === "date") || (spec.type === "datetime")) {
+ fieldInput = (
+ <TextInput
+ onChange={e => {
+ if (spec.type === "string") {
+ patchFormData(path, e.target.value);
+ } else if (spec.type === "number") {
+ patchFormData(path, Number(e.target.value));
+ } else if (spec.type === "date") {
+ patchFormData(path, e.target.value);
+ } else if (spec.type === "datetime") {
+ patchFormData(path, e.target.value + "Z");
+ }
+ }}
+ value={spec.type === "datetime" ? new Date(data).toISOString().split(":").slice(0, -1).join(":") : data}
+ type={spec.type === "string" ? (
+ "text"
+ ) : spec.type === "number" ? (
+ "number"
+ ) : spec.type === "date" ? (
+ "date"
+ ) : spec.type === "datetime" ? (
+ "datetime-local"
+ ) : undefined}
+ />
+ );
+ } else if (spec.type === "boolean") {
+ fieldInput = (
+ <Switch onClick={() => patchFormData(path, !data)} active={data} />
+ );
+ } else if (spec.type === "dropdown") {
+ const selectedOption = spec.options.find(option => option.value === data)!;
+ fieldInput = (
+ <Dropdown menu={(
+ <>
+ {spec.options.map(option => (
+ <DropdownItem onClick={() => patchFormData(path, option.value)} icon={option.icon}>{option.label}</DropdownItem>
+ ))}
+ </>
+ )}>
+ <Button variant="outline" iconLeading={selectedOption.icon} iconTrailing="arrow_drop_down">{selectedOption.label}</Button>
+ </Dropdown>
+ );
+ } else if (spec.type === "array") {
+ fieldInput = (
+ <div className="space-y-6 border border-ui-20 p-4 roundness-xl">
+ {data.map((value: any, i: number) => {
+ return (
+ <Field controls={(
+ <div className="space-x-2">
+ <Button onClick={() => {
+ if (i === 0) return;
+ const newValue = [...data];
+ newValue.splice(i, 1);
+ newValue.splice(i - 1, 0, value);
+ patchFormData(path, newValue);
+ }} iconLeading="arrow_upward" size="small" />
+ <Button onClick={() => {
+ if (i === (data.length - 1)) return;
+ const newValue = [...data];
+ newValue.splice(i, 1);
+ newValue.splice(i + 1, 0, value);
+ patchFormData(path, newValue);
+ }} iconLeading="arrow_downward" size="small" />
+ <Button onClick={() => {
+ const newValue = [...data];
+ newValue.splice(i, 1);
+ patchFormData(path, newValue);
+ }} iconLeading="delete" size="small" />
+ </div>
+ )} path={`${path}[${i}]`} data={value} refData={refData !== undefined ? refData[i] : undefined} spec={spec.of} patchFormData={patchFormData} />
+ );
+ })}
+ <Button iconLeading="add" onClick={() => patchFormData(path, [...data, createDefaultFieldData(spec.of)])}>Add item</Button>
+ </div>
+ );
+ } else if (spec.type === "object") {
+ fieldInput = (
+ <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} />
+ );
+ })}
+ </div>
+ );
+ } else {
+ fieldInput = (
+ <div>
+ <p>Unrecognized field type {spec.type}</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="space-y-2">
+ {spec.icon || spec.displayName || spec.optional || controls ? (
+ <div className="flex flex-row items-center justify-between">
+ <div className="flex flex-row items-center space-x-2">
+ {spec.icon ? <Icon className="text-lg">{spec.icon}</Icon> : null}
+ {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>
+ ) : null}
+ {controls}
+ </div>
+ </div>
+ ) : null}
+ {fieldInput}
+ {spec.helpText ? (
+ <div className="text-type-30 text-xs">{spec.helpText}</div>
+ ) : null}
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Form/index.tsx b/packages/webui/src/app/components/Form/index.tsx
new file mode 100644
index 0000000..b323de0
--- /dev/null
+++ b/packages/webui/src/app/components/Form/index.tsx
@@ -0,0 +1,81 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduitField } from "@jottocraft/iris-sdk";
+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 { usePanelNavigation } from "~/app/data/panels";
+import { usePlugins } from "~/app/data/root";
+import { YggdrasilStatus } from "@jottocraft/yggdrasil";
+
+export default function Form({ ref, onFinished }: { 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 [formData, setFormData] = useState<{ data: unknown; }>({
+ data: clone(defaultData)
+ });
+
+ const patchFormData = useCallback((path: string, value: unknown) => {
+ set(formData, path, value);
+
+ setFormData({
+ data: formData.data
+ });
+ }, [formData]);
+
+ const [formResult, setFormResult] = useState<YggdrasilStatus<unknown> | string | null>(null);
+ const submitForm = useCallback(() => {
+ (async () => {
+ try {
+ const data = await plugins[panelNavigation.pluginId].client().forms[ref.id](formData.data as any);
+ setFormResult(data);
+ } catch(e) {
+ if (!(e instanceof YggdrasilStatus)) throw e;
+ setFormResult(e);
+ }
+ })();
+ }, [panelNavigation, ref, formData]);
+
+ 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">
+ <div className="flex flex-row items-center justify-between">
+ <div className="flex flex-row items-center text-xl space-x-2 font-medium">
+ {form.icon ? <Icon className="text-2xl">{form.icon}</Icon> : null}
+ <h1>{form.displayName}</h1>
+ </div>
+ <div>
+ <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 >
+ </div >
+ ), document.body);
+}
\ 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
new file mode 100644
index 0000000..bafc717
--- /dev/null
+++ b/packages/webui/src/app/components/Panel/index.tsx
@@ -0,0 +1,82 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, Icon } from "@ffxp/react-dom";
+import type { IrisPluginConduit, IrisPluginConduitPanelArgs, IrisPluginConduitPanelLayout } from "@jottocraft/iris-sdk";
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router";
+
+import { usePlugins } from "~/app/data/root";
+import PanelSection from "~/app/components/PanelSection";
+import { usePanelNavigation } from "~/app/data/panels";
+import Form from "~/app/components/Form";
+
+export default function Panel() {
+ const panelNavigation = usePanelNavigation()!;
+ const plugins = usePlugins()!;
+ const [activeForm, setActiveForm] = useState<IrisWebuiFormRef | null>(null);
+
+ if (!panelNavigation.activeRef) {
+ return (
+ <p>Please select a panel to open from the sidebar.</p>
+ );
+ }
+
+ if (!panelNavigation.layout) {
+ return (
+ <p>Loading panel {panelNavigation.activeRef.id}...</p>
+ );
+ }
+
+ const plugin = plugins[panelNavigation.pluginId];
+ const layout = panelNavigation.layout;
+
+ return (
+ <>
+ {activeForm ? (
+ <Form ref={activeForm} onFinished={() => setActiveForm(null)} />
+ ) : 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>
+ {layout.forms ? (
+ <div>
+ {layout.forms?.map((ref) => {
+ const form = plugin.plugin.forms[ref.id];
+
+ return (
+ <div className="inline-block m-1">
+ <Button onClick={() => setActiveForm(ref)} iconLeading={form.icon}>{form.displayName}</Button>
+ </div>
+ );
+ })}
+ </div>
+ ) : null}
+ {layout.main ? (
+ <div>
+ {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">
+ {item.icon ? <Icon>{item.icon}</Icon> : null}
+ <div>{item.displayName}</div>
+ </div>
+ <div>
+ <div>{item.value}</div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : null}
+ {layout.sections ? (
+ <div>
+ {layout.sections.map(section => (
+ <PanelSection section={section} />
+ ))}
+ </div>
+ ) : null}
+ </div>
+ </>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelBreadcrumbs.tsx b/packages/webui/src/app/components/PanelBreadcrumbs.tsx
new file mode 100644
index 0000000..1e824a2
--- /dev/null
+++ b/packages/webui/src/app/components/PanelBreadcrumbs.tsx
@@ -0,0 +1,38 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, Icon, Link } from "@ffxp/react-dom";
+import { Link as RouterLink } from "react-router";
+import { panelRefHref, usePanelNavigation } from "~/app/data/panels";
+
+export default function PanelBreadcrumbs() {
+ const panelNavigation = usePanelNavigation()!;
+
+ if (!panelNavigation.activeRef) return;
+
+ return (
+ <div className="flex-grow flex flex-row items-center border-ui-20 border-2 roundness-lg p-1">
+ <div className="mr-4 space-x-1">
+ <Button onClick={() => panelNavigation.back()} iconLeading="arrow_back" size="small" />
+ <Button onClick={() => panelNavigation.refreshLayout()} iconLeading="refresh" size="small" />
+ </div>
+ {panelNavigation.breadcrumbs.map((breadcrumb, i) => (
+ <div className="flex flex-row items-center space-x-1 text-sm text-type-20">
+ {breadcrumb.icon ? <Icon className="text-base">{breadcrumb.icon}</Icon> : null}
+ <div>
+ <RouterLink to={panelRefHref(panelNavigation.pluginId, breadcrumb.ref)} onClick={(e) => panelNavigation.goToBreadcrumb(i, e)}>
+ <Link>{breadcrumb.displayName}</Link>
+ </RouterLink>
+ </div>
+ <Icon className="text-xl mx-1">keyboard_arrow_right</Icon>
+ </div>
+ ))}
+ {panelNavigation.layout ? (
+ <div className="flex flex-row items-center space-x-1 text-base text-type-0 mr-2">
+ {panelNavigation.layout.icon ? <Icon className="text-lg">{panelNavigation.layout.icon}</Icon> : null}
+ <div className="font-medium">{panelNavigation.layout.displayName}</div>
+ </div>
+ ) : null}
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/PanelNavigator.tsx b/packages/webui/src/app/components/PanelNavigator.tsx
new file mode 100644
index 0000000..16bcb25
--- /dev/null
+++ b/packages/webui/src/app/components/PanelNavigator.tsx
@@ -0,0 +1,94 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayout } from "@jottocraft/iris-sdk";
+import { useCallback, useEffect, useState } from "react";
+import { useNavigate } from "react-router";
+
+import { panelNavigationCtx, panelRefHref } from "~/app/data/panels";
+import { usePlugins } from "~/app/data/root";
+
+export default function PanelNavigator({ children }: { children?: React.ReactNode; }) {
+ const routerNavigate = useNavigate();
+ const plugins = usePlugins()!;
+
+ const [pluginId, setPluginId] = useState<string | null>(null);
+ const [activeRef, setActiveRef] = useState<IrisWebuiPanelRef | null>(null);
+ const [layout, setLayout] = useState<IrisPluginConduitPanelLayout<IrisPluginConduit> | null>(null);
+ const [breadcrumbs, setBreadcrumbs] = useState<IrisWebuiPanelBreadcrumb[]>([]);
+
+ useEffect(() => {
+ if (!activeRef) return;
+ routerNavigate(panelRefHref(pluginId!, activeRef));
+ }, [pluginId, activeRef, routerNavigate]);
+
+ const fetchLayout = useCallback(() => {
+ (async () => {
+ const data = await plugins[pluginId!].client().panels[activeRef!.id]((activeRef!.args ?? {}) as { [key: string]: string; });
+ setLayout(data);
+ })();
+ }, [plugins, pluginId, activeRef]);
+
+ useEffect(() => {
+ if (!activeRef) return;
+
+ fetchLayout();
+ }, [activeRef, fetchLayout]);
+
+ const navigate = useCallback((ref: IrisWebuiPanelRef, e?: React.MouseEvent) => {
+ e?.preventDefault();
+
+ setBreadcrumbs([...breadcrumbs, {
+ displayName: layout?.displayName ?? activeRef!.id,
+ icon: layout?.icon ?? undefined,
+ ref: activeRef!
+ }]);
+ setActiveRef(ref);
+ }, [activeRef, layout, breadcrumbs]);
+
+ const back = useCallback((e?: React.MouseEvent) => {
+ e?.preventDefault();
+ if (!breadcrumbs.length) return;
+
+ setActiveRef(breadcrumbs[breadcrumbs.length - 1].ref);
+ setBreadcrumbs(breadcrumbs.slice(0, -1));
+ }, [breadcrumbs]);
+
+ const goToBreadcrumb = useCallback((index: number, e?: React.MouseEvent) => {
+ e?.preventDefault();
+ if (!breadcrumbs.length) return;
+
+ setActiveRef(breadcrumbs[index].ref);
+ setBreadcrumbs(breadcrumbs.slice(0, index));
+ }, [breadcrumbs]);
+
+ const startNavigation = useCallback((pluginId: string, ref: IrisWebuiPanelRef, e?: React.MouseEvent) => {
+ setPluginId(pluginId);
+ setActiveRef(ref);
+ setBreadcrumbs([]);
+ }, []);
+
+ const refreshLayout = useCallback(() => {
+ setLayout(null);
+ fetchLayout();
+ }, [fetchLayout]);
+
+ return (
+ <panelNavigationCtx.Provider value={activeRef ? {
+ activeRef,
+ layout,
+ pluginId: pluginId!,
+ breadcrumbs,
+ navigate,
+ back,
+ goToBreadcrumb,
+ startNavigation,
+ refreshLayout
+ } : {
+ activeRef,
+ startNavigation
+ }}>
+ {children}
+ </panelNavigationCtx.Provider>
+ );
+}
\ 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
new file mode 100644
index 0000000..1668d1c
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionProperties.tsx
@@ -0,0 +1,10 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionProperties } from "@jottocraft/iris-sdk";
+
+export default function PanelSectionProperties({ section }: { section: IrisPluginConduitPanelLayoutSectionProperties<IrisPluginConduit>; }) {
+ return (
+ <p>TODO</p>
+ );
+}
\ 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
new file mode 100644
index 0000000..c889178
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionRef.tsx
@@ -0,0 +1,10 @@
+// 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
new file mode 100644
index 0000000..26229e4
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/PanelSectionTable.tsx
@@ -0,0 +1,46 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSectionTable } from "@jottocraft/iris-sdk";
+import { Link, useParams } from "react-router";
+
+import { panelRefHref, usePanelNavigation } from "~/app/data/panels";
+
+export default function PanelSectionTable({ section }: { section: IrisPluginConduitPanelLayoutSectionTable<IrisPluginConduit>; }) {
+ const params = useParams();
+ const panelNavigation = usePanelNavigation() as IrisWebuiPanelNavigationCtxActive;
+
+ 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>
+ );
+}
\ 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
new file mode 100644
index 0000000..af483f0
--- /dev/null
+++ b/packages/webui/src/app/components/PanelSection/index.tsx
@@ -0,0 +1,26 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisPluginConduit, IrisPluginConduitPanelLayoutSection } from "@jottocraft/iris-sdk";
+
+import PanelSectionProperties from "~/app/components/PanelSection/PanelSectionProperties";
+import PanelSectionTable from "~/app/components/PanelSection/PanelSectionTable";
+import PanelSectionRef from "~/app/components/PanelSection/PanelSectionRef";
+
+export default function PanelSection({ section }: { section: IrisPluginConduitPanelLayoutSection<IrisPluginConduit>; }) {
+ if (section.type === "properties") {
+ return (
+ <PanelSectionProperties section={section} />
+ );
+ } else if (section.type === "table") {
+ return (
+ <PanelSectionTable section={section} />
+ );
+ } else if (section.type === "ref") {
+ return (
+ <PanelSectionRef section={section} />
+ );
+ } else {
+ throw new Error("Unrecognized panel layout section type " + (section as any).type);
+ }
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/components/Shell.tsx b/packages/webui/src/app/components/Shell.tsx
new file mode 100644
index 0000000..62267db
--- /dev/null
+++ b/packages/webui/src/app/components/Shell.tsx
@@ -0,0 +1,111 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { Button, NavbarLayout, NavbarTitle, SidebarHeader, SidebarItem, SidebarItemGroup, SidebarItems, SidebarLayout, SidebarTitle, useThemeEngine } from "@ffxp/react-dom";
+import { useEffect } from "react";
+import { Link, Outlet, useLocation, useParams, useSearchParams } from "react-router";
+
+import IRIS_SITE from "~/IRIS_SITE";
+import { useAuth, usePlugins } from "~/app/data/root";
+import { isAtPanel, panelRefHref, usePanelNavigation } from "~/app/data/panels";
+import PanelNavigator from "~/app/components/PanelNavigator";
+import PanelBreadcrumbs from "~/app/components/PanelBreadcrumbs";
+
+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={(
+ <div className="flex-grow flex flex-row items-center space-x-8">
+ <Link to="/">
+ <NavbarTitle logo="/webui/assets/favicon.png">
+ Iris
+ </NavbarTitle>
+ </Link>
+ <PanelBreadcrumbs />
+ </div>
+ )}
+ right={(
+ <>
+ <Button onClick={() => {
+ if (window.confirm("Do you want to sign out?")) {
+ window.localStorage.removeItem("jwt");
+ window.location.reload();
+ }
+ }}>
+ Signed in as {auth?.claims.email as string}
+ </Button>
+ </>
+ )}>
+ <SidebarLayout sidebar={(
+ <>
+ <SidebarHeader>
+ {IRIS_SITE.brand.logo ? (
+ <img className="w-full h-10 object-contain" src={theme.config.colors.dark ? IRIS_SITE.brand.logo.light : IRIS_SITE.brand.logo.dark} />
+ ) : (
+ <SidebarTitle>{IRIS_SITE.brand.name}</SidebarTitle>
+ )}
+ </SidebarHeader>
+ <SidebarItems>
+ {IRIS_SITE.plugins.map(({ id }) => {
+ const plugin = plugins[id];
+ return (
+ <SidebarItemGroup title={plugin.plugin.displayName} icon={plugin.plugin.icon}>
+ {plugin.plugin.navigation.map(item => (
+ <Link to={panelRefHref(id, item.panel)} onClick={e => panelNavigation.startNavigation(id, item.panel, e)}>
+ <SidebarItem active={(panelNavigation.activeRef && (panelNavigation.pluginId === id)) ? isAtPanel(panelNavigation.breadcrumbs[0]?.ref ?? panelNavigation.activeRef, item.panel) : false} icon={item.icon}>{item.displayName}</SidebarItem>
+ </Link>
+ ))}
+ </SidebarItemGroup>
+ );
+ })}
+ </SidebarItems>
+ </>
+ )}>
+ {children}
+ </SidebarLayout>
+ </NavbarLayout>
+ );
+}
+
+export default function Shell({ children }: { children?: React.ReactNode; }) {
+ const location = useLocation();
+ const plugins = usePlugins();
+
+ if (location.pathname === "/login") {
+ return (
+ <Outlet />
+ );
+ }
+
+ if (!plugins) {
+ return (
+ <p>Loading plugins...</p>
+ );
+ }
+
+ return (
+ <PanelNavigator>
+ <ShellLayout>
+ {children}
+ </ShellLayout>
+ </PanelNavigator>
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/data/panels.ts b/packages/webui/src/app/data/panels.ts
new file mode 100644
index 0000000..26e99f0
--- /dev/null
+++ b/packages/webui/src/app/data/panels.ts
@@ -0,0 +1,28 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { createContext, useContext } from "react";
+import isEqual from "lodash/isEqual";
+
+export const panelNavigationCtx = createContext<IrisWebuiPanelNavigationCtx | null>(null);
+export function usePanelNavigation() {
+ return useContext(panelNavigationCtx);
+}
+
+export function panelRefHref(pluginId: string, ref: IrisWebuiPanelRef) {
+ let linkHref: string = `/p/${pluginId}/${ref.id}`;
+ if (ref.args) {
+ const linkParams = new URLSearchParams();
+ for (const arg in ref.args) {
+ if (ref.args[arg]) linkParams.set(arg, ref.args[arg]);
+ }
+ linkHref = linkHref + "?" + linkParams.toString();
+ }
+
+ return linkHref;
+}
+
+export function isAtPanel(activeRef: IrisWebuiPanelRef, desiredRef: IrisWebuiPanelRef) {
+ if (activeRef.id !== desiredRef.id) return false;
+ return isEqual(activeRef.args ?? {}, desiredRef.args ?? {});
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/data/root.ts b/packages/webui/src/app/data/root.ts
new file mode 100644
index 0000000..0697087
--- /dev/null
+++ b/packages/webui/src/app/data/root.ts
@@ -0,0 +1,15 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { IrisAuthCtx, IrisPluginCtx } from "@jottocraft/iris-sdk";
+import { createContext, useContext } from "react";
+
+export const pluginContext = createContext<IrisPluginCtx | null>(null);
+export function usePlugins() {
+ return useContext(pluginContext);
+}
+
+export const authContext = createContext<IrisAuthCtx | null>(null);
+export function useAuth() {
+ return useContext(authContext);
+}
\ No newline at end of file
diff --git a/packages/webui/src/app/root.tsx b/packages/webui/src/app/root.tsx
index abab211..c96602f 100644
--- a/packages/webui/src/app/root.tsx
+++ b/packages/webui/src/app/root.tsx
@@ -2,6 +2,9 @@
// Copyright (c) jottocraft 2025
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 { useEffect, useState } from "react";
import {
isRouteErrorResponse,
Links,
@@ -9,12 +12,88 @@
Scripts,
ScrollRestoration,
useLoaderData,
+ useLocation,
type LoaderFunction,
} from "react-router";
import "~/app/app.css";
+import Shell from "~/app/components/Shell";
+import { authContext, pluginContext } from "~/app/data/root";
+import IRIS_SITE from "~/IRIS_SITE";
export default function App() {
+ const location = useLocation();
+
+ const [auth, setAuth] = useState<IrisAuthCtx | null>(null);
+ useEffect(() => {
+ if (location.pathname === "/login") {
+ return;
+ }
+
+ if (!auth) {
+ let jwt = window.localStorage.getItem("jwt");
+ if (jwt) {
+ const jwtData = JSON.parse(atob(jwt.split(".")[1]));
+ if (!jwtData.exp || (new Date().getTime() > (jwtData.exp * 1000))) {
+ jwt = null;
+ } else {
+ setAuth({
+ jwt,
+ claims: jwtData
+ });
+ }
+ }
+
+ if (!jwt) {
+ const redirectUrl = new URL(window.location.origin);
+ redirectUrl.pathname = "/webui/login";
+
+ const nonce = crypto.randomUUID();
+ window.localStorage.setItem("nonce", JSON.stringify({
+ nonce,
+ // Expires in 30 minutes
+ useBefore: new Date().getTime() + 30 * 60 * 1000
+ } satisfies IrisWebuiNonceStorage));
+
+ const authUrl = new URL(IRIS_SITE.auth.authUrl);
+ authUrl.searchParams.set("client_id", IRIS_SITE.auth.clientId);
+ authUrl.searchParams.set("redirect_uri", redirectUrl.href);
+ authUrl.searchParams.set("response_type", "id_token");
+ authUrl.searchParams.set("scope", IRIS_SITE.auth.scope ?? "openid email profile");
+ authUrl.searchParams.set("nonce", nonce);
+ window.location.href = authUrl.href;
+ }
+ }
+ }, [auth, location.pathname]);
+
+ const [plugins, setPlugins] = useState<null | IrisPluginCtx>(null);
+ useEffect(() => {
+ if (!auth) return;
+
+ const plugins: IrisPluginCtx = {};
+ Promise.all(IRIS_SITE.plugins.map(async (sitePlugin) => {
+ if (sitePlugin.type === "conduit") {
+ const client = createConduitClientSideClient<IrisPluginConduitConduitServerTypes<IrisPluginConduit>>({
+ jwt: auth.jwt
+ }, {
+ baseURL: sitePlugin.baseUrl
+ });
+
+ const plugin = await client().plugin();
+
+ plugins[sitePlugin.id] = {
+ type: "conduit",
+ plugin,
+ client
+ };
+ } else {
+ throw new Error("Unexpected plugin type " + sitePlugin.type);
+ }
+ })).then(() => {
+ setPlugins(plugins);
+ }).catch(e => { throw e; });
+ }, [auth]);
+
return (
<Root>
<ThemeEngine plugins={[
@@ -53,7 +132,13 @@
<Links />
</HeadWrapper>
<BodyWrapper>
- <Outlet />
+ <authContext.Provider value={auth}>
+ <pluginContext.Provider value={plugins}>
+ <Shell>
+ <Outlet />
+ </Shell>
+ </pluginContext.Provider>
+ </authContext.Provider>
<ScrollRestoration />
<Scripts />
</BodyWrapper>
diff --git a/packages/webui/src/app/routes/login.tsx b/packages/webui/src/app/routes/login.tsx
new file mode 100644
index 0000000..f63ce03
--- /dev/null
+++ b/packages/webui/src/app/routes/login.tsx
@@ -0,0 +1,68 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import type { JWTPayload } from "jose";
+import { useEffect, useState } from "react";
+
+import IRIS_SITE from "~/IRIS_SITE";
+
+export default function Login() {
+ const [error, setError] = useState<null | string>(null);
+ useEffect(() => {
+ const nonce = window.localStorage.getItem("nonce");
+ if (!nonce) {
+ setError("Missing auth state");
+ return;
+ }
+
+ const nonceData = JSON.parse(nonce) as IrisWebuiNonceStorage;
+ if (new Date().getTime() > nonceData.useBefore) {
+ setError("Auth state has expired");
+ return;
+ }
+
+ if (!window.location.hash.slice(1)) {
+ setError("Missing auth parameters");
+ return;
+ }
+
+ const hashParams = new URLSearchParams(window.location.hash.slice(1));
+ const jwt = hashParams.get("id_token");
+ if (!jwt) {
+ setError("Missing required id_token parameter");
+ return;
+ }
+
+ const jwtData = JSON.parse(atob(jwt.split(".")[1])) as JWTPayload;
+ if ((jwtData.aud !== IRIS_SITE.auth.clientId) ||
+ (jwtData.iss !== IRIS_SITE.auth.iss) ||
+ !jwtData.nbf ||
+ ((jwtData.nbf * 1000) > new Date().getTime()) ||
+ !jwtData.exp ||
+ ((jwtData.exp * 1000) < new Date().getTime()) ||
+ (jwtData.nonce !== nonceData.nonce)) {
+ setError("Invalid or expired id_token");
+ return;
+ }
+
+ const webuiUrl = new URL(window.location.origin);
+ webuiUrl.pathname = "/webui";
+
+ window.localStorage.removeItem("nonce");
+ window.localStorage.setItem("jwt", jwt);
+ window.location.href = webuiUrl.href;
+ }, []);
+
+ return (
+ <div>
+ {error ? (
+ <div>
+ <h1>Authentication error</h1>
+ <p>{error}</p>
+ </div>
+ ) : (
+ <p>Signing in...</p>
+ )}
+ </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
new file mode 100644
index 0000000..1837210
--- /dev/null
+++ b/packages/webui/src/app/routes/p/$.tsx
@@ -0,0 +1,10 @@
+// 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
new file mode 100644
index 0000000..76e2f75
--- /dev/null
+++ b/packages/webui/src/app/routes/p/$pluginId/$panelId.tsx
@@ -0,0 +1,15 @@
+// Iris
+// Copyright (c) jottocraft 2025
+
+import { useState } from "react";
+import { useParams } from "react-router";
+
+import Panel from "~/app/components/Panel";
+
+export default function PanelRoute() {
+ return (
+ <div className="py-12">
+ <Panel />
+ </div >
+ );
+}
\ No newline at end of file
diff --git a/packages/webui/src/webui.d.ts b/packages/webui/src/webui.d.ts
index dc05a7e..869b1fc 100644
--- a/packages/webui/src/webui.d.ts
+++ b/packages/webui/src/webui.d.ts
@@ -1,2 +1,42 @@
// Iris
-// Copyright (c) jottocraft 2025
\ No newline at end of file
+// Copyright (c) jottocraft 2025
+
+declare type IrisWebuiConfig = {
+ origin: string;
+ site: import("@jottocraft/iris-sdk").IrisSiteConfig;
+};
+
+declare type IrisWebuiNonceStorage = {
+ nonce: string;
+ useBefore: number;
+};
+
+declare type IrisWebuiPanelRef = import("@jottocraft/iris-sdk").IrisPluginConduitPanelRef<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+declare type IrisWebuiFormRef = import("@jottocraft/iris-sdk").IrisPluginConduitFormRef<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+
+declare type IrisWebuiPanelNavigationCtx = IrisWebuiPanelNavigationCtxActive | IrisWebuiPanelNavigationCtxInactive;
+
+declare type IrisWebuiPanelNavigationCtxCommon = {
+ startNavigation: (pluginId: string, ref: IrisWebuiPanelRef, e?: React.MouseEvent) => void;
+};
+
+declare type IrisWebuiPanelNavigationCtxActive = IrisWebuiPanelNavigationCtxCommon & {
+ activeRef: IrisWebuiPanelRef;
+ layout: null | import("@jottocraft/iris-sdk").IrisPluginConduitPanelLayout<import("@jottocraft/iris-sdk").IrisPluginConduit>;
+ pluginId: string;
+ breadcrumbs: IrisWebuiPanelBreadcrumb[];
+ navigate: (ref: IrisWebuiPanelRef, e?: React.MouseEvent) => void;
+ back: (e?: React.MouseEvent) => void;
+ goToBreadcrumb: (index: number, e?: React.MouseEvent) => void;
+ refreshLayout: () => void;
+};
+
+declare type IrisWebuiPanelNavigationCtxInactive = IrisWebuiPanelNavigationCtxCommon & {
+ activeRef: null;
+};
+
+declare type IrisWebuiPanelBreadcrumb = {
+ displayName: string;
+ icon?: string;
+ ref: IrisWebuiPanelRef;
+};
\ No newline at end of file