initial commit of next/landscape to new master

This commit is contained in:
Patrick O'Sullivan 2022-08-02 08:33:37 -05:00
commit 10d8a3d888
132 changed files with 20138 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
# Change manually to clear local storage once
VITE_LAST_WIPE=2021-10-20

8
.eslintignore Normal file
View File

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

88
.eslintrc.js Normal file
View File

@ -0,0 +1,88 @@
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',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: false }
],
'no-unused-expressions': ['error', { allowShortCircuit: true }],
'no-use-before-define': 'off',
'no-param-reassign': ['error', { props: true, ignorePropertyModificationsFor: ['draft'] }],
'@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',
'react/no-array-index-key': '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',
'jsx-a11y/label-has-associated-control': [
'error',
{
required: {
some: ['nesting', 'id']
}
}
],
'jsx-a11y/label-has-for': [
'error',
{
required: {
some: ['nesting', 'id']
}
}
]
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts']
},
'import/resolver': {
typescript: {
alwaysTryTypes: true
}
}
}
};

8
.gitignore vendored Normal file
View File

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

1
.husky/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
_

6
.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
.prettierrc Normal file
View File

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

47
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,47 @@
# Contributing to Landscape
Thank you for your interest in contributing to the Urbit ecosystem.
Landscape is entirely open to contributions from the community. We mainly organize through our [project board], [issues], and [weekly call].
For now our code is stored in the main [urbit repo]. If you would like to contribute feel free to open up a PR there.
## Git Conventions
For Landscape we follow the same conventions as the main repo which can be found in it's [contributing doc]. This can be summarized as the following:
Commits should try to be atomic and focused on one feature at a time. Work-in-progress commits should be rebased and combined into one so that the commit history stays clean.
Commits should follow this format:
> component: short description
>
> long description
Where `component` is the closest most relevant area of the code base written as concisely as possible. The short description that accompanies should be a super concise summary of the changes. The total length of the commit message should strive to be 50 characters or less. The long description is optional, but should be used if further explanation is necessary.
### Pull Requests
A pull request (PR) should have a title similar in structure to our commit messages where it has a short identifier component followed by a very concise summary of the PR's intent. All PRs should have a description further laying out what it accomplishes. If the PR addresses certain Github issues, those should be referenced in the body of the description so they get linked.
PRs to this repo should currently tag (or request review) from one of the following contributors:
- [Liam - @liam-fitzgerald](https://github.com/liam-fitzgerald)
- [Hunter - @arthyn](https://github.com/arthyn)
- [James - @nerveharp](https://github.com/nerveharp)
If design or visual changes are made, please provide screenshots and also tag (or request a review) from one of the following contributors:
- [Éd - @urcades](https://github.com/urcades)
- [Gavin - @g-a-v-i-n](https://github.com/g-a-v-i-n)
## Further Information
If you haven't yet, check out the main [contributing doc] at the base of the repo for information on how to get started developing on Urbit. Also you can find a host of resources on [developers.urbit.org], including ways to earn address space by contributing.
[project board]: https://github.com/orgs/urbit/projects/17
[issues]: https://github.com/urbit/landscape/issues
[weekly call]: https://github.com/urbit/landscape/issues/792
[urbit repo]: https://github.com/urbit/urbit
[contributing doc]: ../../CONTRIBUTING.md
[developers.urbit.org]: https://developers.urbit.org/

43
README.md Normal file
View File

@ -0,0 +1,43 @@
# Landscape
Landscape provides the primary launching interface for Tlon's suite of userspace applications. This directory contains the front-end web application to power said interface.
Landscape is built primarily using [React], [Typescript], and [Tailwind CSS]. [Vite] ensures that all code and assets are loaded appropriately, bundles the application for distribution and provides a functional dev environment.
## Getting Started
To get started using Landscape first you need to run, `npm i && npm run bootstrap` at the top level of the greater urbit repo. This will install your npm dependencies and correctly link the current implementation of the packages at `pkg/npm/*` to your dependencies.
If you intend to edit those packages will developing on Landscape, you should also have `npm run watch-libs` running to build and re-link them after every change.
Once that's done, you can then run `npm run mock` if you'd like to get started immediately. This will use hard-coded mock data to power the interface so you can work on the interface without being connected to a ship.
To develop against a working ship, you first need to add a `.env.local` file to the root of this directory. This file will not be committed. Adding `VITE_SHIP_URL={URL}` where **{URL}** is the URL of the ship you would like to point to, will allow you to run `npm run dev`. This will proxy all requests to the ship except for those powering the interface, allowing you to see live data.
Regardless of what you run to develop, Vite will hot-reload code changes as you work so you don't have to constantly refresh.
## Deploying
To deploy, run `npm run build` which will bundle all the code and assets into the `dist/` folder. This can then be made into a glob by doing the following:
1. Create or launch an urbit using the -F flag
2. On that urbit, if you don't already have a desk to run from, run `|merge %work our %base` to create a new desk and mount it with `|mount %work`.
3. Now the `%work` desk is accessible through the host OS's filesystem as a directory of that urbit's pier ie `~/zod/work`.
4. From the directory of grid you can run `rsync -avL --delete dist/ ~/zod/work/grid` where `~/zod` is your fake urbit's pier.
5. Once completed you can then run `|commit %work` on your urbit and you should see your files logged back out from the dojo.
6. Now run `=dir /=garden` to switch to the garden desk directory
7. You can now run `-make-glob %work /grid` which will take the grid folder where you just added files and create a glob which can be thought of as a sort of bundle. It will be output to `~/zod/.urb/put`.
8. If you navigate to `~/zod/.urb/put` you should see a file that looks like this `glob-0v5.fdf99.nph65.qecq3.ncpjn.q13mb.glob`. The characters between `glob-` and `.glob` are a hash of the glob's contents.
9. If you're working at Tlon, you can upload this to our Google storage using `gsutil cp glob-*.* gs://bootstrap.urbit.org`. Otherwise any publicly available HTTP endpoint that can serve files should be sufficient for distributing the glob.
10. Once you've uploaded the glob, you should then update the corresponding entry in the docket file that represents Landscape which currently resides at `pkg/garden/desk.docket-0`. Both the full URL and the hash should be updated to match the glob we just created, on the line that looks like this:
```hoon
glob-http+['https://bootstrap.urbit.org/glob-0v5.fdf99.nph65.qecq3.ncpjn.q13mb.glob' 0v5.fdf99.nph65.qecq3.ncpjn.q13mb]
```
11. This can now be safely committed and deployed.
[react]: https://reactjs.org/
[typescript]: https://www.typescriptlang.org/
[tailwind css]: https://tailwindcss.com/
[vite]: https://vitejs.dev/

26
index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Urbit • Home</title>
<link rel="icon" href="/src/assets/favicon.svg" sizes="any" type="image/svg+xml" />
<link rel="mask-icon" href="/src/assets/safari-pinned-tab.svg" color="#000000" />
<link rel="apple-touch-icon" sizes="180x180" href="/src/assets/apple-touch-icon.png" />
<link rel="manifest" href="/src/assets/manifest.json" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#ffffff" />
<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=Inter:wght@400;600&family=Source+Code+Pro:wght@400;600&display=swap"
rel="stylesheet"
/>
</head>
<body class="text-base font-sans text-gray-800 bg-white antialiased">
<div id="app"></div>
<script type="module" src="/src/storage-wipe.ts"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

12239
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

96
package.json Normal file
View File

@ -0,0 +1,96 @@
{
"name": "landscape",
"version": "0.0.0",
"private": true,
"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",
"test": "tsc --noEmit",
"tsc": "tsc --noEmit"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^3.3.3",
"@radix-ui/react-checkbox": "^0.1.5",
"@radix-ui/react-dialog": "^0.0.20",
"@radix-ui/react-dropdown-menu": "^0.0.23",
"@radix-ui/react-icons": "^1.1.0",
"@radix-ui/react-polymorphic": "^0.0.13",
"@radix-ui/react-portal": "^0.0.15",
"@radix-ui/react-radio-group": "^0.1.5",
"@radix-ui/react-toggle": "^0.0.10",
"@tlon/sigil-js": "^1.4.4",
"@types/lodash": "^4.14.172",
"@urbit/api": "^2.1.1",
"@urbit/http-api": "^2.1.0",
"big-integer": "^1.6.48",
"classnames": "^2.3.1",
"clipboard-copy": "^4.0.1",
"color2k": "^1.2.4",
"fuzzy": "^0.1.3",
"immer": "^9.0.5",
"lodash": "^4.17.21",
"moment": "^2.29.1",
"mousetrap": "^1.6.5",
"postcss-import": "^14.0.2",
"query-string": "^7.0.1",
"react": "^17.0.2",
"react-dnd": "^15.1.1",
"react-dnd-html5-backend": "^15.1.2",
"react-dnd-touch-backend": "^15.1.1",
"react-dom": "^17.0.2",
"react-error-boundary": "^3.1.3",
"react-router-dom": "^5.2.0",
"slugify": "^1.6.0",
"urbit-ob": "^5.0.1",
"zustand": "^3.5.7"
},
"devDependencies": {
"@tailwindcss/aspect-ratio": "^0.2.1",
"@types/lodash": "^4.14.172",
"@types/mousetrap": "^1.6.8",
"@types/node": "^16.7.9",
"@types/react": "^16.0.0",
"@types/react-dom": "^16.0.0",
"@types/react-router-dom": "^5.1.8",
"@typescript-eslint/eslint-plugin": "^4.26.1",
"@typescript-eslint/parser": "^4.26.1",
"@urbit/vite-plugin-urbit": "^0.7.1",
"@vitejs/plugin-react-refresh": "^1.3.1",
"autoprefixer": "^10.3.7",
"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.9",
"prettier": "^2.3.2",
"rollup-plugin-analyzer": "^4.0.0",
"rollup-plugin-visualizer": "^5.5.2",
"tailwindcss": "^2.2.16",
"tailwindcss-theming": "^3.0.0-beta.3",
"tailwindcss-touch": "^1.0.1",
"typescript": "^4.3.2",
"vite": "^2.6.7",
"vite-plugin-html-config": "^1.0.5"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"npm run lint:fix",
"prettier --write"
],
"*.+{json,css,md}": "prettier --write"
}
}

7
postcss.config.js Normal file
View File

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

121
src/app.tsx Normal file
View File

@ -0,0 +1,121 @@
import React, { useEffect } from 'react';
import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory, useLocation } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import FingerprintJS from '@fingerprintjs/fingerprintjs';
import { Grid } from './pages/Grid';
import useDocketState from './state/docket';
import { PermalinkRoutes } from './pages/PermalinkRoutes';
import useKilnState from './state/kiln';
import useContactState from './state/contact';
import api from './state/api';
import { useMedia } from './logic/useMedia';
import { useHarkStore } from './state/hark';
import { useSettingsState, useTheme } from './state/settings';
import { useBrowserId, useLocalState } from './state/local';
import { ErrorAlert } from './components/ErrorAlert';
import { useErrorHandler } from './logic/useErrorHandler';
const getNoteRedirect = (path: string) => {
if (path.startsWith('/desk/')) {
const [, , desk] = path.split('/');
return `/apps/${desk}`;
}
if (path.startsWith('/grid/')) {
// Handle links to grid features (preferences, etc)
const route = path
.split('/')
.filter((el) => el !== 'grid')
.join('/');
return route;
}
return '';
};
const getId = async () => {
const fpPromise = FingerprintJS.load();
const fp = await fpPromise;
const result = await fp.get();
return result.visitorId;
};
const AppRoutes = () => {
const { push } = useHistory();
const { search } = useLocation();
const handleError = useErrorHandler();
const browserId = useBrowserId();
useEffect(() => {
getId().then((value) => {
useLocalState.setState({ browserId: value });
});
}, [browserId]);
useEffect(() => {
const query = new URLSearchParams(search);
if (query.has('grid-note')) {
const redir = getNoteRedirect(query.get('grid-note')!);
push(redir);
}
}, [search]);
const theme = useTheme();
const isDarkMode = useMedia('(prefers-color-scheme: dark)');
useEffect(() => {
if ((isDarkMode && theme === 'auto') || theme === 'dark') {
document.body.classList.add('dark');
useLocalState.setState({ currentTheme: 'dark' });
} else {
document.body.classList.remove('dark');
useLocalState.setState({ currentTheme: 'light' });
}
}, [isDarkMode, theme]);
useEffect(
handleError(() => {
window.name = 'grid';
const { initialize: settingsInitialize, fetchAll } = useSettingsState.getState();
settingsInitialize(api);
fetchAll();
const { fetchDefaultAlly, fetchAllies, fetchCharges } = useDocketState.getState();
fetchDefaultAlly();
fetchCharges();
fetchAllies();
const { fetchVats, fetchLag } = useKilnState.getState();
fetchVats();
fetchLag();
useContactState.getState().initialize(api);
useHarkStore.getState().initialize(api);
Mousetrap.bind(['command+/', 'ctrl+/'], () => {
push('/leap/search');
});
}),
[]
);
return (
<Switch>
<Route path="/perma" component={PermalinkRoutes} />
<Route path={['/leap/:menu', '/']} component={Grid} />
</Switch>
);
};
export function App() {
const base = import.meta.env.MODE === 'mock' ? undefined : '/apps/grid';
return (
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => window.location.reload()}>
<BrowserRouter basename={base}>
<AppRoutes />
</BrowserRouter>
</ErrorBoundary>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
src/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

12
src/assets/favicon.svg Normal file
View File

@ -0,0 +1,12 @@
<svg width="513" height="513" viewBox="0 0 513 513" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.icon-color { fill: #000000; }
@media (prefers-color-scheme: dark) {
.icon-color { fill: #ffffff; }
}
</style>
<path d="M247.634 122.758C247.634 147.519 238.15 162.538 219.182 171.468C198.239 181.21 184.804 197.853 178.876 220.584C172.159 245.751 153.981 260.364 129.481 259.552C109.723 259.146 94.7075 249.404 86.014 231.138C79.2963 216.931 70.6028 204.753 56.7723 197.041C50.4497 193.388 43.732 190.952 36.6192 188.923C16.4661 183.24 1.84525 166.597 0.264615 145.896C-1.71118 121.135 7.37747 103.274 27.5305 93.1266C49.6594 82.5728 63.49 65.1184 69.4173 41.1695C75.3447 16.8145 95.8929 2.60752 122.369 4.6371C137.385 5.85484 148.844 13.5672 157.538 25.7447C163.465 34.2689 168.602 43.199 174.529 51.3173C181.642 61.4652 191.126 67.9598 202.586 72.019C208.118 74.0486 214.045 76.0781 219.182 78.9195C238.15 88.6615 247.634 104.492 247.634 122.758Z" class="icon-color" />
<path d="M512.01 355.757C511.615 372.806 503.712 387.824 486.325 396.349C463.406 407.714 449.18 425.981 442.067 450.335C435.745 471.037 419.543 484.026 398.6 485.65C377.657 487.274 359.084 476.72 350.391 457.236C340.512 434.505 323.915 420.704 300.601 414.209C258.714 403.249 254.762 350.48 279.262 328.561C283.609 324.908 288.351 321.254 293.488 318.819C314.036 308.671 327.076 292.029 333.399 270.109C338.931 250.219 350.786 236.824 370.939 232.765C393.858 228.3 415.197 235.2 426.261 259.555C436.535 282.287 453.132 295.682 476.446 302.582C500.551 309.889 512.01 326.937 512.01 355.757Z" class="icon-color" />
<path d="M463.786 51.3199C462.6 58.2204 461.415 65.121 459.439 71.6156C453.117 93.1291 454.697 113.425 466.157 132.909C476.431 150.363 476.036 168.629 465.762 185.678C455.883 202.32 440.472 210.033 421.899 210.033C417.157 210.033 412.415 208.815 408.069 207.191C384.359 199.073 361.44 200.697 339.706 214.498C310.069 233.17 271.739 206.379 268.973 178.371C268.182 169.441 268.182 160.511 271.344 152.393C280.037 130.067 276.481 109.366 266.997 89.07C262.65 80.1398 259.094 70.8038 259.094 60.6559C259.489 27.3709 290.311 3.42188 321.529 11.9461C331.408 14.7875 341.287 18.4407 351.956 18.4407C364.206 18.4407 374.875 14.3816 385.94 9.10469C393.052 5.45146 400.956 2.20414 408.859 0.580478C434.544 -3.88459 461.02 18.0348 462.996 44.8252C462.996 47.2607 463.391 49.2903 463.391 51.7258C463.391 51.3199 463.786 51.3199 463.786 51.3199Z" class="icon-color" />
<path d="M235.757 342.773C234.571 349.268 233.781 356.168 231.805 362.663C225.483 384.176 227.063 404.878 238.523 424.362C257.095 456.429 237.337 498.239 197.822 501.486C192.684 501.892 187.152 500.674 182.015 499.05C157.515 490.526 134.201 491.744 112.072 505.951C82.8304 524.217 44.8952 497.833 41.7339 470.636C40.9436 461.706 40.5485 453.182 43.7097 444.658C52.4032 421.521 48.8468 399.601 38.1775 378.088C33.0405 367.94 30.2743 356.98 31.4598 345.209C33.8308 322.883 53.9839 302.587 76.1127 300.964C84.4111 300.558 92.3142 301.37 99.8222 304.211C120.766 312.329 140.524 309.488 159.886 298.528C174.507 290.004 190.314 286.757 206.12 294.469C224.692 304.617 234.966 320.448 235.757 342.773Z" class="icon-color" />
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
src/assets/go.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,26 @@
<svg width="202" height="120" viewBox="0 0 202 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M202 63.0346C201.753 63.2833 201.852 63.6316 201.802 63.9301C201.061 68.0595 197.254 70.8953 193.101 70.348C192.805 70.2983 192.607 70.2485 192.459 70.6466C191.618 73.0844 190.728 75.5222 189.839 78.0595C193.003 78.0595 196.068 78.0595 199.281 78.0595C198.342 79.8008 197.452 81.3929 196.562 83.0347C195.82 84.3282 195.128 85.6218 194.535 87.0148C193.991 88.2088 194.09 89.4029 194.782 90.4974C196.166 92.6367 197.155 94.8756 196.957 97.5124C196.809 99.3532 196.166 101.094 195.227 102.637C194.337 104.08 194.387 105.423 195.029 106.915C196.216 109.503 197.748 111.891 199.034 114.428C199.133 114.577 199.281 114.726 199.281 114.975C190.283 114.975 181.236 114.975 172.14 114.975C172.387 114.478 172.634 114.03 172.882 113.532C174.315 110.896 175.65 108.209 177.183 105.622C177.825 104.527 177.875 103.333 177.529 102.189C176.837 99.8507 176.589 97.4626 176.886 94.9751C177.034 93.6815 177.38 92.4377 177.776 91.1939C178.221 89.8009 178.023 88.6069 177.232 87.3631C175.403 84.4277 173.92 81.2934 172.239 78.2585C172.239 78.2088 172.239 78.2088 172.239 78.1093C174.76 78.1093 177.282 78.1093 179.852 78.1093C179.457 76.0197 179.111 73.9799 178.765 71.9898C178.666 71.4923 178.715 70.8953 178.419 70.5471C178.122 70.1988 177.529 70.4476 177.084 70.3978C172.684 69.8008 169.817 66.4674 170.014 62.0395C170.212 57.7112 174.118 54.3778 178.419 54.7758C179.407 54.8753 180.347 55.1241 181.236 55.5718C181.484 55.7211 181.632 55.6713 181.879 55.5718C184.45 54.328 186.971 54.328 189.443 55.8206C189.74 56.0196 189.937 55.9201 190.185 55.7709C194.585 53.333 200.072 55.5718 201.456 60.3977C201.654 60.9948 201.753 61.6415 201.852 62.2883C202 62.4375 202 62.7361 202 63.0346ZM175.7 80.0994C175.65 80.2486 175.749 80.3481 175.798 80.4476C176.787 82.3382 177.726 84.3282 178.913 86.1193C180.248 88.0596 180.347 90.0497 179.654 92.2387C178.666 95.1741 178.567 98.2089 179.457 101.194C180.05 103.134 180.001 105.025 178.913 106.816C177.875 108.507 176.935 110.299 176.046 112.09C175.897 112.388 175.7 112.637 175.65 112.985C182.324 112.985 188.949 112.985 195.623 112.985C196.068 112.985 196.117 112.886 195.87 112.537C195.079 111.194 194.337 109.851 193.645 108.458C192.953 107.065 192.459 105.672 192.656 104.08C192.805 102.935 193.398 102.04 193.892 101.094C194.733 99.4527 195.326 97.7611 195.029 95.8706C194.782 94.2288 194.041 92.8357 193.151 91.4924C192.113 89.8506 191.964 88.1093 192.706 86.3183C193.497 84.3282 194.634 82.4874 195.623 80.5969C195.722 80.4476 195.87 80.2984 195.82 80.0496C189.097 80.0993 182.423 80.0994 175.7 80.0994ZM179.704 62.4873C179.704 65.8207 182.275 68.4077 185.587 68.4077C188.899 68.4077 191.519 65.7709 191.519 62.4873C191.519 59.2535 188.8 56.5171 185.587 56.5171C182.324 56.5171 179.704 59.154 179.704 62.4873ZM191.322 67.761C192.607 68.4575 193.892 68.7062 195.277 68.358C197.551 67.8107 199.133 66.4674 199.775 64.1789C200.418 61.7908 199.825 59.6017 197.897 58.0097C196.068 56.4674 193.991 56.2186 191.816 57.0644C191.47 57.2136 191.421 57.3131 191.668 57.6117C192.953 59.2535 193.497 61.144 193.299 63.2336C193.2 64.9251 192.508 66.4177 191.322 67.761ZM180.05 68.0595C178.616 66.5172 177.825 64.7261 177.776 62.5868C177.776 60.4475 178.517 58.6067 179.951 57.0146C177.282 56.0694 174.513 56.9649 172.98 59.2037C171.448 61.3928 171.646 64.3281 173.376 66.4674C175.007 68.358 177.776 69.0545 180.05 68.0595ZM184.845 74.2287C184.845 73.0844 184.845 71.9401 184.845 70.7461C184.845 70.3978 184.796 70.2486 184.45 70.1988C183.609 70.0993 182.818 69.8008 182.027 69.4525C181.83 69.353 180.495 69.8505 180.396 70.0495C180.396 70.0993 180.396 70.1988 180.396 70.2983C180.841 72.7859 181.286 75.2734 181.731 77.7113C181.78 78.0098 181.879 78.0595 182.176 78.0595C182.917 78.0595 183.659 78.0098 184.45 78.0595C184.796 78.0595 184.845 77.96 184.845 77.6118C184.845 76.4675 184.845 75.3729 184.845 74.2287ZM186.724 74.1292C186.724 75.2237 186.724 76.368 186.724 77.4625C186.724 77.6615 186.526 78.0595 186.971 78.0595C187.317 78.0595 187.812 78.3581 188.009 77.6615C188.85 75.174 189.74 72.6864 190.63 70.1988C190.679 69.9998 190.976 69.751 190.63 69.6018C190.283 69.4525 190.036 68.955 189.492 69.3033C188.8 69.751 187.96 70.0495 187.119 70.1988C186.773 70.2486 186.724 70.3978 186.724 70.6963C186.724 71.7908 186.724 72.9849 186.724 74.1292Z" fill="black"/>
<path d="M0.0988727 76.6171C0.346058 76.4678 0.197749 76.219 0.197749 76.02C0.34606 69.7016 0.444932 63.4329 0.593244 57.1144C0.642681 55.3234 0.69212 53.5323 0.69212 51.7413C0.69212 51.4925 0.741557 51.2438 0.741557 50.8955C2.42242 51.8905 4.05384 52.8856 5.63583 53.8309C6.9212 54.6269 8.20656 55.3732 9.5908 55.9702C11.0245 56.6169 12.5076 56.7164 13.9907 56.1692C17.3524 54.9254 20.6153 55.1244 23.7298 56.8657C24.8669 57.5125 25.9545 57.5125 27.141 56.9652C30.5027 55.4229 33.7161 53.5323 37.0284 51.8408C37.1273 51.791 37.2261 51.7413 37.4239 51.6418C37.2261 60.7961 37.0284 69.8508 36.8306 78.9554C36.6329 79.1046 36.534 78.9056 36.3857 78.8559C33.5678 77.2141 30.651 75.7713 27.8825 74.03C26.4488 73.1344 25.6084 73.1842 24.2736 74.229C22.3456 75.7713 20.1703 76.617 17.6985 76.4678C15.721 76.3683 13.9907 75.622 12.4087 74.428C12.211 74.2787 11.9638 74.0797 11.766 73.9305C10.7773 73.1842 9.73911 73.0847 8.65149 73.7315C6.03133 75.3235 3.26285 76.6171 0.543807 78.0598C0.395496 78.1594 0.197748 78.3086 0 78.2589C0.0988742 77.7116 0.0988727 77.1643 0.0988727 76.6171ZM34.952 75.7215C35.1003 68.8061 35.2487 61.9404 35.397 55.0249C35.1498 55.1741 34.952 55.2736 34.7543 55.3731C32.4307 56.6169 30.1566 57.9105 27.7342 58.9553C26.1522 59.6518 24.5208 59.602 22.9883 58.7563C20.3186 57.2637 17.5502 57.0149 14.7323 58.1095C12.6559 58.9055 10.6784 58.7065 8.70093 57.811C6.97063 57.0149 5.33921 56.0199 3.70778 55.0249C3.41116 54.8259 3.0651 54.6269 2.6696 54.4279C2.52129 61.3433 2.37298 68.1593 2.22467 75.025C4.00441 74.1295 5.68527 73.2837 7.31669 72.3384C8.10769 71.8409 8.94812 71.4429 9.88742 71.3931C11.2717 71.2936 12.4087 71.8907 13.4469 72.7364C15.2266 74.1792 17.2536 74.8758 19.5771 74.428C20.8625 74.1792 21.9995 73.5822 22.9883 72.7862C24.1747 71.8409 25.5095 71.2439 27.0915 71.5921C27.9814 71.7912 28.723 72.2389 29.5139 72.6867C31.2442 73.7315 33.0734 74.6767 34.952 75.7215Z" fill="black"/>
<path d="M79.6927 0C79.6927 0.0497515 79.6927 0.04975 79.6927 0.0995015C78.506 0.0995015 77.3193 0.0995015 76.1326 0.0995015C76.1326 0.04975 76.1326 0.0497515 76.1326 0C77.3193 0 78.506 0 79.6927 0Z" fill="#F2F2F2"/>
<path d="M136.99 119.95C136.397 119.95 135.804 119.95 135.21 120C133.727 119.95 132.195 119.95 130.712 119.9C124.829 119.851 118.946 119.751 113.063 119.701C109.404 119.652 105.795 119.602 102.137 119.602C97.9844 119.552 93.7822 119.502 89.6295 119.502C85.4768 119.452 81.2746 119.403 77.1219 119.403C72.0793 119.353 66.9873 119.303 61.9447 119.253C57.8909 119.204 53.837 119.104 49.7832 119.005C47.3608 118.955 44.9383 118.905 42.5159 118.507C42.071 118.457 42.0216 118.358 42.1204 117.91C44.5428 107.811 47.2619 97.7608 49.9315 87.711C54.0348 72.089 58.138 56.5168 62.2413 40.8948C65.3559 28.9545 68.4704 17.0141 71.6344 5.02398C71.8816 3.9792 72.2276 2.98417 72.8209 2.03889C73.6119 0.844853 74.7489 0.247834 76.1332 0.0488281C77.3196 0.0488281 78.5061 0.0488281 79.6926 0.0488281C82.6589 0.0985797 85.6251 0.148333 88.5913 0.198084C94.4743 0.247836 100.357 0.247839 106.24 0.247839C110.393 0.247839 114.546 0.297589 118.699 0.34734C122.505 0.397092 126.262 0.446842 130.069 0.446842C134.321 0.496593 138.523 0.546346 142.774 0.596098C148.41 0.645849 154.095 0.745349 159.731 0.795101C161.412 0.795101 163.093 0.844851 164.774 0.894603C164.724 0.994106 164.724 1.14337 164.675 1.24287C164.428 1.69063 164.576 2.08864 164.873 2.4369C165.169 2.68566 165.565 2.68566 165.911 2.48666C166.356 2.28765 166.306 1.83988 166.306 1.44187C166.306 1.29262 166.257 1.09361 166.208 0.944357C168.927 0.844854 171.646 1.14336 174.365 1.29262C174.711 1.29262 174.76 1.44187 174.661 1.79013C173.079 7.85982 171.547 13.9793 169.965 20.0489C164.972 39.1038 159.929 58.1089 154.936 77.1637C151.624 89.7011 148.361 102.288 145.098 114.875C144.801 116.069 144.356 117.263 143.565 118.258C142.675 119.303 141.588 119.851 140.253 119.95C139.116 119.95 138.078 119.95 136.99 119.95ZM172.239 3.18318C171.794 3.13342 171.398 3.08367 171.003 3.08367C167.987 2.83492 165.021 2.93442 162.005 2.88466C156.419 2.78516 150.833 2.73541 145.246 2.68566C141.044 2.63591 136.891 2.58616 132.689 2.5364C128.981 2.48665 125.323 2.4369 121.615 2.4369C116.523 2.38715 111.431 2.3374 106.339 2.3374C98.7259 2.3374 91.162 2.3374 83.5487 2.18815C81.6701 2.13839 79.8409 2.1384 77.9623 2.08864C75.1939 1.98914 74.3534 2.58616 73.6119 5.32249C70.003 19.0539 66.4435 32.7356 62.8346 46.467C58.8302 61.7407 54.8258 77.0145 50.8214 92.3379C48.745 100.298 46.5698 108.258 44.6417 116.219C44.5428 116.666 44.6417 116.766 45.0372 116.816C46.1743 116.965 47.3608 116.915 48.5472 116.965C53.046 117.064 57.4954 117.164 61.9941 117.263C65.9985 117.313 70.003 117.363 74.0074 117.363C78.1601 117.413 82.3622 117.462 86.5149 117.462C90.7171 117.512 94.8698 117.562 99.072 117.562C102.879 117.612 106.636 117.661 110.443 117.661C114.348 117.711 118.254 117.761 122.159 117.761C123.741 117.761 125.323 117.761 126.905 117.811C131.058 117.86 135.21 117.96 139.363 118.01C140.945 118.01 142.033 117.363 142.626 115.821C142.774 115.472 142.923 115.074 143.022 114.726C143.417 113.134 143.862 111.592 144.257 110C148.46 93.8802 152.711 77.711 156.913 61.5915C160.819 46.6163 164.823 31.6908 168.729 16.7156C169.965 12.238 171.052 7.76032 172.239 3.18318Z" fill="black"/>
<path d="M27.4866 41.9899C18.4396 41.9899 9.49145 41.9899 0.444456 41.9899C0.839953 41.2436 1.23545 40.5471 1.63095 39.8505C2.91631 37.5122 4.30055 35.2236 5.38817 32.7858C5.98141 31.4425 6.03085 30.0992 5.4376 28.7559C4.00393 25.2733 4.15224 21.8405 5.88254 18.4574C6.42635 17.4126 6.32747 16.3181 5.8331 15.2733C4.59717 12.4872 3.01518 9.85037 1.53207 7.21354C1.13657 6.51701 0.790516 5.82049 0.39502 5.07422C9.44201 5.07422 18.4396 5.07422 27.4866 5.07422C26.7944 6.41751 26.1518 7.6613 25.5091 8.95484C24.8664 10.1986 24.0754 11.3927 23.3833 12.5867C22.7406 13.731 22.1473 14.925 22.3451 16.3181C22.4934 17.3628 22.9383 18.2584 23.581 18.9549C26.3001 21.8902 25.9046 26.1689 23.7293 29.2037C23.4821 29.552 23.235 29.8505 22.9878 30.1987C22.2957 31.144 22.1968 32.139 22.7406 33.1341C23.5316 34.5769 24.372 35.9699 25.1136 37.4625C25.9046 39.0048 26.6956 40.4476 27.4866 41.9899ZM3.75674 7.06428C3.95449 7.41254 4.1028 7.71105 4.25111 7.95981C5.4376 10.0991 6.67353 12.1887 7.66227 14.4275C8.40383 16.1688 8.45327 17.9101 7.5634 19.6017C6.12972 22.3877 6.12972 25.1738 7.31622 28.0594C8.05777 29.9002 8.00833 31.6913 7.26678 33.4823C6.37691 35.5719 5.23986 37.5122 4.15224 39.5023C3.85562 40.0495 3.85562 40.0495 4.44886 40.0495C10.8757 40.0495 17.2531 40.0495 23.6799 40.0495C24.2731 40.0495 24.2237 39.9003 23.9765 39.4525C23.1361 37.761 22.1968 36.0694 21.2575 34.4276C20.071 32.3878 20.2687 30.4972 21.7519 28.7062C22.0979 28.2584 22.444 27.8107 22.6912 27.3132C23.9271 25.0246 24.0754 22.3878 22.1968 20.2484C20.0215 17.7609 19.9227 14.9748 21.4552 12.1389C22.2957 10.6464 23.2844 9.25335 24.026 7.6613C24.3226 7.06428 24.3226 7.06428 23.6305 7.06428C17.2531 7.06428 10.8757 7.06428 4.4983 7.06428C4.25111 7.06428 4.05336 7.06428 3.75674 7.06428Z" fill="black"/>
<path d="M166.208 0.944995C166.257 1.09425 166.307 1.29326 166.307 1.44251C166.356 1.84052 166.356 2.23854 165.911 2.4873C165.565 2.6863 165.169 2.6863 164.873 2.43754C164.527 2.13903 164.428 1.74102 164.675 1.24351C164.724 1.144 164.774 1.0445 164.774 0.895241C165.219 0.696235 165.713 0.745989 166.208 0.944995Z" fill="black"/>
<path d="M175.699 80.1006C182.373 80.1006 189.097 80.1006 195.82 80.1006C195.87 80.2996 195.721 80.4488 195.623 80.6479C194.584 82.5384 193.497 84.3792 192.706 86.3693C191.964 88.1603 192.112 89.9016 193.151 91.5434C193.991 92.8867 194.782 94.3295 195.029 95.9216C195.326 97.8121 194.733 99.5037 193.892 101.145C193.398 102.091 192.755 102.986 192.656 104.131C192.459 105.723 193.002 107.116 193.645 108.509C194.337 109.902 195.079 111.245 195.87 112.588C196.067 112.937 196.018 113.036 195.623 113.036C188.998 113.036 182.324 113.036 175.65 113.036C175.699 112.688 175.897 112.439 176.045 112.141C176.985 110.35 177.875 108.608 178.913 106.867C180 105.076 180.05 103.185 179.457 101.245C178.567 98.2101 178.666 95.2251 179.654 92.2897C180.396 90.1006 180.248 88.1106 178.913 86.1703C177.726 84.3792 176.787 82.3892 175.798 80.4986C175.749 80.3493 175.65 80.2498 175.699 80.1006Z" fill="#F2F2F2"/>
<path d="M179.703 62.4878C179.703 59.2042 182.324 56.5176 185.586 56.5176C188.8 56.5176 191.519 59.2539 191.519 62.4878C191.519 65.7714 188.899 68.4082 185.586 68.4082C182.274 68.4082 179.703 65.7714 179.703 62.4878Z" fill="#F2F2F2"/>
<path d="M191.322 67.7608C192.459 66.4175 193.151 64.925 193.348 63.2334C193.546 61.1438 193.002 59.2533 191.717 57.6115C191.47 57.313 191.519 57.1637 191.865 57.0642C194.09 56.2184 196.166 56.4672 197.946 58.0095C199.874 59.6513 200.467 61.7906 199.825 64.1787C199.182 66.4673 197.6 67.8105 195.326 68.3578C193.942 68.7061 192.607 68.4573 191.322 67.7608Z" fill="#F2F2F2"/>
<path d="M177.776 68.0613C176.144 69.0563 174.118 68.3598 172.98 66.4195C171.745 64.2802 171.596 61.3946 172.684 59.1558C173.771 56.9169 175.798 56.0214 177.726 56.9667C176.738 58.5588 176.144 60.3995 176.194 62.5389C176.194 64.6782 176.738 66.519 177.776 68.0613Z" fill="#F2F2F2"/>
<path d="M34.9521 75.7223C33.0735 74.7273 31.2443 73.7323 29.5635 72.6377C28.8219 72.1402 28.0309 71.6924 27.141 71.5432C25.559 71.1949 24.2242 71.7919 23.0378 72.7372C21.9996 73.5332 20.912 74.1303 19.6266 74.379C17.303 74.8268 15.2761 74.1303 13.4964 72.6875C12.4582 71.8417 11.3212 71.2447 9.93692 71.3442C8.99762 71.3939 8.15719 71.7919 7.36619 72.2895C5.73477 73.2845 4.05391 74.1303 2.27417 74.976C2.42248 68.1103 2.57079 61.2944 2.7191 54.3789C3.1146 54.6277 3.41122 54.7769 3.75728 54.9759C5.38871 55.971 7.02013 56.966 8.75043 57.762C10.7279 58.6575 12.7054 58.8565 14.7818 58.0605C17.6491 56.966 20.3682 57.2147 23.0378 58.7073C24.5703 59.5531 26.2017 59.6028 27.7837 58.9063C30.2061 57.8615 32.4802 56.568 34.8038 55.3242C35.0015 55.2247 35.1993 55.1252 35.4465 54.9759C35.2487 61.9411 35.1004 68.7571 34.9521 75.7223Z" fill="#F2F2F2"/>
<path d="M172.239 3.18284C171.052 7.71023 169.965 12.2376 168.778 16.7153C164.823 31.6905 160.868 46.6159 156.963 61.5911C152.711 77.7106 148.509 93.8799 144.307 109.999C143.911 111.591 143.516 113.134 143.071 114.726C142.972 115.124 142.824 115.472 142.676 115.82C142.132 117.313 140.995 118.009 139.413 118.009C135.26 117.96 131.107 117.86 126.955 117.81C125.373 117.81 123.791 117.761 122.209 117.761C118.303 117.711 114.398 117.661 110.492 117.661C106.685 117.611 102.928 117.562 99.1215 117.562C94.9193 117.512 90.7666 117.462 86.5645 117.462C82.4117 117.412 78.2096 117.363 74.0569 117.363C70.0525 117.313 66.0481 117.313 62.0437 117.263C57.5449 117.213 53.0955 117.114 48.5968 116.965C47.4103 116.915 46.2732 116.965 45.0867 116.815C44.6418 116.766 44.5924 116.666 44.6912 116.218C46.6193 108.208 48.7945 100.298 50.8709 92.3376C54.8753 77.0639 58.8797 61.7901 62.8841 46.4667C66.493 32.7353 70.0525 19.0536 73.6614 5.32216C74.4029 2.58583 75.1939 1.98881 78.0118 2.08831C79.8904 2.13806 81.7196 2.18781 83.5982 2.18781C91.2115 2.28732 98.7754 2.28732 106.389 2.33707C111.481 2.33707 116.573 2.38682 121.665 2.43657C125.373 2.48632 129.031 2.53607 132.739 2.53607C136.941 2.58582 141.094 2.63558 145.296 2.68533C150.882 2.73508 156.469 2.78483 162.055 2.88433C165.071 2.93408 168.037 2.83458 171.052 3.08334C171.399 3.13309 171.794 3.18284 172.239 3.18284ZM86.3173 65.5215C86.2678 66.1683 86.5645 66.5663 87.0588 66.7156C87.7015 66.8648 88.0476 66.5663 88.1959 65.7205C89.0857 61.4419 89.9262 57.1135 90.8161 52.8349C91.2116 50.8946 91.607 48.9045 92.0025 46.9144C92.1509 46.2179 91.9531 45.7701 91.3599 45.5711C90.7172 45.3721 90.3217 45.6707 90.1239 46.5164C89.1352 51.3423 88.1959 56.218 87.2071 61.0439C86.9105 62.5862 86.6139 64.0787 86.3173 65.5215ZM164.626 10.4963C164.626 9.94905 164.28 9.55104 163.736 9.45154C163.241 9.35204 162.895 9.65054 162.747 10.1978C162.104 13.4814 161.412 16.765 160.77 20.0486C160.621 20.6954 160.967 21.2426 161.61 21.3421C162.154 21.4417 162.5 21.1431 162.648 20.4466C163.093 18.1581 163.587 15.8695 164.032 13.5809C164.23 12.5361 164.428 11.4913 164.626 10.4963ZM136.743 104.129C136.743 103.532 136.397 103.134 135.903 103.084C135.309 102.984 135.013 103.233 134.864 103.83C134.222 107.114 133.53 110.397 132.887 113.681C132.739 114.328 133.085 114.825 133.678 114.925C134.222 115.024 134.568 114.726 134.716 114.079C135.161 111.94 135.557 109.85 136.002 107.711C136.298 106.517 136.496 105.323 136.743 104.129ZM81.1264 6.26744C81.1264 5.57092 80.8298 5.1729 80.2859 5.12315C79.7421 5.0734 79.4455 5.32216 79.2972 5.86942C79.1983 6.26743 79.1489 6.66545 79.05 7.01371C78.4568 9.84955 77.913 12.7351 77.3197 15.571C77.1714 16.367 77.4186 16.8148 78.0613 16.9143C78.6545 17.0138 78.9511 16.765 79.1489 15.969C79.5938 13.9292 79.9893 11.8396 80.3848 9.7998C80.6814 8.60576 80.9286 7.36197 81.1264 6.26744ZM158.347 32.6855C158.347 33.3323 158.693 33.7303 159.237 33.78C159.781 33.8298 160.077 33.581 160.226 32.9343C160.868 29.7999 161.511 26.6158 162.104 23.4815C162.302 22.6357 162.055 22.1382 161.412 21.9889C160.819 21.8397 160.473 22.1879 160.275 23.0835C159.83 25.2228 159.385 27.4118 158.99 29.5512C158.743 30.6457 158.545 31.7402 158.347 32.6855ZM92.9418 39.999C93.4362 39.999 93.6834 39.7502 93.7823 39.2527C94.0295 37.9592 94.3261 36.6656 94.5733 35.3223C94.9688 33.3323 95.3643 31.3422 95.7598 29.3521C95.9081 28.7054 95.562 28.2079 94.9688 28.1084C94.425 28.0089 94.0295 28.2576 93.9306 28.9044C93.2879 32.188 92.6452 35.4218 91.9531 38.7054C91.8542 39.4517 92.2497 39.999 92.9418 39.999ZM60.3628 101.492C60.3628 100.895 59.9673 100.447 59.4235 100.447C58.8797 100.397 58.5831 100.696 58.4842 101.243C57.8415 104.527 57.1494 107.761 56.5067 111.044C56.3584 111.691 56.7044 112.238 57.3471 112.338C57.8909 112.437 58.237 112.139 58.3359 111.442C58.8302 109.104 59.2752 106.815 59.7201 104.477C59.9673 103.432 60.165 102.387 60.3628 101.492ZM96.6991 19.4516C96.1553 19.4516 95.7598 19.8496 95.7598 20.3969C95.7598 20.9441 96.1058 21.3422 96.6496 21.3919C97.1934 21.3919 97.5889 20.9939 97.5889 20.4466C97.5889 19.8496 97.1934 19.4516 96.6991 19.4516ZM137.584 96.3177C138.078 96.3177 138.473 95.8699 138.473 95.3227C138.473 94.7754 138.078 94.3774 137.534 94.4271C136.99 94.4271 136.644 94.8251 136.644 95.3724C136.644 95.9197 137.089 96.3177 137.584 96.3177ZM77.3692 24.775C77.3692 24.178 77.0231 23.8297 76.4793 23.8297C75.9355 23.8297 75.54 24.178 75.54 24.7253C75.54 25.3223 75.8366 25.6705 76.4299 25.6705C76.9737 25.6705 77.3197 25.3223 77.3692 24.775ZM61.1538 93.5814C61.6976 93.5814 62.0437 93.2331 62.0437 92.6361C62.0437 92.0888 61.6482 91.6908 61.1538 91.6908C60.61 91.6908 60.2639 92.0888 60.2639 92.6361C60.2639 93.2331 60.61 93.5814 61.1538 93.5814Z" fill="#F2F2F2"/>
<path d="M3.75781 7.06445C4.05444 7.06445 4.25218 7.06445 4.44993 7.06445C10.8273 7.06445 17.2047 7.06445 23.5821 7.06445C24.2742 7.06445 24.2248 7.06445 23.9776 7.66147C23.236 9.25352 22.2473 10.6466 21.4069 12.1391C19.8743 15.0247 19.9732 17.8108 22.1484 20.2486C24.027 22.3879 23.8787 25.0248 22.6428 27.3133C22.3956 27.8108 22.0495 28.2586 21.7035 28.7064C20.2698 30.4974 20.0721 32.4377 21.2091 34.4278C22.1484 36.0696 23.0877 37.7611 23.9282 39.4527C24.1259 39.8507 24.2248 40.0497 23.6315 40.0497C17.2047 40.0497 10.8273 40.0497 4.4005 40.0497C3.80725 40.0497 3.80725 40.0497 4.10387 39.5024C5.19149 37.5124 6.32854 35.5721 7.21841 33.4825C7.95997 31.6915 8.0094 29.8507 7.26785 28.0596C6.08136 25.174 6.13079 22.3879 7.51503 19.6018C8.4049 17.9103 8.35546 16.169 7.61391 14.4277C6.62517 12.1889 5.38924 10.0993 4.20275 7.95998C4.10387 7.66147 3.95556 7.41271 3.75781 7.06445Z" fill="#F2F2F2"/>
<path d="M86.3174 65.5222C86.614 64.0794 86.9106 62.5868 87.2072 61.0445C88.196 56.2186 89.1353 51.343 90.124 46.5171C90.3218 45.6713 90.6678 45.3231 91.36 45.5718C91.9038 45.7708 92.151 46.2186 92.0026 46.9151C91.6072 48.9052 91.2117 50.8455 90.8162 52.8356C89.9263 57.1142 89.0859 61.4426 88.196 65.7212C88.0477 66.567 87.6522 66.8655 87.0589 66.7162C86.5646 66.567 86.3174 66.169 86.3174 65.5222Z" fill="black"/>
<path d="M164.626 10.4972C164.428 11.4923 164.23 12.5371 164.032 13.5321C163.587 15.8207 163.093 18.1092 162.648 20.3978C162.5 21.0943 162.153 21.3928 161.61 21.2933C161.016 21.1938 160.67 20.6963 160.769 19.9998C161.412 16.7162 162.104 13.4326 162.747 10.149C162.846 9.60171 163.241 9.35295 163.736 9.4027C164.28 9.55196 164.626 9.94997 164.626 10.4972Z" fill="black"/>
<path d="M136.744 104.13C136.497 105.275 136.299 106.469 136.052 107.712C135.607 109.852 135.211 111.941 134.766 114.081C134.618 114.777 134.272 115.026 133.728 114.926C133.134 114.827 132.788 114.329 132.937 113.683C133.579 110.399 134.272 107.115 134.914 103.832C135.013 103.235 135.359 103.036 135.953 103.085C136.398 103.185 136.744 103.583 136.744 104.13Z" fill="black"/>
<path d="M81.1268 6.26769C80.929 7.36223 80.6818 8.60602 80.4346 9.80006C80.039 11.8399 79.594 13.9294 79.1984 15.9692C79.0501 16.7653 78.7534 17.014 78.1106 16.9145C77.4678 16.7653 77.1711 16.3175 77.3689 15.5712C77.9128 12.6856 78.5062 9.8498 79.0995 7.01397C79.1984 6.61595 79.2479 6.21794 79.3468 5.86968C79.4457 5.32241 79.7918 5.07365 80.3357 5.12341C80.7807 5.17316 81.1268 5.57117 81.1268 6.26769Z" fill="black"/>
<path d="M158.347 32.686C158.545 31.691 158.743 30.5964 158.99 29.5019C159.435 27.3626 159.88 25.1735 160.276 23.0342C160.474 22.1387 160.82 21.7904 161.413 21.9397C162.056 22.0889 162.303 22.5864 162.105 23.4322C161.462 26.5666 160.82 29.7507 160.226 32.885C160.078 33.5318 159.781 33.8303 159.237 33.7308C158.694 33.7308 158.347 33.3328 158.347 32.686Z" fill="black"/>
<path d="M92.9424 40.0003C92.2501 40.0003 91.8546 39.5028 92.0029 38.7566C92.6457 35.473 93.2885 32.2391 93.9808 28.9555C94.1291 28.3585 94.4752 28.06 95.0191 28.1595C95.6125 28.259 95.9586 28.7565 95.8102 29.4033C95.4147 31.3933 95.0191 33.3834 94.6235 35.3735C94.3763 36.667 94.0796 37.9605 93.8324 39.3038C93.7335 39.7516 93.4863 40.0003 92.9424 40.0003Z" fill="black"/>
<path d="M60.3634 101.492C60.1656 102.437 59.9678 103.432 59.77 104.477C59.325 106.815 58.8306 109.104 58.3855 111.442C58.2372 112.139 57.8911 112.437 57.3966 112.338C56.7538 112.238 56.4077 111.741 56.556 111.044C57.1988 107.761 57.8911 104.527 58.5339 101.243C58.6328 100.746 58.9294 100.397 59.4734 100.447C59.9184 100.447 60.3634 100.895 60.3634 101.492Z" fill="black"/>
<path d="M96.6995 19.4524C97.2434 19.4524 97.5895 19.8504 97.5895 20.4475C97.5895 20.9947 97.1939 21.3927 96.65 21.3927C96.1061 21.3927 95.76 20.945 95.76 20.3977C95.76 19.8007 96.1556 19.4027 96.6995 19.4524Z" fill="black"/>
<path d="M137.584 96.3183C137.089 96.3183 136.644 95.9203 136.644 95.373C136.644 94.8257 136.99 94.4277 137.534 94.4277C138.078 94.4277 138.474 94.776 138.474 95.3233C138.474 95.8705 138.078 96.2686 137.584 96.3183Z" fill="black"/>
<path d="M77.3693 24.7756C77.3693 25.3229 76.9737 25.7209 76.4298 25.6712C75.8859 25.6712 75.5398 25.2732 75.5398 24.7259C75.5398 24.1786 75.9354 23.7806 76.4793 23.8304C77.0232 23.8304 77.3693 24.1786 77.3693 24.7756Z" fill="black"/>
<path d="M61.1538 93.582C60.6099 93.582 60.2144 93.2337 60.2144 92.6367C60.2144 92.0894 60.6099 91.6914 61.1044 91.6914C61.6483 91.6914 61.9944 92.0894 61.9944 92.6367C62.0933 93.2337 61.7472 93.582 61.1538 93.582Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

14
src/assets/manifest.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "urbit",
"short_name": "urbit",
"icons": [
{
"src": "/apps/grid/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -0,0 +1,54 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5565 6989 c-16 -5 -39 -11 -50 -13 -54 -12 -116 -37 -230 -91 -219
-105 -335 -135 -495 -131 -63 2 -126 7 -140 10 -14 4 -36 9 -50 12 -14 3 -79
21 -145 41 -205 61 -361 54 -525 -23 -239 -113 -392 -356 -392 -625 0 -110 25
-201 107 -389 85 -197 116 -320 121 -480 3 -137 -12 -231 -60 -370 -15 -41
-30 -97 -33 -125 -8 -58 -8 -190 0 -255 27 -220 225 -440 475 -527 67 -24 92
-27 197 -27 109 0 127 3 189 28 37 16 100 47 140 70 174 103 316 141 521 140
146 0 208 -10 362 -58 171 -54 291 -54 449 -1 294 99 498 442 448 755 -4 25
-9 50 -10 55 -2 6 -7 24 -10 40 -4 17 -36 86 -71 153 -60 117 -113 253 -128
332 -4 19 -8 91 -10 160 -4 126 8 227 40 336 24 82 65 283 60 297 -2 6 -6 43
-10 81 -8 97 -22 150 -62 231 -44 90 -71 126 -148 200 -123 117 -255 175 -415
180 -52 2 -108 -1 -125 -6z"/>
<path d="M1445 6923 c-38 -8 -106 -31 -150 -52 -187 -90 -284 -214 -354 -455
-59 -201 -123 -321 -243 -451 -81 -88 -154 -143 -295 -222 -64 -35 -135 -79
-157 -96 -100 -76 -187 -210 -223 -347 -21 -80 -24 -308 -4 -390 22 -91 84
-209 146 -277 102 -112 188 -163 385 -228 307 -100 451 -229 620 -550 61 -115
94 -161 164 -228 52 -50 156 -117 181 -117 8 0 16 -4 19 -9 16 -26 274 -53
356 -37 14 3 37 7 53 10 71 12 165 55 237 109 122 90 201 216 265 420 51 165
96 258 172 359 86 114 212 216 358 290 147 75 226 139 292 238 36 53 80 162
89 216 3 22 8 42 10 46 3 4 7 53 9 109 10 221 -44 374 -180 512 -67 68 -167
136 -253 173 -31 14 -66 29 -77 33 -11 5 -47 19 -80 31 -164 59 -294 148 -377
257 -25 32 -97 137 -160 233 -131 199 -185 263 -272 324 -147 103 -335 138
-531 99z"/>
<path d="M5070 3824 c-177 -38 -296 -111 -399 -245 -36 -46 -88 -160 -122
-264 -103 -321 -254 -508 -534 -660 -184 -100 -292 -208 -352 -350 -113 -268
-56 -593 138 -785 81 -80 154 -122 300 -171 152 -51 233 -88 322 -147 139 -93
274 -254 346 -415 83 -185 208 -309 376 -372 84 -32 139 -40 260 -39 145 1
248 34 370 117 121 82 205 205 266 388 113 340 278 537 601 714 145 80 234
172 290 300 86 195 64 503 -49 694 -50 85 -141 172 -224 216 -37 19 -120 53
-184 76 -197 67 -303 129 -425 246 -92 87 -158 179 -230 323 -102 202 -207
301 -380 360 -82 28 -272 36 -370 14z"/>
<path d="M2395 3017 c-76 -22 -110 -37 -270 -119 -72 -38 -195 -82 -252 -93
-127 -22 -184 -24 -293 -10 -86 11 -114 17 -179 40 -132 47 -193 58 -317 57
-103 0 -176 -18 -268 -64 -142 -72 -258 -190 -325 -331 -95 -200 -84 -408 34
-666 95 -209 136 -366 136 -528 1 -142 -12 -213 -67 -380 -22 -66 -27 -97 -27
-198 -1 -66 2 -142 6 -169 29 -183 195 -384 394 -476 186 -87 369 -86 533 2
41 23 102 55 135 73 73 39 196 80 290 95 69 12 250 15 283 6 9 -3 38 -7 64
-11 26 -3 87 -17 135 -31 48 -14 113 -32 143 -41 211 -56 483 46 643 242 48
58 120 194 131 245 3 14 8 36 12 50 13 50 13 225 0 285 -19 81 -35 123 -94
240 -96 188 -122 287 -127 468 -2 115 7 188 40 317 52 202 68 337 50 423 -3
15 -7 39 -10 54 -8 38 -40 125 -58 159 -8 16 -24 45 -33 64 -28 53 -150 172
-229 222 -106 68 -172 89 -295 94 -88 3 -118 0 -185 -19z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
src/assets/system.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

154
src/components/AppInfo.tsx Normal file
View File

@ -0,0 +1,154 @@
import { chadIsRunning, Treaty, Vat } from '@urbit/api';
import clipboardCopy from 'clipboard-copy';
import React, { FC, useCallback, useState } from 'react';
import cn from 'classnames';
import { Button, PillButton } from './Button';
import { Dialog, DialogClose, DialogContent, DialogTrigger } from './Dialog';
import { DocketHeader } from './DocketHeader';
import { Spinner } from './Spinner';
import { VatMeta } from './VatMeta';
import useDocketState, { ChargeWithDesk, useTreaty } from '../state/docket';
import { getAppHref, getAppName } from '../state/util';
import { addRecentApp } from '../nav/search/Home';
import { TreatyMeta } from './TreatyMeta';
type InstallStatus = 'uninstalled' | 'installing' | 'installed';
type App = ChargeWithDesk | Treaty;
interface AppInfoProps {
docket: App;
vat?: Vat;
className?: string;
}
function getInstallStatus(docket: App): InstallStatus {
if (!('chad' in docket)) {
return 'uninstalled';
}
if (chadIsRunning(docket.chad)) {
return 'installed';
}
if ('install' in docket.chad) {
return 'installing';
}
return 'uninstalled';
}
function getRemoteDesk(docket: App, vat?: Vat) {
if (vat && vat.arak.rail) {
const { ship, desk } = vat.arak.rail;
return [ship, desk];
}
if ('chad' in docket) {
return ['', docket.desk];
}
const { ship, desk } = docket;
return [ship, desk];
}
export const AppInfo: FC<AppInfoProps> = ({ docket, vat, className }) => {
const installStatus = getInstallStatus(docket);
const [ship, desk] = getRemoteDesk(docket, vat);
const publisher = vat?.arak?.rail?.publisher ?? ship;
const [copied, setCopied] = useState(false);
const treaty = useTreaty(ship, desk);
const installApp = async () => {
if (installStatus === 'installed') {
return;
}
await useDocketState.getState().installDocket(ship, desk);
};
const copyApp = useCallback(() => {
setCopied(true);
clipboardCopy(`web+urbitgraph://${publisher}/${desk}`);
setTimeout(() => {
setCopied(false);
}, 1250);
}, [publisher, desk]);
const installing = installStatus === 'installing';
if (!docket) {
// TODO: maybe replace spinner with skeletons
return (
<div className="dialog-inner-container flex justify-center text-black">
<Spinner className="w-10 h-10" />
</div>
);
}
return (
<div className={cn('text-black', className)}>
<DocketHeader docket={docket}>
<div className="col-span-2 md:col-span-1 flex items-center space-x-4">
{installStatus === 'installed' && (
<PillButton
variant="alt-primary"
as="a"
href={getAppHref(docket.href)}
target="_blank"
rel="noreferrer"
onClick={() => addRecentApp(docket.desk)}
>
Open App
</PillButton>
)}
{installStatus !== 'installed' && (
<Dialog>
<DialogTrigger as={PillButton} disabled={installing} variant="alt-primary">
{installing ? (
<>
<Spinner />
<span className="sr-only">Installing...</span>
</>
) : (
'Get App'
)}
</DialogTrigger>
<DialogContent
showClose={false}
className="space-y-6"
containerClass="w-full max-w-md"
>
<h2 className="h4">Install &ldquo;{getAppName(docket)}&rdquo;</h2>
<p className="tracking-tight pr-6">
This application will be able to view and interact with the contents of your
Urbit. Only install if you trust the developer.
</p>
<div className="flex space-x-6">
<DialogClose as={Button} variant="secondary">
Cancel
</DialogClose>
<DialogClose as={Button} onClick={installApp}>
Get &ldquo;{getAppName(docket)}&rdquo;
</DialogClose>
</div>
</DialogContent>
</Dialog>
)}
<PillButton variant="alt-secondary" onClick={copyApp}>
{!copied && 'Copy App Link'}
{copied && 'copied!'}
</PillButton>
</div>
</DocketHeader>
<div className="space-y-6">
{vat ? (
<>
<hr className="-mx-5 sm:-mx-8 border-gray-50" />
<VatMeta vat={vat} />
</>
) : null}
{!treaty ? null : (
<>
<hr className="-mx-5 sm:-mx-8 border-gray-50" />
<TreatyMeta treaty={treaty} />
</>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,65 @@
import classNames from 'classnames';
import React, { HTMLProps, ReactNode } from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { DocketWithDesk } from '../state/docket';
import { getAppHref, getAppName } from '../state/util';
import { DocketImage } from './DocketImage';
type Sizes = 'xs' | 'small' | 'default';
type LinkOrAnchorProps = {
[P in keyof LinkProps &
keyof HTMLProps<HTMLAnchorElement>]?: LinkProps[P] extends HTMLProps<HTMLAnchorElement>[P]
? LinkProps[P]
: never;
};
export type AppLinkProps<T extends DocketWithDesk> = Omit<LinkOrAnchorProps, 'to'> & {
app: T;
size?: Sizes;
selected?: boolean;
to?: (app: T) => LinkProps['to'] | undefined;
};
export const AppLink = <T extends DocketWithDesk>({
app,
to,
size = 'default',
selected = false,
className,
...props
}: AppLinkProps<T>) => {
const linkTo = to?.(app);
const linkClassnames = classNames(
'flex items-center default-ring rounded-lg',
size === 'default' && 'ring-offset-2',
size !== 'xs' && 'p-2',
size === 'xs' && 'p-1',
selected && 'bg-blue-200',
className
);
const link = (children: ReactNode) =>
linkTo ? (
<Link to={linkTo} className={linkClassnames} {...props}>
{children}
</Link>
) : (
<a
href={getAppHref(app.href)}
target="_blank"
rel="noreferrer"
className={linkClassnames}
{...props}
>
{children}
</a>
);
return link(
<>
<DocketImage color={app.color} image={app.image} size={size} />
<div className="flex-1 text-black">
<p>{getAppName(app)}</p>
{app.info && size === 'default' && <p className="font-normal">{app.info}</p>}
</div>
</>
);
};

View File

@ -0,0 +1,64 @@
import React, { MouseEvent, useCallback } from 'react';
import classNames from 'classnames';
import { MatchItem } from '../nav/Nav';
import { useRecentsStore } from '../nav/search/Home';
import { AppLink, AppLinkProps } from './AppLink';
import { DocketWithDesk } from '../state/docket';
import { getAppName } from '../state/util';
type AppListProps<T extends DocketWithDesk> = {
apps: T[];
labelledBy: string;
matchAgainst?: MatchItem;
onClick?: (e: MouseEvent<HTMLAnchorElement>, app: T) => void;
listClass?: string;
} & Omit<AppLinkProps<T>, 'app' | 'onClick'>;
export function appMatches(target: DocketWithDesk, match?: MatchItem): boolean {
if (!match) {
return false;
}
const matchValue = match.display || match.value;
return target.title === matchValue || target.desk === matchValue;
}
export const AppList = <T extends DocketWithDesk>({
apps,
labelledBy,
matchAgainst,
onClick,
listClass,
size = 'default',
...props
}: AppListProps<T>) => {
const addRecentApp = useRecentsStore((state) => state.addRecentApp);
const selected = useCallback((app: T) => appMatches(app, matchAgainst), [matchAgainst]);
return (
<ul
className={classNames(
size === 'default' && 'space-y-4',
size !== 'xs' && '-mx-2',
size === 'xs' && '-mx-1',
listClass
)}
aria-labelledby={labelledBy}
>
{apps.map((app) => (
<li key={getAppName(app)} id={getAppName(app)} role="option" aria-selected={selected(app)}>
<AppLink
{...props}
app={app}
size={size}
selected={selected(app)}
onClick={(e) => {
addRecentApp(app.desk);
onClick?.(e, app);
}}
/>
</li>
))}
</ul>
);
};

View File

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

112
src/components/Avatar.tsx Normal file
View File

@ -0,0 +1,112 @@
import classNames from 'classnames';
import React, { useMemo } from 'react';
import { sigil, reactRenderer } from '@tlon/sigil-js';
import { deSig, Contact } from '@urbit/api';
import { darken, lighten, parseToHsla } from 'color2k';
import { useCurrentTheme } from '../state/local';
import { normalizeUrbitColor } from '../state/util';
import { useContact } from '../state/contact';
export type AvatarSizes = 'xs' | 'small' | 'nav' | 'default';
interface AvatarProps {
shipName: string;
size: AvatarSizes;
className?: string;
adjustBG?: boolean;
}
interface AvatarMeta {
classes: string;
size: number;
}
const sizeMap: Record<AvatarSizes, AvatarMeta> = {
xs: { classes: 'w-6 h-6 rounded', size: 12 },
small: { classes: 'w-8 h-8 rounded-lg', size: 16 },
nav: { classes: 'w-9 h-9 rounded-lg', size: 18 },
default: { classes: 'w-12 h-12 rounded-lg', size: 24 }
};
const foregroundFromBackground = (background: string): 'black' | 'white' => {
const rgb = {
r: parseInt(background.slice(1, 3), 16),
g: parseInt(background.slice(3, 5), 16),
b: parseInt(background.slice(5, 7), 16)
};
const brightness = (299 * rgb.r + 587 * rgb.g + 114 * rgb.b) / 1000;
const whiteBrightness = 255;
return whiteBrightness - brightness < 50 ? 'black' : 'white';
};
const emptyContact: Contact = {
nickname: '',
bio: '',
status: '',
color: '#000000',
avatar: null,
cover: null,
groups: [],
'last-updated': 0
};
function themeAdjustColor(color: string, theme: 'light' | 'dark'): string {
const hsla = parseToHsla(color);
const lightness = hsla[2];
if (lightness <= 0.1 && theme === 'dark') {
return lighten(color, 0.1 - lightness);
}
if (lightness >= 0.9 && theme === 'light') {
return darken(color, lightness - 0.9);
}
return color;
}
export const Avatar = ({ shipName, size, className, adjustBG = true }: AvatarProps) => {
const currentTheme = useCurrentTheme();
const contact = useContact(shipName);
const { color, avatar } = { ...emptyContact, ...contact };
const { classes, size: sigilSize } = sizeMap[size];
const adjustedColor = adjustBG
? themeAdjustColor(normalizeUrbitColor(color), currentTheme)
: color;
const foregroundColor = foregroundFromBackground(adjustedColor);
const sigilElement = useMemo(() => {
if (shipName.match(/[_^]/) || shipName.length > 14) {
return null;
}
return sigil({
patp: deSig(shipName) || 'zod',
renderer: reactRenderer,
size: sigilSize,
icon: true,
colors: [adjustedColor, foregroundColor]
});
}, [shipName, adjustedColor, foregroundColor]);
if (avatar) {
return <img className={classNames('', classes)} src={avatar} alt="" />;
}
return (
<div
className={classNames(
'flex-none relative bg-black rounded-lg',
classes,
size === 'xs' && 'p-1.5',
size === 'small' && 'p-2',
size === 'nav' && 'p-[9px]',
size === 'default' && 'p-3',
className
)}
style={{ backgroundColor: adjustedColor }}
>
{sigilElement}
</div>
);
};

51
src/components/Button.tsx Normal file
View File

@ -0,0 +1,51 @@
import React from 'react';
import type * as Polymorphic from '@radix-ui/react-polymorphic';
import classNames from 'classnames';
type ButtonVariant =
| 'primary'
| 'secondary'
| 'caution'
| 'destructive'
| 'alt-primary'
| 'alt-secondary';
type PolymorphicButton = Polymorphic.ForwardRefComponent<
'button',
{
variant?: ButtonVariant;
}
>;
const variants: Record<ButtonVariant, string> = {
primary: 'text-white bg-black',
secondary: 'text-black bg-gray-100',
caution: 'text-white bg-orange-400',
destructive: 'text-white bg-red-500',
'alt-primary': 'text-white bg-blue-400 ring-blue-300',
'alt-secondary': 'text-blue-400 bg-blue-50'
};
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,40 @@
import React, { useState } from 'react';
import classNames from 'classnames';
import * as RadixCheckbox from '@radix-ui/react-checkbox';
import { CheckIcon } from '@radix-ui/react-icons';
export const Checkbox: React.FC<RadixCheckbox.CheckboxProps> = ({
defaultChecked,
checked,
onCheckedChange,
disabled,
className,
children
}) => {
const [on, setOn] = useState(defaultChecked);
const isControlled = !!onCheckedChange;
const proxyChecked = isControlled ? checked : on;
const proxyOnCheckedChange = isControlled ? onCheckedChange : setOn;
return (
<div className="flex content-center items-center space-x-2">
<RadixCheckbox.Root
className={classNames(
'default-ring border-gray-200 border-2 rounded-sm bg-white h-4 w-4',
className
)}
checked={proxyChecked}
onCheckedChange={proxyOnCheckedChange}
disabled={disabled}
id="checkbox"
>
<RadixCheckbox.Indicator className="flex justify-center">
<CheckIcon className="text-black" />
</RadixCheckbox.Indicator>
</RadixCheckbox.Root>
<label htmlFor="checkbox" className="font-semibold">
{children}
</label>
</div>
);
};

View File

@ -0,0 +1,40 @@
import React, { ReactNode } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { useCharge } from '../state/docket';
import { getAppHref } from '../state/util';
interface DeskLinkProps extends React.AnchorHTMLAttributes<any> {
desk: string;
to?: string;
children?: ReactNode;
className?: string;
}
export function DeskLink({ children, className, desk, to = '', ...rest }: DeskLinkProps) {
const { push } = useHistory();
const charge = useCharge(desk);
if (!charge) {
return null;
}
if (desk === window.desk) {
return (
<Link to={to} className={className} {...rest}>
{children}
</Link>
);
}
const href = `${getAppHref(charge.href)}${to}`;
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={className}
{...rest}
onClick={() => push('/')}
>
{children}
</a>
);
}

51
src/components/Dialog.tsx Normal file
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 transform transform-gpu" />
{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,25 @@
import React from 'react';
import { DocketImage } from './DocketImage';
import { getAppName } from '../state/util';
import { DocketWithDesk } from '../state/docket';
interface DocketHeaderProps {
docket: DocketWithDesk;
children?: React.ReactNode;
}
export function DocketHeader(props: DocketHeaderProps) {
const { docket, children } = props;
const { info, image, color } = 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">
<DocketImage color={color} image={image} className="row-span-1 md:row-span-2" />
<div className="col-start-2">
<h1 className="h2">{getAppName(docket)}</h1>
{info && <p className="h4 mt-2 text-gray-500">{info}</p>}
</div>
{children}
</header>
);
}

View File

@ -0,0 +1,39 @@
import React, { useState } from 'react';
import { Docket } from '@urbit/api';
import cn from 'classnames';
import { useTileColor } from '../tiles/useTileColor';
type DocketImageSizes = 'xs' | 'small' | 'default' | 'full';
interface DocketImageProps extends Pick<Docket, 'color' | 'image'> {
className?: string;
size?: DocketImageSizes;
}
const sizeMap: Record<DocketImageSizes, string> = {
xs: 'w-6 h-6 mr-2 rounded',
small: 'w-8 h-8 mr-3 rounded-md',
default: 'w-12 h-12 mr-3 rounded-lg',
full: 'w-20 h-20 md:w-32 md:h-32 rounded-2xl'
};
export function DocketImage({ color, image, className = '', size = 'full' }: DocketImageProps) {
const { tileColor } = useTileColor(color);
const [imageError, setImageError] = useState(false);
return (
<div
className={cn('flex-none relative bg-gray-200 overflow-hidden', sizeMap[size], className)}
style={{ backgroundColor: tileColor }}
>
{image && !imageError && (
<img
className="absolute top-0 left-0 h-full w-full object-cover"
src={image}
alt=""
onError={() => setImageError(true)}
/>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import cn from 'classnames';
import { Dialog, DialogClose, DialogContent } from './Dialog';
import { Button } from './Button';
interface ErrorAlertProps {
error: Error;
resetErrorBoundary: () => void;
className?: string;
}
const SubmitIssue = ({ error }: { error: Error }) => {
const title = error.message;
const body = `\`\`\`%0A${error.stack?.replaceAll('\n', '%0A')}%0A\`\`\``;
return (
<Button
as="a"
variant="caution"
href={`https://github.com/urbit/landscape/issues/new?assignees=&labels=bug&title=${title}&body=${body}`}
target="_blank"
rel="noreferrer"
>
Submit Issue
</Button>
);
};
export const ErrorAlert = ({ error, resetErrorBoundary, className }: ErrorAlertProps) => {
return (
<Dialog defaultOpen modal onOpenChange={() => resetErrorBoundary()}>
<DialogContent
showClose={false}
className={cn('pr-8 space-y-6', className)}
containerClass="w-full max-w-3xl"
>
<h2 className="h4">
<span className="mr-3 text-orange-500">Encountered error:</span>
<span className="font-mono">{error.message}</span>
</h2>
{error.stack && (
<div className="w-full p-2 bg-gray-50 overflow-x-auto rounded">
<pre>{error.stack}</pre>
</div>
)}
<div className="flex space-x-6">
<DialogClose as={Button} variant="secondary">
Try Again
</DialogClose>
<DialogClose as={SubmitIssue} error={error} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,47 @@
import classNames from 'classnames';
import React from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { Contact, Provider } from '@urbit/api';
import { ShipName } from './ShipName';
import { Avatar, AvatarSizes } from './Avatar';
export type ProviderLinkProps = Omit<LinkProps, 'to'> & {
provider: { shipName: string } & Contact;
size?: AvatarSizes;
selected?: boolean;
to?: (p: Provider) => LinkProps['to'];
adjustBG?: boolean;
};
export const ProviderLink = ({
provider,
to,
selected = false,
size = 'default',
className,
adjustBG,
...props
}: ProviderLinkProps) => {
const small = size === 'small' || size === 'xs';
return (
<Link
to={(to && to(provider)) || `/leap/search/${provider.shipName}/apps`}
className={classNames(
'flex items-center p-2 space-x-3 default-ring rounded-lg',
!small && 'ring-offset-2',
selected && 'bg-blue-200',
className
)}
{...props}
>
<Avatar size={size} shipName={provider.shipName} adjustBG={adjustBG} />
<div className="flex-1 text-black">
<div className="flex font-mono space-x-4">
<ShipName name={provider.shipName} />
<span className="text-gray-500">{provider.nickname}</span>
</div>
{provider.status && size === 'default' && <p className="font-normal">{provider.status}</p>}
</div>
</Link>
);
};

View File

@ -0,0 +1,64 @@
import React, { MouseEvent, useCallback } from 'react';
import { Contact, Provider } from '@urbit/api';
import classNames from 'classnames';
import { MatchItem } from '../nav/Nav';
import { useRecentsStore } from '../nav/search/Home';
import { ProviderLink, ProviderLinkProps } from './ProviderLink';
export type ProviderListProps = {
providers: ({ shipName: string } & Contact)[];
labelledBy: string;
matchAgainst?: MatchItem;
onClick?: (e: MouseEvent<HTMLAnchorElement>, p: Provider) => void;
listClass?: string;
adjustBG?: boolean;
} & Omit<ProviderLinkProps, 'provider' | 'onClick'>;
export function providerMatches(target: Provider, match?: MatchItem): boolean {
if (!match) {
return false;
}
const matchValue = match.display || match.value;
return target.nickname === matchValue || target.shipName === matchValue;
}
export const ProviderList = ({
providers,
labelledBy,
matchAgainst,
onClick,
listClass,
size = 'default',
...props
}: ProviderListProps) => {
const addRecentDev = useRecentsStore((state) => state.addRecentDev);
const selected = useCallback(
(provider: Provider) => providerMatches(provider, matchAgainst),
[matchAgainst]
);
return (
<ul
className={classNames('-mx-2', size !== 'default' ? 'space-y-4' : 'space-y-8', listClass)}
aria-labelledby={labelledBy}
>
{providers.map((p) => (
<li key={p.shipName} id={p.shipName} role="option" aria-selected={selected(p)}>
<ProviderLink
{...props}
size={size}
provider={p}
selected={selected(p)}
onClick={(e) => {
addRecentDev(p.shipName);
if (onClick) {
onClick(e, p);
}
}}
/>
</li>
))}
</ul>
);
};

View File

@ -0,0 +1,46 @@
import classNames from 'classnames';
import React, { FC, HTMLAttributes } from 'react';
import slugify from 'slugify';
import { useAsyncCall } from '../logic/useAsyncCall';
import { Spinner } from './Spinner';
import { Toggle } from './Toggle';
type SettingsProps = {
name: string;
on: boolean;
disabled?: boolean;
toggle: (open: boolean) => Promise<void>;
} & HTMLAttributes<HTMLDivElement>;
export const Setting: FC<SettingsProps> = ({
name,
on,
disabled = false,
toggle,
className,
children
}) => {
const { status, call } = useAsyncCall(toggle);
const id = slugify(name);
return (
<section className={className}>
<div className="flex space-x-2">
<Toggle
aria-labelledby={id}
pressed={on}
onPressedChange={call}
className="flex-none self-start text-blue-400"
disabled={disabled}
loading={status === 'loading'}
/>
<div className="flex-1 flex flex-col justify-center">
<h3 id={id} className="flex items-center font-semibold leading-6">
{name} {status === 'loading' && <Spinner className="h-4 w-4 ml-2" />}
</h3>
{children}
</div>
</div>
</section>
);
};

View File

@ -0,0 +1,34 @@
import { cite } from '@urbit/api';
import React, { HTMLAttributes } from 'react';
type ShipNameProps = {
name: string;
} & HTMLAttributes<HTMLSpanElement>;
export const ShipName = ({ name, ...props }: ShipNameProps) => {
const separator = /([_^-])/;
const citedName = cite(name);
if (!citedName) {
return null;
}
const parts = citedName.replace('~', '').split(separator);
const first = parts.shift();
return (
<span {...props}>
<span aria-hidden>~</span>
<span>{first}</span>
{parts.length > 1 && (
<>
{parts.map((piece, index) => (
<span key={`${piece}-${index}`} aria-hidden={separator.test(piece)}>
{piece}
</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} />
);

61
src/components/Toggle.tsx Normal file
View File

@ -0,0 +1,61 @@
import classNames from 'classnames';
import React, { useState } from 'react';
import * as RadixToggle from '@radix-ui/react-toggle';
import type * as Polymorphic from '@radix-ui/react-polymorphic';
type ToggleComponent = Polymorphic.ForwardRefComponent<
Polymorphic.IntrinsicElement<typeof RadixToggle.Root>,
Polymorphic.OwnProps<typeof RadixToggle.Root> & {
loading?: boolean;
toggleClass?: string;
knobClass?: string;
}
>;
export const Toggle = React.forwardRef(
(
{ defaultPressed, pressed, onPressedChange, disabled, className, toggleClass, loading = false },
ref
) => {
const [on, setOn] = useState(defaultPressed);
const isControlled = !!onPressedChange;
const proxyPressed = isControlled ? pressed : on;
const proxyOnPressedChange = isControlled ? onPressedChange : setOn;
const knobPosition = proxyPressed ? 16 : 8;
return (
<RadixToggle.Root
className={classNames('default-ring rounded-full', className)}
pressed={proxyPressed}
onPressedChange={proxyOnPressedChange}
disabled={disabled || loading}
ref={ref}
>
<svg
className={classNames('w-6 h-6', toggleClass)}
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<rect
className={classNames(
'fill-current',
disabled && proxyPressed && 'text-gray-700',
!proxyPressed && 'text-gray-200'
)}
y="4"
width="24"
height="16"
rx="8"
/>
<circle
className={classNames('fill-current text-white', disabled && 'opacity-60')}
cx={knobPosition}
cy="12"
r="6"
/>
</svg>
</RadixToggle.Root>
);
}
) as ToggleComponent;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { daToDate, Treaty } from '@urbit/api';
import moment from 'moment';
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">
{moment(daToDate(cass.da)).format('YYYY.MM.DD')}
</Attribute>
{meta.map((d) => (
<Attribute key={d} attr={d}>
{treaty[d]}
</Attribute>
))}
</div>
);
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Vat } from '@urbit/api';
import { Attribute } from './Attribute';
export function VatMeta(props: { vat: Vat }) {
const { vat } = props;
const { desk, arak, cass, hash } = vat;
const { desk: foreignDesk, ship, next } = arak.rail || {};
const pluralUpdates = next?.length !== 1;
return (
<div className="mt-5 sm:mt-8 space-y-5 sm:space-y-8">
<Attribute title="Desk Hash" attr="hash">
{hash}
</Attribute>
<Attribute title="Installed into" attr="local-desk">
%{desk}
</Attribute>
{next && next.length > 0 ? (
<Attribute attr="next" title="Pending Updates">
{next.length} update{pluralUpdates ? 's are' : ' is'} pending a System Update
</Attribute>
) : null}
</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,14 @@
import React from 'react';
export default function BellIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 11a6.002 6.002 0 0 1 5-5.917V3h2v2.083c2.838.476 5 2.944 5 5.917v2.382l.764.382a2.236 2.236 0 0 1-1 4.236H15a3 3 0 1 1-6 0H6.236a2.236 2.236 0 0 1-1-4.236L6 13.382V11Zm5 7a1 1 0 1 0 2 0h-2Zm-3-7a4 4 0 1 1 8 0v2.902c0 .439.248.84.64 1.036l1.23.615a.236.236 0 0 1-.106.447H6.236a.236.236 0 0 1-.106-.447l1.23-.615c.392-.196.64-.597.64-1.036V11Z"
className="fill-current"
/>
</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 className="fill-current" cx="8" cy="8" r="3" />
</svg>
);

View File

@ -0,0 +1,14 @@
import React from 'react';
export default function BurstIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M13 4a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0V4Zm0 14a1 1 0 1 0-2 0v2a1 1 0 1 0 2 0v-2Zm8-6a1 1 0 0 1-1 1h-2a1 1 0 1 1 0-2h2a1 1 0 0 1 1 1ZM6 13a1 1 0 1 0 0-2H4a1 1 0 1 0 0 2h2Zm-.364-7.364a1 1 0 0 1 1.414 0L8.464 7.05A1 1 0 0 1 7.05 8.464L5.636 7.05a1 1 0 0 1 0-1.414Zm11.314 9.9a1 1 0 0 0-1.414 1.414l1.414 1.414a1 1 0 0 0 1.414-1.414l-1.414-1.414Zm1.414-9.9a1 1 0 0 1 0 1.414L16.95 8.464a1 1 0 0 1-1.414-1.414l1.414-1.414a1 1 0 0 1 1.414 0Zm-9.9 11.314a1 1 0 0 0-1.414-1.414L5.636 16.95a1 1 0 1 0 1.414 1.414l1.414-1.414Z"
className="fill-current"
/>
</svg>
);
}

View File

@ -0,0 +1,12 @@
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"
className="fill-current"
/>
</svg>
);

View File

@ -0,0 +1,14 @@
import React, { HTMLAttributes } from 'react';
type ElbowProps = HTMLAttributes<SVGSVGElement>;
export const Elbow = (props: ElbowProps) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M11 1V5C11 9.41828 14.5817 13 19 13H23"
className="stroke-current"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);

View File

@ -0,0 +1,9 @@
import React from 'react';
export default function ForwardSlashIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m10 17 4-10" className="stroke-current" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}

View File

@ -0,0 +1,13 @@
import { IconProps } from '@radix-ui/react-icons/dist/types';
import React from 'react';
export const FullTlon16Icon = ({ className }: IconProps) => {
return (
<svg className={className} fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 34 16">
<path
className="fill-current"
d="M7.87365.000563h6.71375c.1716-.0039.3431.012482.5109.04878.5353.129094.8716.573732.8859 1.201987.0172.78603.0215 1.57781.0072 2.35667-.0129.69137-.3864 1.06574-1.072 1.07147-1.2652.01148-2.5303 0-3.7955 0a2.545091 2.545091 0 0 0-.3764.01865c-.3421.04877-.4809.20225-.4924.5465v2.71382c0 .36146.1833.5637.4809.57374.3493.01435.5725-.17785.5725-.52927.0115-.53932 0-1.07865.0086-1.61797 0-.45756.1918-.64116.6512-.64116h2.9368c.7156 0 1.0691.34999 1.0705 1.06431v3.25311c0 1.4803-.02 2.9606 0 4.4394.0158.8606-.5152 1.4989-1.4784 1.486H3.26662c-.68125 0-1.36107.0301-2.04232 0-.787156-.0373-1.216515-.4862-1.216515-1.2751V9.11742c0-.74444-.007156-1.49032 0-2.2362 0-.55223.273366-.9481.73278-1.0815a1.238137 1.238137 0 0 1 .354935-.04734h2.69637c.59681 0 .88734.28688.89164.88644v1.32536c0 .32273.18033.54219.45368.57375a.5014.5014 0 0 0 .22267-.00569.501947.501947 0 0 0 .19833-.10161.50343.50343 0 0 0 .13498-.17756.504356.504356 0 0 0 .04513-.21861c.01574-.20511.00859-.41309.00859-.61964V5.24749c0-.33277-.15172-.49199-.47946-.54076a2.605014 2.605014 0 0 0-.40932-.02439H.850761c-.530973 0-.83152-.28688-.8444-.8305-.014312-.59096 0-1.18192 0-1.77288v-.92947C.00636.314691.315499.001998 1.14273.001998L7.87365.000563ZM29.2252 15.9643c-.5216.0157-.9853-.1763-1.4338-.4195-.4628-.2677-.9745-.4334-1.5027-.4865-.665-.0318-1.3256.1267-1.9086.458-.3566.2051-.7407.3546-1.1398.4438-1.46.2688-2.9697-.8534-3.1864-2.3639-.0704-.4879.0111-.9629.0745-1.4408.1546-1.1734-.2111-2.16194-1.0101-2.98689-.3505-.36553-.69-.73249-.897-1.21324-.1611-.39038-.2359-.81285-.2189-1.23673.017-.42388.1253-.83855.3172-1.21388.1919-.37534.4624-.70193.792-.95608.3296-.25414.7099-.42947 1.1134-.51322.4588-.06405.9089-.18241 1.3414-.35274.7951-.36428 1.4353-1.01303 1.8022-1.82625.276-.56893.6141-1.062479 1.1344-1.413791.6172-.403602 1.3646-.538316 2.0782-.374591.7135.163724 1.3349.612515 1.7277 1.247892.1987.33425.3685.68698.5796 1.01269.6003.92451 1.4752 1.39104 2.524 1.53611.7176.09814 1.351.35132 1.8437.92451.4818.53287.7479 1.23536.7438 1.96376-.0042.7284-.2781 1.42766-.7659 1.95473-.305.34421-.6321.66848-.8667 1.07242-.4816.8278-.574 1.7153-.4057 2.6484.1753.9714 0 1.8604-.6596 2.6042-.2651.2946-.5864.5294-.9439.6898-.3574.1603-.7432.2427-1.133.2418Zm-2.3611-7.5994c.0021-.22395-.0796-.44012-.2282-.60356-.1485-.16345-.3525-.26158-.5694-.27402-.2093.00369-.409.09132-.5567.24429-.1476.15297-.2316.35922-.2341.57497-.0048.22707.0774.44697.2287.61204.1514.16506.3598.26199.58.26979.2091-.00337.4085-.09185.5545-.24613.1461-.15428.227-.36183.2252-.57738Z"
/>
</svg>
);
};

View File

@ -0,0 +1,14 @@
import React from 'react';
export default function HelpIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12c0 5.523 4.477 10 10 10s10-4.477 10-10S17.523 2 12 2 2 6.477 2 12Zm2 0a8 8 0 1 0 16 0 8 8 0 0 0-16 0Zm6.818 2.008v.158h1.75v-.158a2.35 2.35 0 0 1 .115-.761c.077-.206.193-.388.347-.545.156-.157.357-.308.6-.452.289-.173.54-.365.752-.577a2.34 2.34 0 0 0 .494-.74c.12-.28.18-.6.18-.96 0-.538-.134-.998-.401-1.38a2.52 2.52 0 0 0-1.108-.872c-.471-.203-1.013-.305-1.626-.305-.556 0-1.066.1-1.527.3a2.538 2.538 0 0 0-1.113.9c-.28.4-.428.908-.443 1.524h1.883c.01-.252.071-.463.185-.633.114-.172.26-.301.438-.387.179-.09.368-.134.568-.134.206 0 .394.043.563.13.173.085.31.208.411.368.102.16.153.347.153.559 0 .2-.045.382-.134.545-.09.16-.21.306-.36.438-.151.133-.322.26-.513.383-.255.16-.474.339-.655.536-.182.197-.32.455-.416.775-.092.32-.14.75-.143 1.288Zm.125 2.789c.218.215.482.323.79.323.196 0 .378-.05.544-.148.166-.101.3-.235.402-.401a1.06 1.06 0 0 0-.175-1.334 1.073 1.073 0 0 0-.772-.324c-.307 0-.57.108-.79.323a1.042 1.042 0 0 0-.318.776c-.003.305.103.566.319.785Z"
className="fill-current"
/>
</svg>
);
}

View File

@ -0,0 +1,11 @@
import React from 'react';
export const Interface = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M10.2629 19.2638C10.2308 19.2612 10.1984 19.2651 10.1678 19.2752C10.1372 19.2852 10.1089 19.3012 10.0845 19.3223C10.0601 19.3433 10.04 19.369 10.0255 19.3978C10.011 19.4267 10.0023 19.4581 10 19.4903C10 22.5145 12.8617 25 16.3777 25H16.6223C20.1383 25 23 22.5512 23 19.4903C22.9977 19.4581 22.9891 19.4267 22.9746 19.3978C22.9601 19.369 22.9401 19.3433 22.9157 19.3223C22.8912 19.3012 22.8628 19.2852 22.8322 19.2752C22.8016 19.2651 22.7692 19.2612 22.7371 19.2638H16.763V17.7517C19.7531 17.6353 22.1256 15.5845 22.1989 12.9643V12.7317L21.777 7.86476C21.7352 7.61399 21.6021 7.38762 21.4034 7.22934C21.2046 7.07107 20.9543 6.99211 20.7008 7.00769H20.6458C20.3817 6.99791 20.1209 7.06726 19.8965 7.20687C19.6721 7.34647 19.4944 7.54997 19.3862 7.7913L18.7747 9.54217C18.7576 9.5815 18.7294 9.61496 18.6935 9.63846C18.6577 9.66197 18.6158 9.67448 18.5729 9.67448C18.5301 9.67448 18.4881 9.66197 18.4523 9.63846C18.4164 9.61496 18.3882 9.5815 18.3711 9.54217L17.7596 7.78517C17.6442 7.55011 17.4653 7.35215 17.2432 7.21371C17.0211 7.07526 16.7647 7.00187 16.5031 7.00187C16.2414 7.00187 15.985 7.07526 15.7629 7.21371C15.5408 7.35215 15.362 7.55011 15.2465 7.78517L14.635 9.54217C14.618 9.58128 14.5896 9.6144 14.5537 9.63731C14.5177 9.66021 14.4758 9.67185 14.4332 9.67073C14.3897 9.67226 14.3467 9.66086 14.3097 9.63798C14.2727 9.6151 14.2433 9.58177 14.2252 9.54217L13.6138 7.77906C13.5084 7.54379 13.3365 7.34468 13.119 7.20646C12.9016 7.06824 12.6483 6.99699 12.3908 7.00157H12.3298C12.0757 6.98756 11.8254 7.0679 11.6268 7.22718C11.4282 7.38645 11.2953 7.61347 11.2536 7.86476L10.8317 12.9582C10.8287 12.9764 10.8287 12.995 10.8317 13.0133C10.9295 15.6029 13.2775 17.6292 16.2676 17.7455V19.2576L10.2629 19.2638Z"
className="stroke-current"
strokeMiterlimit="10"
/>
</svg>
);

View File

@ -0,0 +1,15 @@
import React, { HTMLAttributes } from 'react';
type LeftArrowProps = HTMLAttributes<SVGSVGElement>;
export const LeftArrow = (props: LeftArrowProps) => (
<svg viewBox="0 0 17 17" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M16 8.5H1M1 8.5L7.5 2M1 8.5L7.5 15"
className="stroke-current"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);

View File

@ -0,0 +1,13 @@
import React from 'react';
export const Lock = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="-8.5 -6.5 26 26" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 5H9C9.55228 5 10 5.44772 10 6V11C10 11.5523 9.55229 12 9 12H1C0.447716 12 0 11.5523 0 11V6C0 5.44772 0.447715 5 1 5H2V3C2 1.34315 3.34315 0 5 0C6.65685 0 8 1.34315 8 3V5ZM7 5V3C7 1.89543 6.10457 1 5 1C3.89543 1 3 1.89543 3 3V5H7ZM3 6H9V11H1V6H2H3Z"
className="fill-current"
strokeMiterlimit="10"
/>
</svg>
);

View File

@ -0,0 +1,14 @@
import React from 'react';
export default function LogoutIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7.414 11H14a1 1 0 1 1 0 2H7.414l2.293 2.293a1 1 0 0 1-1.414 1.414l-4-4a1 1 0 0 1 0-1.414l4-4a1 1 0 0 1 1.414 1.414L7.414 11ZM14 18a1 1 0 1 0 0 2h3a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3h-3a1 1 0 1 0 0 2h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1h-3Z"
className="fill-current"
/>
</svg>
);
}

View File

@ -0,0 +1,14 @@
import React from 'react';
export default function MagnifyingGlassIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10 11a3 3 0 1 1 6 0 3 3 0 0 1-6 0Zm3-5a5 5 0 0 0-4.172 7.757l-.535.536-2 2a1 1 0 1 0 1.414 1.414l2-2 .536-.535A5 5 0 1 0 13 6Z"
className="fill-current"
/>
</svg>
);
}

View File

@ -0,0 +1,11 @@
import React from 'react';
export const Notifications = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M22.484 18.3687C22.2804 18.4174 22.0769 18.4601 21.9025 18.5149C20.0649 18.9716 19.8962 19.2518 20.2218 21.2006C20.3381 21.8826 20.4428 22.5647 20.5359 23.2529C20.6114 23.5034 20.6135 23.7719 20.542 24.0237C20.4705 24.2756 20.3286 24.4992 20.1347 24.6658C19.5997 25.0251 19.0879 24.7389 18.6402 24.4161C17.9249 23.8984 17.2271 23.3564 16.5584 22.7779C16.428 22.6392 16.2719 22.5299 16.0997 22.4569C15.9274 22.3838 15.7427 22.3485 15.5569 22.353C15.3711 22.3576 15.1882 22.402 15.0194 22.4834C14.8506 22.5649 14.6995 22.6817 14.5755 22.8266C13.8951 23.4356 13.1915 24.0446 12.4762 24.6232C12.3064 24.8171 12.0787 24.9449 11.8305 24.9858C11.5823 25.0268 11.3283 24.9783 11.1098 24.8485C10.9052 24.6924 10.759 24.4666 10.6964 24.2098C10.6337 23.953 10.6586 23.6814 10.7666 23.4417C11.0225 22.4835 11.3074 21.5355 11.6214 20.5977C11.771 20.2589 11.7871 19.8721 11.6663 19.5211C11.5455 19.17 11.2975 18.8829 10.976 18.7219C10.1968 18.2286 9.42927 17.7232 8.67914 17.1872C8.45772 17.0624 8.27581 16.8726 8.15598 16.6411C8.03615 16.4095 7.98352 16.1464 8.0045 15.884C8.08591 15.275 8.58604 15.0618 9.05705 14.9339C9.73741 14.7634 10.4236 14.6173 11.1098 14.4711C12.8194 14.1179 13.0694 13.7342 12.7844 11.8889C12.6361 11.1242 12.5275 10.3517 12.4589 9.57472C12.3891 8.27146 13.0869 7.8147 14.1743 8.4846C14.9445 8.98903 15.6791 9.55076 16.3724 10.1655C16.5106 10.3113 16.6759 10.426 16.8581 10.5023C17.0402 10.5787 17.2354 10.6151 17.4316 10.6094C17.6278 10.6037 17.8208 10.556 17.9986 10.4691C18.1765 10.3823 18.3354 10.2582 18.4657 10.1046C19.107 9.49923 19.7786 8.93003 20.4777 8.39935C20.9022 8.08267 21.4255 7.79035 21.9547 8.20447C22.4839 8.61859 22.327 9.15451 22.199 9.6539C21.9864 10.4894 21.7321 11.3127 21.4372 12.1203C21.3314 12.3196 21.267 12.5401 21.2484 12.7673C21.2298 12.9945 21.2575 13.2232 21.3295 13.4384C21.4014 13.6536 21.5162 13.8504 21.6661 14.0158C21.8159 14.1812 21.9976 14.3115 22.199 14.398C22.8503 14.7695 23.4725 15.2019 24.0889 15.616C24.5715 15.9632 25.0773 16.3651 24.9901 17.0594C24.8854 17.8693 24.1703 17.9363 23.5946 18.1251C23.2291 18.2283 22.8583 18.3096 22.484 18.3687V18.3687Z"
className="stroke-current"
strokeMiterlimit="10"
/>
</svg>
);

View File

@ -0,0 +1,14 @@
import React from 'react';
export default function PencilIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.707 4.707a1 1 0 1 0-1.414-1.414l-9 9a1 1 0 0 0-.255.432l-2 7a1 1 0 0 0 1.237 1.236l7-2a.999.999 0 0 0 .432-.254l9-9a1 1 0 1 0-1.414-1.414l-8.817 8.817-3.04.868 13.271-13.27a1 1 0 1 0-1.414-1.415L6.022 16.564l.868-3.04 8.817-8.817ZM15 19a1 1 0 1 0 0 2h5a1 1 0 1 0 0-2h-5Z"
className="fill-current"
/>
</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>
);

View File

@ -0,0 +1,14 @@
import React from 'react';
export const System = (props: React.SVGProps<SVGSVGElement>) => (
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M9.09149 13.2275V9.83173c0-.43776.08753-.65664.62518-.66289 2.25063 0 4.50133-.08129 6.75203-.11256 2.2506-.03127 4.3763 0 6.5957-.05628.4939 0 .6689.13133.6252.62536-.0438.96304 0 1.93864 0 2.90794 0 .3127.075.4565.4501.5253.5066.0783.987.2769 1.401.5792.4141.3023.7497.6994.9787 1.1581.229.4588.3447.9657.3375 1.4784-.0073.5127-.1372 1.0162-.379 1.4683-.2095.4663-.5372.8697-.9506 1.1703-.4135.3006-.8983.4878-1.4064.5432-.1375 0-.2813.444-.2938.6816-.05 1.0882-.0563 2.1763-.0688 3.2707 0 .3189-.1313.4377-.4689.4377-4.4951 0-8.9902 0-13.48527.0438-.55016 0-.62518-.2939-.62518-.6816 0-.9506-.05001-1.8761 0-2.8517.04376-.6254-.28744-.838-.79384-.9193-1.38791-.2314-2.32572-1.676-2.38199-3.0205-.03052-.6682.16336-1.3274.55085-1.8725.3875-.5451.94626-.9449 1.58723-1.1355.1563-.0438.30633-.1251.46263-.1626.16181-.0189.32487-.0252.48766-.0187Z"
className="stroke-current"
/>
<path
d="M16.2934 19.4812h-.05v.0563H14.9117l-.0016.0001c-.0961.0032-.2028.0176-.2892.0657-.09.0501-.1547.1351-.1654.2679-.0106.1304.0339.2312.1139.298.0779.0651.1843.0937.2923.0937H17.7289c.0572.0054.115-.0006.1699-.0175.0558-.0172.1075-.0454.1522-.0828.0447-.0375.0816-.0836.1082-.1355.0267-.052.0427-.1087.0471-.1669l.0001-.0028c.0017-.0881-.0152-.1591-.049-.2149-.0342-.0562-.0829-.0929-.1374-.1165-.1034-.0448-.234-.0448-.3394-.0448H16.2934ZM14.0053 14.2844h.0018c.0969-.0035.1891-.0055.2778-.0075.2274-.0049.4316-.0094.6313-.0364.124-.0055.2417-.0562.3307-.1427.0896-.0871.1439-.2043.1524-.3291l.0003.0001-.0002-.0053c-.0023-.0631-.0179-.1249-.0457-.1816-.0277-.0566-.0671-.1068-.1155-.1473-.0484-.0404-.1047-.0703-.1654-.0875a.449567.449567 0 0 0-.1835-.0132c-.5885.0065-1.1767.0565-1.7566.1064-.049.0029-.097.0153-.1412.0367a.372835.372835 0 0 0-.1171.0883c-.0328.0369-.0581.0798-.0743.1265-.0161.0463-.0229.0953-.0202.1443.002.0975.0407.1908.1083.2611.0669.0696.1573.1117.2535.1181.3105.0691.626.0691.8618.0691h.0018ZM17.5314 13.8357c.0035.1284.0527.2282.1316.2952.0774.0657.1791.0965.2843.0971.2807.0311.5886.0625.9094.0625v.0001l.0035-.0002c.0818-.0057.1676-.0097.2558-.0138.1959-.0091.4034-.0187.6032-.049.1144-.0104.2213-.062.3006-.1452.0797-.0836.1264-.1934.131-.3089v-.0008c.0035-.1469-.0549-.2558-.1449-.3266-.0877-.0691-.202-.0996-.3101-.0998-.6085-.0344-1.219-.0028-1.8208.0941-.0966.0096-.1861.0556-.25.1289-.0642.0735-.0976.1689-.0936.2664Zm0 0v-.0003l.0499-.0014-.0499.0021v-.0004ZM13.9928 15.4725v.0004l.0059-.0008c.1305-.0155.2786-.038.394-.1095.0588-.0365.1096-.0859.1453-.1532.0357-.0672.0549-.1494.0549-.2497 0-.1011-.022-.1847-.0626-.2524-.0406-.0677-.0981-.1166-.1642-.1514-.1301-.0685-.2968-.0839-.4484-.0839-.155 0-.315.0304-.4321.1103-.0593.0404-.108.0938-.1384.1621-.0303.0683-.0411.1485-.0292.2405.0235.1814.1023.3066.2253.3845.1203.0761.2768.1031.4495.1031ZM18.7441 15.4727v.0005l.0069-.0009c.1326-.0185.2796-.0412.3927-.1128.0578-.0367.1072-.0863.1417-.1542.0344-.0674.0527-.1502.0527-.2516 0-.1024-.0215-.186-.0617-.2532-.0404-.0673-.0976-.1147-.1635-.1479-.1293-.0651-.2961-.0773-.449-.0804-.1578-.0032-.3156.0237-.4305.1035-.1191.0828-.1847.2173-.1642.4084.0201.1879.099.314.2236.3906.1213.0745.2795.098.4513.098ZM15.7842 17.8231l-.0001.0004.006.0004c.0478.003.0957-.0039.1407-.0201.045-.0163.0864-.0416.1213-.0744.0348-.0328.0626-.0725.0815-.1164.0185-.043.0282-.0892.0285-.1359.0048-.0383.0016-.0771-.0093-.1141-.0113-.0381-.0304-.0734-.0563-.1036-.0258-.0302-.0578-.0545-.0937-.0715-.0344-.0162-.0716-.0252-.1095-.0266-.0465-.0098-.0946-.0096-.141.0008-.048.0107-.0931.032-.1319.0623-.0389.0304-.0703.069-.0923.113a.333125.333125 0 0 0-.0348.1395c-.01.0975.0127.1778.0678.2379.054.0588.1332.0917.2231.1083ZM17.3058 17.4694l.0003.0001-.3123-.296.0033-.0499h-.0005c-.1014-.0067-.1971.0132-.2688.067-.073.0549-.1147.1401-.1152.2495-.0037.0429.0012.0861.0147.1271.0137.0417.0359.0801.0651.1128.0293.0327.065.0591.1048.0774.039.0179.0812.0277.1241.0289.1044.0133.1935-.0112.261-.0701.0668-.0584.1066-.1456.1235-.2468Z"
className="stroke-current"
/>
</svg>
);

View File

@ -0,0 +1,12 @@
import React from 'react';
export default function TlonIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.874 4h6.713c.172-.003.343.013.511.05.536.128.872.573.886 1.201.017.786.022 1.578.007 2.357-.012.691-.386 1.066-1.072 1.071-1.265.012-2.53 0-3.795 0a2.545 2.545 0 0 0-.377.02c-.342.048-.48.201-.492.546v2.713c0 .362.183.564.481.574.35.014.572-.178.572-.53.012-.538 0-1.078.01-1.617 0-.458.19-.641.65-.641h2.937c.716 0 1.07.35 1.07 1.064v3.253c0 1.48-.02 2.96 0 4.44.016.86-.515 1.498-1.478 1.486H7.267c-.682 0-1.361.03-2.043 0-.787-.038-1.216-.487-1.216-1.275v-5.595c0-.744-.007-1.49 0-2.236 0-.552.273-.948.733-1.081.115-.033.235-.05.355-.048h2.696c.597 0 .887.287.892.887v1.325c0 .323.18.542.453.574a.5.5 0 0 0 .556-.285.505.505 0 0 0 .045-.219c.016-.205.009-.413.009-.62V9.248c0-.332-.152-.492-.48-.54a2.605 2.605 0 0 0-.409-.025H4.851c-.531 0-.832-.287-.845-.83-.014-.591 0-1.182 0-1.773v-.93c0-.834.31-1.147 1.137-1.147l6.73-.001Z"
className="fill-current"
/>
</svg>
);
}

8
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
interface ImportMetaEnv extends Readonly<Record<string, string | boolean | undefined>> {
readonly VITE_LAST_WIPE: string;
readonly VITE_STORAGE_VERSION: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

19
src/global.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
declare module 'urbit-ob' {
export function isValidPatp(patp: string): boolean;
}
type Stringified<T> = string &
{
[P in keyof T]: { '_ value': T[P] };
};
interface JSON {
// stringify(value: any, replacer?: (key: string, value: any) => any, space?: string | number): string;
stringify<T>(
value: T,
replacer?: (key: string, value: any) => any,
space?: string | number
): string & Stringified<T>;
// parse(text: string, reviver?: (key: any, value: any) => any): any;
parse<T>(text: Stringified<T>, reviver?: (key: any, value: any) => any): T;
}

30
src/logic/useAsyncCall.ts Normal file
View File

@ -0,0 +1,30 @@
import { useCallback, useState } from 'react';
export type Status = 'initial' | 'loading' | 'success' | 'error';
export function useAsyncCall<ReturnValue>(cb: (...args: any[]) => Promise<ReturnValue>) {
const [status, setStatus] = useState<Status>('initial');
const [error, setError] = useState<Error | null>(null);
const call = useCallback(
(...args: any[]) => {
setStatus('loading');
cb(...args)
.then((result) => {
setStatus('success');
return result;
})
.catch((err) => {
setError(err);
setStatus('error');
});
},
[cb]
);
return {
call,
status,
error
};
}

30
src/logic/useDebounce.ts Normal file
View File

@ -0,0 +1,30 @@
import { debounce, DebounceSettings } from 'lodash';
import { useRef, useEffect, useCallback } from 'react';
import { useIsMounted } from './useIsMounted';
export function useDebounce(
cb: (...args: any[]) => void,
delay: number,
options?: DebounceSettings
) {
const isMounted = useIsMounted();
const inputsRef = useRef({ cb, delay }); // mutable ref like with useThrottle
useEffect(() => {
inputsRef.current = { cb, delay };
}); // also track cur. delay
return useCallback(
debounce(
(...args) => {
// Debounce is an async callback. Cancel it, if in the meanwhile
// (1) component has been unmounted (see isMounted in snippet)
// (2) delay has changed
if (inputsRef.current.delay === delay && isMounted()) inputsRef.current.cb(...args);
},
delay,
options
),
[delay, debounce]
);
}

View File

@ -0,0 +1,17 @@
import { useErrorHandler as useBoundaryHandler } from 'react-error-boundary';
export function useErrorHandler() {
const handle = useBoundaryHandler();
function handleError(cb: (...args: any[]) => any) {
return (...args: any[]) => {
try {
cb(...args);
} catch (error) {
handle(error);
}
};
}
return handleError;
}

11
src/logic/useIsMounted.ts Normal file
View File

@ -0,0 +1,11 @@
import { useRef, useEffect } from 'react';
export function useIsMounted() {
const isMountedRef = useRef(true);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
return () => isMountedRef.current;
}

21
src/logic/useMedia.ts Normal file
View File

@ -0,0 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
export const useMedia = (mediaQuery: string) => {
const [match, setMatch] = useState(false);
const update = useCallback((e: MediaQueryListEvent) => {
setMatch(e.matches);
}, []);
useEffect(() => {
const query = window.matchMedia(mediaQuery);
query.addEventListener('change', update);
update({ matches: query.matches } as MediaQueryListEvent);
return () => {
query.removeEventListener('change', update);
};
}, [update]);
return match;
};

46
src/logic/useQuery.ts Normal file
View File

@ -0,0 +1,46 @@
import _ from 'lodash';
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
function mergeQuery(search: URLSearchParams, added: Record<string, string>) {
_.forIn(added, (v, k) => {
if (v) {
search.append(k, v);
} else {
search.delete(k);
}
});
}
export function useQuery() {
const { search, pathname } = useLocation();
const query = useMemo(() => new URLSearchParams(search), [search]);
const appendQuery = useCallback(
(added: Record<string, string>) => {
const q = new URLSearchParams(search);
mergeQuery(q, added);
return q.toString();
},
[search]
);
const toQuery = useCallback(
(params: Record<string, string>, path = pathname) => {
const q = new URLSearchParams(search);
mergeQuery(q, params);
return {
pathname: path,
search: q.toString()
};
},
[search, pathname]
);
return {
query,
appendQuery,
toQuery
};
}

View File

@ -0,0 +1,45 @@
import { kilnBump, Vat } from '@urbit/api';
import { partition, pick } from 'lodash';
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import api from '../state/api';
import { useCharges } from '../state/docket';
import useKilnState, { useVat } from '../state/kiln';
export function vatIsBlocked(newKelvin: number | undefined, vat: Vat) {
if (!newKelvin) {
return false;
}
return !(vat.arak?.rail?.next || []).find(({ weft }) => weft.kelvin === newKelvin);
}
export function useSystemUpdate() {
const { push } = useHistory();
const base = useVat('base');
const update = base?.arak?.rail?.next?.[0];
const newKelvin = update?.weft?.kelvin;
const charges = useCharges();
const [blocked] = useKilnState((s) => {
const [b, u] = partition(Object.entries(s.vats), ([, vat]) => vatIsBlocked(newKelvin, vat));
return [b.map(([d]) => d), u.map(([d]) => d)] as const;
});
const systemBlocked = update && blocked;
const blockedCharges = Object.values(pick(charges, blocked));
const blockedCount = blockedCharges.length;
const freezeApps = useCallback(async () => {
api.poke(kilnBump(true));
push('/leap/upgrading');
}, []);
return {
base,
update,
systemBlocked,
blockedCharges,
blockedCount,
freezeApps
};
}

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
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')
);

114
src/nav/Help.tsx Normal file
View File

@ -0,0 +1,114 @@
import React from 'react';
import cn from 'classnames';
import { Button } from '../components/Button';
interface Group {
title: string;
icon: string;
color: string;
link: string;
}
const groups: Record<string, Group> = {
uc: {
title: 'Urbit Community',
icon: 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2021.4.02..21.52.41-UC.png',
color: 'bg-black',
link: '/apps/landscape/~landscape/ship/~bitbet-bolbel/urbit-community'
},
discovery: {
title: 'Group Discovery',
icon: 'https://urbit.me/images/icons/icon-512x512.png',
color: 'bg-green-300',
link: '/apps/landscape/~landscape/ship/~rondev/group-discovery'
},
foundation: {
title: 'Foundation',
icon: 'https://interstellar.nyc3.digitaloceanspaces.com/battus-datsun/2022.6.10..21.28.21-urbit-inverted.png',
color: 'bg-black',
link: '/apps/landscape/~landscape/ship/~wolref-podlex/foundation'
},
forge: {
title: 'The Forge',
icon: '',
color: 'bg-black',
link: '/apps/landscape/~landscape/ship/~middev/the-forge'
},
tlonSupport: {
title: 'Tlon Support Forum',
icon: '',
color: 'bg-yellow-500',
link: '/apps/landscape/~landscape/ship/~bitpyx-dildus/tlon-support'
}
};
const GroupLink = ({ group }: { group: Group }) => (
<div className="flex justify-between items-center py-2">
<div className="flex space-x-2 items-center">
{group.icon === '' ? (
<div className={cn('w-8 h-8 rounded', group.color)} />
) : (
<img className="w-8 h-8 rounded" src={group.icon} alt={`${group.title} icon`} />
)}
<div className="flex flex-col">
<span className="font-semibold">{group.title}</span>
</div>
</div>
<Button variant="alt-primary" as="a" href={group.link} target="_blank">
{' '}
Open in Groups{' '}
</Button>
</div>
);
const Wayfinding = ({ tlonCustomer }: { tlonCustomer: boolean }) => (
<div className="inner-section space-y-8">
<span className="text-lg font-bold">
Urbit{!tlonCustomer ? ' Support & ' : null} Wayfinding
</span>
<p>
A community of Urbit enthusiasts, developers, and various Urbit-building organizations are on
the network to guide you.
</p>
<p>
For direct assistance with any urbit-related issues, bugs, or unexpected behavior, please
contact <a href="mailto:support@urbit.org">support@urbit.org</a>.
</p>
<p>
If you need help getting situated on the network, or figuring out what fun things you can do
with your urbit, join the following groups:
</p>
<div className="flex flex-col space-y-2">
<GroupLink group={groups.uc} />
<GroupLink group={groups.discovery} />
</div>
<p>
If you are a developer and want to learn more about building applications for Urbit, check out
these groups:
</p>
<div className="flex flex-col space-y-2">
<GroupLink group={groups.foundation} />
<GroupLink group={groups.forge} />
</div>
</div>
);
export const Help = () => {
const tlonCustomer = !!window.URL.toString().indexOf('tlon.network');
return (
<div className="flex flex-col space-y-4">
{tlonCustomer ? (
<div className="inner-section space-y-8">
<span className="text-lg font-bold">Tlon Customer Support</span>
<p>
As a customer of Tlon, youre able to receive 24/7 support from the{' '}
<span className="font-bold">Tlon Support Forum</span>, or you can email us at{' '}
<a href="mailto:support@tlon.io">support@tlon.io</a>.
</p>
<GroupLink group={groups.tlonSupport} />
</div>
) : null}
<Wayfinding tlonCustomer={tlonCustomer} />
</div>
);
};

328
src/nav/Leap.tsx Normal file
View File

@ -0,0 +1,328 @@
import classNames from 'classnames';
import React, {
ChangeEvent,
FocusEvent,
FormEvent,
KeyboardEvent,
HTMLAttributes,
useCallback,
useImperativeHandle,
useRef,
useEffect
} from 'react';
import { Link, useHistory, useLocation, useRouteMatch } from 'react-router-dom';
import { Cross } from '../components/icons/Cross';
import { useDebounce } from '../logic/useDebounce';
import { useErrorHandler } from '../logic/useErrorHandler';
import { useMedia } from '../logic/useMedia';
import { MenuState, useLeapStore } from './Nav';
function normalizePathEnding(path: string) {
const end = path.length - 1;
return path[end] === '/' ? path.substring(0, end - 1) : path;
}
export function createPreviousPath(current: string): string {
const parts = normalizePathEnding(current).split('/');
parts.pop();
if (parts[parts.length - 1] === 'leap') {
parts.push('search');
}
return parts.join('/');
}
type LeapProps = {
menu: MenuState;
dropdown: string;
navOpen: boolean;
systemMenuOpen: boolean;
} & HTMLAttributes<HTMLDivElement>;
function normalizeMatchString(match: string, keepAltChars: boolean): string {
let normalizedString = match.toLocaleLowerCase().trim();
if (!keepAltChars) {
normalizedString = normalizedString.replace(/[^\w]/, '');
}
return normalizedString;
}
export const Leap = React.forwardRef(
({ menu, dropdown, navOpen, systemMenuOpen, className }: LeapProps, ref) => {
const { push } = useHistory();
const location = useLocation();
const isMobile = useMedia('(max-width: 639px)');
const deskMatch = useRouteMatch<{ menu?: MenuState; query?: string; desk?: string }>(
`/leap/${menu}/:query?/(apps)?/:desk?`
);
const systemPrefMatch = useRouteMatch<{ submenu: string }>(`/leap/system-preferences/:submenu`);
const appsMatch = useRouteMatch(`/leap/${menu}/${deskMatch?.params.query}/apps`);
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current);
const { rawInput, selectedMatch, matches, selection, select } = useLeapStore();
const handleError = useErrorHandler();
useEffect(() => {
const onTreaty = appsMatch && !appsMatch.isExact;
if (selection && rawInput === '' && !onTreaty) {
inputRef.current?.focus();
} else if (selection && onTreaty) {
inputRef.current?.blur();
}
}, [selection, rawInput, appsMatch]);
useEffect(() => {
const newMatch = getMatch(rawInput);
if (newMatch && rawInput) {
useLeapStore.setState({ selectedMatch: newMatch });
}
}, [rawInput, matches]);
const toggleSearch = useCallback(() => {
if (selection || menu === 'search') {
return;
}
push('/leap/search');
}, [selection, menu]);
const onFocus = useCallback(
(e: FocusEvent<HTMLInputElement>) => {
// refocusing tab with input focused is false trigger
const windowFocus = e.nativeEvent.currentTarget === document.body;
if (windowFocus) {
return;
}
toggleSearch();
},
[toggleSearch]
);
const getMatch = useCallback(
(value: string) => {
const onlySymbols = !value.match(/[\w]/g);
const normValue = normalizeMatchString(value, onlySymbols);
return matches.find((m) =>
normalizeMatchString(m.value, onlySymbols).startsWith(normValue)
);
},
[matches]
);
const navigateByInput = useCallback(
(input: string) => {
const normalizedValue = input
.trim()
.replace('%', '')
.replace(/(~?[\w^_-]{3,13})\//, '$1/apps/$1/');
push(`/leap/${menu}/${normalizedValue}`);
},
[menu]
);
const debouncedSearch = useDebounce(
(input: string) => {
if (!deskMatch || appsMatch) {
return;
}
useLeapStore.setState({ searchInput: input });
navigateByInput(input);
},
300,
{ leading: true }
);
const handleSearch = useCallback(debouncedSearch, [deskMatch]);
const matchSystemPrefs = useCallback(
(target: string) => {
if (isMobile) {
return false;
}
if (!systemPrefMatch && target === 'system-updates') {
return true;
}
return systemPrefMatch?.params.submenu === target;
},
[location, systemPrefMatch]
);
const getPlaceholder = () => {
if (systemMenuOpen) {
switch (true) {
case matchSystemPrefs('system-updates'):
return 'My Urbit: About';
case matchSystemPrefs('help'):
return 'My Urbit: Help';
case matchSystemPrefs('security'):
return 'My Urbit: Security';
case matchSystemPrefs('notifications'):
return 'My Urbit: Notifications';
case matchSystemPrefs('privacy'):
return 'My Urbit: Attention & Privacy';
case matchSystemPrefs('appearance'):
return 'My Urbit: Apperance';
case matchSystemPrefs('shortcuts'):
return 'My Urbit: Shortcuts';
default:
return 'Settings';
}
}
return 'Search';
};
const onChange = useCallback(
handleError((e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
const isDeletion = (e.nativeEvent as InputEvent).inputType === 'deleteContentBackward';
const inputMatch = getMatch(value);
const matchValue = inputMatch?.value;
if (matchValue && inputRef.current && !isDeletion) {
inputRef.current.value = matchValue;
const start = matchValue.startsWith(value)
? value.length
: matchValue.substring(0, matchValue.indexOf(value)).length + value.length;
inputRef.current.setSelectionRange(start, matchValue.length);
useLeapStore.setState({
rawInput: matchValue,
selectedMatch: inputMatch
});
} else {
useLeapStore.setState({
rawInput: value,
selectedMatch: matches[0]
});
}
handleSearch(value);
}),
[matches]
);
const onSubmit = useCallback(
handleError((e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const value = inputRef.current?.value.trim();
const currentMatch = selectedMatch || (value && getMatch(value));
if (!currentMatch) {
return;
}
if (currentMatch?.openInNewTab) {
window.open(currentMatch.url, currentMatch.value);
return;
}
push(currentMatch.url);
useLeapStore.setState({ rawInput: '' });
}),
[deskMatch, selectedMatch]
);
const onKeyDown = useCallback(
handleError((e: KeyboardEvent<HTMLDivElement>) => {
const deletion = e.key === 'Backspace' || e.key === 'Delete';
const arrow = e.key === 'ArrowDown' || e.key === 'ArrowUp';
if (deletion && !rawInput && selection) {
e.preventDefault();
select(null, appsMatch && !appsMatch.isExact ? undefined : deskMatch?.params.query);
const pathBack = createPreviousPath(deskMatch?.url || '');
push(pathBack);
}
if (arrow) {
e.preventDefault();
if (matches.length === 0) {
return;
}
const currentIndex = selectedMatch
? matches.findIndex((m) => {
const matchValue = m.value;
const searchValue = selectedMatch.value;
return matchValue === searchValue;
})
: 0;
const unsafeIndex = e.key === 'ArrowUp' ? currentIndex - 1 : currentIndex + 1;
const index = (unsafeIndex + matches.length) % matches.length;
const newMatch = matches[index];
useLeapStore.setState({
rawInput: newMatch.value,
// searchInput: matchValue,
selectedMatch: newMatch
});
}
}),
[selection, rawInput, deskMatch, matches, selectedMatch]
);
return (
<div className="relative z-50 w-full">
<form
className={classNames(
'flex items-center h-9 w-full px-2 rounded-lg bg-white default-ring focus-within:ring-2',
!navOpen ? 'bg-gray-50' : '',
menu === 'upgrading' ? 'bg-orange-500' : '',
className
)}
onSubmit={onSubmit}
>
<label
htmlFor="leap"
className={classNames(
'inline-block flex-none p-2 h4 ',
menu === 'upgrading' ? 'text-white' : !selection ? 'sr-only' : 'text-blue-400'
)}
>
{menu === 'upgrading'
? 'Your Urbit is being updated, this page will update when ready'
: selection || 'Search'}
</label>
{menu !== 'upgrading' ? (
<input
id="leap"
type="text"
ref={inputRef}
placeholder={selection ? '' : getPlaceholder()}
// TODO: style placeholder text with 100% opacity.
// Not immediately clear how to do this within tailwind.
className="flex-1 w-full h-full px-2 text-base bg-transparent text-gray-800 outline-none"
value={rawInput}
onClick={toggleSearch}
onFocus={onFocus}
onChange={onChange}
onKeyDown={onKeyDown}
autoComplete="off"
aria-autocomplete="both"
aria-controls={dropdown}
aria-activedescendant={selectedMatch?.value}
/>
) : null}
</form>
{menu === 'search' && (
<Link
to="/"
className="absolute flex-none w-8 h-8 text-gray-600 top-1/2 right-2 circle-button default-ring -translate-y-1/2"
onClick={() => select(null)}
>
<Cross className="w-3 h-3" />
<span className="sr-only">Close</span>
</Link>
)}
</div>
);
}
);

181
src/nav/Nav.tsx Normal file
View File

@ -0,0 +1,181 @@
import { DialogContent } from '@radix-ui/react-dialog';
import * as Portal from '@radix-ui/react-portal';
import classNames from 'classnames';
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Link, Route, Switch, useHistory, useRouteMatch } from 'react-router-dom';
import create from 'zustand';
import { Avatar } from '../components/Avatar';
import { Dialog } from '../components/Dialog';
import { ErrorAlert } from '../components/ErrorAlert';
import { Help } from './Help';
import { Leap } from './Leap';
import { Notifications } from './Notifications';
import { NotificationsLink } from './NotificationsLink';
import { Search } from './Search';
import { SystemPreferences } from '../preferences/SystemPreferences';
import { useSystemUpdate } from '../logic/useSystemUpdate';
import { Bullet } from '../components/icons/Bullet';
export interface MatchItem {
url: string;
openInNewTab: boolean;
value: string;
display?: string;
}
interface LeapStore {
rawInput: string;
searchInput: string;
matches: MatchItem[];
selectedMatch?: MatchItem;
selection: React.ReactNode;
select: (selection: React.ReactNode, input?: string) => void;
}
export const useLeapStore = create<LeapStore>((set) => ({
rawInput: '',
searchInput: '',
matches: [],
selectedMatch: undefined,
selection: null,
select: (selection: React.ReactNode, input?: string) =>
set({
rawInput: input || '',
searchInput: input || '',
selection
})
}));
window.leap = useLeapStore.getState;
export type MenuState =
| 'closed'
| 'search'
| 'notifications'
| 'help-and-support'
| 'system-preferences'
| 'upgrading';
interface NavProps {
menu?: MenuState;
}
export const Nav: FunctionComponent<NavProps> = ({ menu }) => {
const { push } = useHistory();
const inputRef = useRef<HTMLInputElement>(null);
const navRef = useRef<HTMLDivElement>(null);
const dialogNavRef = useRef<HTMLDivElement>(null);
const systemMenuOpen = useRouteMatch('/leap/system-preferences');
const { systemBlocked } = useSystemUpdate();
const [dialogContentOpen, setDialogContentOpen] = useState(false);
const select = useLeapStore((state) => state.select);
const menuState = menu || 'closed';
const isOpen = menuState !== 'upgrading' && menuState !== 'closed';
useEffect(() => {
if (!isOpen) {
select(null);
setDialogContentOpen(false);
}
}, [isOpen]);
const onOpen = useCallback(
(event: Event) => {
event.preventDefault();
setDialogContentOpen(true);
if (menu === 'search' && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}
},
[menu]
);
const onDialogClose = useCallback((open: boolean) => {
if (!open) {
push('/');
}
}, []);
const preventClose = useCallback((e) => {
const target = e.target as HTMLElement;
const hasNavAncestor = target.closest('#dialog-nav');
if (hasNavAncestor) {
e.preventDefault();
}
}, []);
return (
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => push('/')}>
{/* Using portal so that we can retain the same nav items both in the dialog and in the base header */}
<Portal.Root
containerRef={dialogContentOpen ? dialogNavRef : navRef}
className="flex items-center justify-center w-full space-x-2"
>
<Link to="/leap/system-preferences" className="relative">
<Avatar shipName={window.ship} size="nav" />
{systemBlocked && (
<Bullet
className="absolute -top-2 -right-2 h-5 w-5 ml-auto text-orange-500"
aria-label="System Needs Attention"
/>
)}
</Link>
<NotificationsLink navOpen={isOpen} notificationsOpen={menu === 'notifications'} />
<Leap
ref={inputRef}
menu={menuState}
dropdown="leap-items"
navOpen={isOpen}
systemMenuOpen={!!systemMenuOpen}
/>
</Portal.Root>
<div
ref={navRef}
className={classNames(
'w-full max-w-[712px] mx-auto my-6 text-gray-400 font-semibold',
dialogContentOpen && 'h-9'
)}
role="combobox"
aria-controls="leap-items"
aria-owns="leap-items"
aria-expanded={isOpen}
/>
<Dialog open={isOpen} onOpenChange={onDialogClose}>
<DialogContent
onInteractOutside={preventClose}
onOpenAutoFocus={onOpen}
className="fixed bottom-0 sm:top-0 sm:bottom-auto scroll-left-50 flex flex-col justify-end sm:justify-start scroll-full-width h-full sm:h-auto max-w-[882px] px-4 sm:pb-4 text-gray-400 -translate-x-1/2 outline-none"
role="combobox"
aria-controls="leap-items"
aria-owns="leap-items"
aria-expanded={isOpen}
>
<header
id="dialog-nav"
ref={dialogNavRef}
className="max-w-[712px] w-full mx-auto my-6 sm:mb-3 order-last sm:order-none"
/>
<div
id="leap-items"
className="grid grid-rows-[fit-content(calc(100vh-6.25rem))] mt-4 sm:mt-0 bg-white rounded-xl overflow-hidden default-ring focus-visible:ring-2"
tabIndex={0}
role="listbox"
>
<Switch>
<Route path="/leap/notifications" component={Notifications} />
<Route path="/leap/system-preferences" component={SystemPreferences} />
<Route path="/leap/help-and-support" component={Help} />
<Route path={['/leap/search', '/leap']} component={Search} />
</Switch>
</div>
</DialogContent>
</Dialog>
</ErrorBoundary>
);
};

80
src/nav/Notifications.tsx Normal file
View File

@ -0,0 +1,80 @@
import React, { useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Link, NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { Button } from '../components/Button';
import { ErrorAlert } from '../components/ErrorAlert';
import { useHarkStore } from '../state/hark';
import { Inbox } from './notifications/Inbox';
export const Notifications = ({ history }: RouteComponentProps) => {
const markAllAsRead = () => {
const { archiveAll } = useHarkStore.getState();
archiveAll();
};
useEffect(() => {
function visibilitychange() {
if (document.visibilityState === 'hidden') {
useHarkStore.getState().opened();
}
}
document.addEventListener('visibilitychange', visibilitychange);
return () => {
document.removeEventListener('visibilitychange', visibilitychange);
useHarkStore.getState().opened();
};
}, []);
// const select = useLeapStore((s) => s.select);
return (
<ErrorBoundary
FallbackComponent={ErrorAlert}
onReset={() => history.push('/leap/notifications')}
>
<div className="grid grid-rows-[1fr,auto] sm:grid-rows-[auto,1fr] h-full p-4 md:p-8 overflow-y-auto">
<header className="order-last sm:order-none flex flex-wrap justify-start items-center w-full gap-2 mt-8 sm:mt-0 sm:mb-8">
<NavLink
exact
activeClassName="text-black"
className="flex-none font-semibold px-4"
to="/leap/notifications"
>
New
</NavLink>
<NavLink
activeClassName="text-black"
className="flex-none font-semibold px-4"
to="/leap/notifications/archive"
>
Archive
</NavLink>
<span className="flex-none inline-block sm:hidden w-full flex-shrink-0" />
<Button
onClick={markAllAsRead}
variant="secondary"
className="flex-auto sm:flex-none py-1.5 px-2 sm:px-6 text-sm sm:text-base rounded-full"
>
Archive All
</Button>
<Button
as={Link}
variant="secondary"
to="/leap/system-preferences/notifications"
className="flex-auto sm:flex-none py-1.5 px-3 sm:px-6 text-sm sm:text-base rounded-full"
>
Notification Settings
</Button>
</header>
<Switch>
<Route path="/leap/notifications" exact>
<Inbox />
</Route>
<Route path="/leap/notifications/archive" exact>
<Inbox archived />
</Route>
</Switch>
</div>
</ErrorBoundary>
);
};

View File

@ -0,0 +1,82 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import { Timebox } from '@urbit/api';
import { Link, LinkProps } from 'react-router-dom';
import { Cross } from '../components/icons/Cross';
import { useHarkStore } from '../state/hark';
import { useLeapStore } from './Nav';
import { SettingsState, useSettingsState } from '../state/settings';
import BellIcon from '../components/icons/BellIcon';
type NotificationsState = 'empty' | 'unread' | 'attention-needed' | 'open';
function getNotificationsState(isOpen: boolean, box: Timebox, dnd: boolean): NotificationsState {
const notifications = Object.values(box);
if (isOpen) {
return 'open';
}
if (dnd) {
return 'empty';
}
if (
notifications.filter(
({ bin }) => bin.place.desk === window.desk && ['/lag', 'blocked'].includes(bin.place.path)
).length > 0
) {
return 'attention-needed';
}
// TODO: when real structure, this should be actually be unread not just existence
if (notifications.length > 0) {
return 'unread';
}
return 'empty';
}
type NotificationsLinkProps = Omit<LinkProps<HTMLAnchorElement>, 'to'> & {
navOpen: boolean;
notificationsOpen: boolean;
};
const selDnd = (s: SettingsState) => s.display.doNotDisturb;
export const NotificationsLink = ({ navOpen, notificationsOpen }: NotificationsLinkProps) => {
const unseen = useHarkStore((s) => s.unseen);
const dnd = useSettingsState(selDnd);
const state = getNotificationsState(notificationsOpen, unseen, dnd);
const select = useLeapStore((s) => s.select);
const clearSelection = useCallback(() => select(null), [select]);
return (
<Link
to={state === 'open' ? '/' : '/leap/notifications'}
className={classNames(
'relative z-50 flex-none circle-button h4 default-ring',
navOpen && 'text-opacity-60',
state === 'open' && 'text-gray-400 bg-white',
state === 'empty' && !navOpen && 'text-gray-400 bg-gray-50',
state === 'empty' && navOpen && 'text-gray-400 bg-white',
state === 'unread' && 'bg-blue-400 text-white',
state === 'attention-needed' && 'text-white bg-orange-400'
)}
onClick={clearSelection}
>
{state === 'empty' && <BellIcon className="w-6 h-6" />}
{state === 'unread' && Object.keys(unseen).length}
{state === 'attention-needed' && (
<span className="h2">
! <span className="sr-only">Attention needed</span>
</span>
)}
{state === 'open' && (
<>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</>
)}
</Link>
);
};

28
src/nav/Search.tsx Normal file
View File

@ -0,0 +1,28 @@
import React from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import { TreatyInfo } from './search/TreatyInfo';
import { Apps } from './search/Apps';
import { Home } from './search/Home';
import { Providers } from './search/Providers';
import { ErrorAlert } from '../components/ErrorAlert';
type SearchProps = RouteComponentProps<{
query?: string;
}>;
export const Search = ({ match, history }: SearchProps) => {
return (
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => history.push('/leap/search')}>
<Switch>
<Route
path={[`${match.path}/direct/apps/:host/:desk`, `${match.path}/:ship/apps/:host/:desk`]}
component={TreatyInfo}
/>
<Route path={`${match.path}/:ship/apps`} component={Apps} />
<Route path={`${match.path}/:ship`} component={Providers} />
<Route path={`${match.path}`} component={Home} />
</Switch>
</ErrorBoundary>
);
};

156
src/nav/SystemMenu.tsx Normal file
View File

@ -0,0 +1,156 @@
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, Route, useHistory } from 'react-router-dom';
import { Vat } from '@urbit/api';
import { Adjust } from '../components/icons/Adjust';
import { useVat } from '../state/kiln';
import { disableDefault, handleDropdownLink } from '../state/util';
import { useMedia } from '../logic/useMedia';
import { Cross } from '../components/icons/Cross';
import { useLeapStore } from './Nav';
type SystemMenuProps = HTMLAttributes<HTMLButtonElement> & {
open: boolean;
subMenuOpen: boolean;
shouldDim: boolean;
};
function getHash(vat: Vat): string {
const parts = vat.hash.split('.');
return parts[parts.length - 1];
}
export const SystemMenu = ({ className, open, subMenuOpen, shouldDim }: SystemMenuProps) => {
const { push } = useHistory();
const [copied, setCopied] = useState(false);
const garden = useVat(window.desk);
const hash = garden ? getHash(garden) : null;
const isMobile = useMedia('(max-width: 639px)');
const select = useLeapStore((s) => s.select);
const clearSelection = useCallback(() => select(null), [select]);
const copyHash = useCallback(
(event: Event) => {
event.preventDefault();
if (!hash) {
return;
}
setCopied(true);
clipboardCopy(hash);
setTimeout(() => {
setCopied(false);
}, 1250);
},
[hash]
);
const preventFlash = useCallback((e) => {
const target = e.target as HTMLElement;
if (target.id !== 'system-menu-overlay') {
e.preventDefault();
}
}, []);
return (
<>
<div className="z-40">
<DropdownMenu.Root
modal={false}
open={open}
onOpenChange={(isOpen) => setTimeout(() => !isOpen && push('/'), 15)}
>
<Link
to={open || subMenuOpen ? '/' : '/system-menu'}
className={classNames(
'relative appearance-none circle-button default-ring',
open && 'text-gray-300',
shouldDim && 'opacity-60',
className
)}
onClick={clearSelection}
>
{!open && !subMenuOpen && (
<>
<Adjust className="w-6 h-6 fill-current text-gray" />
<span className="sr-only">System Menu</span>
</>
)}
{(open || subMenuOpen) && (
<>
<Cross className="w-3 h-3 fill-current" />
<span className="sr-only">Close</span>
</>
)}
{/* trigger here just for anchoring the dropdown */}
<DropdownMenu.Trigger className="sr-only top-0 left-0 sm:top-auto sm:left-auto sm:bottom-0" />
</Link>
<Route path="/system-menu">
<DropdownMenu.Content
onCloseAutoFocus={disableDefault}
onInteractOutside={preventFlash}
onFocusOutside={preventFlash}
onPointerDownOutside={preventFlash}
side={isMobile ? 'top' : 'bottom'}
sideOffset={12}
className="dropdown relative z-40 min-w-64 p-4 font-semibold text-gray-500 bg-white"
>
<DropdownMenu.Group>
<DropdownMenu.Item
as={Link}
to="/leap/system-preferences"
className="flex items-center p-2 mb-2 space-x-2 focus:bg-blue-200 focus:outline-none rounded"
onSelect={handleDropdownLink()}
>
<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 p-2 mb-2 space-x-2 focus:bg-blue-200 focus:outline-none rounded"
onSelect={handleDropdownLink()}
>
<span className="w-5 h-5 bg-gray-100 rounded-full" />
<span className="h4">Help and Support</span>
</DropdownMenu.Item>
<DropdownMenu.Item
as={Link}
to={`/app/${window.desk}`}
className="flex items-center p-2 mb-2 space-x-2 focus:bg-blue-200 focus:outline-none rounded"
onSelect={handleDropdownLink()}
>
<span className="w-5 h-5 bg-gray-100 rounded-full" />
<span className="h4">About</span>
</DropdownMenu.Item>
{hash && (
<DropdownMenu.Item
as="button"
className="inline-flex items-center py-2 px-3 m-2 h4 text-black bg-gray-100 rounded focus:bg-blue-200 focus:outline-none"
onSelect={copyHash}
>
<span className="sr-only">Base Hash</span>
<code>
{!copied && <span aria-label={hash.split('').join('-')}>{hash}</span>}
{copied && 'copied!'}
</code>
</DropdownMenu.Item>
)}
</DropdownMenu.Group>
</DropdownMenu.Content>
</Route>
</DropdownMenu.Root>
</div>
<Route path="/system-menu">
<div
id="system-menu-overlay"
className="fixed z-30 right-0 bottom-0 w-screen h-screen bg-black opacity-30"
/>
</Route>
</>
);
};

View File

@ -0,0 +1,103 @@
import React from 'react';
import cn from 'classnames';
import { Notification, harkBinToId, HarkContent, HarkLid } from '@urbit/api';
import { map, take } from 'lodash';
import { useCharge } from '../../state/docket';
import { Elbow } from '../../components/icons/Elbow';
import { ShipName } from '../../components/ShipName';
import { DeskLink } from '../../components/DeskLink';
import { useHarkStore } from '../../state/hark';
import { DocketImage } from '../../components/DocketImage';
import { Button } from '../../components/Button';
interface BasicNotificationProps {
notification: Notification;
lid: HarkLid;
}
const MAX_CONTENTS = 5;
const NotificationText = ({ contents }: { contents: HarkContent[] }) => {
return (
<>
{contents.map((content, idx) => {
if ('ship' in content) {
return <ShipName className="color-blue" key={idx} name={content.ship} />;
}
return content.text;
})}
</>
);
};
export const BasicNotification = ({ notification, lid }: BasicNotificationProps) => {
const { desk } = notification.bin.place;
const binId = harkBinToId(notification.bin);
const id = `notif-${notification.time}-${binId}`;
const charge = useCharge(desk);
const first = notification.body?.[0];
if (!first || !charge) {
return null;
}
const orderedByTime = notification.body.sort((a, b) => a.time - b.time);
const contents = map(orderedByTime, 'content').filter((c) => c.length > 0);
const large = contents.length === 0;
const archive = () => {
useHarkStore.getState().archiveNote(notification.bin, lid);
};
const archiveNoFollow = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
archive();
};
return (
<DeskLink
onClick={archive}
to={`?grid-note=${encodeURIComponent(first.link)}`}
desk={desk}
className={cn(
'text-black rounded-xl group',
'unseen' in lid ? 'bg-blue-100' : 'bg-gray-50',
large ? 'note-grid-no-content' : 'note-grid-content'
)}
aria-labelledby={id}
>
<header id={id} className="contents">
<DocketImage {...charge} size={!large ? 'xs' : 'default'} className="note-grid-icon" />
<div className="font-semibold note-grid-title">{charge?.title || desk}</div>
{!large ? <Elbow className="w-6 h-6 text-gray-300 note-grid-arrow" /> : null}
<h2
id={`${id}-title`}
className="font-semibold leading-tight text-gray-600 note-grid-head sm:leading-normal"
>
<NotificationText contents={first.title} />
</h2>
{!('time' in lid) ? (
<div className="flex self-center justify-center note-grid-actions sm:hidden hover-none:flex pointer-coarse:flex group-hover:flex">
<Button
onClick={archiveNoFollow}
className="px-2 py-1 text-sm leading-none sm:px-4 sm:py-2 sm:text-base sm:leading-normal"
>
Archive
</Button>
</div>
) : null}
</header>
{contents.length > 0 ? (
<div className="leading-tight note-grid-body sm:leading-normal space-y-2">
{take(contents, MAX_CONTENTS).map((content) => (
<p className="">
<NotificationText contents={content} />
</p>
))}
{contents.length > MAX_CONTENTS ? (
<p className="text-gray-300">and {contents.length - MAX_CONTENTS} more</p>
) : null}
</div>
) : null}
</DeskLink>
);
};

