app_store UI: downloads page

This commit is contained in:
bitful-pannul 2024-08-14 15:20:21 +03:00
parent 71d6ad486c
commit 29d30d7c32
11 changed files with 448 additions and 52 deletions

View File

@ -2,12 +2,14 @@ import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Header from "./components/Header";
import { APP_DETAILS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path";
import { APP_DETAILS_PATH, DOWNLOAD_PATH, MY_DOWNLOADS_PATH, PUBLISH_PATH, STORE_PATH } from "./constants/path";
import StorePage from "./pages/StorePage";
import AppPage from "./pages/AppPage";
import DownloadPage from "./pages/DownloadPage";
import PublishPage from "./pages/PublishPage";
import Testing from "./pages/Testing";
import MyDownloadsPage from "./pages/MyDownloadsPage";
const BASE_URL = import.meta.env.BASE_URL;
@ -23,8 +25,10 @@ function App() {
<Routes>
<Route path="/testing" element={<Testing />} />
<Route path={STORE_PATH} element={<StorePage />} />
<Route path={MY_DOWNLOADS_PATH} element={<MyDownloadsPage />} />
<Route path={`${APP_DETAILS_PATH}/:id`} element={<AppPage />} />
<Route path={PUBLISH_PATH} element={<PublishPage />} />
<Route path={`${DOWNLOAD_PATH}/:id`} element={<DownloadPage />} />
</Routes>
</Router>
</div >

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { STORE_PATH, PUBLISH_PATH } from '../constants/path';
import { STORE_PATH, PUBLISH_PATH, MY_DOWNLOADS_PATH } from '../constants/path';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { FaHome } from "react-icons/fa";
@ -14,6 +14,7 @@ const Header: React.FC = () => {
</button>
<Link to={STORE_PATH} className={location.pathname === STORE_PATH ? 'active' : ''}>Apps</Link>
<Link to={PUBLISH_PATH} className={location.pathname === PUBLISH_PATH ? 'active' : ''}>Publish</Link>
<Link to={MY_DOWNLOADS_PATH} className={location.pathname === MY_DOWNLOADS_PATH ? 'active' : ''}>My Downloads</Link>
</nav>
</div>
<div className="header-right">

View File

@ -1,3 +1,5 @@
export const STORE_PATH = '/';
export const PUBLISH_PATH = '/publish';
export const APP_DETAILS_PATH = '/app';
export const DOWNLOAD_PATH = '/download';
export const MY_DOWNLOADS_PATH = '/my-downloads';

View File

@ -857,4 +857,91 @@ button svg,
word-wrap: break-word;
margin: 0;
color: light-dark(var(--off-black), var(--off-white));
}
.downloads-page {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.app-header {
display: flex;
align-items: center;
margin-bottom: 20px;
}
.app-icon {
width: 64px;
height: 64px;
margin-right: 20px;
}
.app-title h2 {
margin: 0;
}
.app-id {
color: #666;
font-size: 0.9em;
}
.app-description {
margin-bottom: 20px;
}
.mirror-selection {
margin-bottom: 20px;
}
.mirror-selection select {
width: 100%;
padding: 10px;
font-size: 1em;
}
.downloads-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
.downloads-table th,
.downloads-table td {
padding: 10px;
border: 1px solid #ddd;
text-align: left;
}
.downloads-table th {
background-color: #f2f2f2;
}
.download-button,
.install-button {
padding: 5px 10px;
font-size: 0.9em;
cursor: pointer;
}
.error-message {
color: #d32f2f;
margin-bottom: 20px;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section h3 {
cursor: pointer;
user-select: none;
}
.json-display {
background-color: #f2f2f2;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 0.9em;
}

View File

@ -1,55 +1,49 @@
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { FaDownload, FaCheck, FaTimes, FaPlay } from "react-icons/fa";
import useAppsStore from "../store";
import { AppListing, PackageState } from "../types/Apps";
export default function AppPage() {
const { id } = useParams();
const navigate = useNavigate();
const { listings, installed, fetchListings, fetchInstalled } = useAppsStore();
const [app, setApp] = useState<AppListing | null>(null);
const [installedApp, setInstalledApp] = useState<PackageState | null>(null);
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
const [latestVersion, setLatestVersion] = useState<string | null>(null);
useEffect(() => {
fetchListings();
fetchInstalled();
}, [fetchListings, fetchInstalled]);
const loadData = async () => {
await Promise.all([fetchListings(), fetchInstalled()]);
const app = useMemo(() => {
const foundApp = listings.find(a => `${a.package_id.package_name}:${a.package_id.publisher_node}` === id) || null;
console.log("Found app:", foundApp);
return foundApp;
}, [listings, id]);
const foundApp = listings.find(a => `${a.package_id.package_name}:${a.package_id.publisher_node}` === id) || null;
setApp(foundApp);
const installedApp = useMemo(() => {
if (!app) return null;
const foundInstalledApp = installed.find(i =>
i.package_id.package_name === app.package_id.package_name &&
i.package_id.publisher_node === app.package_id.publisher_node
) || null;
console.log("Found installed app:", foundInstalledApp);
return foundInstalledApp;
}, [app, installed]);
if (foundApp) {
const foundInstalledApp = installed.find(i =>
i.package_id.package_name === foundApp.package_id.package_name &&
i.package_id.publisher_node === foundApp.package_id.publisher_node
) || null;
setInstalledApp(foundInstalledApp);
const { currentVersion, latestVersion } = useMemo(() => {
let current: string | null = null;
let latest: string | null = null;
if (app?.metadata?.properties?.code_hashes) {
console.log("Code hashes:", app.metadata.properties.code_hashes);
const versions = app.metadata.properties.code_hashes;
if (versions.length > 0) {
latest = versions[versions.length - 1][0];
if (installedApp) {
const installedVersion = versions.find(([_, hash]) => hash === installedApp.our_version_hash);
if (installedVersion) {
current = installedVersion[0];
if (foundApp.metadata?.properties?.code_hashes) {
const versions = foundApp.metadata.properties.code_hashes;
if (versions.length > 0) {
setLatestVersion(versions[versions.length - 1][0]);
if (foundInstalledApp) {
const installedVersion = versions.find(([_, hash]) => hash === foundInstalledApp.our_version_hash);
if (installedVersion) {
setCurrentVersion(installedVersion[0]);
}
}
}
}
}
} else {
console.log("No code hashes found in app metadata");
}
console.log("Current version:", current, "Latest version:", latest);
return { currentVersion: current, latestVersion: latest };
}, [app, installedApp]);
};
loadData();
}, [id, fetchListings, fetchInstalled, listings, installed]);
if (!app) {
return <div className="app-page"><h4>App details not found for {id}</h4></div>;

View File

@ -0,0 +1,212 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { FaDownload, FaCheck, FaSpinner, FaRocket, FaChevronDown, FaChevronUp, FaExclamationTriangle } from "react-icons/fa";
import useAppsStore from "../store";
import { AppListing, DownloadItem, MirrorCheckFile, PackageManifest } from "../types/Apps";
export default function DownloadPage() {
const { id } = useParams();
const { listings, fetchListings, fetchDownloadsForApp, downloadApp, installApp, checkMirror, fetchInstalled, installed, getCaps } = useAppsStore();
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
const [selectedMirror, setSelectedMirror] = useState<string | null>(null);
const [mirrorStatuses, setMirrorStatuses] = useState<{ [mirror: string]: MirrorCheckFile | null }>({});
const [isDownloading, setIsDownloading] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showMetadata, setShowMetadata] = useState(false);
const [showManifest, setShowManifest] = useState(false);
const [showCaps, setShowCaps] = useState(false);
const [manifest, setManifest] = useState<PackageManifest | null>(null);
const app = listings.find(a => `${a.package_id.package_name}:${a.package_id.publisher_node}` === id);
useEffect(() => {
fetchListings();
fetchInstalled();
if (id) {
fetchDownloadsForApp(id).then(setDownloads);
}
}, [id, fetchListings, fetchDownloadsForApp, fetchInstalled]);
useEffect(() => {
if (app) {
checkMirrors();
}
}, [app]);
const checkMirrors = async () => {
if (!app) return;
const mirrors = [app.package_id.publisher_node, ...(app.metadata?.properties?.mirrors || [])];
const statuses: { [mirror: string]: MirrorCheckFile | null } = {};
for (const mirror of mirrors) {
const status = await checkMirror(mirror);
statuses[mirror] = status;
}
setMirrorStatuses(statuses);
setSelectedMirror(statuses[app.package_id.publisher_node]?.is_online ? app.package_id.publisher_node : mirrors.find(m => statuses[m]?.is_online) || null);
};
const handleDownload = async (version: string) => {
if (!app || !selectedMirror) return;
setIsDownloading(true);
setError(null);
try {
await downloadApp(`${app.package_id.package_name}:${app.package_id.publisher_node}`, selectedMirror, version);
const updatedDownloads = await fetchDownloadsForApp(id!);
setDownloads(updatedDownloads);
} catch (error) {
console.error('Download failed:', error);
setError(`Download failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsDownloading(false);
}
};
const handleInstall = async (version: string) => {
if (!app) return;
setIsInstalling(true);
setError(null);
try {
await installApp(`${app.package_id.package_name}:${app.package_id.publisher_node}`, version);
fetchInstalled();
} catch (error) {
console.error('Installation failed:', error);
setError(`Installation failed: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsInstalling(false);
}
};
if (!app) {
return <div className="downloads-page"><h4>App details not found for {id}</h4></div>;
}
return (
<div className="downloads-page">
<div className="app-header">
{app.metadata?.image && (
<img src={app.metadata.image} alt={app.metadata?.name || app.package_id.package_name} className="app-icon" />
)}
<div className="app-title">
<h2>{app.metadata?.name || app.package_id.package_name}</h2>
<p className="app-id">{`${app.package_id.package_name}.${app.package_id.publisher_node}`}</p>
</div>
</div>
<div className="app-description">{app.metadata?.description || "No description available"}</div>
<div className="mirror-selection">
<h3>Select Mirror</h3>
<select
value={selectedMirror || ''}
onChange={(e) => setSelectedMirror(e.target.value)}
disabled={isDownloading}
>
<option value="" disabled>Select Mirror</option>
{Object.entries(mirrorStatuses).map(([mirror, status]) => (
<option key={mirror} value={mirror} disabled={!status?.is_online}>
{mirror} {status?.is_online ? '(Online)' : '(Offline)'}
</option>
))}
</select>
</div>
<h3>Available Versions</h3>
<table className="downloads-table">
<thead>
<tr>
<th>Version</th>
<th>Size</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{app.metadata?.properties?.code_hashes.map(([version, hash]) => {
const download = downloads.find(d => d.name === `${hash}.zip`);
const isDownloaded = !!download;
const isInstalled = installed.some(i => i.package_id.package_name === app.package_id.package_name && i.our_version_hash === version);
return (
<tr key={version}>
<td>{version}</td>
<td>{download?.size ? `${(download.size / 1024 / 1024).toFixed(2)} MB` : 'N/A'}</td>
<td>
{isInstalled ? 'Installed' : isDownloaded ? 'Downloaded' : 'Not downloaded'}
</td>
<td>
{!isDownloaded && (
<button
onClick={() => handleDownload(version)}
disabled={!selectedMirror || isDownloading}
className="download-button"
>
{isDownloading ? <FaSpinner className="fa-spin" /> : <FaDownload />} Download
</button>
)}
{isDownloaded && !isInstalled && (
<button
onClick={() => handleInstall(version)}
disabled={isInstalling}
className="install-button"
>
{isInstalling ? <FaSpinner className="fa-spin" /> : <FaRocket />} Install
</button>
)}
{isInstalled && (
<FaCheck className="installed" />
)}
</td>
</tr>
);
})}
</tbody>
</table>
{error && (
<div className="error-message">
<FaExclamationTriangle /> {error}
</div>
)}
<div className="app-details">
<div className="detail-section">
<h3 onClick={() => setShowMetadata(!showMetadata)}>
Metadata {showMetadata ? <FaChevronUp /> : <FaChevronDown />}
</h3>
{showMetadata && (
<pre className="json-display">
{JSON.stringify(app.metadata, null, 2)}
</pre>
)}
</div>
{manifest && (
<div className="detail-section">
<h3 onClick={() => setShowManifest(!showManifest)}>
Manifest {showManifest ? <FaChevronUp /> : <FaChevronDown />}
</h3>
{showManifest && (
<pre className="json-display">
{JSON.stringify(manifest, null, 2)}
</pre>
)}
</div>
)}
{manifest && manifest.request_capabilities && (
<div className="detail-section">
<h3 onClick={() => setShowCaps(!showCaps)}>
Capabilities {showCaps ? <FaChevronUp /> : <FaChevronDown />}
</h3>
{showCaps && (
<pre className="json-display">
{JSON.stringify(manifest.request_capabilities, null, 2)}
</pre>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,91 @@
import React, { useState, useEffect } from "react";
import { FaFolder, FaFile, FaChevronLeft } from "react-icons/fa";
import useAppsStore from "../store";
import { DownloadItem } from "../types/Apps";
export default function MyDownloadsPage() {
const { fetchDownloads, fetchDownloadsForApp, listings } = useAppsStore();
const [currentPath, setCurrentPath] = useState<string[]>([]);
const [items, setItems] = useState<DownloadItem[]>([]);
useEffect(() => {
loadItems();
}, [currentPath]);
const loadItems = async () => {
let downloads: DownloadItem[];
if (currentPath.length === 0) {
downloads = await fetchDownloads();
} else {
downloads = await fetchDownloadsForApp(currentPath.join(':'));
}
setItems(downloads);
};
const navigateToItem = (item: DownloadItem) => {
if (!item.is_file) {
setCurrentPath([...currentPath, item.name]);
}
};
const navigateUp = () => {
setCurrentPath(currentPath.slice(0, -1));
};
const getItemVersion = (name: string): string | undefined => {
if (currentPath.length === 0) {
return undefined; // No version for top-level directories
}
const appId = currentPath.join(':');
const app = listings.find(a => `${a.package_id.package_name}:${a.package_id.publisher_node}` === appId);
if (app && app.metadata?.properties?.code_hashes) {
const matchingHash = app.metadata.properties.code_hashes.find(([_, hash]) => `${hash}.zip` === name);
return matchingHash ? matchingHash[0] : undefined;
}
return undefined;
};
return (
<div className="downloads-page">
<h2>Downloads</h2>
<div className="file-explorer">
<div className="path-navigation">
{currentPath.length > 0 && (
<button onClick={navigateUp} className="navigate-up">
<FaChevronLeft /> Back
</button>
)}
<span className="current-path">/{currentPath.join('/')}</span>
</div>
<table className="downloads-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Version</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const version = getItemVersion(item.name);
return (
<tr key={index} onClick={() => navigateToItem(item)} className={item.is_file ? 'file' : 'directory'}>
<td>
{item.is_file ? <FaFile /> : <FaFolder />} {item.name}
</td>
<td>{item.is_file ? 'File' : 'Directory'}</td>
<td>{item.size ? `${(item.size / 1024).toFixed(2)} KB` : '-'}</td>
<td>{version || '-'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -11,10 +11,12 @@ export default function StorePage() {
fetchListings();
}, [fetchListings]);
const filteredApps = listings?.filter((app) =>
app.package_id.package_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
app.metadata?.description?.toLowerCase().includes(searchQuery.toLowerCase())
) || [];
const filteredApps = Array.isArray(listings)
? listings.filter((app) =>
app.package_id.package_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
app.metadata?.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: [];
return (
<div className="store-page">
@ -27,7 +29,7 @@ export default function StorePage() {
/>
</div>
<div className="app-list">
{listings === undefined ? (
{!Array.isArray(listings) ? (
<p>Loading...</p>
) : listings.length === 0 ? (
<p>No apps available.</p>
@ -53,6 +55,7 @@ export default function StorePage() {
);
}
// ... rest of the code remains the same
interface AppRowProps {
app: AppListing;
}

View File

@ -47,7 +47,7 @@ const Testing: React.FC = () => {
<div>
<button onClick={() => handleAction(fetchListings, 'listings')}>Refresh Listings</button>
<button onClick={() => handleAction(fetchInstalled, 'installed')}>Refresh Installed Apps</button>
<button onClick={() => handleAction(fetchDownloads, 'downloads')}>Refresh Downloads</button>
{/* <button onClick={() => handleAction(fetchDownloads, 'downloads')}>Refresh Downloads</button> */}
<button onClick={() => handleAction(fetchOurApps, 'ourApps')}>Refresh Our Apps</button>
</div>
<div>

View File

@ -1,6 +1,6 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { PackageState, AppListing, MirrorCheckFile, PackageManifest, Download, PackageId } from '../types/Apps'
import { PackageState, AppListing, MirrorCheckFile, PackageManifest, DownloadItem, PackageId } from '../types/Apps'
import { HTTP_STATUS } from '../constants/http'
import KinodeClientApi from "@kinode/client-api"
import { WEBSOCKET_URL } from '../utils/ws'
@ -10,7 +10,7 @@ const BASE_URL = '/main:app_store:sys'
interface AppsStore {
listings: AppListing[]
installed: PackageState[]
downloads: Record<string, Download[]>
downloads: Record<string, DownloadItem[]>
ourApps: AppListing[]
ws: KinodeClientApi
activeDownloads: Record<string, [number, number]>
@ -18,11 +18,11 @@ interface AppsStore {
fetchListings: () => Promise<void>
fetchListing: (id: string) => Promise<AppListing>
fetchInstalled: () => Promise<void>
fetchDownloads: () => Promise<void>
fetchOurApps: () => Promise<void>
fetchDownloadsForApp: (id: string) => Promise<Download[]>
fetchDownloads: () => Promise<DownloadItem[]>;
fetchOurApps: () => Promise<void>;
fetchDownloadsForApp: (id: string) => Promise<DownloadItem[]>;
checkMirror: (node: string) => Promise<MirrorCheckFile>
installApp: (id: string, version_hash: string) => Promise<void>
uninstallApp: (id: string) => Promise<void>
downloadApp: (id: string, version_hash: string, downloadFrom: string) => Promise<void>
@ -106,8 +106,10 @@ const useAppsStore = create<AppsStore>()(
const res = await fetch(`${BASE_URL}/downloads`)
if (res.status === HTTP_STATUS.OK) {
const downloads = await res.json()
set({ downloads })
set({ downloads: { root: downloads } })
return downloads
}
return []
},
fetchOurApps: async () => {

View File

@ -12,7 +12,7 @@ export interface AppListing {
auto_update: boolean
}
export interface Download {
export interface DownloadItem {
name: string,
is_file: boolean,
size?: number