"use client"; import { useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import type { StoreAppDetail, StoreAppSummary } from "@/lib/shared/contracts/apps"; import { queryKeys } from "@/lib/shared/query-keys"; import { getStoreOperationLabel, isStoreOperationActiveStatus } from "@/lib/shared/store-operations"; import { AppStoreInstallMenu } from "@/modules/apps/components/app-store-install-menu"; import { AppStoreSourcesDialog } from "@/modules/apps/components/app-store-sources-dialog"; import { AppConfiguratorPanel } from "@/modules/apps/components/configurator/app-configurator-panel"; import { UninstallAppDialog } from "@/modules/apps/components/uninstall-app-dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useInstalledApps } from "@/modules/apps/hooks/useInstalledApps"; import type { AppOperationState } from "@/modules/apps/hooks/useStoreActions"; import { useSharedStoreActions } from "@/modules/apps/hooks/StoreActionsContext"; import { useStoreApp } from "@/modules/apps/hooks/useStoreApp"; import { useStoreCatalog } from "@/modules/apps/hooks/useStoreCatalog"; import { useStoreOperation } from "@/modules/apps/hooks/useStoreOperation"; import { useStoreSourceActions, useStoreSources } from "@/modules/apps/hooks/useStoreSources"; import { ArrowUpCircle, ChevronLeftIcon as ChevronLeft, Download, ExternalLink, Loader2, Package, Search, Sparkles, Star, Trash2, Wrench, } from "@/components/icons/platform-icons"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { PANEL_SHELL as STORE_PANEL_SHELL, PANEL_INSET as STORE_PANEL_INSET, BADGE_SURFACE as STORE_BADGE_SURFACE, } from "@/lib/ui/surface-tokens"; import { cn } from "@/lib/utils"; const STORE_PAGE_SIZE = 24; const STORE_SECTION_LIMIT = 8; // ── Helpers ─────────────────────────────────────────────────────────────────── /** * Prefer the link the operator saved. The automatic one is built from the * browser's own location, which is wrong behind a reverse proxy or tunnel. */ function resolveStoreDetailUrl(detail: StoreAppDetail) { const customUrl = detail.installedConfig?.webUiUrl?.trim() ?? ""; if (customUrl.length > 0) return customUrl; if (!detail.webUiPort) return null; const protocol = typeof window !== "undefined" ? window.location.protocol : "http:"; const hostname = typeof window !== "undefined" ? window.location.hostname : "localhost"; return `${protocol}//${hostname}:${detail.webUiPort}`; } function isOperationBusy(op: AppOperationState | undefined) { return Boolean(op && isStoreOperationActiveStatus(op.status)); } function compareImageTags(a: string, b: string): number { const pa = a.split(".").map((s) => parseInt(s, 10) || 0); const pb = b.split(".").map((s) => parseInt(s, 10) || 0); const len = Math.max(pa.length, pb.length); for (let i = 0; i < len; i++) { const diff = (pa[i] ?? 0) - (pb[i] ?? 0); if (diff !== 0) return diff; } return 0; } function extractImageTag(image: string | null | undefined): string | null { if (!image) return null; const lastSlash = image.lastIndexOf("/"); const lastColon = image.lastIndexOf(":"); if (lastColon > lastSlash) return image.slice(lastColon + 1) || null; return null; } function buildUpdateTooltipLines(app: StoreAppSummary): string[] { if (!app.updateAvailable) return []; const currentTag = extractImageTag(app.installedImage); const availableTag = extractImageTag(app.image); const catalogIsNewer = currentTag !== null && availableTag !== null && compareImageTags(availableTag, currentTag) > 0; if (!catalogIsNewer) return []; const lines: string[] = []; if (app.installedImage) lines.push(`Current image: ${app.installedImage}`); if (currentTag) lines.push(`Current tag: ${currentTag}`); if (app.image) lines.push(`Available image: ${app.image}`); if (availableTag) lines.push(`Available tag: ${availableTag}`); return lines; } // ── Small reusables ─────────────────────────────────────────────────────────── function StoreLogo({ logoUrl, alt, className }: { logoUrl: string | null; alt: string; className: string }) { const [failed, setFailed] = useState(false); if (logoUrl && !failed) { return ( // eslint-disable-next-line @next/next/no-img-element setFailed(true)} /> ); } return ; } function AppStatusDot({ app, operation }: { app: StoreAppSummary; operation?: AppOperationState }) { if (operation) { if (operation.status === "error") return ; if (operation.status === "success") return ; return ; } if (app.status === "installed") { return ; } if (app.status === "error") return ; return null; } function UpdateInfoTooltip({ app, children }: { app: StoreAppSummary; children: ReactNode }) { const lines = buildUpdateTooltipLines(app); if (lines.length === 0) return <>{children}>; return ( {children} {lines.map((line) => {line})} ); } function SourceBadge({ sourceName, sourceKind }: Pick) { return ( {sourceName} ); } // ── Category sidebar ────────────────────────────────────────────────────────── type CatalogCategory = { id: string; name: string; description: string; appCount: number }; function CategorySidebar({ categories, selectedCategory, onSelect, }: { categories: CatalogCategory[]; selectedCategory: string | null; onSelect: (category: string | null) => void; }) { return ( ); } // ── Featured strip ──────────────────────────────────────────────────────────── function FeaturedStrip({ title, icon, apps, onSelect, }: { title: string; icon: ReactNode; apps: StoreAppSummary[]; onSelect: (id: string) => void; }) { if (apps.length === 0) return null; return ( {icon} {title} {apps.length} {apps.slice(0, STORE_SECTION_LIMIT).map((app) => ( onSelect(app.id)} className={cn( STORE_PANEL_INSET, "group flex w-48 shrink-0 flex-col gap-2.5 p-3 text-left transition-all hover:-translate-y-0.5 hover:border-primary/30", )} > {app.name} {app.webUiPort && ( :{app.webUiPort} )} {app.description} ))} ); } // ── Catalog row ─────────────────────────────────────────────────────────────── function CatalogRow({ app, operation, onClick, }: { app: StoreAppSummary; operation?: AppOperationState; onClick: () => void; }) { const busy = isOperationBusy(operation); return ( {/* Logo */} {/* Info */} {app.name} {operation && !["error", "success"].includes(operation.status) && ( {getStoreOperationLabel(operation)}… {operation.progressPercent}% )} {app.description} {operation && !["error", "success"].includes(operation.status) ? ( ) : ( {app.categories.slice(0, 2).map((cat) => ( {cat} ))} {app.webUiPort && ( :{app.webUiPort} )} )} {/* Status label */} {operation?.status === "error" ? ( Failed ) : operation?.status === "success" ? ( Done ) : busy ? ( ) : app.status === "installed" ? ( {app.updateAvailable ? "Update" : "Installed"} ) : app.status === "error" ? ( Error ) : ( Get → )} ); } // ── Detail panel ────────────────────────────────────────────────────────────── function DetailInfoRow({ label, children }: { label: string; children: ReactNode }) { return ( {label} {children} ); } function AppStoreDetailPanel({ app, detail, isLoading, operation, actionError, onBack, onInstall, onUpdate, onRedeploy, onUninstall, onCustomInstall, onRemoveCustom, removingCustom, }: { app: StoreAppSummary | null; detail: StoreAppDetail | null | undefined; isLoading: boolean; operation?: AppOperationState; actionError: string | null; onBack: () => void; onInstall: () => void; onUpdate: () => void; onRedeploy: () => void; onUninstall: () => void; onCustomInstall: () => void; onRemoveCustom: () => void; removingCustom: boolean; }) { const busy = isOperationBusy(operation); return ( {/* Nav bar */} Back to Store {!app || isLoading ? ( Loading… ) : !detail ? ( App details unavailable. ) : ( {/* App header */} {/* Logo */} {/* Name + badges + progress */} {detail.name} {operation ? ( {getStoreOperationLabel(operation)}… {operation.progressPercent}% ) : app.status === "installed" ? ( {app.updateAvailable ? "Update available" : "Installed"} ) : null} {detail.categories.map((cat) => ( {cat} ))} {operation && !["error", "success"].includes(operation.status) && ( {operation.message && {operation.message}} )} {/* Actions */} {actionError && {actionError}} {detail.status === "not_installed" ? ( Install ) : detail.updateAvailable ? ( Update ) : resolveStoreDetailUrl(detail) ? ( Open ) : null} Custom {detail.sourceKind === "custom" && detail.status === "not_installed" && ( {removingCustom ? "Removing…" : "Remove"} )} {detail.status !== "not_installed" && ( Uninstall )} {/* Description */} {detail.note} {/* Screenshots */} {detail.screenshots.length > 0 && ( Screenshots {detail.screenshots.slice(0, 4).map((src, i) => ( {/* eslint-disable-next-line @next/next/no-img-element */} ))} )} {/* Metadata */} {detail.platform} {detail.webUiPort && ( {detail.webUiPort} )} {detail.repositoryUrl.startsWith("http") ? ( {detail.repositoryUrl} ) : ( {detail.repositoryUrl} )} )} ); } // ── Main store ──────────────────────────────────────────────────────────────── export function AppStore({ onOpenCustomInstall, launchRequest, }: { onOpenCustomInstall: () => void; launchRequest?: { nonce: number; search?: string; appId?: string } | null; }) { const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search.trim(), 250); const [filter, setFilter] = useState<"all" | "installed">("all"); const [selectedCategory, setSelectedCategory] = useState(null); const [selectedAppId, setSelectedAppId] = useState(null); const [actionError, setActionError] = useState(null); const [customInstallTemplate, setCustomInstallTemplate] = useState(null); const [uninstallDialogApp, setUninstallDialogApp] = useState(null); const [uninstallError, setUninstallError] = useState(null); const [uninstallPending, setUninstallPending] = useState(false); const [visibleCatalogCount, setVisibleCatalogCount] = useState(STORE_PAGE_SIZE); const [isSourcesDialogOpen, setIsSourcesDialogOpen] = useState(false); const queryClient = useQueryClient(); const loadMoreRef = useRef(null); const refreshAttemptedRef = useRef(false); const { operationsByApp, installApp, updateApp, redeployApp, uninstallApp } = useSharedStoreActions(); const storeSourcesQuery = useStoreSources(); const { addSource, updateSource, refreshSource, removeSource } = useStoreSourceActions(); const installedAppsQuery = useInstalledApps(); const catalogQuery = useStoreCatalog({ category: selectedCategory ?? undefined, search: debouncedSearch || undefined, installedOnly: filter === "installed", }); const catalog = catalogQuery.data; const apps = useMemo(() => catalog?.apps ?? [], [catalog]); const categories = catalog?.categories ?? []; const visibleApps = useMemo(() => apps.slice(0, visibleCatalogCount), [apps, visibleCatalogCount]); const hasMoreApps = visibleApps.length < apps.length; const selectedSummary = useMemo(() => apps.find((a) => a.id === selectedAppId) ?? null, [apps, selectedAppId]); const detailQuery = useStoreApp(selectedAppId); const selectedDetail = detailQuery.data; const selectedOperationId = selectedAppId && operationsByApp[selectedAppId] ? operationsByApp[selectedAppId].operationId : null; const selectedOperationQuery = useStoreOperation(selectedOperationId); const selectedOperation = (selectedAppId ? operationsByApp[selectedAppId] : undefined) ?? (selectedOperationQuery.operation ? { operationId: selectedOperationQuery.operation.id, appId: selectedOperationQuery.operation.appId, action: selectedOperationQuery.operation.action, status: selectedOperationQuery.operation.status, progressPercent: selectedOperationQuery.operation.progressPercent, step: selectedOperationQuery.operation.currentStep, message: selectedOperationQuery.operation.errorMessage, } : undefined); const installedCount = installedAppsQuery.data?.length ?? 0; const appById = useMemo(() => new Map(apps.map((a) => [a.id, a])), [apps]); const featuredApps = useMemo( () => (catalog?.featuredAppIds ?? []).map((id) => appById.get(id)).filter(Boolean) as StoreAppSummary[], [appById, catalog?.featuredAppIds], ); const recommendedApps = useMemo( () => (catalog?.recommendedAppIds ?? []).map((id) => appById.get(id)).filter(Boolean) as StoreAppSummary[], [appById, catalog?.recommendedAppIds], ); const shouldShowStrips = filter === "all" && !search.trim() && !selectedCategory; useEffect(() => { if (!launchRequest) return; setFilter("all"); setSelectedCategory(null); setActionError(null); setSearch(launchRequest.search ?? ""); setSelectedAppId(launchRequest.appId ?? null); }, [launchRequest]); useEffect(() => { if (refreshAttemptedRef.current) return; refreshAttemptedRef.current = true; let cancelled = false; void (async () => { try { const res = await fetch("/api/v1/store/check-updates", { method: "POST", cache: "no-store" }); if (!res.ok || cancelled) return; await Promise.allSettled([ queryClient.invalidateQueries({ queryKey: queryKeys.storeCatalog }), queryClient.invalidateQueries({ queryKey: queryKeys.installedApps }), ]); } catch { /* best-effort */ } })(); return () => { cancelled = true; }; }, [queryClient]); useEffect(() => { setVisibleCatalogCount(STORE_PAGE_SIZE); }, [debouncedSearch, filter, selectedCategory]); useEffect(() => { const node = loadMoreRef.current; if (!node || !hasMoreApps || typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) { setVisibleCatalogCount((c) => Math.min(c + STORE_PAGE_SIZE, apps.length)); } }, { rootMargin: "240px 0px" }, ); observer.observe(node); return () => observer.disconnect(); }, [apps.length, hasMoreApps]); async function startInstall(app: StoreAppSummary) { setActionError(null); try { await installApp({ appId: app.id, webUiPort: app.webUiPort ?? undefined }); } catch (err) { setActionError(err instanceof Error ? err.message : "Unable to start install."); } } async function startUpdate(app: StoreAppSummary) { setActionError(null); try { await updateApp({ appId: app.id }); } catch (err) { setActionError(err instanceof Error ? err.message : "Unable to start update."); } } const [removingCustom, setRemovingCustom] = useState(false); async function removeCustomApp() { if (!selectedSummary) return; setActionError(null); setRemovingCustom(true); try { const response = await fetch( `/api/v1/store/custom-apps/${encodeURIComponent(selectedSummary.id)}`, { method: "DELETE" }, ); if (!response.ok) { const json = (await response.json().catch(() => ({}))) as { error?: string }; throw new Error(json.error ?? `Failed to remove the custom app (${response.status})`); } await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.storeCatalog }), queryClient.invalidateQueries({ queryKey: queryKeys.installedApps }), ]); setSelectedAppId(null); } catch (error) { setActionError(error instanceof Error ? error.message : "Failed to remove the custom app"); } finally { setRemovingCustom(false); } } async function submitDetailAction(action: "install" | "update" | "redeploy" | "uninstall") { if (!selectedSummary || !selectedDetail) return; setActionError(null); try { if (action === "uninstall") { setUninstallDialogApp(selectedSummary); return; } if (action === "install") { await installApp({ appId: selectedSummary.id, webUiPort: selectedSummary.webUiPort ?? undefined }); return; } if (action === "update") { await updateApp({ appId: selectedSummary.id }); return; } await redeployApp({ appId: selectedSummary.id }); } catch (err) { setActionError(err instanceof Error ? err.message : "Request failed."); } } async function confirmUninstall(input: { deleteData: boolean }) { if (!uninstallDialogApp) return; setUninstallError(null); setUninstallPending(true); try { await uninstallApp({ appId: uninstallDialogApp.id, removeVolumes: input.deleteData }); setUninstallDialogApp(null); } catch (err) { setUninstallError(err instanceof Error ? err.message : "Unable to start uninstall."); } finally { setUninstallPending(false); } } const uninstallDialog = ( { if (!open) { setUninstallDialogApp(null); setUninstallError(null); } }} onConfirm={confirmUninstall} /> ); // ── Detail view ───────────────────────────────────────────────────────────── if (selectedAppId) { return ( setSelectedAppId(null)} onInstall={() => void submitDetailAction("install")} onUpdate={() => void submitDetailAction("update")} onRedeploy={() => void submitDetailAction("redeploy")} onUninstall={() => void submitDetailAction("uninstall")} onCustomInstall={() => { if (selectedDetail) setCustomInstallTemplate(selectedDetail); }} onRemoveCustom={() => void removeCustomApp()} removingCustom={removingCustom} /> {customInstallTemplate && ( setCustomInstallTemplate(null)} /> )} {uninstallDialog} ); } // ── Catalog view ───────────────────────────────────────────────────────────── return ( {/* Header */} {/* Filter tabs */} {(["all", "installed"] as const).map((f) => ( setFilter(f)} className={cn( "rounded-lg px-3 py-1.5 text-[13px] font-medium transition-colors", filter === f ? "bg-primary/15 text-primary" : "text-muted-foreground hover:bg-background/50 hover:text-foreground", )} > {f === "all" ? "All Apps" : `Installed (${installedCount})`} ))} setIsSourcesDialogOpen(true)} /> {/* Search */} setSearch(e.target.value)} placeholder="Search apps…" className="w-full rounded-lg border border-glass-border bg-background/55 py-1.5 pl-8 pr-3 text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40" /> {uninstallDialog} { await addSource.mutateAsync(input); }} onToggleSource={async (input) => { await updateSource.mutateAsync({ sourceId: input.sourceId, payload: { enabled: input.enabled } }); }} onRefreshSource={async (sourceId) => { await refreshSource.mutateAsync(sourceId); }} onRemoveSource={async (sourceId) => { await removeSource.mutateAsync(sourceId); }} /> {actionError && ( {actionError} )} {catalogQuery.isLoading ? ( Loading catalog… ) : catalogQuery.isError ? ( Unable to load app catalog. ) : apps.length === 0 ? ( No apps found ) : ( {/* Featured + Recommended strips */} {shouldShowStrips && ( <> } apps={featuredApps} onSelect={setSelectedAppId} /> } apps={recommendedApps} onSelect={setSelectedAppId} /> > )} {/* Catalog list */} {selectedCategory ?? (filter === "installed" ? "Installed Apps" : "Catalog")} {apps.length} apps {visibleApps.map((app) => ( setSelectedAppId(app.id)} /> ))} {hasMoreApps && ( setVisibleCatalogCount((c) => Math.min(c + STORE_PAGE_SIZE, apps.length))} className="rounded-lg border border-glass-border bg-background/55 px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-secondary/40 hover:text-foreground" > Load more apps )} )} ); }
{app.description}
{operation.message}
{actionError}
Screenshots
{apps.length} apps