View File

@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import { HarkLid, Notification } from '@urbit/api';
import { BasicNotification } from './BasicNotification';
import { useNotifications } from '../../state/notifications';
import { useHarkStore } from '../../state/hark';
import { OnboardingNotification } from './OnboardingNotification';
function renderNotification(notification: Notification, key: string, lid: HarkLid) {
// Special casing
if (notification.bin.place.desk === window.desk) {
if (notification.bin.place.path === '/onboard') {
return <OnboardingNotification key={key} lid={lid} />;
}
}
return <BasicNotification key={key} notification={notification} lid={lid} />;
}
const Empty = () => (
<section className="flex justify-center items-center min-h-[40vh] text-gray-400 space-y-2">
<span className="h4">All clear!</span>
</section>
);
export const Inbox = ({ archived = false }) => {
const { unseen, seen } = useNotifications();
const archive = useHarkStore((s) => s.archive);
useEffect(() => {
useHarkStore.getState().getMore();
}, [archived]);
if (archived ? archive.size === 0 : Object.keys({ ...seen, ...unseen }).length === 0) {
return <Empty />;
}
return (
<div className="text-gray-400 space-y-2 overflow-y-auto">
{archived ? (
Array.from(archive).map(([key, box]) => {
return Object.entries(box)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n]) =>
renderNotification(n, `${key.toString()}-${binId}`, { time: key.toString() })
);
})
) : (
<>
<header>Unseen</header>
<section className="space-y-2">
{Object.entries(unseen)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n]) => renderNotification(n, `unseen-${binId}`, { unseen: null }))}
</section>
<header>Seen</header>
<section className="space-y-2">
{Object.entries(seen)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n]) => renderNotification(n, `seen-${binId}`, { seen: null }))}
</section>
</>
)}
</div>
);
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import type * as Polymorphic from '@radix-ui/react-polymorphic';
import classNames from 'classnames';
type NotificationButtonVariant = 'primary' | 'secondary' | 'destructive';
type PolymorphicButton = Polymorphic.ForwardRefComponent<
'button',
{
variant?: NotificationButtonVariant;
}
>;
const variants: Record<NotificationButtonVariant, string> = {
primary: 'text-blue bg-white',
secondary: 'text-black bg-white',
destructive: 'text-red-500 bg-white'
};
export const NotificationButton = React.forwardRef(
({ as: Comp = 'button', variant = 'primary', children, className, ...props }, ref) => {
return (
<Comp
ref={ref}
{...props}
className={classNames(
'button p-1 leading-4 font-medium default-ring rounded',
variants[variant],
className
)}
>
{children}
</Comp>
);
}
) as PolymorphicButton;

