grid: init combination from liam + hunter

This commit is contained in:
Hunter Miller 2021-08-13 18:11:16 -05:00
parent 70bba97180
commit f80cf2668a
58 changed files with 2159 additions and 2 deletions

View File

@ -2,7 +2,8 @@
"packages": [
"pkg/npm/*",
"pkg/btc-wallet",
"pkg/interface"
"pkg/interface",
"pkg/grid"
],
"version": "independent"
}

BIN
package-lock.json generated

Binary file not shown.

View File

@ -5,7 +5,8 @@
"eslint": "^7.29.0",
"husky": "^6.0.0",
"lerna": "^4.0.0",
"lint-staged": "^11.0.0"
"lint-staged": "^11.1.2",
"prettier": "^2.3.2"
},
"scripts": {
"watch-libs": "lerna run watch --no-private --parallel",

7
pkg/grid/.eslintignore Normal file
View File

@ -0,0 +1,7 @@
node_modules/
dist/
bin/
.vscode/
.husky/
*.config.js
*.config.ts

66
pkg/grid/.eslintrc.js Normal file
View File

@ -0,0 +1,66 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true
},
extends: [
'plugin:react/recommended',
'airbnb',
'plugin:@typescript-eslint/recommended',
'plugin:import/errors',
'plugin:jsx-a11y/recommended',
'plugin:tailwind/recommended',
'prettier',
'prettier/prettier',
'plugin:prettier/recommended'
],
ignorePatterns: ['**/*.config.js', '**/*.config.ts'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: 12,
sourceType: 'module'
},
plugins: ['react', '@typescript-eslint', 'import', 'jsx-a11y', 'react-hooks'],
rules: {
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/no-empty-function': 'off',
'react/jsx-filename-extension': ['warn', { extensions: ['.tsx'] }],
'import/extensions': [
'error',
'ignorePackages',
{
ts: 'never',
tsx: 'never'
}
],
'no-shadow': 'off',
'@typescript-eslint/no-shadow': ['error'],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'off',
'import/prefer-default-export': 'off',
'react/prop-types': 'off',
'react/jsx-props-no-spreading': 'off',
'react/require-default-props': 'off',
'import/no-extraneous-dependencies': ['error'],
'tailwind/class-order': 'off'
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts']
},
'import/resolver': {
typescript: {
alwaysTryTypes: true
}
}
}
};

7
pkg/grid/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
stats.html
.eslintcache

1
pkg/grid/.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

6
pkg/grid/.husky/pre-commit Executable file
View File

@ -0,0 +1,6 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd pkg/grid
npm test
npx lint-staged

6
pkg/grid/.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "none"
}

16
pkg/grid/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Landscape • Home</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;600&display=swap" rel="stylesheet">
</head>
<body class="text-sm font-sans text-gray-900 bg-white antialiased">
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

BIN
pkg/grid/package-lock.json generated Normal file

Binary file not shown.

76
pkg/grid/package.json Normal file
View File

@ -0,0 +1,76 @@
{
"name": "landscape",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"mock": "vite --mode mock",
"build:mock": "tsc && vite build --mode mock",
"build:profile": "tsc && vite build --mode profile",
"build": "tsc && vite build",
"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": {
"@radix-ui/react-dialog": "^0.0.20",
"@radix-ui/react-dropdown-menu": "^0.0.23",
"@radix-ui/react-polymorphic": "^0.0.13",
"@radix-ui/react-portal": "^0.0.15",
"@urbit/http-api": "^1.3.1",
"classnames": "^2.3.1",
"clipboard-copy": "^4.0.1",
"color2k": "^1.2.4",
"fuzzy": "^0.1.3",
"lodash-es": "^4.17.21",
"mousetrap": "^1.6.5",
"postcss-import": "^14.0.2",
"query-string": "^7.0.1",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-query": "^3.19.2",
"react-router-dom": "^5.2.0",
"slugify": "^1.6.0",
"zustand": "^3.5.7"
},
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.2.1",
"@types/lodash-es": "^4.17.4",
"@types/mousetrap": "^1.6.8",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.8",
"@typescript-eslint/eslint-plugin": "^4.26.1",
"@typescript-eslint/parser": "^4.26.1",
"@vitejs/plugin-react-refresh": "^1.3.1",
"autoprefixer": "^10.3.1",
"eslint": "^7.28.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-typescript": "^2.4.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-tailwind": "^0.2.1",
"husky": "^7.0.0",
"lint-staged": "^11.1.2",
"postcss": "^8.3.6",
"prettier": "^2.3.2",
"rollup-plugin-analyzer": "^4.0.0",
"rollup-plugin-visualizer": "^5.5.2",
"tailwindcss": "^2.2.7",
"tailwindcss-touch": "^1.0.1",
"typescript": "^4.3.2",
"vite": "^2.4.4"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"npm run lint:fix",
"prettier --write"
],
"*.+{json,css,md}": "prettier --write"
}
}

View File

@ -0,0 +1,7 @@
module.exports = {
plugins: [
require('postcss-import'),
require('tailwindcss'),
require('autoprefixer'),
]
}

43
pkg/grid/src/app.tsx Normal file
View File

@ -0,0 +1,43 @@
import React, { useEffect } from 'react';
import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Grid } from './pages/Grid';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false
}
}
});
const AppRoutes = () => {
const { push } = useHistory();
useEffect(() => {
window.name = 'grid';
Mousetrap.bind(['command+/', 'ctrl+/'], () => {
push('/leap/search');
});
}, []);
return (
<Switch>
<Route path={['/leap/:menu', '/']} component={Grid} />
</Switch>
);
};
export function App() {
const base = import.meta.env.MODE === 'mock' ? undefined : '/apps/grid';
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter basename={base}>
<AppRoutes />
</BrowserRouter>
</QueryClientProvider>
);
}

BIN
pkg/grid/src/assets/go.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,15 @@
import React from 'react';
import { capitalize } from 'lodash-es';
interface AttributeProps {
attr: string;
children: React.ReactNode;
title?: string;
}
export const Attribute = ({ attr, children, title }: AttributeProps) => (
<div className="h4">
<h2 className="mb-2 text-gray-500">{title || capitalize(attr)}</h2>
<p className="font-mono">{children}</p>
</div>
);

View File

