IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

app-configurator-panel.tsx

Buka raw
"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<unknown>;
    installCustomApp?: (input: InstallCustomAppInput) => Promise<unknown>;
    saveAppSettings?: (input: SaveAppSettingsInput) => Promise<unknown>;
  };
  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<ConfiguratorView>(
    defaultViewForContext(context),
  );
  const [classicState, setClassicState] =
    useState<ClassicConfigState>(initialClassicState);
  const [composeDraft, setComposeDraft] = useState(initialComposeDraft);
  const [composeParseError, setComposeParseError] = useState<string | null>(
    initialParsed.error,
  );
  const [dockerRunState, setDockerRunState] = useState<DockerRunState>(
    initialDockerRunState,
  );
  const [didSave, setDidSave] = useState(false);
  const [isSaving, setIsSaving] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  const [installingAppId, setInstallingAppId] = useState<string | null>(null);

  const initialClassicRef = useRef<ClassicConfigState>(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<ConfiguratorView[]>(() => {
    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 (
    <div className="flex h-full flex-col bg-card/90">
      <ConfiguratorHeader
        title={title}
        activeView={activeView}
        views={availableViews}
        onViewChange={setActiveView}
        onClose={onClose}
      />

      {shouldBlockOnTemplateLoading ? (
        <div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
          Loading app configuration...
        </div>
      ) : (
        <>
          {context === "installed_edit" && composeQuery.isError ? (
            <div className="mx-3 mt-2 rounded-lg border border-status-red/40 bg-status-red/10 px-2 py-1 text-2xs text-status-red">
              {String(composeQuery.error).includes("installed_compose_missing")
                ? "Installed compose file is unavailable for this app."
                : "Unable to load compose source for this app."}
            </div>
          ) : null}

          {context !== "custom_install" && !effectiveTemplate ? (
            <div className="mx-3 mt-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1 text-2xs text-amber-200">
              App template metadata is unavailable. You can still edit and
              submit manually.
            </div>
          ) : null}

          {activeView === "classic" ? (
            <ClassicFormView
              appIdLabel={queryAppId ?? appId}
              state={classicState}
              onChange={handleClassicChange}
            />
          ) : null}

          {activeView === "compose" ? (
            <ComposeEditorView
              composeDraft={composeDraft}
              onChange={handleComposeChange}
              parseError={composeParseError}
            />
          ) : null}

          {activeView === "docker_run" && context === "custom_install" ? (
            <DockerRunView
              state={dockerRunState}
              onChange={(nextState) => {
                setDidSave(false);
                setSaveError(null);
                setDockerRunState(nextState);
              }}
            />
          ) : null}
        </>
      )}

      <footer className="flex flex-col border-t border-glass-border px-3 py-2.5 gap-2">
        {installingAppId && installingOperation ? (
          <div className="flex flex-col gap-1.5">
            <div className="flex items-center justify-between gap-3">
              <span className="text-xs font-medium text-foreground">
                {installingOperation.status === "success"
                  ? "Installation complete"
                  : installingOperation.status === "error"
                    ? "Installation failed"
                    : installingOperation.step || "Installing…"}
              </span>
              <span className="text-2xs tabular-nums text-muted-foreground shrink-0">
                {installingOperation.progressPercent}%
              </span>
            </div>
            <div className="h-1 w-full overflow-hidden rounded-full bg-white/8">
              <div
                className={`h-full rounded-full transition-all duration-500 ${
                  installingOperation.status === "success"
                    ? "bg-status-green"
                    : installingOperation.status === "error"
                      ? "bg-status-red"
                      : "bg-primary"
                }`}
                style={{ width: `${installingOperation.progressPercent}%` }}
              />
            </div>
            {installingOperation.status === "success" ? (
              <span className="text-2xs text-status-green">
                Installed successfully — closing window…
              </span>
            ) : null}
          </div>
        ) : (
          <div className="flex items-center justify-between gap-2">
            <div className="flex min-w-0 flex-col gap-0.5">
              {saveError ? (
                <span className="truncate text-xs text-status-red">{saveError}</span>
              ) : context === "installed_edit" ? (
                <span className="text-xs text-muted-foreground">
                  {isSaving ? "Applying changes…" : "Edit and save to redeploy"}
                </span>
              ) : (
                <span className="text-xs text-muted-foreground">
                  {didSave ? "Installation started" : "Configure settings before installing"}
                </span>
              )}
            </div>
            <button
              type="button"
              onClick={() => void handleSubmit()}
              disabled={isSaving || !canSubmit}
              className="flex shrink-0 items-center gap-1.5 rounded-[var(--radius)] bg-primary px-3.5 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
            >
              {isSaving ? <Loader2 className="size-3.5 animate-spin" /> : null}
              {buttonLabel}
            </button>
          </div>
        )}
      </footer>
    </div>
  );
}
byte
18.268
baris
559
Jenis
tsx
Commit terakhir
16 Oktober 2026