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/api/task/single.ts b/src/api/task/single.ts index 6231190d6..649599919 100644 --- a/src/api/task/single.ts +++ b/src/api/task/single.ts @@ -151,8 +151,9 @@ export const taskSingle = { status: number options?: { tags?: string[] - requestReview?: boolean comment?: string + /** Opaque query params contributed by plugins */ + queryParams?: Record } }) => { // Build query string manually @@ -160,8 +161,12 @@ export const taskSingle = { if (options?.tags && options.tags.length > 0) { params.set('tags', options.tags.join(',')) } - if (options?.requestReview !== undefined) { - params.set('requestReview', options.requestReview.toString()) + if (options?.queryParams) { + for (const [key, value] of Object.entries(options.queryParams)) { + if (value !== undefined && value !== null) { + params.set(key, String(value)) + } + } } const queryString = params.toString() diff --git a/src/api/taskBundle/queries.ts b/src/api/taskBundle/queries.ts index cd87dff91..8112584ef 100644 --- a/src/api/taskBundle/queries.ts +++ b/src/api/taskBundle/queries.ts @@ -106,16 +106,26 @@ export const taskBundleQueries = { primaryId, status, tags, + queryParams, }: { bundleId: number primaryId: number status: number tags?: string[] + /** Opaque query params contributed by plugins */ + queryParams?: Record }) => { const searchParams: Record = { primaryId: String(primaryId) } if (tags && tags.length > 0) { searchParams.tags = tags.join(',') } + if (queryParams) { + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null) { + searchParams[key] = String(value) + } + } + } await apiRequest.put(`api/v2/taskBundle/${bundleId}/${status}`, { searchParams }) }, onSuccess: (_data, variables) => { diff --git a/src/components/Pages/SettingsPage/UserSettingsForm/GeneralSettings.tsx b/src/components/Pages/SettingsPage/UserSettingsForm/GeneralSettings.tsx index a30e19c4d..fa76b6cd8 100644 --- a/src/components/Pages/SettingsPage/UserSettingsForm/GeneralSettings.tsx +++ b/src/components/Pages/SettingsPage/UserSettingsForm/GeneralSettings.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' import type { UseFormReturn } from 'react-hook-form' import type { z } from 'zod' import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@/components/ui/Field' @@ -21,7 +22,13 @@ import { baseMapOptions, editorOptions, localeOptions } from '@/data/account.jso import { FieldSubmit } from './FieldSubmit' import type { formSchema } from './formSchema' -export const GeneralSettings = ({ form }: { form: UseFormReturn> }) => { +export const GeneralSettings = ({ + form, + children, +}: { + form: UseFormReturn> + children?: ReactNode +}) => { return (
General @@ -127,6 +134,7 @@ export const GeneralSettings = ({ form }: { form: UseFormReturn )} /> + {children}
diff --git a/src/components/Pages/SettingsPage/UserSettingsForm/PluginUserSettingsFields.tsx b/src/components/Pages/SettingsPage/UserSettingsForm/PluginUserSettingsFields.tsx new file mode 100644 index 000000000..a4a7b8a4e --- /dev/null +++ b/src/components/Pages/SettingsPage/UserSettingsForm/PluginUserSettingsFields.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react' +import type { FieldPath, UseFormReturn } from 'react-hook-form' +import type { z } from 'zod' +import { FormField, FormItem, FormMessage } from '@/components/ui/Form' +import { usePluginContext } from '@/contexts/PluginContext' +import type { UserSettingsFieldExtension } from '@/types/Plugin' +import type { UserSettings } from '@/types/User' +import type { formSchema } from './formSchema' + +type SettingsFormValues = z.infer + +/** Renders plugin-owned settings inputs bound to the shared Account form. */ +export const PluginUserSettingsFields = ({ + form, + settings, +}: { + form: UseFormReturn + settings: UserSettings +}) => { + const { getUserSettingsFields } = usePluginContext() + const [fields, setFields] = useState([]) + + useEffect(() => { + let cancelled = false + + void getUserSettingsFields().then((results) => { + if (cancelled) return + setFields(results) + + for (const pluginField of results) { + const settingsRecord = settings as Record + if (settingsRecord[pluginField.name] !== undefined) { + form.setValue( + pluginField.name as FieldPath, + settingsRecord[pluginField.name] as SettingsFormValues[FieldPath] + ) + } + } + }) + + return () => { + cancelled = true + } + }, [getUserSettingsFields, form, settings]) + + if (fields.length === 0) return null + + return ( + <> + {fields.map((pluginField) => { + const FieldComponent = pluginField.component + return ( + } + render={({ field }) => ( + + + + + )} + /> + ) + })} + + ) +} diff --git a/src/components/Pages/SettingsPage/UserSettingsForm/formSchema.ts b/src/components/Pages/SettingsPage/UserSettingsForm/formSchema.ts index d75982892..daadb28c0 100644 --- a/src/components/Pages/SettingsPage/UserSettingsForm/formSchema.ts +++ b/src/components/Pages/SettingsPage/UserSettingsForm/formSchema.ts @@ -1,30 +1,31 @@ import z from 'zod' import { baseMapOptions, editorOptions, localeOptions } from '@/data/account.json' -export const formSchema = z.object({ - defaultEditor: z - .number() - .refine((val) => editorOptions.some((option) => option.value === val), { - message: 'Invalid editor option', - }) - .optional(), - defaultBasemap: z.refine((val) => baseMapOptions.some((option) => option.value === val), { - message: 'Invalid basemap option', - }), - defaultBasemapId: z.string().optional(), - locale: z - .string() - .refine((val) => localeOptions.some((option) => option.value === val), { - message: 'Invalid language option', - }) - .optional(), - email: z.email().optional().or(z.literal('')), - emailOptIn: z.boolean().optional(), - leaderboardOptOut: z.boolean().optional(), - needsReview: z.number().min(0).optional(), - isReviewer: z.boolean().optional(), - allowFollowing: z.boolean().optional(), - theme: z.number().min(0).max(2).optional(), - seeTagFixSuggestions: z.boolean().optional(), - disableTaskConfirm: z.boolean().optional(), -}) +export const formSchema = z + .object({ + defaultEditor: z + .number() + .refine((val) => editorOptions.some((option) => option.value === val), { + message: 'Invalid editor option', + }) + .optional(), + defaultBasemap: z.refine((val) => baseMapOptions.some((option) => option.value === val), { + message: 'Invalid basemap option', + }), + defaultBasemapId: z.string().optional(), + locale: z + .string() + .refine((val) => localeOptions.some((option) => option.value === val), { + message: 'Invalid language option', + }) + .optional(), + email: z.email().optional().or(z.literal('')), + emailOptIn: z.boolean().optional(), + leaderboardOptOut: z.boolean().optional(), + allowFollowing: z.boolean().optional(), + theme: z.number().min(0).max(2).optional(), + seeTagFixSuggestions: z.boolean().optional(), + disableTaskConfirm: z.boolean().optional(), + }) + // Allow plugin-contributed settings fields (e.g. from getUserSettingsFields) + .loose() diff --git a/src/components/Pages/SettingsPage/UserSettingsForm/index.tsx b/src/components/Pages/SettingsPage/UserSettingsForm/index.tsx index 1b3b491cb..d90bf175e 100644 --- a/src/components/Pages/SettingsPage/UserSettingsForm/index.tsx +++ b/src/components/Pages/SettingsPage/UserSettingsForm/index.tsx @@ -2,17 +2,35 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import type { z } from 'zod' +import { api } from '@/api' import { FieldGroup } from '@/components/ui/Field' import { Form } from '@/components/ui/Form' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs' -import type { User } from '@/types/User' +import type { User, UserSettings } from '@/types/User' import { ApiSettings } from './ApiSettings' import { formSchema } from './formSchema' import { GeneralSettings } from './GeneralSettings' import { NotificationsSettings } from './NotificationsSettings' import { PluginSettings } from './PluginSettings' +import { PluginUserSettingsFields } from './PluginUserSettingsFields' + +const CORE_SETTINGS_KEYS = new Set([ + 'defaultEditor', + 'defaultBasemap', + 'defaultBasemapId', + 'locale', + 'email', + 'emailOptIn', + 'leaderboardOptOut', + 'allowFollowing', + 'theme', + 'seeTagFixSuggestions', + 'disableTaskConfirm', +]) export const UserSettingsForm = ({ user }: { user: User }) => { + const updateSettingsMutation = api.user.useUpdateUserSettings() + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -24,14 +42,34 @@ export const UserSettingsForm = ({ user }: { user: User }) => { }, }) - const onSubmit = async () => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(true) - }, 1000) - }).then(() => { - toast('User settings updated') - }) + const onSubmit = async (values: z.infer) => { + try { + const pluginSettings: Record = {} + for (const [key, value] of Object.entries(values)) { + if (!CORE_SETTINGS_KEYS.has(key)) { + pluginSettings[key] = value + } + } + + await updateSettingsMutation.mutateAsync({ + userId: user.id, + settings: { + ...user.settings, + defaultEditor: values.defaultEditor, + defaultBasemap: + typeof values.defaultBasemap === 'number' + ? values.defaultBasemap + : user.settings.defaultBasemap, + defaultBasemapId: values.defaultBasemapId, + locale: values.locale, + email: values.email, + ...(pluginSettings as UserSettings), + }, + }) + toast.success('User settings updated') + } catch { + toast.error('Failed to update user settings') + } } return ( @@ -48,7 +86,9 @@ export const UserSettingsForm = ({ user }: { user: User }) => {
- + + + diff --git a/src/components/Pages/TaskEditPage/TaskActionModal.tsx b/src/components/Pages/TaskEditPage/TaskActionModal.tsx index b6dc895d9..d0d75fe91 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) @@ -115,18 +133,29 @@ export const TaskActionModal = ({ resolvedBundleId = task.bundleId } + const pluginQueryParams = Object.assign( + {}, + ...extensions.map( + (extension) => extension.getStatusQueryParams?.(formState, { newStatus, task }) ?? {} + ) + ) + if (resolvedBundleId != null) { await updateBundleStatusMutation.mutateAsync({ bundleId: resolvedBundleId, primaryId: task.id, status: newStatus, tags: tagList, + queryParams: pluginQueryParams, }) } else { await updateTaskStatusMutation.mutateAsync({ taskId: task.id, status: newStatus, - options: { tags: tagList }, + options: { + tags: tagList, + queryParams: pluginQueryParams, + }, }) } @@ -175,6 +204,7 @@ export const TaskActionModal = ({ setNewStatus(initialStatus) setNextTaskType('random') setSelectedNearbyTaskId(null) + setFormState({}) onOpenChange(false) } @@ -232,13 +262,31 @@ export const TaskActionModal = ({ setTags(e.target.value)} />

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..5fe74cb31 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,33 @@ import type { PluginNavigationItem, PluginPage, RouteParams, + TaskActionExtension, + TaskActionPanelExtension, TaskMapEditor, + UserSettingsFieldExtension, } 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 +85,9 @@ interface PluginContextType { getPluginPage: (pluginId: string, pageId: string) => Promise getPluginPageByPath: (path: string) => Promise getTaskMapEditors: () => Promise + getTaskActionExtensions: () => Promise + getTaskActionPanels: () => Promise + getUserSettingsFields: () => Promise isPluginEnabled: (pluginId: string) => boolean registerPluginFromUrl: (moduleUrl: string) => Promise removeRemotePlugin: (pluginId: string) => Promise @@ -75,6 +104,9 @@ const defaultPluginContext: PluginContextType = { getPluginPage: async () => null, getPluginPageByPath: async () => null, getTaskMapEditors: async () => [], + getTaskActionExtensions: async () => [], + getTaskActionPanels: async () => [], + getUserSettingsFields: async () => [], isPluginEnabled: () => false, registerPluginFromUrl: async () => ({ success: false, error: 'Not authenticated' }), removeRemotePlugin: async () => {}, @@ -102,13 +134,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 +166,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 +185,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 +362,78 @@ 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 getUserSettingsFields = useCallback(async (): Promise => { + const fields: UserSettingsFieldExtension[] = [] + + for (const pluginId of enabledPlugins) { + const plugin = pluginRegistry.get(pluginId) + if (plugin?.getUserSettingsFields) { + try { + const pluginFields = await plugin.getUserSettingsFields() + fields.push(...pluginFields) + } catch (error) { + logger.error(`Failed to get user settings fields from plugin ${pluginId}`, { error }) + } + } + } + + fields.sort((a, b) => { + const orderA = a.order ?? 999 + const orderB = b.order ?? 999 + return orderA - orderB + }) + + return fields + }, [enabledPlugins]) + const togglePlugin = useCallback( async (pluginId: string, enabled: boolean) => { try { @@ -412,6 +546,9 @@ const PluginProviderInner = ({ getPluginPage, getPluginPageByPath, getTaskMapEditors, + getTaskActionExtensions, + getTaskActionPanels, + getUserSettingsFields, isPluginEnabled, registerPluginFromUrl, removeRemotePlugin, @@ -427,6 +564,9 @@ const PluginProviderInner = ({ getPluginPage, getPluginPageByPath, getTaskMapEditors, + getTaskActionExtensions, + getTaskActionPanels, + getUserSettingsFields, 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/types/Plugin.ts b/src/types/Plugin.ts index afd2863b0..94fea3b95 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> + } } /** @@ -84,7 +99,7 @@ export interface PluginPage { component: ComponentType<{ params?: RouteParams }> /** * Custom route path with optional parameters - * Examples: '/example', '/tasks/:id/review', '/challenge/:challengeId/tasks/:taskId' + * Examples: '/example', '/tasks/:id', '/challenge/:challengeId/tasks/:taskId' */ path: string /** Optional description */ @@ -108,6 +123,88 @@ 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 query params to attach to the task/bundle status PUT. + * Host forwards these without interpreting keys. + */ + getStatusQueryParams?: ( + formState: Record, + context: { newStatus: number; task: unknown } + ) => Record + /** 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 + }> +} + +/** + * User settings field extension + * Plugin owns the input UI; host binds it into the shared Account form by `name`. + */ +export interface UserSettingsFieldExtension { + /** Unique identifier for the field extension */ + id: string + /** Form field name (must exist on the host settings schema) */ + name: string + /** Optional order/priority for display (lower numbers appear first) */ + order?: number + /** Field UI — receives the bound value from the host form */ + component: ComponentType<{ + value: unknown + onChange: (value: unknown) => void + disabled?: boolean + }> +} + /** * Plugin metadata */ @@ -166,6 +263,24 @@ 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 + + /** + * Get user settings fields provided by this plugin. + * Host binds each field into the shared Account form by `name`. + */ + getUserSettingsFields?: () => UserSettingsFieldExtension[] | 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..6f0b25a35 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,39 @@ 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 +78,7 @@ export default defineConfig({ tanstackRouter({ target: 'react', autoCodeSplitting: true }), viteReact(), runtimeEnv(), + servePlugins(), ], build: { sourcemap: true, @@ -57,6 +91,10 @@ export default defineConfig({ '@': resolve(__dirname, './src'), }, }, + preview: { + port: 3001, + host: true, + }, server: { port: 3001, host: true,