interface: add profile screen and settings

This commit is contained in:
Liam Fitzgerald 2020-07-31 13:00:58 +10:00
parent d9a9ac991f
commit bddf9bfdba
9 changed files with 616 additions and 6 deletions

View File

@ -4679,6 +4679,11 @@
}
}
},
"fn-name": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fn-name/-/fn-name-3.0.0.tgz",
"integrity": "sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA=="
},
"follow-redirects": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.11.0.tgz",
@ -7311,6 +7316,11 @@
"react-is": "^16.8.1"
}
},
"property-expr": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.2.tgz",
"integrity": "sha512-bc/5ggaYZxNkFKj374aLbEDqVADdYaLcFo8XBkishUWbaAdjlphaBFns9TvRA2pUseVL/wMFmui9X3IdNDU37g=="
},
"proxy-addr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
@ -8916,6 +8926,11 @@
"xml-reader": "2.4.3"
}
},
"synchronous-promise": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.13.tgz",
"integrity": "sha512-R9N6uDkVsghHePKh1TEqbnLddO2IY25OcsksyFp/qBe7XYd0PVbKEWxhcdMhpLzE1I6skj5l4aEZ3CRxcbArlA=="
},
"tabbable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-4.0.0.tgz",
@ -9148,6 +9163,11 @@
"integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==",
"dev": true
},
"toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha1-riF2gXXRVZ1IvvNUILL0li8JwzA="
},
"transformation-matrix": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/transformation-matrix/-/transformation-matrix-2.1.1.tgz",
@ -11716,6 +11736,20 @@
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
}
},
"yup": {
"version": "0.29.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-0.29.1.tgz",
"integrity": "sha512-U7mPIbgfQWI6M3hZCJdGFrr+U0laG28FxMAKIgNvgl7OtyYuUoc4uy9qCWYHZjh49b8T7Ug8NNDdiMIEytcXrQ==",
"requires": {
"@babel/runtime": "^7.9.6",
"fn-name": "~3.0.0",
"lodash": "^4.17.15",
"lodash-es": "^4.17.11",
"property-expr": "^2.0.2",
"synchronous-promise": "^2.0.10",
"toposort": "^2.0.2"
}
}
}
}

View File

