Skip to content

Commit

Permalink
feature(webui): add context menu in breadcrumb links to copy relative…
Browse files Browse the repository at this point in the history
…/absolute paths
  • Loading branch information
byCedric committed Aug 25, 2024
1 parent 0f17034 commit d15cf9d
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 15 deletions.
Binary file modified webui/bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@expo/styleguide-native": "^7.0.1",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-context-menu": "^2.2.1",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
Expand Down
69 changes: 54 additions & 15 deletions webui/src/components/BreadcrumbLinks.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Link } from 'expo-router';
import { ComponentProps, Fragment, useMemo } from 'react';
import { ComponentProps, Fragment, PropsWithChildren, useCallback, useMemo } from 'react';

import {
Breadcrumb,
Expand All @@ -9,6 +9,13 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '~/ui/Breadcrumb';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,

Check warning on line 16 in webui/src/components/BreadcrumbLinks.tsx

View workflow job for this annotation

GitHub Actions / core

'ContextMenuSeparator' is defined but never used. Allowed unused vars must match /^_/u
ContextMenuTrigger,
} from '~/ui/Menu';
import { type PartialAtlasBundle } from '~core/data/types';

type BreadcrumbLinksProps = {
Expand All @@ -33,20 +40,24 @@ export function BreadcrumbLinks(props: BreadcrumbLinksProps) {
</Link>
</BreadcrumbLink>
{links.map((link) => (
<Fragment key={link.key}>
<Fragment key={link.filePath}>
<BreadcrumbSeparator className="text-secondary" />
<BreadcrumbItem>
{!link.href ? (
<BreadcrumbPage className="text-lg">{link.label}</BreadcrumbPage>
<BreadcrumbLinkMenu bundle={props.bundle} link={link}>
<BreadcrumbPage className="text-lg">{link.label}</BreadcrumbPage>
</BreadcrumbLinkMenu>
) : (
<BreadcrumbLink asChild>
<Link
className="text-lg text-default font-bold underline-offset-4 hover:underline"
href={link.href}
>
{link.label}
</Link>
</BreadcrumbLink>
<BreadcrumbLinkMenu bundle={props.bundle} link={link}>
<BreadcrumbLink asChild>
<Link
className="text-lg text-default font-bold underline-offset-4 hover:underline"
href={link.href}
>
{link.label}
</Link>
</BreadcrumbLink>
</BreadcrumbLinkMenu>
)}
</BreadcrumbItem>
</Fragment>
Expand All @@ -56,24 +67,52 @@ export function BreadcrumbLinks(props: BreadcrumbLinksProps) {
);
}

type BreadcrumbLinkMenuProps = PropsWithChildren<{
bundle: PartialAtlasBundle;
link: BreadcrumbLinkItem;
}>;

function BreadcrumbLinkMenu(props: BreadcrumbLinkMenuProps) {
const onCopyRelativePath = useCallback(() => {
navigator.clipboard.writeText(props.link.filePath);
}, [props.link.filePath]);

const onCopyAbsolutePath = useCallback(() => {
navigator.clipboard.writeText(`${props.bundle.sharedRoot}/${props.link.filePath}`);
}, [props.link.filePath, props.bundle.sharedRoot]);

return (
<ContextMenu>
<ContextMenuTrigger>{props.children}</ContextMenuTrigger>
<ContextMenuContent className="w-64">
<ContextMenuItem onClick={onCopyRelativePath}>Copy relative path</ContextMenuItem>
<ContextMenuItem onClick={onCopyAbsolutePath}>Copy absolute path</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

type BreadcrumbLinkItem = {
key: string;
label: string;
filePath: string;
href?: ComponentProps<typeof Link>['href'];
};

function getBreadcrumbLinks(props: BreadcrumbLinksProps): BreadcrumbLinkItem[] {
return props.path.split('/').map((label, index, breadcrumbs) => {
const isLastSegment = index === breadcrumbs.length - 1;
const breadcrumb: BreadcrumbLinkItem = { key: `${index}-${label}`, label };
const breadcrumb: BreadcrumbLinkItem = {
label,
key: `${index}-${label}`,
filePath: breadcrumbs.slice(0, index + 1).join('/'),
};

// NOTE(cedric): a bit of a workaround to avoid linking to the current page, might need to change this
if (!isLastSegment || !label.includes('.')) {
const path = breadcrumbs.slice(0, index + 1).join('/');
breadcrumb.key = path;
breadcrumb.href = {
pathname: '/(atlas)/[bundle]/folders/[path]',
params: { bundle: props.bundle.id, path },
params: { bundle: props.bundle.id, path: breadcrumb.filePath },
};
}

Expand Down
183 changes: 183 additions & 0 deletions webui/src/ui/Menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// see: https://ui.shadcn.com/docs/components/context-menu

import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { cx } from 'class-variance-authority';
// @ts-expect-error
import CheckIcon from 'lucide-react/dist/esm/icons/check';
// @ts-expect-error
import ChevronRightIcon from 'lucide-react/dist/esm/icons/chevron-right';
// @ts-expect-error
import DotFilledIcon from 'lucide-react/dist/esm/icons/dot';
import {
type ComponentPropsWithoutRef,
type ElementRef,
forwardRef,
type HTMLAttributes,
} from 'react';

export const ContextMenu = ContextMenuPrimitive.Root;

export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;

export const ContextMenuGroup = ContextMenuPrimitive.Group;

export const ContextMenuPortal = ContextMenuPrimitive.Portal;

export const ContextMenuSub = ContextMenuPrimitive.Sub;

export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;

export const ContextMenuSubTrigger = forwardRef<
ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cx(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;

export const ContextMenuSubContent = forwardRef<
ElementRef<typeof ContextMenuPrimitive.SubContent>,
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cx(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;

export const ContextMenuContent = forwardRef<
ElementRef<typeof ContextMenuPrimitive.Content>,
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cx(
'p-1 z-50 min-w-[8rem] overflow-hidden border border-default rounded-md bg-screen text-default shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 ',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;

export const ContextMenuItem = forwardRef<
ElementRef<typeof ContextMenuPrimitive.Item>,
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cx(
'px-2 py-1.5 relative flex items-center cursor-default select-none outline-none rounded-sm text-sm',
'focus:bg-default focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;

export const ContextMenuCheckboxItem = forwardRef<
ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cx(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;

export const ContextMenuRadioItem = forwardRef<
ElementRef<typeof ContextMenuPrimitive.RadioItem>,
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cx(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<DotFilledIcon className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;

export const ContextMenuLabel = forwardRef<
ElementRef<typeof ContextMenuPrimitive.Label>,
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cx('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;

export const ContextMenuSeparator = forwardRef<
ElementRef<typeof ContextMenuPrimitive.Separator>,
ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cx('-mx-1 my-1 h-px bg-hover', className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;

export const ContextMenuShortcut = ({ className, ...props }: HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cx('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = 'ContextMenuShortcut';

0 comments on commit d15cf9d

Please sign in to comment.