IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

app-grid-content.tsx

Open raw
"use client";

import { getStoreOperationActionLabel } from "@/lib/shared/store-operations";
import {
  getAppVisualState,
  type ActiveAppOperation,
  type AppItem,
} from "@/modules/apps/components/app-grid-presenters";
import { Loader2 } from "@/components/icons/platform-icons";
import { Skeleton } from "@/components/ui/skeleton";
import { useState, type MouseEvent } from "react";

type AppGridContentProps = {
  activeAppId: string | null;
  activeOperationsCount: number;
  animationsEnabled: boolean;
  apps: AppItem[];
  iconSize: "small" | "medium" | "large";
  isAppsError: boolean;
  isAppsLoading: boolean;
  onAppContextMenu: (event: MouseEvent, app: AppItem) => void;
  onOpenApp: (app: AppItem) => void;
  primaryOperation: ActiveAppOperation | null;
};

export function AppGridContent({
  activeAppId,
  activeOperationsCount,
  animationsEnabled,
  apps,
  iconSize,
  isAppsError,
  isAppsLoading,
  onAppContextMenu,
  onOpenApp,
  primaryOperation,
}: AppGridContentProps) {
  const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
  const iconContainerClass =
    iconSize === "small"
      ? "size-12 rounded-xl"
      : iconSize === "large"
        ? "size-16 rounded-3xl"
        : "size-14 rounded-2xl";
  const iconGlyphClass =
    iconSize === "small"
      ? "size-6"
      : iconSize === "large"
        ? "size-8"
        : "size-7";

  return (
    <>
      {primaryOperation ? (
        <div
          className="fixed bottom-[5.75rem] left-1/2 z-[150] -translate-x-1/2 w-[min(92vw,22rem)] overflow-hidden rounded-[var(--system-radius-control)] border border-glass-border/50 bg-popover/90 shadow-[0_8px_32px_rgba(0,0,0,0.28)] backdrop-blur-2xl animate-in fade-in slide-in-from-bottom-2 duration-200"
          role="status"
          aria-live="polite"
        >
          <div className="flex items-center gap-3 px-4 py-3">
            <span className="size-1.5 shrink-0 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.6)] animate-pulse" />
            <div className="min-w-0 flex-1">
              <p className="truncate text-xs font-medium text-foreground">
                {getStoreOperationActionLabel(primaryOperation.action)}{" "}
                <span className="text-foreground/60">{primaryOperation.appName}</span>
              </p>
            </div>
            <span className="shrink-0 tabular-nums text-2xs text-muted-foreground/60">
              {activeOperationsCount > 1
                ? `+${activeOperationsCount - 1} more`
                : `${primaryOperation.progressPercent}%`}
            </span>
          </div>
          <div className="h-px w-full bg-glass-border/30">
            <div
              className="h-full bg-primary/60 transition-all duration-500"
              style={{ width: `${Math.max(2, primaryOperation.progressPercent)}%` }}
            />
          </div>
        </div>
      ) : null}

      {isAppsLoading && apps.length === 0 ? (
        <div className="mt-24" role="status" aria-live="polite">
          <div className="mx-auto flex max-w-xs flex-col items-center text-center">
            <div className="relative h-1 w-48 overflow-hidden rounded-full bg-glass-border/30">
              <div className="absolute inset-0 animate-grid-scan rounded-full bg-gradient-to-r from-transparent via-primary/60 to-transparent" />
            </div>
            <p className="mt-4 text-2xs uppercase tracking-[0.2em] font-mono text-muted-foreground">
              Syncing containers
            </p>
          </div>
          {/* Skeleton placeholder icons that match the real grid layout */}
          <div
            aria-hidden="true"
            className="mt-8 grid grid-cols-[repeat(4,minmax(0,5.5rem))] justify-center gap-x-1 gap-y-6 opacity-40 sm:grid-cols-[repeat(5,minmax(0,5.5rem))] md:grid-cols-[repeat(6,minmax(0,5.5rem))] lg:grid-cols-[repeat(8,minmax(0,5.5rem))] xl:grid-cols-[repeat(10,minmax(0,5.5rem))]"
          >
            {Array.from({ length: 20 }).map((_, i) => (
              <div key={i} className="flex flex-col items-center gap-2 p-2">
                <Skeleton className={`${iconContainerClass} shrink-0`} />
                <Skeleton className="h-2.5 w-12 rounded" />
              </div>
            ))}
          </div>
        </div>
      ) : isAppsError && apps.length === 0 ? (
        <div className="mt-24 text-center">
          <p className="text-2xs uppercase tracking-[0.2em] font-mono text-status-red">
            Connection failed
          </p>
        </div>
      ) : apps.length === 0 ? (
        <div className="mt-24 min-h-[12rem]" aria-hidden="true" />
      ) : (
        <div className="mt-24 grid grid-cols-[repeat(4,minmax(0,5.5rem))] justify-center gap-x-1 gap-y-6 sm:grid-cols-[repeat(5,minmax(0,5.5rem))] md:grid-cols-[repeat(6,minmax(0,5.5rem))] lg:grid-cols-[repeat(8,minmax(0,5.5rem))] xl:grid-cols-[repeat(10,minmax(0,5.5rem))]">
          {apps.map((app) => {
            const visualState = getAppVisualState(app);
            const BadgeIcon = visualState.badgeIcon;

            return (
              <button
                key={app.id}
                onClick={() => onOpenApp(app)}
                onContextMenu={(event) => onAppContextMenu(event, app)}
                className={`group flex flex-col items-center gap-2 rounded-xl p-2 cursor-pointer ${
                  activeAppId === app.id
                    ? "bg-primary/15 ring-1 ring-primary/40"
                    : "hover:bg-foreground/5"
                } ${animationsEnabled ? "transition-all duration-200" : ""} ${
                  animationsEnabled ? "group-active:scale-95" : ""
                }`}
                aria-label={`Open ${app.name}`}
                data-app-status={app.status}
                title={visualState.title}
              >
                <div className="relative">
                  <div
                    className={`${iconContainerClass} relative flex items-center justify-center overflow-hidden border grain-overlay ${visualState.ringClass} ${!app.logoUrl ? `${app.bgColor} ${app.color}` : "bg-white/90"} shadow-lg shadow-black/20 ${
                      animationsEnabled
                        ? `transition-transform duration-200 group-hover:scale-110`
                        : ""
                    }`}
                    style={{ transform: "translateZ(0)" }}
                  >
                    {app.logoUrl ? (
                      <>
                        {!loadedImages.has(app.logoUrl) ? (
                          <div className="absolute inset-0 flex items-center justify-center">
                            <div className={`${iconGlyphClass} animate-pulse`}>
                              <app.icon className="h-full w-full opacity-30" />
                            </div>
                          </div>
                        ) : null}
                        {/* eslint-disable-next-line @next/next/no-img-element */}
                        <img
                          src={app.logoUrl}
                          alt={`${app.name} logo`}
                          className={`absolute inset-0 h-full w-full object-contain p-1.5 ${visualState.imageClass}`}
                          loading="lazy"
                          decoding="async"
                          onLoad={() => {
                            setLoadedImages((previous) =>
                              new Set(previous).add(app.logoUrl!),
                            );
                          }}
                          onError={(event) => {
                            const img = event.currentTarget;
                            const container = img.parentElement;
                            if (!container) return;
                            img.style.display = "none";
                            container.classList.remove("bg-white/90");
                            container.classList.add(app.bgColor, app.color);
                            const fallback = container.querySelector(
                              "[data-fallback-icon]",
                            );
                            if (fallback instanceof HTMLElement) {
                              fallback.style.display = "block";
                            }
                          }}
                        />
                        <app.icon
                          className={`${iconGlyphClass} hidden ${visualState.imageClass}`}
                          data-fallback-icon
                        />
                      </>
                    ) : (
                      <app.icon
                        className={`${iconGlyphClass} ${visualState.imageClass}`}
                      />
                    )}
                  </div>
                  {BadgeIcon ? (
                    <span className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
                      <BadgeIcon
                        className={`size-7 drop-shadow-[0_1px_3px_rgba(0,0,0,0.7)] ${visualState.badgeClass} ${
                          animationsEnabled ? visualState.badgeIconClass : ""
                        }`}
                      />
                    </span>
                  ) : null}
                  {app.updateAvailable && app.status === "running" ? (
                    <span
                      className="pointer-events-none absolute -top-0.5 -right-0.5 size-2.5 rounded-full border border-background bg-primary"
                      title="Update available"
                    />
                  ) : null}
                </div>

                <span
                  className={`max-w-[4.5rem] truncate text-center text-2xs font-medium leading-tight tracking-tight text-foreground/80 ${
                    animationsEnabled
                      ? "transition-colors group-hover:text-foreground"
                      : ""
                  }`}
                >
                  {app.name}
                </span>
              </button>
            );
          })}
        </div>
      )}
    </>
  );
}
bytes
10,057
lines
226
Type
tsx
Latest commit
16 October 2026