) => (
+
+);
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 (
+
+ );
+};
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.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 && (
+
+ )}
+
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 (
+
+ );
+};
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 (
+
+ );
+};
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 && (
+
![]({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()]
+}));