View File

@ -0,0 +1,157 @@
import React from 'react';
import cn from 'classnames';
import { Link } from 'react-router-dom';
import { HarkLid, Vats, getVatPublisher } from '@urbit/api';
import { Button } from '../../components/Button';
import { useBrowserId, useCurrentTheme } from '../../state/local';
import { getDarkColor } from '../../state/util';
import useKilnState from '../../state/kiln';
import { useHarkStore } from '../../state/hark';
import { useProtocolHandling } from '../../state/settings';
const getCards = (vats: Vats, protocol: boolean): OnboardingCardProps[] => {
const cards = [
{
title: 'Terminal',
body: "A web interface to your Urbit's command line (the dojo).",
button: 'Install',
color: '#9CA4B1',
href: '/leap/search/direct/apps/~mister-dister-dozzod-dozzod/webterm',
ship: '~mister-dister-dozzod-dozzod',
desk: 'webterm'
},
{
title: 'Groups',
body: 'A suite of applications to communicate on Urbit',
button: 'Install',
color: '#D1DDD3',
href: '/leap/search/direct/apps/~lander-dister-dozzod-dozzod/landscape',
ship: '~lander-dister-dozzod-dozzod',
desk: 'landscape'
},
{
title: 'Bitcoin',
body: ' A Bitcoin Wallet that lets you send and receive Bitcoin directly to and from other Urbit users',
button: 'Install',
color: '#F6EBDB',
href: '/leap/search/direct/apps/~mister-dister-dozzod-dozzod/bitcoin',
ship: '~mister-dister-dozzod-dozzod',
desk: 'bitcoin'
}
// Commenting out until we have something real
// {
// title: 'Debug',
// body: "Install a debugger. You can inspect your ship's internals using this interface",
// button: 'Install',
// color: '#E5E5E5',
// href: '/leap/search/direct/apps/~zod/debug'
// }
// {
// title: 'Build an app',
// body: 'You can instantly get started building new things on Urbit. Just right click your Landscape and select “New App”',
// button: 'Learn more',
// color: '#82A6CA'
// }
];
if ('registerProtocolHandler' in window.navigator && !protocol) {
cards.push({
title: 'Open Urbit-Native Links',
body: 'Enable your Urbit to open links you find in the wild',
button: 'Enable Link Handler',
color: '#82A6CA',
href: '/leap/system-preferences/interface',
desk: '',
ship: ''
});
}
return cards.filter((card) => {
return !Object.values(vats).find(
(vat) => getVatPublisher(vat) == card.ship && vat?.arak?.rail?.desk === card.desk
);
});
};
if ('registerProtocolHandler' in window.navigator) {
}
interface OnboardingCardProps {
title: string;
button: string;
href: string;
body: string;
color: string;
ship: string;
desk: string;
}
const OnboardingCard = ({ title, button, href, body, color }: OnboardingCardProps) => (
<div
className="flex flex-col justify-between p-4 text-black bg-gray-100 space-y-2 rounded-xl"
style={color ? { backgroundColor: color } : {}}
>
<div className="space-y-1">
<h4 className="font-semibold text-black">{title}</h4>
<p>{body}</p>
</div>
<Button as={Link} to={href} variant="primary" className="bg-gray-500">
{button}
</Button>
</div>
);
interface OnboardingNotificationProps {
unread?: boolean;
lid: HarkLid;
}
export const OnboardingNotification = ({ unread = false, lid }: OnboardingNotificationProps) => {
const theme = useCurrentTheme();
const vats = useKilnState((s) => s.vats);
const browserId = useBrowserId();
const protocolHandling = useProtocolHandling(browserId);
const cards = getCards(vats, protocolHandling);
if (cards.length === 0 && !('time' in lid)) {
useHarkStore.getState().archiveNote(
{
path: '/',
place: {
path: '/onboard',
desk: window.desk
}
},
lid
);
return null;
}
return (
<section
className={cn('notification space-y-2 text-black', unread ? 'bg-blue-100' : 'bg-gray-50')}
aria-labelledby=""
>
<header id="system-updates-blocked" className="relative space-y-2">
<div className="flex space-x-2">
<span className="inline-block w-6 h-6 bg-gray-200 rounded" />
<span className="font-semibold">System</span>
</div>
<div className="flex space-x-2">
<h2 id="runtime-lag">Hello there, and welcome!</h2>
</div>
</header>
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4">
{
/* eslint-disable-next-line react/no-array-index-key */
cards.map((card, i) => (
<OnboardingCard
key={i}
{...card}
color={theme === 'dark' ? getDarkColor(card.color) : card.color}
/>
))
}
</div>
</section>
);
};

