mirror of
https://github.com/ilyakooo0/urbit.git
synced 2025-01-07 07:30:23 +03:00
Merge pull request #5164 from urbit/hm/grid-search-improvement
Leap: improving search capability
This commit is contained in:
commit
a1d80c2352
8518
package-lock.json
generated
8518
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
2
pkg/btc-wallet/package-lock.json
generated
2
pkg/btc-wallet/package-lock.json
generated
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "urbit-bitcoin-wallet",
|
||||
"name": "btc-wallet",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
|
2
pkg/grid/.gitignore
vendored
2
pkg/grid/.gitignore
vendored
@ -5,4 +5,4 @@ dist-ssr
|
||||
*.local
|
||||
stats.html
|
||||
.eslintcache
|
||||
.vercel
|
||||
.vercel
|
||||
|
@ -10,7 +10,6 @@
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint --cache \"**/*.{js,jsx,ts,tsx}\"",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"prepare": "husky install",
|
||||
"test": "echo \"No test yet\""
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavStore } from './Nav';
|
||||
import { useLeapStore } from './Nav';
|
||||
|
||||
export const Help = () => {
|
||||
const select = useNavStore((state) => state.select);
|
||||
const select = useLeapStore((state) => state.select);
|
||||
|
||||
useEffect(() => {
|
||||
select('Help and Support');
|
||||
|
233
pkg/grid/src/nav/Leap.tsx
Normal file
233
pkg/grid/src/nav/Leap.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import classNames from 'classnames';
|
||||
import { debounce } from 'lodash-es';
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
FocusEvent,
|
||||
FormEvent,
|
||||
KeyboardEvent,
|
||||
HTMLAttributes,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useRef
|
||||
} from 'react';
|
||||
import { Link, useHistory, useRouteMatch } from 'react-router-dom';
|
||||
import slugify from 'slugify';
|
||||
import { Cross } from '../components/icons/Cross';
|
||||
import { MenuState, useLeapStore } from './Nav';
|
||||
|
||||
function normalizePathEnding(path: string) {
|
||||
const end = path.length - 1;
|
||||
return path[end] === '/' ? path.substring(0, end - 1) : path;
|
||||
}
|
||||
|
||||
export function createPreviousPath(current: string): string {
|
||||
const parts = normalizePathEnding(current).split('/');
|
||||
parts.pop();
|
||||
|
||||
if (parts[parts.length - 1] === 'leap') {
|
||||
parts.push('search');
|
||||
}
|
||||
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
type LeapProps = {
|
||||
menu: MenuState;
|
||||
dropdown: string;
|
||||
showClose: boolean;
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Leap = React.forwardRef(({ menu, dropdown, showClose, className }: LeapProps, ref) => {
|
||||
const { push } = useHistory();
|
||||
const match = useRouteMatch<{ menu?: MenuState; query?: string; desk?: string }>(
|
||||
`/leap/${menu}/:query?/(apps)?/:desk?`
|
||||
);
|
||||
const appsMatch = useRouteMatch(`/leap/${menu}/${match?.params.query}/apps`);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useImperativeHandle(ref, () => inputRef.current);
|
||||
const { rawInput, selectedMatch, matches, selection, select } = useLeapStore();
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
if (selection || menu === 'search') {
|
||||
return;
|
||||
}
|
||||
|
||||
push('/leap/search');
|
||||
}, [selection, menu]);
|
||||
|
||||
const onFocus = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
// refocusing tab with input focused is false trigger
|
||||
const windowFocus = e.nativeEvent.currentTarget === document.body;
|
||||
if (windowFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleSearch();
|
||||
},
|
||||
[toggleSearch]
|
||||
);
|
||||
|
||||
const getMatch = useCallback(
|
||||
(value: string) => {
|
||||
return matches.find((m) => m.display?.startsWith(value) || m.value.startsWith(value));
|
||||
},
|
||||
[matches]
|
||||
);
|
||||
|
||||
const navigateByInput = useCallback(
|
||||
(input: string) => {
|
||||
const normalizedValue = input.trim().replace(/(~?[\w^_-]{3,13})\//, '$1/apps/');
|
||||
push(`/leap/${menu}/${normalizedValue}`);
|
||||
},
|
||||
[menu]
|
||||
);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
debounce(
|
||||
(input: string) => {
|
||||
if (!match || appsMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
useLeapStore.setState({ searchInput: input });
|
||||
navigateByInput(input);
|
||||
},
|
||||
300,
|
||||
{ leading: true }
|
||||
),
|
||||
[menu, match]
|
||||
);
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
|
||||
const inputMatch = getMatch(value);
|
||||
const matchValue = inputMatch?.display || inputMatch?.value;
|
||||
|
||||
if (matchValue && inputRef.current && !isDeletion) {
|
||||
inputRef.current.value = matchValue;
|
||||
inputRef.current.setSelectionRange(value.length, matchValue.length);
|
||||
useLeapStore.setState({
|
||||
rawInput: matchValue,
|
||||
selectedMatch: inputMatch
|
||||
});
|
||||
} else {
|
||||
useLeapStore.setState({
|
||||
rawInput: value,
|
||||
selectedMatch: matches[0]
|
||||
});
|
||||
}
|
||||
|
||||
handleSearch(value);
|
||||
},
|
||||
[matches]
|
||||
);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const value = inputRef.current?.value.trim();
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = [slugify(getMatch(value)?.value || value)];
|
||||
if (appsMatch) {
|
||||
input.unshift(match?.params.query || '');
|
||||
} else {
|
||||
input.push('');
|
||||
}
|
||||
|
||||
navigateByInput(input.join('/'));
|
||||
useLeapStore.setState({ rawInput: '' });
|
||||
},
|
||||
[match]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const deletion = e.key === 'Backspace' || e.key === 'Delete';
|
||||
const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp';
|
||||
|
||||
if (deletion && !rawInput && selection) {
|
||||
e.preventDefault();
|
||||
select(null, appsMatch ? undefined : match?.params.query);
|
||||
const pathBack = createPreviousPath(match?.url || '');
|
||||
push(pathBack);
|
||||
}
|
||||
|
||||
if (arrow) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentIndex = selectedMatch
|
||||
? matches.findIndex((m) => {
|
||||
const matchValue = m.display || m.value;
|
||||
const searchValue = selectedMatch.display || selectedMatch.value;
|
||||
return matchValue === searchValue;
|
||||
})
|
||||
: 0;
|
||||
const unsafeIndex = e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1;
|
||||
const index = (unsafeIndex + matches.length) % matches.length;
|
||||
|
||||
const newMatch = matches[index];
|
||||
const matchValue = newMatch.display || newMatch.value;
|
||||
useLeapStore.setState({
|
||||
rawInput: matchValue,
|
||||
// searchInput: matchValue,
|
||||
selectedMatch: newMatch
|
||||
});
|
||||
}
|
||||
},
|
||||
[selection, rawInput, match, matches, selectedMatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className={classNames(
|
||||
'relative z-50 flex items-center w-full px-2 rounded-full bg-white default-ring focus-within:ring-4',
|
||||
className
|
||||
)}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<label
|
||||
htmlFor="leap"
|
||||
className={classNames(
|
||||
'inline-block flex-none p-2 h4 text-blue-400',
|
||||
!selection && 'sr-only'
|
||||
)}
|
||||
>
|
||||
{selection || 'Search Landscape'}
|
||||
</label>
|
||||
<input
|
||||
id="leap"
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={selection ? '' : 'Search Landscape'}
|
||||
className="flex-1 w-full h-full px-2 h4 rounded-full bg-transparent outline-none"
|
||||
value={rawInput}
|
||||
onClick={toggleSearch}
|
||||
onFocus={onFocus}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-autocomplete="both"
|
||||
aria-controls={dropdown}
|
||||
aria-activedescendant={selectedMatch?.display || selectedMatch?.value}
|
||||
/>
|
||||
{showClose && (
|
||||
<Link
|
||||
to="/"
|
||||
className="circle-button w-8 h-8 text-gray-400 bg-gray-100 default-ring"
|
||||
onClick={() => select(null)}
|
||||
>
|
||||
<Cross className="w-3 h-3 fill-current" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
});
|
@ -1,25 +1,45 @@
|
||||
import { DialogContent } from '@radix-ui/react-dialog';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
FocusEvent,
|
||||
FunctionComponent,
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { Link, Route, Switch, useHistory, useLocation } from 'react-router-dom';
|
||||
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, Route, Switch, useHistory } from 'react-router-dom';
|
||||
import create from 'zustand';
|
||||
import { Dialog } from '../components/Dialog';
|
||||
import { Cross } from '../components/icons/Cross';
|
||||
import { Help } from './Help';
|
||||
import { Leap } from './Leap';
|
||||
import { Notifications } from './Notifications';
|
||||
import { Search } from './Search';
|
||||
import { SystemMenu } from './SystemMenu';
|
||||
import { SystemPreferences } from './SystemPreferences';
|
||||
|
||||
export interface MatchItem {
|
||||
value: string;
|
||||
display?: string;
|
||||
}
|
||||
|
||||
interface LeapStore {
|
||||
rawInput: string;
|
||||
searchInput: string;
|
||||
matches: MatchItem[];
|
||||
selectedMatch?: MatchItem;
|
||||
selection: React.ReactNode;
|
||||
select: (selection: React.ReactNode, input?: string) => void;
|
||||
}
|
||||
|
||||
export const useLeapStore = create<LeapStore>((set) => ({
|
||||
rawInput: '',
|
||||
searchInput: '',
|
||||
matches: [],
|
||||
selectedMatch: undefined,
|
||||
selection: null,
|
||||
select: (selection: React.ReactNode, input?: string) =>
|
||||
set({
|
||||
rawInput: input || '',
|
||||
searchInput: input || '',
|
||||
selection
|
||||
})
|
||||
}));
|
||||
|
||||
export type MenuState =
|
||||
| 'closed'
|
||||
| 'search'
|
||||
@ -31,226 +51,113 @@ interface NavProps {
|
||||
menu?: MenuState;
|
||||
}
|
||||
|
||||
interface NavStore {
|
||||
searchInput: string;
|
||||
setSearchInput: (input: string) => void;
|
||||
selection: React.ReactNode;
|
||||
select: (selection: React.ReactNode, input?: string) => void;
|
||||
}
|
||||
|
||||
export const useNavStore = create<NavStore>((set) => ({
|
||||
searchInput: '',
|
||||
setSearchInput: (input: string) => set({ searchInput: input }),
|
||||
selection: null,
|
||||
select: (selection: React.ReactNode, input?: string) =>
|
||||
set({ searchInput: input || '', selection })
|
||||
}));
|
||||
|
||||
export function createNextPath(current: string, nextPart?: string): string {
|
||||
let end = nextPart;
|
||||
const parts = current.split('/').reverse();
|
||||
if (parts[1] === 'search') {
|
||||
end = 'apps';
|
||||
}
|
||||
|
||||
if (parts[0] === 'leap') {
|
||||
end = `search/${nextPart}`;
|
||||
}
|
||||
|
||||
return `${current}/${end}`;
|
||||
}
|
||||
|
||||
export function createPreviousPath(current: string): string {
|
||||
const parts = current.split('/');
|
||||
parts.pop();
|
||||
|
||||
if (parts[parts.length - 1] === 'leap') {
|
||||
parts.push('search');
|
||||
}
|
||||
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
export const Nav: FunctionComponent<NavProps> = ({ menu = 'closed' }) => {
|
||||
export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
|
||||
const { push } = useHistory();
|
||||
const location = useLocation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { searchInput, setSearchInput, selection, select } = useNavStore();
|
||||
const navRef = useRef<HTMLDivElement>(null);
|
||||
const dialogNavRef = useRef<HTMLDivElement>(null);
|
||||
const [systemMenuOpen, setSystemMenuOpen] = useState(false);
|
||||
const [dialogContentOpen, setDialogContentOpen] = useState(false);
|
||||
const { selection, select } = useLeapStore((state) => ({
|
||||
selectedMatch: state.selectedMatch,
|
||||
selection: state.selection,
|
||||
select: state.select
|
||||
}));
|
||||
|
||||
const isOpen = menu !== 'closed';
|
||||
const menuState = menu || 'closed';
|
||||
const isOpen = menuState !== 'closed';
|
||||
const eitherOpen = isOpen || systemMenuOpen;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
select(null);
|
||||
setDialogContentOpen(false);
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [selection, isOpen]);
|
||||
|
||||
const onOpen = useCallback(
|
||||
(event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
setDialogContentOpen(true);
|
||||
if (menu === 'search' && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[menu]
|
||||
);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (!menu || menu === 'search') {
|
||||
// select(null);
|
||||
// inputRef.current?.focus();
|
||||
// }
|
||||
// }, [menu]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selection]);
|
||||
|
||||
const toggleSearch = useCallback(() => {
|
||||
if (selection || menu === 'search') {
|
||||
return;
|
||||
}
|
||||
|
||||
push('/leap/search');
|
||||
}, [selection, menu]);
|
||||
|
||||
const onDialogClose = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
select(null);
|
||||
push('/');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onDialogKey = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!selection || searchInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
select(null);
|
||||
const pathBack = createPreviousPath(location.pathname);
|
||||
push(pathBack);
|
||||
}
|
||||
},
|
||||
[selection, searchInput, location.pathname]
|
||||
);
|
||||
|
||||
const onFocus = useCallback((e: FocusEvent<HTMLInputElement>) => {
|
||||
// refocusing tab with input focused is false trigger
|
||||
const windowFocus = e.nativeEvent.currentTarget === document.body;
|
||||
if (windowFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggleSearch();
|
||||
}, []);
|
||||
|
||||
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
setSearchInput(value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<menu className="w-full max-w-3xl my-6 px-4 text-gray-400 font-semibold">
|
||||
<div className={classNames('flex space-x-2', isOpen && 'invisible')}>
|
||||
{!isOpen && (
|
||||
<SystemMenu
|
||||
showOverlay
|
||||
open={systemMenuOpen}
|
||||
setOpen={setSystemMenuOpen}
|
||||
className={classNames(
|
||||
'relative z-50 flex-none',
|
||||
eitherOpen ? 'bg-white' : 'bg-gray-100'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<>
|
||||
<Portal.Root
|
||||
containerRef={dialogContentOpen ? dialogNavRef : navRef}
|
||||
className="flex space-x-2"
|
||||
>
|
||||
<SystemMenu
|
||||
open={systemMenuOpen}
|
||||
setOpen={setSystemMenuOpen}
|
||||
className={classNames('relative z-50 flex-none', eitherOpen ? 'bg-white' : 'bg-gray-100')}
|
||||
/>
|
||||
<Link
|
||||
to="/leap/notifications"
|
||||
className="relative z-50 flex-none circle-button bg-blue-400 text-white default-ring"
|
||||
className="relative z-50 flex-none circle-button bg-blue-400 text-white h4"
|
||||
>
|
||||
3
|
||||
</Link>
|
||||
<input
|
||||
onClick={toggleSearch}
|
||||
onFocus={onFocus}
|
||||
type="text"
|
||||
className="relative z-50 rounded-full w-full pl-4 h4 bg-gray-100 default-ring"
|
||||
placeholder="Search Landscape"
|
||||
<Leap
|
||||
ref={inputRef}
|
||||
menu={menuState}
|
||||
dropdown="leap-items"
|
||||
showClose={isOpen}
|
||||
className={!isOpen ? 'bg-gray-100' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</Portal.Root>
|
||||
<div
|
||||
ref={navRef}
|
||||
className={classNames(
|
||||
'w-full max-w-3xl my-6 px-4 text-gray-400 font-semibold',
|
||||
dialogContentOpen && 'h-12'
|
||||
)}
|
||||
role="combobox"
|
||||
aria-controls="leap-items"
|
||||
aria-owns="leap-items"
|
||||
aria-expanded={isOpen}
|
||||
/>
|
||||
<Dialog open={isOpen} onOpenChange={onDialogClose}>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={onOpen}
|
||||
className="fixed top-0 left-[calc(50%-7.5px)] w-[calc(100%-15px)] max-w-3xl px-4 text-gray-400 -translate-x-1/2 outline-none"
|
||||
className="fixed top-0 left-[calc(50%)] w-[calc(100%-15px)] max-w-3xl px-4 text-gray-400 -translate-x-1/2 outline-none"
|
||||
role="combobox"
|
||||
aria-controls="leap-items"
|
||||
aria-owns="leap-items"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<div tabIndex={-1} onKeyDown={onDialogKey} role="presentation">
|
||||
<header className="flex my-6 space-x-2">
|
||||
<SystemMenu
|
||||
open={systemMenuOpen}
|
||||
setOpen={setSystemMenuOpen}
|
||||
className={classNames(
|
||||
'relative z-50 flex-none',
|
||||
eitherOpen ? 'bg-white' : 'bg-gray-100'
|
||||
)}
|
||||
/>
|
||||
<Link
|
||||
to="/leap/notifications"
|
||||
className="relative z-50 flex-none circle-button bg-blue-400 text-white"
|
||||
>
|
||||
3
|
||||
</Link>
|
||||
<div className="relative z-50 flex items-center w-full px-2 rounded-full bg-white default-ring focus-within:ring-4">
|
||||
<label
|
||||
htmlFor="leap"
|
||||
className={classNames(
|
||||
'inline-block flex-none p-2 h4 text-blue-400',
|
||||
!selection && 'sr-only'
|
||||
)}
|
||||
>
|
||||
{selection || 'Search Landscape'}
|
||||
</label>
|
||||
<input
|
||||
id="leap"
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
placeholder={selection ? '' : 'Search Landscape'}
|
||||
className="flex-1 w-full h-full px-2 h4 rounded-full bg-transparent outline-none"
|
||||
value={searchInput}
|
||||
onClick={toggleSearch}
|
||||
onFocus={onFocus}
|
||||
onChange={onChange}
|
||||
role="combobox"
|
||||
aria-controls="leap-items"
|
||||
aria-expanded
|
||||
/>
|
||||
{(selection || searchInput) && (
|
||||
<Link
|
||||
to="/"
|
||||
className="circle-button w-8 h-8 text-gray-400 bg-gray-100 default-ring"
|
||||
onClick={() => select(null)}
|
||||
>
|
||||
<Cross className="w-3 h-3 fill-current" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
id="leap-items"
|
||||
className="grid grid-rows-[fit-content(calc(100vh-7.5rem))] bg-white rounded-3xl overflow-hidden"
|
||||
role="listbox"
|
||||
>
|
||||
<Switch>
|
||||
<Route path="/leap/notifications" component={Notifications} />
|
||||
<Route path="/leap/system-preferences" component={SystemPreferences} />
|
||||
<Route path="/leap/help-and-support" component={Help} />
|
||||
<Route path={['/leap/search', '/leap']} component={Search} />
|
||||
</Switch>
|
||||
</div>
|
||||
<header ref={dialogNavRef} className="my-6" />
|
||||
<div
|
||||
id="leap-items"
|
||||
className="grid grid-rows-[fit-content(calc(100vh-7.5rem))] bg-white rounded-3xl overflow-hidden default-ring"
|
||||
tabIndex={0}
|
||||
role="listbox"
|
||||
>
|
||||
<Switch>
|
||||
<Route path="/leap/notifications" component={Notifications} />
|
||||
<Route path="/leap/system-preferences" component={SystemPreferences} />
|
||||
<Route path="/leap/help-and-support" component={Help} />
|
||||
<Route path={['/leap/search', '/leap']} component={Search} />
|
||||
</Switch>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavStore } from './Nav';
|
||||
import { useLeapStore } from './Nav';
|
||||
|
||||
export const Notifications = () => {
|
||||
const select = useNavStore((state) => state.select);
|
||||
const select = useLeapStore((state) => state.select);
|
||||
|
||||
useEffect(() => {
|
||||
select('Notifications');
|
||||
|
@ -1,8 +1,8 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavStore } from './Nav';
|
||||
import { useLeapStore } from './Nav';
|
||||
|
||||
export const SystemPreferences = () => {
|
||||
const select = useNavStore((state) => state.select);
|
||||
const select = useLeapStore((state) => state.select);
|
||||
|
||||
useEffect(() => {
|
||||
select('System Preferences');
|
||||
|
@ -5,11 +5,11 @@ import { ShipName } from '../../components/ShipName';
|
||||
import { Spinner } from '../../components/Spinner';
|
||||
import { TreatyMeta } from '../../components/TreatyMeta';
|
||||
import { useTreaty } from '../../logic/useTreaty';
|
||||
import { useLeapStore } from '../Nav';
|
||||
import { useCharges } from '../../state/docket';
|
||||
import { useNavStore } from '../Nav';
|
||||
|
||||
export const AppInfo = () => {
|
||||
const select = useNavStore((state) => state.select);
|
||||
const select = useLeapStore((state) => state.select);
|
||||
const { ship, desk, treaty, installStatus, copyApp, installApp } = useTreaty();
|
||||
const charges = useCharges();
|
||||
const installed = (charges || {})[desk] || installStatus === 'success';
|
||||
|
@ -1,19 +1,43 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, RouteComponentProps } from 'react-router-dom';
|
||||
import fuzzy from 'fuzzy';
|
||||
import slugify from 'slugify';
|
||||
import classNames from 'classnames';
|
||||
import { ShipName } from '../../components/ShipName';
|
||||
import useDocketState from '../../state/docket';
|
||||
import { Treaty } from '../../state/docket-types';
|
||||
import { useNavStore } from '../Nav';
|
||||
import { useLeapStore } from '../Nav';
|
||||
|
||||
type AppsProps = RouteComponentProps<{ ship: string }>;
|
||||
|
||||
export const Apps = ({ match }: AppsProps) => {
|
||||
const { select } = useNavStore();
|
||||
const { searchInput, selectedMatch, select } = useLeapStore((state) => ({
|
||||
searchInput: state.searchInput,
|
||||
select: state.select,
|
||||
selectedMatch: state.selectedMatch
|
||||
}));
|
||||
const provider = match?.params.ship;
|
||||
const fetchProviderTreaties = useDocketState((s) => s.fetchProviderTreaties);
|
||||
const [treaties, setTreaties] = useState<Treaty[]>();
|
||||
const count = treaties?.length;
|
||||
const results = useMemo(
|
||||
() =>
|
||||
treaties
|
||||
? fuzzy
|
||||
.filter(
|
||||
searchInput,
|
||||
treaties.map((t) => t.title)
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const left = a.string.startsWith(searchInput) ? a.score + 1 : a.score;
|
||||
const right = b.string.startsWith(searchInput) ? b.score + 1 : b.score;
|
||||
|
||||
return right - left;
|
||||
})
|
||||
.map((result) => treaties[result.index])
|
||||
: undefined,
|
||||
[treaties, searchInput]
|
||||
);
|
||||
const count = results?.length;
|
||||
|
||||
useEffect(() => {
|
||||
select(
|
||||
@ -23,14 +47,36 @@ export const Apps = ({ match }: AppsProps) => {
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (results) {
|
||||
useLeapStore.setState({
|
||||
matches: results.map((treaty) => ({ value: treaty.desk, display: treaty.title }))
|
||||
});
|
||||
}
|
||||
}, [results]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getTreaties() {
|
||||
setTreaties(await fetchProviderTreaties(provider));
|
||||
}
|
||||
|
||||
getTreaties();
|
||||
if (provider) {
|
||||
getTreaties();
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(target: Treaty) => {
|
||||
if (!selectedMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchValue = selectedMatch.display || selectedMatch.value;
|
||||
return target.title === matchValue || target.desk === matchValue;
|
||||
},
|
||||
[selectedMatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400">
|
||||
<div id="developed-by">
|
||||
@ -41,13 +87,21 @@ export const Apps = ({ match }: AppsProps) => {
|
||||
{count} result{count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
{treaties && (
|
||||
{results && (
|
||||
<ul className="space-y-8" aria-labelledby="developed-by">
|
||||
{treaties.map((app) => (
|
||||
<li key={app.desk}>
|
||||
{results.map((app) => (
|
||||
<li
|
||||
key={app.desk}
|
||||
id={app.title || app.desk}
|
||||
role="option"
|
||||
aria-selected={isSelected(app)}
|
||||
>
|
||||
<Link
|
||||
to={`${match?.path.replace(':ship', provider)}/${slugify(app.desk)}`}
|
||||
className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg"
|
||||
className={classNames(
|
||||
'flex items-center space-x-3 default-ring ring-offset-2 rounded-lg',
|
||||
isSelected(app) && 'ring-4'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex-none relative w-12 h-12 bg-gray-200 rounded-lg"
|
||||
|
@ -1,28 +1,6 @@
|
||||
import { debounce } from 'lodash-es';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import { createNextPath, useNavStore } from '../Nav';
|
||||
|
||||
type HomeProps = RouteComponentProps;
|
||||
|
||||
export const Home = ({ match, history }: HomeProps) => {
|
||||
const searchInput = useNavStore((state) => state.searchInput);
|
||||
const { push } = history;
|
||||
const { path } = match;
|
||||
|
||||
const handleSearch = useCallback(
|
||||
debounce((input: string) => {
|
||||
push(createNextPath(path, input.trim()));
|
||||
}, 300),
|
||||
[path]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchInput) {
|
||||
handleSearch(searchInput);
|
||||
}
|
||||
}, [searchInput]);
|
||||
import React from 'react';
|
||||
|
||||
export const Home = () => {
|
||||
return (
|
||||
<div className="h-full p-4 md:p-8 space-y-8 overflow-y-auto">
|
||||
<h2 className="h4 text-gray-500">Recent Apps</h2>
|
||||
|
@ -1,24 +1,42 @@
|
||||
import { debounce } from 'lodash-es';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import fuzzy from 'fuzzy';
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, RouteComponentProps } from 'react-router-dom';
|
||||
import { ShipName } from '../../components/ShipName';
|
||||
import useDocketState from '../../state/docket';
|
||||
import { Provider } from '../../state/docket-types';
|
||||
import { useNavStore } from '../Nav';
|
||||
import useDocketState from '../../state/docket';
|
||||
import { useLeapStore } from '../Nav';
|
||||
|
||||
type ProvidersProps = RouteComponentProps<{ ship: string }>;
|
||||
|
||||
export const Providers = ({ match, history }: ProvidersProps) => {
|
||||
const { searchInput, select } = useNavStore((state) => ({
|
||||
searchInput: state.searchInput,
|
||||
select: state.select
|
||||
export const Providers = ({ match }: ProvidersProps) => {
|
||||
const { selectedMatch, select } = useLeapStore((state) => ({
|
||||
select: state.select,
|
||||
selectedMatch: state.selectedMatch
|
||||
}));
|
||||
const { push } = history;
|
||||
const { path } = match;
|
||||
const provider = match?.params.ship;
|
||||
const fetchProviders = useDocketState((s) => s.fetchProviders);
|
||||
const [providers, setProviders] = useState<Provider[]>();
|
||||
const count = providers?.length;
|
||||
const search = provider || '';
|
||||
const results = useMemo(
|
||||
() =>
|
||||
providers
|
||||
? fuzzy
|
||||
.filter(
|
||||
search,
|
||||
providers.map((p) => p.shipName + (p.nickname || ''))
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const left = a.string.startsWith(search) ? a.score + 1 : a.score;
|
||||
const right = b.string.startsWith(search) ? b.score + 1 : b.score;
|
||||
|
||||
return right - left;
|
||||
})
|
||||
.map((el) => providers[el.index])
|
||||
: [],
|
||||
[providers, search]
|
||||
);
|
||||
const count = results?.length;
|
||||
|
||||
useEffect(() => {
|
||||
async function getProviders() {
|
||||
@ -29,18 +47,25 @@ export const Providers = ({ match, history }: ProvidersProps) => {
|
||||
getProviders();
|
||||
}, [provider]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
debounce((input: string) => {
|
||||
push(match?.path.replace(':ship', input.trim()));
|
||||
}, 300),
|
||||
[path]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchInput) {
|
||||
handleSearch(searchInput);
|
||||
if (results) {
|
||||
useLeapStore.setState({
|
||||
matches: results.map((p) => ({ value: p.shipName, display: p.nickname }))
|
||||
});
|
||||
}
|
||||
}, [searchInput]);
|
||||
}, [results]);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(target: Provider) => {
|
||||
if (!selectedMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchValue = selectedMatch.display || selectedMatch.value;
|
||||
return target.nickname === matchValue || target.shipName === matchValue;
|
||||
},
|
||||
[selectedMatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400" aria-live="polite">
|
||||
@ -50,13 +75,21 @@ export const Providers = ({ match, history }: ProvidersProps) => {
|
||||
{count} result{count === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
{providers && (
|
||||
{results && (
|
||||
<ul className="space-y-8" aria-labelledby="providers">
|
||||
{providers.map((p) => (
|
||||
<li key={p.shipName}>
|
||||
{results.map((p) => (
|
||||
<li
|
||||
key={p.shipName}
|
||||
id={p.nickname || p.shipName}
|
||||
role="option"
|
||||
aria-selected={isSelected(p)}
|
||||
>
|
||||
<Link
|
||||
to={`${match?.path.replace(':ship', p.shipName)}/apps`}
|
||||
className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg"
|
||||
className={classNames(
|
||||
'flex items-center space-x-3 default-ring ring-offset-2 rounded-lg',
|
||||
isSelected(p) && 'ring-4'
|
||||
)}
|
||||
>
|
||||
<div className="flex-none relative w-12 h-12 bg-black rounded-lg">
|
||||
{/* TODO: Handle sigils */}
|
||||
|
@ -42,11 +42,7 @@ const useDocketState = create<DocketState>((set, get) => ({
|
||||
? await fakeRequest(mockTreaties)
|
||||
: ((await (await fetch('/~/scry/docket/charges.json')).json()) as ChargesResponse).initial;
|
||||
|
||||
const charges = Object.entries(dockets).reduce((obj: Dockets, [key, value]) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
obj[key] = normalizeDocket(value);
|
||||
return obj;
|
||||
}, {});
|
||||
const charges = normalizeDockets(dockets);
|
||||
|
||||
set({ charges });
|
||||
},
|
||||
@ -58,7 +54,7 @@ const useDocketState = create<DocketState>((set, get) => ({
|
||||
fetchProviderTreaties: async (provider: string) => {
|
||||
const { treaties, providers } = get();
|
||||
const dev = providers[provider];
|
||||
const treatyList = Object.values(treaties).map((treaty) => normalizeDocket(treaty));
|
||||
const treatyList = Object.values(treaties);
|
||||
|
||||
if (dev.treaties) {
|
||||
return dev.treaties.map((key) => treaties[key]);
|
||||
@ -80,8 +76,9 @@ const useDocketState = create<DocketState>((set, get) => ({
|
||||
}
|
||||
|
||||
providerTreaties.forEach((treaty) => {
|
||||
const key = `${provider}/${treaty.desk}`;
|
||||
draft.treaties[key] = treaty;
|
||||
// may need to do this when not mock data
|
||||
// const key = `${provider}/${treaty.desk}`;
|
||||
// draft.treaties[key] = treaty;
|
||||
draft.providers[provider].treaties?.push(treaty.desk);
|
||||
});
|
||||
})
|
||||
@ -143,7 +140,7 @@ const useDocketState = create<DocketState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
},
|
||||
treaties: useMockData ? mockTreaties : {},
|
||||
treaties: useMockData ? normalizeDockets(mockTreaties) : {},
|
||||
charges: {},
|
||||
providers: useMockData ? mockProviders : {},
|
||||
set
|
||||
@ -157,6 +154,14 @@ function normalizeDocket<T extends Docket>(docket: T): T {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDockets<T extends Docket>(dockets: Record<string, T>): Record<string, T> {
|
||||
return Object.entries(dockets).reduce((obj: Record<string, T>, [key, value]) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
obj[key] = normalizeDocket(value);
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
interface AddDockEvent {
|
||||
'add-dock': {
|
||||
desk: string;
|
||||
|
@ -3,25 +3,25 @@
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 font-semibold text-base tracking-tight rounded-lg cursor-pointer;
|
||||
@apply inline-flex items-center justify-center px-4 py-2 font-semibold text-base tracking-tight rounded-lg cursor-pointer;
|
||||
}
|
||||
|
||||
.dialog-container {
|
||||
@apply fixed z-40 top-1/2 left-1/2 min-w-80 transform -translate-x-1/2 -translate-y-1/2;
|
||||
@apply fixed z-40 top-1/2 left-1/2 min-w-80 transform -translate-x-1/2 -translate-y-1/2;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
@apply relative p-5 sm:p-8 bg-white rounded-3xl;
|
||||
@apply relative p-5 sm:p-8 bg-white rounded-3xl;
|
||||
}
|
||||
|
||||
.dialog-inner-container {
|
||||
@apply h-full p-4 md:p-8 space-y-8 overflow-y-auto;
|
||||
@apply h-full p-4 md:p-8 space-y-8 overflow-y-auto;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
@apply min-w-52 p-4 space-y-4 rounded-xl;
|
||||
@apply min-w-52 p-4 rounded-xl;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply inline-flex items-center w-6 h-6 animate-spin;
|
||||
}
|
||||
@apply inline-flex items-center w-6 h-6 animate-spin;
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ export const Tile: FunctionComponent<TileProps> = ({ docket, desk }) => {
|
||||
active={active}
|
||||
menuColor={menuColor}
|
||||
lightText={lightText}
|
||||
className="absolute z-10 top-2.5 right-2.5 sm:top-4 sm:right-4 opacity-0 hover-none:opacity-100 focus:opacity-100 group-hover:opacity-100"
|
||||
className="absolute z-10 top-2.5 right-2.5 sm:top-4 sm:right-4 opacity-0 hover-none:opacity-100 pointer-coarse:opacity-100 focus:opacity-100 group-hover:opacity-100"
|
||||
/>
|
||||
<div className="h4 absolute bottom-4 left-4 lg:bottom-8 lg:left-8">
|
||||
<h3
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import type * as Polymorphic from '@radix-ui/react-polymorphic';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import classNames from 'classnames';
|
||||
@ -30,7 +30,7 @@ const Item = React.forwardRef(({ children, ...props }, ref) => (
|
||||
<DropdownMenu.Item
|
||||
ref={ref}
|
||||
{...props}
|
||||
className="block w-full px-4 py-1 leading-none rounded mix-blend-hard-light select-none default-ring ring-gray-600"
|
||||
className="block w-full px-4 py-3 leading-none rounded mix-blend-hard-light select-none default-ring ring-gray-600"
|
||||
>
|
||||
{children}
|
||||
</DropdownMenu.Item>
|
||||
@ -40,6 +40,10 @@ export const TileMenu = ({ desk, active, menuColor, lightText, className }: Tile
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggleDocket = useDocketState((s) => s.toggleDocket);
|
||||
const menuBg = { backgroundColor: menuColor };
|
||||
const linkOnSelect = useCallback((e: Event) => {
|
||||
e.preventDefault();
|
||||
setTimeout(() => setOpen(false), 15);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root open={open} onOpenChange={(isOpen) => setOpen(isOpen)}>
|
||||
@ -64,50 +68,29 @@ export const TileMenu = ({ desk, active, menuColor, lightText, className }: Tile
|
||||
sideOffset={4}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
className={classNames(
|
||||
'dropdown font-semibold',
|
||||
'dropdown py-2 font-semibold',
|
||||
lightText ? 'text-gray-100' : 'text-gray-800'
|
||||
)}
|
||||
style={menuBg}
|
||||
>
|
||||
<DropdownMenu.Group className="space-y-4">
|
||||
<DropdownMenu.Group>
|
||||
{/*
|
||||
TODO: revisit with Liam
|
||||
<Item as={Link} to={`/leap/search/${provider}/apps/${name.toLowerCase()}`} onSelect={(e) => { e.preventDefault(); setTimeout(() => setOpen(false), 0) }}>App Info</Item>
|
||||
*/}
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/app/${desk}`}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setTimeout(() => setOpen(false), 0);
|
||||
}}
|
||||
>
|
||||
<Item as={Link} to={`/app/${desk}`} onSelect={linkOnSelect}>
|
||||
App Info
|
||||
</Item>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator className="-mx-4 my-2 border-t-2 border-solid border-gray-500 mix-blend-soft-light" />
|
||||
<DropdownMenu.Group className="space-y-4">
|
||||
<DropdownMenu.Group>
|
||||
{active && (
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/app/${desk}/suspend`}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setTimeout(() => setOpen(false), 0);
|
||||
}}
|
||||
>
|
||||
<Item as={Link} to={`/app/${desk}/suspend`} onSelect={linkOnSelect}>
|
||||
Suspend App
|
||||
</Item>
|
||||
)}
|
||||
{!active && <Item onSelect={() => toggleDocket(desk)}>Resume App</Item>}
|
||||
<Item
|
||||
as={Link}
|
||||
to={`/app/${desk}/remove`}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setTimeout(() => setOpen(false), 0);
|
||||
}}
|
||||
>
|
||||
<Item as={Link} to={`/app/${desk}/remove`} onSelect={linkOnSelect}>
|
||||
Remove App
|
||||
</Item>
|
||||
</DropdownMenu.Group>
|
||||
|
5
pkg/npm/api/package-lock.json
generated
5
pkg/npm/api/package-lock.json
generated
@ -268,11 +268,6 @@
|
||||
"eslint-visitor-keys": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@urbit/eslint-config": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@urbit/eslint-config/-/eslint-config-1.0.1.tgz",
|
||||
"integrity": "sha512-7NY/3R1S7yEh+L2y9iIhe7f9nIlHuiZqV0dsqbTNxGlp64QaQ88njHwSDgtYURJMotdm02g7d46BGPsiRM6UQg=="
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
|
Loading…
Reference in New Issue
Block a user