mirror of
https://github.com/itsPLK/ps5-payload-manager.git
synced 2026-10-19 02:19:53 -05:00
feat(repository): overhaul payload repository to app store experience
This commit completely redesigns how users browse, view, and install payloads. Backend C Changes: - Updated C structures to support multiple concurrent payload sources (config.h, repository.h). - Modified repository.c and config.c to parse and retain multi-source JSON payloads. - Allowed the HTTP server to serve the dynamically merged JSON sources. Frontend React Changes: - Converted StorageHub.jsx into a grid-based "App Shop" catalog layout. - Replaced the modal payload view with a seamless full-page PayloadDetailPanel.jsx component for extended markdown reading and details. - Added RepositoryContext.jsx to unify repository fetching across views. - Styled payload cards with a dynamic marquee animation for overflowing text names (PayloadName.jsx). - Allowed external icons, with a fallback placeholder for payloads without one. - Polished padding, margins, and transition effects for spatial controller navigation. - Removed duplicate versioning strings in payload titles and tweaked dashboard header text.
This commit is contained in:
1 parent
218f291d0e
commit
cb3fbf4716
19 files changed
+713
-255
No files matched your search
Generated
+30
@@ -9,9 +9,11 @@
|
||||
"version": "0.1.1",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.4.13",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"marked": "^18.0.9",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
@@ -1422,6 +1424,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
@@ -2476,6 +2485,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -4861,6 +4879,18 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "18.0.9",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz",
|
||||
"integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher-collection": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/matcher-collection/-/matcher-collection-2.0.1.tgz",
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.4.13",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"marked": "^18.0.9",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
||||
+80
-20
@@ -11,12 +11,16 @@ import {
|
||||
Menu,
|
||||
Terminal,
|
||||
X,
|
||||
Star
|
||||
Star,
|
||||
Trash2
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import './App.css'
|
||||
|
||||
// Contexts
|
||||
import { RepositoryProvider } from './contexts/RepositoryContext'
|
||||
|
||||
// Utilities
|
||||
import { cn, isPS5, isSystemPayload } from './utils/helpers'
|
||||
|
||||
@@ -42,6 +46,7 @@ function App() {
|
||||
const { t } = useTranslation();
|
||||
const [view, setView] = useState('dashboard')
|
||||
const [isFavoriteEditMode, setIsFavoriteEditMode] = useState(false)
|
||||
const [isManageMode, setIsManageMode] = useState(false)
|
||||
const mainRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,6 +120,20 @@ function App() {
|
||||
}, [showLogs])
|
||||
const [isOffline, setIsOffline] = useState(false)
|
||||
|
||||
const [updateStatus, setUpdateStatus] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const checkUpdates = async () => {
|
||||
try {
|
||||
const data = await api('/update_check')
|
||||
if (data && data.status !== 'none') {
|
||||
setUpdateStatus(data)
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
checkUpdates()
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -426,12 +445,11 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
|
||||
<div className={cn(
|
||||
"min-h-screen min-h-[100dvh] ps5-bg text-zinc-100 font-ps5 flex",
|
||||
isPS5 ? "flex-row overflow-hidden" : "flex-col md:flex-row md:overflow-hidden"
|
||||
)}>
|
||||
<RepositoryProvider config={config}>
|
||||
<div className={cn(
|
||||
"min-h-screen min-h-[100dvh] ps5-bg text-zinc-100 font-ps5 flex",
|
||||
isPS5 ? "flex-row overflow-hidden" : "flex-col md:flex-row md:overflow-hidden"
|
||||
)}>
|
||||
{/* Toast Container */}
|
||||
<div className="fixed top-0 right-0 p-8 z-[2000] space-y-4 pointer-events-none">
|
||||
{toasts.map(t => (
|
||||
@@ -536,6 +554,25 @@ function App() {
|
||||
"flex flex-col relative",
|
||||
isPS5 ? "h-screen flex-1 min-h-0" : "md:h-screen md:flex-1 md:min-h-0"
|
||||
)}>
|
||||
{updateStatus && (
|
||||
<div className="bg-ps-blue/20 border-b border-ps-blue/50 p-4 flex items-center justify-between z-[50]">
|
||||
<div className="flex items-center gap-3">
|
||||
<CloudDownload className="w-5 h-5 text-ps-blue" />
|
||||
<span className="text-zinc-200">
|
||||
<strong className="text-white">Update Available!</strong>
|
||||
{updateStatus.status === 'ready-to-apply'
|
||||
? " A new version is ready to be applied. Launch it from the dashboard."
|
||||
: " A new version is available in the repository. Please download it."}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setUpdateStatus(null)}
|
||||
className="p-2 hover:bg-white/10 rounded-full transition-colors text-zinc-400 hover:text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<main ref={mainRef} className={cn(
|
||||
"custom-scrollbar max-w-[1800px] mx-auto w-full flex flex-col",
|
||||
isPS5 ? "pt-16 px-16 pb-12 flex-1 overflow-y-auto" : "pt-6 px-6 pb-36 md:pt-16 md:px-16 md:pb-12 md:flex-1 md:overflow-y-auto"
|
||||
@@ -557,13 +594,20 @@ function App() {
|
||||
<PayloadButton
|
||||
key={p}
|
||||
path={p}
|
||||
onClick={() => isFavoriteEditMode ? toggleFavorite(p) : loadPayload(p)}
|
||||
onClick={() => {
|
||||
if (isManageMode) handleDelete(p.split('/').pop())
|
||||
else if (isFavoriteEditMode) toggleFavorite(p)
|
||||
else loadPayload(p)
|
||||
}}
|
||||
isLoading={loading && activeLoadingName === p.split('/').pop().replace(/\.(elf|bin)$/i, '').replace(/_/g, ' ')}
|
||||
sourceName={config.MULTI_SOURCES_ENABLED ? (payloadMeta[p.split('/').pop()]?.source_name || null) : null}
|
||||
version={payloadMeta[p.split('/').pop()]?.version || null}
|
||||
icon={payloadMeta[p.split('/').pop()]?.icon || null}
|
||||
author={payloadMeta[p.split('/').pop()]?.author || null}
|
||||
isFavorite={favoritePayloads.includes(p)}
|
||||
isLaunched={launchHistory.includes(p)}
|
||||
isEditMode={isFavoriteEditMode}
|
||||
isManageMode={isManageMode}
|
||||
onMoveFavorite={moveFavorite}
|
||||
canMoveLeft={activeFavorites.indexOf(p) > 0}
|
||||
canMoveRight={activeFavorites.indexOf(p) < activeFavorites.length - 1}
|
||||
@@ -572,29 +616,44 @@ function App() {
|
||||
return (
|
||||
<div className={cn(
|
||||
"space-y-8 md:space-y-12 transition-all",
|
||||
isFavoriteEditMode && "-m-4 md:-m-6 p-4 md:p-6 border-2 border-yellow-400/50 bg-yellow-400/5 rounded-ps-2xl"
|
||||
isFavoriteEditMode ? "-m-4 md:-m-6 p-4 md:p-6 border-2 border-yellow-400/50 bg-yellow-400/5 rounded-ps-2xl" :
|
||||
isManageMode ? "-m-4 md:-m-6 p-4 md:p-6 border-2 border-red-500/50 bg-red-500/5 rounded-ps-2xl" : ""
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={cn(
|
||||
"text-4xl font-extrabold tracking-tight",
|
||||
isFavoriteEditMode ? "text-yellow-400" : "text-white"
|
||||
isFavoriteEditMode ? "text-yellow-400" : isManageMode ? "text-red-500" : "text-white"
|
||||
)}>
|
||||
{isFavoriteEditMode ? (
|
||||
t("app.dashboard.editFavorites", "Edit Favorites")
|
||||
) : isManageMode ? (
|
||||
t("app.dashboard.removePayload", "Remove Payload")
|
||||
) : (
|
||||
<>{t("app.dashboard.title_1", "Launch")} <span className="text-ps-blue">{t("app.dashboard.title_2", "Payload")}</span></>
|
||||
)}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsFavoriteEditMode(!isFavoriteEditMode)}
|
||||
className={cn(
|
||||
"p-3 rounded-full transition-colors flex items-center justify-center",
|
||||
isFavoriteEditMode ? "bg-yellow-400/20 text-yellow-400" : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
title={t("app.dashboard.editFavorites", "Edit Favorites")}
|
||||
>
|
||||
<Star className={cn("w-6 h-6", isFavoriteEditMode && "fill-yellow-400")} />
|
||||
</button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => { setIsManageMode(!isManageMode); setIsFavoriteEditMode(false); }}
|
||||
className={cn(
|
||||
"p-3 rounded-full transition-colors flex items-center justify-center",
|
||||
isManageMode ? "bg-red-600/20 text-red-500" : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
title={t("app.dashboard.removePayload", "Remove Payload")}
|
||||
>
|
||||
<Trash2 className={cn("w-6 h-6", isManageMode && "text-red-500")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsFavoriteEditMode(!isFavoriteEditMode); setIsManageMode(false); }}
|
||||
className={cn(
|
||||
"p-3 rounded-full transition-colors flex items-center justify-center",
|
||||
isFavoriteEditMode ? "bg-yellow-400/20 text-yellow-400" : "bg-white/5 text-zinc-400 hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
title={t("app.dashboard.editFavorites", "Edit Favorites")}
|
||||
>
|
||||
<Star className={cn("w-6 h-6", isFavoriteEditMode && "fill-yellow-400")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loadingPayloads ? (
|
||||
<div className={gridCols}>
|
||||
@@ -750,6 +809,7 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RepositoryProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Loader2, Globe, Star, ChevronLeft, ChevronRight, CheckCircle2 } from 'lucide-react'
|
||||
import { Loader2, Globe, Star, ChevronLeft, ChevronRight, CheckCircle2, Trash2 } from 'lucide-react'
|
||||
import PayloadName from './PayloadName'
|
||||
import PayloadIcon from './PayloadIcon'
|
||||
import { cn } from '../../utils/helpers'
|
||||
|
||||
const PayloadButton = ({ path, onClick, isLoading, sourceName, version, isFavorite, isLaunched, isEditMode, onMoveFavorite, canMoveLeft, canMoveRight }) => {
|
||||
const PayloadButton = ({ path, onClick, isLoading, sourceName, version, icon, author, isFavorite, isLaunched, isEditMode, isManageMode, onMoveFavorite, canMoveLeft, canMoveRight }) => {
|
||||
const { t } = useTranslation()
|
||||
const isUsb = path.startsWith('/mnt/usb')
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={isLoading}
|
||||
className="group glass-card p-6 rounded-ps-xl flex flex-col border border-white/5 hover:border-ps-blue hover:bg-ps-blue/5 transition-all text-left relative"
|
||||
disabled={isLoading || (isManageMode && isUsb)}
|
||||
className={cn(
|
||||
"group glass-card pl-4 pr-3 py-4 rounded-ps-xl flex flex-col border transition-all text-left relative",
|
||||
isManageMode ? "border-red-500/20 hover:border-red-500 hover:bg-red-500/10" : "border-white/5 hover:border-ps-blue hover:bg-ps-blue/5",
|
||||
isManageMode && isUsb && "opacity-50 grayscale cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between w-full z-10">
|
||||
<PayloadName path={path} version={version} className="text-white text-xl" stacked />
|
||||
<div className="flex items-center gap-4 min-w-0 pr-2">
|
||||
<PayloadIcon icon={icon} name={path.split('/').pop()} className="w-12 h-12 text-xl shadow-md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<PayloadName path={path} hideVersion={true} className="text-white text-xl" stacked={false} />
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{version && <span className="text-[10px] text-zinc-400 font-bold bg-white/10 px-1.5 py-0.5 rounded uppercase shrink-0">{version}</span>}
|
||||
{author && (
|
||||
<div className="text-[10px] text-zinc-500 font-medium truncate">
|
||||
{author}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isEditMode && (
|
||||
<div
|
||||
@@ -25,7 +44,12 @@ const PayloadButton = ({ path, onClick, isLoading, sourceName, version, isFavori
|
||||
<Star className={cn("w-5 h-5", isFavorite && "fill-yellow-400")} />
|
||||
</div>
|
||||
)}
|
||||
{isLaunched && !isEditMode && !isLoading && (
|
||||
{isManageMode && !isUsb && (
|
||||
<div className="p-1 rounded-lg text-red-500 transition-all opacity-50 group-hover:opacity-100">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
{isLaunched && !isEditMode && !isManageMode && !isLoading && (
|
||||
<div className="group/tooltip relative p-1 text-green-400">
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<div className="absolute bottom-full right-0 mb-2 px-2 py-1 bg-ps-black border border-white/10 shadow-lg text-xs font-bold text-white rounded opacity-0 group-hover/tooltip:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
|
||||
@@ -36,12 +60,12 @@ const PayloadButton = ({ path, onClick, isLoading, sourceName, version, isFavori
|
||||
{isLoading && <Loader2 className="w-6 h-6 animate-spin text-ps-blue" />}
|
||||
</div>
|
||||
</div>
|
||||
{path.startsWith('/mnt/usb') && (
|
||||
{isUsb && (
|
||||
<div className="mt-3 text-[10px] text-zinc-500 font-medium truncate opacity-60 group-hover:opacity-100 transition-opacity z-10 select-none">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
{sourceName && !path.startsWith('/mnt/usb') && !(isEditMode && isFavorite) && (
|
||||
{sourceName && !isUsb && !(isEditMode && isFavorite) && !isManageMode && (
|
||||
<div className="absolute bottom-2 right-3 flex items-center gap-1 z-10 pointer-events-none">
|
||||
<Globe className="w-3 h-3 text-zinc-500 shrink-0" />
|
||||
<span className="text-[11px] text-zinc-400 font-medium truncate max-w-[120px] select-none">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import PayloadName from './PayloadName';
|
||||
import PayloadIcon from './PayloadIcon';
|
||||
|
||||
const PayloadCard = ({ payload, onClick, isUpdate, isInstalled }) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group flex flex-col p-4 md:p-6 bg-white/[0.02] hover:bg-white/[0.05] border border-white/5 hover:border-ps-blue/30 rounded-3xl transition-all text-left relative",
|
||||
(isInstalled && !isUpdate) && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className="flex justify-center mb-4 w-full">
|
||||
<PayloadIcon icon={payload.icon} name={payload.filename} className="w-20 h-20 md:w-28 md:h-28 shadow-2xl" />
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0 w-full text-center">
|
||||
<PayloadName path={payload.filename} hideVersion={true} className="text-lg md:text-xl text-white font-extrabold truncate w-full flex justify-center" />
|
||||
<div className="flex items-center justify-center gap-2 mt-1 w-full">
|
||||
{payload.version && <span className="text-[10px] text-zinc-400 font-bold bg-white/10 px-1.5 py-0.5 rounded uppercase shrink-0">{payload.version}</span>}
|
||||
{payload.author && (
|
||||
<div className="text-zinc-500 font-medium text-[11px] md:text-xs truncate">
|
||||
{payload.author}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="absolute top-4 right-4 flex flex-col gap-2">
|
||||
{isUpdate && (
|
||||
<span className="px-2 py-1 rounded-lg bg-emerald-500/20 text-emerald-400 font-bold text-[10px] uppercase tracking-widest border border-emerald-500/20">
|
||||
Update
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayloadCard;
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, CloudDownload, Loader2, ArrowLeft } from 'lucide-react';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import PayloadIcon from './PayloadIcon';
|
||||
import PayloadName from './PayloadName';
|
||||
|
||||
const PayloadDetailPanel = ({ payload, onClose, onInstall, isInstalled, isUpdate, srcUrl, srcId }) => {
|
||||
const [detailsHtml, setDetailsHtml] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload?.details_url) return;
|
||||
|
||||
// Resolve full URL if relative
|
||||
let url = payload.details_url;
|
||||
if (!url.startsWith('http')) {
|
||||
// If srcUrl is the repo json, resolve relative to that directory
|
||||
if (srcUrl) {
|
||||
const base = srcUrl.substring(0, srcUrl.lastIndexOf('/') + 1);
|
||||
url = base + url;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDetails = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('Failed to fetch details');
|
||||
const text = await res.text();
|
||||
const html = DOMPurify.sanitize(marked.parse(text));
|
||||
setDetailsHtml(html);
|
||||
} catch (err) {
|
||||
setDetailsHtml('<p class="text-red-400">Failed to load details.</p>');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetails();
|
||||
}, [payload, srcUrl]);
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full min-h-full animate-in fade-in duration-300">
|
||||
|
||||
{/* View Header with Back Button */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-4 py-2 rounded-xl"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span className="font-bold text-sm uppercase tracking-wider">Back to Repository</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Header section */}
|
||||
<div className="flex flex-col md:flex-row items-start gap-6 border-b border-white/10 pb-8 shrink-0">
|
||||
<div className="flex justify-between w-full md:w-auto">
|
||||
<PayloadIcon icon={payload.icon} name={payload.filename} className="w-24 h-24 text-4xl shadow-xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<div className="flex justify-between items-start">
|
||||
<PayloadName path={payload.filename} version={payload.version} className="text-2xl md:text-3xl text-white font-extrabold truncate w-full" />
|
||||
</div>
|
||||
|
||||
<div className="text-zinc-400 font-medium text-sm mt-1 mb-4 flex items-center gap-2">
|
||||
{payload.author && <span className="text-ps-blue">{payload.author}</span>}
|
||||
{payload.author && <span>•</span>}
|
||||
<span className="opacity-70 truncate">{payload.filename}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onInstall(payload, srcId, srcUrl)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center space-x-3 px-6 py-4 rounded-2xl font-bold text-lg transition-all active:scale-95",
|
||||
isUpdate ? "bg-emerald-600 hover:bg-emerald-500 text-white" :
|
||||
isInstalled ? "bg-white/10 text-white/50 cursor-not-allowed" :
|
||||
"bg-ps-blue hover:bg-ps-blue/80 text-white"
|
||||
)}
|
||||
disabled={isInstalled && !isUpdate}
|
||||
>
|
||||
<CloudDownload className="w-5 h-5" />
|
||||
<span>
|
||||
{isUpdate ? "Update" : isInstalled ? "Installed" : "Install"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 md:p-8">
|
||||
{payload.description && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-bold text-white mb-3">Description</h3>
|
||||
<p className="text-zinc-300 leading-relaxed text-base">{payload.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payload.release_notes && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-bold text-white mb-3">What's New</h3>
|
||||
<div className="text-zinc-300 leading-relaxed text-sm whitespace-pre-wrap bg-white/5 p-4 rounded-2xl border border-white/5">
|
||||
{payload.release_notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payload.details_url && (
|
||||
<div className="mt-8 pt-8 border-t border-white/10">
|
||||
<h3 className="text-lg font-bold text-white mb-4">About</h3>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-3 text-zinc-500">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
<span>Loading details...</span>
|
||||
</div>
|
||||
) : detailsHtml ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none prose-a:text-ps-blue hover:prose-a:text-ps-blue/80 prose-headings:text-white prose-p:text-zinc-300"
|
||||
dangerouslySetInnerHTML={{ __html: detailsHtml }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayloadDetailPanel;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { Package } from 'lucide-react';
|
||||
|
||||
const COLORS = [
|
||||
['#3b82f6', '#1d4ed8'], // blue
|
||||
['#10b981', '#047857'], // emerald
|
||||
['#8b5cf6', '#6d28d9'], // violet
|
||||
['#f59e0b', '#b45309'], // amber
|
||||
['#ef4444', '#b91c1c'], // red
|
||||
['#ec4899', '#be185d'], // pink
|
||||
['#14b8a6', '#0f766e'], // teal
|
||||
];
|
||||
|
||||
const stringToHash = (str) => {
|
||||
let hash = 0;
|
||||
if (!str) return 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return Math.abs(hash);
|
||||
};
|
||||
|
||||
const PayloadIcon = ({ icon, name, className }) => {
|
||||
if (icon) {
|
||||
return (
|
||||
<div className={cn("rounded-2xl overflow-hidden shadow-lg border border-white/10 shrink-0 bg-white/5", className)}>
|
||||
<img src={icon} alt={name || 'Payload'} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
const hash = stringToHash(name || 'Payload');
|
||||
const [colorStart, colorEnd] = COLORS[hash % COLORS.length];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-2xl flex flex-col items-center justify-center shadow-lg border border-white/10 shrink-0 text-white font-bold relative overflow-hidden", className)}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colorStart}, ${colorEnd})`
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<Package className="w-1/2 h-1/2 opacity-80 z-10 drop-shadow-md" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PayloadIcon;
|
||||
@@ -1,24 +1,68 @@
|
||||
import React from 'react'
|
||||
import React, { useRef, useState, useEffect } from 'react'
|
||||
import { Zap, Usb } from 'lucide-react'
|
||||
import { cn, parsePayloadName } from '../../utils/helpers'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const PayloadName = ({ path, version: repoVersion, className, versionClassName, stacked = false, hideIcon = false, lastUpdate = null }) => {
|
||||
const PayloadName = ({ path, version: repoVersion, className, versionClassName, stacked = false, hideIcon = false, lastUpdate = null, hideVersion = false }) => {
|
||||
const { t } = useTranslation();
|
||||
let { displayName, version, isDelay, delaySeconds } = parsePayloadName(path);
|
||||
const isUsb = path?.startsWith('/mnt/usb');
|
||||
|
||||
if (repoVersion) version = repoVersion;
|
||||
if (hideVersion) version = null;
|
||||
if (isDelay) {
|
||||
displayName = t("autoload.delay_item", "Delay ({{seconds}}s)", { seconds: delaySeconds || 1 });
|
||||
}
|
||||
|
||||
const containerRef = useRef(null);
|
||||
const textRef = useRef(null);
|
||||
const [scrollAmount, setScrollAmount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (containerRef.current && textRef.current) {
|
||||
const overflow = textRef.current.scrollWidth - containerRef.current.clientWidth;
|
||||
setScrollAmount(overflow > 0 ? overflow : 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Check initially and on resize
|
||||
checkOverflow();
|
||||
window.addEventListener('resize', checkOverflow);
|
||||
return () => window.removeEventListener('resize', checkOverflow);
|
||||
}, [displayName]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-w-0 flex-1 w-full", stacked ? "flex-col items-stretch" : "items-center space-x-3", className)}>
|
||||
<div className="flex items-center space-x-2 min-w-0">
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
{isDelay && !hideIcon && <Zap className="w-4 h-4 text-ps-blue shrink-0" />}
|
||||
{isUsb && !hideIcon && <Usb className="w-5 h-5 text-ps-blue shrink-0 mr-1" />}
|
||||
<span className="font-bold truncate shrink leading-tight">{displayName}</span>
|
||||
<div
|
||||
className="overflow-hidden min-w-0 flex-1 relative"
|
||||
ref={containerRef}
|
||||
>
|
||||
{/* Static truncated text with ellipsis */}
|
||||
<div
|
||||
className={cn(
|
||||
"truncate font-bold text-left leading-tight transition-opacity",
|
||||
scrollAmount > 0 ? "group-hover:opacity-0 group-focus-within:opacity-0" : ""
|
||||
)}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
|
||||
{/* Scrolling text overlay (only visible on hover/focus if overflowing) */}
|
||||
<div
|
||||
ref={textRef}
|
||||
className={cn(
|
||||
"absolute top-0 left-0 font-bold text-left leading-tight whitespace-nowrap inline-block transition-opacity",
|
||||
scrollAmount > 0 ? "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 animate-marquee" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
style={{ '--scroll-amount': `${scrollAmount}px` }}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(version || lastUpdate) && (
|
||||
<div className={cn("flex items-center", stacked ? "mt-1" : "")}>
|
||||
|
||||
@@ -1,129 +1,50 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useMemo, useLayoutEffect } from 'react'
|
||||
import { CloudDownload, Upload, Package, Database, RefreshCw, Trash2, Loader2, AlertTriangle, HardDrive, Usb, ChevronDown, Globe, Search } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { cn, isPS5, isIOS, parsePayloadName } from '../../utils/helpers'
|
||||
import PayloadName from '../ui/PayloadName'
|
||||
|
||||
const PayloadItem = ({ p, multiSources, isPS5, onInstall, srcId, srcUrl }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col md:flex-row justify-between gap-4 md:gap-8 p-6 md:p-8 transition-all",
|
||||
multiSources
|
||||
? "hover:bg-white/[0.03]"
|
||||
: "glass-card rounded-ps-3xl border border-white/10 hover:border-ps-blue/20 bg-white/[0.01]",
|
||||
isPS5 ? "flex-row items-center" : "items-start md:items-center"
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2 min-w-0">
|
||||
<PayloadName path={p.filename} version={p.version} className="text-xl md:text-2xl text-white" stacked lastUpdate={p.last_update} />
|
||||
{p.description && (
|
||||
<p className="text-sm md:text-base text-zinc-400 font-medium leading-relaxed">{p.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onInstall(p, srcId === 'legacy-repo' ? null : srcId, srcUrl)}
|
||||
className={cn(
|
||||
"flex items-center justify-center space-x-3 px-6 md:px-8 py-3 md:py-5 rounded-2xl font-bold text-lg transition-all shrink-0 transform active:scale-95",
|
||||
isPS5 ? "w-auto px-12" : "w-full md:w-auto",
|
||||
p.isUpdate
|
||||
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
: "bg-ps-blue hover:bg-ps-blue/80 text-white"
|
||||
)}
|
||||
>
|
||||
<CloudDownload className="w-5 h-5 md:w-6 md:h-6" />
|
||||
<span>{p.isUpdate ? t("storage_hub.update_btn", "Update") : t("storage_hub.install_btn", "Install")}</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CategoryGroup = ({ category, payloads, multiSources, isPS5, onInstall, srcId, srcUrl, defaultExpanded = false }) => {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
return (
|
||||
<div className="border-b border-white/5 last:border-0">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full flex items-center justify-between p-4 md:p-6 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="font-bold text-white/80 uppercase tracking-widest text-sm">{category} ({payloads.length})</span>
|
||||
<ChevronDown className={cn("w-5 h-5 text-zinc-500 transition-transform", expanded && "rotate-180")} />
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className={cn(
|
||||
multiSources ? "divide-y divide-white/5 bg-black/20" : "grid grid-cols-1 gap-4 p-4",
|
||||
"border-t border-white/5"
|
||||
)}>
|
||||
{payloads.map(p => (
|
||||
<PayloadItem
|
||||
key={p.filename}
|
||||
p={p}
|
||||
multiSources={multiSources}
|
||||
isPS5={isPS5}
|
||||
onInstall={onInstall}
|
||||
srcId={srcId}
|
||||
srcUrl={srcUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useRepository } from '../../contexts/RepositoryContext'
|
||||
import PayloadCard from '../ui/PayloadCard'
|
||||
import PayloadDetailPanel from '../ui/PayloadDetailPanel'
|
||||
|
||||
const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onImportFromUsb, config, ip, scrollTarget, onClearScrollTarget }) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const multiSources = config?.MULTI_SOURCES_ENABLED === true
|
||||
|
||||
const [repoData, setRepoData] = useState(null) // null = not loaded yet
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const { repoData, loading, error, fetchRemote } = useRepository()
|
||||
|
||||
const [expandedSource, setExpandedSource] = useState(null) // id of expanded catalog
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortMode, setSortMode] = useState(() => {
|
||||
return localStorage.getItem('repoSortMode') || 'category'
|
||||
return localStorage.getItem('repoSortMode') || 'name'
|
||||
})
|
||||
|
||||
const [selectedPayload, setSelectedPayload] = useState(null)
|
||||
const [savedScroll, setSavedScroll] = useState(0)
|
||||
|
||||
const handlePayloadSelect = (payloadData) => {
|
||||
const mainEl = document.querySelector('main')
|
||||
if (mainEl) setSavedScroll(mainEl.scrollTop)
|
||||
setSelectedPayload(payloadData)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mainEl = document.querySelector('main')
|
||||
if (!mainEl) return
|
||||
|
||||
if (selectedPayload) {
|
||||
mainEl.scrollTop = 0
|
||||
} else {
|
||||
mainEl.scrollTop = savedScroll
|
||||
}
|
||||
}, [selectedPayload, savedScroll])
|
||||
|
||||
const handleSortChange = (mode) => {
|
||||
setSortMode(mode)
|
||||
localStorage.setItem('repoSortMode', mode)
|
||||
}
|
||||
|
||||
const fetchRemote = async (force = false) => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
try {
|
||||
const endpoint = force ? '/repository_refresh' : '/repository_payloads'
|
||||
const res = await fetch(endpoint)
|
||||
if (!res.ok) throw new Error()
|
||||
const data = await res.json()
|
||||
setRepoData(data)
|
||||
|
||||
// For multi-source: auto-expand first source with payloads
|
||||
if (data?.sources?.length > 0 && expandedSource === null) {
|
||||
const first = data.sources.find(s => s.payloads?.length > 0)
|
||||
if (first) setExpandedSource(first.id)
|
||||
}
|
||||
|
||||
// Legacy single-source: auto-refresh if older than 24h
|
||||
if (!force && data?.last_update) {
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (now - Number(data.last_update) > 24 * 60 * 60) {
|
||||
await fetchRemote(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchRemote() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollTarget) {
|
||||
@@ -166,23 +87,23 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
|
||||
|
||||
/* ---- Source-grouped data ---- */
|
||||
const enrichedSources = useMemo(() => {
|
||||
if (multiSources && repoData?.sources) {
|
||||
if (repoData?.sources) {
|
||||
return repoData.sources.map(src => ({
|
||||
...src,
|
||||
id: src.id || src.url,
|
||||
payloads: enrichPayloads(src.payloads || [])
|
||||
}))
|
||||
} else if (!multiSources && repoData?.payloads) {
|
||||
} else if (repoData?.payloads) {
|
||||
return [{
|
||||
id: 'legacy-repo',
|
||||
name: t("storage_hub.default_repository", "Default Repository"),
|
||||
name: repoData.name || t("storage_hub.default_repository", "Default Repository"),
|
||||
url: repoData.repo_url || '',
|
||||
last_update: repoData.last_update || 0,
|
||||
payloads: enrichPayloads(repoData.payloads)
|
||||
}]
|
||||
}
|
||||
return []
|
||||
}, [repoData, multiSources, localFilenames, t])
|
||||
}, [repoData, localFilenames, t])
|
||||
|
||||
const legacyRepoUrl = repoData?.repo_url || ''
|
||||
|
||||
@@ -193,8 +114,25 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
|
||||
return meta?.source_name || meta?.install_source_detail || null
|
||||
}
|
||||
|
||||
if (selectedPayload) {
|
||||
return (
|
||||
<PayloadDetailPanel
|
||||
payload={selectedPayload.payload}
|
||||
srcId={selectedPayload.srcId}
|
||||
srcUrl={selectedPayload.srcUrl}
|
||||
isInstalled={selectedPayload.isInstalled}
|
||||
isUpdate={selectedPayload.isUpdate}
|
||||
onClose={() => setSelectedPayload(null)}
|
||||
onInstall={(p, sid, surl) => {
|
||||
onInstall(p, sid, surl)
|
||||
setSelectedPayload(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-12 animate-in fade-in duration-300">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-8">
|
||||
<h2 className="text-4xl font-extrabold text-white tracking-tight">
|
||||
{t("storage_hub.title_1", "Payload")} <span className="text-ps-blue">{t("storage_hub.title_2", "Management")}</span>
|
||||
@@ -209,79 +147,6 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Installed Payloads Section */}
|
||||
<section className="space-y-6">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="label-caps !text-white flex items-center space-x-4 text-lg">
|
||||
<Database className="w-6 h-6 text-ps-blue" />
|
||||
<span>{t("storage_hub.installed_title", "Installed Payloads")}</span>
|
||||
</h3>
|
||||
<span className="bg-white/5 px-4 py-1 rounded-full text-zinc-500 font-bold text-xs">
|
||||
{t("storage_hub.files_count", "{{count}} File", { count: internalPayloads.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("grid gap-4", isPS5 ? "grid-cols-2" : "grid-cols-1 lg:grid-cols-2")}>
|
||||
{internalPayloads.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-4 bg-white/[0.01]">
|
||||
<Package className="w-16 h-16 text-white/5" />
|
||||
<p className="text-zinc-500 font-bold uppercase tracking-widest text-sm italic">{t("storage_hub.library_empty", "Library Empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
internalPayloads.map((path) => {
|
||||
const fileName = path.split('/').pop()
|
||||
const sourceBadge = getSourceBadge(fileName)
|
||||
// Find update in all sources (multi or legacy)
|
||||
const allRemote = enrichedSources.flatMap(s => s.payloads)
|
||||
const remoteMatch = allRemote.find(rp => rp.filename === fileName || rp.installedFilename === fileName)
|
||||
const remoteVersion = remoteMatch?.version || (remoteMatch?.filename ? parsePayloadName(remoteMatch.filename).version : null)
|
||||
return (
|
||||
<div key={path} className="group flex flex-col justify-center p-4 md:p-6 glass-card rounded-ps-2xl border-white/10 hover:border-ps-blue/30 gap-3 md:gap-4 relative overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<div className="flex items-center space-x-4 md:space-x-6 min-w-0 flex-1">
|
||||
<div className="p-3 md:p-4 bg-white/5 rounded-2xl group-hover:bg-ps-blue/10 transition-colors shrink-0">
|
||||
<Package className="w-6 h-6 md:w-8 md:h-8 text-zinc-400 group-hover:text-ps-blue transition-colors" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<PayloadName path={fileName} className="text-xl md:text-2xl text-white" stacked />
|
||||
{sourceBadge && (
|
||||
<div className="flex items-center gap-1 text-zinc-500 text-[11px] select-none font-medium">
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>{sourceBadge}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center shrink-0">
|
||||
<button
|
||||
onClick={() => onDelete(fileName)}
|
||||
className="p-3 md:p-4 rounded-xl bg-red-950/20 text-red-500 border border-red-500/10 hover:bg-red-500 hover:text-white transition-all"
|
||||
title={t("storage_hub.remove_payload", "Remove Payload")}
|
||||
>
|
||||
<Trash2 className="w-5 h-5 md:w-6 md:h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{remoteMatch?.isUpdate && (
|
||||
<button
|
||||
onClick={() => onInstall(remoteMatch, remoteMatch.source_id, legacyRepoUrl)}
|
||||
className="w-full flex items-center justify-center space-x-2 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-xl font-bold text-xs md:text-sm transition-all"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 md:w-5 md:h-5" />
|
||||
<span>
|
||||
{remoteVersion
|
||||
? t("storage_hub.update_to_version", "Update (to v{{version}})", { version: remoteVersion.replace(/^v/i, '') })
|
||||
: t("storage_hub.update_btn", "Update")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cloud Repository Section */}
|
||||
<section id="cloud-repository" className="space-y-6">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@@ -439,10 +304,10 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Catalog payload list */}
|
||||
{/* Catalog payload grid */}
|
||||
{isExpanded && (
|
||||
<div className={cn(
|
||||
multiSources ? "border-t border-white/5 divide-y divide-white/5" : "grid grid-cols-1 gap-4"
|
||||
multiSources ? "border-t border-white/5 p-4 md:p-6" : ""
|
||||
)}>
|
||||
{src.payloads.length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center space-y-3 text-zinc-600">
|
||||
@@ -454,30 +319,45 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
|
||||
</div>
|
||||
) : hasMultipleCategories ? (
|
||||
categories.map(cat => (
|
||||
<CategoryGroup
|
||||
key={cat}
|
||||
category={cat}
|
||||
payloads={groupedPayloads[cat]}
|
||||
multiSources={multiSources}
|
||||
isPS5={isPS5}
|
||||
onInstall={onInstall}
|
||||
srcId={src.id}
|
||||
srcUrl={src.url}
|
||||
defaultExpanded={availablePayloads.length <= 10}
|
||||
/>
|
||||
<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-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
|
||||
{groupedPayloads[cat].map(p => (
|
||||
<PayloadCard
|
||||
key={p.filename}
|
||||
payload={p}
|
||||
isUpdate={p.isUpdate}
|
||||
isInstalled={p.isInstalled}
|
||||
onClick={() => handlePayloadSelect({
|
||||
payload: p,
|
||||
srcId: src.id === 'legacy-repo' ? null : src.id,
|
||||
srcUrl: src.url,
|
||||
isInstalled: p.isInstalled,
|
||||
isUpdate: p.isUpdate
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
availablePayloads.map(p => (
|
||||
<PayloadItem
|
||||
key={p.filename}
|
||||
p={p}
|
||||
multiSources={multiSources}
|
||||
isPS5={isPS5}
|
||||
onInstall={onInstall}
|
||||
srcId={src.id}
|
||||
srcUrl={src.url}
|
||||
/>
|
||||
))
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6">
|
||||
{availablePayloads.map(p => (
|
||||
<PayloadCard
|
||||
key={p.filename}
|
||||
payload={p}
|
||||
isUpdate={p.isUpdate}
|
||||
isInstalled={p.isInstalled}
|
||||
onClick={() => handlePayloadSelect({
|
||||
payload: p,
|
||||
srcId: src.id === 'legacy-repo' ? null : src.id,
|
||||
srcUrl: src.url,
|
||||
isInstalled: p.isInstalled,
|
||||
isUpdate: p.isUpdate
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -578,6 +458,7 @@ const StorageHub = ({ payloads, payloadMeta, onInstall, onDelete, onUpload, onIm
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const RepositoryContext = createContext();
|
||||
|
||||
export const useRepository = () => useContext(RepositoryContext);
|
||||
|
||||
export const RepositoryProvider = ({ children, config }) => {
|
||||
const [repoData, setRepoData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Safely check if config exists before checking MULTI_SOURCES_ENABLED
|
||||
const multiSources = config?.MULTI_SOURCES_ENABLED === true;
|
||||
|
||||
const fetchRemote = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const endpoint = force ? '/repository_refresh' : '/repository_payloads';
|
||||
const res = await fetch(endpoint);
|
||||
if (!res.ok) throw new Error();
|
||||
const data = await res.json();
|
||||
|
||||
setRepoData(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(true);
|
||||
setRepoData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [multiSources]);
|
||||
|
||||
// Fetch only once when config is loaded
|
||||
useEffect(() => {
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
if (repoData === null) {
|
||||
fetchRemote();
|
||||
}
|
||||
}
|
||||
}, [config, fetchRemote, repoData]);
|
||||
|
||||
return (
|
||||
<RepositoryContext.Provider value={{ repoData, loading, error, fetchRemote }}>
|
||||
{children}
|
||||
</RepositoryContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -241,3 +241,15 @@ h1, h2, h3, h4 {
|
||||
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
|
||||
@keyframes marquee-alternate {
|
||||
0%, 15% { transform: translateX(0); }
|
||||
85%, 100% { transform: translateX(calc(-1 * var(--scroll-amount, 0px))); }
|
||||
}
|
||||
|
||||
button:hover .animate-marquee,
|
||||
button:focus .animate-marquee,
|
||||
.group:hover .animate-marquee,
|
||||
.group:focus-within .animate-marquee {
|
||||
animation: marquee-alternate 4s ease-in-out infinite alternate;
|
||||
}
|
||||
+2
-1
@@ -13,6 +13,7 @@ typedef struct PldmgrConfig {
|
||||
int scan_usb_payloads; /* Default: 0 (off) */
|
||||
int auto_install_app; /* Default: 1 (on) */
|
||||
int multi_sources_enabled; /* Default: 0 (off) */
|
||||
int show_update_notifications; /* Default: 1 (on) */
|
||||
} PldmgrConfig;
|
||||
|
||||
/* Read all config values from PLDMGR_CONFIG_PATH.
|
||||
@@ -30,7 +31,7 @@ int config_read_bool(const char *key, int default_val);
|
||||
/* Apply a JSON config update sent from the frontend /set_config route.
|
||||
* Handles AUTOLOAD_ENABLED, AUTO_BROWSER_OPEN, AUTOLOAD_DELAY,
|
||||
* KILL_DISC_PLAYER_ON_STARTUP, SCAN_USB_PAYLOADS, AUTO_INSTALL_APP,
|
||||
* MULTI_SOURCES_ENABLED, and AUTOLOAD_LIST.
|
||||
* MULTI_SOURCES_ENABLED, SHOW_UPDATE_NOTIFICATIONS, and AUTOLOAD_LIST.
|
||||
* Returns 0 on success, -1 on error. */
|
||||
int config_handle_set_json(const char *json_data);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#define ROUTE_REPO_INSTALL "/repository_install"
|
||||
#define ROUTE_REPO_PUSH "/repository_push"
|
||||
#define ROUTE_REPO_INSTALL_PUSH "/repository_install_push"
|
||||
#define ROUTE_UPDATE_CHECK "/update_check"
|
||||
#define ROUTE_SOURCES_LIST "/sources_list"
|
||||
#define ROUTE_SOURCES_SET "/sources_set"
|
||||
#define ROUTE_SOURCES_ADD "/sources_add"
|
||||
|
||||
@@ -16,6 +16,10 @@ typedef struct RepoPayload {
|
||||
char checksum[65];
|
||||
char source_name[256];
|
||||
char category[128];
|
||||
char icon[16384];
|
||||
char release_notes[2048];
|
||||
char author[128];
|
||||
char details_url[1024];
|
||||
} RepoPayload;
|
||||
|
||||
/* Download a URL to a local file. Returns 0 on success, -1 on failure. */
|
||||
@@ -67,4 +71,8 @@ int get_elf_pldmgr_version(const char *path, char *out_version, size_t out_size)
|
||||
* If found, writes the path to out_path and returns 0. */
|
||||
int repository_check_self_update(char *out_path, size_t out_size);
|
||||
|
||||
/* Check if a newer version of the payload manager is available in the repo cache.
|
||||
* Returns 0 if an update is available, -1 otherwise. */
|
||||
int repository_check_repo_update(void);
|
||||
|
||||
#endif
|
||||
@@ -22,6 +22,7 @@ void config_read(PldmgrConfig *cfg) {
|
||||
cfg->scan_usb_payloads = 0;
|
||||
cfg->auto_install_app = 1;
|
||||
cfg->multi_sources_enabled = 0;
|
||||
cfg->show_update_notifications = 1;
|
||||
|
||||
f = fopen(PLDMGR_CONFIG_PATH, "r");
|
||||
if (!f)
|
||||
@@ -44,6 +45,8 @@ void config_read(PldmgrConfig *cfg) {
|
||||
cfg->auto_install_app = atoi(line + 17);
|
||||
else if (strncmp(line, "MULTI_SOURCES_ENABLED=", 22) == 0)
|
||||
cfg->multi_sources_enabled = atoi(line + 22);
|
||||
else if (strncmp(line, "SHOW_UPDATE_NOTIFICATIONS=", 26) == 0)
|
||||
cfg->show_update_notifications = atoi(line + 26);
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
@@ -64,6 +67,7 @@ int config_write(const PldmgrConfig *cfg) {
|
||||
fprintf(f, "SCAN_USB_PAYLOADS=%d\n", cfg->scan_usb_payloads ? 1 : 0);
|
||||
fprintf(f, "AUTO_INSTALL_APP=%d\n", cfg->auto_install_app ? 1 : 0);
|
||||
fprintf(f, "MULTI_SOURCES_ENABLED=%d\n", cfg->multi_sources_enabled ? 1 : 0);
|
||||
fprintf(f, "SHOW_UPDATE_NOTIFICATIONS=%d\n", cfg->show_update_notifications ? 1 : 0);
|
||||
fclose(f);
|
||||
return 0;
|
||||
}
|
||||
@@ -204,6 +208,7 @@ int config_handle_set_json(const char *json_data) {
|
||||
cfg.scan_usb_payloads = extract_json_bool(json_data, "SCAN_USB_PAYLOADS", cfg.scan_usb_payloads);
|
||||
cfg.auto_install_app = extract_json_bool(json_data, "AUTO_INSTALL_APP", cfg.auto_install_app);
|
||||
cfg.multi_sources_enabled = extract_json_bool(json_data, "MULTI_SOURCES_ENABLED", cfg.multi_sources_enabled);
|
||||
cfg.show_update_notifications = extract_json_bool(json_data, "SHOW_UPDATE_NOTIFICATIONS", cfg.show_update_notifications);
|
||||
|
||||
if (config_write(&cfg) == 0) {
|
||||
pldmgr_log("[PLDMGR] Saved config to %s\n", PLDMGR_CONFIG_PATH);
|
||||
|
||||
+31
-3
@@ -29,7 +29,7 @@
|
||||
#include "assets_favicon_svg.h"
|
||||
#include "assets_icon_png.h"
|
||||
|
||||
#define RESPONSE_BUFFER_SIZE 1048576
|
||||
#define RESPONSE_BUFFER_SIZE 4194304
|
||||
#define CORS_ORIGIN "*"
|
||||
|
||||
/* Shared flag — set from main() shutdown route, read by main loop */
|
||||
@@ -576,6 +576,33 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn,
|
||||
MHD_add_response_header(resp, "Cache-Control", "max-age=604800");
|
||||
|
||||
/* ── API routes ────────────────────────────────────────── */
|
||||
} else if (strcmp(url, ROUTE_UPDATE_CHECK) == 0) {
|
||||
char *resp_buf;
|
||||
struct MHD_Response *oom_resp = alloc_response_buffer(&resp_buf);
|
||||
if (oom_resp)
|
||||
return MHD_queue_response(conn, MHD_HTTP_INTERNAL_SERVER_ERROR, oom_resp);
|
||||
|
||||
char update_path[512] = {0};
|
||||
|
||||
PldmgrConfig cfg;
|
||||
config_read(&cfg);
|
||||
|
||||
if (!cfg.show_update_notifications) {
|
||||
snprintf(resp_buf, RESPONSE_BUFFER_SIZE, "{\"status\":\"none\"}");
|
||||
} else if (repository_check_self_update(update_path, sizeof(update_path)) == 0) {
|
||||
char escaped_path[1024];
|
||||
pldmgr_json_escape(update_path, escaped_path, sizeof(escaped_path));
|
||||
snprintf(resp_buf, RESPONSE_BUFFER_SIZE, "{\"status\":\"ready-to-apply\",\"path\":\"%s\"}", escaped_path);
|
||||
} else if (repository_check_repo_update() == 0) {
|
||||
snprintf(resp_buf, RESPONSE_BUFFER_SIZE, "{\"status\":\"update-available\"}");
|
||||
} else {
|
||||
snprintf(resp_buf, RESPONSE_BUFFER_SIZE, "{\"status\":\"none\"}");
|
||||
}
|
||||
|
||||
resp = MHD_create_response_from_buffer(strlen(resp_buf), (void *)resp_buf, MHD_RESPMEM_MUST_FREE);
|
||||
MHD_add_response_header(resp, "Content-Type", "application/json");
|
||||
add_cors_headers(resp);
|
||||
return MHD_queue_response(conn, MHD_HTTP_OK, resp);
|
||||
} else if (strcmp(url, ROUTE_CHECK) == 0) {
|
||||
const char *filename = MHD_lookup_connection_value(conn, MHD_GET_ARGUMENT_KIND, "filename");
|
||||
if (!filename) {
|
||||
@@ -959,12 +986,13 @@ enum MHD_Result http_on_request(void *cls, struct MHD_Connection *conn,
|
||||
"REPOSITORY_UPDATE\":%ld,"
|
||||
"\"AUTO_BROWSER_OPEN\":%s,\"AUTOLOAD_DELAY\":%d,\"KILL_DISC_"
|
||||
"PLAYER_ON_STARTUP\":%s,\"SCAN_USB_PAYLOADS\":%s,\"AUTO_INSTALL_APP\":%s,"
|
||||
"\"MULTI_SOURCES_ENABLED\":%s}",
|
||||
"\"MULTI_SOURCES_ENABLED\":%s,\"SHOW_UPDATE_NOTIFICATIONS\":%s}",
|
||||
cfg.autoload_enabled ? "true" : "false", list_escaped, cfg.last_repository_update,
|
||||
cfg.auto_browser_open ? "true" : "false", cfg.autoload_delay,
|
||||
cfg.kill_disc_player ? "true" : "false", cfg.scan_usb_payloads ? "true" : "false",
|
||||
cfg.auto_install_app ? "true" : "false",
|
||||
cfg.multi_sources_enabled ? "true" : "false");
|
||||
cfg.multi_sources_enabled ? "true" : "false",
|
||||
cfg.show_update_notifications ? "true" : "false");
|
||||
resp = MHD_create_response_from_buffer(strlen(resp_buf),
|
||||
(void *)resp_buf,
|
||||
MHD_RESPMEM_MUST_FREE);
|
||||
|
||||
+8
-2
@@ -242,6 +242,7 @@ size_t payload_mgr_list_json(char *json_buf, size_t buf_size) {
|
||||
char d_checksum[65] = "", d_downloaded[64] = "";
|
||||
char d_install_src[128] = "", d_install_detail[1024] = "";
|
||||
char d_source_name[256] = "";
|
||||
char d_author[128] = "", d_details_url[1024] = "";
|
||||
|
||||
const char *end = details_json + details_size;
|
||||
json_extract_string(details_json, end, "name", d_name, sizeof(d_name));
|
||||
@@ -256,9 +257,12 @@ size_t payload_mgr_list_json(char *json_buf, size_t buf_size) {
|
||||
json_extract_string(details_json, end, "install_source", d_install_src, sizeof(d_install_src));
|
||||
json_extract_string(details_json, end, "install_source_detail", d_install_detail, sizeof(d_install_detail));
|
||||
json_extract_string(details_json, end, "source_name", d_source_name, sizeof(d_source_name));
|
||||
json_extract_string(details_json, end, "author", d_author, sizeof(d_author));
|
||||
json_extract_string(details_json, end, "details_url", d_details_url, sizeof(d_details_url));
|
||||
|
||||
char filename_escaped[512], ne[512], de[2048], ve[128], ue[2048], se[2048], sde[2048];
|
||||
char lue[128], ce[128], dae[128], ise[256], ide[2048], sne[512];
|
||||
char ae[256], due[2048];
|
||||
pldmgr_json_escape(payloads[i].filename, filename_escaped, sizeof(filename_escaped));
|
||||
pldmgr_json_escape(d_name, ne, sizeof(ne));
|
||||
pldmgr_json_escape(d_desc, de, sizeof(de));
|
||||
@@ -272,16 +276,18 @@ size_t payload_mgr_list_json(char *json_buf, size_t buf_size) {
|
||||
pldmgr_json_escape(d_install_src, ise, sizeof(ise));
|
||||
pldmgr_json_escape(d_install_detail, ide, sizeof(ide));
|
||||
pldmgr_json_escape(d_source_name, sne, sizeof(sne));
|
||||
pldmgr_json_escape(d_author, ae, sizeof(ae));
|
||||
pldmgr_json_escape(d_details_url, due, sizeof(due));
|
||||
|
||||
json_append(&jb, "%s\"%s\":{"
|
||||
"\"display_name\":\"%s\",\"description\":\"%s\",\"version\":\"%s\","
|
||||
"\"url\":\"%s\",\"source\":\"%s\",\"source_direct\":\"%s\","
|
||||
"\"last_update\":\"%s\",\"checksum\":\"%s\",\"downloaded_at\":\"%s\","
|
||||
"\"install_source\":\"%s\",\"install_source_detail\":\"%s\","
|
||||
"\"source_name\":\"%s\"}",
|
||||
"\"source_name\":\"%s\",\"author\":\"%s\",\"details_url\":\"%s\"}",
|
||||
meta_first ? "" : ",",
|
||||
filename_escaped,
|
||||
ne, de, ve, ue, se, sde, lue, ce, dae, ise, ide, sne);
|
||||
ne, de, ve, ue, se, sde, lue, ce, dae, ise, ide, sne, ae, due);
|
||||
|
||||
meta_first = 0;
|
||||
free(details_json);
|
||||
|
||||
+82
-7
@@ -120,12 +120,38 @@ int parse_repository_payloads(const char *json, RepoPayload **out_items, size_t
|
||||
*out_count = 0;
|
||||
|
||||
while ((p = strchr(p, '{')) != NULL) {
|
||||
const char *end = strchr(p, '}');
|
||||
const char *end = p + 1;
|
||||
int depth = 1;
|
||||
int in_string = 0;
|
||||
int escaped = 0;
|
||||
|
||||
while (*end && depth > 0) {
|
||||
if (in_string) {
|
||||
if (escaped) {
|
||||
escaped = 0;
|
||||
} else if (*end == '\\') {
|
||||
escaped = 1;
|
||||
} else if (*end == '"') {
|
||||
in_string = 0;
|
||||
}
|
||||
} else {
|
||||
if (*end == '"') {
|
||||
in_string = 1;
|
||||
} else if (*end == '{') {
|
||||
depth++;
|
||||
} else if (*end == '}') {
|
||||
depth--;
|
||||
}
|
||||
}
|
||||
if (depth > 0) end++;
|
||||
}
|
||||
|
||||
if (!*end && depth > 0)
|
||||
break; /* Malformed JSON, no matching brace */
|
||||
|
||||
RepoPayload item;
|
||||
RepoPayload *tmp;
|
||||
if (!end)
|
||||
break;
|
||||
|
||||
|
||||
memset(&item, 0, sizeof(item));
|
||||
if (json_extract_string(p, end, "name", item.name, sizeof(item.name)) != 0 ||
|
||||
json_extract_string(p, end, "filename", item.filename, sizeof(item.filename)) != 0 ||
|
||||
@@ -141,6 +167,19 @@ int parse_repository_payloads(const char *json, RepoPayload **out_items, size_t
|
||||
json_extract_string(p, end, "version", item.version, sizeof(item.version));
|
||||
json_extract_string(p, end, "checksum", item.checksum, sizeof(item.checksum));
|
||||
|
||||
json_extract_string(p, end, "author", item.author, sizeof(item.author));
|
||||
json_extract_string(p, end, "details_url", item.details_url, sizeof(item.details_url));
|
||||
|
||||
json_extract_string(p, end, "release_notes", item.release_notes, sizeof(item.release_notes));
|
||||
if (strlen(item.release_notes) == sizeof(item.release_notes) - 1) {
|
||||
strcpy(item.release_notes + sizeof(item.release_notes) - 4, "...");
|
||||
}
|
||||
|
||||
json_extract_string(p, end, "icon", item.icon, sizeof(item.icon));
|
||||
if (strlen(item.icon) == sizeof(item.icon) - 1) {
|
||||
item.icon[0] = '\0'; /* Drop if > 16KB */
|
||||
}
|
||||
|
||||
if (json_extract_string(p, end, "category", item.category, sizeof(item.category)) != 0 || strlen(item.category) == 0) {
|
||||
strncpy(item.category, "Uncategorized", sizeof(item.category) - 1);
|
||||
}
|
||||
@@ -184,7 +223,8 @@ int write_payload_details_json(const RepoPayload *item, const char *details_path
|
||||
char name[256], filename[384], url[1400], source[1400], source_direct[1400];
|
||||
char description[1400], last_update[128], version[128], checksum[128], category[256];
|
||||
char downloaded_at[64], i_src[256], i_detail[1400], i_src_name[256];
|
||||
char json_buf[8192];
|
||||
char icon[16384], release_notes[4096], author[256], details_url[1400];
|
||||
char json_buf[40960];
|
||||
time_t now = time(NULL);
|
||||
struct tm tmv;
|
||||
|
||||
@@ -205,6 +245,10 @@ int write_payload_details_json(const RepoPayload *item, const char *details_path
|
||||
pldmgr_json_escape(install_source ? install_source : "unknown", i_src, sizeof(i_src));
|
||||
pldmgr_json_escape(install_source_detail ? install_source_detail : "", i_detail, sizeof(i_detail));
|
||||
pldmgr_json_escape(item->source_name[0] ? item->source_name : "", i_src_name, sizeof(i_src_name));
|
||||
pldmgr_json_escape(item->icon, icon, sizeof(icon));
|
||||
pldmgr_json_escape(item->release_notes, release_notes, sizeof(release_notes));
|
||||
pldmgr_json_escape(item->author, author, sizeof(author));
|
||||
pldmgr_json_escape(item->details_url, details_url, sizeof(details_url));
|
||||
|
||||
snprintf(json_buf, sizeof(json_buf),
|
||||
"{\n"
|
||||
@@ -221,11 +265,15 @@ int write_payload_details_json(const RepoPayload *item, const char *details_path
|
||||
" \"downloaded_at\": \"%s\",\n"
|
||||
" \"install_source\": \"%s\",\n"
|
||||
" \"install_source_detail\": \"%s\",\n"
|
||||
" \"source_name\": \"%s\"\n"
|
||||
" \"source_name\": \"%s\",\n"
|
||||
" \"icon\": \"%s\",\n"
|
||||
" \"release_notes\": \"%s\",\n"
|
||||
" \"author\": \"%s\",\n"
|
||||
" \"details_url\": \"%s\"\n"
|
||||
"}\n",
|
||||
name, filename, url, source, source_direct, description,
|
||||
last_update, version, checksum, category, downloaded_at, i_src, i_detail,
|
||||
i_src_name);
|
||||
i_src_name, icon, release_notes, author, details_url);
|
||||
|
||||
FILE *f = fopen(details_path, "w");
|
||||
if (!f) return -1;
|
||||
@@ -627,6 +675,10 @@ static int compare_versions(const char *v1, const char *v2) {
|
||||
const char *p1 = v1;
|
||||
const char *p2 = v2;
|
||||
|
||||
/* Skip leading non-numeric prefixes (e.g., 'v') */
|
||||
while (*p1 && (*p1 < '0' || *p1 > '9')) p1++;
|
||||
while (*p2 && (*p2 < '0' || *p2 > '9')) p2++;
|
||||
|
||||
int maj1 = parse_version_part(&p1);
|
||||
int maj2 = parse_version_part(&p2);
|
||||
if (maj1 != maj2) return maj1 - maj2;
|
||||
@@ -702,3 +754,26 @@ int repository_check_self_update(char *out_path, size_t out_size) {
|
||||
closedir(dir);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int repository_check_repo_update(void) {
|
||||
RepoPayload *items = NULL;
|
||||
size_t count = 0;
|
||||
if (load_cached_repository(&items, &count) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int found = 0;
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (strcasestr(items[i].filename, "pldmgr") || strcasestr(items[i].filename, "payload-manager")) {
|
||||
if (items[i].version[0] != '\0') {
|
||||
if (compare_versions(items[i].version, MENU_VERSION) > 0) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(items);
|
||||
return found ? 0 : -1;
|
||||
}
|
||||
+8
-2
@@ -472,6 +472,7 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
|
||||
char name_pe[256], filename_e[512], desc_e[2048];
|
||||
char ver_e[128], url_e[2048], src_id_e[128], src_name_e[512];
|
||||
char last_upd_e[128], cat_e[256];
|
||||
char auth_e[256], det_url_e[2048], rn_e[4096], icon_e[16384];
|
||||
|
||||
pldmgr_json_escape(items[pi].name, name_pe, sizeof(name_pe));
|
||||
pldmgr_json_escape(items[pi].filename, filename_e, sizeof(filename_e));
|
||||
@@ -482,12 +483,17 @@ size_t sources_multi_repository_list_json(char *buf, size_t size, int force_refr
|
||||
pldmgr_json_escape(items[pi].category, cat_e, sizeof(cat_e));
|
||||
pldmgr_json_escape(sources[si].id, src_id_e, sizeof(src_id_e));
|
||||
pldmgr_json_escape(sources[si].name, src_name_e, sizeof(src_name_e));
|
||||
pldmgr_json_escape(items[pi].author, auth_e, sizeof(auth_e));
|
||||
pldmgr_json_escape(items[pi].details_url, det_url_e, sizeof(det_url_e));
|
||||
pldmgr_json_escape(items[pi].release_notes,rn_e, sizeof(rn_e));
|
||||
pldmgr_json_escape(items[pi].icon, icon_e, sizeof(icon_e));
|
||||
|
||||
if (json_append(&jb, " {\"name\":\"%s\",\"filename\":\"%s\",\"description\":\"%s\","
|
||||
"\"version\":\"%s\",\"last_update\":\"%s\",\"url\":\"%s\","
|
||||
"\"source_id\":\"%s\",\"source_name\":\"%s\",\"category\":\"%s\"}%s\n",
|
||||
"\"source_id\":\"%s\",\"source_name\":\"%s\",\"category\":\"%s\","
|
||||
"\"author\":\"%s\",\"details_url\":\"%s\",\"release_notes\":\"%s\",\"icon\":\"%s\"}%s\n",
|
||||
name_pe, filename_e, desc_e, ver_e, last_upd_e, url_e,
|
||||
src_id_e, src_name_e, cat_e,
|
||||
src_id_e, src_name_e, cat_e, auth_e, det_url_e, rn_e, icon_e,
|
||||
(pi < count - 1) ? "," : "") != 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user