@ -0,0 +1,42 @@
import React from 'react';
import type * as Polymorphic from '@radix-ui/react-polymorphic';
import classNames from 'classnames';
type ButtonVariant = 'primary' | 'secondary' | 'destructive';
type PolymorphicButton = Polymorphic.ForwardRefComponent<
'button',
{
variant?: ButtonVariant;
}
>;
const variants: Record<ButtonVariant, string> = {
primary: 'text-white bg-blue-400',
secondary: 'text-blue-400 bg-blue-100',
destructive: 'text-white bg-red-400'
};
export const Button = React.forwardRef(
({ as: Comp = 'button', variant = 'primary', children, className, ...props }, ref) => {
return (
<Comp
ref={ref}
{...props}
className={classNames('button default-ring', variants[variant], className)}
>
{children}
</Comp>
);
}
) as PolymorphicButton;
export const PillButton = React.forwardRef(({ className, children, ...props }, ref) => (
<Button
ref={ref}
{...props}
className={classNames('px-4 py-2 sm:px-6 sm:py-3 text-sm sm:text-base rounded-full', className)}
>
{children}
</Button>
)) as PolymorphicButton;

View File

@ -0,0 +1,51 @@
import React, { FC } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import type * as Polymorphic from '@radix-ui/react-polymorphic';
import classNames from 'classnames';
export const Dialog: FC<DialogPrimitive.DialogOwnProps> = ({ children, ...props }) => {
return (
<DialogPrimitive.Root {...props}>
<DialogPrimitive.Overlay className="fixed top-0 bottom-0 left-0 right-0 z-30 bg-black opacity-30" />
{children}
</DialogPrimitive.Root>
);
};
type DialogContentComponent = Polymorphic.ForwardRefComponent<
Polymorphic.IntrinsicElement<typeof DialogPrimitive.Content>,
Polymorphic.OwnProps<typeof DialogPrimitive.Content> & {
containerClass?: string;
showClose?: boolean;
}
>;
export const DialogContent = React.forwardRef(
({ showClose = true, containerClass, children, className, ...props }, forwardedRef) => (
<DialogPrimitive.Content
as="section"
className={classNames('dialog-container', containerClass)}
{...props}
ref={forwardedRef}
>
<div className={classNames('dialog', className)}>
{children}
{showClose && (
<DialogPrimitive.Close className="absolute top-4 right-4 sm:top-7 sm:right-7 p-2 bg-gray-100 rounded-full default-ring">
<svg
className="w-3.5 h-3.5 stroke-current text-gray-500"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M4 4L20 20" strokeWidth="3" strokeLinecap="round" />
<path d="M20 4L4 20" strokeWidth="3" strokeLinecap="round" />
</svg>
</DialogPrimitive.Close>
)}
</div>
</DialogPrimitive.Content>
)
) as DialogContentComponent;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogClose = DialogPrimitive.Close;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { Docket } from '../state/docket-types';
interface DocketHeaderProps {
docket: Docket;
children?: React.ReactNode;
}
export function DocketHeader(props: DocketHeaderProps) {
const { docket, children } = props;
const color = `#${docket.color.slice(2).replace('.', '')}`.toUpperCase();
const { info, title, img } = docket;
return (
<header className="grid grid-cols-[5rem,1fr] md:grid-cols-[8rem,1fr] auto-rows-min grid-flow-row-dense mb-5 sm:mb-8 gap-x-6 gap-y-4">
<div
className="flex-none row-span-1 md:row-span-2 relative w-20 h-20 md:w-32 md:h-32 bg-gray-200 rounded-xl"
style={{ backgroundColor: color }}
>
{img && (
<img
className="absolute top-1/2 left-1/2 h-[40%] w-[40%] object-contain transform -translate-x-1/2 -translate-y-1/2"
src={img}
alt=""
/>
)}
</div>
<div className="col-start-2">
<h1 className="h2">{title}</h1>
{info && <p className="h4 mt-2 text-gray-500">{info}</p>}
</div>
{children}
</header>
);
}

View File

@ -0,0 +1,20 @@
import React, { HTMLAttributes } from 'react';
type ShipNameProps = {
name: string;
} & HTMLAttributes<HTMLSpanElement>;
export const ShipName = ({ name, ...props }: ShipNameProps) => {
const parts = name.replace('~', '').split(/[_^-]/);
return (
<span {...props}>
<span aria-hidden>~</span>
{/* <span className="sr-only">sig</span> */}
<span>{parts[0]}</span>
<span aria-hidden>-</span>
{/* <span className="sr-only">hep</span> */}
<span>{parts[1]}</span>
</span>
);
};

View File

@ -0,0 +1,7 @@
import classNames from 'classnames';
import React from 'react';
import { SpinnerIcon } from './icons/SpinnerIcon';
export const Spinner = ({ className, ...props }: React.HTMLAttributes<SVGSVGElement>) => (
<SpinnerIcon className={classNames('spinner', className)} {...props} />
);

View File

@ -0,0 +1,26 @@
import React from 'react';
import { Treaty } from '../state/docket-types';
import { Attribute } from './Attribute';
const meta = ['license', 'website', 'version'] as const;
export function TreatyMeta(props: { treaty: Treaty }) {
const { treaty } = props;
const { desk, ship, cass } = treaty;
return (
<div className="mt-5 sm:mt-8 space-y-5 sm:space-y-8">
<Attribute title="Developer Desk" attr="desk">
{ship}/{desk}
</Attribute>
<Attribute title="Last Software Update" attr="case">
{JSON.stringify(cass)}
</Attribute>
{meta.map((d) => (
<Attribute key={d} attr={d}>
{treaty[d]}
</Attribute>
))}
</div>
);
}

View File

@ -0,0 +1,11 @@
import React from 'react';
export const Adjust = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 24 24">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 2C5.44772 2 5 2.44772 5 3V10.126C3.27477 10.5701 2 12.1362 2 14C2 15.8638 3.27477 17.4299 5 17.874V21C5 21.5523 5.44772 22 6 22C6.55228 22 7 21.5523 7 21V17.874C8.72523 17.4299 10 15.8638 10 14C10 12.1362 8.72523 10.5701 7 10.126V3C7 2.44772 6.55228 2 6 2ZM18 2C17.4477 2 17 2.44772 17 3V6.12602C15.2748 6.57006 14 8.13616 14 10C14 11.8638 15.2748 13.4299 17 13.874V21C17 21.5523 17.4477 22 18 22C18.5523 22 19 21.5523 19 21V13.874C20.7252 13.4299 22 11.8638 22 10C22 8.13616 20.7252 6.57006 19 6.12602V3C19 2.44772 18.5523 2 18 2ZM6 16C7.10457 16 8 15.1046 8 14C8 12.8954 7.10457 12 6 12C4.89543 12 4 12.8954 4 14C4 15.1046 4.89543 16 6 16Z"
/>
</svg>
);

View File

@ -0,0 +1,7 @@
import React from 'react';
export const Bullet = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 16 16">
<circle cx="8" cy="8" r="3" />
</svg>
);

