diff --git a/lerna.json b/lerna.json index 890a4248b..abd412c3f 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,8 @@ "packages": [ "pkg/npm/*", "pkg/btc-wallet", - "pkg/interface" + "pkg/interface", + "pkg/grid" ], "version": "independent" } diff --git a/package-lock.json b/package-lock.json index 3dd0f6f30..464f89438 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index 71ac8adf8..76e3e4a5c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pkg/grid/.eslintignore b/pkg/grid/.eslintignore new file mode 100644 index 000000000..6a0844dd2 --- /dev/null +++ b/pkg/grid/.eslintignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +bin/ +.vscode/ +.husky/ +*.config.js +*.config.ts \ No newline at end of file diff --git a/pkg/grid/.eslintrc.js b/pkg/grid/.eslintrc.js new file mode 100644 index 000000000..37ca88bb1 --- /dev/null +++ b/pkg/grid/.eslintrc.js @@ -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 + } + } + } +}; diff --git a/pkg/grid/.gitignore b/pkg/grid/.gitignore new file mode 100644 index 000000000..41404eb26 --- /dev/null +++ b/pkg/grid/.gitignore @@ -0,0 +1,7 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +stats.html +.eslintcache \ No newline at end of file diff --git a/pkg/grid/.husky/.gitignore b/pkg/grid/.husky/.gitignore new file mode 100644 index 000000000..31354ec13 --- /dev/null +++ b/pkg/grid/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/pkg/grid/.husky/pre-commit b/pkg/grid/.husky/pre-commit new file mode 100755 index 000000000..837d8ad5a --- /dev/null +++ b/pkg/grid/.husky/pre-commit @@ -0,0 +1,6 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +cd pkg/grid +npm test +npx lint-staged diff --git a/pkg/grid/.prettierrc b/pkg/grid/.prettierrc new file mode 100644 index 000000000..97fe3fe64 --- /dev/null +++ b/pkg/grid/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "semi": true, + "printWidth": 100, + "trailingComma": "none" +} diff --git a/pkg/grid/index.html b/pkg/grid/index.html new file mode 100644 index 000000000..3d2721deb --- /dev/null +++ b/pkg/grid/index.html @@ -0,0 +1,16 @@ + + + + + + + Landscape • Home + + + + + +
+ + + diff --git a/pkg/grid/package-lock.json b/pkg/grid/package-lock.json new file mode 100644 index 000000000..dae714ee3 Binary files /dev/null and b/pkg/grid/package-lock.json differ diff --git a/pkg/grid/package.json b/pkg/grid/package.json new file mode 100644 index 000000000..87e5f5f73 --- /dev/null +++ b/pkg/grid/package.json @@ -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" + } +} diff --git a/pkg/grid/postcss.config.js b/pkg/grid/postcss.config.js new file mode 100644 index 000000000..44f2e62f6 --- /dev/null +++ b/pkg/grid/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: [ + require('postcss-import'), + require('tailwindcss'), + require('autoprefixer'), + ] +} diff --git a/pkg/grid/src/app.tsx b/pkg/grid/src/app.tsx new file mode 100644 index 000000000..3025806ad --- /dev/null +++ b/pkg/grid/src/app.tsx @@ -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 ( + + + + ); +}; + +export function App() { + const base = import.meta.env.MODE === 'mock' ? undefined : '/apps/grid'; + + return ( + + + + + + ); +} diff --git a/pkg/grid/src/assets/go.png b/pkg/grid/src/assets/go.png new file mode 100644 index 000000000..77bd1f958 Binary files /dev/null and b/pkg/grid/src/assets/go.png differ diff --git a/pkg/grid/src/assets/system.png b/pkg/grid/src/assets/system.png new file mode 100644 index 000000000..09f6a4e60 Binary files /dev/null and b/pkg/grid/src/assets/system.png differ diff --git a/pkg/grid/src/components/Attribute.tsx b/pkg/grid/src/components/Attribute.tsx new file mode 100644 index 000000000..1b31fe74e --- /dev/null +++ b/pkg/grid/src/components/Attribute.tsx @@ -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) => ( +
+

