import React, { useState, useEffect, useMemo, useLayoutEffect, useRef, useCallback } from 'react' import { CloudDownload, Upload, Package, Database, RefreshCw, Loader2, AlertTriangle, HardDrive, Usb, ChevronDown, Globe, Search, ArrowUpDown, X } from 'lucide-react' import { useTranslation } from 'react-i18next' import { QRCodeSVG } from 'qrcode.react' import { cn, isPS5, isIOS } from '../../utils/helpers' import { getPayloadStatus, resolveSourceDisplayName, isRepositoryPayloadVisible } from '../../utils/payloadStatus' import { slug } from '../../utils/versions' import { formatSyncTime } from '../../utils/syncTime' import PayloadName from '../ui/PayloadName' import Modal from '../ui/Modal' import { useRepository } from '../../contexts/RepositoryContext' import PayloadCard from '../ui/PayloadCard' import PayloadDetailPanel from '../ui/PayloadDetailPanel' const StorageHub = ({ items = [], self = null, payloads = [], onInstall, onUpload, onImportFromUsb, config, ip, scrollTarget, onClearScrollTarget, selectedPayload: selectedPayloadProp, onSelectPayload, onClosePayload, conflictPrompt: externalConflictPrompt, setConflictPrompt: externalSetConflictPrompt, externalLinkUrl, setExternalLinkUrl, installingPayloadId = null, }) => { const { t, i18n } = useTranslation() const multiSources = config?.MULTI_SOURCES_ENABLED === true const { sources, repoData, loading, error, fetchRemote } = useRepository() const [expandedSource, setExpandedSource] = useState(null) // id of expanded catalog const [search, setSearch] = useState('') const [isSearchOpen, setIsSearchOpen] = useState(false) const searchInputRef = useRef(null) const [sortMode, setSortMode] = useState(() => { return localStorage.getItem('repoSortMode') || 'name' }) const [localConflictPrompt, setLocalConflictPrompt] = useState(null) const conflictPrompt = externalConflictPrompt !== undefined ? externalConflictPrompt : localConflictPrompt const setConflictPrompt = externalSetConflictPrompt || setLocalConflictPrompt const [localSelectedPayload, setLocalSelectedPayload] = useState(null) const selectedPayload = selectedPayloadProp !== undefined ? selectedPayloadProp : localSelectedPayload const setSelectedPayload = onSelectPayload || setLocalSelectedPayload const [savedScroll, setSavedScroll] = useState(0) const handlePayloadSelect = (payloadData) => { const mainEl = document.querySelector('main') if (mainEl) setSavedScroll(mainEl.scrollTop) if (onSelectPayload) { onSelectPayload(payloadData) } else { setSelectedPayload(payloadData) } } const handleCloseDetail = useCallback(() => { if (onClosePayload) { onClosePayload() } else { setSelectedPayload(null) } }, [onClosePayload, setSelectedPayload]) const handleSortChange = (mode) => { setSortMode(mode) localStorage.setItem('repoSortMode', mode) } useEffect(() => { if (scrollTarget) { const timer = setTimeout(() => { const element = document.getElementById(scrollTarget) if (element) element.scrollIntoView({ behavior: 'smooth' }) if (onClearScrollTarget) onClearScrollTarget() }, 300) return () => clearTimeout(timer) } }, [scrollTarget, onClearScrollTarget]) /* ---- Source-grouped data with statusInfo ---- */ const enrichedSources = useMemo(() => { return (sources || []).map((src) => { const rawPayloads = src.payloads || [] const filteredPayloads = rawPayloads.filter((p) => isRepositoryPayloadVisible(p, src.url)) const enrichedPayloads = filteredPayloads.map((p) => { const statusInfo = getPayloadStatus({ payload: p, srcUrl: src.url, items, self, sources, multiSources, }) return { ...p, statusInfo, isInstalled: statusInfo.isInstalled, isUpdate: statusInfo.isUpdate, } }) return { ...src, payloads: enrichedPayloads, } }) }, [sources, items, multiSources, self]) // eslint-disable-next-line react-hooks/preserve-manual-memoization const resolvedPayloadData = useMemo(() => { if (!selectedPayload) return null const targetPayload = selectedPayload.payload || (typeof selectedPayload === 'object' && (selectedPayload.name || selectedPayload.id || selectedPayload.filename) ? selectedPayload : null) const targetIdentifier = selectedPayload.payload?.id || selectedPayload.id || selectedPayload.payload?.filename || selectedPayload.filename || (typeof selectedPayload === 'string' ? selectedPayload : null) const targetSrcId = selectedPayload.srcId || null const targetSrcUrl = selectedPayload.srcUrl || null // Check candidate sources from enrichedSources first so that fresh status is always picked up const candidateSources = targetSrcId ? [ enrichedSources.find((s) => s.id === targetSrcId), ...enrichedSources.filter((s) => s.id !== targetSrcId), ].filter(Boolean) : (targetSrcUrl ? [ enrichedSources.find((s) => s.url === targetSrcUrl), ...enrichedSources.filter((s) => s.url !== targetSrcUrl), ].filter(Boolean) : enrichedSources) for (const src of candidateSources) { const match = src.payloads?.find( (candidate) => (targetIdentifier && candidate.id === targetIdentifier) || (targetIdentifier && candidate.filename === targetIdentifier) || (targetIdentifier && candidate.filename?.toLowerCase() === targetIdentifier.toLowerCase()) || (targetPayload && (candidate.id === targetPayload.id || candidate.filename === targetPayload.filename)) ) if (match) { return { payload: match, srcId: src.id, srcUrl: src.url, statusInfo: match.statusInfo, isInstalled: match.isInstalled, isUpdate: match.isUpdate, } } } if (targetPayload && (targetPayload.name || targetPayload.id || targetPayload.filename)) { const srcUrl = targetSrcUrl || (targetSrcId ? enrichedSources.find((s) => s.id === targetSrcId)?.url : enrichedSources[0]?.url) || '' const statusInfo = getPayloadStatus({ payload: targetPayload, srcUrl, items, self, sources, multiSources, }) return { ...selectedPayload, payload: targetPayload, srcId: targetSrcId || enrichedSources[0]?.id || '', srcUrl, statusInfo, isInstalled: statusInfo.isInstalled, isUpdate: statusInfo.isUpdate, } } return null }, [selectedPayload, enrichedSources, items, sources, multiSources, self]) const currentDetailId = resolvedPayloadData?.payload?.id || (resolvedPayloadData?.payload ? slug(resolvedPayloadData.payload.name || resolvedPayloadData.payload.filename) : null) const isDetailInstalling = Boolean(installingPayloadId && currentDetailId === installingPayloadId) useLayoutEffect(() => { const mainEl = document.querySelector('main') if (!mainEl) return if (resolvedPayloadData) { mainEl.scrollTop = 0 } else { mainEl.scrollTop = savedScroll } }, [resolvedPayloadData, savedScroll]) useEffect(() => { if (selectedPayload && !resolvedPayloadData && !loading && repoData) { handleCloseDetail() } }, [selectedPayload, resolvedPayloadData, loading, repoData, handleCloseDetail]) const handleInstallClick = (payload, srcId, srcUrl, statusInfo) => { // If there is an install conflict across repositories if (statusInfo?.hasConflict && statusInfo?.conflictSlot) { const srcObj = enrichedSources.find((s) => s.id === srcId || s.url === srcUrl) setConflictPrompt({ payload, srcId, srcUrl, conflictSlot: statusInfo.conflictSlot, thisSourceName: srcObj?.name || 'this repository', }) return } onInstall(payload, srcId, srcUrl) } if (selectedPayload && !resolvedPayloadData && (loading || !repoData)) { return (
{t("storage_hub.loading", "Loading repository...")}
{conflictPrompt && t( "storage_hub.conflict_message", "\"{{name}}\" is already installed from {{otherSource}} ({{version}}). Install from {{thisSource}} too?", { name: conflictPrompt.payload.name || conflictPrompt.payload.id, otherSource: resolveSourceDisplayName(conflictPrompt.conflictSlot, sources), version: conflictPrompt.conflictSlot.version || "", thisSource: conflictPrompt.thisSourceName, } )}
{conflictPrompt && t( "storage_hub.conflict_message", "\"{{name}}\" is already installed from {{otherSource}} ({{version}}). Install from {{thisSource}} too?", { name: conflictPrompt.payload.name || conflictPrompt.payload.id, otherSource: resolveSourceDisplayName(conflictPrompt.conflictSlot, sources), version: conflictPrompt.conflictSlot.version || "", thisSource: conflictPrompt.thisSourceName, } )}
{t("storage_hub.syncing", "Syncing with Repository...")}
{t("storage_hub.repo_unavailable", "Repository Unavailable")}
{t("storage_hub.repo_unavailable_desc", "Check your internet connection and try again.")}
{t("storage_hub.last_sync", "Last Sync: {{date}}", { date: formatSyncTime(src.last_update, i18n.resolvedLanguage, isPS5), })}
)} {multiSources && ( )} {isExpanded && ({t("storage_hub.source_empty", "Source is empty")}
{t("storage_hub.repo_empty", "Repository is empty")}
{t("storage_hub.no_usb_payloads", "No USB Payloads Found")}
{!config?.SCAN_USB_PAYLOADS && (
{t("storage_hub.usb_scan_disabled_1", "Extended USB scanning is currently disabled.")}
\"Scan USB Payloads\" in settings to search for payloads on connected drives."
),
}}
/>
{path}
{ip}:8084
{t( "storage_hub.remote_management_desc", "Access this dashboard from your computer or phone to manage and upload payloads directly from your local network." )}