added picharts to location cards and refactored

This commit is contained in:
myung03 2024-05-16 16:43:07 -07:00
parent 585ee12336
commit 91860b8b13
5 changed files with 337 additions and 229 deletions

View file

@ -0,0 +1,90 @@
import { useEffect, useMemo, useState } from 'react';
import { humanizeSize } from '@sd/client';
import { Card, CircularProgress, tw } from '@sd/ui';
import { Icon } from '~/components';
import { useIsDark, useLocale } from '~/hooks';
import StatCard from './StatCard';
type StatCardProps = {
name: string;
icon: string;
totalSpace: string | number[];
freeSpace?: string | number[];
color: string;
connectionType: 'lan' | 'p2p' | 'cloud' | null;
};
const Pill = tw.div`px-1.5 py-[1px] rounded text-tiny font-medium text-ink-dull bg-app-box border border-app-line`;
const DeviceCard = ({ icon, name, connectionType, ...stats }: StatCardProps) => {
const [mounted, setMounted] = useState(false);
const isDark = useIsDark();
const { totalSpace, freeSpace, usedSpaceSpace } = useMemo(() => {
const totalSpace = humanizeSize(stats.totalSpace);
const freeSpace = stats.freeSpace == null ? totalSpace : humanizeSize(stats.freeSpace);
return {
totalSpace,
freeSpace,
usedSpaceSpace: humanizeSize(totalSpace.bytes - freeSpace.bytes)
};
}, [stats]);
useEffect(() => {
setMounted(true);
}, []);
const progress = useMemo(() => {
if (!mounted || totalSpace.bytes === 0n) return 0;
return Math.floor((usedSpaceSpace.value / totalSpace.value) * 100);
}, [mounted, totalSpace, usedSpaceSpace]);
const { t } = useLocale();
return (
<StatCard body={
<div className="flex flex-row items-center gap-5 p-4 px-6">
<div className="flex flex-1 flex-col overflow-hidden ">
<Icon className="-ml-1" name={icon as any} size={60} />
<span className="truncate font-medium">{name}</span>
<span className="mt-1 truncate text-tiny text-ink-faint">
{freeSpace.value}
{freeSpace.unit} {' ' + t('free_of') + ' ' + totalSpace.value + ' ' + totalSpace.unit}
</span>
</div>
<div className=' flex items-center justify-center'>
<CircularProgress
radius={40}
progress={progress}
strokeWidth={6}
trackStrokeWidth={6}
strokeColor={progress > 90 ? '#E14444' : '#2599FF'}
fillColor="transparent"
trackStrokeColor={isDark ? '#252631' : '#efefef'}
strokeLinecap="square"
className="flex items-center justify-center"
transition="stroke-dashoffset 1s ease 0s, stroke 1s ease"
>
<div className="absolute text-lg font-semibold">
{usedSpaceSpace.value}
<span className="">
{usedSpaceSpace.unit}
</span>
</div>
</CircularProgress>
</div>
</div>
} footer= {
<Pill className="uppercase">{connectionType || t('local')}</Pill>
}>
</StatCard>
);
};
export default DeviceCard;

View file

