IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

app-grid-controller.ts

Open raw
"use client";

import {
  buildActiveAppOperations,
  buildAppItems,
  buildUnmanagedAppItems,
  requireAppActionTarget,
  resolveAppActionTarget,
  type AppActionTarget,
  type AppGridStatus,
  type AppItem,
} from "@/modules/apps/components/app-grid-presenters";
import { useInstalledApps } from "@/modules/apps/hooks/useInstalledApps";
import { useSharedStoreActions } from "@/modules/apps/hooks/StoreActionsContext";
import { useStoreCatalog } from "@/modules/apps/hooks/useStoreCatalog";
import { useUnmanagedContainers } from "@/modules/apps/hooks/useUnmanagedContainers";
import { useEffect, useMemo, useState } from "react";
import { isStoreOperationActiveStatus } from "@/lib/shared/store-operations";
import { toast } from "sonner";

type AppGridMenuAction =
  | "open"
  | "start"
  | "stop"
  | "restart"
  | "logs"
  | "terminal"
  | "settings"
  | "update"
  | "copy-url"
  | "remove";

type UseAppGridControllerOptions = {
  onCopyUrl?: (target: AppActionTarget) => void;
  onOpenDashboard?: (target: AppActionTarget) => void;
  onOpenSettings?: (target: AppActionTarget) => void;
  onOpenTerminal?: (target: AppActionTarget) => void;
  onViewLogs?: (target: AppActionTarget) => void;
};

