Skip to content

Commit b77c117

Browse files
authored
Merge pull request #10 from oasisprotocol/mz/dashboardCards
Dashboard cards
2 parents b1a44a7 + ae56a43 commit b77c117

File tree

15 files changed

+422
-93
lines changed

15 files changed

+422
-93
lines changed

src/components/AppCard/index.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { type FC } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { type RoflApp } from '../../nexus/api';
4+
import { Badge } from '@oasisprotocol/ui-library/src/components/ui/badge';
5+
import { Button } from '@oasisprotocol/ui-library/src/components/ui/button';
6+
import {
7+
Card,
8+
CardContent,
9+
CardFooter,
10+
CardHeader,
11+
} from '@oasisprotocol/ui-library/src/components/ui/card';
12+
import { ArrowUpRight } from 'lucide-react';
13+
import { AppStatusIcon } from '../AppStatusIcon';
14+
import { cn } from '@oasisprotocol/ui-library/src/lib/utils';
15+
import { trimLongString } from '../../utils/trimLongString';
16+
17+
type AppCardProps = {
18+
app: RoflApp;
19+
network: string;
20+
type?: 'explore' | 'dashboard';
21+
};
22+
23+
export const AppCard: FC<AppCardProps> = ({ app, network, type }) => {
24+
return (
25+
<Card className="rounded-md">
26+
<CardHeader className="">
27+
<div className="flex items-start justify-between">
28+
<h3 className="text-lg font-semibold text-foreground pr-2 break-all">
29+
<>
30+
{app.metadata?.['net.oasis.rofl.name'] || trimLongString(app.id)}
31+
</>
32+
</h3>
33+
<AppStatusIcon
34+
hasActiveInstances={!!app.num_active_instances}
35+
removed={app.removed}
36+
/>
37+
</div>
38+
</CardHeader>
39+
<CardContent className="flex-1">
40+
{type === 'explore' && (
41+
<p className="text-muted-foreground text-xs leading-relaxed">
42+
<>{app.metadata?.['net.oasis.rofl.description']}</>
43+
</p>
44+
)}
45+
{type === 'dashboard' && (
46+
<div className="flex flex-col gap-2">
47+
{!!app.metadata?.['net.oasis.rofl.version'] && (
48+
<Badge variant="secondary">
49+
<>{app.metadata?.['net.oasis.rofl.version']}</>
50+
</Badge>
51+
)}
52+
<span className="text-xs text-muted-foreground">{app.id}</span>
53+
</div>
54+
)}
55+
</CardContent>
56+
<CardFooter className="flex justify-between">
57+
{type === 'dashboard' && (
58+
<Button variant="secondary" asChild>
59+
<Link to={`/dashboard/apps/${app.id}`}>View details</Link>
60+
</Button>
61+
)}
62+
63+
<Button
64+
variant="secondary"
65+
asChild
66+
className={cn('bg-background', type === 'explore' && 'w-full')}
67+
>
68+
<a
69+
href={`https://explorer.oasis.io/${network}/sapphire/rofl/app/${app.id}`}
70+
target="_blank"
71+
rel="noopener noreferrer"
72+
>
73+
<span className="flex items-center justify-center">
74+
<span>Explorer</span>
75+
<ArrowUpRight className="ml-2 h-4 w-4" />
76+
</span>
77+
</a>
78+
</Button>
79+
</CardFooter>
80+
</Card>
81+
);
82+
};