View File

@ -0,0 +1,11 @@
import React from 'react';
export const Cross = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 12 12">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.292893 10.2942C-0.0976311 10.6847 -0.0976311 11.3179 0.292893 11.7084C0.683417 12.0989 1.31658 12.0989 1.70711 11.7084L6.00063 7.41487L10.2948 11.7091C10.6853 12.0996 11.3185 12.0996 11.709 11.7091C12.0996 11.3185 12.0996 10.6854 11.709 10.2949L7.41484 6.00066L11.7084 1.70711C12.0989 1.31658 12.0989 0.683417 11.7084 0.292894C11.3179 -0.0976312 10.6847 -0.0976312 10.2942 0.292894L6.00063 4.58645L1.70775 0.293571C1.31723 -0.0969534 0.684061 -0.0969534 0.293536 0.293571C-0.0969879 0.684095 -0.0969879 1.31726 0.293536 1.70778L4.58641 6.00066L0.292893 10.2942Z"
/>
</svg>
);

View File

@ -0,0 +1,11 @@
import React from 'react';
export const SpinnerIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="7" className="stroke-current" strokeOpacity="0.4" strokeWidth="8" />
<path
className="fill-current"
d="M23 12C23 13.7359 22.5892 15.4472 21.8011 16.9939C21.013 18.5406 19.87 19.8788 18.4656 20.8992C17.0613 21.9195 15.4353 22.593 13.7208 22.8646C12.0062 23.1361 10.2518 22.998 8.60081 22.4616L11.0482 14.9293C11.5105 15.0795 12.0017 15.1181 12.4818 15.0421C12.9619 14.966 13.4172 14.7775 13.8104 14.4918C14.2036 14.2061 14.5236 13.8314 14.7443 13.3983C14.965 12.9652 15.08 12.4861 15.08 12L23 12Z"
/>
</svg>
);

15
pkg/grid/src/favicon.svg Normal file
View File

@ -0,0 +1,15 @@
<svg width="410" height="404" viewBox="0 0 410 404" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z" fill="url(#paint0_linear)"/>
<path d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z" fill="url(#paint1_linear)"/>
<defs>
<linearGradient id="paint0_linear" x1="6.00017" y1="32.9999" x2="235" y2="344" gradientUnits="userSpaceOnUse">
<stop stop-color="#41D1FF"/>
<stop offset="1" stop-color="#BD34FE"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="194.651" y1="8.81818" x2="236.076" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEA83"/>
<stop offset="0.0833333" stop-color="#FFDD35"/>
<stop offset="1" stop-color="#FFA800"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,28 @@
import clipboardCopy from 'clipboard-copy';
import { useCallback } from 'react';
import { useMutation, useQuery } from 'react-query';
import { useParams } from 'react-router-dom';
import { installDocket, requestTreaty, treatyKey } from '../state/docket';
export function useTreaty() {
const { ship, desk } = useParams<{ ship: string; desk: string }>();
const { data: treaty } = useQuery(treatyKey([ship, desk]), () => requestTreaty(ship, desk));
const { mutate, ...installStatus } = useMutation(() => installDocket(ship, desk));
const copyApp = useCallback(async () => {
clipboardCopy(`${ship}/${desk}`);
}, [ship, desk]);
const installApp = useCallback(() => {
mutate();
}, []);
return {
ship,
desk,
treaty,
installStatus,
installApp,
copyApp
};
}

View File

@ -0,0 +1,37 @@
import { useCallback, useEffect, useState } from 'react';
export function useWaitForProps<P>(props: P, timeout = 0) {
const [mainResolve, setMainResolve] = useState<() => void>(() => () => {});
const [ready, setReady] = useState<(p: P) => boolean | undefined>();
useEffect(() => {
if (typeof ready === 'function' && ready(props)) {
mainResolve();
}
}, [props, ready, mainResolve]);
/**
* Waits until some predicate is true
*
* @param r - Predicate to wait for
* @returns A promise that resolves when `r` returns true, or rejects if the
* waiting times out
*
*/
const waiter = useCallback(
(r: (props: P) => boolean) => {
setReady(() => r);
return new Promise<void>((resolve, reject) => {
setMainResolve(() => resolve);
if (timeout > 0) {
setTimeout(() => {
reject(new Error('Timed out'));
}, timeout);
}
});
},
[setMainResolve, setReady, timeout]
);
return waiter;
}

11
pkg/grid/src/main.tsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { App } from './app';
import './styles/index.css';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('app')
);

20
pkg/grid/src/nav/Help.tsx Normal file
View File

@ -0,0 +1,20 @@
import React, { useEffect } from 'react';
import { useNavStore } from './Nav';
export const Help = () => {
const select = useNavStore((state) => state.select);
useEffect(() => {
select('Help and Support');
}, []);
return (
<div className="p-4 md:p-8 space-y-8">
<h2 className="h4 text-gray-500">Recent Apps</h2>
<div className="min-h-[150px] rounded-xl bg-gray-100" />
<hr className="-mx-4 md:-mx-8" />
<h2 className="h4 text-gray-500">Recent Developers</h2>
<div className="min-h-[150px] rounded-xl bg-gray-100" />
</div>
);
};

256
pkg/grid/src/nav/Nav.tsx Normal file
View File

@ -0,0 +1,256 @@
import { DialogContent } from '@radix-ui/react-dialog';
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 create from 'zustand';
import { Dialog } from '../components/Dialog';
import { Cross } from '../components/icons/Cross';
import { Help } from './Help';
import { Notifications } from './Notifications';
import { Search } from './Search';
import { SystemMenu } from './SystemMenu';
import { SystemPreferences } from './SystemPreferences';
export type MenuState =
| 'closed'
| 'search'
| 'notifications'
| 'help-and-support'
| 'system-preferences';
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' }) => {
const { push } = useHistory();
const location = useLocation();
const inputRef = useRef<HTMLInputElement>(null);
const { searchInput, setSearchInput, selection, select } = useNavStore();
const [systemMenuOpen, setSystemMenuOpen] = useState(false);
const isOpen = menu !== 'closed';
const eitherOpen = isOpen || systemMenuOpen;
const onOpen = useCallback(
(event: Event) => {
event.preventDefault();
if (menu === 'search' && inputRef.current) {
inputRef.current.focus();
}
},
[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'
)}
/>
)}
<Link
to="/leap/notifications"
className="relative z-50 flex-none circle-button bg-blue-400 text-white default-ring"
>
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"
/>
</div>
<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"
>
<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>
</div>
</DialogContent>
</Dialog>
</menu>
);
};

View File