{title || capitalize(attr)}

+

{children}

+
+); diff --git a/pkg/grid/src/components/Button.tsx b/pkg/grid/src/components/Button.tsx new file mode 100644 index 000000000..e8f6c88eb --- /dev/null +++ b/pkg/grid/src/components/Button.tsx @@ -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 = { + 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 ( + + {children} + + ); + } +) as PolymorphicButton; + +export const PillButton = React.forwardRef(({ className, children, ...props }, ref) => ( + +)) as PolymorphicButton; diff --git a/pkg/grid/src/components/Dialog.tsx b/pkg/grid/src/components/Dialog.tsx new file mode 100644 index 000000000..6144a7822 --- /dev/null +++ b/pkg/grid/src/components/Dialog.tsx @@ -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 = ({ children, ...props }) => { + return ( + + + {children} + + ); +}; + +type DialogContentComponent = Polymorphic.ForwardRefComponent< + Polymorphic.IntrinsicElement, + Polymorphic.OwnProps & { + containerClass?: string; + showClose?: boolean; + } +>; + +export const DialogContent = React.forwardRef( + ({ showClose = true, containerClass, children, className, ...props }, forwardedRef) => ( + +
+ {children} + {showClose && ( + + + + + + + )} +
+
+ ) +) as DialogContentComponent; + +export const DialogTrigger = DialogPrimitive.Trigger; +export const DialogClose = DialogPrimitive.Close; diff --git a/pkg/grid/src/components/DocketHeader.tsx b/pkg/grid/src/components/DocketHeader.tsx new file mode 100644 index 000000000..1b70605e3 --- /dev/null +++ b/pkg/grid/src/components/DocketHeader.tsx @@ -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 ( +
+
+ {img && ( + + )} +
+
+

{title}

+ {info &&

{info}

} +
+ {children} +
+ ); +} diff --git a/pkg/grid/src/components/ShipName.tsx b/pkg/grid/src/components/ShipName.tsx new file mode 100644 index 000000000..99c2a3feb --- /dev/null +++ b/pkg/grid/src/components/ShipName.tsx @@ -0,0 +1,20 @@ +import React, { HTMLAttributes } from 'react'; + +type ShipNameProps = { + name: string; +} & HTMLAttributes; + +export const ShipName = ({ name, ...props }: ShipNameProps) => { + const parts = name.replace('~', '').split(/[_^-]/); + + return ( + + ~ + {/* sig */} + {parts[0]} + - + {/* hep */} + {parts[1]} + + ); +}; diff --git a/pkg/grid/src/components/Spinner.tsx b/pkg/grid/src/components/Spinner.tsx new file mode 100644 index 000000000..5e7579a1a --- /dev/null +++ b/pkg/grid/src/components/Spinner.tsx @@ -0,0 +1,7 @@ +import classNames from 'classnames'; +import React from 'react'; +import { SpinnerIcon } from './icons/SpinnerIcon'; + +export const Spinner = ({ className, ...props }: React.HTMLAttributes) => ( + +); diff --git a/pkg/grid/src/components/TreatyMeta.tsx b/pkg/grid/src/components/TreatyMeta.tsx new file mode 100644 index 000000000..a9c0d5bbd --- /dev/null +++ b/pkg/grid/src/components/TreatyMeta.tsx @@ -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 ( +
+ + {ship}/{desk} + + + {JSON.stringify(cass)} + + {meta.map((d) => ( + + {treaty[d]} + + ))} +
+ ); +} diff --git a/pkg/grid/src/components/icons/Adjust.tsx b/pkg/grid/src/components/icons/Adjust.tsx new file mode 100644 index 000000000..d9aef173a --- /dev/null +++ b/pkg/grid/src/components/icons/Adjust.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export const Adjust = (props: React.SVGProps) => ( + + + +); diff --git a/pkg/grid/src/components/icons/Bullet.tsx b/pkg/grid/src/components/icons/Bullet.tsx new file mode 100644 index 000000000..700128256 --- /dev/null +++ b/pkg/grid/src/components/icons/Bullet.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +export const Bullet = (props: React.SVGProps) => ( + + + +); diff --git a/pkg/grid/src/components/icons/Cross.tsx b/pkg/grid/src/components/icons/Cross.tsx new file mode 100644 index 000000000..70c41ddb2 --- /dev/null +++ b/pkg/grid/src/components/icons/Cross.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export const Cross = (props: React.SVGProps) => ( + + + +); diff --git a/pkg/grid/src/components/icons/SpinnerIcon.tsx b/pkg/grid/src/components/icons/SpinnerIcon.tsx new file mode 100644 index 000000000..405b2821d --- /dev/null +++ b/pkg/grid/src/components/icons/SpinnerIcon.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +export const SpinnerIcon = (props: React.SVGProps) => ( + + + + +); diff --git a/pkg/grid/src/favicon.svg b/pkg/grid/src/favicon.svg new file mode 100644 index 000000000..de4aeddc1 --- /dev/null +++ b/pkg/grid/src/favicon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/pkg/grid/src/logic/useTreaty.ts b/pkg/grid/src/logic/useTreaty.ts new file mode 100644 index 000000000..69f2428bf --- /dev/null +++ b/pkg/grid/src/logic/useTreaty.ts @@ -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 + }; +} diff --git a/pkg/grid/src/logic/useWaitForProps.ts b/pkg/grid/src/logic/useWaitForProps.ts new file mode 100644 index 000000000..278bb57b5 --- /dev/null +++ b/pkg/grid/src/logic/useWaitForProps.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from 'react'; + +export function useWaitForProps