111
src/nav/search/Apps.tsx Normal file
View File

@ -0,0 +1,111 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import fuzzy from 'fuzzy';
import { Treaty } from '@urbit/api';
import { ShipName } from '../../components/ShipName';
import { useAllyTreaties } from '../../state/docket';
import { useLeapStore } from '../Nav';
import { AppList } from '../../components/AppList';
import { addRecentDev } from './Home';
import { Spinner } from '../../components/Spinner';
type AppsProps = RouteComponentProps<{ ship: string }>;
export const Apps = ({ match }: AppsProps) => {
const { searchInput, selectedMatch, select } = useLeapStore((state) => ({
searchInput: state.searchInput,
select: state.select,
selectedMatch: state.selectedMatch
}));
const provider = match?.params.ship;
const { treaties, status } = useAllyTreaties(provider);
useEffect(() => {
if (provider) {
addRecentDev(provider);
}
}, [provider]);
const results = useMemo(() => {
if (!treaties) {
return undefined;
}
const values = Object.values(treaties);
return fuzzy
.filter(
searchInput,
values.map((v) => v.title)
)
.sort((a, b) => {
const left = a.string.startsWith(searchInput) ? a.score + 1 : a.score;
const right = b.string.startsWith(searchInput) ? b.score + 1 : b.score;
return right - left;
})
.map((result) => values[result.index]);
}, [treaties, searchInput]);
const count = results?.length;
const getAppPath = useCallback(
(app: Treaty) => `${match?.path.replace(':ship', provider)}/${app.ship}/${app.desk}`,
[match]
);
useEffect(() => {
select(
<>
Apps by <ShipName name={provider} className="font-mono" />
</>
);
}, [provider]);
useEffect(() => {
if (results) {
useLeapStore.setState({
matches: results.map((r) => ({
url: getAppPath(r),
openInNewTab: false,
value: r.desk,
display: r.title
}))
});
}
}, [results]);
const showNone =
status === 'error' || ((status === 'success' || status === 'initial') && results?.length === 0);
return (
<div className="dialog-inner-container md:px-6 md:py-8 h4 text-gray-400">
{status === 'loading' && (
<span className="mb-3">
<Spinner className="w-7 h-7 mr-3" /> Finding software...
</span>
)}
{results && results.length > 0 && (
<>
<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>
<AppList
apps={results}
labelledBy="developed-by"
matchAgainst={selectedMatch}
to={getAppPath}
/>
<p>That&apos;s it!</p>
</>
)}
{showNone && (
<h2>
Unable to find software developed by <ShipName name={provider} className="font-mono" />
</h2>
)}
</div>
);
};