@ -0,0 +1,20 @@
import React, { useEffect } from 'react';
import { useNavStore } from './Nav';
export const Notifications = () => {
const select = useNavStore((state) => state.select);
useEffect(() => {
select('Notifications');
}, []);
return (
<div className="p-4 md:p-8 space-y-8">
<h2 className="h4 text-gray-500">Recent Apps</h2>
<div className="min-h-[150px] rounded-xl bg-gray-100" />
<hr className="-mx-4 md:-mx-8" />
<h2 className="h4 text-gray-500">Recent Developers</h2>
<div className="min-h-[150px] rounded-xl bg-gray-100" />
</div>
);
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import { AppInfo } from './search/AppInfo';
import { Apps } from './search/Apps';
import { Home } from './search/Home';
import { Providers } from './search/Providers';
type SearchProps = RouteComponentProps<{
query?: string;
}>;
export const Search = ({ match }: SearchProps) => {
return (
<Switch>
<Route path={`${match.path}/:ship/apps/:desk`} component={AppInfo} />
<Route path={`${match.path}/:ship/apps`} component={Apps} />
<Route path={`${match.path}/:ship`} component={Providers} />
<Route path={`${match.path}`} component={Home} />
</Switch>
);
};

View File

@ -0,0 +1,89 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import classNames from 'classnames';
import clipboardCopy from 'clipboard-copy';
import React, { HTMLAttributes, useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import { Adjust } from '../components/icons/Adjust';
type SystemMenuProps = HTMLAttributes<HTMLButtonElement> & {
open: boolean;
setOpen: (open: boolean) => void;
showOverlay?: boolean;
};
export const SystemMenu = ({ open, setOpen, className, showOverlay = false }: SystemMenuProps) => {
const [copied, setCopied] = useState(false);
const copyHash = useCallback((event: Event) => {
event.preventDefault();
setCopied(true);
clipboardCopy('fjuhl');
setTimeout(() => {
setCopied(false);
}, 1250);
}, []);
return (
<>
<DropdownMenu.Root open={open} onOpenChange={(isOpen) => setOpen(isOpen)}>
<DropdownMenu.Trigger
className={classNames('circle-button default-ring', open && 'text-gray-300', className)}
>
<Adjust className="w-6 h-6 fill-current" />
<span className="sr-only">System Menu</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content
onCloseAutoFocus={(e) => e.preventDefault()}
sideOffset={12}
className="dropdown min-w-64 p-6 font-semibold text-gray-500 bg-white"
>
<DropdownMenu.Group className="space-y-6">
<DropdownMenu.Item
as={Link}
to="/leap/system-preferences"
className="flex items-center space-x-2 default-ring ring-offset-2 rounded"
onSelect={(e) => {
e.preventDefault();
setTimeout(() => setOpen(false), 0);
}}
>
<span className="w-5 h-5 bg-gray-100 rounded-full" />
<span className="h4">System Preferences</span>
</DropdownMenu.Item>
<DropdownMenu.Item
as={Link}
to="/leap/help-and-support"
className="flex items-center space-x-2 default-ring ring-offset-2 rounded"
onSelect={(e) => {
e.stopPropagation();
e.preventDefault();
setTimeout(() => setOpen(false), 0);
}}
>
<span className="w-5 h-5 bg-gray-100 rounded-full" />
<span className="h4">Help and Support</span>
</DropdownMenu.Item>
<DropdownMenu.Item
as="button"
className="inline-flex items-center py-2 px-3 h4 text-black bg-gray-100 rounded default-ring"
onSelect={copyHash}
>
<span className="sr-only">Base Hash</span>
<code>
{!copied && <span aria-label="f-j-u-h-l">fjuhl</span>}
{copied && 'copied!'}
</code>
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{showOverlay && open && (
<div className="fixed z-30 right-0 bottom-0 w-screen h-screen bg-black opacity-30" />
)}
</>
);
};

View File

@ -0,0 +1,20 @@
import React, { useEffect } from 'react';
import { useNavStore } from './Nav';
export const SystemPreferences = () => {
const select = useNavStore((state) => state.select);
useEffect(() => {
select('System Preferences');
}, []);
return (
<div className="p-4 md:p-8 space-y-8">
<h2 className="h4 text-gray-500">Recent Apps</h2>
<div className="min-h-[150px] rounded-xl bg-gray-100" />
<hr className="-mx-4 md:-mx-8" />
<h2 className="h4 text-gray-500">Recent Developers</h2>
<div className="min-h-[150px] rounded-xl bg-gray-100" />
</div>
);
};

View File

@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import { useQuery } from 'react-query';
import { PillButton } from '../../components/Button';
import { DocketHeader } from '../../components/DocketHeader';
import { ShipName } from '../../components/ShipName';
import { Spinner } from '../../components/Spinner';
import { TreatyMeta } from '../../components/TreatyMeta';
import { useTreaty } from '../../logic/useTreaty';
import { chargesKey, fetchCharges } from '../../state/docket';
import { useNavStore } from '../Nav';
export const AppInfo = () => {
const select = useNavStore((state) => state.select);
const { ship, desk, treaty, installStatus, copyApp, installApp } = useTreaty();
const { data: charges } = useQuery(chargesKey(), fetchCharges);
const installed = (charges || {})[desk] || installStatus.isSuccess;
useEffect(() => {
select(
<>
Apps by <ShipName name={ship} className="font-mono" />: {treaty?.title}
</>
);
}, [treaty?.title]);
if (!treaty) {
// TODO: maybe replace spinner with skeletons
return (
<div className="dialog-inner-container text-black">
<span>Loading...</span>
</div>
);
}
return (
<div className="dialog-inner-container text-black">
<DocketHeader docket={treaty}>
<div className="col-span-2 md:col-span-1 flex items-center space-x-4">
{installed && (
<PillButton as="a" href={`/apps/${treaty.base}`} target={treaty.title || '_blank'}>
Open App
</PillButton>
)}
{!installed && (
<PillButton onClick={installApp}>
{installStatus.isIdle && 'Get App'}
{installStatus.isLoading && (
<>
<Spinner />
<span className="sr-only">Installing...</span>
</>
)}
</PillButton>
)}
<PillButton variant="secondary" onClick={copyApp}>
Copy App Link
</PillButton>
</div>
</DocketHeader>
<hr className="-mx-5 sm:-mx-8" />
<TreatyMeta treaty={treaty} />
</div>
);
};

View File

@ -0,0 +1,79 @@
import React, { useCallback, useEffect } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { Link, RouteComponentProps } from 'react-router-dom';
import slugify from 'slugify';
import { ShipName } from '../../components/ShipName';
import { fetchProviderTreaties, treatyKey } from '../../state/docket';
import { Treaty } from '../../state/docket-types';
import { useNavStore } from '../Nav';
type AppsProps = RouteComponentProps<{ ship: string }>;
export const Apps = ({ match }: AppsProps) => {
const queryClient = useQueryClient();
const { select } = useNavStore();
const provider = match?.params.ship;
const { data } = useQuery(treatyKey([provider]), () => fetchProviderTreaties(provider), {
enabled: !!provider
});
const count = data?.length;
useEffect(() => {
select(
<>
Apps by <ShipName name={provider} className="font-mono" />
</>
);
}, []);
const preloadApp = useCallback(
(app: Treaty) => {
queryClient.setQueryData(treatyKey([provider, app.desk]), app);
},
[queryClient]
);
return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400">
<div id="developed-by">
<h2 className="mb-3">
Software developed by <ShipName name={provider} className="font-mono" />
</h2>
<p>
{count} result{count === 1 ? '' : 's'}
</p>
</div>
{data && (
<ul className="space-y-8" aria-labelledby="developed-by">
{data.map((app) => (
<li key={app.desk}>
<Link
to={`${match?.path.replace(':ship', provider)}/${slugify(app.desk)}`}
className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg"
onClick={() => preloadApp(app)}
>
<div
className="flex-none relative w-12 h-12 bg-gray-200 rounded-lg"
style={{ backgroundColor: app.color }}
>
{app.img && (
<img
className="absolute top-1/2 left-1/2 h-[40%] w-[40%] object-contain transform -translate-x-1/2 -translate-y-1/2"
src={app.img}
alt=""
/>
)}
</div>
<div className="flex-1 text-black">
<p>{app.title}</p>
{app.info && <p className="font-normal">{app.info}</p>}
</div>
</Link>
</li>
))}
</ul>
)}
<p>That&apos;s it!</p>
</div>
);
};

View File

@ -0,0 +1,35 @@
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]);
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>
<div className="min-h-[150px] rounded-xl bg-gray-100" />
<hr className="-mx-4 md:-mx-8" />
<h2 className="h4 text-gray-500">Recent Developers</h2>
<div className="min-h-[150px] rounded-xl bg-gray-100" />
</div>
);
};

