Merge pull request #193 from spacedriveapp/feature/eng-40-implement-context-menu

Add context menu
This commit is contained in:
maxichrome
2022-05-29 18:23:44 -05:00
committed by GitHub
14 changed files with 335 additions and 128 deletions

View File

@@ -21,13 +21,11 @@
"shortDescription": "Your personal virtual cloud.",
"longDescription": "Spacedrive is an open source virtual filesystem, a personal cloud powered by your everyday devices. Feature-rich benefits of the cloud, only its owned and hosted by you with security, privacy and ownership as a foundation. Spacedrive makes it possible to create a limitless directory of your digital life that will stand the test of time.",
"deb": {
"depends": [],
"useBootstrapper": false
"depends": []
},
"macOS": {
"frameworks": [],
"minimumSystemVersion": "",
"useBootstrapper": false,
"exceptionDomain": "",
"signingIdentity": null,
"entitlements": null

View File

@@ -2,7 +2,7 @@ import '@fontsource/inter/variable.css';
import { BaseTransport, ClientProvider, setTransport } from '@sd/client';
import { Button } from '@sd/ui';
import clsx from 'clsx';
import React, { useContext, useEffect, useState } from 'react';
import React, { useContext, useEffect } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
@@ -34,7 +34,6 @@ import KeysSettings from './screens/settings/KeysSetting';
import LibrarySettings from './screens/settings/LibrarySettings';
import LocationSettings from './screens/settings/LocationSettings';
import SecuritySettings from './screens/settings/SecuritySettings';
import ContactsSettings from './screens/settings/SharingSettings';
import SharingSettings from './screens/settings/SharingSettings';
import SyncSettings from './screens/settings/SyncSettings';
import TagsSettings from './screens/settings/TagsSettings';
@@ -64,10 +63,17 @@ function AppLayout() {
const appPropsContext = useContext(AppPropsContext);
const isWindowRounded = appPropsContext?.platform === 'macOS';
const hasWindowBorder = appPropsContext?.platform !== 'browser' && appPropsContext?.platform !== 'windows';
const hasWindowBorder =
appPropsContext?.platform !== 'browser' && appPropsContext?.platform !== 'windows';
return (
<div
onContextMenu={(e) => {
// TODO: allow this on some UI text at least
// disable default browser context menu
e.preventDefault();
return false;
}}
className={clsx(
'flex flex-row h-screen overflow-hidden text-gray-900 bg-white select-none dark:text-white dark:bg-gray-650',
isWindowRounded && 'rounded-xl',

View File

@@ -1,17 +1,9 @@
import { KeyIcon } from '@heroicons/react/outline';
import { CogIcon, LockClosedIcon } from '@heroicons/react/solid';
import { Button } from '@sd/ui';
import {
Cloud,
Desktop,
DeviceMobileCamera,
DotsSixVertical,
Laptop,
Phone,
PhoneX
} from 'phosphor-react';
import { Cloud, Desktop, DeviceMobileCamera, DotsSixVertical, Laptop } from 'phosphor-react';
import React, { useState } from 'react';
import LoadingIcons, { Rings } from 'react-loading-icons';
import { Rings } from 'react-loading-icons';
import FileItem from '../file/FileItem';
import ProgressBar from '../primitive/ProgressBar';
@@ -20,9 +12,8 @@ export interface DeviceProps {
name: string;
size: string;
type: 'laptop' | 'desktop' | 'phone' | 'server';
locations: { name: string }[];
locations: { name: string; folder?: boolean; format?: string; icon?: string }[];
runningJob?: { amount: number; task: string };
removeThisSoon?: boolean;
}
export function Device(props: DeviceProps) {
@@ -32,8 +23,9 @@ export function Device(props: DeviceProps) {
if (selectedFile === key) setSelectedFile(null);
else setSelectedFile(key);
}
return (
<div className="w-full bg-gray-600 border rounded-md border-gray-550 ">
<div className="w-full bg-gray-50 dark:bg-gray-600 border rounded-md border-gray-100 dark:border-gray-550">
<div className="flex flex-row items-center px-4 pt-2 pb-2">
<DotsSixVertical weight="bold" className="mr-3 opacity-30" />
{props.type === 'phone' && <DeviceMobileCamera weight="fill" size={20} className="mr-2" />}
@@ -42,17 +34,17 @@ export function Device(props: DeviceProps) {
{props.type === 'server' && <Cloud weight="fill" size={20} className="mr-2" />}
<h3 className="font-semibold text-md">{props.name || 'Unnamed Device'}</h3>
<div className="flex flex-row space-x-1.5 mt-0.5">
<span className="font-semibold flex flex-row h-[19px] -mt-0.5 ml-3 py-0.5 px-1.5 text-[10px] rounded bg-gray-500 text-gray-400">
<span className="font-semibold flex flex-row h-[19px] -mt-0.5 ml-3 py-0.5 px-1.5 text-[10px] rounded bg-gray-250 text-gray-500 dark:bg-gray-500 dark:text-gray-400">
<LockClosedIcon className="w-3 h-3 mr-1 -ml-0.5 m-[1px]" />
P2P
</span>
</div>
<span className="font-semibold py-0.5 px-1.5 text-sm ml-2 text-gray-400 ">
<span className="font-semibold py-0.5 px-1.5 text-sm ml-2 text-gray-400 ">
{props.size}
</span>
<div className="flex flex-grow" />
{props.runningJob && (
<div className="flex flex-row ml-5 bg-opacity-50 rounded-md bg-gray-550 ">
<div className="flex flex-row ml-5 bg-opacity-50 rounded-md bg-gray-300 dark:bg-gray-550">
<Rings
stroke="#2599FF"
strokeOpacity={4}
@@ -83,30 +75,14 @@ export function Device(props: DeviceProps) {
{props.locations.map((location, key) => (
<FileItem
key={key}
selected={selectedFile == location.name}
selected={selectedFile === location.name}
onClick={() => handleSelect(location.name)}
fileName={location.name}
folder
folder={location.folder}
format={location.format}
iconName={location.icon}
/>
))}
{props.removeThisSoon && (
<>
<FileItem
selected={selectedFile == 'tsx'}
onClick={() => handleSelect('tsx')}
fileName="App.tsx"
format="tsx"
iconName="reactts"
/>
<FileItem
selected={selectedFile == 'vite'}
onClick={() => handleSelect('vite')}
fileName="vite.config.js"
format="vite"
iconName="vite"
/>
</>
)}
</div>
</div>
);

View File

@@ -1,8 +1,10 @@
import clsx from 'clsx';
import React from 'react';
import { ArrowLineRight, FilePlus, FileText, Plus, Share, Trash } from 'phosphor-react';
import React, { MouseEventHandler } from 'react';
import icons from '../../assets/icons';
import { ReactComponent as Folder } from '../../assets/svg/folder.svg';
import { WithContextMenu } from '../layout/MenuOverlay';
import { DefaultProps } from '../primitive/types';
interface Props extends DefaultProps {
@@ -11,7 +13,7 @@ interface Props extends DefaultProps {
format?: string;
folder?: boolean;
selected?: boolean;
onClick?: () => void;
onClick?: MouseEventHandler<HTMLDivElement>;
}
export default function FileItem(props: Props) {
@@ -26,78 +28,128 @@ export default function FileItem(props: Props) {
// );
// };
return (
<div onClick={props.onClick} className="inline-block w-[100px] mb-3" draggable>
<div
className={clsx(
'border-2 border-transparent rounded-lg text-center w-[100px] h-[100px] mb-1',
<WithContextMenu
menu={[
[
{
'bg-gray-50 dark:bg-gray-650': props.selected
label: 'Details',
icon: FileText,
onClick() {
alert('There are no details ඞ');
}
},
{
label: 'Share',
icon: Share,
onClick() {
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}
}
)}
>
{props.folder ? (
<div className="flex items-center justify-center w-full h-full active:translate-y-[1px]">
<div className="w-[70px]">
<Folder className="" />
</div>
</div>
) : (
<div
className={clsx(
'w-[64px] mt-1.5 m-auto transition duration-200 rounded-lg h-[90px] relative active:translate-y-[1px]',
{
'': props.selected
}
)}
>
<svg
className="absolute top-0 left-0 pointer-events-none fill-gray-150 dark:fill-gray-550"
width="65"
height="85"
viewBox="0 0 65 81"
>
<path d="M0 8C0 3.58172 3.58172 0 8 0H39.6863C41.808 0 43.8429 0.842855 45.3431 2.34315L53.5 10.5L62.6569 19.6569C64.1571 21.1571 65 23.192 65 25.3137V73C65 77.4183 61.4183 81 57 81H8C3.58172 81 0 77.4183 0 73V8Z" />
</svg>
<svg
width="22"
height="22"
className="absolute top-1 -right-[1px] z-10 fill-gray-50 dark:fill-gray-500 pointer-events-none"
viewBox="0 0 41 41"
>
<path d="M41.4116 40.5577H11.234C5.02962 40.5577 0 35.5281 0 29.3238V0L41.4116 40.5577Z" />
</svg>
<div className="absolute flex flex-col items-center justify-center w-full h-full">
{/* @ts-ignore */}
{props.iconName && icons[props.iconName] ? (
(() => {
// @ts-ignore
let Icon = icons[props.iconName];
return (
<Icon className="mt-2 pointer-events-none margin-auto w-[40px] h-[40px]" />
);
})()
) : (
<></>
)}
<span className="mt-1 text-xs font-bold text-center uppercase cursor-default text-gray-450">
{props.format}
</span>
</div>
</div>
)}
</div>
<div className="flex justify-center">
<span
],
[
{
label: 'More More More',
icon: Plus,
onClick() {},
children: [
[
{
label: 'Super cool nested item',
onClick() {
alert("That's right, you clicked the cool item");
}
}
]
]
}
],
[
{
label: 'Delete',
icon: Trash,
danger: true,
onClick() {}
}
]
]}
>
<div onClick={props.onClick} className="inline-block w-[100px] mb-3" draggable>
<div
className={clsx(
'px-1.5 py-[1px] rounded-md text-sm font-medium text-gray-300 cursor-default',
'border-2 border-transparent rounded-lg text-center w-[100px] h-[100px] mb-1',
{
'bg-primary !text-white': props.selected
'bg-gray-50 dark:bg-gray-650': props.selected
}
)}
>
{props.fileName}
</span>
{props.folder ? (
<div className="flex items-center justify-center w-full h-full active:translate-y-[1px]">
<div className="w-[70px]">
<Folder className="" />
</div>
</div>
) : (
<div
className={clsx(
'w-[64px] mt-1.5 m-auto transition duration-200 rounded-lg h-[90px] relative active:translate-y-[1px]',
{
'': props.selected
}
)}
>
<svg
className="absolute top-0 left-0 pointer-events-none fill-gray-150 dark:fill-gray-550"
width="65"
height="85"
viewBox="0 0 65 81"
>
<path d="M0 8C0 3.58172 3.58172 0 8 0H39.6863C41.808 0 43.8429 0.842855 45.3431 2.34315L53.5 10.5L62.6569 19.6569C64.1571 21.1571 65 23.192 65 25.3137V73C65 77.4183 61.4183 81 57 81H8C3.58172 81 0 77.4183 0 73V8Z" />
</svg>
<svg
width="22"
height="22"
className="absolute top-1 -right-[1px] z-10 fill-gray-50 dark:fill-gray-500 pointer-events-none"
viewBox="0 0 41 41"
>
<path d="M41.4116 40.5577H11.234C5.02962 40.5577 0 35.5281 0 29.3238V0L41.4116 40.5577Z" />
</svg>
<div className="absolute flex flex-col items-center justify-center w-full h-full">
{/* @ts-ignore */}
{props.iconName && icons[props.iconName] ? (
(() => {
// @ts-ignore
let Icon = icons[props.iconName];
return (
<Icon className="mt-2 pointer-events-none margin-auto w-[40px] h-[40px]" />
);
})()
) : (
<></>
)}
<span className="mt-1 text-xs font-bold text-center uppercase cursor-default text-gray-450">
{props.format}
</span>
</div>
</div>
)}
</div>
<div className="flex justify-center">
<span
className={clsx(
'px-1.5 py-[1px] rounded-md text-sm font-medium text-gray-550 dark:text-gray-300 cursor-default',
{
'bg-primary !text-white': props.selected
}
)}
>
{props.fileName}
</span>
</div>
</div>
</div>
</WithContextMenu>
);
}

View File

@@ -1,14 +1,12 @@
import { Transition } from '@headlessui/react';
import { ShareIcon } from '@heroicons/react/solid';
import { FilePath } from '@sd/core';
import { Button } from '@sd/ui';
import { Input, TextArea } from '@sd/ui';
import { Button, TextArea } from '@sd/ui';
import moment from 'moment';
import { Heart, Link } from 'phosphor-react';
import React from 'react';
import { default as types } from '../../constants/file-types.json';
import { useExplorerState } from './FileList';
import FileThumb from './FileThumb';
interface MetaItemProps {

View File

@@ -0,0 +1,17 @@
import { ContextMenu, ContextMenuProps, Root, Trigger } from '@sd/ui';
import React, { ComponentProps } from 'react';
export const WithContextMenu: React.FC<{
menu: ContextMenuProps['items'];
children: ComponentProps<typeof Trigger>['children'];
}> = (props) => {
const { menu: sections = [], children } = props;
return (
<Root>
<Trigger>{children}</Trigger>
<ContextMenu items={sections} />
</Root>
);
};

View File

@@ -5,8 +5,6 @@ import clsx from 'clsx';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { MacWindowControls } from '../file/Sidebar';
export interface ModalProps {
full?: boolean;
children: React.ReactNode;

View File

@@ -1,8 +1,7 @@
import { MenuIcon, PlusIcon } from '@heroicons/react/solid';
import { PlusIcon } from '@heroicons/react/solid';
import { useBridgeQuery } from '@sd/client';
import { Statistics } from '@sd/core';
import { Button } from '@sd/ui';
import { Input } from '@sd/ui';
import { Button, Input } from '@sd/ui';
import byteSize from 'byte-size';
import clsx from 'clsx';
import React, { useContext, useEffect, useState } from 'react';
@@ -171,24 +170,38 @@ export const OverviewScreen: React.FC<{}> = (props) => {
</div>
</div>
<div className="flex flex-col pb-4 space-y-4">
<Device
name="James' MacBook Pro"
size="1.4TB"
runningJob={{ amount: 65, task: 'Generating preview media' }}
locations={[{ name: 'Pictures' }, { name: 'Downloads' }, { name: 'Minecraft' }]}
type="laptop"
/>
{clientState && (
<Device
name={clientState?.node_name ?? 'This Device'}
size="1.4TB"
runningJob={{ amount: 65, task: 'Generating preview media' }}
locations={[
{ name: 'Pictures', folder: true },
{ name: 'Downloads', folder: true },
{ name: 'Minecraft', folder: true }
]}
type="laptop"
/>
)}
<Device
name={`James' iPhone 12`}
size="47.7GB"
locations={[{ name: 'Camera Roll' }, { name: 'Notes' }]}
locations={[
{ name: 'Camera Roll', folder: true },
{ name: 'Notes', folder: true },
{ name: 'App.tsx', format: 'tsx', icon: 'reactts' },
{ name: 'vite.config.js', format: 'js', icon: 'vite' }
]}
type="phone"
removeThisSoon
/>
<Device
name={`Spacedrive Server`}
size="5GB"
locations={[{ name: 'Cached' }, { name: 'Photos' }, { name: 'Documents' }]}
locations={[
{ name: 'Cached', folder: true },
{ name: 'Photos', folder: true },
{ name: 'Documents', folder: true }
]}
type="server"
/>
</div>

View File

@@ -12,7 +12,8 @@ module.exports = {
implementation: require('postcss')
}
}
}
},
'storybook-tailwind-dark-mode'
],
webpackFinal: async (config) => {
config.module.rules.push({

View File

@@ -19,7 +19,9 @@
"dependencies": {
"@headlessui/react": "^1.5.0",
"@heroicons/react": "^1.0.6",
"@radix-ui/react-context-menu": "^0.1.6",
"clsx": "^1.1.1",
"phosphor-react": "^1.4.1",
"postcss": "^8.4.12",
"react": "^18.0.0",
"react-dom": "^18.0.0",
@@ -45,6 +47,7 @@
"postcss-loader": "^7.0.0",
"sass": "^1.50.0",
"sass-loader": "^13.0.0",
"storybook-tailwind-dark-mode": "^1.0.12",
"style-loader": "^3.3.1",
"typescript": "^4.6.3"
}

View File

@@ -0,0 +1,41 @@
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { FileText, Plus, Trash } from 'phosphor-react';
import React from 'react';
import { ContextMenu } from './ContextMenu';
export default {
title: 'UI/Context Menu',
component: ContextMenu,
argTypes: {},
parameters: {},
args: {}
} as ComponentMeta<typeof ContextMenu>;
const Template: ComponentStory<typeof ContextMenu> = (args) => <ContextMenu {...args} />;
export const Default = Template.bind({});
Default.args = {
items: [
[
{
label: 'New Item',
icon: Plus,
onClick: () => {}
}
],
[
{
label: 'View Info',
icon: FileText,
onClick: () => {}
},
{
label: 'Delete',
icon: Trash,
danger: true,
onClick: () => {}
}
]
]
};

View File

@@ -0,0 +1,103 @@
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { Root, Trigger } from '@radix-ui/react-context-menu';
import clsx from 'clsx';
import { CaretRight, Icon } from 'phosphor-react';
import { Question } from 'phosphor-react';
import React, { ComponentPropsWithRef } from 'react';
export interface ContextMenuItem {
label: string;
icon?: Icon;
danger?: boolean;
onClick: () => void;
children?: ContextMenuSection[];
}
export type ContextMenuSection = (ContextMenuItem | string)[];
export interface ContextMenuProps {
items?: ContextMenuSection[];
className?: string;
}
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
const { items: sections = [], className, ...rest } = props;
return (
<ContextMenuPrimitive.Content
className={clsx(
'shadow-2xl min-w-[15rem] shadow-gray-300 dark:shadow-gray-750 flex flex-col select-none cursor-default bg-gray-50 text-gray-800 border-gray-200 dark:bg-gray-650 dark:text-gray-100 dark:border-gray-550 text-left text-sm rounded gap-1.5 border py-1.5',
className
)}
{...rest}
>
{sections.map((sec, i) => (
<>
{i !== 0 && (
<ContextMenuPrimitive.Separator className="border-0 border-b border-b-gray-300 dark:border-b-gray-550 mx-2" />
)}
<ContextMenuPrimitive.Group key={i} className="flex items-stretch flex-col gap-0.5">
{sec.map((item) => {
if (typeof item === 'string')
return (
<ContextMenuPrimitive.Label className="text-xs ml-2 mt-1 uppercase text-gray-400">
{item}
</ContextMenuPrimitive.Label>
);
const { icon: ItemIcon = Question } = item;
let ItemComponent:
| typeof ContextMenuPrimitive.Item
| typeof ContextMenuPrimitive.TriggerItem = ContextMenuPrimitive.Item;
if ((item.children?.length ?? 0) > 0)
ItemComponent = ((props) => (
<ContextMenuPrimitive.Root>
<ContextMenuPrimitive.TriggerItem {...props}>
{props.children}
</ContextMenuPrimitive.TriggerItem>
<ContextMenu items={item.children} className="relative -left-1 -top-2" />
</ContextMenuPrimitive.Root>
)) as typeof ContextMenuPrimitive.TriggerItem;
return (
<ItemComponent
style={{
font: 'inherit',
textAlign: 'inherit'
}}
className={clsx(
'focus:outline-none group cursor-default flex-1 px-1.5 py-0 group-first:pt-1.5',
{
'text-red-600 dark:text-red-400': item.danger
}
)}
onClick={item.onClick}
key={item.label}
>
<div className="px-1.5 py-[0.4em] group-focus:bg-gray-150 group-hover:bg-gray-150 dark:group-focus:bg-gray-550 dark:group-hover:bg-gray-550 flex flex-row gap-2.5 items-center rounded-sm">
{<ItemIcon size={18} />}
<ContextMenuPrimitive.Label className="leading-snug flex-grow text-[14px] font-normal">
{item.label}
</ContextMenuPrimitive.Label>
{(item.children?.length ?? 0) > 0 && (
<CaretRight weight="fill" size={12} alt="" />
)}
</div>
</ItemComponent>
);
})}
</ContextMenuPrimitive.Group>
</>
))}
</ContextMenuPrimitive.Content>
);
};
export { Trigger, Root };

View File

@@ -1,3 +1,4 @@
export * from './Button';
export * from './Dropdown';
export * from './ContextMenu';
export * from './Input';

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.