170
src/nav/search/Home.tsx Normal file
View File

@ -0,0 +1,170 @@
import produce from 'immer';
import create from 'zustand';
import _ from 'lodash';
import React, { useEffect } from 'react';
import { persist } from 'zustand/middleware';
import { MatchItem, useLeapStore } from '../Nav';
import { providerMatch } from './Providers';
import { AppList } from '../../components/AppList';
import { ProviderList } from '../../components/ProviderList';
import { AppLink } from '../../components/AppLink';
import { ShipName } from '../../components/ShipName';
import { ProviderLink } from '../../components/ProviderLink';
import useDocketState, { ChargesWithDesks, useCharges } from '../../state/docket';
import {
clearStorageMigration,
createStorageKey,
getAppHref,
storageVersion
} from '../../state/util';
import useContactState from '../../state/contact';
export interface RecentsStore {
recentApps: string[];
recentDevs: string[];
addRecentApp: (desk: string) => void;
addRecentDev: (ship: string) => void;
removeRecentApp: (desk: string) => void;
}
export const useRecentsStore = create<RecentsStore>(
persist(
(set) => ({
recentApps: [],
recentDevs: [],
addRecentApp: (desk: string) => {
set(
produce((draft: RecentsStore) => {
const hasApp = draft.recentApps.find((testDesk) => testDesk === desk);
if (!hasApp) {
draft.recentApps.unshift(desk);
}
draft.recentApps = _.take(draft.recentApps, 3);
})
);
},
addRecentDev: (dev) => {
set(
produce((draft: RecentsStore) => {
const hasDev = draft.recentDevs.includes(dev);
if (!hasDev) {
draft.recentDevs.unshift(dev);
}
draft.recentDevs = _.take(draft.recentDevs, 3);
})
);
},
removeRecentApp: (desk: string) => {
set(
produce((draft: RecentsStore) => {
_.remove(draft.recentApps, (test) => test === desk);
})
);
}
}),
{
whitelist: ['recentApps', 'recentDevs'],
name: createStorageKey('recents-store'),
version: storageVersion,
migrate: clearStorageMigration
}
)
);
window.recents = useRecentsStore.getState;
export function addRecentDev(dev: string) {
return useRecentsStore.getState().addRecentDev(dev);
}
export function addRecentApp(app: string) {
return useRecentsStore.getState().addRecentApp(app);
}
function getApps(desks: string[], charges: ChargesWithDesks) {
return desks.filter((desk) => desk in charges).map((desk) => charges[desk]);
}
export const Home = () => {
const selectedMatch = useLeapStore((state) => state.selectedMatch);
const { recentApps, recentDevs } = useRecentsStore();
const charges = useCharges();
const groups = charges?.landscape;
const contacts = useContactState((s) => s.contacts);
const defaultAlly = useDocketState((s) =>
s.defaultAlly ? { shipName: s.defaultAlly, ...contacts[s.defaultAlly] } : null
);
const providerList = recentDevs.map((d) => ({ shipName: d, ...contacts[d] }));
const apps = getApps(recentApps, charges);
useEffect(() => {
const appMatches = apps.map((app) => ({
url: getAppHref(app.href),
openInNewTab: true,
value: app.desk,
display: app.title
}));
const devs = recentDevs.map(providerMatch);
useLeapStore.setState({
matches: ([] as MatchItem[]).concat(appMatches, devs)
});
}, [recentApps, recentDevs]);
return (
<div className="h-full p-4 md:p-8 font-semibold leading-tight text-black overflow-y-auto">
<h2 id="recent-apps" className="mb-4 h4 text-gray-500">
Recent Apps
</h2>
{apps.length === 0 && (
<div className="p-6 rounded-xl bg-gray-50">
<p>Apps you use will be listed here, in the order you used them.</p>
<p className="mt-4">You can click/tap/keyboard on a listed app to open it.</p>
{groups && (
<AppLink
app={groups}
size="small"
className="mt-6"
onClick={() => addRecentApp('groups')}
/>
)}
</div>
)}
{apps.length > 0 && (
<AppList apps={apps} labelledBy="recent-apps" matchAgainst={selectedMatch} size="small" />
)}
<hr className="-mx-4 my-6 md:-mx-8 md:my-9 border-t-2 border-gray-50" />
<h2 id="recent-devs" className="mb-4 h4 text-gray-500">
Recent Developers
</h2>
{recentDevs.length === 0 && (
<div className="p-6 rounded-xl bg-gray-50">
<p className="mb-4">Urbit app developers you search for will be listed here.</p>
{defaultAlly && (
<>
<p className="mb-6">
Try out app discovery by visiting <ShipName name={defaultAlly.shipName} /> below.
</p>
<ProviderLink
adjustBG={false}
provider={defaultAlly}
size="small"
onClick={() => addRecentDev(defaultAlly.shipName)}
/>
</>
)}
</div>
)}
{recentDevs.length > 0 && (
<ProviderList
providers={providerList}
labelledBy="recent-devs"
matchAgainst={selectedMatch}
size="small"
/>
)}
</div>
);
};

