app_store UI: add wip notification bay

This commit is contained in:
bitful-pannul 2024-11-07 20:59:39 +04:00
parent 6eb02b0670
commit 5e77a5807d
8 changed files with 476 additions and 20 deletions

View File

@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { STORE_PATH, PUBLISH_PATH, MY_APPS_PATH } from '../constants/path';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { FaHome } from "react-icons/fa";
import NotificationBay from './NotificationBay';
const Header: React.FC = () => {
return (
@ -18,10 +19,10 @@ const Header: React.FC = () => {
</nav>
</div>
<div className="header-right">
<NotificationBay />
<ConnectButton />
</div>
</header>
);
};
export default Header;

View File

@ -0,0 +1,56 @@
import React from 'react';
import { ManifestResponse, PackageManifestEntry } from '../types/Apps';
interface ManifestDisplayProps {
manifestResponse: ManifestResponse;
}
const capabilityMap: Record<string, string> = {
'vfs:distro:sys': 'Virtual Filesystem',
'http_client:distro:sys': 'HTTP Client',
'http_server:distro:sys': 'HTTP Server',
'eth:distro:sys': 'Ethereum RPC access',
'homepage:homepage:sys': 'Ability to add itself to homepage',
'main:app_store:sys': 'App Store',
'chain:app_store:sys': 'Chain',
'terminal:terminal:sys': 'Terminal',
};
// note: we can do some future regex magic mapping here too!
// if includes("root") return WARNING
const transformCapabilities = (capabilities: any[]) => {
return capabilities.map(cap => capabilityMap[cap] || cap);
};
const renderManifest = (manifest: PackageManifestEntry) => (
<div className="manifest-item">
<p><strong>Process Name:</strong> {manifest.process_name}</p>
<p><strong>Requests Network Access:</strong> {manifest.request_networking ? 'Yes' : 'No'}</p>
<p><strong>Wants to Grant Capabilities:</strong> {transformCapabilities(manifest.grant_capabilities).join(', ') || 'None'}</p>
<p><strong>Is Public:</strong> {manifest.public ? 'Yes' : 'No'}</p>
<p><strong>Requests the Capabilities:</strong> {transformCapabilities(manifest.request_capabilities).join(', ') || 'None'}</p>
{/* <p><strong>Process Wasm Path:</strong> {manifest.process_wasm_path}</p> */}
{/* <p><strong>On Exit:</strong> {manifest.on_exit}</p> */}
</div>
);
const ManifestDisplay: React.FC<ManifestDisplayProps> = ({ manifestResponse }) => {
if (!manifestResponse) {
return <p>No manifest data available.</p>;
}
const parsedManifests: PackageManifestEntry[] = JSON.parse(manifestResponse.manifest);
return (
<div className="manifest-display">
{parsedManifests.map((manifest, index) => (
<div key={index}>
{renderManifest(manifest)}
</div>
))}
</div>
);
};
export default ManifestDisplay;

View File

@ -0,0 +1,135 @@
import React, { ReactNode, useState } from 'react';
import { FaBell, FaChevronDown, FaChevronUp, FaTrash, FaTimes } from 'react-icons/fa';
import useAppsStore from '../store';
import { Notification, NotificationAction } from '../types/Apps';
import { useNavigate } from 'react-router-dom';
interface ModalProps {
children: ReactNode;
onClose: () => void;
}
const Modal: React.FC<ModalProps> = ({ children, onClose }) => {
return (
<div className="modal-overlay">
<div className="modal-content">
<button className="modal-close" onClick={onClose}>
<FaTimes />
</button>
{children}
</div>
</div>
);
};
const NotificationBay: React.FC = () => {
const { notifications, removeNotification } = useAppsStore();
const [isExpanded, setIsExpanded] = useState(false);
const [modalContent, setModalContent] = useState<React.ReactNode | null>(null);
const navigate = useNavigate();
const handleActionClick = (action: NotificationAction) => {
switch (action.action.type) {
case 'modal':
const content = typeof action.action.modalContent === 'function'
? action.action.modalContent()
: action.action.modalContent;
setModalContent(content);
break;
case 'click':
action.action.onClick?.();
break;
case 'redirect':
if (action.action.path) {
navigate(action.action.path);
}
break;
}
};
const handleDismiss = (notificationId: string, event: React.MouseEvent) => {
event.stopPropagation(); // Prevent event bubbling
removeNotification(notificationId);
};
const renderNotification = (notification: Notification) => {
return (
<div key={notification.id} className={`notification-item ${notification.type}`}>
{notification.renderContent ? (
notification.renderContent(notification)
) : (
<>
<div className="notification-content">
<p>{notification.message}</p>
{notification.type === 'download' && notification.metadata?.progress && (
<div className="progress-bar">
<div
className="progress"
style={{ width: `${notification.metadata.progress}%` }}
/>
</div>
)}
</div>
{notification.actions && (
<div className="notification-actions">
{notification.actions.map((action, index) => (
<button
key={index}
onClick={() => handleActionClick(action)}
className={`action-button ${action.variant || 'secondary'}`}
>
{action.icon && <action.icon />}
{action.label}
</button>
))}
</div>
)}
{!notification.persistent && (
<button
className="dismiss-button"
onClick={(e) => handleDismiss(notification.id, e)}
>
<FaTrash />
</button>
)}
</>
)}
</div>
);
};
return (
<>
<div className="notification-bay">
<button onClick={() => setIsExpanded(!isExpanded)} className="notification-button">
<FaBell />
{notifications.length > 0 && (
<span className="badge">{notifications.length}</span>
)}
{isExpanded ? <FaChevronUp /> : <FaChevronDown />}
</button>
{isExpanded && (
<div className="notification-details">
{notifications.length === 0 ? (
<p>All clear, no notifications!</p>
) : (
notifications.map(renderNotification)
)}
</div>
)}
</div>
{modalContent && (
<Modal onClose={() => setModalContent(null)}>
{modalContent}
</Modal>
)}
</>
);
};
export default NotificationBay;

View File

@ -1,3 +1,5 @@
export { default as Header } from './Header';
export { default as MirrorSelector } from './MirrorSelector';
export { default as PackageSelector } from './PackageSelector';
export { default as ManifestDisplay } from './ManifestDisplay';
export { default as NotificationBay } from './NotificationBay';

View File

@ -49,6 +49,13 @@ a:hover {
gap: 1rem;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
/* Provides consistent spacing between NotificationBay and ConnectButton */
}
.header-left h1 {
margin: 0;
font-size: 1.5rem;
@ -433,3 +440,166 @@ td {
.fa-spin {
animation: spin 1s linear infinite;
}
.manifest-display {
background-color: light-dark(var(--tan), var(--tasteful-dark));
color: light-dark(var(--off-black), var(--off-white));
padding: 1rem;
border-radius: var(--border-radius);
margin-bottom: 1rem;
}
.manifest-item {
margin-bottom: 1rem;
padding: 1rem;
border: 1px solid var(--gray);
border-radius: var(--border-radius);
background-color: light-dark(var(--white), var(--off-black));
}
.manifest-item p {
margin: 0.5rem 0;
}
.notification-bay {
position: relative;
margin-right: 1rem;
}
.notification-button {
background: none;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
color: light-dark(var(--off-black), var(--off-white));
}
.notification-details {
position: absolute;
top: 100%;
right: 0;
width: 320px;
max-height: 400px;
overflow-y: auto;
background-color: light-dark(var(--white), var(--tasteful-dark));
border-radius: var(--border-radius);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding: 0.5rem;
}
.badge {
background-color: var(--orange);
color: var(--white);
border-radius: 50%;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-width: 1.5rem;
text-align: center;
}
.notification-item {
display: flex;
align-items: center;
padding: 1rem;
margin: 0.5rem 0;
border-radius: var(--border-radius);
background-color: light-dark(var(--tan), var(--off-black));
color: light-dark(var(--off-black), var(--off-white));
}
.notification-item.error {
background-color: light-dark(#ffe6e6, #4a2020);
}
.notification-item.success {
background-color: light-dark(#e6ffe6, #204a20);
}
.notification-item.warning {
background-color: light-dark(#fff3e6, #4a3820);
}
.notification-item.download {
background-color: light-dark(#e6f3ff, #20304a);
}
.notification-content {
flex: 1;
margin-right: 1rem;
}
.notification-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
.dismiss-button {
background: none;
border: none;
cursor: pointer;
color: light-dark(var(--gray), var(--off-white));
padding: 0.25rem;
}
.dismiss-button:hover {
color: var(--orange);
}
.progress-bar {
margin-top: 0.5rem;
height: 4px;
background-color: light-dark(var(--white), var(--off-black));
border-radius: 2px;
overflow: hidden;
}
.progress {
height: 100%;
background-color: var(--orange);
transition: width 0.3s ease;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
}
.modal-content {
background-color: light-dark(var(--white), var(--tasteful-dark));
color: light-dark(var(--off-black), var(--off-white));
padding: 1.5rem;
border-radius: var(--border-radius);
position: relative;
max-width: 80%;
max-height: 80vh;
overflow-y: auto;
}
.modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
cursor: pointer;
color: light-dark(var(--gray), var(--off-white));
padding: 0.25rem;
}
.modal-close:hover {
color: var(--orange);
}

View File

@ -2,7 +2,8 @@ import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useParams } from "react-router-dom";
import { FaDownload, FaSpinner, FaChevronDown, FaChevronUp, FaRocket, FaTrash, FaPlay } from "react-icons/fa";
import useAppsStore from "../store";
import { MirrorSelector } from '../components';
import { MirrorSelector, ManifestDisplay } from '../components';
import { ManifestResponse } from "../types/Apps";
export default function DownloadPage() {
const { id } = useParams<{ id: string }>();
@ -17,7 +18,7 @@ export default function DownloadPage() {
removeDownload,
clearAllActiveDownloads,
fetchHomepageApps,
getLaunchUrl
getLaunchUrl,
} = useAppsStore();
const [showMetadata, setShowMetadata] = useState(false);
@ -26,7 +27,7 @@ export default function DownloadPage() {
const [showMyDownloads, setShowMyDownloads] = useState(false);
const [isMirrorOnline, setIsMirrorOnline] = useState<boolean | null>(null);
const [showCapApproval, setShowCapApproval] = useState(false);
const [manifest, setManifest] = useState<any>(null);
const [manifestResponse, setManifestResponse] = useState<ManifestResponse | null>(null);
const [isInstalling, setIsInstalling] = useState(false);
const [isCheckingLaunch, setIsCheckingLaunch] = useState(false);
const [launchPath, setLaunchPath] = useState<string | null>(null);
@ -151,8 +152,12 @@ export default function DownloadPage() {
const download = appDownloads.find(d => d.File && d.File.name === `${hash}.zip`);
if (download?.File?.manifest) {
try {
const manifestData = JSON.parse(download.File.manifest);
setManifest(manifestData);
const manifest_response: ManifestResponse = {
package_id: app.package_id,
version_hash: hash,
manifest: download.File.manifest
};
setManifestResponse(manifest_response);
setShowCapApproval(true);
} catch (error) {
console.error('Failed to parse manifest:', error);
@ -170,7 +175,7 @@ export default function DownloadPage() {
setLaunchPath(null);
installApp(id, versionData.hash).then(() => {
setShowCapApproval(false);
setManifest(null);
setManifestResponse(null);
fetchData(id);
});
}
@ -337,13 +342,11 @@ export default function DownloadPage() {
)}
</div>
{showCapApproval && manifest && (
{showCapApproval && manifestResponse && (
<div className="cap-approval-popup">
<div className="cap-approval-content">
<h3>Approve Capabilities</h3>
<pre className="json-display">
{JSON.stringify(manifest[0]?.request_capabilities || [], null, 2)}
</pre>
<ManifestDisplay manifestResponse={manifestResponse} />
<div className="approval-buttons">
<button onClick={() => setShowCapApproval(false)}>Cancel</button>
<button onClick={confirmInstall}>
@ -354,7 +357,6 @@ export default function DownloadPage() {
</div>
)}
<div className="app-details">
<h3>App Details</h3>
<button onClick={() => setShowMetadata(!showMetadata)}>

View File

@ -1,6 +1,6 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { PackageState, AppListing, MirrorCheckFile, PackageManifest, DownloadItem, HomepageApp } from '../types/Apps'
import { PackageState, AppListing, MirrorCheckFile, DownloadItem, HomepageApp, ManifestResponse, Notification } from '../types/Apps'
import { HTTP_STATUS } from '../constants/http'
import KinodeClientApi from "@kinode/client-api"
import { WEBSOCKET_URL } from '../utils/ws'
@ -13,6 +13,7 @@ interface AppsStore {
downloads: Record<string, DownloadItem[]>
ourApps: AppListing[]
ws: KinodeClientApi
notifications: Notification[]
homepageApps: HomepageApp[]
activeDownloads: Record<string, { downloaded: number, total: number }>
@ -29,11 +30,15 @@ interface AppsStore {
fetchHomepageApps: () => Promise<void>
getLaunchUrl: (id: string) => string | null
addNotification: (notification: Notification) => void;
removeNotification: (id: string) => void;
clearNotifications: () => void;
installApp: (id: string, version_hash: string) => Promise<void>
uninstallApp: (id: string) => Promise<void>
downloadApp: (id: string, version_hash: string, downloadFrom: string) => Promise<void>
removeDownload: (packageId: string, versionHash: string) => Promise<void>
getCaps: (id: string) => Promise<PackageManifest | null>
getManifest: (id: string, version_hash: string) => Promise<ManifestResponse | null>
approveCaps: (id: string) => Promise<void>
startMirroring: (id: string) => Promise<void>
stopMirroring: (id: string) => Promise<void>
@ -52,6 +57,7 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
ourApps: [],
activeDownloads: {},
homepageApps: [],
notifications: [],
fetchData: async (id: string) => {
@ -282,14 +288,14 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
}
},
getCaps: async (id: string) => {
getManifest: async (id: string, version_hash: string) => {
try {
const res = await fetch(`${BASE_URL}/apps/${id}/caps`);
const res = await fetch(`${BASE_URL}/manifest?id=${id}&version_hash=${version_hash}`);
if (res.status === HTTP_STATUS.OK) {
return await res.json() as PackageManifest;
return await res.json() as ManifestResponse;
}
} catch (error) {
console.error("Error getting caps:", error);
console.error("Error getting manifest:", error);
}
return null;
},
@ -355,6 +361,18 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
}));
},
addNotification: (notification) => set(state => ({
notifications: [...state.notifications, notification]
})),
removeNotification: (id) => set(state => ({
notifications: state.notifications.filter(n => n.id !== id)
})),
clearNotifications: () => set({ notifications: [] }),
clearActiveDownload: (appId) => {
set((state) => {
const { [appId]: _, ...rest } = state.activeDownloads;
@ -374,10 +392,40 @@ const useAppsStore = create<AppsStore>()((set, get) => ({
const { package_id, version_hash, downloaded, total } = data.data;
const appId = `${package_id.package_name}:${package_id.publisher_node}:${version_hash}`;
get().setActiveDownload(appId, downloaded, total);
const existingNotification = get().notifications.find(
n => n.id === `download-${appId}`
);
if (existingNotification) {
get().removeNotification(`download-${appId}`);
}
get().addNotification({
id: `download-${appId}`,
type: 'download',
message: `Downloading ${package_id.package_name}`,
timestamp: Date.now(),
metadata: {
packageId: `${package_id.package_name}:${package_id.publisher_node}`,
versionHash: version_hash,
progress: Math.round((downloaded / total) * 100)
}
});
} else if (data.kind === 'complete') {
const { package_id, version_hash } = data.data;
const appId = `${package_id.package_name}:${package_id.publisher_node}:${version_hash}`;
get().clearActiveDownload(appId);
get().removeNotification(`download-${appId}`);
get().addNotification({
id: `complete-${appId}`,
type: 'success',
message: `Download complete: ${package_id.package_name}`,
timestamp: Date.now(),
});
get().fetchData(`${package_id.package_name}:${package_id.publisher_node}`);
}
} catch (error) {

View File

@ -1,3 +1,6 @@
import { ReactNode } from "react";
import { IconType } from "react-icons/lib";
export interface PackageId {
package_name: string;
publisher_node: string;
@ -59,9 +62,10 @@ export interface PackageState {
our_version_hash: string;
verified: boolean;
caps_approved: boolean;
pending_update_hash?: string;
}
export interface PackageManifest {
export interface PackageManifestEntry {
process_name: string
process_wasm_path: string
on_exit: string
@ -71,6 +75,12 @@ export interface PackageManifest {
public: boolean
}
export interface ManifestResponse {
package_id: PackageId;
version_hash: string;
manifest: string;
}
export interface HomepageApp {
id: string;
process: string;
@ -83,3 +93,35 @@ export interface HomepageApp {
order: number;
favorite: boolean;
}
export type NotificationActionType = 'click' | 'modal' | 'popup' | 'redirect';
export type NotificationAction = {
label: string;
icon?: IconType;
variant?: 'primary' | 'secondary' | 'danger';
action: {
type: NotificationActionType;
onClick?: () => void;
modalContent?: ReactNode | (() => ReactNode);
popupContent?: ReactNode | (() => ReactNode);
path?: string;
};
};
export type Notification = {
id: string;
type: 'error' | 'success' | 'warning' | 'info' | 'download' | 'install' | 'update';
message: string;
timestamp: number;
metadata?: {
packageId?: string;
versionHash?: string;
progress?: number;
error?: string;
};
actions?: NotificationAction[];
renderContent?: (notification: Notification) => ReactNode;
persistent?: boolean;
};