(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((resolve, reject) => { + setMainResolve(() => resolve); + if (timeout > 0) { + setTimeout(() => { + reject(new Error('Timed out')); + }, timeout); + } + }); + }, + [setMainResolve, setReady, timeout] + ); + + return waiter; +} diff --git a/pkg/grid/src/main.tsx b/pkg/grid/src/main.tsx new file mode 100644 index 000000000..ed91dc168 --- /dev/null +++ b/pkg/grid/src/main.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { App } from './app'; +import './styles/index.css'; + +ReactDOM.render( + + + , + document.getElementById('app') +); diff --git a/pkg/grid/src/nav/Help.tsx b/pkg/grid/src/nav/Help.tsx new file mode 100644 index 000000000..f340e7656 --- /dev/null +++ b/pkg/grid/src/nav/Help.tsx @@ -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 ( +

+

Recent Apps

+
+
+

Recent Developers

+
+
+ ); +}; diff --git a/pkg/grid/src/nav/Nav.tsx b/pkg/grid/src/nav/Nav.tsx new file mode 100644 index 000000000..94bf52f21 --- /dev/null +++ b/pkg/grid/src/nav/Nav.tsx @@ -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((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 = ({ menu = 'closed' }) => { + const { push } = useHistory(); + const location = useLocation(); + const inputRef = useRef(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) => { + 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) => { + // refocusing tab with input focused is false trigger + const windowFocus = e.nativeEvent.currentTarget === document.body; + if (windowFocus) { + return; + } + + toggleSearch(); + }, []); + + const onChange = useCallback((e: ChangeEvent) => { + const input = e.target as HTMLInputElement; + const value = input.value.trim(); + setSearchInput(value); + }, []); + + return ( + +
+ {!isOpen && ( + + )} + + 3 + + +
+ + + +
+
+ + + 3 + +
+ + + {(selection || searchInput) && ( + select(null)} + > + + Close + + )} +
+
+
+ + + + + + +
+
+
+
+
+ ); +}; diff --git a/pkg/grid/src/nav/Notifications.tsx b/pkg/grid/src/nav/Notifications.tsx new file mode 100644 index 000000000..35304e383 --- /dev/null +++ b/pkg/grid/src/nav/Notifications.tsx @@ -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 ( +
+

Recent Apps

+
+
+

Recent Developers

+
+
+ ); +}; diff --git a/pkg/grid/src/nav/Search.tsx b/pkg/grid/src/nav/Search.tsx new file mode 100644 index 000000000..b8890605d --- /dev/null +++ b/pkg/grid/src/nav/Search.tsx @@ -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 ( + + + + + + + ); +}; diff --git a/pkg/grid/src/nav/SystemMenu.tsx b/pkg/grid/src/nav/SystemMenu.tsx new file mode 100644 index 000000000..1bee8dc8e --- /dev/null +++ b/pkg/grid/src/nav/SystemMenu.tsx @@ -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 & { + 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 ( + <> + setOpen(isOpen)}> + + + System Menu + + + e.preventDefault()} + sideOffset={12} + className="dropdown min-w-64 p-6 font-semibold text-gray-500 bg-white" + > + + { + e.preventDefault(); + setTimeout(() => setOpen(false), 0); + }} + > + + System Preferences + + { + e.stopPropagation(); + e.preventDefault(); + setTimeout(() => setOpen(false), 0); + }} + > + + Help and Support + + + Base Hash + + {!copied && fjuhl} + {copied && 'copied!'} + + + + + + + {showOverlay && open && ( +
+ )} + + ); +}; diff --git a/pkg/grid/src/nav/SystemPreferences.tsx b/pkg/grid/src/nav/SystemPreferences.tsx new file mode 100644 index 000000000..2eaa65618 --- /dev/null +++ b/pkg/grid/src/nav/SystemPreferences.tsx @@ -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 ( +
+

Recent Apps

+
+
+

Recent Developers

+
+
+ ); +}; diff --git a/pkg/grid/src/nav/search/AppInfo.tsx b/pkg/grid/src/nav/search/AppInfo.tsx new file mode 100644 index 000000000..d99690ed8 --- /dev/null +++ b/pkg/grid/src/nav/search/AppInfo.tsx @@ -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 : {treaty?.title} + + ); + }, [treaty?.title]); + + if (!treaty) { + // TODO: maybe replace spinner with skeletons + return ( +
+ Loading... +
+ ); + } + + return ( +
+ +
+ {installed && ( + + Open App + + )} + {!installed && ( + + {installStatus.isIdle && 'Get App'} + {installStatus.isLoading && ( + <> + + Installing... + + )} + + )} + + Copy App Link + +
+
+
+ +
+ ); +}; diff --git a/pkg/grid/src/nav/search/Apps.tsx b/pkg/grid/src/nav/search/Apps.tsx new file mode 100644 index 000000000..198ff3039 --- /dev/null +++ b/pkg/grid/src/nav/search/Apps.tsx @@ -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 + + ); + }, []); + + const preloadApp = useCallback( + (app: Treaty) => { + queryClient.setQueryData(treatyKey([provider, app.desk]), app); + }, + [queryClient] + ); + + return ( +
+
+

+ Software developed by +

+

+ {count} result{count === 1 ? '' : 's'} +

+
+ {data && ( +
    + {data.map((app) => ( +
  • + preloadApp(app)} + > +
    + {app.img && ( + + )} +
    +
    +

    {app.title}

    + {app.info &&

    {app.info}

    } +
    + +
  • + ))} +
+ )} +