View File

@ -0,0 +1,160 @@
import React, { useEffect, useMemo } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import fuzzy from 'fuzzy';
import { Provider, deSig } from '@urbit/api';
import * as ob from 'urbit-ob';
import { MatchItem, useLeapStore } from '../Nav';
import { useAllies, useCharges } from '../../state/docket';
import { ProviderList } from '../../components/ProviderList';
import useContactState from '../../state/contact';
import { AppList } from '../../components/AppList';
import { getAppHref } from '../../state/util';
type ProvidersProps = RouteComponentProps<{ ship: string }>;
export function providerMatch(provider: Provider | string): MatchItem {
const value = typeof provider === 'string' ? provider : provider.shipName;
const display = typeof provider === 'string' ? undefined : provider.nickname;
return {
value,
display,
url: `/leap/search/${value}/apps`,
openInNewTab: false
};
}
function fuzzySort(search: string) {
return (a: fuzzy.FilterResult<string>, b: fuzzy.FilterResult<string>): number => {
const left = a.string.startsWith(search) ? a.score + 1 : a.score;
const right = b.string.startsWith(search) ? b.score + 1 : b.score;
return right - left;
};
}
export const Providers = ({ match }: ProvidersProps) => {
const selectedMatch = useLeapStore((state) => state.selectedMatch);
const provider = match?.params.ship;
const contacts = useContactState((s) => s.contacts);
const charges = useCharges();
const allies = useAllies();
const search = provider || '';
const chargeArray = Object.entries(charges);
const appResults = useMemo(
() =>
charges
? fuzzy
.filter(
search,
chargeArray.map(([desk, charge]) => charge.title + desk)
)
.sort(fuzzySort(search))
.map((el) => chargeArray[el.index][1])
: [],
[charges, search]
);
const patp = `~${deSig(search) || ''}`;
const isValidPatp = ob.isValidPatp(patp);
const results = useMemo(() => {
if (!allies) {
return [];
}
const exact =
isValidPatp && !Object.keys(allies).includes(patp)
? [
{
shipName: patp,
...contacts[patp]
}
]
: [];
return [
...exact,
...fuzzy
.filter(
search,
Object.entries(allies).map(([ship]) => ship)
)
.sort(fuzzySort(search))
.map((el) => ({ shipName: el.original, ...contacts[el.original] }))
];
}, [allies, search, contacts]);
const count = results?.length;
useEffect(() => {
if (search) {
useLeapStore.setState({ rawInput: search });
}
}, []);
useEffect(() => {
if (results) {
const providerMatches = results ? results.map(providerMatch) : [];
const appMatches = appResults
? appResults.map((app) => ({
url: getAppHref(app.href),
openInNewTab: true,
value: app.desk,
display: app.title
}))
: [];
const newProviderMatches = isValidPatp
? [
{
url: `/leap/search/${patp}/apps`,
value: patp,
display: patp,
openInNewTab: false
}
]
: [];
useLeapStore.setState({
matches: ([] as MatchItem[]).concat(appMatches, providerMatches, newProviderMatches)
});
}
}, [results, patp, isValidPatp]);
return (
<div
className="dialog-inner-container md:px-6 md:py-8 space-y-0 h4 text-gray-400"
aria-live="polite"
>
{appResults && !(results?.length > 0 && appResults.length === 0) && (
<div>
<h2 id="installed" className="mb-3">
Installed Apps
</h2>
<AppList
apps={appResults}
labelledBy="installed"
matchAgainst={selectedMatch}
listClass="mb-6"
/>
</div>
)}
{results && !(appResults?.length > 0 && results.length === 0) && (
<div>
<div id="providers">
<h2 className="mb-1">Searching Software Providers</h2>
<p className="mb-3">
{count} result{count === 1 ? '' : 's'}
</p>
</div>
<ProviderList
providers={results}
labelledBy="providers"
matchAgainst={selectedMatch}
listClass="mb-6"
/>
</div>
)}
<p>That&apos;s it!</p>
</div>
);
};

