Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.env
local/
node_modules/
.DS_Store
nginx.config.example
Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ ENV CYPRESS_CACHE_FOLDER=/tmp/CypressCache

RUN npm ci --safe-chain-skip-minimum-package-age

# Build plugin-loader (TypeScript → dist/) before app builds need it
RUN npm run build --if-present -w packages/plugin-loader

FROM builder AS base

ARG APP
Expand Down
3 changes: 3 additions & 0 deletions apps/admin-server/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# auto-generated plugin registry
src/lib/generated-plugin-registry.ts
13 changes: 13 additions & 0 deletions apps/admin-server/next.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const path = require('path');

/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
Expand All @@ -11,6 +13,17 @@ const nextConfig = {
},
];
},
webpack(config) {
// Resolve @openstad-headless packages from the monorepo packages dir,
// which is mounted at ../../packages in both local dev and Docker.
config.resolve.modules = [
path.resolve(__dirname, 'node_modules'),
'node_modules',
...(config.resolve.modules || []),
];

return config;
},
};

module.exports = nextConfig;
4 changes: 3 additions & 1 deletion apps/admin-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "next lint",
"generate:plugin-registry": "node -e \"require('./next.config.js')\""
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.2",
Expand All @@ -31,6 +32,7 @@
"@openstad-headless/leaflet-map": "*",
"@openstad-headless/likes": "*",
"@openstad-headless/multi-project-resource-overview": "*",
"@openstad-headless/plugin-loader": "*",
"@openstad-headless/raw-resource": "*",
"@openstad-headless/resource-detail": "*",
"@openstad-headless/resource-detail-with-map": "*",
Expand Down
5 changes: 3 additions & 2 deletions apps/admin-server/src/components/dialog-widget-create.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import InfoDialog from '@/components/ui/info-hover';
import { useWidgetDefinitions } from '@/hooks/use-widget-definitions';
import { useWidgetsHook } from '@/hooks/use-widgets';
import { WidgetDefinitions } from '@/lib/widget-definitions';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { useRouter } from 'next/router';
Expand Down Expand Up @@ -61,7 +61,8 @@ type FormData = z.infer<typeof formSchema>;
export function CreateWidgetDialog({ projectId }: Props) {
const [open, setOpen] = useState<boolean>(false);
const router = useRouter();
const widgetTypes = Object.entries(WidgetDefinitions);
const widgetDefinitions = useWidgetDefinitions();
const widgetTypes = Object.entries(widgetDefinitions);
const { createWidget } = useWidgetsHook(projectId);

async function onSubmit(values: FormData) {
Expand Down
181 changes: 181 additions & 0 deletions apps/admin-server/src/components/plugin-component-loader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';

type PluginComponentLoaderProps = {
pluginName: string;
bundleType: 'admin' | 'widget-admin';
componentName: string;
props?: Record<string, any>;
apiUrl?: string;
};

declare global {
interface Window {
__openstad_plugin_props?: Record<string, Record<string, any>>;
[key: `OpenStadPlugin_${string}`]: Record<string, any> | undefined;
}
}

export default function PluginComponentLoader({
pluginName,
bundleType,
componentName,
props = {},
apiUrl,
}: PluginComponentLoaderProps) {
const containerRef = useRef<HTMLDivElement>(null);
const scriptRef = useRef<HTMLScriptElement | null>(null);
const unmountRef = useRef<((container: HTMLElement) => void) | null>(null);
const propsRef = useRef(props);
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(
'loading'
);
const [errorMessage, setErrorMessage] = useState<string>('');
const cacheBuster = useRef(Date.now());

// Keep propsRef in sync
propsRef.current = props;

// Build the bundle URL from the api-server
const resolvedApiUrl = apiUrl || process.env.NEXT_PUBLIC_API_URL || '';
const bundleUrl = `${resolvedApiUrl}/api/plugin/bundle/${pluginName}/${bundleType}?v=${cacheBuster.current}`;

const loadBundle = useCallback(() => {
// Set plugin props on window so the IIFE can read them
if (!window.__openstad_plugin_props) {
window.__openstad_plugin_props = {};
}
window.__openstad_plugin_props[pluginName] = propsRef.current;

// Remove any existing script for this plugin/bundleType
if (scriptRef.current) {
scriptRef.current.remove();
scriptRef.current = null;
}

// Clean up previous mount
if (unmountRef.current && containerRef.current) {
unmountRef.current(containerRef.current);
unmountRef.current = null;
}

const script = document.createElement('script');
script.src = bundleUrl;
script.async = true;
script.setAttribute('data-plugin', pluginName);
script.setAttribute('data-bundle-type', bundleType);

script.onload = () => {
const globalKey = `OpenStadPlugin_${pluginName}` as const;
const pluginExports = window[globalKey];

if (!pluginExports) {
setStatus('error');
setErrorMessage(
`Plugin "${pluginName}" loaded but window.${globalKey} not found`
);
return;
}

// Prefer mount/unmount pattern (plugin self-renders with its own React)
if (typeof pluginExports.mount === 'function') {
if (containerRef.current) {
pluginExports.mount(containerRef.current, propsRef.current);
unmountRef.current = pluginExports.unmount || null;
setStatus('ready');
}
return;
}

// Fallback: try rendering component via host React (same-version only)
if (typeof pluginExports[componentName] === 'function') {
const ReactDOM = require('react-dom/client');
if (containerRef.current) {
const root = ReactDOM.createRoot(containerRef.current);
const PluginComponent = pluginExports[componentName];
root.render(React.createElement(PluginComponent, propsRef.current));
unmountRef.current = () => root.unmount();
setStatus('ready');
}
return;
}

setStatus('error');
setErrorMessage(
`Plugin "${pluginName}" loaded but no mount() or "${componentName}" found on window.${globalKey}`
);
};

script.onerror = () => {
setStatus('error');
setErrorMessage(`Failed to load plugin bundle from ${bundleUrl}`);
};

document.body.appendChild(script);
scriptRef.current = script;
}, [pluginName, bundleType, componentName, bundleUrl]);

// Load bundle on mount
useEffect(() => {
loadBundle();

return () => {
// Cleanup on unmount
if (unmountRef.current && containerRef.current) {
unmountRef.current(containerRef.current);
unmountRef.current = null;
}
if (scriptRef.current) {
scriptRef.current.remove();
scriptRef.current = null;
}
// Clean up globals (IIFE var declarations are non-configurable, so use try/catch)
try {
const globalKey = `OpenStadPlugin_${pluginName}` as const;
(window as any)[globalKey] = undefined;
} catch (_) {}
if (window.__openstad_plugin_props) {
delete window.__openstad_plugin_props[pluginName];
}
};
}, [loadBundle]);

// Update props without reloading the bundle (uses deep comparison)
const serializedProps = JSON.stringify(props);
useEffect(() => {
if (window.__openstad_plugin_props) {
window.__openstad_plugin_props[pluginName] = props;
}

// Re-mount with updated props if already ready
if (status === 'ready' && containerRef.current) {
const globalKey = `OpenStadPlugin_${pluginName}` as const;
const pluginExports = window[globalKey];
if (pluginExports && typeof pluginExports.mount === 'function') {
pluginExports.mount(containerRef.current, props);
}
}
}, [serializedProps, pluginName, status]);

if (status === 'error') {
return (
<div className="p-6 bg-white rounded-md">
<p className="text-red-600">Plugin kon niet worden geladen</p>
<p className="text-sm text-gray-500 mt-2">{errorMessage}</p>
</div>
);
}

return (
<div>
{status === 'loading' && (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
)}
<div
ref={containerRef}
style={{ display: status === 'loading' ? 'none' : 'block' }}
/>
</div>
);
}
39 changes: 39 additions & 0 deletions apps/admin-server/src/components/ui/sidenav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AlertTriangle,
FolderOpen,
LogOut,
Plug,
Settings,
Users,
} from 'lucide-react';
Expand All @@ -14,6 +15,8 @@ import { useRouter } from 'next/router';
import { useContext, useEffect, useState } from 'react';

import { SessionContext } from '../../auth';
import hasRole from '../../lib/hasRole';
import type { Role } from '../../lib/roles';
import { Logo } from './logo';

export function Sidenav({
Expand All @@ -26,11 +29,21 @@ export function Sidenav({
const router = useRouter();
const [location, setLocation] = useState('');
const sessionData = useContext(SessionContext);
const [pluginMenuItems, setPluginMenuItems] = useState<
Array<{ label: string; href: string; role?: string }>
>([]);

useEffect(() => {
setLocation(router.pathname);
}, [router]);

useEffect(() => {
fetch('/api/plugin-menu-items')
.then((res) => res.json())
.then((items) => setPluginMenuItems(items))
.catch(() => {});
}, []);

return (
<nav
className={cn(
Expand Down Expand Up @@ -126,6 +139,32 @@ export function Sidenav({
</Button>
</Link>
) : null}
{pluginMenuItems.map((item) =>
sessionData?.role &&
hasRole(
{ role: sessionData.role as Role },
(item.role || 'superuser') as Role
) ? (
<Link key={item.href} href={item.href}>
<Button
variant={location.startsWith(item.href) ? 'secondary' : 'ghost'}
className={cn(
'w-full flex flex-row justify-start',
narrow ? 'p-0 h-10 w-10 justify-center' : null
)}>
<Plug
size="20"
className={
location.startsWith(item.href)
? 'text-brand'
: 'text-foreground'
}
/>
{narrow ? '' : item.label}
</Button>
</Link>
) : null
)}
</div>
<div className="flex-grow"></div>
<div
Expand Down
3 changes: 1 addition & 2 deletions apps/admin-server/src/components/widget-preview.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { Separator } from '@/components/ui/separator';
import { Heading } from '@/components/ui/typography';
import { WidgetDefinition } from '@/lib/widget-definitions';
import React, { useCallback, useContext, useEffect } from 'react';

import { SessionContext } from '../auth';

// Can we type config better? Or should we define types for all widgetConfigs and use them as seperate props. A.k.a. likeConifg?:LikeConfig, argConfig?: ArgConfig
type Props = {
type: WidgetDefinition;
type: string;
config?: any;
projectId: string;
};
Expand Down
10 changes: 8 additions & 2 deletions apps/admin-server/src/components/widget-publish.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@ import { useRouter } from 'next/router';
import React from 'react';
import { toast } from 'react-hot-toast';

export default function WidgetPublish({ apiUrl }: { apiUrl: string }) {
export default function WidgetPublish({
apiUrl,
idOverride,
}: {
apiUrl: string;
idOverride?: string;
}) {
const router = useRouter();
const id = router.query.id;
const id = idOverride ?? router.query.id;
const widgetScriptTag = `<script src="${apiUrl}/widget/${id}" type="text/javascript"></script>`;
const widgetUrl = `${apiUrl}/widget/${id}`;

Expand Down
4 changes: 2 additions & 2 deletions apps/admin-server/src/hooks/use-widget-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { useRouter } from 'next/router';
import toast from 'react-hot-toast';
import useSWR from 'swr';

export function useWidgetConfig<R>() {
export function useWidgetConfig<R>(idOverride?: string) {
const router = useRouter();
let id = router.query.id;
let id = idOverride ?? router.query.id;
let projectId = router.query.project;

let projectNumber: number | undefined = validateProjectNumber(projectId);
Expand Down
16 changes: 16 additions & 0 deletions apps/admin-server/src/hooks/use-widget-definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CoreWidgetDefinitions } from '@/lib/widget-definitions';
import useSWR from 'swr';

type WidgetDefinitionEntry = {
name: string;
description: string;
image: string;
};

export type WidgetDefinitionsMap = Record<string, WidgetDefinitionEntry>;

export function useWidgetDefinitions(): WidgetDefinitionsMap {
const { data } = useSWR<WidgetDefinitionsMap>('/api/widget-definitions');

return data || CoreWidgetDefinitions;
}
Loading
Loading