That's it!

+
+ ); +}; diff --git a/pkg/grid/src/nav/search/Home.tsx b/pkg/grid/src/nav/search/Home.tsx new file mode 100644 index 000000000..03b191004 --- /dev/null +++ b/pkg/grid/src/nav/search/Home.tsx @@ -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 ( +
+

Recent Apps

+
+
+

Recent Developers

+
+
+ ); +}; diff --git a/pkg/grid/src/nav/search/Providers.tsx b/pkg/grid/src/nav/search/Providers.tsx new file mode 100644 index 000000000..48fe30472 --- /dev/null +++ b/pkg/grid/src/nav/search/Providers.tsx @@ -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 ( +
+
+

Searching Software Providers

+

+ {count} result{count === 1 ? '' : 's'} +

+
+ {data && ( +
    + {data.map((p) => ( +
  • + +
    + {/* TODO: Handle sigils */} +
    +
    +

    {p.nickname || }

    + {p.status &&

    {p.status}

    } +
    + +
  • + ))} +
+ )} +

That's it!

+
+ ); +}; diff --git a/pkg/grid/src/pages/Grid.tsx b/pkg/grid/src/pages/Grid.tsx new file mode 100644 index 000000000..fe6bce119 --- /dev/null +++ b/pkg/grid/src/pages/Grid.tsx @@ -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 = ({ 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 ( +
+
+
+ +
+ {isLoading && Loading...} + {isSuccess && ( +
+ {charges && + map(charges, (charge, desk) => )} +
+ )} + + + + + + +
+
+ ); +}; diff --git a/pkg/grid/src/state/api.ts b/pkg/grid/src/state/api.ts new file mode 100644 index 000000000..1051d8aff --- /dev/null +++ b/pkg/grid/src/state/api.ts @@ -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; diff --git a/pkg/grid/src/state/docket-types.ts b/pkg/grid/src/state/docket-types.ts new file mode 100644 index 000000000..ac73178ae --- /dev/null +++ b/pkg/grid/src/state/docket-types.ts @@ -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; +} diff --git a/pkg/grid/src/state/docket.ts b/pkg/grid/src/state/docket.ts new file mode 100644 index 000000000..d59734938 --- /dev/null +++ b/pkg/grid/src/state/docket.ts @@ -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(data: T, time = 300): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(data); + }, time); + }); +} + +const stableTreatyMap = new Map(); + +interface ChargesResponse { + initial: Dockets; +} + +export async function fetchCharges(): Promise { + const charges = queryClient.getQueryData(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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const charges = await queryClient.fetchQuery(chargesKey(), fetchCharges); + const docket = (charges || {})[desk]; + docket.status = docket.status === 'active' ? 'suspended' : 'active'; +} + +function normalizeDocket(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(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; diff --git a/pkg/grid/src/state/mock-data.ts b/pkg/grid/src/state/mock-data.ts new file mode 100644 index 000000000..391fdd31b --- /dev/null +++ b/pkg/grid/src/state/mock-data.ts @@ -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 = { + 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 + } +}; diff --git a/pkg/grid/src/styles/base.css b/pkg/grid/src/styles/base.css new file mode 100644 index 000000000..cdba3ee7f --- /dev/null +++ b/pkg/grid/src/styles/base.css @@ -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; +} \ No newline at end of file diff --git a/pkg/grid/src/styles/components.css b/pkg/grid/src/styles/components.css new file mode 100644 index 000000000..90caef24b --- /dev/null +++ b/pkg/grid/src/styles/components.css @@ -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; +} \ No newline at end of file diff --git a/pkg/grid/src/styles/index.css b/pkg/grid/src/styles/index.css new file mode 100644 index 000000000..f68930e3a --- /dev/null +++ b/pkg/grid/src/styles/index.css @@ -0,0 +1,8 @@ +@import "tailwindcss/base"; +@import "./base.css"; + +@import "tailwindcss/components"; +@import "./components.css"; + +@import "tailwindcss/utilities"; +@import "./utilities.css"; \ No newline at end of file diff --git a/pkg/grid/src/styles/utilities.css b/pkg/grid/src/styles/utilities.css new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/grid/src/tiles/RemoveApp.tsx b/pkg/grid/src/tiles/RemoveApp.tsx new file mode 100644 index 000000000..f66e3432a --- /dev/null +++ b/pkg/grid/src/tiles/RemoveApp.tsx @@ -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(chargesKey([desk])); + const { mutate } = useMutation(() => uninstallDocket(desk), { + onSuccess: () => { + history.push('/'); + queryClient.invalidateQueries(chargesKey()); + } + }); + + // TODO: add optimistic updates + const handleRemoveApp = useCallback(() => { + mutate(); + }, []); + + return ( + !open && history.push('/')}> + +