View File

@ -0,0 +1,38 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { AppInfo } from '../../components/AppInfo';
import { Spinner } from '../../components/Spinner';
import useDocketState, { useCharge, useTreaty } from '../../state/docket';
import { useVat } from '../../state/kiln';
import { getAppName } from '../../state/util';
import { useLeapStore } from '../Nav';
export const TreatyInfo = () => {
const select = useLeapStore((state) => state.select);
const { host, desk } = useParams<{ host: string; desk: string }>();
const treaty = useTreaty(host, desk);
const vat = useVat(desk);
const charge = useCharge(desk);
const name = getAppName(treaty);
useEffect(() => {
if (!charge) {
useDocketState.getState().requestTreaty(host, desk);
}
}, [host, desk]);
useEffect(() => {
select(<>{name}</>);
useLeapStore.setState({ matches: [] });
}, [name]);
if (!treaty) {
// TODO: maybe replace spinner with skeletons
return (
<div className="dialog-inner-container flex justify-center text-black">
<Spinner className="w-10 h-10" />
</div>
);
}
return <AppInfo className="dialog-inner-container" docket={charge || treaty} vat={vat} />;
};

64
src/pages/Grid.tsx Normal file
View File

@ -0,0 +1,64 @@
import React, { FunctionComponent, useEffect } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Route, useHistory, useParams } from 'react-router-dom';
import { ErrorAlert } from '../components/ErrorAlert';
import { MenuState, Nav } from '../nav/Nav';
import useKilnState from '../state/kiln';
import { RemoveApp } from '../tiles/RemoveApp';
import { SuspendApp } from '../tiles/SuspendApp';
import { TileGrid } from '../tiles/TileGrid';
import { TileInfo } from '../tiles/TileInfo';
interface RouteProps {
menu?: MenuState;
}
export const Grid: FunctionComponent = () => {
const { push } = useHistory();
const { menu } = useParams<RouteProps>();
useEffect(() => {
// TOOD: rework
// Heuristically detect reload completion and redirect
async function attempt(count = 0) {
if (count > 5) {
window.location.reload();
}
const start = performance.now();
await useKilnState.getState().fetchVats();
await useKilnState.getState().fetchVats();
if (performance.now() - start > 5000) {
attempt(count + 1);
} else {
push('/');
}
}
if (menu === 'upgrading') {
attempt();
}
}, [menu]);
return (
<div className="flex flex-col">
<header className="fixed sm:sticky bottom-0 sm:bottom-auto sm:top-0 left-0 z-30 flex justify-center w-full px-4">
<Nav menu={menu} />
</header>
<main className="h-full w-full flex justify-center pt-4 md:pt-16 pb-32 relative z-0">
<TileGrid menu={menu} />
<ErrorBoundary FallbackComponent={ErrorAlert} onReset={() => push('/')}>
<Route exact path="/app/:desk">
<TileInfo />
</Route>
<Route exact path="/app/:desk/suspend">
<SuspendApp />
</Route>
<Route exact path="/app/:desk/remove">
<RemoveApp />
</Route>
</ErrorBoundary>
</main>
</div>
);
};

View File

@ -0,0 +1,97 @@
import React, { useEffect } from 'react';
import { Switch, Route, Redirect, RouteComponentProps } from 'react-router-dom';
import { Spinner } from '../components/Spinner';
import { useQuery } from '../logic/useQuery';
import { useCharge } from '../state/docket';
import useKilnState, { useKilnLoaded } from '../state/kiln';
import { getAppHref } from '../state/util';
function getDeskByForeignRef(ship: string, desk: string): string | undefined {
const { vats } = useKilnState.getState();
const found = Object.entries(vats).find(
([, vat]) => vat.arak.rail?.ship === ship && vat.arak.rail?.desk === desk
);
return found ? found[0] : undefined;
}
type AppLinkProps = RouteComponentProps<{
ship: string;
desk: string;
link: string;
}>;
function AppLink({ match, history, location }: AppLinkProps) {
const { ship, desk, link = '' } = match.params;
const ourDesk = getDeskByForeignRef(ship, desk);
console.log(ourDesk);
if (ourDesk) {
return <AppLinkRedirect desk={ourDesk} link={link} />;
}
return <AppLinkNotFound match={match} history={history} location={location} />;
}
function AppLinkNotFound({ match }: AppLinkProps) {
const { ship, desk } = match.params;
return <Redirect to={`/leap/search/direct/apps/${ship}/${desk}`} />;
}
function AppLinkInvalid() {
return (
<div>
<h4>Link was malformed</h4>
<p>The link you tried to follow was invalid</p>
</div>
);
}
function AppLinkRedirect({ desk, link }: { desk: string; link: string }) {
const charge = useCharge(desk);
useEffect(() => {
if (!charge) {
return;
}
const query = new URLSearchParams({
'grid-link': encodeURIComponent(`/${link}`)
});
const url = `${getAppHref(charge.href)}?${query.toString()}`;
window.open(url, desk);
}, [charge]);
return <Redirect to="/" />;
}
const LANDSCAPE_DESK = 'landscape';
const LANDSCAPE_HOST = '~lander-dister-dozzod-dozzod';
function LandscapeLink({ match }: RouteComponentProps<{ link: string }>) {
const { link } = match.params;
return <Redirect to={`/perma/${LANDSCAPE_HOST}/${LANDSCAPE_DESK}/group/${link}`} />;
}
export function PermalinkRoutes() {
const loaded = useKilnLoaded();
const { query } = useQuery();
if (query.has('ext')) {
const ext = query.get('ext')!;
const url = `/perma${ext.slice(16)}`;
return <Redirect to={url} />;
}
if (!loaded) {
return <Spinner />;
}
return (
<Switch>
<Route path="/perma/group/:link+" component={LandscapeLink} />
<Route path="/perma/:ship/:desk/:link*" component={AppLink} />
<Route path="/" component={AppLinkInvalid} />
</Switch>
);
}

View File

@ -0,0 +1,39 @@
import React, { useCallback } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { Setting } from '../components/Setting';
import { ShipName } from '../components/ShipName';
import { useCharge } from '../state/docket';
import useKilnState, { useVat } from '../state/kiln';
import { getAppName } from '../state/util';
export const AppPrefs = ({ match }: RouteComponentProps<{ desk: string }>) => {
const { desk } = match.params;
const charge = useCharge(desk);
const vat = useVat(desk);
const tracking = !!vat?.arak.rail;
const otasEnabled = !vat?.arak.rail?.paused;
const otaSource = vat?.arak.rail?.ship;
const toggleOTAs = useKilnState((s) => s.toggleOTAs);
const toggleUpdates = useCallback((on: boolean) => toggleOTAs(desk, on), [desk, toggleOTAs]);
return (
<>
<h2 className="h3 mb-7">{getAppName(charge)} Settings</h2>
<div className="space-y-3">
{tracking ? (
<Setting on={otasEnabled} toggle={toggleUpdates} name="Automatic Updates">
<p>Automatically download and apply updates to keep {getAppName(charge)} up to date.</p>
{otaSource && (
<p>
OTA Source: <ShipName name={otaSource} className="font-semibold font-mono" />
</p>
)}
</Setting>
) : (
<h4 className="text-gray-500">No settings</h4>
)}
</div>
</>
);
};

View File