@ -1,47 +1,79 @@
import { ReactComponent as Ellipsis } from '@sd/assets/svgs/ellipsis.svg';
import { useMemo } from 'react';
import { humanizeSize } from '@sd/client';
import { Button, Card, tw } from '@sd/ui';
import { tw } from '@sd/ui';
import { Icon } from '~/components';
import { Link } from 'react-router-dom';
import PieChart from './PieChart';
import StatCard from './StatCard';
import LocationMenu from './LocationMenu';
import { useLocale } from '~/hooks';
type LocationCardProps = {
name: string;
icon: string;
totalSpace: string | number[];
color: string;
connectionType: 'lan' | 'p2p' | 'cloud' | null;
name: string;
icon: string;
locationId: number;
totalSpace: string | number[];
freeSpace?: string | number[];
color: string;
connectionType: 'lan' | 'p2p' | 'cloud' | null;
link?: string;
};
const data = [
{ label: 'Category 1', value: 72 },
{ label: 'Category 2', value: 50 },
{ label: 'Category 3', value: 89 },
{ label: 'Category 4', value: 50 },
{ label: 'Category 5', value: 15 }
];
const colors = ['#0079E7', '#3F57B0', '#6D35D9', '#9621FF', '#BC13FF'];
const Pill = tw.div`px-1.5 py-[1px] rounded text-tiny font-medium text-ink-dull bg-app-box border border-app-line`;
const LocationCard = ({ icon, name, connectionType, ...stats }: LocationCardProps) => {
const { totalSpace } = useMemo(() => {
return {
totalSpace: humanizeSize(stats.totalSpace)
};
}, [stats]);
const HoverPill = tw(Pill)`
transition duration-300 ease-in-out
hover:bg-[#353347] hover:cursor-pointer
`;
return (
<Card className="flex w-[280px] shrink-0 flex-col bg-app-box/50 !p-0 ">
<div className="flex flex-row items-center gap-5 p-4 px-6 ">
<div className="flex flex-col overflow-hidden">
<Icon className="-ml-1" name={icon as any} size={60} />
<span className="truncate font-medium">{name}</span>
<span className="mt-1 truncate text-tiny text-ink-faint">
{totalSpace.value}
{totalSpace.unit}
</span>
</div>
</div>
<div className="flex h-10 flex-row items-center gap-1.5 border-t border-app-line px-2">
<Pill className="uppercase">{connectionType || 'Local'}</Pill>
<div className="grow" />
<Button size="icon" variant="outline">
<Ellipsis className="size-3 opacity-50" />
</Button>
</div>
</Card>
);
const LocationCard = ({ icon, name, connectionType, link, locationId, ...stats }: LocationCardProps) => {
const totalSpace = humanizeSize(stats.totalSpace);
const { t } = useLocale();
return (
<StatCard
body={
<div className="flex flex-row items-center gap-5 p-4 px-6">
<div className="flex flex-1 flex-col overflow-hidden ">
<Icon className="-ml-1" name={icon as any} size={60} />
<span className="truncate font-medium">{name}</span>
<span className="mt-1 truncate text-tiny text-ink-faint">
Users/matthewyung/applications
</span>
</div>
<div className=' flex items-center justify-center'>
<PieChart
data={data}
radius={40}
innerRadius={32}
strokeWidth={0}
colors={colors}
units={totalSpace.unit}
/>
</div>
</div>
}
footer={
<>
<Pill className="uppercase">{connectionType || t('local')}</Pill>
<div className="ml-auto flex items-center gap-1.5">
<Link to={link}>
<HoverPill className="opacity-0 group-hover/open:opacity-100">OPEN</HoverPill>
</Link>
<LocationMenu id={locationId}></LocationMenu>
</div>
</>
}>
</StatCard>
);
};
export default LocationCard;

View file

@ -0,0 +1,144 @@
import React, { useState, useEffect, useRef, FunctionComponent } from 'react';
import clsx from 'clsx';
export type PieChartProps = {
data: { label: string; value: number }[];
radius: number;
strokeWidth?: number;
innerRadius?: number;
colors?: string[];
className?: string;
units?: string;
};
const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
const PieChart: FunctionComponent<PieChartProps> = ({
data,
radius,
strokeWidth = 2,
innerRadius = 0,
colors = ['indianred', 'lightblue', 'lightgreen', 'lightcoral', 'lightgoldenrodyellow'],
className = '',
units = '',
}) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const pieChartRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseOut = (event: MouseEvent) => {
if (pieChartRef.current && !pieChartRef.current.contains(event.target as Node)) {
setHoveredIndex(null);
}
};
document.addEventListener('mouseout', handleMouseOut);
return () => {
document.removeEventListener('mouseout', handleMouseOut);
};
}, []);
const total = data.reduce((sum, item) => sum + item.value, 0);
const margin = 10;
const svgSize = 2 * (radius + strokeWidth) + margin * 2;
const center = radius + strokeWidth + margin;
const renderSlices = () => {
let cumulativeValue = 0;
return data.map((item, index) => {
const sliceAngle = (item.value / total) * 360;
const startAngle = (cumulativeValue / total) * 360;
cumulativeValue += item.value;
const isHovered = index === hoveredIndex;
const middleAngle = startAngle + sliceAngle / 2;
const translationDistance = sliceAngle > 50 ? 5 : 10;
const translation = polarToCartesian(0, 0, translationDistance, middleAngle);
const outerStart = polarToCartesian(center, center, radius, startAngle);
const outerEnd = polarToCartesian(center, center, radius, startAngle + sliceAngle);
const innerStart = polarToCartesian(center, center, innerRadius!, startAngle + sliceAngle);
const innerEnd = polarToCartesian(center, center, innerRadius!, startAngle);
const largeArcFlag = sliceAngle > 180 ? 1 : 0;
const outerPathData = [
`M ${outerStart.x} ${outerStart.y}`,
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${outerEnd.x} ${outerEnd.y}`,
`L ${center} ${center}`,
`Z`,
].join(' ');
const innerPathData = [
`M ${innerStart.x} ${innerStart.y}`,
`A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerEnd.x} ${innerEnd.y}`,
`L ${center} ${center}`,
`Z`,
].join(' ');
const fullPathData = [
`M ${outerStart.x} ${outerStart.y}`,
`A ${radius} ${radius} 0 ${largeArcFlag} 1 ${outerEnd.x} ${outerEnd.y}`,
`L ${innerEnd.x} ${innerEnd.y}`,
`A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} 0 ${innerStart.x} ${innerStart.y}`,
`Z`,
].join(' ');
return (
<g
key={index}
onMouseOver={() => setHoveredIndex(index)}
onMouseOut={() => setHoveredIndex(null)}
className={clsx('transition-transform duration-300 ease-out', {
'opacity-100': isHovered,
'opacity-50': hoveredIndex !== null && !isHovered,
})}
style={{
transform: isHovered ? `translate(${translation.x}px, ${translation.y}px) scale(1.05)` : 'scale(1)',
opacity: hoveredIndex !== null && !isHovered ? 0.5 : 1,
transition: 'transform 0.3s ease-out, opacity 0.3s ease-out',
zIndex: isHovered ? 10 : 1,
}}
>
{/* Full path for the whole slice, used for hover effect */}
<path d={fullPathData} fill="transparent" />
{/* Path for the outer portion of the slice, with color */}
<path d={outerPathData} fill={colors[index % colors.length]} />
</g>
);
});
};
return (
<div ref={pieChartRef} className="relative inline-block">
<div
className={clsx('relative', className)}
style={{ width: `${svgSize}px`, height: `${svgSize}px` }}
>
<svg width={svgSize} height={svgSize} viewBox={`0 0 ${svgSize} ${svgSize}`}>
{renderSlices()}
{hoveredIndex !== null && (
<text
x={center}
y={center}
textAnchor="middle"
dominantBaseline="central"
className="pointer-events-none text-lg font-bold transition-opacity duration-300 ease-out"
style={{ zIndex: 10 }}
>
{data[hoveredIndex]?.value}
<tspan className="ml-0.5 text-tiny text-ink-faint">{units}</tspan>
</text>
)}
</svg>
</div>
</div>
);
};
export default PieChart;

View file

@ -1,95 +1,36 @@
import { useEffect, useMemo, useState } from 'react';
import { humanizeSize } from '@sd/client';
import { Card, CircularProgress, tw } from '@sd/ui';
import { Icon } from '~/components';
import { useIsDark, useLocale } from '~/hooks';
import { Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { Card, tw } from '@sd/ui';
type StatCardProps = {
name: string;
icon: string;
totalSpace: string | number[];
freeSpace?: string | number[];
color: string;
connectionType: 'lan' | 'p2p' | 'cloud' | null;
link?: string;
devices?: boolean;
right?: React.ReactNode;
body: React.ReactNode;
footer: React.ReactNode;
};
const data = [
{ label: 'Category 1', value: 72 },
{ label: 'Category 2', value: 50 },
{ label: 'Category 3', value: 89 },
{ label: 'Category 4', value: 50 },
{ label: 'Category 5', value: 15 }
];
const colors = ['#0079E7', '#3F57B0', '#6D35D9', '#9621FF', '#BC13FF'];
const Pill = tw.div`px-1.5 py-[1px] rounded text-tiny font-medium text-ink-dull bg-app-box border border-app-line`;
const StatCard = ({ icon, name, right, connectionType, link, devices, ...stats }: StatCardProps) => {
const StatCard = ({ body, footer }: StatCardProps) => {
const [mounted, setMounted] = useState(false);
const isDark = useIsDark();
const { totalSpace, freeSpace, usedSpaceSpace } = useMemo(() => {
const totalSpace = humanizeSize(stats.totalSpace);
const freeSpace = stats.freeSpace == null ? totalSpace : humanizeSize(stats.freeSpace);
return {
totalSpace,
freeSpace,
usedSpaceSpace: humanizeSize(totalSpace.bytes - freeSpace.bytes)
};
}, [stats]);
useEffect(() => {
setMounted(true);
}, []);
const progress = useMemo(() => {
if (!mounted || totalSpace.bytes === 0n) return 0;
return Math.floor((usedSpaceSpace.value / totalSpace.value) * 100);
}, [mounted, totalSpace, usedSpaceSpace]);
const { t } = useLocale();
return (
<Card className="flex w-[280px] shrink-0 flex-col bg-app-box/50 !p-0">
<div className="flex flex-row items-center gap-5 p-4 px-6">
{stats.freeSpace && (
<CircularProgress
radius={40}
progress={progress}
strokeWidth={6}
trackStrokeWidth={6}
strokeColor={progress > 90 ? '#E14444' : '#2599FF'}
fillColor="transparent"
trackStrokeColor={isDark ? '#252631' : '#efefef'}
strokeLinecap="square"
className="flex items-center justify-center"
transition="stroke-dashoffset 1s ease 0s, stroke 1s ease"
>
<div className="absolute text-lg font-semibold">
{usedSpaceSpace.value}
<span className="ml-0.5 text-tiny opacity-60">
{usedSpaceSpace.unit}
</span>
</div>
</CircularProgress>
)}
<div className="flex flex-col overflow-hidden">
<Icon className="-ml-1" name={icon as any} size={60} />
<span className="truncate font-medium">{name}</span>
<span className="mt-1 truncate text-tiny text-ink-faint">
{freeSpace.value}
{freeSpace.unit} {devices && ' ' + t('free_of') + ' ' + totalSpace.value + ' ' + totalSpace.unit}
</span>
</div>
</div>
<Card className="group/open flex w-[280px] shrink-0 flex-col bg-app-box/50 !p-0">
{body}
<div className="flex h-10 flex-row items-center gap-1.5 border-t border-app-line px-2">
<Pill className="uppercase">{connectionType || t('local')}</Pill>
<div className="ml-auto flex items-center gap-1.5">
{right && link && <Link
to={link}
className="mx-1 rounded border px-2 text-xs font-medium transition duration-300 ease-in-out"
>
Open
</Link>}
{right}
</div>
{footer}
</div>
</Card>
);

View file

@ -12,9 +12,11 @@ import OverviewSection from './Layout/Section';
import LibraryStatistics from './LibraryStats';
import NewCard from './NewCard';
import StatisticItem from './StatCard';
import LocationMenu from './LocationMenu';
import LocationCard from './LocationCard';
import DeviceCard from './DeviceCard';
export const Component = () => {
useRouteTitle('Overview');
const os = useOperatingSystem();
@ -43,33 +45,6 @@ export const Component = () => {
center={<SearchBar redirectToSearch />}
right={
os === 'windows' && <TopBarOptions />
// <TopBarOptions
// options={[
// [
// {
// toolTipLabel: 'Spacedrop',
// onClick: () => {},
// icon: <Broadcast className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// },
// {
// toolTipLabel: 'Key Manager',
// onClick: () => {},
// icon: <Key className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// },
// {
// toolTipLabel: 'Overview Display Settings',
// onClick: () => {},
// icon: <SlidersHorizontal className={TOP_BAR_ICON_STYLE} />,
// individual: true,
// showAtResolution: 'sm:flex'
// }
// ]
// ]}
// />
}
/>
<div className="mt-4 flex flex-col gap-3 pt-3">
@ -78,10 +53,10 @@ export const Component = () => {
</OverviewSection>
<OverviewSection>
<FileKindStatistics />
</OverviewSection>
</OverviewSection>
<OverviewSection count={1} title={t('devices')}>
{node && (
<StatisticItem
<DeviceCard
name={node.name}
icon={hardwareModelToIcon(node.device_model as any)}
totalSpace={
@ -90,49 +65,8 @@ export const Component = () => {
connectionType={null}
freeSpace={stats.data?.statistics?.total_local_bytes_free || '0'}
color="#0362FF"
devices={true}
/>
)}
{/* <StatisticItem
name="Jamie's MacBook"
icon="Laptop"
total_space="4098046511104"
free_space="969004651119"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Jamie's iPhone"
icon="Mobile"
total_space="500046511104"
free_space="39006511104"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Titan NAS"
icon="Server"
total_space="60000046511104"
free_space="43000046511104"
color="#0362FF"
connection_type="p2p"
/>
<StatisticItem
name="Jamie's iPad"
icon="Tablet"
total_space="1074077906944"
free_space="121006553275"
color="#0362FF"
connection_type="lan"
/>
<StatisticItem
name="Jamie's Air"
icon="Laptop"
total_space="4098046511104"
free_space="969004651119"
color="#0362FF"
connection_type="p2p"
/> */}
<NewCard
icons={['Laptop', 'Server', 'SilverBox', 'Tablet']}
text={t('connect_device_description')}
@ -144,18 +78,17 @@ export const Component = () => {
<OverviewSection count={locations.length} title={t('locations')}>
{locations?.map((item) => (
<StatisticItem
key={item.id}
name={item.name || t('unnamed_location')}
icon="Folder"
totalSpace={item.size_in_bytes || [0]}
color="#0362FF"
connectionType={null}
link={`../location/${item.id}`}
right={
<LocationMenu id={item.id}></LocationMenu>
}
/>
<LocationCard
key={item.id}
locationId={item.id}
name={item.name || t('unnamed_location')}
icon="Folder"
totalSpace={item.size_in_bytes || [0]}
color="#0362FF"
connectionType={null}
link={`../location/${item.id}`}>
</LocationCard>
))}
{!locations?.length && (
<NewCard
@ -167,22 +100,6 @@ export const Component = () => {
</OverviewSection>
<OverviewSection count={0} title={t('cloud_drives')}>
{/* <StatisticItem
name="James Pine"
icon="DriveDropbox"
total_space="104877906944"
free_space="074877906944"
color="#0362FF"
connection_type="cloud"
/>
<StatisticItem
name="Spacedrive S3"
icon="DriveAmazonS3"
total_space="1074877906944"
free_space="704877906944"
color="#0362FF"
connection_type="cloud"
/> */}
<NewCard
icons={[
@ -196,22 +113,6 @@ export const Component = () => {
// buttonText={t('connect_cloud)}
/>
</OverviewSection>
{/* <OverviewSection title="Locations">
<div className="flex flex-row gap-2">
{locations.map((location) => (
<div
key={location.id}
className="flex w-[100px] flex-col items-center gap-2"
>
<Icon size={80} name="Folder" />
<span className="text-xs font-medium truncate">
{location.name}
</span>
</div>
))}
</div>
</OverviewSection> */}
</div>
</div>
</SearchContextProvider>