View File

@ -0,0 +1,73 @@
import { debounce } from 'lodash-es';
import React, { useCallback, useEffect } from 'react';
import { useQuery } from 'react-query';
import { Link, RouteComponentProps } from 'react-router-dom';
import { ShipName } from '../../components/ShipName';
import { fetchProviders, providersKey } from '../../state/docket';
import { useNavStore } from '../Nav';
type ProvidersProps = RouteComponentProps<{ ship: string }>;
export const Providers = ({ match, history }: ProvidersProps) => {
const { searchInput, select } = useNavStore((state) => ({
searchInput: state.searchInput,
select: state.select
}));
const { push } = history;
const { path } = match;
const provider = match?.params.ship;
const { data } = useQuery(providersKey([provider]), () => fetchProviders(provider), {
enabled: !!provider,
keepPreviousData: true
});
const count = data?.length;
useEffect(() => {
select(null, provider);
}, []);
const handleSearch = useCallback(
debounce((input: string) => {
push(match?.path.replace(':ship', input.trim()));
}, 300),
[path]
);
useEffect(() => {
if (searchInput) {
handleSearch(searchInput);
}
}, [searchInput]);
return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400" aria-live="polite">
<div id="providers">
<h2 className="mb-3">Searching Software Providers</h2>
<p>
{count} result{count === 1 ? '' : 's'}
</p>
</div>
{data && (
<ul className="space-y-8" aria-labelledby="providers">
{data.map((p) => (
<li key={p.shipName}>
<Link
to={`${match?.path.replace(':ship', p.shipName)}/apps`}
className="flex items-center space-x-3 default-ring ring-offset-2 rounded-lg"
>
<div className="flex-none relative w-12 h-12 bg-black rounded-lg">
{/* TODO: Handle sigils */}
</div>
<div className="flex-1 text-black">
<p className="font-mono">{p.nickname || <ShipName name={p.shipName} />}</p>
{p.status && <p className="font-normal">{p.status}</p>}
</div>
</Link>
</li>
))}
</ul>
)}
<p>That&apos;s it!</p>
</div>
);
};

View File

@ -0,0 +1,53 @@
import { map } from 'lodash-es';
import React, { FunctionComponent } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { Route, RouteComponentProps } from 'react-router-dom';
import { MenuState, Nav } from '../nav/Nav';
import { chargesKey, fetchCharges } from '../state/docket';
import { Treaties } from '../state/docket-types';
import { RemoveApp } from '../tiles/RemoveApp';
import { SuspendApp } from '../tiles/SuspendApp';
import { Tile } from '../tiles/Tile';
type GridProps = RouteComponentProps<{
menu?: MenuState;
}>;
export const Grid: FunctionComponent<GridProps> = ({ match }) => {
const queryClient = useQueryClient();
const {
data: charges,
isLoading,
isSuccess
} = useQuery(chargesKey(), fetchCharges, {
onSuccess: (dockets: Treaties) => {
Object.entries(dockets).forEach(([k, v]) => {
queryClient.setQueryData(chargesKey([k]), v);
});
}
});
return (
<div className="flex flex-col">
<header className="sticky top-0 left-0 z-30 flex justify-center w-full bg-white">
<Nav menu={match.params.menu} />
</header>
<main className="h-full w-full flex justify-center pt-24 pb-32 relative z-0">
{isLoading && <span>Loading...</span>}
{isSuccess && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6 px-4 md:px-8 w-full max-w-6xl">
{charges &&
map(charges, (charge, desk) => <Tile key={desk} docket={charge} desk={desk} />)}
</div>
)}
<Route exact path="/app/:desk/suspend">
<SuspendApp />
</Route>
<Route exact path="/app/:desk/remove">
<RemoveApp />
</Route>
</main>
</div>
);
};

20
pkg/grid/src/state/api.ts Normal file
View File

@ -0,0 +1,20 @@
import Urbit from '@urbit/http-api';
declare global {
interface Window {
ship: string;
}
}
const api =
import.meta.env.MODE === 'mock'
? {
poke: () => {},
subscribe: () => {},
subscribeOnce: () => {},
ship: ''
}
: new Urbit('', '');
api.ship = window.ship;
export default api;

View File