@ -0,0 +1,75 @@
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import * as RadioGroup from '@radix-ui/react-radio-group';
import { useSettingsState, useTheme } from '../state/settings';
type prefType = 'auto' | 'dark' | 'light';
interface RadioOptionProps {
value: string;
label: string;
selected: boolean;
}
interface ApperanceOption {
value: prefType;
label: string;
}
const apperanceOptions: ApperanceOption[] = [
{ value: 'auto', label: 'System Theme' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' }
];
const RadioOption = ({ value, label, selected }: RadioOptionProps) => (
<div className="flex space-x-3 ">
<RadioGroup.Item
className={classNames('flex items-center border-gray-200 w-4 h-4 rounded-full', {
'border-2': !selected
})}
value={value}
id={value}
>
<RadioGroup.Indicator className="flex items-center border-4 rounded-full border-gray-800 w-full h-full" />
</RadioGroup.Item>
<label className="font-semibold" htmlFor={value}>
{label}
</label>
</div>
);
export const AppearancePrefs = () => {
const theme = useTheme();
const [pref, setPref] = useState<prefType>(theme || 'auto');
useEffect(() => {
useSettingsState.getState().set((draft) => {
draft.display.theme = pref;
});
}, [pref]);
const handleChange = (value: string) => {
setPref(value as prefType);
};
return (
<div className="inner-section space-y-8">
<h2 className="h4">Landscape Apperance</h2>
<RadioGroup.Root
className="flex flex-col space-y-3"
value={pref}
onValueChange={handleChange}
>
{apperanceOptions.map((option) => (
<RadioOption
key={`radio-option-${option.value}`}
value={option.value}
label={option.label}
selected={pref === option.value}
/>
))}
</RadioGroup.Root>
</div>
);
};

View File

@ -0,0 +1,100 @@
import React from 'react';
import { Setting } from '../components/Setting';
import { SettingsState, useSettingsState } from '../state/settings';
type AttentionProperty =
| 'disableAppTileUnreads'
| 'disableAvatars'
| 'disableNicknames'
| 'disableSpellcheck'
| 'disableRemoteContent';
const attentionProperties: Record<string, AttentionProperty> = {
disableAppTileUnreads: 'disableAppTileUnreads',
disableAvatars: 'disableAvatars',
disableNicknames: 'disableNicknames',
disableSpellcheck: 'disableSpellcheck',
disableRemoteContent: 'disableRemoteContent'
};
async function toggle(property: AttentionProperty) {
const selProp = (s: SettingsState) => s.display[property];
const state = useSettingsState.getState();
const curr = selProp(state);
await state.putEntry('calmEngine', property, !curr);
}
export const AttentionAndPrivacy = () => {
const {
disableAppTileUnreads,
disableAvatars,
disableNicknames,
disableSpellcheck,
disableRemoteContent
} = useSettingsState().calmEngine;
return (
<div className="flex flex-col space-y-4">
<div className="inner-section space-y-8 relative">
<h2 className="h4">CalmEngine</h2>
<span className="font-semibold text-gray-400">
Modulate attention-hacking interfaces across your urbit
</span>
<Setting
on={disableAppTileUnreads}
toggle={() => toggle(attentionProperties.disableAppTileUnreads)}
name="Hide unread counts on Landscape app tiles"
>
<p className="text-gray-600 leading-5">
Turn off notification counts on individual app tiles.
</p>
</Setting>
<Setting
on={disableAvatars}
toggle={() => toggle(attentionProperties.disableAvatars)}
name="Disable avatars"
>
<p className="text-gray-600 leading-5">
Turn user-set visual avatars off and only display urbit sigils across all of your apps.
</p>
</Setting>
<Setting
on={disableNicknames}
toggle={() => toggle(attentionProperties.disableNicknames)}
name="Disable nicknames"
>
<p className="text-gray-600 leading-5">
Turn user-set nicknames off and only display urbit-style names across all of your apps.
</p>
</Setting>
</div>
<div className="inner-section space-y-8 relative">
<h2 className="h4">Privacy</h2>
<span className="font-semibold text-gray-400">
Limit your urbits ability to be read or tracked by clearnet services
</span>
<Setting
on={disableSpellcheck}
toggle={() => toggle(attentionProperties.disableSpellcheck)}
name="Disable spellcheck"
>
<p className="text-gray-600 leading-5">
Turn spellcheck off across all text inputs in your urbits software/applications. Spell
check reads your keyboard input, and may be undesirable.
</p>
</Setting>
<Setting
on={disableRemoteContent}
toggle={() => toggle(attentionProperties.disableRemoteContent)}
name="Disable remote content"
>
<p className="text-gray-600 leading-5">
Turn off automatically-displaying media embeds across all of your urbits
software/applications. This may result in some software appearing to have content
missing.
</p>
</Setting>
</div>
</div>
);
};

View File

@ -0,0 +1,69 @@
import React from 'react';
import { Setting } from '../components/Setting';
import {
setBrowserSetting,
useBrowserSettings,
useProtocolHandling,
useSettingsState
} from '../state/settings';
import { useBrowserId } from '../state/local';
export function InterfacePrefs() {
const settings = useBrowserSettings();
const browserId = useBrowserId();
const protocolHandling = useProtocolHandling(browserId);
const secure = window.location.protocol === 'https:' || window.location.hostname === 'localhost';
const linkHandlingAllowed = secure && 'registerProtocolHandler' in window.navigator;
const setProtocolHandling = (setting: boolean) => {
const newSettings = setBrowserSetting(settings, { protocolHandling: setting }, browserId);
useSettingsState
.getState()
.putEntry('browserSettings', 'settings', JSON.stringify(newSettings));
};
const toggleProtoHandling = async () => {
if (!protocolHandling && window?.navigator?.registerProtocolHandler) {
try {
window.navigator.registerProtocolHandler(
'web+urbitgraph',
'/apps/grid/perma?ext=%s',
'Urbit Links'
);
setProtocolHandling(true);
} catch (e) {
console.error(e);
}
} else if (protocolHandling && window.navigator?.unregisterProtocolHandler) {
try {
window.navigator.unregisterProtocolHandler('web+urbitgraph', '/apps/grid/perma?ext=%s');
setProtocolHandling(false);
} catch (e) {
console.error(e);
}
}
};
return (
<>
<div className="space-y-3">
<Setting
on={protocolHandling}
toggle={toggleProtoHandling}
name="Handle Urbit links"
disabled={!linkHandlingAllowed}
>
<p>
Automatically open urbit links when using this browser.
{!linkHandlingAllowed && (
<>
<strong className="text-orange-500">
Unavailable with this browser/connection.
</strong>
</>
)}
</p>
</Setting>
</div>
</>
);
}

View File

@ -0,0 +1,85 @@
import { setMentions } from '@urbit/api';
import React from 'react';
import { Setting } from '../components/Setting';
import { pokeOptimisticallyN } from '../state/base';
import { HarkState, reduceGraph, useHarkStore } from '../state/hark';
import { useBrowserId } from '../state/local';
import {
useSettingsState,
useBrowserNotifications,
useBrowserSettings,
SettingsState,
setBrowserSetting
} from '../state/settings';
const selDnd = (s: SettingsState) => s.display.doNotDisturb;
async function toggleDnd() {
const state = useSettingsState.getState();
const curr = selDnd(state);
await state.putEntry('display', 'doNotDisturb', !curr);
}
const selMentions = (s: HarkState) => s.notificationsGraphConfig.mentions;
async function toggleMentions() {
const state = useHarkStore.getState();
await pokeOptimisticallyN(useHarkStore, setMentions(!selMentions(state)), reduceGraph);
}
export const NotificationPrefs = () => {
const doNotDisturb = useSettingsState(selDnd);
const mentions = useHarkStore(selMentions);
const settings = useBrowserSettings();
const browserId = useBrowserId();
const browserNotifications = useBrowserNotifications(browserId);
const secure = window.location.protocol === 'https:' || window.location.hostname === 'localhost';
const notificationsAllowed = secure && 'Notification' in window;
const setBrowserNotifications = (setting: boolean) => {
const newSettings = setBrowserSetting(settings, { browserNotifications: setting }, browserId);
useSettingsState
.getState()
.putEntry('browserSettings', 'settings', JSON.stringify(newSettings));
};
const toggleNotifications = async () => {
if (!browserNotifications) {
Notification.requestPermission();
setBrowserNotifications(true);
} else {
setBrowserNotifications(false);
}
};
return (
<>
<div className="space-y-3">
<Setting on={doNotDisturb} toggle={toggleDnd} name="Do Not Disturb">
<p>
Blocks Urbit notifications in Landscape from appearing as badges and prevents browser
notifications if enabled.
</p>
</Setting>
<Setting
on={browserNotifications}
toggle={toggleNotifications}
name="Show Desktop Notifications"
disabled={!notificationsAllowed}
>
<p>
Show desktop notifications in this browser.
{!secure && (
<>
<strong className="text-orange-500">
Unavailable with this browser/connection.
</strong>
</>
)}
</p>
</Setting>
<Setting on={mentions} toggle={toggleMentions} name="Mentions">
<p>Notify me if someone mentions my @p in a channel I&apos;ve joined</p>
</Setting>
</div>
</>
);
};

View File

@ -0,0 +1,154 @@
import React, { ChangeEvent, KeyboardEvent, useEffect, useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import fuzzy from 'fuzzy';
import classNames from 'classnames';
import MagnifyingGlassIcon from '../components/icons/MagnifyingGlassIcon';
import BellIcon from '../components/icons/BellIcon';
import { Interface } from '../components/icons/Interface';
import BurstIcon from '../components/icons/BurstIcon';
import HelpIcon from '../components/icons/HelpIcon';
import TlonIcon from '../components/icons/TlonIcon';
import LogoutIcon from '../components/icons/LogoutIcon';
import PencilIcon from '../components/icons/PencilIcon';
import ForwardSlashIcon from '../components/icons/ForwardSlashIcon';
const navOptions: { route: string; title: string; icon: React.ReactElement }[] = [
{
route: 'help',
title: 'Help and Support',
icon: <HelpIcon className="w-4 h-4 text-gray-600" />
},
{
route: 'interface',
title: 'Interface Settings',
icon: <Interface className="w-4 h-4 text-gray-600" />
},
{
route: 'notifications',
title: 'Notifications',
icon: <BellIcon className="w-4 h-4 text-gray-600" />
},
{
route: 'appearance',
title: 'Appearance',
icon: <PencilIcon className="w-4 h-4 text-gray-600" />
},
{
route: 'shortcuts',
title: 'Shortcuts',
icon: <ForwardSlashIcon className="w-4 h-4 text-gray-600" />
},
{
route: 'privacy',
title: 'Attention & Privacy',
icon: <BurstIcon className="w-4 h-4 text-gray-600" />
},
{
route: 'security',
title: 'Log Out...',
icon: <LogoutIcon className="w-4 h-4 text-gray-600" />
},
{
route: 'system-updates',
title: 'About System',
icon: <TlonIcon className="w-4 h-4 text-gray-600" />
}
];
interface SearchSystemPrefencesProps {
subUrl: (submenu: string) => string;
}
export default function SearchSystemPreferences({ subUrl }: SearchSystemPrefencesProps) {
const { push } = useHistory();
const [searchInput, setSearchInput] = useState('');
const [matchingNavOptions, setMatchingNavOptions] = useState<string[]>([]);
const [highlightNavOption, setHighlightNavOption] = useState<number>();
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const value = input.value.trim();
setSearchInput(value);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const { key } = e;
if (key === 'ArrowDown' && searchInput !== '' && matchingNavOptions.length > 0) {
if (highlightNavOption === undefined) {
setHighlightNavOption(0);
} else {
setHighlightNavOption((prevState) => prevState! + 1);
}
}
if (
key === 'ArrowUp' &&
searchInput !== '' &&
matchingNavOptions.length > 0 &&
highlightNavOption !== undefined &&
highlightNavOption !== 0
) {
setHighlightNavOption((prevState) => prevState! - 1);
}
if (key === 'Enter' && searchInput !== '' && highlightNavOption !== undefined) {
push(subUrl(navOptions[highlightNavOption].route));
}
};
const handleBlur = () => {
setSearchInput('');
};
useEffect(() => {
const results = fuzzy.filter(searchInput, navOptions, { extract: (obj) => obj.title });
const matches = results.map((el) => el.string);
setMatchingNavOptions(matches);
}, [searchInput]);
return (
<>
<label className="relative flex items-center">
<span className="sr-only">Search Prefences</span>
<span className="absolute h-8 w-8 text-gray-400 flex items-center pl-2 inset-y-1 left-0">
<MagnifyingGlassIcon />
</span>
<input
className="input bg-gray-50 pl-8 placeholder:font-semibold mb-5 h-10"
placeholder="Search Preferences"
value={searchInput}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
/>
</label>
<div className="relative">
{matchingNavOptions.length > 0 && searchInput !== '' ? (
<div className="absolute -top-3 flex flex-col bg-white space-y-2 rounded-2xl shadow-md w-full py-3">
{matchingNavOptions.map((opt, index) => {
const matchingNavOption = navOptions.find((navOpt) => navOpt.title === opt);
if (matchingNavOption !== undefined) {
return (
<Link
className={classNames(
'flex px-2 py-3 items-center space-x-2 hover:text-black hover:bg-gray-50',
{
'bg-gray-50': highlightNavOption === index
}
)}
to={subUrl(matchingNavOption.route)}
>
{matchingNavOption.icon}
<span className="text-gray-900">{matchingNavOption?.title}</span>
</Link>
);
}
return null;
})}
</div>
) : null}
</div>
</>
);
}

View File

@ -0,0 +1,41 @@
import React, { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Button } from '../components/Button';
import { Checkbox } from '../components/Checkbox';
import { Dialog, DialogContent } from '../components/Dialog';
export const SecurityPrefs = () => {
const [allSessions, setAllSessions] = useState(false);
const { push } = useHistory();
return (
<Dialog open onOpenChange={(open) => !open && push('/leap/system-preferences')}>
<DialogContent containerClass="w-1/3" showClose={false}>
<h3 className="flex items-center mb-2 h4">Log Out</h3>
<div className="flex flex-col justify-center flex-1 space-y-6 pt-6">
<p>
Logging out of Landscape will additionally log you ot of any applications installed on
your urbit.
</p>
<p>You&apos;ll need to log into your urbit again in order to access its apps.</p>
<Checkbox
defaultChecked={false}
checked={allSessions}
onCheckedChange={() => setAllSessions((prev) => !prev)}
>
Log out of all connected sessions.
</Checkbox>
<div className="flex space-x-2 justify-end">
<Button variant="secondary" onClick={() => push('/leap/system-preferences')}>
Cancel
</Button>
<form method="post" action="/~/logout">
{allSessions && <input type="hidden" name="all" />}
<Button>Logout</Button>
</form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,117 @@
import React, { ChangeEvent, KeyboardEvent, useEffect, useState } from 'react';
import classNames from 'classnames';
import fuzzy from 'fuzzy';
import MagnifyingGlassIcon from '../components/icons/MagnifyingGlassIcon';
interface Shortcut {
action: string;
keybinding: string;
}
const shortcuts: Shortcut[] = [
{
action: 'Up in List',
keybinding: 'Alt + X'
},
{ action: 'Down in List', keybinding: 'Alt + X' },
{ action: 'Next Page', keybinding: 'Alt + X' },
{ action: 'Previous Page', keybinding: 'Alt + X' },
{ action: 'Context-Aware Search', keybinding: 'Ctrl + /' }
];
interface SearchKeyboardShortcutsProps {
searchInput: string;
setSearchInput: (input: string) => void;
setMatchingShortcuts: (newMatchingShortcuts: string[]) => void;
}
const SearchKeyboardShortcuts = ({
searchInput,
setSearchInput,
setMatchingShortcuts
}: SearchKeyboardShortcutsProps) => {
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const input = e.target as HTMLInputElement;
const { value } = input;
setSearchInput(value);
};
const handleBlur = () => {
setSearchInput('');
};
useEffect(() => {
const results = fuzzy.filter(searchInput, shortcuts, { extract: (obj) => obj.action });
const matches = results.map((el) => el.string);
setMatchingShortcuts(matches);
}, [searchInput]);
return (
<>
<label className="relative flex items-center">
<span className="sr-only">Search Actions</span>
<span className="absolute h-8 w-8 text-gray-400 flex items-center pl-2 inset-y-1 left-0">
<MagnifyingGlassIcon />
</span>
<input
className="input bg-gray-50 pl-8 placeholder:font-semibold mb-5 h-10"
placeholder="Search Actions"
value={searchInput}
onChange={handleChange}
onBlur={handleBlur}
/>
</label>
</>
);
};
export const ShortcutPrefs = () => {
const [searchInput, setSearchInput] = useState('');
const [matchingShortcuts, setMatchingShortcuts] = useState<string[]>([]);
const metaKey = window.navigator.platform.includes('Mac') ? '⌘' : 'Ctrl';
const altKey = window.navigator.platform.includes('Mac') ? '⌥' : 'Alt';
return (
<div className="inner-section space-y-8">
<h2 className="h4">Keyboard Shortcuts</h2>
<SearchKeyboardShortcuts
searchInput={searchInput}
setSearchInput={setSearchInput}
setMatchingShortcuts={setMatchingShortcuts}
/>
<div className="grid grid-cols-2 rounded-lg border-2 border-gray-50 bg-gray-50 gap-y-2">
<span className="px-3 py-2 text-gray-400 text-sm font-semibold">Action</span>
<span className="px-3 py-2 text-gray-400 text-sm font-semibold">Keybinding</span>
{shortcuts
.map((shortcut) => ({
action: shortcut.action,
keybinding: shortcut.keybinding.replace('Ctrl', metaKey).replace('Alt', altKey)
}))
.filter((shortcut) =>
matchingShortcuts.length > 0
? matchingShortcuts.find((sc) => shortcut.action === sc)
: true
)
.map((shortcut, index) => (
<React.Fragment key={`${shortcut.action}-${index}`}>
<span
className={classNames('text-gray-800 font-semibold p-3 rounded-lg', {
'bg-white': index % 2 === 0
})}
>
{shortcut.action}
</span>
<span
className={classNames('text-gray-800 font-semibold p-3 rounded-lg', {
'bg-white': index % 2 === 0
})}
>
{shortcut.keybinding}
</span>
</React.Fragment>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,193 @@
import React, { PropsWithChildren, useCallback } from 'react';
import { Link, Route, RouteComponentProps, Switch, useRouteMatch } from 'react-router-dom';
import { ErrorBoundary } from 'react-error-boundary';
import classNames from 'classnames';
import { NotificationPrefs } from './NotificationPrefs';
import { AboutSystem } from './about-system/AboutSystem';
import { InterfacePrefs } from './InterfacePrefs';
import { SecurityPrefs } from './SecurityPrefs';
import { AppearancePrefs } from './ApperancePrefs';
import { useCharges } from '../state/docket';
import { AppPrefs } from './AppPrefs';
import { DocketImage } from '../components/DocketImage';
import { ErrorAlert } from '../components/ErrorAlert';
import { useMedia } from '../logic/useMedia';
import { LeftArrow } from '../components/icons/LeftArrow';
import { Interface } from '../components/icons/Interface';
import { getAppName } from '../state/util';
import { Help } from '../nav/Help';
import TlonIcon from '../components/icons/TlonIcon';
import HelpIcon from '../components/icons/HelpIcon';
import LogoutIcon from '../components/icons/LogoutIcon';
import BellIcon from '../components/icons/BellIcon';
import BurstIcon from '../components/icons/BurstIcon';
import PencilIcon from '../components/icons/PencilIcon';
import ForwardSlashIcon from '../components/icons/ForwardSlashIcon';
import { useSystemUpdate } from '../logic/useSystemUpdate';
import { Bullet } from '../components/icons/Bullet';
import SearchSystemPreferences from './SearchSystemPrefences';
import { ShortcutPrefs } from './ShortcutPrefs';
import { AttentionAndPrivacy } from './AttentionAndPrivacy';
interface SystemPreferencesSectionProps {
url: string;
active: boolean;
}
function SystemPreferencesSection({
url,
active,
children
}: PropsWithChildren<SystemPreferencesSectionProps>) {
return (
<li>
<Link
to={url}
className={classNames(
'flex items-center px-2 py-2 hover:text-black hover:bg-gray-50 rounded-lg',
active && 'text-black bg-gray-50'
)}
>
{children}
</Link>
</li>
);
}
export const SystemPreferences = (props: RouteComponentProps<{ submenu: string }>) => {
const { match, history } = props;
const subMatch = useRouteMatch<{ submenu: string; desk?: string }>(
`${match.url}/:submenu/:desk?`
);
const { systemBlocked } = useSystemUpdate();
const charges = useCharges();
const filteredCharges = Object.values(charges).filter((charge) => charge.desk !== window.desk);
const isMobile = useMedia('(max-width: 639px)');
const settingsPath = isMobile ? `${match.url}/:submenu` : '/';
const matchSub = useCallback(
(target: string, desk?: string) => {
if (isMobile) {
return false;
}
if (!subMatch && target === 'system-updates') {
return true;
}
if (desk && subMatch?.params.desk !== desk) {
return false;
}
return subMatch?.params.submenu === target;
},
[match, subMatch]
);
const subUrl = useCallback((submenu: string) => `${match.url}/${submenu}`, [match]);
return (
<ErrorBoundary
FallbackComponent={ErrorAlert}
onReset={() => history.push('/leap/system-preferences')}
>
<div className="h-full overflow-y-auto sm:flex bg-gray-50">
<Route exact={isMobile} path={match.url}>
<aside className="self-start flex-none w-full py-4 font-semibold text-black border-r-2 sm:w-auto min-w-60 sm:py-8 sm:text-gray-600 border-gray-50 bg-white">
<nav className="px-2 sm:px-6 flex flex-col">
<SearchSystemPreferences subUrl={subUrl} />
<span className="text-gray-400 font-semibold pt-1 pl-2 pb-3 text-sm">Landscape</span>
<ul className="space-y-1">
<SystemPreferencesSection
url={subUrl('system-updates')}
active={matchSub('system-updates')}
>
<TlonIcon className="w-6 h-6 mr-3 rounded-md text-gray-600" />
About System
{systemBlocked && <Bullet className="h-5 w-5 ml-auto text-orange-500" />}
</SystemPreferencesSection>
<SystemPreferencesSection url={subUrl('help')} active={matchSub('help')}>
<HelpIcon className="w-6 h-6 mr-3 rounded-md text-gray-600" />
Help and Support
</SystemPreferencesSection>
<SystemPreferencesSection url={subUrl('security')} active={matchSub('security')}>
<LogoutIcon className="w-6 h-6 mr-3 rounded-md text-gray-600" />
Log Out...
</SystemPreferencesSection>
</ul>
</nav>
<nav className="px-2 sm:px-6 flex flex-col">
<span className="text-gray-400 font-semibold pt-5 pl-2 pb-3 text-sm">Settings</span>
<ul className="space-y-1">
<SystemPreferencesSection
url={subUrl('notifications')}
active={matchSub('notifications')}
>
<BellIcon className="w-6 h-6 mr-3 rounded-md text-gray-600" />
Notifications
</SystemPreferencesSection>
<SystemPreferencesSection url={subUrl('privacy')} active={matchSub('privacy')}>
<BurstIcon className="w-6 h-6 mr-3 rounded-md text-gray-600" />
Attention & Privacy
</SystemPreferencesSection>
<SystemPreferencesSection
url={subUrl('appearance')}
active={matchSub('appearance')}
>
<PencilIcon className="w-6 h-6 mr-3 rounded-md text-gray-600" />
Appearance
</SystemPreferencesSection>
<SystemPreferencesSection url={subUrl('shortcuts')} active={matchSub('shortcuts')}>
<ForwardSlashIcon className="w-6 h-6 mr-3 rounded-md text-gray-600" />
Shortcuts
</SystemPreferencesSection>
<SystemPreferencesSection url={subUrl('interface')} active={matchSub('interface')}>
<Interface className="w-8 h-8 mr-3 bg-gray-100 rounded-md" />
Interface Settings
</SystemPreferencesSection>
</ul>
</nav>
<nav className="px-2 sm:px-6 flex flex-col">
<span className="text-gray-400 font-semibold pt-5 pl-2 pb-3 text-sm">
Installed App Settings
</span>
<ul className="space-y-1">
{filteredCharges.map((charge) => (
<SystemPreferencesSection
key={charge.desk}
url={subUrl(`apps/${charge.desk}`)}
active={matchSub('apps', charge.desk)}
>
<DocketImage size="small" className="mr-3" {...charge} />
{getAppName(charge)}
</SystemPreferencesSection>
))}
</ul>
</nav>
</aside>
</Route>
<Route path={settingsPath}>
<section className="flex-1 flex flex-col min-h-[60vh] p-4 sm:p-8 text-gray-800 bg-gray-50">
<Switch>
<Route path={`${match.url}/apps/:desk`} component={AppPrefs} />
<Route path={`${match.url}/help`} component={Help} />
<Route path={`${match.url}/interface`} component={InterfacePrefs} />
<Route path={`${match.url}/appearance`} component={AppearancePrefs} />
<Route path={`${match.url}/shortcuts`} component={ShortcutPrefs} />
<Route path={`${match.url}/notifications`} component={NotificationPrefs} />
<Route path={`${match.url}/privacy`} component={AttentionAndPrivacy} />
<Route path={[`${match.url}/system-updates`, match.url]} component={AboutSystem} />
</Switch>
<Link
to={match.url}
className="inline-flex items-center pt-4 mt-auto text-gray-400 sm:hidden sm:none h4"
>
<LeftArrow className="w-3 h-3 mr-2" /> Back
</Link>
</section>
</Route>
<Route path={`${match.url}/security`} component={SecurityPrefs} />
</div>
</ErrorBoundary>
);
};

View File

@ -0,0 +1,104 @@
import { Vat } from '@urbit/api';
import React from 'react';
import { AppList } from '../../components/AppList';
import { Button } from '../../components/Button';
import { Dialog, DialogClose, DialogContent, DialogTrigger } from '../../components/Dialog';
import { FullTlon16Icon } from '../../components/icons/FullTlon16Icon';
import { useSystemUpdate } from '../../logic/useSystemUpdate';
import { useCharge } from '../../state/docket';
import { useVat } from '../../state/kiln';
import { disableDefault, pluralize } from '../../state/util';
import { UpdatePreferences } from './UpdatePreferences';
function getHash(vat: Vat): string {
const parts = vat.hash.split('.');
return parts[parts.length - 1];
}
export const AboutSystem = () => {
const garden = useVat('garden');
const gardenCharge = useCharge('garden');
const { base, update, systemBlocked, blockedCharges, blockedCount, freezeApps } =
useSystemUpdate();
const hash = base && getHash(base);
const aeon = base ? base.arak.rail?.aeon : '';
const nextAeon = update?.aeon;
return (
<>
<div className="inner-section space-y-8 relative mb-4">
<div className="flex items-center justify-between">
<h2 className="h4">About System</h2>
{systemBlocked && (
<span className="bg-orange-50 text-orange-500 text-sm font-semibold px-2 py-1 rounded-md">
System Update Blocked
</span>
)}
</div>
<div className="leading-5 space-y-4">
<FullTlon16Icon className="h-4" />
<div>
<p>Landscape Operating Environment</p>
<p>by Tlon Corporation</p>
</div>
<div>
<p>
Version {gardenCharge?.version} ({hash})
</p>
{systemBlocked && (
<p>
Aeon {aeon}{' '}
<span className="text-orange-500 mx-4 space-x-2">
<span>&mdash;&gt;</span> <span>/</span> <span>&mdash;&gt;</span>
</span>{' '}
Aeon {nextAeon}
</p>
)}
</div>
{systemBlocked ? (
<>
<p className="text-orange-500">Update is currently blocked by the following apps:</p>
<AppList
apps={blockedCharges}
labelledBy="blocked-apps"
size="xs"
className="font-medium"
/>
<Dialog>
<DialogTrigger as={Button} variant="caution">
Freeze {blockedCount} {pluralize('app', blockedCount)} and Apply Update
</DialogTrigger>
<DialogContent
showClose={false}
onOpenAutoFocus={disableDefault}
className="space-y-6 tracking-tight"
containerClass="w-full max-w-md"
>
<h2 className="h4">
Freeze {blockedCount} {pluralize('App', blockedCount)} and Apply System Update
</h2>
<p>
The following apps will be archived until their developer provides a compatible
update to your system.
</p>
<AppList apps={blockedCharges} labelledBy="blocked-apps" size="xs" />
<div className="flex space-x-6">
<DialogClose as={Button} variant="secondary">
Cancel
</DialogClose>
<DialogClose as={Button} variant="caution" onClick={freezeApps}>
Freeze Apps
</DialogClose>
</div>
</DialogContent>
</Dialog>
</>
) : (
<p>Your urbit is up to date.</p>
)}
</div>
</div>
<UpdatePreferences base={base} />
</>
);
};

Some files were not shown because too many files have changed in this diff Show More