From f0c431df38b37a59eddac2805110a073bb85a876 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Fri, 3 Jul 2026 18:14:23 -0300 Subject: [PATCH 1/3] set up native plugins wiring --- .env.example | 2 + index.html | 7 +- .../Pages/TaskEditPage/TaskActionModal.tsx | 39 +++- .../Pages/TaskEditPage/TaskPanel.tsx | 54 +++++- .../TaskEditPage/contexts/TaskContext.tsx | 4 +- src/contexts/PluginContext.tsx | 169 +++++++++++++++--- src/lib/pluginRoutes.ts | 6 + src/lib/routerRef.ts | 63 +++++++ src/main.tsx | 3 + src/plugins/DynamicPluginLoader.ts | 1 - src/plugins/PluginRegistry.ts | 10 ++ src/plugins/pluginSecurity.ts | 10 +- src/plugins/pluginUi.ts | 10 ++ src/routes/_app/$.tsx | 34 +++- src/routes/_app/tasks/[$taskId]/index.tsx | 2 +- src/types/Plugin.ts | 82 +++++++++ src/vite-env.d.ts | 2 + vite.config.ts | 36 +++- 18 files changed, 489 insertions(+), 45 deletions(-) create mode 100644 src/lib/pluginRoutes.ts create mode 100644 src/lib/routerRef.ts create mode 100644 src/plugins/pluginUi.ts diff --git a/.env.example b/.env.example index 97c6c5cf7..ae59f0f62 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,5 @@ VITE_SERVER_API_KEY="" # (e.g. https://mpr.lt/c//t/). If unset, # short links will be omitted from changeset comments. VITE_SHORT_URL="https://mpr.lt" +# Comma-separated URLs of plugin bundles to auto-load at login (e.g. maproulette-review) +# VITE_DEPLOYMENT_PLUGIN_URLS="http://localhost:4201/maprouletteReviewPlugin.js" diff --git a/index.html b/index.html index 77792b193..fe06c1279 100644 --- a/index.html +++ b/index.html @@ -13,18 +13,15 @@
- diff --git a/src/components/Pages/TaskEditPage/TaskActionModal.tsx b/src/components/Pages/TaskEditPage/TaskActionModal.tsx index b6dc895d9..244328b9d 100644 --- a/src/components/Pages/TaskEditPage/TaskActionModal.tsx +++ b/src/components/Pages/TaskEditPage/TaskActionModal.tsx @@ -24,8 +24,10 @@ import { SelectValue, } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' +import { usePluginContext } from '@/contexts/PluginContext' import { logger } from '@/lib/logger' import { STATUS_LABELS } from '@/lib/taskConstants' +import type { TaskActionExtension } from '@/types/Plugin' import type { Task } from '@/types/Task' import { PENDING_BUNDLE_ID, useTaskBundleContext } from './contexts/TaskBundleContext' import { TaskNearbyMap } from './TaskNearbyMap' @@ -53,6 +55,7 @@ export const TaskActionModal = ({ }: TaskActionModalProps) => { const queryClient = useQueryClient() const navigate = useNavigate() + const { getTaskActionExtensions } = usePluginContext() const commentId = useId() const tagsId = useId() const randomId = useId() @@ -62,6 +65,8 @@ export const TaskActionModal = ({ const [tags, setTags] = useState('') const [nextTaskType, setNextTaskType] = useState<'nearby' | 'random'>('random') const [selectedNearbyTaskId, setSelectedNearbyTaskId] = useState(null) + const [formState, setFormState] = useState>({}) + const [extensions, setExtensions] = useState([]) const [isSubmitting, setIsSubmitting] = useState(false) const addTaskCommentMutation = api.task.useAddTaskComment() const updateTaskStatusMutation = api.task.useUpdateTaskStatus() @@ -71,11 +76,24 @@ export const TaskActionModal = ({ const { activeBundle, initialBundle } = useTaskBundleContext() const currentStatus = task.status ?? 0 const currentStatusLabel = STATUS_LABELS[currentStatus] || 'Unknown' - useEffect(() => { setNewStatus(initialStatus) }, [initialStatus]) + useEffect(() => { + let cancelled = false + const loadExtensions = async () => { + const results = await getTaskActionExtensions() + if (!cancelled) { + setExtensions(results) + } + } + void loadExtensions() + return () => { + cancelled = true + } + }, [getTaskActionExtensions]) + const handleSubmit = async () => { try { setIsSubmitting(true) @@ -175,6 +193,7 @@ export const TaskActionModal = ({ setNewStatus(initialStatus) setNextTaskType('random') setSelectedNearbyTaskId(null) + setFormState({}) onOpenChange(false) } @@ -239,6 +258,24 @@ export const TaskActionModal = ({