@ -0,0 +1,36 @@
export type DeskStatus = 'active' | 'suspended';
export interface Docket {
title: string;
info?: string;
color: string;
base: string;
website: string;
license: string;
version: string;
// yet to be implemented
img?: string;
status: DeskStatus;
}
export interface Treaty extends Docket {
ship: string;
desk: string;
cass: string;
hash: string;
}
export interface Dockets {
[ref: string]: Docket;
}
export interface Treaties {
[ref: string]: Treaty;
}
export interface Provider {
shipName: string;
nickname?: string;
status?: string;
}

View File

@ -0,0 +1,181 @@
import fuzzy from 'fuzzy';
import { omit } from 'lodash-es';
import { queryClient } from '../app';
import api from './api';
import { Treaty, Dockets, Docket, Provider } from './docket-types';
import { providers, treaties } from './mock-data';
const useMockData = import.meta.env.MODE === 'mock';
function makeKeyFn(key: string) {
return (childKeys: string[] = []) => {
return [key].concat(childKeys);
};
}
export const chargesKey = makeKeyFn('charges');
export const providersKey = makeKeyFn('providers');
export const treatyKey = makeKeyFn('treaty');
async function fakeRequest<T>(data: T, time = 300): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(data);
}, time);
});
}
const stableTreatyMap = new Map<string, Treaty[]>();
interface ChargesResponse {
initial: Dockets;
}
export async function fetchCharges(): Promise<Dockets> {
const charges = queryClient.getQueryData<Dockets>(chargesKey());
if (useMockData && charges) {
return charges;
}
const dockets = useMockData
? await fakeRequest(treaties)
: ((await (await fetch('/~/scry/docket/charges.json')).json()) as ChargesResponse).initial;
return Object.entries(dockets).reduce((obj: Dockets, [key, value]) => {
// eslint-disable-next-line no-param-reassign
obj[key] = normalizeDocket(value);
return obj;
}, {});
}
export async function fetchProviders(query?: string): Promise<Provider[]> {
const searchTexts = providers.map((p) => p.shipName + (p.nickname || ''));
return fakeRequest(fuzzy.filter(query || '', searchTexts).map((el) => providers[el.index]));
}
export async function fetchProviderTreaties(provider: string): Promise<Treaty[]> {
const treatyList = Object.values(treaties).map(normalizeDocket);
if (!stableTreatyMap.has(provider)) {
stableTreatyMap.set(
provider,
treatyList.filter(() => !!Math.round(Math.random()))
);
}
return fakeRequest(stableTreatyMap.get(provider) || []);
}
export async function requestTreaty(ship: string, desk: string): Promise<Treaty> {
if (useMockData) {
return fakeRequest(treaties[desk]);
}
const key = `${ship}/${desk}`;
const result = await api.subscribeOnce('docket', `/treaty/${key}`, 20000);
return { ...normalizeDocket(result), ship, desk };
}
export async function installDocket(ship: string, desk: string): Promise<number | void> {
if (useMockData) {
const docket = normalizeDocket(await requestTreaty(ship, desk));
const charges = await queryClient.fetchQuery(chargesKey(), fetchCharges);
addCharge(charges, { desk, docket });
}
return api.poke({
app: 'hood',
mark: 'kiln-install',
json: {
ship,
desk,
local: desk
}
});
}
export async function uninstallDocket(desk: string): Promise<number | void> {
if (useMockData) {
const charges = await queryClient.fetchQuery(chargesKey(), fetchCharges);
delCharge(charges, desk);
}
return api.poke({
app: 'docket',
mark: 'docket-uninstall',
json: desk
});
}
export async function toggleDocket(desk: string): Promise<void> {
const charges = await queryClient.fetchQuery(chargesKey(), fetchCharges);
const docket = (charges || {})[desk];
docket.status = docket.status === 'active' ? 'suspended' : 'active';
}
function normalizeDocket<T extends Docket>(docket: T): T {
return {
...docket,
status: docket.status || 'active',
color: `#${docket.color.slice(2).replace('.', '')}`.toUpperCase()
};
}
interface AddDockEvent {
'add-dock': {
desk: string;
docket: Docket;
};
}
interface DelDockEvent {
'del-dock': string;
}
type DocketEvent = AddDockEvent | DelDockEvent;
function addCharge(charges: Dockets | undefined, { desk, docket }: AddDockEvent['add-dock']) {
queryClient.setQueryData(chargesKey(), { ...charges, [desk]: docket });
}
function delCharge(charges: Dockets | undefined, desk: DelDockEvent['del-dock']) {
queryClient.setQueryData(chargesKey(), omit(charges, desk));
}
api.subscribe({
app: 'docket',
path: '/charges',
event: (data: DocketEvent): void => {
const charges = queryClient.getQueryData<Dockets>(chargesKey());
console.log(data);
if ('add-dock' in data) {
addCharge(charges, data['add-dock']);
}
if ('del-dock' in data) {
delCharge(charges, data['del-dock']);
}
}
});
// const selCharges = (s: DocketState) => {
// return omit(s.charges, "grid");
// };
// export function useCharges() {
// return useDocketState(selCharges);
// }
// export function useCharge(desk: string) {
// return useDocketState(useCallback(state => state.charges[desk], [desk]));
// }
// const selRequest = (s: DocketState) => s.request;
// export function useRequestDocket() {
// return useDocketState(selRequest);
// }
// export default useDocketState;

View File