src/components/AppsList/index.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { useEffect, type FC, type ReactNode } from 'react';
2+
import { MainLayout } from '../Layout/MainLayout';
3+
import { Skeleton } from '@oasisprotocol/ui-library/src/components/ui/skeleton';
4+
import { AppCard } from '../AppCard';
5+
import {
6+
getGetRuntimeRoflAppsQueryKey,
7+
GetRuntimeRoflApps,
8+
} from '../../nexus/api';
9+
import { useNetwork } from '../../hooks/useNetwork';
10+
import { useInfiniteQuery } from '@tanstack/react-query';
11+
import { useInView } from 'react-intersection-observer';
12+
import { useAccount } from 'wagmi';
13+
14+
type AppsListProps = {
15+
emptyState: ReactNode;
16+
type: 'dashboard' | 'explore';
17+
};
18+
19+
export const AppsList: FC<AppsListProps> = ({ emptyState, type }) => {
20+
const pageLimit = type === 'dashboard' ? 9 : 18;
21+
const { isConnected } = useAccount();
22+
const { ref, inView } = useInView();
23+
const network = useNetwork('mainnet');
24+
25+
const {
26+
data,
27+
fetchNextPage,
28+
hasNextPage,
29+
isFetchingNextPage,
30+
isLoading,
31+
isFetched,
32+
} = useInfiniteQuery({
33+
queryKey: [...getGetRuntimeRoflAppsQueryKey(network, 'sapphire'), type],
34+
queryFn: async ({ pageParam = 0 }) => {
35+
const result = await GetRuntimeRoflApps(network, 'sapphire', {
36+
limit: pageLimit,
37+
offset: pageParam,
38+
});
39+
return result;
40+
},
41+
initialPageParam: 0,
42+
enabled: type === 'explore' || (type === 'dashboard' && isConnected),
43+
getNextPageParam: (lastPage, allPages) => {
44+
const totalFetched = allPages.length * pageLimit;
45+
return totalFetched < lastPage.data.total_count
46+
? totalFetched
47+
: undefined;
48+
},
49+
});
50+
51+
useEffect(() => {
52+
if (inView && hasNextPage && !isFetchingNextPage) {
53+
fetchNextPage();
54+
}
55+
}, [fetchNextPage, hasNextPage, isFetchingNextPage, inView]);
56+
57+
const allRoflApps = data?.pages.flatMap((page) => page.data.rofl_apps) || [];
58+
const isEmpty =
59+
(type === 'dashboard' && !isConnected) ||
60+
(isFetched && allRoflApps.length === 0);
61+
62+
return (
63+
<MainLayout>
64+
{isEmpty && <>{emptyState}</>}
65+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
66+
{isLoading &&
67+
Array.from({ length: pageLimit }).map((_, index) => (
68+
<Skeleton key={index} className="w-full h-[200px]" />
69+
))}
70+
71+
{allRoflApps.map((app) => (
72+
<AppCard key={app.id} app={app} network={network} type={type} />
73+
))}
74+
75+
{isFetchingNextPage &&
76+
Array.from({ length: 3 }).map((_, index) => (
77+
<Skeleton key={`next-page-${index}`} className="w-full h-[200px]" />
78+
))}
79+
</div>
80+
81+
<div ref={ref} className="h-10 w-full" />
82+
</MainLayout>
83+
);
84+
};

