mirror of
https://github.com/itsPLK/ps5-payload-manager.git
synced 2026-10-19 02:19:53 -05:00
816 lines
35 KiB
React
816 lines
35 KiB
React
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 (
|
|
<div className="py-24 flex flex-col items-center justify-center space-y-4">
|
|
<Loader2 className="w-10 h-10 text-ps-blue animate-spin" />
|
|
<p className="text-zinc-400 font-medium">{t("storage_hub.loading", "Loading repository...")}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (resolvedPayloadData) {
|
|
return (
|
|
<div className="w-full min-w-0 max-w-full">
|
|
<PayloadDetailPanel
|
|
payload={resolvedPayloadData.payload}
|
|
srcId={resolvedPayloadData.srcId}
|
|
srcUrl={resolvedPayloadData.srcUrl}
|
|
statusInfo={resolvedPayloadData.statusInfo}
|
|
isInstalled={resolvedPayloadData.isInstalled}
|
|
isUpdate={resolvedPayloadData.isUpdate}
|
|
isInstalling={isDetailInstalling}
|
|
externalLinkUrl={externalLinkUrl}
|
|
setExternalLinkUrl={setExternalLinkUrl}
|
|
onClose={handleCloseDetail}
|
|
onInstall={(p, sid, surl, sInfo) => {
|
|
handleInstallClick(p, sid, surl, sInfo || resolvedPayloadData.statusInfo)
|
|
}}
|
|
/>
|
|
|
|
{/* Conflict Modal */}
|
|
<Modal
|
|
show={Boolean(conflictPrompt)}
|
|
title={t("storage_hub.conflict_title", "Payload Conflict")}
|
|
onClose={() => setConflictPrompt(null)}
|
|
footer={
|
|
<div className="flex flex-col sm:flex-row w-full gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setConflictPrompt(null)}
|
|
className="flex-1 py-3 px-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-bold uppercase text-sm transition-all"
|
|
>
|
|
{t("common.cancel", "Cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const cp = conflictPrompt
|
|
setConflictPrompt(null)
|
|
onInstall(cp.payload, cp.srcId, cp.srcUrl, {
|
|
mode: 'replace',
|
|
replaceSlot: cp.conflictSlot.slot,
|
|
})
|
|
}}
|
|
className="flex-1 py-3 px-4 rounded-xl bg-amber-600 hover:bg-amber-500 text-white font-black uppercase text-sm transition-all shadow-lg"
|
|
>
|
|
{t("storage_hub.conflict_replace", "Replace")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const cp = conflictPrompt
|
|
setConflictPrompt(null)
|
|
onInstall(cp.payload, cp.srcId, cp.srcUrl, {
|
|
mode: 'keep_both',
|
|
})
|
|
}}
|
|
className="flex-1 py-3 px-4 rounded-xl bg-ps-blue hover:bg-ps-blue/80 text-white font-black uppercase text-sm transition-all shadow-lg"
|
|
>
|
|
{t("storage_hub.conflict_keep_both", "Keep both")}
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<p>
|
|
{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,
|
|
}
|
|
)}
|
|
</p>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-12 animate-in fade-in duration-300">
|
|
{/* Conflict Modal */}
|
|
<Modal
|
|
show={Boolean(conflictPrompt)}
|
|
title={t("storage_hub.conflict_title", "Payload Conflict")}
|
|
onClose={() => setConflictPrompt(null)}
|
|
footer={
|
|
<div className="flex flex-col sm:flex-row w-full gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setConflictPrompt(null)}
|
|
className="flex-1 py-3 px-4 rounded-xl bg-white/5 hover:bg-white/10 text-white font-bold uppercase text-sm transition-all"
|
|
>
|
|
{t("common.cancel", "Cancel")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const cp = conflictPrompt
|
|
setConflictPrompt(null)
|
|
onInstall(cp.payload, cp.srcId, cp.srcUrl, {
|
|
mode: 'replace',
|
|
replaceSlot: cp.conflictSlot.slot,
|
|
})
|
|
}}
|
|
className="flex-1 py-3 px-4 rounded-xl bg-amber-600 hover:bg-amber-500 text-white font-black uppercase text-sm transition-all shadow-lg"
|
|
>
|
|
{t("storage_hub.conflict_replace", "Replace")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const cp = conflictPrompt
|
|
setConflictPrompt(null)
|
|
onInstall(cp.payload, cp.srcId, cp.srcUrl, {
|
|
mode: 'keep_both',
|
|
})
|
|
}}
|
|
className="flex-1 py-3 px-4 rounded-xl bg-ps-blue hover:bg-ps-blue/80 text-white font-black uppercase text-sm transition-all shadow-lg"
|
|
>
|
|
{t("storage_hub.conflict_keep_both", "Keep both")}
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<p>
|
|
{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,
|
|
}
|
|
)}
|
|
</p>
|
|
</Modal>
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
<h2 className="text-3xl sm:text-4xl font-extrabold text-white tracking-tight shrink-0">
|
|
{t("storage_hub.title_1", "Get")} <span className="text-ps-blue">{t("storage_hub.title_2", "Payloads")}</span>
|
|
</h2>
|
|
|
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
|
{isSearchOpen ? (
|
|
<div className="flex items-center bg-black/40 border border-white/10 rounded-full px-3 py-1.5 sm:px-4 sm:py-2 focus-within:border-ps-blue/50 transition-all w-40 sm:w-64 md:w-80">
|
|
<Search className="w-4 h-4 sm:w-5 sm:h-5 text-ps-blue mr-2 shrink-0" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
placeholder={t("storage_hub.search_placeholder", "Search payloads by name or description...")}
|
|
value={search}
|
|
onChange={(e) => 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 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setSearch('')}
|
|
className="p-1 hover:text-white text-zinc-500 shrink-0"
|
|
title={t("app.buttons.clear", "Clear")}
|
|
>
|
|
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsSearchOpen(false)
|
|
setSearch('')
|
|
}}
|
|
className="p-1 hover:text-white text-zinc-500 ml-1 shrink-0"
|
|
title={t("app.buttons.close", "Close")}
|
|
>
|
|
<X className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsSearchOpen(true)}
|
|
className={cn(
|
|
"p-3 rounded-full transition-colors flex items-center justify-center",
|
|
search ? "bg-ps-blue/20 text-ps-blue" : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10"
|
|
)}
|
|
title={t("storage_hub.search_placeholder", "Search payloads by name or description...")}
|
|
>
|
|
<Search className="w-6 h-6" />
|
|
</button>
|
|
)}
|
|
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"p-3 rounded-full transition-colors flex items-center justify-center",
|
|
sortMode !== 'name' ? "bg-ps-blue/20 text-ps-blue" : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10"
|
|
)}
|
|
title={`${t("storage_hub.sort_by", "Sort by:")} ${
|
|
sortMode === 'category'
|
|
? t("storage_hub.sort_category", "Category")
|
|
: sortMode === 'update_time'
|
|
? t("storage_hub.sort_time", "Update Time")
|
|
: t("storage_hub.sort_name", "Name")
|
|
}`}
|
|
>
|
|
<ArrowUpDown className="w-6 h-6" />
|
|
</button>
|
|
<select
|
|
value={sortMode}
|
|
onChange={(e) => handleSortChange(e.target.value)}
|
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
|
title={t("storage_hub.sort_by", "Sort by:")}
|
|
>
|
|
<option value="name" className="bg-[#121214] text-white">{t("storage_hub.sort_name", "Name")}</option>
|
|
<option value="category" className="bg-[#121214] text-white">{t("storage_hub.sort_category", "Category")}</option>
|
|
<option value="update_time" className="bg-[#121214] text-white">{t("storage_hub.sort_time", "Update Time")}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchRemote(true)}
|
|
disabled={loading}
|
|
className="p-3 rounded-full transition-colors flex items-center justify-center bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10 disabled:opacity-50"
|
|
title={t("storage_hub.refresh", "Refresh Repository")}
|
|
>
|
|
<RefreshCw className={cn("w-6 h-6", loading && "animate-spin text-ps-blue")} />
|
|
</button>
|
|
|
|
{!isPS5 && (
|
|
<label
|
|
className="p-3 rounded-full transition-colors flex items-center justify-center bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10 cursor-pointer"
|
|
title={t("storage_hub.upload_btn", "Upload ELF Payload")}
|
|
>
|
|
<Upload className="w-6 h-6" />
|
|
<input type="file" className="hidden" onChange={onUpload} accept={isIOS ? undefined : ".elf,.bin"} />
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cloud Repository Section */}
|
|
<section id="cloud-repository" className="space-y-6 -mx-3 sm:-mx-6 md:-mx-10 lg:-mx-11">
|
|
{loading && !repoData ? (
|
|
<div className="py-24 glass-panel rounded-ps-3xl border-white/5 flex flex-col items-center justify-center space-y-6">
|
|
<Loader2 className="w-16 h-16 text-ps-blue animate-spin" />
|
|
<p className="label-caps">{t("storage_hub.syncing", "Syncing with Repository...")}</p>
|
|
</div>
|
|
) : error ? (
|
|
<div className="py-20 glass-card rounded-ps-3xl border-red-500/20 flex flex-col items-center justify-center space-y-6 bg-red-950/5">
|
|
<AlertTriangle className="w-16 h-16 text-red-500 opacity-50" />
|
|
<div className="text-center">
|
|
<p className="text-xl font-bold text-white uppercase tracking-tight">{t("storage_hub.repo_unavailable", "Repository Unavailable")}</p>
|
|
<p className="text-zinc-500 mt-1">{t("storage_hub.repo_unavailable_desc", "Check your internet connection and try again.")}</p>
|
|
</div>
|
|
<button onClick={() => fetchRemote(true)} className="px-8 py-3 bg-white/5 border border-white/10 hover:bg-white/10 text-white rounded-xl font-bold uppercase text-xs transition-all">{t("storage_hub.retry_btn", "Retry Connection")}</button>
|
|
</div>
|
|
) : enrichedSources.length > 0 ? (
|
|
/* ===== REPOSITORY CATALOGS ===== */
|
|
<div className="space-y-4">
|
|
{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 (
|
|
<div
|
|
key={src.id}
|
|
className={cn(
|
|
multiSources
|
|
? "bg-ps-card border border-ps-border rounded-ps-3xl overflow-hidden"
|
|
: "flex flex-col space-y-4"
|
|
)}
|
|
>
|
|
{!multiSources && src.last_update > 0 && (
|
|
<p className="px-3 sm:px-4 text-xs uppercase tracking-widest text-zinc-500">
|
|
{t("storage_hub.last_sync", "Last Sync: {{date}}", {
|
|
date: formatSyncTime(src.last_update, i18n.resolvedLanguage, isPS5),
|
|
})}
|
|
</p>
|
|
)}
|
|
|
|
{multiSources && (
|
|
<button
|
|
onClick={() => setExpandedSource(isExpanded ? null : src.id)}
|
|
className="w-full flex items-center justify-between px-4 py-3.5 sm:px-6 sm:py-4 md:px-6 md:py-5 hover:bg-white/5 transition-colors"
|
|
>
|
|
<div className="flex items-center space-x-4">
|
|
<div className="p-2.5 bg-ps-blue/10 rounded-xl">
|
|
<Globe className="w-5 h-5 text-ps-blue" />
|
|
</div>
|
|
<div className="text-left">
|
|
<p className="font-bold text-white text-lg">{src.name}</p>
|
|
{src.error && (
|
|
<p className="text-xs text-red-400 flex items-center space-x-1 mt-0.5">
|
|
<AlertTriangle className="w-3 h-3" />
|
|
<span>{t("storage_hub.fetch_failed", "Fetch failed")}</span>
|
|
</p>
|
|
)}
|
|
{src.last_update > 0 && (
|
|
<p className="text-xs text-zinc-500 uppercase tracking-widest mt-1">
|
|
{t("storage_hub.updated_at", "Updated {{date}}", {
|
|
date: formatSyncTime(src.last_update, i18n.resolvedLanguage, isPS5),
|
|
})}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
<span className="px-3 py-1 rounded-full bg-white/5 text-zinc-500 text-xs font-bold">
|
|
{t("storage_hub.payload_count", "{{count}} payloads", { count: availablePayloads.length })}
|
|
</span>
|
|
{enrichedSources.length > 1 && (
|
|
<ChevronDown
|
|
className={cn(
|
|
"w-5 h-5 text-zinc-500 transition-transform",
|
|
isExpanded && "rotate-180"
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</button>
|
|
)}
|
|
|
|
{isExpanded && (
|
|
<div className={cn(multiSources ? "border-t border-white/5 p-3 sm:p-4 md:p-4.5" : "")}>
|
|
{src.payloads.length === 0 ? (
|
|
<div className="py-12 flex flex-col items-center justify-center space-y-3 text-zinc-600">
|
|
<p className="text-sm font-bold uppercase tracking-widest italic">
|
|
{t("storage_hub.source_empty", "Source is empty")}
|
|
</p>
|
|
</div>
|
|
) : hasMultipleCategories ? (
|
|
categories.map((cat) => (
|
|
<div key={cat} className="space-y-4 mb-8 last:mb-0">
|
|
<h4 className="text-white/80 font-bold uppercase tracking-widest text-sm px-2">{cat}</h4>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 md:gap-5">
|
|
{groupedPayloads[cat].map((p) => (
|
|
<PayloadCard
|
|
key={p.id || p.filename}
|
|
payload={p}
|
|
statusInfo={p.statusInfo}
|
|
isUpdate={p.isUpdate}
|
|
isInstalled={p.isInstalled}
|
|
onClick={() =>
|
|
handlePayloadSelect({
|
|
payload: p,
|
|
srcId: src.id,
|
|
srcUrl: src.url,
|
|
statusInfo: p.statusInfo,
|
|
isInstalled: p.isInstalled,
|
|
isUpdate: p.isUpdate,
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 md:gap-5">
|
|
{availablePayloads.map((p) => (
|
|
<PayloadCard
|
|
key={p.id || p.filename}
|
|
payload={p}
|
|
statusInfo={p.statusInfo}
|
|
isUpdate={p.isUpdate}
|
|
isInstalled={p.isInstalled}
|
|
onClick={() =>
|
|
handlePayloadSelect({
|
|
payload: p,
|
|
srcId: src.id,
|
|
srcUrl: src.url,
|
|
statusInfo: p.statusInfo,
|
|
isInstalled: p.isInstalled,
|
|
isUpdate: p.isUpdate,
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="py-20 border-2 border-dashed border-white/5 rounded-ps-3xl flex flex-col items-center justify-center space-y-4 bg-white/[0.01]">
|
|
<CloudDownload className="w-16 h-16 text-white/5" />
|
|
<p className="text-zinc-500 font-bold uppercase tracking-widest text-sm italic">
|
|
{t("storage_hub.repo_empty", "Repository is empty")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* USB Storage Section */}
|
|
<section id="usb-storage" className="space-y-6 -mx-3 sm:-mx-6 md:-mx-10 lg:-mx-11">
|
|
<div className="flex items-center justify-between px-3 sm:px-4 md:px-6">
|
|
<h3 className="label-caps !text-ps-blue flex items-center space-x-4 text-lg">
|
|
<HardDrive className="w-6 h-6" />
|
|
<span>{t("storage_hub.usb_storage_title", "USB Storage")}</span>
|
|
</h3>
|
|
<span className="bg-ps-blue/5 px-4 py-1 rounded-full text-ps-blue font-bold text-xs border border-ps-blue/20">
|
|
{t("storage_hub.files_count", "{{count}} File", {
|
|
count: payloads.filter((p) => p.includes('/mnt/usb')).length,
|
|
})}
|
|
</span>
|
|
</div>
|
|
|
|
<div className={cn("grid gap-4", isPS5 ? "grid-cols-2" : "grid-cols-1 lg:grid-cols-2")}>
|
|
{payloads.filter((p) => p.includes('/mnt/usb')).length === 0 ? (
|
|
<div className="col-span-full py-20 border-2 border-dashed border-white/5 rounded-ps-3xl flex flex-col items-center justify-center space-y-6 bg-white/[0.01]">
|
|
<div className="relative">
|
|
<HardDrive className="w-16 h-16 text-white/5" />
|
|
{!config?.SCAN_USB_PAYLOADS && (
|
|
<div className="absolute -top-1 -right-1 bg-amber-500/20 rounded-full p-1 border border-amber-500/50">
|
|
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="text-center space-y-2">
|
|
<p className="text-zinc-500 font-bold uppercase tracking-widest text-sm italic">
|
|
{t("storage_hub.no_usb_payloads", "No USB Payloads Found")}
|
|
</p>
|
|
{!config?.SCAN_USB_PAYLOADS && (
|
|
<p className="text-xs text-zinc-600 max-w-xs mx-auto leading-relaxed">
|
|
{t("storage_hub.usb_scan_disabled_1", "Extended USB scanning is currently disabled.")} <br />
|
|
<span
|
|
dangerouslySetInnerHTML={{
|
|
__html: t(
|
|
"storage_hub.usb_scan_disabled_2",
|
|
"Enable <strong>\"Scan USB Payloads\"</strong> in settings to search for payloads on connected drives."
|
|
),
|
|
}}
|
|
/>
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
payloads
|
|
.filter((p) => p.includes('/mnt/usb'))
|
|
.map((path) => {
|
|
return (
|
|
<div
|
|
key={path}
|
|
className="group flex flex-row items-center justify-between p-4 md:p-6 glass-card rounded-ps-2xl border-white/10 hover:border-ps-blue/30 gap-4"
|
|
>
|
|
<div className="flex items-center space-x-4 md:space-x-6 min-w-0">
|
|
<div className="p-3 md:p-4 bg-white/5 rounded-2xl group-hover:bg-ps-blue/10 transition-colors shrink-0">
|
|
<Usb className="w-6 h-6 md:w-8 md:h-8 text-zinc-400 group-hover:text-ps-blue transition-colors" />
|
|
</div>
|
|
<div className="space-y-1 min-w-0 flex-1">
|
|
<PayloadName path={path} className="text-xl md:text-2xl text-white" stacked hideIcon={true} />
|
|
<p className="text-[10px] text-zinc-600 font-medium font-mono uppercase tracking-tighter opacity-60 truncate">
|
|
{path}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center shrink-0">
|
|
<button
|
|
onClick={() => onImportFromUsb(path)}
|
|
className="flex items-center space-x-2 md:space-x-3 px-4 md:px-6 py-3 md:py-4 bg-white/5 hover:bg-ps-blue text-white rounded-xl font-bold text-xs md:text-sm transition-all border border-white/10 hover:border-ps-blue group/btn"
|
|
>
|
|
<Database className="w-4 h-4 md:w-5 md:h-5 text-ps-blue group-hover/btn:text-white transition-colors" />
|
|
<span>{t("storage_hub.move_internal_btn", "Move to Internal")}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* Footer Info for PS5 */}
|
|
{isPS5 && (
|
|
<div
|
|
className={cn(
|
|
"glass-card p-6 md:p-10 rounded-ps-3xl flex items-center gap-8 md:gap-12 border-white/10 bg-black/40 mt-12 md:mt-16",
|
|
isPS5 ? "flex-row" : "flex-col md:flex-row"
|
|
)}
|
|
>
|
|
<div className="flex flex-col items-center space-y-4 md:space-y-6 shrink-0">
|
|
<div className="bg-white p-4 md:p-6 rounded-3xl">
|
|
<QRCodeSVG value={`http://${ip}:8084`} size={isPS5 ? 160 : 120} level="M" />
|
|
</div>
|
|
<code className="text-white font-mono text-base md:text-lg font-black opacity-90 italic tracking-tight uppercase">
|
|
{ip}:8084
|
|
</code>
|
|
</div>
|
|
<div className="flex-1 space-y-3 md:space-y-4 text-center md:text-left">
|
|
<h4 className="label-caps !text-white !opacity-100 text-xl md:text-2xl tracking-widest flex items-center justify-center md:justify-start space-x-3 md:space-x-4">
|
|
<div className="w-1.5 h-6 md:w-2 md:h-8 bg-ps-blue rounded-full" />
|
|
<span>{t("storage_hub.remote_management_title", "Remote Management")}</span>
|
|
</h4>
|
|
<p className="text-lg md:text-xl text-zinc-400 leading-relaxed italic font-medium max-w-3xl">
|
|
{t(
|
|
"storage_hub.remote_management_desc",
|
|
"Access this dashboard from your computer or phone to manage and upload payloads directly from your local network."
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default StorageHub
|