@ -0,0 +1,168 @@
import systemUrl from '../assets/system.png';
import goUrl from '../assets/go.png';
import { Provider, Treaties, Treaty } from './docket-types';
export const providers: Provider[] = [
{
shipName: '~zod',
nickname: 'Tlon Corporation'
},
{
shipName: '~nocsyx-lassul',
status: 'technomancing an electron wrapper for urbit'
},
{
shipName: '~nachus-hollyn'
},
{
shipName: '~nalbel_litzod',
status: 'congratulations'
},
{
shipName: '~litmus^ritten'
},
{
shipName: '~nalput_litzod',
status: 'Queen'
},
{
shipName: '~nalrex_bannus',
status: 'Script, command and inspect your Urbit. Use TUI applications'
},
{
shipName: '~nalrys',
status: 'hosting coming soon'
}
];
export const appMetaData: Pick<Treaty, 'cass' | 'hash' | 'website' | 'license' | 'version'> = {
cass: '~2021.8.11..05.11.10..b721',
hash: '0v6.nj6ls.l7unh.l9bhk.d839n.n8nlq.m2dmc.fj80i.pvqun.uhg6g.1kk0h',
website: 'https://tlon.io',
license: 'MIT',
version: '2.0.1'
};
export const treaties: Treaties = {
groups: {
ship: '~zod',
desk: 'groups',
title: 'Groups',
info: 'Simple Software for Community Assembly',
status: 'active',
base: 'groups',
color: '##CDE7EF',
...appMetaData
},
messages: {
title: 'Messages',
ship: '~zod',
desk: 'messages',
status: 'active',
base: 'messages',
info: 'A lengthier description of the app down here',
color: '##8BE789',
...appMetaData
},
calls: {
title: 'Calls',
ship: '~zod',
desk: 'calls',
status: 'active',
base: 'calls',
info: 'A lengthier description of the app down here',
color: '##C2D6BE',
...appMetaData
},
'bitcoin-wallet': {
title: 'Bitcoin Wallet',
ship: '~zod',
desk: 'bitcoin-wallet',
status: 'active',
base: 'bitcoin-wallet',
info: 'A lengthier description of the app down here',
color: '##F0AE70',
...appMetaData
},
system: {
title: 'System',
ship: '~zod',
desk: 'system',
status: 'active',
base: 'system',
info: 'A lengthier description of the app down here',
color: '##2D0118',
img: systemUrl,
...appMetaData
},
'my-apps': {
title: 'My Apps',
ship: '~zod',
desk: 'groups',
status: 'active',
base: 'my-apps',
info: 'A lengthier description of the app down here',
color: '##D8B14E',
...appMetaData
},
go: {
title: 'Go',
ship: '~zod',
desk: 'go',
status: 'active',
base: 'go',
info: 'A lengthier description of the app down here',
color: '##A58E52',
img: goUrl,
...appMetaData
},
terminal: {
title: 'Terminal',
ship: '~zod',
desk: 'terminal',
status: 'active',
base: 'terminal',
info: 'A lengthier description of the app down here',
color: '##2D382B',
...appMetaData
},
pomodoro: {
title: 'Pomodoro',
ship: '~zod',
desk: 'pomodoro',
status: 'active',
base: 'pomodoro',
info: 'A lengthier description of the app down here',
color: '##EE5432',
...appMetaData
},
clocks: {
title: 'Clocks',
ship: '~zod',
desk: 'clocks',
status: 'active',
base: 'clocks',
info: 'A lengthier description of the app down here',
color: '##DCDCDC',
...appMetaData
},
uniswap: {
title: 'Uniswap',
ship: '~zod',
desk: 'uniswap',
status: 'active',
base: 'uniswap',
info: 'A lengthier description of the app down here',
color: '##FDA1FF',
...appMetaData
},
inbox: {
title: 'Inbox',
ship: '~zod',
desk: 'inbox',
status: 'active',
base: 'inbox',
color: '##FEFFBA',
...appMetaData
}
};

View File

@ -0,0 +1,19 @@
.h1 {
@apply text-2xl sm:text-3xl font-bold leading-relaxed tracking-tighter;
}
.h2 {
@apply text-xl sm:text-2xl font-semibold leading-snug tracking-tight;
}
.h3 {
@apply text-lg sm:text-xl font-semibold leading-relaxed tracking-tight;
}
.h4 {
@apply text-sm sm:text-base font-semibold leading-normal tracking-tight;
}
.default-ring {
@apply focus:ring-4 ring-blue-400 ring-opacity-80 focus:outline-none;
}

View File

@ -0,0 +1,27 @@
.circle-button {
@apply inline-flex items-center justify-center h-12 w-12 font-semibold rounded-full;
}
.button {
@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;
}
.dialog {
@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;
}
.dropdown {
@apply min-w-52 p-4 space-y-4 rounded-xl;
}
.spinner {
@apply inline-flex items-center w-6 h-6 animate-spin;
}

View File

@ -0,0 +1,8 @@
@import "tailwindcss/base";
@import "./base.css";
@import "tailwindcss/components";
@import "./components.css";
@import "tailwindcss/utilities";
@import "./utilities.css";

View File

View File