@ -6,7 +6,7 @@
"dependencies": {
"@babel/runtime": "^7.10.5",
"@reach/disclosure": "^0.10.5",
"@reach/menu-button": "^0.10.1",
"@reach/menu-button": "^0.10.18",
"@reach/tabs": "^0.10.5",
"@tlon/indigo-light": "^1.0.3",
"@tlon/indigo-react": "^1.1.15",
@ -30,7 +30,8 @@
"styled-system": "^5.1.5",
"suncalc": "^1.8.0",
"urbit-ob": "^5.0.0",
"urbit-sigil-js": "^1.3.2"
"urbit-sigil-js": "^1.3.2",
"yup": "^0.29.1"
},
"devDependencies": {
"@babel/core": "^7.9.0",

View File

@ -15,6 +15,7 @@ import DojoApp from './apps/dojo/app';
import GroupsApp from './apps/groups/app';
import LinksApp from './apps/links/app';
import PublishApp from './apps/publish/app';
import Profile from './apps/profile/profile';
import StatusBar from './components/StatusBar';
import ErrorComponent from './components/Error';
@ -40,6 +41,13 @@ const Root = styled.div`
width: 100%;
padding: 0;
margin: 0;
${p => p.background?.type === 'url' ? `
background-image: url('${p.background?.url}');
background-size: cover;
` : p.background?.type === 'color' ? `
background-color: ${p.background.color}
` : ``
}
`;
const Content = styled.div`
@ -88,10 +96,11 @@ class App extends React.Component {
const selectedGroups = this.state.selectedGroups ? this.state.selectedGroups : [];
const { state } = this;
const theme = state.dark ? dark : light;
const { background } = state;
return (
<ThemeProvider theme={theme}>
<Root>
<Root background={background} >
<Router>
<StatusBarWithRouter props={this.props}
associations={associations}
@ -163,6 +172,12 @@ class App extends React.Component {
/>
)}
/>
<Route
path="/~profile"
render={ p => (
<Profile ship={this.ship} api={this.api} {...state} />
)}
/>
<Route
render={(props) => (
<ErrorComponent {...props} code={404} description="Not Found" />

View File

@ -0,0 +1,205 @@
import React, { useCallback } from "react";
import {
Input,
Box,
Center,
Col,
InputLabel,
Radio,
Checkbox,
Button,
} from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import _ from "lodash";
import GlobalApi from "../../../../api/global";
import { BackgroundConfig } from "../../../../types/local-update";
import { LaunchState } from "../../../../types/launch-update";
const tiles = ["publish", "links", "chat", "dojo", "clock", "weather"];
const formSchema = Yup.object().shape({
order: Yup.string()
.required("Required")
.test(
"tiles",
"Invalid tile ordering",
(o: string = "") =>
_.difference(
o.split(", ").map((i) => i.trim()),
tiles
).length === 0
),
bgType: Yup.string()
.oneOf(["none", "color", "url"], "invalid")
.required("Required"),
bgUrl: Yup.string().url(),
bgColor: Yup.string().matches(/#([A-F]|[a-f]|[0-9]){6}/, "Invalid color"),
avatars: Yup.boolean(),
nicknames: Yup.boolean(),
});
type BgType = "none" | "url" | "color";
interface FormSchema {
order: string;
bgType: BgType;
bgColor: string | undefined;
bgUrl: string | undefined;
avatars: boolean;
nicknames: boolean;
}
interface DisplayFormProps {
api: GlobalApi;
launch: LaunchState;
dark: boolean;
background: BackgroundConfig;
hideAvatars: boolean;
hideNicknames: boolean;
}
function ImagePicker({ url }: { url: string }) {
return (
<Center
width="250px"
height="250px"
p={3}
backgroundImage={`url('${url}')`}
backgroundSize="cover"
>
<Box>Change</Box>
</Center>
);
}
function BackgroundPicker({
bgType,
bgUrl,
}: {
bgType: BgType;
bgUrl?: string;
}) {
return (
<Box>
<InputLabel>Landscape Background</InputLabel>
<Box display="flex" alignItems="center">
<Box mt={3} mr={10}>
<Radio label="Image" id="url" name="bgType" />
<Radio label="Color" id="color" name="bgType" />
<Radio label="None" id="none" name="bgType" />
</Box>
{bgType === "url" && (
<Col>
<Input ml={4} type="text" label="URL" id="bgUrl" name="bgUrl" />
{/*<ImagePicker url={bgUrl || ''} />*/}
</Col>
)}
{bgType === "color" && (
<Input ml={4} type="text" label="Color" id="bgColor" name="bgColor" />
)}
</Box>
</Box>
);
}
export default function DisplayForm(props: DisplayFormProps) {
const { api, launch, background, hideAvatars, hideNicknames } = props;
const initialOrder = launch.tileOrdering.join(", ");
let bgColor, bgUrl;
if (background?.type === "url") {
bgUrl = background.url;
}
if (background?.type === "color") {
bgColor = background.color;
}
const bgType = background?.type || "none";
const logoutAll = useCallback(() => {}, []);
return (
<Formik
validationSchema={formSchema}
initialValues={
{
order: initialOrder,
bgType,
bgColor,
bgUrl,
avatars: hideAvatars,
nicknames: hideNicknames,
} as FormSchema
}
onSubmit={(values, actions) => {
api.launch.changeOrder(values.order.split(", "));
const bgConfig: BackgroundConfig =
values.bgType === "color"
? { type: "color", color: values.bgColor || "" }
: values.bgType === "url"
? { type: "url", url: values.bgUrl || "" }
: undefined;
api.local.setBackground(bgConfig);
api.local.hideAvatars(values.avatars);
api.local.hideNicknames(values.nicknames);
api.local.dehydrate();
actions.setSubmitting(false);
}}
>
{(props) => (
<Form>
<Box
display="grid"
gridTemplateColumns="1fr"
gridTemplateRows="auto"
gridRowGap={2}
>
<Box color="black" fontSize={1} mb={4} fontWeight={900}>
Display Preferences
</Box>
<Box>
<Input
label="Home Tile Order"
id="order"
mt={2}
type="text"
width={256}
/>
</Box>
<BackgroundPicker
bgType={props.values.bgType}
bgUrl={props.values.bgUrl}
/>
<Box mt={3}>
<Checkbox
mt={3}
label="Disable avatars"
id="avatars"
caption="Do not show user-set avatars"
/>
<Checkbox
mt={3}
label="Disable nicknames"
id="nicknames"
caption="Do not show user-set nicknames"
/>
</Box>
</Box>
<Button
onClick={logoutAll}
border={1}
borderColor="washedGray"
type="submit"
>
Submit
</Button>
</Form>
)}
</Formik>
);
}

View File

@ -0,0 +1,193 @@
import React, { useCallback } from "react";
import {
Input,
Box,
Center,
Button,
Checkbox,
Col,
Text,
Menu,
MenuButton,
MenuList,
MenuItem,
} from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import GlobalApi from "../../../../api/global";
import { S3State } from "../../../../types/s3-update";
function BucketList({
buckets,
selected,
api,
}: {
buckets: Set<string>;
selected: string;
api: GlobalApi;
}) {
const _buckets = Array.from(buckets);
const onSubmit = useCallback(
(values: { newBucket: string }) => {
api.s3.addBucket(values.newBucket);
},
[api]
);
const onSelect = useCallback(
(bucket: string) => {
return function () {
api.s3.setCurrentBucket(bucket);
};
},
[api]
);
const onDelete = useCallback(
(bucket: string) => {
return function () {
api.s3.removeBucket(bucket);
};
},
[api]
);
return (
<Formik initialValues={{ newBucket: "" }} onSubmit={onSubmit}>
<Form>
<Col alignItems="start">
{_buckets.map((bucket) => (
<Box
key={bucket}
display="flex"
justifyContent="space-between"
alignItems="center"
borderRadius={1}
border={1}
borderColor="washedGray"
fontSize={1}
pl={2}
mb={2}
width="100%"
>
<Text>{bucket}</Text>
{bucket === selected && (
<Text p={1} color="green">
Active
</Text>
)}
{bucket !== selected && (
<Menu>
<MenuButton sm>Options</MenuButton>
<MenuList>
<MenuItem onSelect={onSelect(bucket)}>Make Active</MenuItem>
<MenuItem onSelect={onDelete(bucket)}>Delete</MenuItem>
</MenuList>
</Menu>
)}
</Box>
))}
<Input
mt={4}
type="text"
label="New Bucket"
id="newBucket"
name="newBucket"
/>
<Button border={1} borderColor="washedGrey" type="submit">
Add
</Button>
</Col>
</Form>
</Formik>
);
}
interface FormSchema {
s3bucket: string;
s3buckets: string[];
s3endpoint: string;
s3accessKeyId: string;
s3secretAccessKey: string;
}
interface S3FormProps {
api: GlobalApi;
s3: S3State;
}
export default function S3Form(props: S3FormProps) {
const { api, s3 } = props;
const onSubmit = useCallback(
(values: FormSchema) => {
if (values.s3secretAccessKey !== s3.credentials?.secretAccessKey) {
api.s3.setSecretAccessKey(values.s3secretAccessKey);
}
if (values.s3endpoint !== s3.credentials?.endpoint) {
api.s3.setEndpoint(values.s3endpoint);
}
if (values.s3accessKeyId !== s3.credentials?.accessKeyId) {
api.s3.setAccessKeyId(values.s3accessKeyId);
}
},
[api]
);
return (
<Box
display="grid"
gridTemplateColumns="1fr"
gridTemplateRows="auto"
gridRowGap={4}
justifyItems="start"
>
<Box color="black" fontSize={1} mb={4} mt={7} fontWeight={900}>
S3 Credentials
</Box>
<Formik
initialValues={
{
s3bucket: s3.configuration.currentBucket,
s3buckets: Array.from(s3.configuration.buckets),
s3endpoint: s3.credentials?.endpoint,
s3accessKeyId: s3.credentials?.accessKeyId,
s3secretAccessKey: s3.credentials?.secretAccessKey,
} as FormSchema
}
onSubmit={onSubmit}
>
<Form>
<Input width="256px" type="text" label="Endpoint" id="s3endpoint" />
<Input
width="256px"
type="text"
label="Access Key ID"
id="s3accessKeyId"
/>
<Input
width="256px"
type="password"
label="Secret Access Key"
id="s3secretAccessKey"
/>
<Button border={1} type="submit">
Submit
</Button>
</Form>
</Formik>
<Box color="black" fontSize={1} my={2} fontWeight={700}>
S3 Buckets
</Box>
<BucketList
buckets={s3.configuration.buckets}
selected={s3.configuration.currentBucket}
api={api}
/>
</Box>
);
}

View File

@ -0,0 +1,41 @@
import React from "react";
import { Box, Button } from "@tlon/indigo-react";
import GlobalApi from "../../../../api/global";
interface SecuritySettingsProps {
api: GlobalApi;
}
export default function SecuritySettings({ api }: SecuritySettingsProps) {
return (
<>
<Box color="black" fontSize={1} mt={7} mb={2} fontWeight={900}>
Security
</Box>
<Box color="black" fontSize={0} mt={4} fontWeight={700}>
Log out of this session
</Box>
<Box fontSize={0} mt={2} color="gray">
You will be logged out of your Urbit on this browser
<form method="post" action="/~/logout">
<Button narrow mt={2} border={1}>
Logout
</Button>
</form>
</Box>
<Box color="black" fontSize={0} mt={4} fontWeight={700}>
Log out of all sessions
</Box>
<Box fontSize={0} mt={2} color="gray">
You will be logged out of all browsers that have currently logged into
your Urbit
<form method="post" action="/~/logout">
<Button error narrow mt={2} border={1}>
Logout Everywhere
</Button>
</form>
</Box>
</>
);
}

View File

@ -0,0 +1,60 @@
import React from "react";
import {
Box,
Text,
Button,
Col,
Input,
InputLabel,
Radio,
Checkbox,
} from "@tlon/indigo-react";
import * as Yup from "yup";
import { Formik, Form } from "formik";
import _ from "lodash";
import GlobalApi from "../../../api/global";
import { StoreState } from "../../../store/type";
import DisplayForm from "./lib/DisplayForm";
import S3Form from "./lib/S3Form";
import SecuritySettings from "./lib/Security";
type ProfileProps = StoreState & { api: GlobalApi; ship: string };
export default function Profile({
api,
launch,
s3,
dark,
hideAvatars,
hideNicknames,
background,
}: ProfileProps) {
return (
<Col
backgroundColor="white"
fontSize={2}
p={4}
m={3}
borderRadius={1}
maxWidth="300px"
>
<Box color="black" fontSize={0} mb={4}>
Ship Settings
</Box>
<DisplayForm
api={api}
launch={launch}
dark={dark}
hideNicknames={hideNicknames}
hideAvatars={hideAvatars}
background={background}
/>
<S3Form api={api} s3={s3} />
<SecuritySettings api={api} />
</Col>
);
}

View File

@ -0,0 +1,59 @@
import React from "react";
import { Box, Col, Center, Icon } from "@tlon/indigo-react";
import { Sigil } from "../../lib/sigil";
import Settings from "./components/settings";
export default function ProfileScreen(props: any) {
const { ship, dark } = props;
return (
<Box height="100%" px={3} pb={3} borderRadius={1}>
<Box
height="100%"
width="100%"
display="flex"
borderRadius={1}
bg="white"
border={1}
borderColor="washedGray"
>
<Col collapse borderRight={1} borderColor="washedGray">
<Box borderBottom={1} borderBottomColor="washedGray">
<Box
backgroundColor="black"
borderRadius={8}
margin={4}
height={128}
width={128}
display="flex"
justifyContent="center"
alignItems="center"
>
<Sigil ship={ship} size={80} color={dark ? '#FFFFFF' : '#00000'}/>
</Box>
</Box>
<Box py={4}>
<Box
display="flex"
alignItems="center"
verticalAlign="middle"
fontSize={0}
py={1}
px={3}
color="blue"
backgroundColor="washedBlue"
>
<Icon mr={2} display="inline-block" icon="Circle" fill="blue" />
Ship Settings
</Box>
</Box>
</Col>
<Box overflowY="auto" flexGrow={1}>
<Settings {...props} />
</Box>
</Box>
</Box>
);
}

View File

@ -15,6 +15,8 @@ const getLocationName = (basePath) => {
return 'Links';
else if (basePath === '~publish')
return 'Publish';
else if (basePath === '~profile')
return 'Profile';
else
return 'Unknown';
};
@ -40,12 +42,12 @@ const StatusBar = (props) => {
return (
<div
className={
'bg-white bg-gray0-d w-100 justify-between relative tc pt3 ' + display
'w-100 justify-between relative tc pt3 ' + display
}
style={{ height: 45 }}
>
<div className="fl lh-copy absolute left-0 pl4" style={{ top: 8 }}>
<Link to="/~groups/me"
<div className="bg-white b--gray4 ba bg-gray0-d fl lh-copy absolute left-0 ml4 pl2 pr2 br1" style={{ top: 8 }}>
<Link to="/~profile"
className="dib v-top" style={{ lineHeight: 0, paddingTop: 6 }}>
<Sigil
ship={'~' + window.ship}