app_store UI: simplified downloads page

This commit is contained in:
bitful-pannul 2024-08-27 16:06:07 +03:00
parent f963c3705d
commit 7b1312b4ac
3 changed files with 391 additions and 118 deletions

View File

@ -3,7 +3,7 @@ import useAppsStore from "../store";
interface MirrorSelectorProps {
packageId: string | undefined;
onMirrorSelect: (mirror: string) => void;
onMirrorSelect: (mirror: string, status: boolean | null | 'http') => void;
}
const MirrorSelector: React.FC<MirrorSelectorProps> = ({ packageId, onMirrorSelect }) => {
@ -40,8 +40,10 @@ const MirrorSelector: React.FC<MirrorSelectorProps> = ({ packageId, onMirrorSele
}, [packageId, fetchListing, checkMirror]);
useEffect(() => {
onMirrorSelect(selectedMirror);
}, [selectedMirror, onMirrorSelect]);
if (selectedMirror) {
onMirrorSelect(selectedMirror, mirrorStatuses[selectedMirror]);
}
}, [selectedMirror, mirrorStatuses, onMirrorSelect]);
const handleMirrorChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;

View File

@ -317,35 +317,220 @@ td {
/* Download Page */
.downloads-page {
background-color: light-dark(var(--off-white), var(--off-black));
border-radius: var(--border-radius);
padding: 1rem;
background-color: light-dark(var(--white), var(--maroon));
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
}
.mirror-selection {
max-width: 300px;
.app-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.app-header h2 {
margin: 0;
}
.launch-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 14px;
background-color: var(--orange);
color: var(--white);
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.launch-button:hover {
background-color: var(--dark-orange);
}
.version-selector {
margin-bottom: 1rem;
}
.version-selector select {
width: 100%;
padding: 0.75rem;
border: 2px solid var(--orange);
border-radius: 4px;
background-color: light-dark(var(--white), var(--tasteful-dark));
color: light-dark(var(--off-black), var(--off-white));
transition: all 0.3s ease;
}
.version-selector select:focus {
outline: none;
border-color: var(--dark-orange);
box-shadow: 0 0 0 3px rgba(255, 79, 0, 0.2);
}
.download-section {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.download-button,
.install-button,
.installed-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 16px;
font-weight: bold;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.download-button {
background-color: var(--orange);
color: var(--white);
}
.download-button:hover:not(:disabled) {
background-color: var(--dark-orange);
}
.install-button {
background-color: var(--blue);
color: var(--white);
}
.install-button:hover {
background-color: color-mix(in srgb, var(--blue) 80%, black);
}
.installed-button {
background-color: var(--gray);
color: var(--white);
cursor: not-allowed;
}
.download-button:disabled,
.install-button:disabled,
.installed-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.my-downloads {
margin-top: 1rem;
}
.my-downloads>button {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: transparent;
color: var(--orange);
border: 2px solid var(--orange);
padding: 0.5rem 1rem;
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease;
}
.my-downloads>button:hover {
background-color: var(--orange);
color: var(--white);
}
.my-downloads table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.my-downloads th,
.my-downloads td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--gray);
}
.my-downloads td button {
margin-right: 0.5rem;
}
.app-details {
margin-top: 1rem;
}
.detail-section {
.app-details>button {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: transparent;
color: var(--orange);
border: 2px solid var(--orange);
padding: 0.5rem 1rem;
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease;
}
.app-details>button:hover {
background-color: var(--orange);
color: var(--white);
}
.app-details pre {
background-color: light-dark(var(--tan), var(--tasteful-dark));
border-radius: var(--border-radius);
color: light-dark(var(--off-black), var(--off-white));
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
overflow-x: auto;
margin-top: 1rem;
}
.cap-approval-popup {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.cap-approval-content {
background-color: light-dark(var(--white), var(--tasteful-dark));
color: light-dark(var(--off-black), var(--off-white));
padding: 2rem;
border-radius: 8px;
max-width: 80%;
max-height: 80%;
overflow-y: auto;
}
.json-display {
background-color: light-dark(var(--tan), var(--off-black));
color: light-dark(var(--off-black), var(--off-white));
padding: 1rem;
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
background-color: light-dark(var(--off-white), var(--off-black));
padding: 0.5rem;
border-radius: var(--border-radius);
word-break: break-word;
}
.approval-buttons {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1rem;
}
/* My Downloads Page */

View File

@ -1,10 +1,11 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useParams } from "react-router-dom";
import { FaDownload, FaCheck, FaSpinner, FaRocket, FaChevronDown, FaChevronUp, FaTrash } from "react-icons/fa";
import { useParams, useNavigate } from "react-router-dom";
import { FaDownload, FaSpinner, FaChevronDown, FaChevronUp, FaRocket, FaTrash, FaPlay } from "react-icons/fa";
import useAppsStore from "../store";
import { MirrorSelector } from '../components';
export default function DownloadPage() {
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const {
listings,
@ -15,14 +16,15 @@ export default function DownloadPage() {
downloadApp,
installApp,
removeDownload,
clearAllActiveDownloads,
checkMirror,
} = useAppsStore();
const [showMetadata, setShowMetadata] = useState(false);
const [selectedMirror, setSelectedMirror] = useState<string>("");
const [selectedVersion, setSelectedVersion] = useState<string>("");
const [showMyDownloads, setShowMyDownloads] = useState(false);
const [isMirrorOnline, setIsMirrorOnline] = useState<boolean | null>(null);
const [showCapApproval, setShowCapApproval] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<{ version: string, hash: string } | null>(null);
const [manifest, setManifest] = useState<any>(null);
const app = useMemo(() => listings[id || ""], [listings, id]);
@ -31,21 +33,80 @@ export default function DownloadPage() {
useEffect(() => {
if (id) {
clearAllActiveDownloads();
fetchData(id);
}
}, [id, fetchData, clearAllActiveDownloads]);
}, [id, fetchData]);
const handleMirrorSelect = useCallback((mirror: string, status: boolean | null | 'http') => {
setSelectedMirror(mirror);
setIsMirrorOnline(status === 'http' ? true : status);
}, []);
const sortedVersions = useMemo(() => {
if (!app || !app.metadata?.properties?.code_hashes) return [];
return app.metadata.properties.code_hashes
.map(([version, hash]) => ({ version, hash }))
.sort((a, b) => {
const vA = a.version.split('.').map(Number);
const vB = b.version.split('.').map(Number);
for (let i = 0; i < Math.max(vA.length, vB.length); i++) {
if (vA[i] > vB[i]) return -1;
if (vA[i] < vB[i]) return 1;
}
return 0;
});
}, [app]);
useEffect(() => {
if (app && !selectedMirror) {
setSelectedMirror(app.package_id.publisher_node || "");
if (sortedVersions.length > 0 && !selectedVersion) {
setSelectedVersion(sortedVersions[0].version);
}
}, [app, selectedMirror]);
}, [sortedVersions, selectedVersion]);
const handleDownload = useCallback((version: string, hash: string) => {
if (!id || !selectedMirror || !app) return;
downloadApp(id, hash, selectedMirror);
}, [id, selectedMirror, app, downloadApp]);
const isDownloaded = useMemo(() => {
if (!app || !selectedVersion) return false;
const versionData = sortedVersions.find(v => v.version === selectedVersion);
if (!versionData) return false;
return appDownloads.some(d => d.File && d.File.name === `${versionData.hash}.zip`);
}, [app, selectedVersion, sortedVersions, appDownloads]);
const isDownloading = useMemo(() => {
if (!app || !selectedVersion) return false;
const versionData = sortedVersions.find(v => v.version === selectedVersion);
if (!versionData) return false;
const downloadKey = `${app.package_id.package_name}:${selectedMirror}:${versionData.hash}`;
return !!activeDownloads[downloadKey];
}, [app, selectedVersion, sortedVersions, selectedMirror, activeDownloads]);
const downloadProgress = useMemo(() => {
if (!isDownloading || !app || !selectedVersion) return null;
const versionData = sortedVersions.find(v => v.version === selectedVersion);
if (!versionData) return null;
const downloadKey = `${app.package_id.package_name}:${selectedMirror}:${versionData.hash}`;
const progress = activeDownloads[downloadKey];
return progress ? Math.round((progress.downloaded / progress.total) * 100) : 0;
}, [isDownloading, app, selectedVersion, sortedVersions, selectedMirror, activeDownloads]);
const isCurrentVersionInstalled = useMemo(() => {
if (!app || !selectedVersion || !installedApp) return false;
const versionData = sortedVersions.find(v => v.version === selectedVersion);
return versionData ? installedApp.our_version_hash === versionData.hash : false;
}, [app, selectedVersion, installedApp, sortedVersions]);
const handleDownload = useCallback(() => {
if (!id || !selectedMirror || !app || !selectedVersion) return;
const versionData = sortedVersions.find(v => v.version === selectedVersion);
if (versionData) {
downloadApp(id, versionData.hash, selectedMirror);
}
}, [id, selectedMirror, app, selectedVersion, sortedVersions, downloadApp]);
const handleRemoveDownload = useCallback((hash: string) => {
if (!id) return;
removeDownload(id, hash).then(() => fetchData(id));
}, [id, removeDownload, fetchData]);
const handleInstall = useCallback((version: string, hash: string) => {
if (!id || !app) return;
@ -54,7 +115,6 @@ export default function DownloadPage() {
try {
const manifestData = JSON.parse(download.File.manifest);
setManifest(manifestData);
setSelectedVersion({ version, hash });
setShowCapApproval(true);
} catch (error) {
console.error('Failed to parse manifest:', error);
@ -64,37 +124,27 @@ export default function DownloadPage() {
}
}, [id, app, appDownloads]);
const canDownload = useMemo(() => {
return selectedMirror && (isMirrorOnline === true || selectedMirror.startsWith('http')) && !isDownloading && !isDownloaded;
}, [selectedMirror, isMirrorOnline, isDownloading, isDownloaded]);
const confirmInstall = useCallback(() => {
if (!id || !selectedVersion) return;
installApp(id, selectedVersion.hash).then(() => {
fetchData(id);
setShowCapApproval(false);
setManifest(null);
});
}, [id, selectedVersion, installApp, fetchData]);
const versionData = sortedVersions.find(v => v.version === selectedVersion);
if (versionData) {
installApp(id, versionData.hash).then(() => {
fetchData(id);
setShowCapApproval(false);
setManifest(null);
});
}
}, [id, selectedVersion, sortedVersions, installApp, fetchData]);
const handleRemoveDownload = useCallback((version: string, hash: string) => {
if (!id) return;
removeDownload(id, hash).then(() => fetchData(id));
}, [id, removeDownload, fetchData]);
const versionList = useMemo(() => {
if (!app || !app.metadata?.properties?.code_hashes) return [];
return app.metadata.properties.code_hashes.map(([version, hash]) => {
const download = appDownloads.find(d => d.File && d.File.name === `${hash}.zip`);
const downloadKey = `${app.package_id.package_name}:${app.package_id.publisher_node}:${hash}`;
const activeDownload = activeDownloads[downloadKey];
const isDownloaded = !!download?.File && download.File.size > 0;
const isInstalled = installedApp?.our_version_hash === hash;
const isDownloading = !!activeDownload && activeDownload.downloaded < activeDownload.total;
const progress = isDownloading ? activeDownload : { downloaded: 0, total: 100 };
console.log(`Version ${version} - isInstalled: ${isInstalled}, installedApp:`, installedApp);
return { version, hash, isDownloaded, isInstalled, isDownloading, progress };
});
}, [app, appDownloads, activeDownloads, installedApp]);
const handleLaunch = useCallback(() => {
if (app) {
navigate(`/${app.package_id.package_name}:${app.package_id.package_name}:${app.package_id.publisher_node}/`);
}
}, [app, navigate]);
if (!app) {
return <div className="downloads-page"><h4>Loading app details...</h4></div>;
@ -102,82 +152,107 @@ export default function DownloadPage() {
return (
<div className="downloads-page">
<h2>{app.metadata?.name || app.package_id.package_name}</h2>
<div className="app-header">
<h2>{app.metadata?.name || app.package_id.package_name}</h2>
{installedApp && (
<button onClick={handleLaunch} className="launch-button">
<FaPlay /> Launch
</button>
)}
</div>
<p>{app.metadata?.description}</p>
<MirrorSelector packageId={id} onMirrorSelect={setSelectedMirror} />
<div className="version-selector">
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
>
{sortedVersions.map(({ version }, index) => (
<option key={version} value={version}>
{version} {index === 0 ? "(newest)" : ""}
</option>
))}
</select>
</div>
<div className="version-list">
<h3>Available Versions</h3>
{versionList.length === 0 ? (
<p>No versions available for this app.</p>
<div className="download-section">
<MirrorSelector
packageId={id}
onMirrorSelect={handleMirrorSelect}
/>
{isCurrentVersionInstalled ? (
<button className="installed-button" disabled>
<FaRocket /> Installed
</button>
) : isDownloaded ? (
<button
onClick={() => {
const versionData = sortedVersions.find(v => v.version === selectedVersion);
if (versionData) {
handleInstall(versionData.version, versionData.hash);
}
}}
className="install-button"
>
<FaRocket /> Install
</button>
) : (
<button
onClick={handleDownload}
disabled={!canDownload}
className="download-button"
>
{isDownloading ? (
<>
<FaSpinner className="fa-spin" />
Downloading... {downloadProgress}%
</>
) : (
<>
<FaDownload /> Download
</>
)}
</button>
)}
</div>
<div className="my-downloads">
<button onClick={() => setShowMyDownloads(!showMyDownloads)}>
{showMyDownloads ? <FaChevronUp /> : <FaChevronDown />} My Downloads
</button>
{showMyDownloads && (
<table>
<thead>
<tr>
<th>Version</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{versionList.map(({ version, hash, isDownloaded, isInstalled, isDownloading, progress }) => (
<tr key={version}>
<td>{version}</td>
<td>
{isInstalled ? 'Installed' : isDownloaded ? 'Downloaded' : isDownloading ? 'Downloading' : 'Not downloaded'}
</td>
<td>
{!isDownloaded && !isDownloading && (
<button
onClick={() => handleDownload(version, hash)}
disabled={!selectedMirror}
className="download-button"
>
<FaDownload /> Download
{appDownloads.map((download) => {
const fileName = download.File?.name;
const hash = fileName ? fileName.replace('.zip', '') : '';
const versionData = sortedVersions.find(v => v.hash === hash);
if (!versionData) return null;
return (
<tr key={hash}>
<td>{versionData.version}</td>
<td>
<button onClick={() => handleInstall(versionData.version, hash)}>
<FaRocket /> Install
</button>
)}
{isDownloading && (
<div className="download-progress">
<FaSpinner className="fa-spin" />
Downloading... {Math.round((progress.downloaded / progress.total) * 100)}%
</div>
)}
{isDownloaded && !isInstalled && (
<>
<button
onClick={() => handleInstall(version, hash)}
className="install-button"
>
<FaRocket /> Install
</button>
<button
onClick={() => handleRemoveDownload(version, hash)}
className="delete-button"
>
<FaTrash /> Delete
</button>
</>
)}
{isInstalled && <FaCheck className="installed" />}
</td>
</tr>
))}
<button onClick={() => handleRemoveDownload(hash)}>
<FaTrash /> Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
<div className="app-details">
<h3>App Details</h3>
<button onClick={() => setShowMetadata(!showMetadata)}>
{showMetadata ? <FaChevronUp /> : <FaChevronDown />} Metadata
</button>
{showMetadata && (
<pre>{JSON.stringify(app.metadata, null, 2)}</pre>
)}
</div>
{showCapApproval && manifest && (
<div className="cap-approval-popup">
<div className="cap-approval-content">
@ -194,6 +269,17 @@ export default function DownloadPage() {
</div>
</div>
)}
<div className="app-details">
<h3>App Details</h3>
<button onClick={() => setShowMetadata(!showMetadata)}>
{showMetadata ? <FaChevronUp /> : <FaChevronDown />} Metadata
</button>
{showMetadata && (
<pre>{JSON.stringify(app.metadata, null, 2)}</pre>
)}
</div>
</div>
);
}