@ -0,0 +1,39 @@
import React, { useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { useHistory, useParams } from 'react-router-dom';
import { Button } from '../components/Button';
import { Dialog, DialogContent } from '../components/Dialog';
import { chargesKey, uninstallDocket } from '../state/docket';
import { Docket } from '../state/docket-types';
export const RemoveApp = () => {
const queryClient = useQueryClient();
const history = useHistory();
const { desk } = useParams<{ desk: string }>();
const { data: docket } = useQuery<Docket>(chargesKey([desk]));
const { mutate } = useMutation(() => uninstallDocket(desk), {
onSuccess: () => {
history.push('/');
queryClient.invalidateQueries(chargesKey());
}
});
// TODO: add optimistic updates
const handleRemoveApp = useCallback(() => {
mutate();
}, []);
return (
<Dialog open onOpenChange={(open) => !open && history.push('/')}>
<DialogContent>
<h1 className="h4 mb-9">Remove &ldquo;{docket?.title || ''}&rdquo;</h1>
<p className="text-base tracking-tight mb-4 pr-6">
Explanatory writing about what data will be kept.
</p>
<Button variant="destructive" onClick={handleRemoveApp}>
Remove
</Button>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,41 @@
import React, { useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { Redirect, useHistory, useParams } from 'react-router-dom';
import { Button } from '../components/Button';
import { Dialog, DialogContent } from '../components/Dialog';
import { chargesKey, toggleDocket } from '../state/docket';
import { Docket } from '../state/docket-types';
export const SuspendApp = () => {
const queryClient = useQueryClient();
const history = useHistory();
const { desk } = useParams<{ desk: string }>();
const { data: docket } = useQuery<Docket>(chargesKey([desk]));
const { mutate } = useMutation(() => toggleDocket(desk), {
onSuccess: () => {
history.push('/');
queryClient.invalidateQueries(chargesKey());
}
});
// TODO: add optimistic updates
const handleSuspendApp = useCallback(() => mutate(), []);
if (docket?.status === 'suspended') {
<Redirect to="/" />;
}
return (
<Dialog open onOpenChange={(open) => !open && history.push('/')}>
<DialogContent>
<h1 className="h4 mb-9">Suspend &ldquo;{docket?.title || ''}&rdquo;</h1>
<p className="text-base tracking-tight mb-4 pr-6">
Suspending an app will freeze its current state, and render it unable
</p>
<Button variant="destructive" onClick={handleSuspendApp}>
Suspend
</Button>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,69 @@
import classNames from 'classnames';
import React, { FunctionComponent } from 'react';
import { darken, hsla, lighten, parseToHsla, readableColorIsBlack } from 'color2k';
import { TileMenu } from './TileMenu';
import { Docket } from '../state/docket-types';
type TileProps = {
docket: Docket;
desk: string;
};
function getMenuColor(color: string, lightText: boolean, active: boolean): string {
const hslaColor = parseToHsla(color);
const satAdjustedColor = hsla(
hslaColor[0],
active ? Math.max(0.2, hslaColor[1]) : 0,
hslaColor[2],
1
);
return lightText ? lighten(satAdjustedColor, 0.1) : darken(satAdjustedColor, 0.1);
}
export const Tile: FunctionComponent<TileProps> = ({ docket, desk }) => {
const { title, base, color, img, status } = docket;
const active = status === 'active';
const lightText = !readableColorIsBlack(color);
const menuColor = getMenuColor(color, lightText, active);
const suspendColor = 'rgb(220,220,220)';
return (
<a
href={active ? `/apps/${base}/` : undefined}
target={base}
className={classNames(
'group relative font-semibold aspect-w-1 aspect-h-1 rounded-xl default-ring',
!active && 'cursor-default'
)}
style={{ backgroundColor: active ? color || 'purple' : suspendColor }}
>
<div>
<TileMenu
desk={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"
/>
<div className="h4 absolute bottom-4 left-4 lg:bottom-8 lg:left-8">
<h3
className={`${
lightText && active ? 'text-gray-200' : 'text-gray-800'
} mix-blend-hard-light`}
>
{title}
</h3>
{!active && <span className="text-gray-400">Suspended</span>}
</div>
{img && (
<img
className="absolute top-1/2 left-1/2 h-[40%] w-[40%] object-contain transform -translate-x-1/2 -translate-y-1/2"
src={img}
alt=""
/>
)}
</div>
</a>
);
};

View File

@ -0,0 +1,128 @@
import React, { 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';
import { Link } from 'react-router-dom';
import { useMutation, useQueryClient } from 'react-query';
import { chargesKey, toggleDocket } from '../state/docket';
export interface TileMenuProps {
desk: string;
lightText: boolean;
menuColor: string;
active: boolean;
className?: string;
}
const MenuIcon = ({ className }: { className: string }) => (
<svg className={classNames('fill-current', className)} viewBox="0 0 16 16">
<path fillRule="evenodd" clipRule="evenodd" d="M14 8.5H2V7.5H14V8.5Z" />
<path fillRule="evenodd" clipRule="evenodd" d="M2 2.5H14V3.5H2V2.5Z" />
<path fillRule="evenodd" clipRule="evenodd" d="M14 13.5H2V12.5H14V13.5Z" />
</svg>
);
type ItemComponent = Polymorphic.ForwardRefComponent<
Polymorphic.IntrinsicElement<typeof DropdownMenu.Item>,
Polymorphic.OwnProps<typeof DropdownMenu.Item>
>;
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"
>
{children}
</DropdownMenu.Item>
)) as ItemComponent;
export const TileMenu = ({ desk, active, menuColor, lightText, className }: TileMenuProps) => {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const { mutate } = useMutation(() => toggleDocket(desk), {
onSuccess: () => {
queryClient.invalidateQueries(chargesKey());
}
});
const menuBg = { backgroundColor: menuColor };
return (
<DropdownMenu.Root open={open} onOpenChange={(isOpen) => setOpen(isOpen)}>
<DropdownMenu.Trigger
className={classNames(
'flex items-center justify-center w-8 h-8 rounded-full transition-opacity duration-75 default-ring',
open && 'opacity-100',
className
)}
style={menuBg}
// onMouseOver={() => queryClient.setQueryData(['apps', name], app)}
>
<MenuIcon
className={classNames('w-4 h-4 mix-blend-hard-light', lightText && 'text-gray-100')}
/>
<span className="sr-only">Menu</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content
align="start"
alignOffset={-32}
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
className={classNames(
'dropdown font-semibold',
lightText ? 'text-gray-100' : 'text-gray-800'
)}
style={menuBg}
>
<DropdownMenu.Group className="space-y-4">
{/*
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);
}}
>
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">
{active && (
<Item
as={Link}
to={`/app/${desk}/suspend`}
onSelect={(e) => {
e.preventDefault();
setTimeout(() => setOpen(false), 0);
}}
>
Suspend App
</Item>
)}
{!active && <Item onSelect={() => mutate()}>Resume App</Item>}
<Item
as={Link}
to={`/app/${desk}/remove`}
onSelect={(e) => {
e.preventDefault();
setTimeout(() => setOpen(false), 0);
}}
>
Remove App
</Item>
</DropdownMenu.Group>
<DropdownMenu.Arrow
className="w-4 h-[10px] fill-current -translate-x-10"
style={{ color: menuColor }}
/>
</DropdownMenu.Content>
</DropdownMenu.Root>
);
};

1
pkg/grid/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,74 @@
const colors = require('tailwindcss/colors');
module.exports = {
mode: 'jit',
purge: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
colors: {
transparent: "transparent",
white: "#FFFFFF",
black: "#000000",
gray: {
...colors.trueGray,
100: "#F2F2F2",
200: "#CCCCCC",
300: "#B3B3B3",
400: "#808080",
500: "#666666",
},
blue: {
100: "#E9F5FF",
200: "#D3EBFF",
300: "#BCE2FF",
400: "#219DFF",
},
red: {
100: "#FFF6F5",
200: "#FFC6C3",
400: "#FF4136",
},
green: {
100: "#E6F5F0",
200: "#B3E2D1",
400: "#009F65",
},
yellow: {
100: "#FFF9E6",
200: "#FFEEB3",
300: "#FFDD66",
400: "#FFC700",
},
},
fontFamily: {
sans: [
'"Inter"',
'"Inter UI"',
"-apple-system",
"BlinkMacSystemFont",
'"San Francisco"',
'"Helvetica Neue"',
"Arial",
"sans-serif",
],
mono: [
'"Source Code Pro"',
'"Roboto mono"',
'"Courier New"',
"monospace",
],
},
minWidth: theme => theme('spacing'),
},
},
variants: {
extend: {
opacity: ['hover-none']
},
},
plugins: [
require('@tailwindcss/aspect-ratio'),
require('tailwindcss-touch')()
],
}

19
pkg/grid/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"include": ["./src"]
}

33
pkg/grid/vite.config.ts Normal file
View File

@ -0,0 +1,33 @@
import { defineConfig } from 'vite';
import analyze from 'rollup-plugin-analyzer';
import { visualizer } from 'rollup-plugin-visualizer';
import reactRefresh from '@vitejs/plugin-react-refresh';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
base: mode === 'mock' ? undefined : '/apps/grid/',
server:
mode === 'mock'
? undefined
: {
proxy: {
'^((?!/apps/grid).)*$': {
target: 'http://localhost:8080'
}
}
},
build:
mode !== 'profile'
? undefined
: {
rollupOptions: {
plugins: [
analyze({
limit: 20
}),
visualizer()
]
}
},
plugins: [reactRefresh()]
}));