Separate multiple tags with commas

+ {extensions.map((extension) => { + const ExtensionComponent = extension.component + return ( +
+ setFormState((prev) => ({ ...prev, ...patch }))} + /> +
+ ) + })} + {/* Next Task Selection */}
diff --git a/src/components/Pages/TaskEditPage/TaskPanel.tsx b/src/components/Pages/TaskEditPage/TaskPanel.tsx index 15e8a4d02..99a1b0a01 100644 --- a/src/components/Pages/TaskEditPage/TaskPanel.tsx +++ b/src/components/Pages/TaskEditPage/TaskPanel.tsx @@ -1,3 +1,4 @@ +import { useLocation } from '@tanstack/react-router' import { useEffect, useRef, useState } from 'react' import { api } from '@/api' import { isTaskEligibleForBundle } from '@/components/Map/TaskMarkers/utils' @@ -12,12 +13,16 @@ import { TaskTab } from '@/components/TaskInfoPanel/TaskTab/TaskTab' import { TaskTabs } from '@/components/TaskInfoPanel/TaskTabs' import { Drawer } from '@/components/ui/Drawer' import { useAuthContext } from '@/contexts/AuthContext' +import { usePluginContext } from '@/contexts/PluginContext' +import type { TaskActionPanelExtension } from '@/types/Plugin' import type { Task, TaskMarker } from '@/types/Task' import { TaskActions } from './TaskActions/TaskActions' import { TaskInfoHeader } from './TaskInfoHeader' export const TaskPanel = () => { + const location = useLocation() const { task, isLocked } = useTaskContext() + const { getTaskActionPanels } = usePluginContext() const { user } = useAuthContext() const { activeBundle, setActiveBundle, setInitialBundle, bundleEditsDisabled } = useTaskBundleContext() @@ -25,6 +30,7 @@ export const TaskPanel = () => { useTaskMapContext() const { highlightIdEntityRef, activeView } = useEditorContext() const [drawerTaskId, setDrawerTaskId] = useState(null) + const [taskActionPanels, setTaskActionPanels] = useState([]) // 'closed' | 'open' | 'sliding-out' (animating out before switching task) const [drawerState, setDrawerState] = useState<'closed' | 'open' | 'sliding-out'>('closed') @@ -39,6 +45,20 @@ export const TaskPanel = () => { const prevTargetRef = useRef(targetTaskId) const drawerStateRef = useRef(drawerState) drawerStateRef.current = drawerState + useEffect(() => { + let cancelled = false + const loadTaskActionPanels = async () => { + const results = await getTaskActionPanels() + if (!cancelled) { + setTaskActionPanels(results) + } + } + void loadTaskActionPanels() + return () => { + cancelled = true + } + }, [getTaskActionPanels]) + useEffect(() => { const prevTarget = prevTargetRef.current prevTargetRef.current = targetTaskId @@ -173,6 +193,11 @@ export const TaskPanel = () => { } const isViewedTaskInBundle = activeBundle?.taskIds.includes(viewedTaskId) ?? false + const search = (location.search as Record) ?? {} + const panelContext = { pathname: location.pathname, search, task } + const activePanels = taskActionPanels.filter((panel) => panel.isActive?.(panelContext) ?? true) + const replacePanels = activePanels.filter((panel) => panel.slot === 'replace') + const appendPanels = activePanels.filter((panel) => panel.slot !== 'replace') // Check if the selected marker is eligible for bundling const isSelectedMarkerEligible = @@ -207,7 +232,34 @@ export const TaskPanel = () => { {/* Task Actions Footer - floats over content, under drawer */}
- + {replacePanels.length > 0 ? ( + replacePanels.map((panel) => { + const PanelComponent = panel.component + return ( + + ) + }) + ) : ( + <> + + {appendPanels.map((panel) => { + const PanelComponent = panel.component + return ( + + ) + })} + + )}
{/* Drawer overlay for non-primary tasks */} diff --git a/src/components/Pages/TaskEditPage/contexts/TaskContext.tsx b/src/components/Pages/TaskEditPage/contexts/TaskContext.tsx index 4417bc01a..7e327dbb8 100644 --- a/src/components/Pages/TaskEditPage/contexts/TaskContext.tsx +++ b/src/components/Pages/TaskEditPage/contexts/TaskContext.tsx @@ -19,7 +19,9 @@ export interface TaskContextType { export const TaskContext = createContext(undefined) export const TaskProvider = ({ children }: { children: ReactNode }) => { - const { task } = useLoaderData({ from: '/_app/tasks/$taskId/' }) + const { task: loaderTask } = useLoaderData({ from: '/_app/tasks/$taskId/' }) + const { data: cachedTask } = api.task.getTask(loaderTask.id) + const task = cachedTask ?? loaderTask const { isAuthenticated } = useAuthContext() const lockTaskMutation = api.task.useLockTask() const unlockTaskMutation = api.task.useUnlockTask() diff --git a/src/contexts/PluginContext.tsx b/src/contexts/PluginContext.tsx index 848329747..235363a45 100644 --- a/src/contexts/PluginContext.tsx +++ b/src/contexts/PluginContext.tsx @@ -1,8 +1,10 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import { api, apiRequest } from '@/api' import { logger } from '@/lib/logger' +import { navigateInApp } from '@/lib/routerRef' import type { PluginLoadResult } from '@/plugins/DynamicPluginLoader' import { pluginRegistry } from '@/plugins/PluginRegistry' +import { pluginUi } from '@/plugins/pluginUi' import type { Plugin, PluginApiContext, @@ -10,9 +12,32 @@ import type { PluginNavigationItem, PluginPage, RouteParams, + TaskActionExtension, + TaskActionPanelExtension, TaskMapEditor, } from '@/types/Plugin' import { useAuthContext } from './AuthContext' +import { useThemeContext } from './ThemeContext' + +const resolvePluginUrl = (url: string): string => { + if (url.startsWith('/')) { + return `${window.location.origin}${url}` + } + return url +} + +const getDeploymentPluginUrls = (): string[] => { + const rawUrls = window.env.VITE_DEPLOYMENT_PLUGIN_URLS + if (!rawUrls || typeof rawUrls !== 'string') { + return [] + } + + return rawUrls + .split(',') + .map((url) => url.trim()) + .filter((url) => url.length > 0) + .map(resolvePluginUrl) +} const matchPath = (pattern: string, path: string): { matched: boolean; params: RouteParams } => { const normalizedPattern = @@ -59,6 +84,8 @@ interface PluginContextType { getPluginPage: (pluginId: string, pageId: string) => Promise getPluginPageByPath: (path: string) => Promise getTaskMapEditors: () => Promise + getTaskActionExtensions: () => Promise + getTaskActionPanels: () => Promise isPluginEnabled: (pluginId: string) => boolean registerPluginFromUrl: (moduleUrl: string) => Promise removeRemotePlugin: (pluginId: string) => Promise @@ -75,6 +102,8 @@ const defaultPluginContext: PluginContextType = { getPluginPage: async () => null, getPluginPageByPath: async () => null, getTaskMapEditors: async () => [], + getTaskActionExtensions: async () => [], + getTaskActionPanels: async () => [], isPluginEnabled: () => false, registerPluginFromUrl: async () => ({ success: false, error: 'Not authenticated' }), removeRemotePlugin: async () => {}, @@ -102,13 +131,20 @@ const PluginProviderInner = ({ children: React.ReactNode user: NonNullable['user']> }) => { + const { theme } = useThemeContext() const [enabledPlugins, setEnabledPlugins] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [remotePluginUrls, setRemotePluginUrls] = useState([]) useEffect(() => { + const getThemeTokens = (): Record => ({}) + const apiContext: PluginApiContext = { + theme: { + isDarkMode: () => document.documentElement.classList.contains('dark'), + getThemeTokens, + }, api: { task: { useTask: api.task.getTask, @@ -127,10 +163,17 @@ const PluginProviderInner = ({ }, }, apiRequest, + user: user ? { id: user.id } : null, + navigate: navigateInApp, + ui: pluginUi, } pluginRegistry.setApiContext(apiContext) - }, []) + if (typeof window !== 'undefined') { + ;(window as unknown as { __maproulettePluginApi?: PluginApiContext }).__maproulettePluginApi = + apiContext + } + }, [theme, user]) useEffect(() => { const loadPluginPreferences = async () => { @@ -139,42 +182,58 @@ const PluginProviderInner = ({ const storageKey = `plugin_preferences_${user.id}` const stored = localStorage.getItem(storageKey) + const preferences = stored ? (JSON.parse(stored) as PluginConfiguration[]) : [] + const storedPreferenceByPluginId = new Map( + preferences.map((config) => [config.pluginId, config]) + ) - if (stored) { - const preferences = JSON.parse(stored) as PluginConfiguration[] - - const remotePlugins = preferences.filter((p) => p.source === 'remote' && p.moduleUrl) - const remoteUrls: string[] = [] - - for (const config of remotePlugins) { - if (config.moduleUrl) { - try { - const result = await pluginRegistry.registerFromUrl(config.moduleUrl) - if (result.success) { - remoteUrls.push(config.moduleUrl) - } else { - logger.error(`Failed to load remote plugin from ${config.moduleUrl}`, { - error: result.error, - }) - setError(`Failed to load plugin: ${result.error}`) - } - } catch (err) { - logger.error(`Error loading remote plugin from ${config.moduleUrl}`, { error: err }) + const deploymentUrls = getDeploymentPluginUrls() + const storedRemoteUrls = preferences + .filter((config) => config.source === 'remote' && config.moduleUrl) + .map((config) => config.moduleUrl as string) + const urlsToLoad = Array.from(new Set([...deploymentUrls, ...storedRemoteUrls])) + + const remoteUrls: string[] = [] + const deploymentLoadedPluginIds: string[] = [] + + for (const moduleUrl of urlsToLoad) { + try { + const result = await pluginRegistry.registerFromUrl(moduleUrl) + if (result.success && result.plugin) { + remoteUrls.push(moduleUrl) + if (deploymentUrls.includes(moduleUrl)) { + deploymentLoadedPluginIds.push(result.plugin.metadata.id) } + } else { + logger.error(`Failed to load remote plugin from ${moduleUrl}`, { + error: result.error, + }) + setError(`Failed to load plugin: ${result.error}`) } + } catch (err) { + logger.error(`Error loading remote plugin from ${moduleUrl}`, { error: err }) } + } - setRemotePluginUrls(remoteUrls) - - const enabled = preferences - .filter((config) => config.enabled) - .map((config) => config.pluginId) + setRemotePluginUrls(remoteUrls) - setEnabledPlugins(enabled) + const enabled = new Set( + preferences.filter((config) => config.enabled).map((config) => config.pluginId) + ) - for (const pluginId of enabled) { - await pluginRegistry.initialize(pluginId) + for (const pluginId of deploymentLoadedPluginIds) { + const storedPreference = storedPreferenceByPluginId.get(pluginId) + if (storedPreference?.enabled === false) { + continue } + enabled.add(pluginId) + } + + const enabledPluginIds = Array.from(enabled) + setEnabledPlugins(enabledPluginIds) + + for (const pluginId of enabledPluginIds) { + await pluginRegistry.initialize(pluginId) } } catch (error) { logger.error('Failed to load plugin preferences', { error }) @@ -300,6 +359,54 @@ const PluginProviderInner = ({ return editors }, [enabledPlugins]) + const getTaskActionExtensions = useCallback(async (): Promise => { + const extensions: TaskActionExtension[] = [] + + for (const pluginId of enabledPlugins) { + const plugin = pluginRegistry.get(pluginId) + if (plugin?.getTaskActionExtensions) { + try { + const pluginExtensions = await plugin.getTaskActionExtensions() + extensions.push(...pluginExtensions) + } catch (error) { + logger.error(`Failed to get task action extensions from plugin ${pluginId}`, { error }) + } + } + } + + extensions.sort((a, b) => { + const orderA = a.order ?? 999 + const orderB = b.order ?? 999 + return orderA - orderB + }) + + return extensions + }, [enabledPlugins]) + + const getTaskActionPanels = useCallback(async (): Promise => { + const panels: TaskActionPanelExtension[] = [] + + for (const pluginId of enabledPlugins) { + const plugin = pluginRegistry.get(pluginId) + if (plugin?.getTaskActionPanels) { + try { + const pluginPanels = await plugin.getTaskActionPanels() + panels.push(...pluginPanels) + } catch (error) { + logger.error(`Failed to get task action panels from plugin ${pluginId}`, { error }) + } + } + } + + panels.sort((a, b) => { + const orderA = a.order ?? 999 + const orderB = b.order ?? 999 + return orderA - orderB + }) + + return panels + }, [enabledPlugins]) + const togglePlugin = useCallback( async (pluginId: string, enabled: boolean) => { try { @@ -412,6 +519,8 @@ const PluginProviderInner = ({ getPluginPage, getPluginPageByPath, getTaskMapEditors, + getTaskActionExtensions, + getTaskActionPanels, isPluginEnabled, registerPluginFromUrl, removeRemotePlugin, @@ -427,6 +536,8 @@ const PluginProviderInner = ({ getPluginPage, getPluginPageByPath, getTaskMapEditors, + getTaskActionExtensions, + getTaskActionPanels, isPluginEnabled, registerPluginFromUrl, removeRemotePlugin, diff --git a/src/lib/pluginRoutes.ts b/src/lib/pluginRoutes.ts new file mode 100644 index 000000000..db6df9773 --- /dev/null +++ b/src/lib/pluginRoutes.ts @@ -0,0 +1,6 @@ +/** First-class app routes — never show plugin catch-all 404 for these paths. */ +const CORE_APP_PATH = + /^\/(tasks|challenge|project|manage|teams|profile|dashboard|settings|notifications|super-admin)(\/|$)/ + +export const isCoreAppPath = (pathname: string): boolean => + pathname === '/' || CORE_APP_PATH.test(pathname) diff --git a/src/lib/routerRef.ts b/src/lib/routerRef.ts new file mode 100644 index 000000000..725ecb708 --- /dev/null +++ b/src/lib/routerRef.ts @@ -0,0 +1,63 @@ +import { isCoreAppPath } from '@/lib/pluginRoutes' + +interface AppRouter { + navigate: (options: Record) => unknown +} + +let routerInstance: AppRouter | null = null + +export const setAppRouter = (router: AppRouter): void => { + routerInstance = router +} + +export const getAppRouter = (): AppRouter | null => routerInstance + +const parseSearchParams = (url: URL): Record => { + const search: Record = {} + + for (const [key, value] of url.searchParams.entries()) { + if (value === 'true' || value === '1') { + search[key] = true + continue + } + if (value === 'false' || value === '0') { + search[key] = false + continue + } + search[key] = value + } + + return search +} + +export const navigateInApp = (path: string): void => { + if (!routerInstance) { + window.location.assign(path) + return + } + + const url = new URL(path, window.location.origin) + const search = parseSearchParams(url) + + const taskMatch = url.pathname.match(/^\/tasks\/(\d+)\/?$/) + if (taskMatch) { + void routerInstance.navigate({ + to: '/tasks/$taskId', + params: { taskId: taskMatch[1] }, + search, + }) + return + } + + const splatMatch = url.pathname.match(/^\/([^/]+)\/?$/) + if (splatMatch && !isCoreAppPath(url.pathname)) { + void routerInstance.navigate({ + to: '/$', + params: { _splat: splatMatch[1] }, + search, + }) + return + } + + void routerInstance.navigate({ href: `${url.pathname}${url.search}` }) +} diff --git a/src/main.tsx b/src/main.tsx index b8537e960..1eba51094 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,7 @@ import { routeTree } from './routeTree.gen' import './main.css' import { NotFound } from '@/components/shared/NotFound' import { Loader } from '@/components/ui/Loader' +import { setAppRouter } from '@/lib/routerRef' const queryClient = new QueryClient({ defaultOptions: { @@ -48,6 +49,8 @@ export const router = createRouter({ defaultPendingComponent: () => , }) +setAppRouter(router) + declare module '@tanstack/react-router' { interface Register { router: typeof router diff --git a/src/plugins/DynamicPluginLoader.ts b/src/plugins/DynamicPluginLoader.ts index 158b0180b..3cde56676 100644 --- a/src/plugins/DynamicPluginLoader.ts +++ b/src/plugins/DynamicPluginLoader.ts @@ -100,7 +100,6 @@ export const loadPluginViaScript = ( const script = document.createElement('script') script.src = moduleUrl script.async = true - script.crossOrigin = 'anonymous' script.onload = () => { try { diff --git a/src/plugins/PluginRegistry.ts b/src/plugins/PluginRegistry.ts index 57e2e5eec..27da1af63 100644 --- a/src/plugins/PluginRegistry.ts +++ b/src/plugins/PluginRegistry.ts @@ -11,6 +11,7 @@ class PluginRegistry { private plugins: Map = new Map() private initializedPlugins: Set = new Set() private remotePluginUrls: Map = new Map() // pluginId -> moduleUrl + private loadedModuleUrls: Map = new Map() // moduleUrl -> pluginId private apiContext: PluginApiContext | null = null /** @@ -131,11 +132,20 @@ class PluginRegistry { } } + const existingPluginId = this.loadedModuleUrls.get(moduleUrl) + if (existingPluginId) { + const plugin = this.plugins.get(existingPluginId) + if (plugin) { + return { success: true, plugin } + } + } + const result = await loadPluginFromUrl(moduleUrl) if (result.success && result.plugin) { this.register(result.plugin) this.remotePluginUrls.set(result.plugin.metadata.id, moduleUrl) + this.loadedModuleUrls.set(moduleUrl, result.plugin.metadata.id) } return result diff --git a/src/plugins/pluginSecurity.ts b/src/plugins/pluginSecurity.ts index 9b7782ee4..6ead106f3 100644 --- a/src/plugins/pluginSecurity.ts +++ b/src/plugins/pluginSecurity.ts @@ -31,7 +31,15 @@ const ALLOWED_PLUGIN_HOSTS = [ */ export const validatePluginUrl = (url: string): boolean => { try { - const parsed = new URL(url) + const parsed = new URL(url, typeof window !== 'undefined' ? window.location.origin : undefined) + + const isSameOrigin = typeof window !== 'undefined' && parsed.origin === window.location.origin + + // Same-origin bundles (e.g. /plugins/... served as static files) are always allowed. + if (isSameOrigin) { + pluginLogger.debug('Plugin URL validated (same-origin)', { url }) + return true + } // Only allow HTTPS (or HTTP in development for localhost) if (parsed.protocol !== 'https:') { diff --git a/src/plugins/pluginUi.ts b/src/plugins/pluginUi.ts new file mode 100644 index 000000000..cfc3886d5 --- /dev/null +++ b/src/plugins/pluginUi.ts @@ -0,0 +1,10 @@ +import { Button } from '@/components/ui/Button' +import { Label } from '@/components/ui/Label' +import { Textarea } from '@/components/ui/Textarea' + +/** Stable UI surface exposed to runtime plugins via PluginApiContext.ui */ +export const pluginUi = { + Button, + Label, + Textarea, +} as const diff --git a/src/routes/_app/$.tsx b/src/routes/_app/$.tsx index f1c57ecd7..03841fb60 100644 --- a/src/routes/_app/$.tsx +++ b/src/routes/_app/$.tsx @@ -6,12 +6,12 @@ import { Loader } from '@/components/ui/Loader' import type { PluginPageMatch } from '@/contexts/PluginContext' import { usePluginContext } from '@/contexts/PluginContext' import { logger } from '@/lib/logger' +import { isCoreAppPath } from '@/lib/pluginRoutes' /** * Catch-all route that handles plugin-defined custom routes * This allows plugins to register their own paths like: * - /example - * - /tasks/:id/review * - /challenge/:challengeId/tasks/:taskId */ const DynamicPluginRoute = () => { @@ -21,28 +21,54 @@ const DynamicPluginRoute = () => { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const isCorePath = isCoreAppPath(location.pathname) + useEffect(() => { + if (isCorePath) { + return + } + + let cancelled = false + const loadPage = async () => { setLoading(true) setError(null) + setPageMatch(null) try { const match = await getPluginPageByPath(location.pathname) + if (cancelled) { + return + } + if (match) { setPageMatch(match) } else { setError(`No plugin page found for path: ${location.pathname}`) } } catch (err) { + if (cancelled) { + return + } logger.error('Failed to load plugin page', { error: err }) setError(err instanceof Error ? err.message : 'Failed to load plugin page') } finally { - setLoading(false) + if (!cancelled) { + setLoading(false) + } } } - loadPage() - }, [location.pathname, getPluginPageByPath]) + void loadPage() + + return () => { + cancelled = true + } + }, [location.pathname, getPluginPageByPath, isCorePath]) + + if (isCorePath) { + return null + } if (loading) { return diff --git a/src/routes/_app/tasks/[$taskId]/index.tsx b/src/routes/_app/tasks/[$taskId]/index.tsx index d53a267f1..5235f4910 100644 --- a/src/routes/_app/tasks/[$taskId]/index.tsx +++ b/src/routes/_app/tasks/[$taskId]/index.tsx @@ -7,7 +7,7 @@ import { logger } from '@/lib/logger' const taskSearchSchema = z.object({ tab: z.enum(['task', 'properties', 'comments', 'osm']).optional(), -}) +}).and(z.record(z.string(), z.unknown())) export const Route = createFileRoute('/_app/tasks/$taskId/')({ validateSearch: taskSearchSchema, diff --git a/src/types/Plugin.ts b/src/types/Plugin.ts index afd2863b0..5a25c7508 100644 --- a/src/types/Plugin.ts +++ b/src/types/Plugin.ts @@ -12,6 +12,11 @@ export interface RouteParams { * All API methods are React hooks that return { data, isLoading, error } */ export interface PluginApiContext { + /** Theme context from the host app */ + theme: { + isDarkMode: () => boolean + getThemeTokens: () => Record + } /** API hooks for making requests */ api: { /** Task API hooks */ @@ -52,6 +57,16 @@ export interface PluginApiContext { } /** Base API request function (ky instance) for custom requests */ apiRequest: unknown + /** Current authenticated user, when available */ + user?: { id: number } | null + /** Navigate within the host SPA (path may include search params) */ + navigate?: (path: string) => void + /** Host UI components so plugins match native styling without bundling their own */ + ui: { + Button: ComponentType> + Label: ComponentType> + Textarea: ComponentType> + } } /** @@ -108,6 +123,61 @@ export interface TaskMapEditor { order?: number } +/** + * Task action extension definition + * Allows plugins to inject custom controls into the task action modal + */ +export interface TaskActionExtension { + /** Unique identifier for the extension */ + id: string + /** Optional display label */ + label?: string + /** Extension component rendered inside task action modal */ + component: ComponentType<{ + task: unknown + newStatus: number + setNewStatus: (status: number) => void + formState: Record + setFormState: (patch: Record) => void + }> + /** Optional order/priority for display (lower numbers appear first) */ + order?: number +} + +/** + * Task action panel extension definition + * Allows plugins to replace or append content in the task footer panel. + */ +export interface TaskActionPanelExtension { + /** Unique identifier for the extension */ + id: string + /** Optional display label */ + label?: string + /** + * Where to render the panel: + * - replace: replaces the default task actions if active + * - append: renders alongside default task actions + */ + slot?: 'replace' | 'append' + /** Optional order/priority for display (lower numbers appear first) */ + order?: number + /** + * Optional activation check for current task/page context. + * If omitted, the panel is considered active. + */ + isActive?: (context: { + pathname: string + search: Record + task: unknown + }) => boolean + /** Panel component rendered in the task footer */ + component: ComponentType<{ + task: unknown + search: Record + pathname: string + }> +} + /** * Plugin metadata */ @@ -166,6 +236,18 @@ export interface Plugin { */ getTaskMapEditors?: () => TaskMapEditor[] | Promise + /** + * Get task action extensions provided by this plugin + * These extensions appear inside the task action modal. + */ + getTaskActionExtensions?: () => TaskActionExtension[] | Promise + + /** + * Get task footer panel extensions provided by this plugin + * These extensions can replace or append task footer UI. + */ + getTaskActionPanels?: () => TaskActionPanelExtension[] | Promise + /** * Optional hook to extend the plugin with custom functionality */ diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index dfccc2098..f4e7a2b9f 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -22,6 +22,8 @@ interface AppEnv { readonly VITE_OSM_API_SERVER: string | undefined readonly VITE_SHORT_URL: string | undefined readonly VITE_EMAIL_ENFORCEMENT: string | undefined + /** Comma-separated URLs of deployment plugin bundles to auto-load at login */ + readonly VITE_DEPLOYMENT_PLUGIN_URLS: string | undefined } interface ImportMetaEnv extends AppEnv {} diff --git a/vite.config.ts b/vite.config.ts index e84eccc1c..62c1d1646 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import tailwindcss from '@tailwindcss/vite' import { tanstackRouter } from '@tanstack/router-plugin/vite' @@ -37,6 +37,35 @@ function runtimeEnv(): Plugin { } } +function pluginMiddleware(distDir: string) { + return (req: { url?: string }, res: { setHeader: (k: string, v: string) => void; end: (body: Buffer) => void }, next: () => void) => { + if (!req.url?.startsWith('/plugins/')) return next() + const filePath = resolve(distDir, req.url.slice(1)) + if (!filePath.startsWith(distDir)) return next() + if (!existsSync(filePath)) return next() + const content = readFileSync(filePath) + res.setHeader('Content-Type', 'application/javascript') + res.setHeader('Cache-Control', 'public, max-age=60') + res.end(content) + } +} + +function servePlugins(): Plugin { + let distDir: string + return { + name: 'maproulette:serve-plugins', + configResolved(config) { + distDir = resolve(config.root, config.build.outDir) + }, + configureServer(server) { + server.middlewares.use(pluginMiddleware(distDir)) + }, + configurePreviewServer(server) { + server.middlewares.use(pluginMiddleware(distDir)) + }, + } +} + // https://vitejs.dev/config/ export default defineConfig({ plugins: [ @@ -45,6 +74,7 @@ export default defineConfig({ tanstackRouter({ target: 'react', autoCodeSplitting: true }), viteReact(), runtimeEnv(), + servePlugins(), ], build: { sourcemap: true, @@ -57,6 +87,10 @@ export default defineConfig({ '@': resolve(__dirname, './src'), }, }, + preview: { + port: 3001, + host: true, + }, server: { port: 3001, host: true, From 7924c2d30096487c568cec453e5ef04740893752 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Fri, 3 Jul 2026 18:22:58 -0300 Subject: [PATCH 2/3] fix build --- src/routes/_app/tasks/[$taskId]/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/_app/tasks/[$taskId]/index.tsx b/src/routes/_app/tasks/[$taskId]/index.tsx index 5235f4910..d53a267f1 100644 --- a/src/routes/_app/tasks/[$taskId]/index.tsx +++ b/src/routes/_app/tasks/[$taskId]/index.tsx @@ -7,7 +7,7 @@ import { logger } from '@/lib/logger' const taskSearchSchema = z.object({ tab: z.enum(['task', 'properties', 'comments', 'osm']).optional(), -}).and(z.record(z.string(), z.unknown())) +}) export const Route = createFileRoute('/_app/tasks/$taskId/')({ validateSearch: taskSearchSchema, From fa23ae9d7d15eff574dcb037e223f7686df2bf5c Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 5 Jul 2026 15:47:27 -0300 Subject: [PATCH 3/3] run biome --- vite.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 62c1d1646..6f0b25a35 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -38,7 +38,11 @@ function runtimeEnv(): Plugin { } function pluginMiddleware(distDir: string) { - return (req: { url?: string }, res: { setHeader: (k: string, v: string) => void; end: (body: Buffer) => void }, next: () => void) => { + return ( + req: { url?: string }, + res: { setHeader: (k: string, v: string) => void; end: (body: Buffer) => void }, + next: () => void + ) => { if (!req.url?.startsWith('/plugins/')) return next() const filePath = resolve(distDir, req.url.slice(1)) if (!filePath.startsWith(distDir)) return next()