"use client"; import type { StoreAppDetail } from "@/lib/shared/contracts/apps"; import type { AppActionTarget } from "@/modules/apps/components/app-grid"; import { ClassicFormView } from "@/modules/apps/components/configurator/classic-form-view"; import { ComposeEditorView } from "@/modules/apps/components/configurator/compose-editor-view"; import { ConfiguratorHeader } from "@/modules/apps/components/configurator/configurator-header"; import { buildInitialComposeDraft, buildInitialDockerRunState, buildInstallPayloadFromClassic, buildSettingsPayloadFromClassic, classicStateToCompose, createDefaultClassicState, safeComposeToClassicState, toAppId, type ClassicConfigState, type ConfiguratorView, type DockerRunState, } from "@/modules/apps/components/configurator/configurator-mapper"; import { DockerRunView } from "@/modules/apps/components/configurator/docker-run-view"; import { useAppCompose } from "@/modules/apps/hooks/useAppCompose"; import { type InstallAppInput, type InstallCustomAppInput, type SaveAppSettingsInput, } from "@/modules/apps/hooks/useStoreActions"; import { useSharedStoreActions } from "@/modules/apps/hooks/StoreActionsContext"; import { useStoreApp } from "@/modules/apps/hooks/useStoreApp"; import { Loader2 } from "@/components/icons/platform-icons"; import { useEffect, useMemo, useRef, useState } from "react"; export type AppConfiguratorContext = | "installed_edit" | "catalog_install" | "custom_install"; export type AppConfiguratorPanelProps = { context: AppConfiguratorContext; target?: AppActionTarget; template?: StoreAppDetail; customDefaults?: { name?: string; iconUrl?: string; }; actions?: { installApp?: (input: InstallAppInput) => Promise; installCustomApp?: (input: InstallCustomAppInput) => Promise; saveAppSettings?: (input: SaveAppSettingsInput) => Promise; }; onClose?: () => void; }; function parseDashboardUrl(input: string | undefined) { const browserHost = typeof window !== "undefined" && window.location.hostname.trim().length > 0 ? window.location.hostname : "localhost"; if (!input) { return { host: browserHost, port: undefined as number | undefined, }; } try { const parsed = new URL(input); return { host: parsed.hostname || browserHost, port: parsed.port ? Number.parseInt(parsed.port, 10) : undefined, }; } catch { return { host: browserHost, port: undefined as number | undefined, }; } } function defaultViewForContext( context: AppConfiguratorContext, ): ConfiguratorView { if (context === "custom_install") return "docker_run"; return "classic"; } function titleForContext( context: AppConfiguratorContext, currentTitle: string, ) { if (context === "installed_edit") return `${currentTitle || "App"} Settings`; if (context === "catalog_install") return `Install ${currentTitle || "App"}`; return "Install Custom App"; } export function AppConfiguratorPanel({ context, target, template, customDefaults, actions, onClose, }: AppConfiguratorPanelProps) { const derivedTargetAppId = target ? target.appId || toAppId(target.appName) : undefined; const queryAppId = context === "custom_install" ? null : (template?.id ?? derivedTargetAppId ?? null); const detailQuery = useStoreApp(queryAppId); const fetchedDetail = (detailQuery.data ?? undefined) || undefined; const effectiveTemplate = useMemo(() => { if (context === "catalog_install") { return template ?? fetchedDetail; } if (context === "installed_edit") { return fetchedDetail ?? template; } return undefined; }, [context, fetchedDetail, template]); const composeSource = context === "installed_edit" ? "installed" : "catalog"; const shouldFetchCompose = Boolean( queryAppId && context !== "custom_install", ); const composeQuery = useAppCompose( queryAppId ?? undefined, shouldFetchCompose, composeSource, ); const composeResponse = composeQuery.data; const composeData = composeResponse?.primary; const primaryServiceName = composeResponse?.primaryServiceName; const dashboard = parseDashboardUrl(target?.dashboardUrl); const seedTitle = effectiveTemplate?.installedConfig?.displayName ?? effectiveTemplate?.name ?? target?.appName ?? customDefaults?.name ?? ""; const seedIconUrl = effectiveTemplate?.installedConfig?.iconUrl ?? effectiveTemplate?.logoUrl ?? customDefaults?.iconUrl ?? ""; const seedPort = effectiveTemplate?.webUiPort ?? dashboard.port; const seedHost = dashboard.host; const seedWebUiLink = effectiveTemplate?.installedConfig?.webUiUrl ?? ""; const fallbackAppId = toAppId(seedTitle || "custom-app") || "custom-app"; const appId = queryAppId ?? fallbackAppId; const initialComposeDraft = useMemo( () => composeResponse?.compose ?? buildInitialComposeDraft({ appId, template: effectiveTemplate, composeData, dockerImageFallback: `${appId}/${appId}:latest`, }), [appId, composeData, composeResponse?.compose, effectiveTemplate], ); const initialParsed = useMemo( () => safeComposeToClassicState({ composeDraft: initialComposeDraft, seed: { title: seedTitle, iconUrl: seedIconUrl, fallbackPort: seedPort, fallbackHost: seedHost, webUiLink: seedWebUiLink, }, appId, primaryServiceName, }), [ appId, initialComposeDraft, primaryServiceName, seedHost, seedIconUrl, seedPort, seedTitle, seedWebUiLink, ], ); const initialClassicState: ClassicConfigState = initialParsed.state ?? createDefaultClassicState({ title: seedTitle, iconUrl: seedIconUrl, fallbackPort: seedPort, fallbackHost: seedHost, webUiLink: seedWebUiLink, }); const initialDockerRunState = useMemo( () => buildInitialDockerRunState({ title: initialClassicState.title || "my-app", iconUrl: initialClassicState.iconUrl, port: initialClassicState.webUi.port, }), [ initialClassicState.iconUrl, initialClassicState.title, initialClassicState.webUi.port, ], ); const [activeView, setActiveView] = useState( defaultViewForContext(context), ); const [classicState, setClassicState] = useState(initialClassicState); const [composeDraft, setComposeDraft] = useState(initialComposeDraft); const [composeParseError, setComposeParseError] = useState( initialParsed.error, ); const [dockerRunState, setDockerRunState] = useState( initialDockerRunState, ); const [didSave, setDidSave] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [installingAppId, setInstallingAppId] = useState(null); const initialClassicRef = useRef(initialClassicState); const initialComposeRef = useRef(initialComposeDraft); const fallbackActions = useSharedStoreActions(); const installingOperation = installingAppId ? fallbackActions.operationsByApp[installingAppId] : null; // Auto-close on success; surface error and stay open on failure. useEffect(() => { if (!installingAppId || !installingOperation) return; if (installingOperation.status === "success") { const timer = setTimeout(() => onClose?.(), 1_500); return () => clearTimeout(timer); } if (installingOperation.status === "error") { setSaveError(installingOperation.message ?? "Installation failed"); setInstallingAppId(null); } }, [installingAppId, installingOperation, onClose]); const saveAppSettings = actions?.saveAppSettings ?? fallbackActions.saveAppSettings; const installApp = actions?.installApp ?? fallbackActions.installApp; const installCustomApp = actions?.installCustomApp ?? fallbackActions.installCustomApp; useEffect(() => { setActiveView(defaultViewForContext(context)); setClassicState(initialClassicState); setComposeDraft(initialComposeDraft); setComposeParseError(initialParsed.error); setDockerRunState(initialDockerRunState); setDidSave(false); setSaveError(null); setInstallingAppId(null); initialClassicRef.current = initialClassicState; initialComposeRef.current = initialComposeDraft; }, [ context, initialClassicState, initialComposeDraft, initialDockerRunState, initialParsed.error, ]); const availableViews = useMemo(() => { if (context === "custom_install") { return ["classic", "compose", "docker_run"]; } return ["classic", "compose"]; }, [context]); useEffect(() => { if (availableViews.includes(activeView)) return; setActiveView(availableViews[0]); }, [activeView, availableViews]); function handleClassicChange(nextState: ClassicConfigState) { setDidSave(false); setSaveError(null); setClassicState(nextState); setComposeDraft((previousDraft) => classicStateToCompose(nextState, previousDraft, { appId, primaryServiceName, }), ); setComposeParseError(null); } function handleComposeChange(nextDraft: string) { setDidSave(false); setSaveError(null); setComposeDraft(nextDraft); const parsed = safeComposeToClassicState({ composeDraft: nextDraft, seed: { title: classicState.title, iconUrl: classicState.iconUrl, fallbackPort: seedPort, fallbackHost: seedHost, }, appId, primaryServiceName, }); if (parsed.state) { setClassicState(parsed.state); setComposeParseError(null); return; } setComposeParseError(parsed.error); } const title = titleForContext(context, classicState.title || seedTitle); const buttonLabel = context === "installed_edit" ? isSaving ? "Saving..." : "Save" : isSaving ? "Installing..." : "Install"; // "classic" view edits form fields directly — the compose YAML is derived // from those fields, so a parse error on the underlying draft is irrelevant // and must not block the submit button. const classicCanSubmit = classicState.title.trim().length > 0 && classicState.dockerImage.trim().length > 0 && (context !== "catalog_install" || Boolean(queryAppId)); const composeCanSubmit = composeDraft.trim().length > 0 && !composeParseError && (context !== "catalog_install" || Boolean(queryAppId)); const dockerRunCanSubmit = dockerRunState.name.trim().length > 0 && dockerRunState.source.trim().length > 0; const canSubmit = activeView === "classic" ? classicCanSubmit : activeView === "docker_run" ? dockerRunCanSubmit : composeCanSubmit; const shouldBlockOnTemplateLoading = context === "catalog_install" && !effectiveTemplate && detailQuery.isLoading; async function handleSubmit() { if (isSaving || !canSubmit) return; setIsSaving(true); setDidSave(false); setSaveError(null); try { if (context === "installed_edit") { if (!queryAppId) throw new Error("App ID is required for settings"); await saveAppSettings( buildSettingsPayloadFromClassic({ appId: queryAppId, current: classicState, initial: initialClassicRef.current, composeSource: composeDraft, initialComposeSource: initialComposeRef.current, }), ); } else if (context === "catalog_install") { if (!queryAppId) throw new Error("App ID is required for install"); await installApp( buildInstallPayloadFromClassic({ appId: queryAppId, state: classicState, composeSource: composeDraft, }), ); } else if (activeView === "docker_run") { const result = (await installCustomApp({ name: dockerRunState.name.trim(), iconUrl: dockerRunState.iconUrl.trim() || undefined, repositoryUrl: dockerRunState.repositoryUrl.trim() || undefined, sourceType: "docker-run", source: dockerRunState.source, })) as { appId: string } | undefined; setInstallingAppId(result?.appId ?? null); return; } else { const result = (await installCustomApp({ name: classicState.title.trim() || "Custom App", iconUrl: classicState.iconUrl.trim() || undefined, sourceType: "docker-compose", source: composeDraft, })) as { appId: string } | undefined; setInstallingAppId(result?.appId ?? null); return; } setDidSave(true); onClose?.(); } catch (error) { setSaveError( error instanceof Error ? error.message : "Unable to submit app configuration.", ); } finally { setIsSaving(false); } } return (
{shouldBlockOnTemplateLoading ? (
Loading app configuration...
) : ( <> {context === "installed_edit" && composeQuery.isError ? (
{String(composeQuery.error).includes("installed_compose_missing") ? "Installed compose file is unavailable for this app." : "Unable to load compose source for this app."}
) : null} {context !== "custom_install" && !effectiveTemplate ? (
App template metadata is unavailable. You can still edit and submit manually.
) : null} {activeView === "classic" ? ( ) : null} {activeView === "compose" ? ( ) : null} {activeView === "docker_run" && context === "custom_install" ? ( { setDidSave(false); setSaveError(null); setDockerRunState(nextState); }} /> ) : null} )}
); }