add routing support for selected instance
This commit is contained in:
parent
17488ff8a0
commit
39d279debd
|
@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [Unreleased]
|
||||
### Added
|
||||
- It's now shown in the front-end if an instance wasn't crawled because of its robots.txt.
|
||||
- You can now link directly to instances at e.g. /instance/mastodon.social.
|
||||
- Instance details now have a link to the corresponding fediverse.network page.
|
||||
### Changed
|
||||
### Deprecated
|
||||
### Removed
|
||||
|
|
|
@ -68,6 +68,6 @@ config :backend, :crawler,
|
|||
|
||||
config :backend, Backend.Scheduler,
|
||||
jobs: [
|
||||
# Every 15 minutes
|
||||
{"*/15 * * * *", {Backend.Scheduler, :prune_crawls, [12, "hour"]}}
|
||||
# Every 5 minutes
|
||||
{"*/5 * * * *", {Backend.Scheduler, :prune_crawls, [12, "month"]}}
|
||||
]
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
The React frontend for [fediverse.space](https://fediverse.space). Written in Typescript.
|
||||
|
||||
- Set the environment variable `REACT_APP_STAGING=true` when building to use the staging backend.
|
||||
- React components are organized into atoms, molecules, organisms, and screens according to [Atomic Design](http://bradfrost.com/blog/post/atomic-web-design/).
|
||||
|
||||
# Default README
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@
|
|||
"@blueprintjs/icons": "^3.9.1",
|
||||
"@blueprintjs/select": "^3.9.0",
|
||||
"classnames": "^2.2.6",
|
||||
"connected-react-router": "^6.5.2",
|
||||
"cross-fetch": "^3.0.4",
|
||||
"cytoscape": "^3.8.1",
|
||||
"cytoscape-popper": "^1.0.4",
|
||||
|
|
|
@ -3,11 +3,12 @@ import * as React from "react";
|
|||
import { Button, Classes, Dialog } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
import { BrowserRouter, Route } from "react-router-dom";
|
||||
import { Nav } from "./components/Nav";
|
||||
import { AboutScreen } from "./components/screens/AboutScreen";
|
||||
import { GraphScreen } from "./components/screens/GraphScreen";
|
||||
import { DESKTOP_WIDTH_THRESHOLD } from "./constants";
|
||||
import { ConnectedRouter } from "connected-react-router";
|
||||
import { Route, RouteComponentProps } from "react-router-dom";
|
||||
import { Nav } from "./components/organisms/";
|
||||
import { AboutScreen, GraphScreen } from "./components/screens/";
|
||||
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
|
||||
import { history } from "./index";
|
||||
|
||||
interface IAppLocalState {
|
||||
mobileDialogOpen: boolean;
|
||||
|
@ -20,14 +21,19 @@ export class AppRouter extends React.Component<{}, IAppLocalState> {
|
|||
|
||||
public render() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ConnectedRouter history={history}>
|
||||
<div className={`${Classes.DARK} App`}>
|
||||
<Nav />
|
||||
<Route exact={true} path="/" component={GraphScreen} />
|
||||
{/* We use `children={}` instead of `component={}` such that the graph is never unmounted */}
|
||||
<Route exact={true} path="/">
|
||||
<Route path={INSTANCE_DOMAIN_PATH}>
|
||||
{(routeProps: RouteComponentProps<IInstanceDomainPath>) => <GraphScreen {...routeProps} />}
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/about" component={AboutScreen} />
|
||||
{this.renderMobileDialog()}
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</ConnectedRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import { NonIdealState } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
import * as React from "react";
|
||||
|
||||
export const ErrorState: React.SFC = () => <NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} />;
|
|
@ -1,101 +0,0 @@
|
|||
import { get } from "lodash";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { Dispatch } from "redux";
|
||||
import { selectAndLoadInstance } from "../redux/actions";
|
||||
import { IAppState, IGraph } from "../redux/types";
|
||||
import Cytoscape from "./Cytoscape";
|
||||
import { ErrorState } from "./ErrorState";
|
||||
import { FloatingResetButton } from "./FloatingResetButton";
|
||||
|
||||
interface IGraphProps {
|
||||
graph?: IGraph;
|
||||
currentInstanceName: string | null;
|
||||
selectAndLoadInstance: (name: string) => void;
|
||||
}
|
||||
class GraphImpl extends React.Component<IGraphProps> {
|
||||
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
||||
|
||||
public constructor(props: IGraphProps) {
|
||||
super(props);
|
||||
this.cytoscapeComponent = React.createRef();
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (!this.props.graph) {
|
||||
return <ErrorState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Cytoscape
|
||||
elements={this.props.graph}
|
||||
onInstanceSelect={this.onInstanceSelect}
|
||||
onInstanceDeselect={this.onInstanceDeselect}
|
||||
ref={this.cytoscapeComponent}
|
||||
/>
|
||||
<FloatingResetButton onClick={this.resetGraphPosition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IGraphProps) {
|
||||
const { currentInstanceName } = this.props;
|
||||
if (prevProps.currentInstanceName !== currentInstanceName) {
|
||||
const cy = this.getCytoscape();
|
||||
cy.$id(prevProps.currentInstanceName).unselect();
|
||||
if (currentInstanceName) {
|
||||
// Select instance
|
||||
cy.$id(`${currentInstanceName}`).select();
|
||||
// Center it
|
||||
const selected = cy.$id(currentInstanceName);
|
||||
cy.center(selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resetGraphPosition = () => {
|
||||
const cy = this.getCytoscape();
|
||||
const { currentInstanceName } = this.props;
|
||||
if (currentInstanceName) {
|
||||
cy.zoom({
|
||||
level: 0.2,
|
||||
position: cy.$id(currentInstanceName).position()
|
||||
});
|
||||
} else {
|
||||
cy.zoom({
|
||||
level: 0.2,
|
||||
position: { x: 0, y: 0 }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onInstanceSelect = (domain: string) => {
|
||||
this.props.selectAndLoadInstance(domain);
|
||||
};
|
||||
|
||||
private onInstanceDeselect = () => {
|
||||
this.props.selectAndLoadInstance("");
|
||||
};
|
||||
|
||||
private getCytoscape = () => {
|
||||
const cy = get(this.cytoscapeComponent, "current.cy");
|
||||
if (!cy) {
|
||||
throw new Error("Expected cytoscape component but did not find one.");
|
||||
}
|
||||
return cy;
|
||||
};
|
||||
}
|
||||
const mapStateToProps = (state: IAppState) => ({
|
||||
currentInstanceName: state.currentInstance.currentInstanceName,
|
||||
graph: state.data.graph
|
||||
});
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
|
||||
});
|
||||
const Graph = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(GraphImpl);
|
||||
export default Graph;
|
|
@ -1,41 +0,0 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Alignment, Button, Navbar } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { InstanceSearch } from "./InstanceSearch";
|
||||
|
||||
interface INavState {
|
||||
aboutIsOpen: boolean;
|
||||
}
|
||||
export class Nav extends React.Component<{}, INavState> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = { aboutIsOpen: false };
|
||||
}
|
||||
|
||||
public render() {
|
||||
const StyledLink = styled(Link)`
|
||||
color: white !important;
|
||||
`;
|
||||
return (
|
||||
<Navbar fixedToTop={true}>
|
||||
<Navbar.Group align={Alignment.LEFT}>
|
||||
<Navbar.Heading>fediverse.space</Navbar.Heading>
|
||||
<Navbar.Divider />
|
||||
<StyledLink to="/">
|
||||
<Button icon={IconNames.GLOBE_NETWORK} text="Home" minimal={true} />
|
||||
</StyledLink>
|
||||
<StyledLink to="/about">
|
||||
<Button icon={IconNames.INFO_SIGN} text="About" minimal={true} />
|
||||
</StyledLink>
|
||||
</Navbar.Group>
|
||||
<Navbar.Group align={Alignment.RIGHT}>
|
||||
<InstanceSearch />
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
import styled from "styled-components";
|
||||
|
||||
export const Page = styled.div`
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
padding: 2em;
|
||||
`;
|
26
frontend/src/components/atoms/Page.tsx
Normal file
26
frontend/src/components/atoms/Page.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Backdrop = styled.div`
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #293742;
|
||||
z-index: 100;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
padding: 2em;
|
||||
`;
|
||||
|
||||
const Page: React.FC = ({ children }) => (
|
||||
<Backdrop>
|
||||
<Container>{children}</Container>
|
||||
</Backdrop>
|
||||
);
|
||||
|
||||
export default Page;
|
2
frontend/src/components/atoms/index.ts
Normal file
2
frontend/src/components/atoms/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as Page } from "./Page";
|
||||
export { default as FloatingCard } from "./FloatingCard";
|
|
@ -1,30 +1,32 @@
|
|||
import cytoscape from "cytoscape";
|
||||
import popper from "cytoscape-popper";
|
||||
import * as React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import styled from "styled-components";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
import { DEFAULT_NODE_COLOR, SELECTED_NODE_COLOR } from "../constants";
|
||||
import { DEFAULT_NODE_COLOR, SELECTED_NODE_COLOR } from "../../constants";
|
||||
|
||||
const EntireWindowDiv = styled.div`
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
const CytoscapeContainer = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
interface ICytoscapeProps {
|
||||
currentNodeId: string | null;
|
||||
elements: cytoscape.ElementsDefinition;
|
||||
onInstanceSelect: (domain: string) => void;
|
||||
onInstanceDeselect: () => void;
|
||||
navigateToInstancePath: (domain: string) => void;
|
||||
navigateToRoot: () => void;
|
||||
}
|
||||
class Cytoscape extends React.Component<ICytoscapeProps> {
|
||||
public cy?: cytoscape.Core;
|
||||
private cy?: cytoscape.Core;
|
||||
|
||||
public shouldComponentUpdate(prevProps: ICytoscapeProps) {
|
||||
// We only want to update this component if the current instance selection changes.
|
||||
// We know that the `elements` prop will never change so we skip the expensive computations here.
|
||||
return prevProps.currentNodeId !== this.props.currentNodeId;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
const container = ReactDOM.findDOMNode(this);
|
||||
cytoscape.use(popper as any);
|
||||
this.cy = cytoscape({
|
||||
autoungrabify: true,
|
||||
container: container as any,
|
||||
|
@ -90,11 +92,11 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
"font-size": 50,
|
||||
"min-zoomed-font-size": 16
|
||||
})
|
||||
.selector(".hidden")
|
||||
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected
|
||||
.style({
|
||||
display: "none"
|
||||
})
|
||||
.selector(".thickEdge")
|
||||
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
|
||||
.style({
|
||||
width: 2
|
||||
})
|
||||
|
@ -102,8 +104,8 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
|
||||
this.cy.nodes().on("select", e => {
|
||||
const instanceId = e.target.data("id");
|
||||
if (instanceId) {
|
||||
this.props.onInstanceSelect(instanceId);
|
||||
if (instanceId && instanceId !== this.props.currentNodeId) {
|
||||
this.props.navigateToInstancePath(instanceId);
|
||||
}
|
||||
|
||||
const neighborhood = this.cy!.$id(instanceId).closedNeighborhood();
|
||||
|
@ -119,7 +121,6 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
});
|
||||
});
|
||||
this.cy.nodes().on("unselect", e => {
|
||||
this.props.onInstanceDeselect();
|
||||
this.cy!.batch(() => {
|
||||
this.cy!.nodes().removeClass("hidden");
|
||||
this.cy!.edges().removeClass("thickEdge");
|
||||
|
@ -128,14 +129,17 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
this.cy.on("click", e => {
|
||||
// Clicking on the background should also deselect
|
||||
const target = e.target;
|
||||
if (!target) {
|
||||
this.props.onInstanceDeselect();
|
||||
if (!target || target === this.cy || target.isEdge()) {
|
||||
// Go to the URL "/"
|
||||
this.props.navigateToRoot();
|
||||
}
|
||||
this.cy!.batch(() => {
|
||||
this.cy!.nodes().removeClass("hidden");
|
||||
this.cy!.edges().removeClass("thickEdge");
|
||||
});
|
||||
});
|
||||
|
||||
this.setNodeSelection();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: ICytoscapeProps) {
|
||||
this.setNodeSelection(prevProps.currentNodeId);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
|
@ -145,8 +149,47 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
return <EntireWindowDiv />;
|
||||
return <CytoscapeContainer />;
|
||||
}
|
||||
|
||||
public resetGraphPosition() {
|
||||
if (!this.cy) {
|
||||
return;
|
||||
}
|
||||
const { currentNodeId } = this.props;
|
||||
if (currentNodeId) {
|
||||
this.cy.zoom({
|
||||
level: 0.2,
|
||||
position: this.cy.$id(currentNodeId).position()
|
||||
});
|
||||
} else {
|
||||
this.cy.zoom({
|
||||
level: 0.2,
|
||||
position: { x: 0, y: 0 }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates cytoscape's internal state to match our props.
|
||||
*/
|
||||
private setNodeSelection = (prevNodeId?: string | null) => {
|
||||
if (!this.cy) {
|
||||
return;
|
||||
}
|
||||
if (prevNodeId) {
|
||||
this.cy.$id(prevNodeId).unselect();
|
||||
}
|
||||
|
||||
const { currentNodeId } = this.props;
|
||||
if (currentNodeId) {
|
||||
// Select instance
|
||||
this.cy.$id(currentNodeId).select();
|
||||
// Center it
|
||||
const selected = this.cy.$id(currentNodeId);
|
||||
this.cy.center(selected);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Cytoscape;
|
7
frontend/src/components/molecules/ErrorState.tsx
Normal file
7
frontend/src/components/molecules/ErrorState.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { NonIdealState } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
import * as React from "react";
|
||||
|
||||
const ErrorState: React.SFC = () => <NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} />;
|
||||
|
||||
export default ErrorState;
|
|
@ -1,12 +1,13 @@
|
|||
import { Button } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import FloatingCard from "./FloatingCard";
|
||||
import { FloatingCard } from "../atoms/";
|
||||
|
||||
interface IFloatingResetButtonProps {
|
||||
onClick?: () => any;
|
||||
}
|
||||
export const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
|
||||
const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
|
||||
<FloatingCard>
|
||||
<Button icon="compass" onClick={onClick} />
|
||||
</FloatingCard>
|
||||
);
|
||||
export default FloatingResetButton;
|
3
frontend/src/components/molecules/index.ts
Normal file
3
frontend/src/components/molecules/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as Cytoscape } from "./Cytoscape";
|
||||
export { default as ErrorState } from "./ErrorState";
|
||||
export { default as FloatingResetButton } from "./FloatingResetButton";
|
76
frontend/src/components/organisms/Graph.tsx
Normal file
76
frontend/src/components/organisms/Graph.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { push } from "connected-react-router";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { IAppState, IGraph } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { Cytoscape, ErrorState, FloatingResetButton } from "../molecules/";
|
||||
|
||||
const GraphDiv = styled.div`
|
||||
flex: 3;
|
||||
`;
|
||||
|
||||
// TODO: merge this component with Cytoscape.tsx
|
||||
interface IGraphProps {
|
||||
graph?: IGraph;
|
||||
currentInstanceName: string | null;
|
||||
navigate: (path: string) => void;
|
||||
}
|
||||
class GraphImpl extends React.Component<IGraphProps> {
|
||||
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
||||
|
||||
public constructor(props: IGraphProps) {
|
||||
super(props);
|
||||
this.cytoscapeComponent = React.createRef();
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (!this.props.graph) {
|
||||
return <ErrorState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GraphDiv>
|
||||
<Cytoscape
|
||||
currentNodeId={this.props.currentInstanceName}
|
||||
elements={this.props.graph}
|
||||
navigateToInstancePath={this.navigateToInstancePath}
|
||||
navigateToRoot={this.navigateToRoot}
|
||||
ref={this.cytoscapeComponent}
|
||||
/>
|
||||
<FloatingResetButton onClick={this.resetGraphPosition} />
|
||||
</GraphDiv>
|
||||
);
|
||||
}
|
||||
|
||||
private resetGraphPosition = () => {
|
||||
if (this.cytoscapeComponent.current) {
|
||||
this.cytoscapeComponent.current.resetGraphPosition();
|
||||
}
|
||||
};
|
||||
|
||||
private navigateToInstancePath = (domain: string) => {
|
||||
this.props.navigate(`/instance/${domain}`);
|
||||
};
|
||||
|
||||
private navigateToRoot = () => {
|
||||
this.props.navigate("/");
|
||||
};
|
||||
}
|
||||
const mapStateToProps = (state: IAppState) => {
|
||||
const match = domainMatchSelector(state);
|
||||
return {
|
||||
currentInstanceName: match && match.params.domain,
|
||||
graph: state.data.graph
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
navigate: (path: string) => dispatch(push(path))
|
||||
});
|
||||
const Graph = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(GraphImpl);
|
||||
export default Graph;
|
|
@ -6,14 +6,15 @@ import { Button, MenuItem } from "@blueprintjs/core";
|
|||
import { IconNames } from "@blueprintjs/icons";
|
||||
import { IItemRendererProps, ItemPredicate, Select } from "@blueprintjs/select";
|
||||
|
||||
import { RouteComponentProps, withRouter } from "react-router";
|
||||
import { selectAndLoadInstance } from "../redux/actions";
|
||||
import { IAppState, IInstance } from "../redux/types";
|
||||
import { push } from "connected-react-router";
|
||||
import { IAppState, IInstance } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
|
||||
interface IInstanceSearchProps extends RouteComponentProps {
|
||||
interface IInstanceSearchProps {
|
||||
currentInstanceName: string | null;
|
||||
pathname: string;
|
||||
instances?: IInstance[];
|
||||
selectAndLoadInstance: (instanceName: string) => void;
|
||||
selectInstance: (instanceName: string) => void;
|
||||
}
|
||||
|
||||
const InstanceSelect = Select.ofType<IInstance>();
|
||||
|
@ -26,7 +27,9 @@ class InstanceSearchImpl extends React.Component<IInstanceSearchProps> {
|
|||
itemRenderer={this.itemRenderer}
|
||||
onItemSelect={this.onItemSelect}
|
||||
itemPredicate={this.itemPredicate}
|
||||
disabled={!this.props.instances || this.props.location.pathname !== "/"}
|
||||
disabled={
|
||||
!this.props.instances || (!this.props.pathname.startsWith("/instance/") && this.props.pathname !== "/")
|
||||
}
|
||||
initialContent={this.renderInitialContent()}
|
||||
noResults={this.renderNoResults()}
|
||||
popoverProps={{ popoverClassName: "fediverse-instance-search-popover" }}
|
||||
|
@ -66,20 +69,23 @@ class InstanceSearchImpl extends React.Component<IInstanceSearchProps> {
|
|||
};
|
||||
|
||||
private onItemSelect = (item: IInstance, event?: React.SyntheticEvent<HTMLElement>) => {
|
||||
this.props.selectAndLoadInstance(item.name);
|
||||
this.props.selectInstance(item.name);
|
||||
};
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: IAppState) => ({
|
||||
currentInstanceName: state.currentInstance.currentInstanceName,
|
||||
instances: state.data.instances
|
||||
});
|
||||
const mapStateToProps = (state: IAppState) => {
|
||||
const match = domainMatchSelector(state);
|
||||
return {
|
||||
currentInstanceName: match && match.params.domain,
|
||||
instances: state.data.instances,
|
||||
pathname: state.router.location.pathname
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
|
||||
selectInstance: (domain: string) => dispatch(push(`/instance/${domain}`))
|
||||
});
|
||||
export const InstanceSearch = withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(InstanceSearchImpl)
|
||||
);
|
||||
const InstanceSearch = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(InstanceSearchImpl);
|
||||
export default InstanceSearch;
|
56
frontend/src/components/organisms/Nav.tsx
Normal file
56
frontend/src/components/organisms/Nav.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Alignment, Navbar } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
import { Classes } from "@blueprintjs/core";
|
||||
import { match, NavLink } from "react-router-dom";
|
||||
import { InstanceSearch } from ".";
|
||||
import { IInstanceDomainPath } from "../../constants";
|
||||
|
||||
interface INavState {
|
||||
aboutIsOpen: boolean;
|
||||
}
|
||||
|
||||
const linkIsActive = (currMatch: match<IInstanceDomainPath>, location: Location) => {
|
||||
return location.pathname === "/" || location.pathname.startsWith("/instance/");
|
||||
};
|
||||
|
||||
class Nav extends React.Component<{}, INavState> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.state = { aboutIsOpen: false };
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Navbar fixedToTop={true}>
|
||||
<Navbar.Group align={Alignment.LEFT}>
|
||||
<Navbar.Heading>fediverse.space</Navbar.Heading>
|
||||
<Navbar.Divider />
|
||||
<NavLink
|
||||
to="/"
|
||||
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.GLOBE_NETWORK}`}
|
||||
activeClassName={Classes.INTENT_PRIMARY}
|
||||
isActive={linkIsActive as any}
|
||||
>
|
||||
Home
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/about"
|
||||
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
|
||||
activeClassName={Classes.INTENT_PRIMARY}
|
||||
exact={true}
|
||||
>
|
||||
About
|
||||
</NavLink>
|
||||
</Navbar.Group>
|
||||
<Navbar.Group align={Alignment.RIGHT}>
|
||||
<InstanceSearch />
|
||||
</Navbar.Group>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Nav;
|
|
@ -3,7 +3,6 @@ import moment from "moment";
|
|||
import * as numeral from "numeral";
|
||||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import sanitize from "sanitize-html";
|
||||
|
||||
import {
|
||||
|
@ -27,10 +26,53 @@ import {
|
|||
} from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
import { selectAndLoadInstance } from "../redux/actions";
|
||||
import { IAppState, IGraph, IInstanceDetails } from "../redux/types";
|
||||
import FullDiv from "./atoms/FullDiv";
|
||||
import { ErrorState } from "./ErrorState";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { IAppState, IGraph, IInstanceDetails } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { ErrorState } from "../molecules/";
|
||||
import { FullDiv } from "../styled-components";
|
||||
|
||||
interface IClosedProp {
|
||||
closed?: boolean;
|
||||
}
|
||||
const SidebarContainer = styled.div<IClosedProp>`
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
bottom: 0;
|
||||
right: ${props => (props.closed ? "-400px" : 0)};
|
||||
min-width: 400px;
|
||||
width: 25%;
|
||||
z-index: 20;
|
||||
overflow: scroll;
|
||||
overflow-x: hidden;
|
||||
transition-property: all;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
@media screen and (min-width: 1600px) {
|
||||
right: ${props => (props.closed ? "-25%" : 0)};
|
||||
}
|
||||
`;
|
||||
const StyledCard = styled(Card)`
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledButton = styled(Button)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -40px;
|
||||
z-index: 20;
|
||||
transition-property: all;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
`;
|
||||
const StyledHTMLTable = styled(HTMLTable)`
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledLinkToFdNetwork = styled.div`
|
||||
margin-top: 3em;
|
||||
text-align: center;
|
||||
`;
|
||||
|
||||
interface ISidebarProps {
|
||||
graph?: IGraph;
|
||||
|
@ -38,7 +80,6 @@ interface ISidebarProps {
|
|||
instanceLoadError: boolean;
|
||||
instanceDetails: IInstanceDetails | null;
|
||||
isLoadingInstanceDetails: boolean;
|
||||
selectAndLoadInstance: (instanceName: string) => void;
|
||||
}
|
||||
interface ISidebarState {
|
||||
isOpen: boolean;
|
||||
|
@ -63,21 +104,12 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
const closedClass = this.state.isOpen ? "" : " closed";
|
||||
const buttonIcon = this.state.isOpen ? IconNames.DOUBLE_CHEVRON_RIGHT : IconNames.DOUBLE_CHEVRON_LEFT;
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
onClick={this.handleToggle}
|
||||
large={true}
|
||||
icon={buttonIcon}
|
||||
className={"fediverse-sidebar-toggle-button" + closedClass}
|
||||
minimal={true}
|
||||
/>
|
||||
<Card className={"fediverse-sidebar" + closedClass} elevation={Elevation.TWO}>
|
||||
{this.renderSidebarContents()}
|
||||
</Card>
|
||||
</div>
|
||||
<SidebarContainer closed={!this.state.isOpen}>
|
||||
<StyledButton onClick={this.handleToggle} large={true} icon={buttonIcon} minimal={true} />
|
||||
<StyledCard elevation={Elevation.TWO}>{this.renderSidebarContents()}</StyledCard>
|
||||
</SidebarContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -149,6 +181,16 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
|
||||
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
|
||||
</Tabs>
|
||||
<StyledLinkToFdNetwork>
|
||||
<a
|
||||
href={`https://fediverse.network/${this.props.instanceName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${Classes.BUTTON} bp3-icon-${IconNames.LINK}`}
|
||||
>
|
||||
See more statistics at fediverse.network
|
||||
</a>
|
||||
</StyledLinkToFdNetwork>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -196,7 +238,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
const { version, userCount, statusCount, domainCount, lastUpdated, insularity } = this.props.instanceDetails;
|
||||
return (
|
||||
<div>
|
||||
<HTMLTable small={true} striped={true} className="fediverse-sidebar-table">
|
||||
<StyledHTMLTable small={true} striped={true}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Version</td>
|
||||
|
@ -238,7 +280,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</StyledHTMLTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -261,9 +303,13 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
const neighborRows = orderBy(neighbors, ["weight"], ["desc"]).map((neighborDetails: any, idx: number) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<AnchorButton minimal={true} onClick={this.selectInstance}>
|
||||
<Link
|
||||
to={`/instance/${neighborDetails.neighbor}`}
|
||||
className={`${Classes.BUTTON} ${Classes.MINIMAL}`}
|
||||
role="button"
|
||||
>
|
||||
{neighborDetails.neighbor}
|
||||
</AnchorButton>
|
||||
</Link>
|
||||
</td>
|
||||
<td>{neighborDetails.weight.toFixed(4)}</td>
|
||||
</tr>
|
||||
|
@ -274,7 +320,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
The mention ratio is the average of how many times the two instances mention each other per status. A mention
|
||||
ratio of 1 would mean that every single status contained a mention of a user on the other instance.
|
||||
</p>
|
||||
<HTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
|
||||
<StyledHTMLTable small={true} striped={true} interactive={false}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instance</th>
|
||||
|
@ -282,7 +328,7 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>{neighborRows}</tbody>
|
||||
</HTMLTable>
|
||||
</StyledHTMLTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -293,11 +339,11 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
return;
|
||||
}
|
||||
const peerRows = peers.map(instance => (
|
||||
<tr key={instance.name} onClick={this.selectInstance}>
|
||||
<tr key={instance.name}>
|
||||
<td>
|
||||
<AnchorButton minimal={true} onClick={this.selectInstance}>
|
||||
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
|
||||
{instance.name}
|
||||
</AnchorButton>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
|
@ -306,9 +352,9 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
<p className={Classes.TEXT_MUTED}>
|
||||
All the instances, past and present, that {this.props.instanceName} knows about.
|
||||
</p>
|
||||
<HTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
|
||||
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
|
||||
<tbody>{peerRows}</tbody>
|
||||
</HTMLTable>
|
||||
</StyledHTMLTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -399,23 +445,17 @@ class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
|||
private openInstanceLink = () => {
|
||||
window.open("https://" + this.props.instanceName, "_blank");
|
||||
};
|
||||
|
||||
private selectInstance = (e: any) => {
|
||||
this.props.selectAndLoadInstance(e.target.innerText);
|
||||
};
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: IAppState) => ({
|
||||
graph: state.data.graph,
|
||||
instanceDetails: state.currentInstance.currentInstanceDetails,
|
||||
instanceLoadError: state.currentInstance.error,
|
||||
instanceName: state.currentInstance.currentInstanceName,
|
||||
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
|
||||
});
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
|
||||
});
|
||||
export const Sidebar = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SidebarImpl);
|
||||
const mapStateToProps = (state: IAppState) => {
|
||||
const match = domainMatchSelector(state);
|
||||
return {
|
||||
graph: state.data.graph,
|
||||
instanceDetails: state.currentInstance.currentInstanceDetails,
|
||||
instanceLoadError: state.currentInstance.error,
|
||||
instanceName: match && match.params.domain,
|
||||
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
|
||||
};
|
||||
};
|
||||
const Sidebar = connect(mapStateToProps)(SidebarImpl);
|
||||
export default Sidebar;
|
4
frontend/src/components/organisms/index.ts
Normal file
4
frontend/src/components/organisms/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export { default as Graph } from "./Graph";
|
||||
export { default as Sidebar } from "./Sidebar";
|
||||
export { default as Nav } from "./Nav";
|
||||
export { default as InstanceSearch } from "./InstanceSearch";
|
|
@ -1,8 +1,8 @@
|
|||
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import { Page } from "../Page";
|
||||
import { Page } from "../atoms/";
|
||||
|
||||
export const AboutScreen: React.FC = () => (
|
||||
const AboutScreen: React.FC = () => (
|
||||
<Page>
|
||||
<H1>About</H1>
|
||||
<p className={Classes.RUNNING_TEXT}>
|
||||
|
@ -85,3 +85,4 @@ export const AboutScreen: React.FC = () => (
|
|||
</p>
|
||||
</Page>
|
||||
);
|
||||
export default AboutScreen;
|
||||
|
|
|
@ -1,60 +1,82 @@
|
|||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { NonIdealState, Spinner } from "@blueprintjs/core";
|
||||
|
||||
import { fetchGraph, fetchInstances } from "../../redux/actions";
|
||||
import { IAppState, IGraph, IInstance } from "../../redux/types";
|
||||
import { ErrorState } from "../ErrorState";
|
||||
import Graph from "../Graph";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
import { fetchGraph, fetchInstances, loadInstance } from "../../redux/actions";
|
||||
import { IAppState } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { ErrorState } from "../molecules/";
|
||||
import { Graph, Sidebar } from "../organisms/";
|
||||
|
||||
const GraphContainer = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`;
|
||||
const FullDiv = styled.div`
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
`;
|
||||
|
||||
interface IGraphScreenProps {
|
||||
graph?: IGraph;
|
||||
instances?: IInstance[];
|
||||
currentInstanceName: string | null;
|
||||
pathname: string;
|
||||
isLoadingGraph: boolean;
|
||||
isLoadingInstances: boolean;
|
||||
graphLoadError: boolean;
|
||||
loadInstance: (domain: string | null) => void;
|
||||
fetchInstances: () => void;
|
||||
fetchGraph: () => void;
|
||||
}
|
||||
/**
|
||||
* This component takes care of loading or deselecting the current instance when the URL path changes.
|
||||
*/
|
||||
class GraphScreenImpl extends React.Component<IGraphScreenProps> {
|
||||
public render() {
|
||||
let body = <div />;
|
||||
let content;
|
||||
if (this.props.isLoadingInstances || this.props.isLoadingGraph) {
|
||||
body = this.loadingState("Loading...");
|
||||
content = this.loadingState("Loading...");
|
||||
} else if (!!this.props.graphLoadError) {
|
||||
content = <ErrorState />;
|
||||
} else {
|
||||
body = this.graphState();
|
||||
content = (
|
||||
<GraphContainer>
|
||||
<Graph />
|
||||
<Sidebar />
|
||||
</GraphContainer>
|
||||
);
|
||||
}
|
||||
return <div>{body}</div>;
|
||||
return <FullDiv>{content}</FullDiv>;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.load();
|
||||
this.loadInstancesAndGraph();
|
||||
this.loadCurrentInstance();
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.load();
|
||||
public componentDidUpdate(prevProps: IGraphScreenProps) {
|
||||
this.loadCurrentInstance(prevProps.currentInstanceName);
|
||||
}
|
||||
|
||||
private load = () => {
|
||||
if (!this.props.instances && !this.props.isLoadingInstances && !this.props.graphLoadError) {
|
||||
this.props.fetchInstances();
|
||||
}
|
||||
if (!this.props.graph && !this.props.isLoadingGraph && !this.props.graphLoadError) {
|
||||
private loadInstancesAndGraph = () => {
|
||||
if (!this.props.isLoadingGraph && !this.props.graphLoadError) {
|
||||
this.props.fetchGraph();
|
||||
}
|
||||
if (!this.props.isLoadingInstances && !this.props.graphLoadError) {
|
||||
this.props.fetchInstances();
|
||||
}
|
||||
};
|
||||
|
||||
private graphState = () => {
|
||||
const content = this.props.graphLoadError ? <ErrorState /> : <Graph />;
|
||||
return (
|
||||
<div>
|
||||
<Sidebar />
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
private loadCurrentInstance = (prevInstanceName?: string | null) => {
|
||||
if (prevInstanceName !== this.props.currentInstanceName) {
|
||||
this.props.loadInstance(this.props.currentInstanceName);
|
||||
}
|
||||
};
|
||||
|
||||
private loadingState = (title?: string) => {
|
||||
|
@ -62,18 +84,25 @@ class GraphScreenImpl extends React.Component<IGraphScreenProps> {
|
|||
};
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: IAppState) => ({
|
||||
graph: state.data.graph,
|
||||
graphLoadError: state.data.error,
|
||||
instances: state.data.instances,
|
||||
isLoadingGraph: state.data.isLoadingGraph,
|
||||
isLoadingInstances: state.data.isLoadingInstances
|
||||
});
|
||||
const mapStateToProps = (state: IAppState) => {
|
||||
const match = domainMatchSelector(state);
|
||||
return {
|
||||
currentInstanceName: match && match.params.domain,
|
||||
graph: state.data.graph,
|
||||
graphLoadError: state.data.error,
|
||||
instances: state.data.instances,
|
||||
isLoadingGraph: state.data.isLoadingGraph,
|
||||
isLoadingInstances: state.data.isLoadingInstances,
|
||||
pathname: state.router.location.pathname
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
fetchGraph: () => dispatch(fetchGraph() as any),
|
||||
fetchInstances: () => dispatch(fetchInstances() as any)
|
||||
fetchInstances: () => dispatch(fetchInstances() as any),
|
||||
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any)
|
||||
});
|
||||
export const GraphScreen = connect(
|
||||
const GraphScreen = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(GraphScreenImpl);
|
||||
export default GraphScreen;
|
||||
|
|
2
frontend/src/components/screens/index.ts
Normal file
2
frontend/src/components/screens/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as AboutScreen } from "./AboutScreen";
|
||||
export { default as GraphScreen } from "./GraphScreen";
|
|
@ -1,6 +1,6 @@
|
|||
import styled from "styled-components";
|
||||
|
||||
export default styled.div`
|
||||
export const FullDiv = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
|
@ -3,3 +3,8 @@ export const DESKTOP_WIDTH_THRESHOLD = 800;
|
|||
|
||||
export const DEFAULT_NODE_COLOR = "#CED9E0";
|
||||
export const SELECTED_NODE_COLOR = "#48AFF0";
|
||||
|
||||
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
||||
export interface IInstanceDomainPath {
|
||||
domain: string;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@ body {
|
|||
margin: 0;
|
||||
padding: 50px 0 0 0;
|
||||
font-family: sans-serif;
|
||||
/*background-color: #30404D;*/
|
||||
background-color: #293742;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue,
|
||||
|
@ -15,50 +14,3 @@ body {
|
|||
min-width: 300px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.fediverse-sidebar {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
min-width: 400px;
|
||||
width: 25%;
|
||||
z-index: 20;
|
||||
overflow: scroll;
|
||||
overflow-x: hidden;
|
||||
transition-property: all;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.fediverse-sidebar.closed {
|
||||
right: -400px;
|
||||
}
|
||||
|
||||
.fediverse-sidebar-toggle-button {
|
||||
position: absolute;
|
||||
top: 50px;
|
||||
right: 400px;
|
||||
z-index: 20;
|
||||
transition-property: all;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.fediverse-sidebar-toggle-button.closed {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.fediverse-sidebar-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1600px) {
|
||||
.fediverse-sidebar.closed {
|
||||
right: -25%;
|
||||
}
|
||||
|
||||
.fediverse-sidebar-toggle-button {
|
||||
right: 25%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@ import "../node_modules/@blueprintjs/select/lib/css/blueprint-select.css";
|
|||
import "../node_modules/normalize.css/normalize.css";
|
||||
import "./index.css";
|
||||
|
||||
import cytoscape from "cytoscape";
|
||||
import popper from "cytoscape-popper";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { Provider } from "react-redux";
|
||||
|
@ -12,16 +14,26 @@ import thunk from "redux-thunk";
|
|||
|
||||
import { FocusStyleManager } from "@blueprintjs/core";
|
||||
|
||||
import { routerMiddleware } from "connected-react-router";
|
||||
import { createBrowserHistory } from "history";
|
||||
import { AppRouter } from "./AppRouter";
|
||||
import { rootReducer } from "./redux/reducers";
|
||||
import createRootReducer from "./redux/reducers";
|
||||
|
||||
// https://blueprintjs.com/docs/#core/accessibility.focus-management
|
||||
FocusStyleManager.onlyShowFocusOnTabs();
|
||||
|
||||
export const history = createBrowserHistory();
|
||||
|
||||
// Initialize redux
|
||||
// @ts-ignore
|
||||
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));
|
||||
const store = createStore(
|
||||
createRootReducer(history),
|
||||
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))
|
||||
);
|
||||
|
||||
// Initialize cytoscape plugins
|
||||
cytoscape.use(popper as any);
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
|
@ -30,8 +42,8 @@ ReactDOM.render(
|
|||
document.getElementById("root") as HTMLElement
|
||||
);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// tslint:disable-next-line:no-var-requires
|
||||
const axe = require("react-axe");
|
||||
axe(React, ReactDOM, 5000);
|
||||
}
|
||||
// if (process.env.NODE_ENV !== "production") {
|
||||
// // tslint:disable-next-line:no-var-requires
|
||||
// const axe = require("react-axe");
|
||||
// axe(React, ReactDOM, 5000);
|
||||
// }
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { Dispatch } from "redux";
|
||||
|
||||
import { push } from "connected-react-router";
|
||||
import { getFromApi } from "../util";
|
||||
import { ActionType, IGraph, IInstance, IInstanceDetails } from "./types";
|
||||
import { ActionType, IAppState, IGraph, IInstance, IInstanceDetails } from "./types";
|
||||
|
||||
// selectInstance and deselectInstance are not exported since we only call them from selectAndLoadInstance()
|
||||
const selectInstance = (instanceName: string) => {
|
||||
// requestInstanceDetails and deselectInstance are not exported since we only call them from loadInstance()
|
||||
const requestInstanceDetails = (instanceName: string) => {
|
||||
return {
|
||||
payload: instanceName,
|
||||
type: ActionType.SELECT_INSTANCE
|
||||
type: ActionType.REQUEST_INSTANCE_DETAILS
|
||||
};
|
||||
};
|
||||
const deselectInstance = () => {
|
||||
|
@ -71,13 +72,16 @@ export const fetchInstances = () => {
|
|||
};
|
||||
};
|
||||
|
||||
export const selectAndLoadInstance = (instanceName: string) => {
|
||||
return (dispatch: Dispatch) => {
|
||||
export const loadInstance = (instanceName: string | null) => {
|
||||
return (dispatch: Dispatch, getState: () => IAppState) => {
|
||||
if (!instanceName) {
|
||||
dispatch(deselectInstance());
|
||||
if (getState().router.location.pathname.startsWith("/instance/")) {
|
||||
dispatch(push("/"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
dispatch(selectInstance(instanceName));
|
||||
dispatch(requestInstanceDetails(instanceName));
|
||||
return getFromApi("instances/" + instanceName)
|
||||
.then(details => dispatch(receiveInstanceDetails(details)))
|
||||
.catch(e => dispatch(instanceLoadFailed()));
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { connectRouter } from "connected-react-router";
|
||||
import { combineReducers } from "redux";
|
||||
|
||||
import { History } from "history";
|
||||
import { ActionType, IAction, ICurrentInstanceState, IDataState } from "./types";
|
||||
|
||||
const initialDataState = {
|
||||
|
@ -46,29 +48,29 @@ const data = (state: IDataState = initialDataState, action: IAction) => {
|
|||
|
||||
const initialCurrentInstanceState: ICurrentInstanceState = {
|
||||
currentInstanceDetails: null,
|
||||
currentInstanceName: null,
|
||||
error: false,
|
||||
isLoadingInstanceDetails: false
|
||||
};
|
||||
const currentInstance = (state = initialCurrentInstanceState, action: IAction): ICurrentInstanceState => {
|
||||
switch (action.type) {
|
||||
case ActionType.SELECT_INSTANCE:
|
||||
case ActionType.REQUEST_INSTANCE_DETAILS:
|
||||
return {
|
||||
...state,
|
||||
currentInstanceName: action.payload,
|
||||
error: false,
|
||||
isLoadingInstanceDetails: true
|
||||
};
|
||||
case ActionType.RECEIVE_INSTANCE_DETAILS:
|
||||
return {
|
||||
...state,
|
||||
currentInstanceDetails: action.payload,
|
||||
error: false,
|
||||
isLoadingInstanceDetails: false
|
||||
};
|
||||
case ActionType.DESELECT_INSTANCE:
|
||||
return {
|
||||
...state,
|
||||
currentInstanceDetails: null,
|
||||
currentInstanceName: null
|
||||
error: false
|
||||
};
|
||||
case ActionType.INSTANCE_LOAD_ERROR:
|
||||
return {
|
||||
|
@ -81,7 +83,10 @@ const currentInstance = (state = initialCurrentInstanceState, action: IAction):
|
|||
}
|
||||
};
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
currentInstance,
|
||||
data
|
||||
});
|
||||
export default (history: History) =>
|
||||
combineReducers({
|
||||
router: connectRouter(history),
|
||||
// tslint:disable-next-line:object-literal-sort-keys
|
||||
currentInstance,
|
||||
data
|
||||
});
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { RouterState } from "connected-react-router";
|
||||
|
||||
export enum ActionType {
|
||||
SELECT_INSTANCE = "SELECT_INSTANCE",
|
||||
REQUEST_INSTANCE_DETAILS = "REQUEST_INSTANCE_DETAILS",
|
||||
REQUEST_INSTANCES = "REQUEST_INSTANCES",
|
||||
RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
|
||||
REQUEST_GRAPH = "REQUEST_GRAPH",
|
||||
|
@ -60,9 +62,9 @@ export interface IGraph {
|
|||
|
||||
// Redux state
|
||||
|
||||
// The current instance name is stored in the URL. See state -> router -> location
|
||||
export interface ICurrentInstanceState {
|
||||
currentInstanceDetails: IInstanceDetails | null;
|
||||
currentInstanceName: string | null;
|
||||
isLoadingInstanceDetails: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
@ -76,6 +78,7 @@ export interface IDataState {
|
|||
}
|
||||
|
||||
export interface IAppState {
|
||||
router: RouterState;
|
||||
currentInstance: ICurrentInstanceState;
|
||||
data: IDataState;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { createMatchSelector } from "connected-react-router";
|
||||
import fetch from "cross-fetch";
|
||||
import { IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
|
||||
import { IAppState } from "./redux/types";
|
||||
|
||||
let API_ROOT = "http://localhost:4000/api/";
|
||||
if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
|
||||
|
@ -12,3 +15,5 @@ export const getFromApi = (path: string): Promise<any> => {
|
|||
path = path.endsWith("/") ? path : path + "/";
|
||||
return fetch(domain + path).then(response => response.json());
|
||||
};
|
||||
|
||||
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
|
||||
|
|
|
@ -2966,6 +2966,15 @@ connect-history-api-fallback@^1.3.0:
|
|||
resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc"
|
||||
integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg==
|
||||
|
||||
connected-react-router@^6.5.2:
|
||||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/connected-react-router/-/connected-react-router-6.5.2.tgz#422af70f86cb276681e20ab4295cf27dd9b6c7e3"
|
||||
integrity sha512-qzsLPZCofSI80fwy+HgxtEgSGS4ndYUUZAWaw1dqaOGPLKX/FVwIOEb7q+hjHdnZ4v5pKZcNv5GG4urjujIoyA==
|
||||
dependencies:
|
||||
immutable "^3.8.1"
|
||||
prop-types "^15.7.2"
|
||||
seamless-immutable "^7.1.3"
|
||||
|
||||
console-browserify@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10"
|
||||
|
@ -5245,6 +5254,11 @@ immer@1.10.0:
|
|||
resolved "https://registry.yarnpkg.com/immer/-/immer-1.10.0.tgz#bad67605ba9c810275d91e1c2a47d4582e98286d"
|
||||
integrity sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==
|
||||
|
||||
immutable@^3.8.1:
|
||||
version "3.8.2"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-3.8.2.tgz#c2439951455bb39913daf281376f1530e104adf3"
|
||||
integrity sha1-wkOZUUVbs5kT2vKBN28VMOEErfM=
|
||||
|
||||
import-cwd@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
|
||||
|
@ -9556,6 +9570,11 @@ schema-utils@^1.0.0:
|
|||
ajv-errors "^1.0.0"
|
||||
ajv-keywords "^3.1.0"
|
||||
|
||||
seamless-immutable@^7.1.3:
|
||||
version "7.1.4"
|
||||
resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8"
|
||||
integrity sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A==
|
||||
|
||||
select-hose@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
|
||||
|
|
Loading…
Reference in a new issue