src/components/EmptyState/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function EmptyState({ title, description, children }: EmptyStateProps) {
1818
return (
1919
<Card className="h-full rounded-md border-0 flex justify-center p-8 gap-2">
2020
<CardHeader className="text-xl font-semibold text-white text-center">
21-
{isConnected ? title : 'Connect is not connected'}
21+
{isConnected ? title : 'Wallet is not connected'}
2222
</CardHeader>
2323
<CardContent className="max-w-[60%] mx-auto text-gray-400 text-sm text-balance text-center leading-relaxed">
2424
{isConnected
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { type FC } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { type RoflMarketProvider } from '../../nexus/api';
4+
import { Button } from '@oasisprotocol/ui-library/src/components/ui/button';
5+
import {
6+
Card,
7+
CardContent,
8+
CardFooter,
9+
CardHeader,
10+
} from '@oasisprotocol/ui-library/src/components/ui/card';
11+
import { MachineStatusIcon } from '../MachineStatusIcon';
12+
import { trimLongString } from '../../utils/trimLongString';
13+
14+
type ExploreAppCardProps = {
15+
machine: RoflMarketProvider;
16+
};
17+
18+
export const MachineCard: FC<ExploreAppCardProps> = ({ machine }) => {
19+
return (
20+
<Card className="rounded-md">
21+
<CardHeader className="">
22+
<div className="flex items-start justify-between">
23+
<h3 className="text-lg font-semibold text-foreground pr-2 break-all">
24+
<>
25+
{machine.metadata?.['net.oasis.provider.name'] ||
26+
trimLongString(machine.address)}
27+
</>
28+
</h3>
29+
<MachineStatusIcon removed={machine.removed} />
30+
</div>
31+
</CardHeader>
32+
<CardContent className="flex-1">
33+
<div className="flex flex-col gap-2">
34+
<span className="text-md text-primary break-all">
35+
{/* TODO */}
36+
ROFL App name
37+
</span>
38+
<span className="text-xs text-muted-foreground">
39+
{machine.scheduler}
40+
</span>
41+
</div>
42+
</CardContent>
43+
<CardFooter className="flex justify-between">
44+
<Button variant="secondary" asChild>
45+
<Link to={`/dashboard/machines/${machine.address}`}>
46+
View details
47+
</Link>
48+
</Button>
49+
</CardFooter>
50+
</Card>
51+
);
52+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { type FC } from 'react';
2+
import { CircleCheck, CircleMinus } from 'lucide-react';
3+
4+
// TODO: this will need expiring badge too
5+
type MachineStatusTypes = 'active' | 'removed';
6+
7+
function getMachineStatus(removed: boolean): MachineStatusTypes {
8+
return removed ? 'removed' : 'active';
9+
}
10+
11+
type MachineStatusIconProps = {
12+
removed: boolean;
13+
};
14+
15+
export const MachineStatusIcon: FC<MachineStatusIconProps> = ({ removed }) => {
16+
const status = getMachineStatus(removed);
17+
const getStatusIcon = (status: MachineStatusTypes) => {
18+
switch (status) {
19+
case 'active':
20+
return (
21+
<CircleCheck
22+
className="h-5 w-5"
23+
style={{ color: 'var(--success)' }}
24+
/>
25+
);
26+
case 'removed':
27+
return (
28+
<CircleMinus className="h-5 w-5" style={{ color: 'var(--error)' }} />
29+
);
30+
default:
31+
return null;
32+
}
33+
};
34+
35+
return <>{getStatusIcon(status)}</>;
36+
};

src/hooks/useNetwork.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useAccount } from 'wagmi';
22

3-
export function useNetwork(fallback): 'mainnet' | 'testnet' {
3+
export function useNetwork(fallback?: 'mainnet' | 'testnet') {
44
const { chainId } = useAccount();
55

66
if (chainId === 23294) {

src/main.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ createRoot(document.getElementById('root')!).render(
5151
<Route path="apps" element={<MyApps />} />
5252
<Route path="apps/:id" element={<AppDetails />} />
5353
<Route path="machines" element={<Machines />} />
54-
<Route path="machines:id" element={<MachinesDetails />} />
54+
<Route path="machines/:id" element={<MachinesDetails />} />
5555
<Route path="create" element={<Create />} />
5656
</Route>
5757
<Route path="/explore" element={<Explore />} />
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { FC } from 'react';
2+
import { MainLayout } from '../../../components/Layout/MainLayout';
23

34
export const AppDetails: FC = () => {
4-
return <>AppDetails</>;
5+
return <MainLayout>AppDetails</MainLayout>;
56
};
Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,80 @@
1-
import type { FC } from 'react';
1+
import { useEffect, type FC } from 'react';
22
import { MachinesEmptyState } from './emptyState';
33
import { MainLayout } from '../../../components/Layout/MainLayout';
4+
import { Skeleton } from '@oasisprotocol/ui-library/src/components/ui/skeleton';
5+
import { useInfiniteQuery } from '@tanstack/react-query';
6+
import { useInView } from 'react-intersection-observer';
7+
import { useAccount } from 'wagmi';
8+
import { useNetwork } from '../../../hooks/useNetwork';
9+
import {
10+
getGetRuntimeRoflAppsQueryKey,
11+
GetRuntimeRoflmarketProviders,
12+
} from '../../../nexus/api';
13+
import { MachineCard } from '../../../components/MachineCard';
14+
15+
const pageLimit = 9;
416

517
export const Machines: FC = () => {
18+
const { isConnected } = useAccount();
19+
const { ref, inView } = useInView();
20+
const network = useNetwork();
21+
22+
const {
23+
data,
24+
fetchNextPage,
25+
hasNextPage,
26+
isFetchingNextPage,
27+
isLoading,
28+
isFetched,
29+
} = useInfiniteQuery({
30+
queryKey: [...getGetRuntimeRoflAppsQueryKey(network, 'sapphire')],
31+
queryFn: async ({ pageParam = 0 }) => {
32+
const result = await GetRuntimeRoflmarketProviders(network, 'sapphire', {
33+
limit: pageLimit,
34+
offset: pageParam,
35+
});
36+
return result;
37+
},
38+
initialPageParam: 0,
39+
enabled: isConnected,
40+
getNextPageParam: (lastPage, allPages) => {
41+
const totalFetched = allPages.length * pageLimit;
42+
return totalFetched < lastPage.data.total_count
43+
? totalFetched
44+
: undefined;
45+
},
46+
});
47+
48+
useEffect(() => {
49+
if (inView && hasNextPage && !isFetchingNextPage) {
50+
fetchNextPage();
51+
}
52+
}, [fetchNextPage, hasNextPage, isFetchingNextPage, inView]);
53+
54+
const allRoflProviders =
55+
data?.pages.flatMap((page) => page.data.providers) || [];
56+
const isEmpty = isFetched && allRoflProviders.length === 0;
57+
658
return (
759
<MainLayout>
8-
<MachinesEmptyState />
60+
{isEmpty && <MachinesEmptyState />}
61+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
62+
{isLoading &&
63+
Array.from({ length: pageLimit }).map((_, index) => (
64+
<Skeleton key={index} className="w-full h-[200px]" />
65+
))}
66+
67+
{allRoflProviders.map((machine) => (
68+
<MachineCard key={machine.address} machine={machine} />
69+
))}
70+
71+
{isFetchingNextPage &&
72+
Array.from({ length: 3 }).map((_, index) => (
73+
<Skeleton key={`next-page-${index}`} className="w-full h-[200px]" />
74+
))}
75+
</div>
76+
77+
<div ref={ref} className="h-10 w-full" />
978
</MainLayout>
1079
);
1180
};
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { FC } from 'react';
2+
import { MainLayout } from '../../../components/Layout/MainLayout';
23

34
export const MachinesDetails: FC = () => {
4-
return <>MachinesDetails</>;
5+
return <MainLayout>MachinesDetails</MainLayout>;
56
};

0 commit comments

Comments
 (0)