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...")}

) } if (resolvedPayloadData) { return (
{ handleInstallClick(p, sid, surl, sInfo || resolvedPayloadData.statusInfo) }} /> {/* Conflict Modal */} setConflictPrompt(null)} footer={
} >

{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, } )}

) } return (
{/* Conflict Modal */} setConflictPrompt(null)} footer={
} >

{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.title_1", "Get")} {t("storage_hub.title_2", "Payloads")}

{isSearchOpen ? (
setSearch(e.target.value)} className="bg-transparent border-none outline-none text-white w-full text-xs sm:text-sm font-medium placeholder:text-zinc-600 min-w-0" autoFocus onKeyDown={(e) => { if (e.key === 'Escape') { setIsSearchOpen(false) setSearch('') } }} /> {search && ( )}
) : ( )}
{!isPS5 && ( )}
{/* Cloud Repository Section */}
{loading && !repoData ? (

{t("storage_hub.syncing", "Syncing with Repository...")}

) : error ? (

{t("storage_hub.repo_unavailable", "Repository Unavailable")}

{t("storage_hub.repo_unavailable_desc", "Check your internet connection and try again.")}

) : enrichedSources.length > 0 ? ( /* ===== REPOSITORY CATALOGS ===== */
{enrichedSources.map((src) => { let availablePayloads = [...src.payloads] if (search.trim() !== '') { const q = search.toLowerCase() availablePayloads = availablePayloads.filter( (p) => (p.name && p.name.toLowerCase().includes(q)) || (p.id && p.id.toLowerCase().includes(q)) || (p.filename && p.filename.toLowerCase().includes(q)) || (p.description && p.description.toLowerCase().includes(q)) ) } // Auto-expand if there's only 1 source, otherwise respect state or active search const isExpanded = enrichedSources.length === 1 || expandedSource === src.id || search.trim() !== '' let hasMultipleCategories = false let groupedPayloads = {} let categories = [] if (sortMode === 'category' && search.trim() === '') { groupedPayloads = availablePayloads.reduce((acc, p) => { const catName = p.category ? t(`category.${p.category}`, p.category) : t("storage_hub.uncategorized", "Uncategorized") if (!acc[catName]) acc[catName] = [] acc[catName].push(p) return acc }, {}) categories = Object.keys(groupedPayloads).sort() hasMultipleCategories = categories.length > 1 } else { availablePayloads.sort((a, b) => { if (sortMode === 'update_time') { const dateA = a.last_update || '' const dateB = b.last_update || '' return dateB.localeCompare(dateA) } else if (sortMode === 'name') { const nameA = a.name || a.filename || '' const nameB = b.name || b.filename || '' return nameA.localeCompare(nameB) } return 0 }) } return (
{!multiSources && src.last_update > 0 && (

{t("storage_hub.last_sync", "Last Sync: {{date}}", { date: formatSyncTime(src.last_update, i18n.resolvedLanguage, isPS5), })}

)} {multiSources && ( )} {isExpanded && (
{src.payloads.length === 0 ? (

{t("storage_hub.source_empty", "Source is empty")}

) : hasMultipleCategories ? ( categories.map((cat) => (

{cat}

{groupedPayloads[cat].map((p) => ( handlePayloadSelect({ payload: p, srcId: src.id, srcUrl: src.url, statusInfo: p.statusInfo, isInstalled: p.isInstalled, isUpdate: p.isUpdate, }) } /> ))}
)) ) : (
{availablePayloads.map((p) => ( handlePayloadSelect({ payload: p, srcId: src.id, srcUrl: src.url, statusInfo: p.statusInfo, isInstalled: p.isInstalled, isUpdate: p.isUpdate, }) } /> ))}
)}
)}
) })}
) : (

{t("storage_hub.repo_empty", "Repository is empty")}

)}
{/* USB Storage Section */}

{t("storage_hub.usb_storage_title", "USB Storage")}

{t("storage_hub.files_count", "{{count}} File", { count: payloads.filter((p) => p.includes('/mnt/usb')).length, })}
{payloads.filter((p) => p.includes('/mnt/usb')).length === 0 ? (
{!config?.SCAN_USB_PAYLOADS && (
)}

{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." ), }} />

)}
) : ( payloads .filter((p) => p.includes('/mnt/usb')) .map((path) => { return (

{path}

) }) )}
{/* Footer Info for PS5 */} {isPS5 && (
{ip}:8084

{t("storage_hub.remote_management_title", "Remote Management")}

{t( "storage_hub.remote_management_desc", "Access this dashboard from your computer or phone to manage and upload payloads directly from your local network." )}

)}
) } export default StorageHub