Remove “{docket?.title || ''}”

+

+ Explanatory writing about what data will be kept. +

+ +
+
+ ); +}; diff --git a/pkg/grid/src/tiles/SuspendApp.tsx b/pkg/grid/src/tiles/SuspendApp.tsx new file mode 100644 index 000000000..b412d10fb --- /dev/null +++ b/pkg/grid/src/tiles/SuspendApp.tsx @@ -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(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') { + ; + } + + return ( + !open && history.push('/')}> + +

Suspend “{docket?.title || ''}”

+

+ Suspending an app will freeze its current state, and render it unable +

+ +
+
+ ); +}; diff --git a/pkg/grid/src/tiles/Tile.tsx b/pkg/grid/src/tiles/Tile.tsx new file mode 100644 index 000000000..2ab682141 --- /dev/null +++ b/pkg/grid/src/tiles/Tile.tsx @@ -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 = ({ 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 ( + +
+ +
+

+ {title} +

+ {!active && Suspended} +
+ {img && ( + + )} +
+
+ ); +}; diff --git a/pkg/grid/src/tiles/TileMenu.tsx b/pkg/grid/src/tiles/TileMenu.tsx new file mode 100644 index 000000000..2f0a4a5ae --- /dev/null +++ b/pkg/grid/src/tiles/TileMenu.tsx @@ -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 }) => ( + + + + + +); + +type ItemComponent = Polymorphic.ForwardRefComponent< + Polymorphic.IntrinsicElement, + Polymorphic.OwnProps +>; + +const Item = React.forwardRef(({ children, ...props }, ref) => ( + + {children} + +)) 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 ( + setOpen(isOpen)}> + queryClient.setQueryData(['apps', name], app)} + > + + Menu + + + e.preventDefault()} + className={classNames( + 'dropdown font-semibold', + lightText ? 'text-gray-100' : 'text-gray-800' + )} + style={menuBg} + > + + {/* + TODO: revisit with Liam + { e.preventDefault(); setTimeout(() => setOpen(false), 0) }}>App Info + */} + { + e.preventDefault(); + setTimeout(() => setOpen(false), 0); + }} + > + App Info + + + + + {active && ( + { + e.preventDefault(); + setTimeout(() => setOpen(false), 0); + }} + > + Suspend App + + )} + {!active && mutate()}>Resume App} + { + e.preventDefault(); + setTimeout(() => setOpen(false), 0); + }} + > + Remove App + + + + + + ); +}; diff --git a/pkg/grid/src/vite-env.d.ts b/pkg/grid/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/pkg/grid/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/pkg/grid/tailwind.config.js b/pkg/grid/tailwind.config.js new file mode 100644 index 000000000..65122483b --- /dev/null +++ b/pkg/grid/tailwind.config.js @@ -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')() + ], +} diff --git a/pkg/grid/tsconfig.json b/pkg/grid/tsconfig.json new file mode 100644 index 000000000..013e6c54f --- /dev/null +++ b/pkg/grid/tsconfig.json @@ -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"] +} diff --git a/pkg/grid/vite.config.ts b/pkg/grid/vite.config.ts new file mode 100644 index 000000000..c151b2398 --- /dev/null +++ b/pkg/grid/vite.config.ts @@ -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()] +}));