export function useAppGridController({
  onCopyUrl,
  onOpenDashboard,
  onOpenSettings,
  onOpenTerminal,
  onViewLogs,
}: UseAppGridControllerOptions) {
  const installedAppsQuery = useInstalledApps();
  const unmanagedContainersQuery = useUnmanagedContainers();
  const installedCatalogQuery = useStoreCatalog({
    installedOnly: true,
  });
  const {
    operationsByApp,
    uninstallApp,
    startApp,
    stopApp,
    restartApp,
    checkAppUpdates,
  } = useSharedStoreActions();
  const [statusByAppId, setStatusByAppId] = useState<
    Record<string, AppGridStatus>
  >({});
  const [activeAppId, setActiveAppId] = useState<string | null>(null);
  const [uninstallAppId, setUninstallAppId] = useState<string | null>(null);
  const [uninstallError, setUninstallError] = useState<string | null>(null);
  const [uninstallPending, setUninstallPending] = useState(false);
  const [actionError, setActionError] = useState<string | null>(null);
  const [contextMenu, setContextMenu] = useState<{
    x: number;
    y: number;
    appId: string;
  } | null>(null);

  const installedCatalogApps = useMemo(
    () => installedCatalogQuery.data?.apps ?? [],
    [installedCatalogQuery.data?.apps],
  );
  const installedApps = useMemo(
    () => installedAppsQuery.data ?? [],
    [installedAppsQuery.data],
  );

  const unmanagedContainers = useMemo(
    () => unmanagedContainersQuery.data ?? [],
    [unmanagedContainersQuery.data],
  );

  const apps = useMemo(
    () => [
      ...buildAppItems({
        installedApps,
        installedCatalogApps,
        operationsByApp,
        statusByAppId,
      }),
      // Appended, so Homeio's own apps keep the front of the grid.
      ...buildUnmanagedAppItems(unmanagedContainers),
    ],
    [
      installedApps,
      installedCatalogApps,
      operationsByApp,
      statusByAppId,
      unmanagedContainers,
    ],
  );

  const activeOperations = useMemo(
    () =>
      buildActiveAppOperations({
        installedApps,
        installedCatalogApps,
        operationsByApp,
      }),
    [installedApps, installedCatalogApps, operationsByApp],
  );

  const isAppsLoading =
    installedAppsQuery.isLoading || installedCatalogQuery.isLoading;
  const isAppsError =
    installedAppsQuery.isError && installedCatalogQuery.isError;
  const primaryOperation = activeOperations[0] ?? null;
  const menuApp = contextMenu
    ? (apps.find((app) => app.id === contextMenu.appId) ?? null)
    : null;
  const uninstallTarget = uninstallAppId
    ? (apps.find((app) => app.id === uninstallAppId) ?? null)
    : null;
  const menuOperation = menuApp ? operationsByApp[menuApp.id] : undefined;
  const menuTarget = menuApp ? resolveAppActionTarget(menuApp) : null;
  const menuHasDashboardUrl = Boolean(
    menuTarget && menuTarget.dashboardUrl.trim().length > 0,
  );
  const isMenuAppBusy = Boolean(
    (menuOperation && isStoreOperationActiveStatus(menuOperation.status)) ||
      menuApp?.status === "updating",
  );

  function closeContextMenu() {
    setContextMenu(null);
    setActiveAppId(null);
  }

  useEffect(() => {
    if (!contextMenu) return;
    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") closeContextMenu();
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [contextMenu]);

  function updateAppStatus(appId: string, status: AppItem["status"]) {
    setStatusByAppId((previous) => ({
      ...previous,
      [appId]: status,
    }));
  }

  function clearAppStatus(appId: string) {
    setStatusByAppId((previous) => {
      if (!(appId in previous)) return previous;
      const next = { ...previous };
      delete next[appId];
      return next;
    });
  }

  function openContextMenu(event: React.MouseEvent, app: AppItem) {
    event.preventDefault();
    setActionError(null);
    setActiveAppId(app.id);
    const x = Math.min(event.clientX, window.innerWidth - 220);
    const y = Math.min(event.clientY, window.innerHeight - 320);
    setContextMenu({ x, y, appId: app.id });
  }

  async function openDashboardForApp(app: AppItem) {
    setActionError(null);

    try {
      const resolvedTarget = requireAppActionTarget(app, {
        requireDashboardUrl: true,
      });
      if (!resolvedTarget || resolvedTarget.dashboardUrl.trim().length === 0) {
        setActionError("No web port configured for this app.");
        return;
      }

      if (onOpenDashboard) {
        onOpenDashboard(resolvedTarget);
      } else {
        window.open(
          resolvedTarget.dashboardUrl,
          "_blank",
          "noopener,noreferrer",
        );
      }
    } catch {}
  }

  async function handleMenuAction(action: AppGridMenuAction) {
    if (!menuApp) return;

    try {
      setActionError(null);

      if (action === "open") {
        await openDashboardForApp(menuApp);
      } else if (action === "start") {
        updateAppStatus(menuApp.id, "running");
        try {
          await startApp(menuApp.id);
          toast.success(`${menuApp.name} started`);
        } finally {
          clearAppStatus(menuApp.id);
        }
      } else if (action === "stop") {
        updateAppStatus(menuApp.id, "stopped");
        try {
          await stopApp(menuApp.id);
          toast.success(`${menuApp.name} stopped`);
        } finally {
          clearAppStatus(menuApp.id);
        }
      } else if (action === "restart") {
        updateAppStatus(menuApp.id, "updating");
        try {
          await restartApp(menuApp.id);
          toast.success(`${menuApp.name} restarted`);
        } finally {
          clearAppStatus(menuApp.id);
        }
      } else if (action === "update") {
        updateAppStatus(menuApp.id, "updating");
        try {
          await checkAppUpdates(menuApp.id);
        } finally {
          clearAppStatus(menuApp.id);
        }
      } else if (action === "logs") {
        const resolvedTarget = requireAppActionTarget(menuApp, {
          requireContainerName: true,
        });
        if (!resolvedTarget) {
          setActionError("Container name is not available for this app.");
          closeContextMenu();
          return;
        }
        onViewLogs?.(resolvedTarget);
      } else if (action === "terminal") {
        const resolvedTarget = requireAppActionTarget(menuApp, {
          requireContainerName: true,
        });
        if (!resolvedTarget) {
          setActionError("Container name is not available for this app.");
          closeContextMenu();
          return;
        }
        onOpenTerminal?.(resolvedTarget);
      } else if (action === "settings") {
        const resolvedTarget = requireAppActionTarget(menuApp);
        if (!resolvedTarget) {
          closeContextMenu();
          return;
        }
        onOpenSettings?.(resolvedTarget);
      } else if (action === "copy-url") {
        const resolvedTarget = requireAppActionTarget(menuApp, {
          requireDashboardUrl: true,
        });
        if (!resolvedTarget) {
          setActionError("No web port configured for this app.");
          closeContextMenu();
          return;
        }

        if (onCopyUrl) {
          onCopyUrl(resolvedTarget);
        } else if (navigator.clipboard?.writeText) {
          await navigator.clipboard.writeText(resolvedTarget.dashboardUrl);
          toast.success("URL copied to clipboard");
        } else {
          setActionError("Clipboard is not available. Copy the URL manually: " + resolvedTarget.dashboardUrl);
        }
      } else if (action === "remove") {
        setUninstallError(null);
        setUninstallAppId(menuApp.id);
      }
    } catch (error) {
      setActionError(error instanceof Error ? error.message : "Action failed.");
    }

    closeContextMenu();
  }

  async function confirmUninstall(input: { deleteData: boolean }) {
    if (!uninstallAppId) return;

    setUninstallError(null);
    setUninstallPending(true);

    try {
      await uninstallApp({
        appId: uninstallAppId,
        removeVolumes: input.deleteData,
      });
      setUninstallAppId(null);
    } catch (error) {
      setUninstallError(
        error instanceof Error ? error.message : "Unable to start uninstall.",
      );
    } finally {
      setUninstallPending(false);
    }
  }

  function handleUninstallDialogOpenChange(nextOpen: boolean) {
    if (nextOpen) return;
    setUninstallAppId(null);
    setUninstallError(null);
  }

  return {
    actionError,
    activeAppId,
    dismissActionError: () => setActionError(null),
    activeOperationsCount: activeOperations.length,
    apps,
    closeContextMenu,
    confirmUninstall,
    contextMenu,
    handleMenuAction,
    handleUninstallDialogOpenChange,
    isAppsError,
    isAppsLoading,
    isMenuAppBusy,
    menuApp,
    menuHasDashboardUrl,
    openContextMenu,
    openDashboardForApp,
    primaryOperation,
    uninstallDialogAppName: uninstallTarget?.name ?? null,
    uninstallDialogError: uninstallError,
    uninstallDialogOpen: Boolean(uninstallAppId),
    uninstallDialogSubmitting: uninstallPending,
  };
}
bytes
10,322
lines
350
Type
ts
Latest commit
16 October 2026