Update to 2.1.0
This commit is contained in:
parent
ff33580c99
commit
62a77988d6
|
@ -1,6 +1,10 @@
|
|||
include:
|
||||
template: Dependency-Scanning.gitlab-ci.yml
|
||||
|
||||
dependency_scanning:
|
||||
except:
|
||||
- develop
|
||||
|
||||
test-frontend:
|
||||
image: node:lts-alpine
|
||||
stage: test
|
||||
|
@ -14,9 +18,6 @@ test-frontend:
|
|||
paths:
|
||||
- frontend/node_modules/
|
||||
- frontend/.yarn
|
||||
except:
|
||||
- master
|
||||
- develop
|
||||
only:
|
||||
changes:
|
||||
- frontend/*
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"elixirLS.projectDir": "backend/"
|
||||
}
|
42
CHANGELOG.md
42
CHANGELOG.md
|
@ -1,34 +1,70 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Deprecated
|
||||
|
||||
### Removed
|
||||
|
||||
### Fixed
|
||||
|
||||
### Security
|
||||
|
||||
## [2.0.0] - 2019-07-20
|
||||
## [2.1.0 - 2019-07-24]
|
||||
|
||||
### 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.
|
||||
- The main graph is no longer displayed on mobile. Instead, a smaller neighborhood graph is shown.
|
||||
|
||||
### Changed
|
||||
|
||||
- You no longer have to zoom completely in to see labels.
|
||||
- Label size is now dependent on the instance size.
|
||||
- The instance lookup field is now front-and-center. Is also uses the backend for faster lookups. This is to improve
|
||||
performance, and it lays the groundwork for full-text search over instance names and descriptions.
|
||||
- The reset-graph-view button now explains what it's for when you hover over it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Previously, direct links to /about would return a 404 on Netlify's infrastructure. No longer.
|
||||
|
||||
## [2.0.0] - 2019-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- The backend has been completely rewritten in Elixir for improved stability and performance.
|
||||
- An "insularity score" was added to show the percentage of mentions to users on the same instance.
|
||||
- The crawler now respects robots.txt.
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the frontend graph from Sigma.js to Cytoscape.js.
|
||||
- To improve performance, instances with no neighbors are no longer shown on the graph.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- The /api/v1 endpoint no longer exists; now there's a new /api.
|
||||
### Removed
|
||||
### Fixed
|
||||
|
||||
### Security
|
||||
|
||||
- Spam domains can be blacklisted in the backend crawler's config.
|
||||
- Add basic automated security scanning (using [Sobelow](https://github.com/andmarti1424/sc-im.git) and Gitlab's dependency scanning).
|
||||
|
||||
## [1.0.0] - 2018-09-01
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release. The date above is inaccurate; this first version was released sometime in the fall of 2018.
|
||||
- This release had a Django backend and a [Sigma.js](http://sigmajs.org/) graph.
|
||||
|
|
|
@ -70,7 +70,7 @@ You don't have to follow these instructions, but it's one way to set up a contin
|
|||
* `dokku postgres:link fediversedb phoenix`
|
||||
* `dokku postgres:link fediversedb gephi`
|
||||
5. Update the backend configuration. In particular, change the `user_agent` in [config.exs](/backend/config/config.exs) to something descriptive.
|
||||
6. Push the apps, e.g. `git push dokku@<DOMAIN>:phoenix` (from your local machine or CD pipeline)
|
||||
6. Push the apps, e.g. `git push dokku@<DOMAIN>:phoenix` (note that the first push cannot be from the CD pipeline).
|
||||
7. Set up SSL for the Phoenix app
|
||||
* `dokku letsencrypt phoenix`
|
||||
* `dokku letsencrypt:cron-job --add`
|
||||
|
|
|
@ -59,7 +59,7 @@ config :backend, Backend.Repo,
|
|||
config :backend, :crawler,
|
||||
status_age_limit_days: 28,
|
||||
status_count_limit: 100,
|
||||
personal_instance_threshold: 1,
|
||||
personal_instance_threshold: 5,
|
||||
crawl_interval_mins: 1,
|
||||
crawl_workers: 10,
|
||||
blacklist: [
|
||||
|
@ -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"]}}
|
||||
]
|
||||
|
|
|
@ -21,9 +21,11 @@ defmodule Backend.Api do
|
|||
* have a user count (required to give the instance a size on the graph)
|
||||
* the user count is > the threshold
|
||||
* have x and y coordinates
|
||||
|
||||
If `domain` is passed, then this function only returns nodes that are neighbors of that instance.
|
||||
"""
|
||||
@spec list_nodes() :: [Instance.t()]
|
||||
def list_nodes() do
|
||||
def list_nodes(domain \\ nil) do
|
||||
user_threshold = get_config(:personal_instance_threshold)
|
||||
|
||||
Instance
|
||||
|
@ -32,17 +34,38 @@ defmodule Backend.Api do
|
|||
not is_nil(i.x) and not is_nil(i.y) and not is_nil(i.user_count) and
|
||||
i.user_count >= ^user_threshold
|
||||
)
|
||||
|> maybe_filter_nodes_to_neighborhood(domain)
|
||||
|> select([c], [:domain, :user_count, :x, :y])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
# if we're getting the sub-graph around a given domain, only return neighbors.
|
||||
defp maybe_filter_nodes_to_neighborhood(query, domain) do
|
||||
case domain do
|
||||
nil ->
|
||||
query
|
||||
|
||||
_ ->
|
||||
query
|
||||
|> join(:inner, [i], outgoing_edges in Edge, on: outgoing_edges.source_domain == i.domain)
|
||||
|> join(:inner, [i], incoming_edges in Edge, on: incoming_edges.target_domain == i.domain)
|
||||
|> where(
|
||||
[i, outgoing_edges, incoming_edges],
|
||||
outgoing_edges.target_domain == ^domain or incoming_edges.source_domain == ^domain or
|
||||
i.domain == ^domain
|
||||
)
|
||||
|> distinct(true)
|
||||
end
|
||||
end
|
||||
|
||||
@spec list_edges() :: [Edge.t()]
|
||||
def list_edges() do
|
||||
def list_edges(domain \\ nil) do
|
||||
user_threshold = get_config(:personal_instance_threshold)
|
||||
|
||||
Edge
|
||||
|> join(:inner, [e], i1 in Instance, on: e.source_domain == i1.domain)
|
||||
|> join(:inner, [e], i2 in Instance, on: e.target_domain == i2.domain)
|
||||
|> maybe_filter_edges_to_neighborhood(domain)
|
||||
|> select([e], [:id, :source_domain, :target_domain, :weight])
|
||||
|> where(
|
||||
[e, i1, i2],
|
||||
|
@ -52,4 +75,38 @@ defmodule Backend.Api do
|
|||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp maybe_filter_edges_to_neighborhood(query, domain) do
|
||||
case domain do
|
||||
nil ->
|
||||
query
|
||||
|
||||
_ ->
|
||||
# we want all edges in the neighborhood -- not just edges connected to `domain`
|
||||
query
|
||||
|> join(:inner, [e], neighbor_edges in Edge,
|
||||
on:
|
||||
neighbor_edges.source_domain == e.target_domain or
|
||||
neighbor_edges.target_domain == e.source_domain
|
||||
)
|
||||
|> where(
|
||||
[e, i1, i2, neighbor_edges],
|
||||
e.source_domain == ^domain or e.target_domain == ^domain or
|
||||
neighbor_edges.source_domain == ^domain or neighbor_edges.target_domain == ^domain
|
||||
)
|
||||
|> distinct(true)
|
||||
end
|
||||
end
|
||||
|
||||
def search_instances(query, cursor_after \\ nil) do
|
||||
ilike_query = "%#{query}%"
|
||||
|
||||
%{entries: instances, metadata: metadata} =
|
||||
Instance
|
||||
|> where([i], ilike(i.domain, ^ilike_query))
|
||||
|> order_by(asc: :id)
|
||||
|> Repo.paginate(after: cursor_after, cursor_fields: [:id], limit: 50)
|
||||
|
||||
%{instances: instances, next: metadata.after}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,6 +3,8 @@ defmodule Backend.Repo do
|
|||
otp_app: :backend,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
|
||||
use Paginator
|
||||
|
||||
def init(_type, config) do
|
||||
{:ok, Keyword.put(config, :url, System.get_env("DATABASE_URL"))}
|
||||
end
|
||||
|
|
|
@ -10,4 +10,10 @@ defmodule BackendWeb.GraphController do
|
|||
edges = Api.list_edges()
|
||||
render(conn, "index.json", nodes: nodes, edges: edges)
|
||||
end
|
||||
|
||||
def show(conn, %{"id" => domain}) do
|
||||
nodes = Api.list_nodes(domain)
|
||||
edges = Api.list_edges(domain)
|
||||
render(conn, "index.json", nodes: nodes, edges: edges)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,7 +13,7 @@ defmodule BackendWeb.InstanceController do
|
|||
|
||||
def show(conn, %{"id" => domain}) do
|
||||
instance = Api.get_instance!(domain)
|
||||
last_crawl = get_last_successful_crawl(domain)
|
||||
last_crawl = get_last_crawl(domain)
|
||||
render(conn, "show.json", instance: instance, crawl: last_crawl)
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
defmodule BackendWeb.SearchController do
|
||||
use BackendWeb, :controller
|
||||
alias Backend.Api
|
||||
|
||||
action_fallback(BackendWeb.FallbackController)
|
||||
|
||||
def index(conn, params) do
|
||||
query = Map.get(params, "query")
|
||||
cursor_after = Map.get(params, "after", nil)
|
||||
%{instances: instances, next: next} = Api.search_instances(query, cursor_after)
|
||||
render(conn, "index.json", instances: instances, next: next)
|
||||
end
|
||||
end
|
|
@ -2,13 +2,14 @@ defmodule BackendWeb.Router do
|
|||
use BackendWeb, :router
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
plug(:accepts, ["json"])
|
||||
end
|
||||
|
||||
scope "/api", BackendWeb do
|
||||
pipe_through :api
|
||||
pipe_through(:api)
|
||||
|
||||
resources "/instances", InstanceController, only: [:index, :show]
|
||||
resources "/graph", GraphController, only: [:index]
|
||||
resources("/instances", InstanceController, only: [:index, :show])
|
||||
resources("/graph", GraphController, only: [:index, :show])
|
||||
resources("/search", SearchController, only: [:index])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
defmodule BackendWeb.SearchView do
|
||||
use BackendWeb, :view
|
||||
alias BackendWeb.SearchView
|
||||
import Backend.Util
|
||||
|
||||
def render("index.json", %{instances: instances, next: next}) do
|
||||
%{
|
||||
results: render_many(instances, SearchView, "instance.json", as: :instance),
|
||||
next: next
|
||||
}
|
||||
end
|
||||
|
||||
def render("instance.json", %{instance: instance}) do
|
||||
threshold = get_config(:personal_instance_threshold)
|
||||
|
||||
description =
|
||||
if instance.user_count != nil and instance.user_count < threshold do
|
||||
nil
|
||||
else
|
||||
instance.description
|
||||
end
|
||||
|
||||
%{
|
||||
name: instance.domain,
|
||||
description: description,
|
||||
userCount: instance.user_count
|
||||
}
|
||||
end
|
||||
end
|
|
@ -4,7 +4,7 @@ defmodule Backend.MixProject do
|
|||
def project do
|
||||
[
|
||||
app: :backend,
|
||||
version: "2.0.0",
|
||||
version: "2.1.0",
|
||||
elixir: "~> 1.5",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
compilers: [:phoenix, :gettext] ++ Mix.compilers(),
|
||||
|
@ -47,7 +47,8 @@ defmodule Backend.MixProject do
|
|||
{:quantum, "~> 2.3"},
|
||||
{:corsica, "~> 1.1.2"},
|
||||
{:sobelow, "~> 0.8", only: :dev},
|
||||
{:gollum, "~> 0.3.2"}
|
||||
{:gollum, "~> 0.3.2"},
|
||||
{:paginator, "~> 0.6.0"}
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
|
||||
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
|
||||
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
|
||||
"paginator": {:hex, :paginator, "0.6.0", "bc2c01abdd98281ff39b6a7439cf540091122a7927bdaabc167c61d4508f9cbb", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
|
||||
"phoenix": {:hex, :phoenix, "1.4.9", "746d098e10741c334d88143d3c94cab1756435f94387a63441792e66ec0ee974", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "NODE_ENV=development react-scripts start",
|
||||
|
@ -32,15 +32,17 @@
|
|||
"@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",
|
||||
"lodash": "^4.17.14",
|
||||
"inflection": "^1.12.0",
|
||||
"lodash": "^4.17.15",
|
||||
"moment": "^2.22.2",
|
||||
"normalize.css": "^8.0.0",
|
||||
"numeral": "^2.0.6",
|
||||
"react": "^16.4.2",
|
||||
"react-dom": "^16.4.2",
|
||||
"react": "^16.8.0",
|
||||
"react-dom": "^16.8.0",
|
||||
"react-redux": "^7.1.0",
|
||||
"react-router-dom": "^5.0.1",
|
||||
"react-scripts": "^3.0.1",
|
||||
|
@ -55,20 +57,22 @@
|
|||
"devDependencies": {
|
||||
"@blueprintjs/tslint-config": "^1.8.1",
|
||||
"@types/classnames": "^2.2.9",
|
||||
"@types/cytoscape": "^3.4.3",
|
||||
"@types/cytoscape": "^3.8.0",
|
||||
"@types/inflection": "^1.5.28",
|
||||
"@types/jest": "^24.0.15",
|
||||
"@types/lodash": "^4.14.136",
|
||||
"@types/node": "^12.6.2",
|
||||
"@types/node": "^12.6.8",
|
||||
"@types/numeral": "^0.0.25",
|
||||
"@types/react": "^16.8.23",
|
||||
"@types/react-dom": "^16.8.4",
|
||||
"@types/react-redux": "^7.1.1",
|
||||
"@types/react-router-dom": "^4.3.4",
|
||||
"@types/react-virtualized": "^9.21.2",
|
||||
"@types/react-virtualized": "^9.21.3",
|
||||
"@types/sanitize-html": "^1.20.1",
|
||||
"@types/styled-components": "4.1.18",
|
||||
"husky": "^3.0.0",
|
||||
"husky": "^3.0.1",
|
||||
"lint-staged": "^9.2.0",
|
||||
"react-axe": "^3.2.0",
|
||||
"tslint": "^5.18.0",
|
||||
"tslint-config-security": "^1.16.0",
|
||||
"tslint-eslint-rules": "^5.4.0",
|
||||
|
|
|
@ -5,6 +5,24 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
|
||||
<!-- For search engines -->
|
||||
<meta name="description" content="A tool to visualize decentralized social networks." />
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:site_name" content="fediverse.space" />
|
||||
<meta property="og:description" content="" />
|
||||
<meta property="og:image" content="%PUBLIC_URL%/preview.png" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="914" />
|
||||
<meta property="og:image:height" content="679" />
|
||||
<meta property="og:image:alt" content="A screenshot of fediverse.space. Shows a graph of fediverse instances." />
|
||||
<!-- Twitter cards -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="fediverse.space">
|
||||
<meta name="twitter:description" content="A tool to visualize decentralized social networks.">
|
||||
<meta name="twitter:image" content="%PUBLIC_URL%/preview.png">
|
||||
<meta name="twitter:image:alt" content="A screenshot of fediverse.space. Shows a graph of fediverse instances." />
|
||||
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is added to the
|
||||
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
|
||||
|
@ -30,4 +48,4 @@
|
|||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
Binary file not shown.
After Width: | Height: | Size: 402 KiB |
|
@ -1,71 +1,21 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Button, Classes, Dialog } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
import { Classes } from "@blueprintjs/core";
|
||||
|
||||
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 } from "react-router-dom";
|
||||
import { Nav } from "./components/organisms/";
|
||||
import { AboutScreen, GraphScreen } from "./components/screens/";
|
||||
import { history } from "./index";
|
||||
|
||||
interface IAppLocalState {
|
||||
mobileDialogOpen: boolean;
|
||||
}
|
||||
export class AppRouter extends React.Component<{}, IAppLocalState> {
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
this.state = { mobileDialogOpen: false };
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className={`${Classes.DARK} App`}>
|
||||
<Nav />
|
||||
<Route exact={true} path="/" component={GraphScreen} />
|
||||
<Route path="/about" component={AboutScreen} />
|
||||
{this.renderMobileDialog()}
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
if (window.innerWidth < DESKTOP_WIDTH_THRESHOLD) {
|
||||
this.handleMobileDialogOpen();
|
||||
}
|
||||
}
|
||||
|
||||
private renderMobileDialog = () => {
|
||||
return (
|
||||
<Dialog
|
||||
icon={IconNames.DESKTOP}
|
||||
title="Desktop-optimized site"
|
||||
onClose={this.handleMobileDialogClose}
|
||||
isOpen={this.state.mobileDialogOpen}
|
||||
className={Classes.DARK + " fediverse-about-dialog"}
|
||||
>
|
||||
<div className={Classes.DIALOG_BODY}>
|
||||
<p className={Classes.RUNNING_TEXT}>
|
||||
fediverse.space is optimized for desktop computers. Feel free to check it out on your phone (ideally in
|
||||
landscape mode) but for best results, open it on a computer.
|
||||
</p>
|
||||
</div>
|
||||
<div className={Classes.DIALOG_FOOTER}>
|
||||
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||
<Button icon={IconNames.THUMBS_UP} text="OK!" onClick={this.handleMobileDialogClose} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
private handleMobileDialogOpen = () => {
|
||||
this.setState({ mobileDialogOpen: true });
|
||||
};
|
||||
|
||||
private handleMobileDialogClose = () => {
|
||||
this.setState({ mobileDialogOpen: false });
|
||||
};
|
||||
}
|
||||
const AppRouter: React.FC = () => (
|
||||
<ConnectedRouter history={history}>
|
||||
<div className={`${Classes.DARK} App`}>
|
||||
<Nav />
|
||||
<Route path="/about" component={AboutScreen} />
|
||||
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
|
||||
<GraphScreen />
|
||||
</div>
|
||||
</ConnectedRouter>
|
||||
);
|
||||
export default AppRouter;
|
||||
|
|
|
@ -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,12 +0,0 @@
|
|||
import { Button } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import FloatingCard from "./FloatingCard";
|
||||
|
||||
interface IFloatingResetButtonProps {
|
||||
onClick?: () => any;
|
||||
}
|
||||
export const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
|
||||
<FloatingCard>
|
||||
<Button icon="compass" onClick={onClick} />
|
||||
</FloatingCard>
|
||||
);
|
|
@ -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,85 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
|
||||
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";
|
||||
|
||||
interface IInstanceSearchProps extends RouteComponentProps {
|
||||
currentInstanceName: string | null;
|
||||
instances?: IInstance[];
|
||||
selectAndLoadInstance: (instanceName: string) => void;
|
||||
}
|
||||
|
||||
const InstanceSelect = Select.ofType<IInstance>();
|
||||
|
||||
class InstanceSearchImpl extends React.Component<IInstanceSearchProps> {
|
||||
public render() {
|
||||
return (
|
||||
<InstanceSelect
|
||||
items={this.props.instances || []}
|
||||
itemRenderer={this.itemRenderer}
|
||||
onItemSelect={this.onItemSelect}
|
||||
itemPredicate={this.itemPredicate}
|
||||
disabled={!this.props.instances || this.props.location.pathname !== "/"}
|
||||
initialContent={this.renderInitialContent()}
|
||||
noResults={this.renderNoResults()}
|
||||
popoverProps={{ popoverClassName: "fediverse-instance-search-popover" }}
|
||||
>
|
||||
<Button
|
||||
icon={IconNames.SELECTION}
|
||||
rightIcon={IconNames.CARET_DOWN}
|
||||
text={this.props.currentInstanceName || "Select an instance"}
|
||||
disabled={!this.props.instances}
|
||||
/>
|
||||
</InstanceSelect>
|
||||
);
|
||||
}
|
||||
|
||||
private renderInitialContent = () => {
|
||||
return <MenuItem disabled={true} text={"Start typing"} />;
|
||||
};
|
||||
|
||||
private renderNoResults = () => {
|
||||
return <MenuItem disabled={true} text={"Keep typing"} />;
|
||||
};
|
||||
|
||||
private itemRenderer = (item: IInstance, itemProps: IItemRendererProps) => {
|
||||
if (!itemProps.modifiers.matchesPredicate) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<MenuItem text={item.name} key={item.name} active={itemProps.modifiers.active} onClick={itemProps.handleClick} />
|
||||
);
|
||||
};
|
||||
|
||||
private itemPredicate: ItemPredicate<IInstance> = (query, item, index) => {
|
||||
if (!item.name || query.length < 4) {
|
||||
return false;
|
||||
}
|
||||
return item.name.toLowerCase().indexOf(query.toLowerCase()) >= 0;
|
||||
};
|
||||
|
||||
private onItemSelect = (item: IInstance, event?: React.SyntheticEvent<HTMLElement>) => {
|
||||
this.props.selectAndLoadInstance(item.name);
|
||||
};
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: IAppState) => ({
|
||||
currentInstanceName: state.currentInstance.currentInstanceName,
|
||||
instances: state.data.instances
|
||||
});
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
selectAndLoadInstance: (instanceName: string) => dispatch(selectAndLoadInstance(instanceName) as any)
|
||||
});
|
||||
export const InstanceSearch = withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(InstanceSearchImpl)
|
||||
);
|
|
@ -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;
|
||||
`;
|
|
@ -1,405 +0,0 @@
|
|||
import { orderBy } from "lodash";
|
||||
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 {
|
||||
AnchorButton,
|
||||
Button,
|
||||
Callout,
|
||||
Card,
|
||||
Classes,
|
||||
Code,
|
||||
Divider,
|
||||
Elevation,
|
||||
H2,
|
||||
H4,
|
||||
HTMLTable,
|
||||
Icon,
|
||||
NonIdealState,
|
||||
Position,
|
||||
Tab,
|
||||
Tabs,
|
||||
Tooltip
|
||||
} 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";
|
||||
|
||||
interface ISidebarProps {
|
||||
graph?: IGraph;
|
||||
instanceName: string | null;
|
||||
instanceLoadError: boolean;
|
||||
instanceDetails: IInstanceDetails | null;
|
||||
isLoadingInstanceDetails: boolean;
|
||||
selectAndLoadInstance: (instanceName: string) => void;
|
||||
}
|
||||
interface ISidebarState {
|
||||
isOpen: boolean;
|
||||
neighbors?: string[];
|
||||
isProcessingNeighbors: boolean;
|
||||
}
|
||||
class SidebarImpl extends React.Component<ISidebarProps, ISidebarState> {
|
||||
constructor(props: ISidebarProps) {
|
||||
super(props);
|
||||
const isOpen = window.innerWidth >= 900 ? true : false;
|
||||
this.state = { isOpen, isProcessingNeighbors: false };
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.processEdgesToFindNeighbors();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: ISidebarProps, prevState: ISidebarState) {
|
||||
if (prevProps.instanceName !== this.props.instanceName) {
|
||||
this.processEdgesToFindNeighbors();
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
private handleToggle = () => {
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
};
|
||||
|
||||
private processEdgesToFindNeighbors = () => {
|
||||
const { graph, instanceName } = this.props;
|
||||
if (!graph || !instanceName) {
|
||||
return;
|
||||
}
|
||||
this.setState({ isProcessingNeighbors: true });
|
||||
const edges = graph.edges.filter(e => [e.data.source, e.data.target].indexOf(instanceName!) > -1);
|
||||
const neighbors: any[] = [];
|
||||
edges.forEach(e => {
|
||||
if (e.data.source === instanceName) {
|
||||
neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
|
||||
} else {
|
||||
neighbors.push({ neighbor: e.data.source, weight: e.data.weight });
|
||||
}
|
||||
});
|
||||
this.setState({ neighbors, isProcessingNeighbors: false });
|
||||
};
|
||||
|
||||
private renderSidebarContents = () => {
|
||||
let content;
|
||||
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors) {
|
||||
content = this.renderLoadingState();
|
||||
} else if (!this.props.instanceDetails) {
|
||||
return this.renderEmptyState();
|
||||
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
|
||||
content = this.renderPersonalInstanceErrorState();
|
||||
} else if (this.props.instanceDetails.status !== "success") {
|
||||
content = this.renderMissingDataState();
|
||||
} else if (this.props.instanceLoadError) {
|
||||
return (content = <ErrorState />);
|
||||
} else {
|
||||
content = this.renderTabs();
|
||||
}
|
||||
return (
|
||||
<FullDiv>
|
||||
{this.renderHeading()}
|
||||
{content}
|
||||
</FullDiv>
|
||||
);
|
||||
};
|
||||
|
||||
private renderTabs = () => {
|
||||
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
|
||||
|
||||
const insularCallout = hasNeighbors ? (
|
||||
undefined
|
||||
) : (
|
||||
<Callout icon={IconNames.INFO_SIGN} title="Insular instance">
|
||||
<p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
|
||||
</Callout>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{insularCallout}
|
||||
<Tabs>
|
||||
{this.props.instanceDetails!.description && (
|
||||
<Tab id="description" title="Description" panel={this.renderDescription()} />
|
||||
)}
|
||||
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
|
||||
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
|
||||
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private shouldRenderStats = () => {
|
||||
const details = this.props.instanceDetails;
|
||||
return details && (details.version || details.userCount || details.statusCount || details.domainCount);
|
||||
};
|
||||
|
||||
private renderHeading = () => {
|
||||
let content: JSX.Element;
|
||||
if (!this.props.instanceName) {
|
||||
return;
|
||||
} else {
|
||||
content = (
|
||||
<span>
|
||||
{this.props.instanceName + " "}
|
||||
<Tooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
|
||||
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<H2>{content}</H2>
|
||||
<Divider />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private renderDescription = () => {
|
||||
const description = this.props.instanceDetails!.description;
|
||||
if (!description) {
|
||||
return;
|
||||
}
|
||||
return <p className={Classes.RUNNING_TEXT} dangerouslySetInnerHTML={{ __html: sanitize(description) }} />;
|
||||
};
|
||||
|
||||
private renderVersionAndCounts = () => {
|
||||
if (!this.props.instanceDetails) {
|
||||
throw new Error("Did not receive instance details as expected!");
|
||||
}
|
||||
const { version, userCount, statusCount, domainCount, lastUpdated, insularity } = this.props.instanceDetails;
|
||||
return (
|
||||
<div>
|
||||
<HTMLTable small={true} striped={true} className="fediverse-sidebar-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Version</td>
|
||||
<td>{<Code>{version}</Code> || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Users</td>
|
||||
<td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Statuses</td>
|
||||
<td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Insularity{" "}
|
||||
<Tooltip
|
||||
content={
|
||||
<span>
|
||||
The percentage of mentions that are directed
|
||||
<br />
|
||||
toward users on the same instance.
|
||||
</span>
|
||||
}
|
||||
position={Position.TOP}
|
||||
className={Classes.DARK}
|
||||
>
|
||||
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Known peers</td>
|
||||
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last updated</td>
|
||||
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private renderNeighbors = () => {
|
||||
if (!this.props.graph || !this.props.instanceName) {
|
||||
return;
|
||||
}
|
||||
const edges = this.props.graph.edges.filter(
|
||||
e => [e.data.source, e.data.target].indexOf(this.props.instanceName!) > -1
|
||||
);
|
||||
const neighbors: any[] = [];
|
||||
edges.forEach(e => {
|
||||
if (e.data.source === this.props.instanceName) {
|
||||
neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
|
||||
} else {
|
||||
neighbors.push({ neighbor: e.data.source, weight: e.data.weight });
|
||||
}
|
||||
});
|
||||
const neighborRows = orderBy(neighbors, ["weight"], ["desc"]).map((neighborDetails: any, idx: number) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<AnchorButton minimal={true} onClick={this.selectInstance}>
|
||||
{neighborDetails.neighbor}
|
||||
</AnchorButton>
|
||||
</td>
|
||||
<td>{neighborDetails.weight.toFixed(4)}</td>
|
||||
</tr>
|
||||
));
|
||||
return (
|
||||
<div>
|
||||
<p className={Classes.TEXT_MUTED}>
|
||||
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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instance</th>
|
||||
<th>Mention ratio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{neighborRows}</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private renderPeers = () => {
|
||||
const peers = this.props.instanceDetails!.peers;
|
||||
if (!peers || peers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const peerRows = peers.map(instance => (
|
||||
<tr key={instance.name} onClick={this.selectInstance}>
|
||||
<td>
|
||||
<AnchorButton minimal={true} onClick={this.selectInstance}>
|
||||
{instance.name}
|
||||
</AnchorButton>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
return (
|
||||
<div>
|
||||
<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">
|
||||
<tbody>{peerRows}</tbody>
|
||||
</HTMLTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private renderEmptyState = () => {
|
||||
return (
|
||||
<NonIdealState
|
||||
icon={IconNames.CIRCLE}
|
||||
title="No instance selected"
|
||||
description="Select an instance from the graph or the top-right dropdown to see its details."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderLoadingState = () => {
|
||||
return (
|
||||
<div>
|
||||
<H4>
|
||||
<span className={Classes.SKELETON}>Description</span>
|
||||
</H4>
|
||||
<p className={Classes.SKELETON}>
|
||||
Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt. Cupiditate sit
|
||||
voluptates quia nulla et saepe id suscipit. Voluptas sed rerum placeat consectetur pariatur necessitatibus
|
||||
tempora. Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt.
|
||||
Cupiditate sit voluptates quia nulla et saepe id suscipit. Voluptas sed rerum placeat consectetur pariatur
|
||||
necessitatibus tempora.
|
||||
</p>
|
||||
<H4>
|
||||
<span className={Classes.SKELETON}>Version</span>
|
||||
</H4>
|
||||
<p className={Classes.SKELETON}>Eaque rerum sequi unde omnis voluptatibus non quia fugit.</p>
|
||||
<H4>
|
||||
<span className={Classes.SKELETON}>Stats</span>
|
||||
</H4>
|
||||
<p className={Classes.SKELETON}>
|
||||
Eaque rerum sequi unde omnis voluptatibus non quia fugit. Dignissimos asperiores aut incidunt. Cupiditate sit
|
||||
voluptates quia nulla et saepe id suscipit. Eaque rerum sequi unde omnis voluptatibus non quia fugit.
|
||||
Dignissimos asperiores aut incidunt. Cupiditate sit voluptates quia nulla et saepe id suscipit.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private renderPersonalInstanceErrorState = () => {
|
||||
return (
|
||||
<NonIdealState
|
||||
icon={IconNames.BLOCKED_PERSON}
|
||||
title="No data"
|
||||
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
|
||||
action={
|
||||
<AnchorButton icon={IconNames.CONFIRM} href="https://cursed.technology/@fediversespace" target="_blank">
|
||||
Message @fediversespace to opt in
|
||||
</AnchorButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderMissingDataState = () => {
|
||||
return (
|
||||
<FullDiv>
|
||||
<NonIdealState
|
||||
icon={IconNames.ERROR}
|
||||
title="No data"
|
||||
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
|
||||
/>
|
||||
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
|
||||
{this.props.instanceDetails && this.props.instanceDetails.status}
|
||||
</span>
|
||||
</FullDiv>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
|
@ -1,6 +0,0 @@
|
|||
import styled from "styled-components";
|
||||
|
||||
export default styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`;
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Page } from "./Page";
|
||||
export { default as FloatingCard } from "./FloatingCard";
|
|
@ -1,30 +1,33 @@
|
|||
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%;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
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,
|
||||
|
@ -87,14 +90,14 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
.selector("node[label]")
|
||||
.style({
|
||||
color: DEFAULT_NODE_COLOR,
|
||||
"font-size": 50,
|
||||
"font-size": "mapData(size, 1, 6, 10, 100)",
|
||||
"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 +105,10 @@ 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) {
|
||||
if (this.props.navigateToInstancePath) {
|
||||
this.props.navigateToInstancePath(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
const neighborhood = this.cy!.$id(instanceId).closedNeighborhood();
|
||||
|
@ -119,7 +124,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 +132,19 @@ 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()) {
|
||||
if (this.props.navigateToRoot) {
|
||||
// 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 +154,47 @@ class Cytoscape extends React.Component<ICytoscapeProps> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
return <EntireWindowDiv />;
|
||||
return <CytoscapeContainer />;
|
||||
}
|
||||
|
||||
public resetGraphPosition() {
|
||||
if (!this.cy) {
|
||||
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||
}
|
||||
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) {
|
||||
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||
}
|
||||
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;
|
|
@ -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;
|
|
@ -0,0 +1,13 @@
|
|||
import { Button } from "@blueprintjs/core";
|
||||
import * as React from "react";
|
||||
import { FloatingCard } from "../atoms/";
|
||||
|
||||
interface IFloatingResetButtonProps {
|
||||
onClick?: () => any;
|
||||
}
|
||||
const FloatingResetButton: React.FC<IFloatingResetButtonProps> = ({ onClick }) => (
|
||||
<FloatingCard>
|
||||
<Button icon="compass" title="Reset graph view" onClick={onClick} />
|
||||
</FloatingCard>
|
||||
);
|
||||
export default FloatingResetButton;
|
|
@ -0,0 +1,48 @@
|
|||
import { Card, Classes, Elevation, H4 } from "@blueprintjs/core";
|
||||
import inflection from "inflection";
|
||||
import * as numeral from "numeral";
|
||||
import React from "react";
|
||||
import sanitize from "sanitize-html";
|
||||
import styled from "styled-components";
|
||||
import { ISearchResultInstance } from "../../redux/types";
|
||||
|
||||
const StyledCard = styled(Card)`
|
||||
width: 80%;
|
||||
margin: 1em auto;
|
||||
background-color: #394b59 !important;
|
||||
text-align: left;
|
||||
`;
|
||||
const StyledH4 = styled(H4)`
|
||||
margin-bottom: 5px;
|
||||
`;
|
||||
const StyledUserCount = styled.div`
|
||||
margin: 0;
|
||||
`;
|
||||
const StyledDescription = styled.div`
|
||||
margin-top: 10px;
|
||||
`;
|
||||
interface ISearchResultProps {
|
||||
result: ISearchResultInstance;
|
||||
onClick: () => void;
|
||||
}
|
||||
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick }) => {
|
||||
let shortenedDescription;
|
||||
if (result.description) {
|
||||
shortenedDescription = result.description && sanitize(result.description);
|
||||
if (shortenedDescription.length > 100) {
|
||||
shortenedDescription = shortenedDescription.substring(0, 100) + "...";
|
||||
}
|
||||
}
|
||||
return (
|
||||
<StyledCard elevation={Elevation.ONE} interactive={true} key={result.name} onClick={onClick}>
|
||||
<StyledH4>{result.name}</StyledH4>
|
||||
{result.userCount && (
|
||||
<StyledUserCount className={Classes.TEXT_MUTED}>
|
||||
{numeral.default(result.userCount).format("0,0")} {inflection.inflect("people", result.userCount, "person")}
|
||||
</StyledUserCount>
|
||||
)}
|
||||
{shortenedDescription && <StyledDescription dangerouslySetInnerHTML={{ __html: shortenedDescription }} />}
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
export default SearchResult;
|
|
@ -0,0 +1,4 @@
|
|||
export { default as Cytoscape } from "./Cytoscape";
|
||||
export { default as ErrorState } from "./ErrorState";
|
||||
export { default as FloatingResetButton } from "./FloatingResetButton";
|
||||
export { default as SearchResult } from "./SearchResult";
|
|
@ -0,0 +1,98 @@
|
|||
import * as React from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
||||
import { NonIdealState, Spinner } from "@blueprintjs/core";
|
||||
import { push } from "connected-react-router";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { fetchGraph } from "../../redux/actions";
|
||||
import { IAppState, IGraph } from "../../redux/types";
|
||||
import { domainMatchSelector } from "../../util";
|
||||
import { Cytoscape, ErrorState, FloatingResetButton } from "../molecules/";
|
||||
|
||||
const GraphDiv = styled.div`
|
||||
flex: 2;
|
||||
`;
|
||||
|
||||
interface IGraphProps {
|
||||
currentInstanceName: string | null;
|
||||
fetchGraph: () => void;
|
||||
graph?: IGraph;
|
||||
graphLoadError: boolean;
|
||||
isLoadingGraph: boolean;
|
||||
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 componentDidMount() {
|
||||
this.loadGraph();
|
||||
}
|
||||
|
||||
public render() {
|
||||
let content;
|
||||
if (this.props.isLoadingGraph) {
|
||||
content = <NonIdealState icon={<Spinner />} title="Loading..." />;
|
||||
} else if (this.props.graphLoadError || !this.props.graph) {
|
||||
content = <ErrorState />;
|
||||
} else {
|
||||
content = (
|
||||
<>
|
||||
<Cytoscape
|
||||
currentNodeId={this.props.currentInstanceName}
|
||||
elements={this.props.graph}
|
||||
navigateToInstancePath={this.navigateToInstancePath}
|
||||
navigateToRoot={this.navigateToRoot}
|
||||
ref={this.cytoscapeComponent}
|
||||
/>
|
||||
<FloatingResetButton onClick={this.resetGraphPosition} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <GraphDiv>{content}</GraphDiv>;
|
||||
}
|
||||
|
||||
private loadGraph = () => {
|
||||
if (!this.props.isLoadingGraph && !this.props.graphLoadError) {
|
||||
this.props.fetchGraph();
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
graphLoadError: state.data.error,
|
||||
isLoadingGraph: state.data.isLoadingGraph
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
fetchGraph: () => dispatch(fetchGraph() as any),
|
||||
navigate: (path: string) => dispatch(push(path))
|
||||
});
|
||||
const Graph = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(GraphImpl);
|
||||
export default Graph;
|
|
@ -0,0 +1,52 @@
|
|||
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 { 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Nav;
|
|
@ -0,0 +1,29 @@
|
|||
import { Card, Elevation } from "@blueprintjs/core";
|
||||
import React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
const RightDiv = styled.div`
|
||||
align-self: right;
|
||||
background-color: grey;
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-x: hidden;
|
||||
`;
|
||||
const StyledCard = styled(Card)`
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
padding: 20px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
`;
|
||||
const SidebarContainer: React.FC = ({ children }) => {
|
||||
return (
|
||||
<RightDiv>
|
||||
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
|
||||
</RightDiv>
|
||||
);
|
||||
};
|
||||
export default SidebarContainer;
|
|
@ -0,0 +1,3 @@
|
|||
export { default as Graph } from "./Graph";
|
||||
export { default as Nav } from "./Nav";
|
||||
export { default as SidebarContainer } from "./SidebarContainer";
|
|
@ -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,79 +1,87 @@
|
|||
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 { Route, RouteComponentProps, Switch, withRouter } from "react-router";
|
||||
import { InstanceScreen, SearchScreen } from ".";
|
||||
import { INSTANCE_DOMAIN_PATH } from "../../constants";
|
||||
import { loadInstance } from "../../redux/actions";
|
||||
import { IAppState } from "../../redux/types";
|
||||
import { domainMatchSelector, isSmallScreen } from "../../util";
|
||||
import { Graph, SidebarContainer } from "../organisms/";
|
||||
|
||||
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";
|
||||
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[];
|
||||
isLoadingGraph: boolean;
|
||||
isLoadingInstances: boolean;
|
||||
interface IGraphScreenProps extends RouteComponentProps {
|
||||
currentInstanceName: string | null;
|
||||
pathname: string;
|
||||
graphLoadError: boolean;
|
||||
fetchInstances: () => void;
|
||||
fetchGraph: () => void;
|
||||
loadInstance: (domain: string | null) => void;
|
||||
}
|
||||
/**
|
||||
* This component takes care of loading or deselecting the current instance when the URL path changes.
|
||||
* It also handles changing and animating the screen shown in the sidebar.
|
||||
*/
|
||||
class GraphScreenImpl extends React.Component<IGraphScreenProps> {
|
||||
public render() {
|
||||
let body = <div />;
|
||||
if (this.props.isLoadingInstances || this.props.isLoadingGraph) {
|
||||
body = this.loadingState("Loading...");
|
||||
} else {
|
||||
body = this.graphState();
|
||||
}
|
||||
return <div>{body}</div>;
|
||||
return <Route render={this.renderRoutes} />;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.load();
|
||||
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) {
|
||||
this.props.fetchGraph();
|
||||
}
|
||||
};
|
||||
private renderRoutes = ({ location }: RouteComponentProps) => (
|
||||
<FullDiv>
|
||||
<GraphContainer>
|
||||
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
|
||||
{isSmallScreen || <Graph />}
|
||||
<SidebarContainer>
|
||||
<Switch>
|
||||
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
|
||||
<Route exact={true} path="/" component={SearchScreen} />
|
||||
</Switch>
|
||||
</SidebarContainer>
|
||||
</GraphContainer>
|
||||
</FullDiv>
|
||||
);
|
||||
|
||||
private graphState = () => {
|
||||
const content = this.props.graphLoadError ? <ErrorState /> : <Graph />;
|
||||
return (
|
||||
<div>
|
||||
<Sidebar />
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private loadingState = (title?: string) => {
|
||||
return <NonIdealState icon={<Spinner />} title={title || "Loading..."} />;
|
||||
private loadCurrentInstance = (prevInstanceName?: string | null) => {
|
||||
if (prevInstanceName !== this.props.currentInstanceName) {
|
||||
this.props.loadInstance(this.props.currentInstanceName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
graphLoadError: state.data.error,
|
||||
pathname: state.router.location.pathname
|
||||
};
|
||||
};
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
fetchGraph: () => dispatch(fetchGraph() 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 withRouter(GraphScreen);
|
||||
|
|
|
@ -0,0 +1,441 @@
|
|||
import { orderBy } from "lodash";
|
||||
import moment from "moment";
|
||||
import * as numeral from "numeral";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import sanitize from "sanitize-html";
|
||||
|
||||
import {
|
||||
AnchorButton,
|
||||
Button,
|
||||
Callout,
|
||||
Classes,
|
||||
Code,
|
||||
Divider,
|
||||
H2,
|
||||
HTMLTable,
|
||||
Icon,
|
||||
NonIdealState,
|
||||
Position,
|
||||
Spinner,
|
||||
Tab,
|
||||
Tabs,
|
||||
Tooltip
|
||||
} from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
|
||||
import { push } from "connected-react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { IAppState, IGraph, IInstanceDetails } from "../../redux/types";
|
||||
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
|
||||
import { Cytoscape, ErrorState } from "../molecules/";
|
||||
|
||||
const InstanceScreenContainer = styled.div`
|
||||
margin-bottom: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
`;
|
||||
const HeadingContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
`;
|
||||
const StyledHeadingH2 = styled(H2)`
|
||||
margin: 0;
|
||||
`;
|
||||
const StyledCloseButton = styled(Button)`
|
||||
justify-self: flex-end;
|
||||
`;
|
||||
const StyledHeadingTooltip = styled(Tooltip)`
|
||||
margin-left: 5px;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
const StyledHTMLTable = styled(HTMLTable)`
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledLinkToFdNetwork = styled.div`
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
`;
|
||||
const StyledCallout = styled(Callout)`
|
||||
margin: 10px 20px;
|
||||
width: auto;
|
||||
`;
|
||||
const StyledTabs = styled(Tabs)`
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
`;
|
||||
const StyledGraphContainer = styled.div`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 10px;
|
||||
`;
|
||||
interface IInstanceScreenProps {
|
||||
graph?: IGraph;
|
||||
instanceName: string | null;
|
||||
instanceLoadError: boolean;
|
||||
instanceDetails: IInstanceDetails | null;
|
||||
isLoadingInstanceDetails: boolean;
|
||||
navigateToRoot: () => void;
|
||||
navigateToInstance: (domain: string) => void;
|
||||
}
|
||||
interface IInstanceScreenState {
|
||||
neighbors?: string[];
|
||||
isProcessingNeighbors: boolean;
|
||||
// Local (neighborhood) graph. Used only on small screens (mobile devices).
|
||||
isLoadingLocalGraph: boolean;
|
||||
localGraph?: IGraph;
|
||||
localGraphLoadError?: boolean;
|
||||
}
|
||||
class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> {
|
||||
public constructor(props: IInstanceScreenProps) {
|
||||
super(props);
|
||||
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
|
||||
}
|
||||
|
||||
public render() {
|
||||
let content;
|
||||
if (this.props.isLoadingInstanceDetails || this.state.isProcessingNeighbors || this.state.isLoadingLocalGraph) {
|
||||
content = this.renderLoadingState();
|
||||
} else if (this.props.instanceLoadError || this.state.localGraphLoadError || !this.props.instanceDetails) {
|
||||
return (content = <ErrorState />);
|
||||
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
|
||||
content = this.renderPersonalInstanceErrorState();
|
||||
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) {
|
||||
content = this.renderRobotsTxtState();
|
||||
} else if (this.props.instanceDetails.status !== "success") {
|
||||
content = this.renderMissingDataState();
|
||||
} else {
|
||||
content = this.renderTabs();
|
||||
}
|
||||
return (
|
||||
<InstanceScreenContainer>
|
||||
<HeadingContainer>
|
||||
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
|
||||
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
|
||||
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
|
||||
</StyledHeadingTooltip>
|
||||
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
|
||||
</HeadingContainer>
|
||||
<Divider />
|
||||
{content}
|
||||
</InstanceScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.loadLocalGraphOnSmallScreen();
|
||||
this.processEdgesToFindNeighbors();
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: IInstanceScreenProps, prevState: IInstanceScreenState) {
|
||||
const isNewInstance = prevProps.instanceName !== this.props.instanceName;
|
||||
const receivedNewEdges = !!this.props.graph && !this.state.isProcessingNeighbors && !this.state.neighbors;
|
||||
const receivedNewLocalGraph = !!this.state.localGraph && !prevState.localGraph;
|
||||
if (isNewInstance || receivedNewEdges || receivedNewLocalGraph) {
|
||||
this.processEdgesToFindNeighbors();
|
||||
}
|
||||
}
|
||||
|
||||
private processEdgesToFindNeighbors = () => {
|
||||
const { graph, instanceName } = this.props;
|
||||
const { localGraph } = this.state;
|
||||
if ((!graph && !localGraph) || !instanceName) {
|
||||
return;
|
||||
}
|
||||
this.setState({ isProcessingNeighbors: true });
|
||||
|
||||
const graphToUse = !!graph ? graph : localGraph;
|
||||
const edges = graphToUse!.edges.filter(e => [e.data.source, e.data.target].indexOf(instanceName!) > -1);
|
||||
const neighbors: any[] = [];
|
||||
edges.forEach(e => {
|
||||
if (e.data.source === instanceName) {
|
||||
neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
|
||||
} else {
|
||||
neighbors.push({ neighbor: e.data.source, weight: e.data.weight });
|
||||
}
|
||||
});
|
||||
this.setState({ neighbors, isProcessingNeighbors: false });
|
||||
};
|
||||
|
||||
private loadLocalGraphOnSmallScreen = () => {
|
||||
if (!isSmallScreen) {
|
||||
return;
|
||||
}
|
||||
this.setState({ isLoadingLocalGraph: true });
|
||||
getFromApi(`graph/${this.props.instanceName}`)
|
||||
.then((response: IGraph) => {
|
||||
// We do some processing of edges here to make sure that every edge's source and target are in the neighborhood
|
||||
// We could (and should) be doing this in the backend, but I don't want to mess around with complex SQL
|
||||
// queries.
|
||||
// TODO: think more about moving the backend to a graph database that would make this easier.
|
||||
const nodeIds = new Set(response.nodes.map(n => n.data.id));
|
||||
const edges = response.edges.filter(e => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
|
||||
this.setState({ isLoadingLocalGraph: false, localGraph: { ...response, edges } });
|
||||
})
|
||||
.catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true }));
|
||||
};
|
||||
|
||||
private renderTabs = () => {
|
||||
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
|
||||
|
||||
const hasLocalGraph =
|
||||
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
|
||||
const insularCallout =
|
||||
this.props.graph && !this.state.isProcessingNeighbors && !hasNeighbors && !hasLocalGraph ? (
|
||||
<StyledCallout icon={IconNames.INFO_SIGN} title="Insular instance">
|
||||
<p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
|
||||
</StyledCallout>
|
||||
) : (
|
||||
undefined
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{insularCallout}
|
||||
{this.maybeRenderLocalGraph()}
|
||||
<StyledTabs>
|
||||
{this.props.instanceDetails!.description && (
|
||||
<Tab id="description" title="Description" panel={this.renderDescription()} />
|
||||
)}
|
||||
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
|
||||
<Tab id="neighbors" title="Neighbors" panel={this.renderNeighbors()} />
|
||||
<Tab id="peers" title="Known peers" panel={this.renderPeers()} />
|
||||
</StyledTabs>
|
||||
<StyledLinkToFdNetwork>
|
||||
<AnchorButton
|
||||
href={`https://fediverse.network/${this.props.instanceName}`}
|
||||
minimal={true}
|
||||
rightIcon={IconNames.SHARE}
|
||||
target="_blank"
|
||||
text="See more statistics at fediverse.network"
|
||||
/>
|
||||
</StyledLinkToFdNetwork>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
private maybeRenderLocalGraph = () => {
|
||||
const { localGraph } = this.state;
|
||||
const hasLocalGraph =
|
||||
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
|
||||
if (!hasLocalGraph) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<StyledGraphContainer>
|
||||
<Cytoscape
|
||||
elements={localGraph!}
|
||||
currentNodeId={this.props.instanceName}
|
||||
navigateToInstancePath={this.props.navigateToInstance}
|
||||
/>
|
||||
<Divider />
|
||||
</StyledGraphContainer>
|
||||
);
|
||||
};
|
||||
|
||||
private shouldRenderStats = () => {
|
||||
const details = this.props.instanceDetails;
|
||||
return details && (details.version || details.userCount || details.statusCount || details.domainCount);
|
||||
};
|
||||
|
||||
private renderDescription = () => {
|
||||
const description = this.props.instanceDetails!.description;
|
||||
if (!description) {
|
||||
return;
|
||||
}
|
||||
return <p className={Classes.RUNNING_TEXT} dangerouslySetInnerHTML={{ __html: sanitize(description) }} />;
|
||||
};
|
||||
|
||||
private renderVersionAndCounts = () => {
|
||||
if (!this.props.instanceDetails) {
|
||||
throw new Error("Did not receive instance details as expected!");
|
||||
}
|
||||
const { version, userCount, statusCount, domainCount, lastUpdated, insularity } = this.props.instanceDetails;
|
||||
return (
|
||||
<StyledHTMLTable small={true} striped={true}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Version</td>
|
||||
<td>{<Code>{version}</Code> || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Users</td>
|
||||
<td>{(userCount && numeral.default(userCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Statuses</td>
|
||||
<td>{(statusCount && numeral.default(statusCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
Insularity{" "}
|
||||
<Tooltip
|
||||
content={
|
||||
<span>
|
||||
The percentage of mentions that are directed
|
||||
<br />
|
||||
toward users on the same instance.
|
||||
</span>
|
||||
}
|
||||
position={Position.TOP}
|
||||
className={Classes.DARK}
|
||||
>
|
||||
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Known peers</td>
|
||||
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last updated</td>
|
||||
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</StyledHTMLTable>
|
||||
);
|
||||
};
|
||||
|
||||
private renderNeighbors = () => {
|
||||
if (!this.state.neighbors) {
|
||||
return;
|
||||
}
|
||||
const neighborRows = orderBy(this.state.neighbors, ["weight"], ["desc"]).map(
|
||||
(neighborDetails: any, idx: number) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<Link
|
||||
to={`/instance/${neighborDetails.neighbor}`}
|
||||
className={`${Classes.BUTTON} ${Classes.MINIMAL}`}
|
||||
role="button"
|
||||
>
|
||||
{neighborDetails.neighbor}
|
||||
</Link>
|
||||
</td>
|
||||
<td>{neighborDetails.weight.toFixed(4)}</td>
|
||||
</tr>
|
||||
)
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p className={Classes.TEXT_MUTED}>
|
||||
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>
|
||||
<StyledHTMLTable small={true} striped={true} interactive={false}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Instance</th>
|
||||
<th>Mention ratio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{neighborRows}</tbody>
|
||||
</StyledHTMLTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private renderPeers = () => {
|
||||
const peers = this.props.instanceDetails!.peers;
|
||||
if (!peers || peers.length === 0) {
|
||||
return;
|
||||
}
|
||||
const peerRows = peers.map(instance => (
|
||||
<tr key={instance.name}>
|
||||
<td>
|
||||
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
|
||||
{instance.name}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
));
|
||||
return (
|
||||
<div>
|
||||
<p className={Classes.TEXT_MUTED}>
|
||||
All the instances, past and present, that {this.props.instanceName} knows about.
|
||||
</p>
|
||||
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
|
||||
<tbody>{peerRows}</tbody>
|
||||
</StyledHTMLTable>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
|
||||
|
||||
private renderPersonalInstanceErrorState = () => {
|
||||
return (
|
||||
<NonIdealState
|
||||
icon={IconNames.BLOCKED_PERSON}
|
||||
title="No data"
|
||||
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
|
||||
action={
|
||||
<AnchorButton icon={IconNames.CONFIRM} href="https://cursed.technology/@fediversespace" target="_blank">
|
||||
Message @fediversespace to opt in
|
||||
</AnchorButton>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderMissingDataState = () => {
|
||||
return (
|
||||
<>
|
||||
<NonIdealState
|
||||
icon={IconNames.ERROR}
|
||||
title="No data"
|
||||
description="This instance could not be crawled. Either it was down or it's an instance type we don't support yet."
|
||||
/>
|
||||
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
|
||||
{this.props.instanceDetails && this.props.instanceDetails.status}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
private renderRobotsTxtState = () => {
|
||||
return (
|
||||
<NonIdealState
|
||||
icon={
|
||||
<span role="img" aria-label="robot">
|
||||
🤖
|
||||
</span>
|
||||
}
|
||||
title="No data"
|
||||
description="This instance was not crawled because its robots.txt did not allow us to."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private openInstanceLink = () => {
|
||||
window.open("https://" + this.props.instanceName, "_blank");
|
||||
};
|
||||
}
|
||||
|
||||
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 mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
|
||||
navigateToRoot: () => dispatch(push("/"))
|
||||
});
|
||||
const InstanceScreen = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(InstanceScreenImpl);
|
||||
export default InstanceScreen;
|
|
@ -0,0 +1,136 @@
|
|||
import { Button, Classes, H2, NonIdealState, Spinner } from "@blueprintjs/core";
|
||||
import { IconNames } from "@blueprintjs/icons";
|
||||
import { push } from "connected-react-router";
|
||||
import React from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Dispatch } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { updateSearch } from "../../redux/actions";
|
||||
import { IAppState, ISearchResultInstance } from "../../redux/types";
|
||||
import { SearchResult } from "../molecules";
|
||||
|
||||
const SearchContainer = styled.div`
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
`;
|
||||
const SearchBarContainer = styled.div`
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
`;
|
||||
const SearchResults = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
const StyledSpinner = styled(Spinner)`
|
||||
margin-top: 10px;
|
||||
`;
|
||||
|
||||
interface ISearchScreenProps {
|
||||
error: boolean;
|
||||
isLoadingResults: boolean;
|
||||
query: string;
|
||||
hasMoreResults: boolean;
|
||||
results: ISearchResultInstance[];
|
||||
handleSearch: (query: string) => void;
|
||||
navigateToInstance: (domain: string) => void;
|
||||
}
|
||||
interface ISearchScreenState {
|
||||
currentQuery: string;
|
||||
}
|
||||
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> {
|
||||
public constructor(props: ISearchScreenProps) {
|
||||
super(props);
|
||||
this.state = { currentQuery: "" };
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
if (this.props.query) {
|
||||
this.setState({ currentQuery: this.props.query });
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { error, hasMoreResults, results, isLoadingResults, query } = this.props;
|
||||
|
||||
if (error) {
|
||||
return <NonIdealState icon={IconNames.ERROR} title="Something went wrong." action={this.renderSearchBar()} />;
|
||||
} else if (!isLoadingResults && query && results.length === 0) {
|
||||
return (
|
||||
<NonIdealState
|
||||
icon={IconNames.SEARCH}
|
||||
title="No search results"
|
||||
description="Try searching for something else."
|
||||
action={this.renderSearchBar()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchContainer>
|
||||
<H2>Find an instance</H2>
|
||||
{this.renderSearchBar()}
|
||||
<SearchResults>
|
||||
{results.map(result => (
|
||||
<SearchResult result={result} key={result.name} onClick={this.selectInstanceFactory(result.name)} />
|
||||
))}
|
||||
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
|
||||
{!isLoadingResults && hasMoreResults && (
|
||||
<Button onClick={this.search} minimal={true}>
|
||||
Load more results
|
||||
</Button>
|
||||
)}
|
||||
</SearchResults>
|
||||
</SearchContainer>
|
||||
);
|
||||
}
|
||||
|
||||
private handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ currentQuery: event.currentTarget.value });
|
||||
};
|
||||
|
||||
private handleKeyPress = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter" && this.state.currentQuery !== this.props.query) {
|
||||
this.search();
|
||||
}
|
||||
};
|
||||
|
||||
private search = () => {
|
||||
this.props.handleSearch(this.state.currentQuery);
|
||||
};
|
||||
|
||||
private selectInstanceFactory = (domain: string) => () => {
|
||||
this.props.navigateToInstance(domain);
|
||||
};
|
||||
|
||||
private renderSearchBar = () => (
|
||||
<SearchBarContainer className={`${Classes.INPUT_GROUP} ${Classes.LARGE}`}>
|
||||
<span className={`${Classes.ICON} bp3-icon-${IconNames.SEARCH}`} />
|
||||
<input
|
||||
className={Classes.INPUT}
|
||||
type="search"
|
||||
placeholder="Instance name"
|
||||
dir="auto"
|
||||
value={this.state.currentQuery}
|
||||
onChange={this.handleInputChange}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
/>
|
||||
</SearchBarContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: IAppState) => ({
|
||||
error: state.search.error,
|
||||
hasMoreResults: !!state.search.next,
|
||||
isLoadingResults: state.search.isLoadingResults,
|
||||
query: state.search.query,
|
||||
results: state.search.results
|
||||
});
|
||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||
handleSearch: (query: string) => dispatch(updateSearch(query) as any),
|
||||
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`))
|
||||
});
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SearchScreen);
|
|
@ -0,0 +1,4 @@
|
|||
export { default as AboutScreen } from "./AboutScreen";
|
||||
export { default as GraphScreen } from "./GraphScreen";
|
||||
export { default as SearchScreen } from "./SearchScreen";
|
||||
export { default as InstanceScreen } from "./InstanceScreen";
|
|
@ -1,5 +1,10 @@
|
|||
/* Screen widths less than this will be treated as mobile */
|
||||
export const DESKTOP_WIDTH_THRESHOLD = 800;
|
||||
export const DESKTOP_WIDTH_THRESHOLD = 1000;
|
||||
|
||||
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 { AppRouter } from "./AppRouter";
|
||||
import { rootReducer } from "./redux/reducers";
|
||||
import { routerMiddleware } from "connected-react-router";
|
||||
import { createBrowserHistory } from "history";
|
||||
import AppRouter from "./AppRouter";
|
||||
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}>
|
||||
|
@ -29,3 +41,9 @@ ReactDOM.render(
|
|||
</Provider>,
|
||||
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);
|
||||
// }
|
||||
|
|
|
@ -1,13 +1,25 @@
|
|||
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, IInstanceDetails, ISearchResponse } from "./types";
|
||||
|
||||
// selectInstance and deselectInstance are not exported since we only call them from selectAndLoadInstance()
|
||||
const selectInstance = (instanceName: string) => {
|
||||
// Instance details
|
||||
const requestInstanceDetails = (instanceName: string) => {
|
||||
return {
|
||||
payload: instanceName,
|
||||
type: ActionType.SELECT_INSTANCE
|
||||
type: ActionType.REQUEST_INSTANCE_DETAILS
|
||||
};
|
||||
};
|
||||
const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => {
|
||||
return {
|
||||
payload: instanceDetails,
|
||||
type: ActionType.RECEIVE_INSTANCE_DETAILS
|
||||
};
|
||||
};
|
||||
const instanceLoadFailed = () => {
|
||||
return {
|
||||
type: ActionType.INSTANCE_LOAD_ERROR
|
||||
};
|
||||
};
|
||||
const deselectInstance = () => {
|
||||
|
@ -16,71 +28,85 @@ const deselectInstance = () => {
|
|||
};
|
||||
};
|
||||
|
||||
export const requestInstances = () => {
|
||||
return {
|
||||
type: ActionType.REQUEST_INSTANCES
|
||||
};
|
||||
};
|
||||
|
||||
export const receiveInstances = (instances: IInstance[]) => {
|
||||
return {
|
||||
payload: instances,
|
||||
type: ActionType.RECEIVE_INSTANCES
|
||||
};
|
||||
};
|
||||
export const requestGraph = () => {
|
||||
// Graph
|
||||
const requestGraph = () => {
|
||||
return {
|
||||
type: ActionType.REQUEST_GRAPH
|
||||
};
|
||||
};
|
||||
|
||||
export const receiveGraph = (graph: IGraph) => {
|
||||
const receiveGraph = (graph: IGraph) => {
|
||||
return {
|
||||
payload: graph,
|
||||
type: ActionType.RECEIVE_GRAPH
|
||||
};
|
||||
};
|
||||
|
||||
const graphLoadFailed = () => {
|
||||
return {
|
||||
type: ActionType.GRAPH_LOAD_ERROR
|
||||
};
|
||||
};
|
||||
|
||||
const instanceLoadFailed = () => {
|
||||
// Search
|
||||
const requestSearchResult = (query: string) => {
|
||||
return {
|
||||
type: ActionType.INSTANCE_LOAD_ERROR
|
||||
payload: query,
|
||||
type: ActionType.REQUEST_SEARCH_RESULTS
|
||||
};
|
||||
};
|
||||
const receiveSearchResults = (result: ISearchResponse) => {
|
||||
return {
|
||||
payload: result,
|
||||
type: ActionType.RECEIVE_SEARCH_RESULTS
|
||||
};
|
||||
};
|
||||
const searchFailed = () => {
|
||||
return {
|
||||
type: ActionType.SEARCH_RESULTS_ERROR
|
||||
};
|
||||
};
|
||||
|
||||
export const receiveInstanceDetails = (instanceDetails: IInstanceDetails) => {
|
||||
const resetSearch = () => {
|
||||
return {
|
||||
payload: instanceDetails,
|
||||
type: ActionType.RECEIVE_INSTANCE_DETAILS
|
||||
type: ActionType.RESET_SEARCH
|
||||
};
|
||||
};
|
||||
|
||||
/** Async actions: https://redux.js.org/advanced/asyncactions */
|
||||
|
||||
export const fetchInstances = () => {
|
||||
return (dispatch: Dispatch) => {
|
||||
dispatch(requestInstances());
|
||||
return getFromApi("instances")
|
||||
.then(instances => dispatch(receiveInstances(instances)))
|
||||
.catch(e => dispatch(graphLoadFailed()));
|
||||
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(requestInstanceDetails(instanceName));
|
||||
return getFromApi("instances/" + instanceName)
|
||||
.then(details => dispatch(receiveInstanceDetails(details)))
|
||||
.catch(() => dispatch(instanceLoadFailed()));
|
||||
};
|
||||
};
|
||||
|
||||
export const selectAndLoadInstance = (instanceName: string) => {
|
||||
return (dispatch: Dispatch) => {
|
||||
if (!instanceName) {
|
||||
dispatch(deselectInstance());
|
||||
export const updateSearch = (query: string) => {
|
||||
return (dispatch: Dispatch, getState: () => IAppState) => {
|
||||
query = query.trim();
|
||||
|
||||
if (!query) {
|
||||
dispatch(resetSearch());
|
||||
return;
|
||||
}
|
||||
dispatch(selectInstance(instanceName));
|
||||
return getFromApi("instances/" + instanceName)
|
||||
.then(details => dispatch(receiveInstanceDetails(details)))
|
||||
.catch(e => dispatch(instanceLoadFailed()));
|
||||
|
||||
const next = getState().search.next;
|
||||
let url = `search/?query=${query}`;
|
||||
if (next) {
|
||||
url += `&after=${next}`;
|
||||
}
|
||||
dispatch(requestSearchResult(query));
|
||||
return getFromApi(url)
|
||||
.then(result => dispatch(receiveSearchResults(result)))
|
||||
.catch(() => dispatch(searchFailed()));
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -89,6 +115,6 @@ export const fetchGraph = () => {
|
|||
dispatch(requestGraph());
|
||||
return getFromApi("graph")
|
||||
.then(graph => dispatch(receiveGraph(graph)))
|
||||
.catch(e => dispatch(graphLoadFailed()));
|
||||
.catch(() => dispatch(graphLoadFailed()));
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,29 +1,19 @@
|
|||
import { connectRouter } from "connected-react-router";
|
||||
import { combineReducers } from "redux";
|
||||
|
||||
import { ActionType, IAction, ICurrentInstanceState, IDataState } from "./types";
|
||||
import { History } from "history";
|
||||
import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types";
|
||||
|
||||
const initialDataState = {
|
||||
error: false,
|
||||
isLoadingGraph: false,
|
||||
isLoadingInstances: false
|
||||
isLoadingGraph: false
|
||||
};
|
||||
const data = (state: IDataState = initialDataState, action: IAction) => {
|
||||
switch (action.type) {
|
||||
case ActionType.REQUEST_INSTANCES:
|
||||
return {
|
||||
...state,
|
||||
instances: [],
|
||||
isLoadingInstances: true
|
||||
};
|
||||
case ActionType.RECEIVE_INSTANCES:
|
||||
return {
|
||||
...state,
|
||||
instances: action.payload,
|
||||
isLoadingInstances: false
|
||||
};
|
||||
case ActionType.REQUEST_GRAPH:
|
||||
return {
|
||||
...state,
|
||||
graph: undefined,
|
||||
isLoadingGraph: true
|
||||
};
|
||||
case ActionType.RECEIVE_GRAPH:
|
||||
|
@ -36,8 +26,7 @@ const data = (state: IDataState = initialDataState, action: IAction) => {
|
|||
return {
|
||||
...state,
|
||||
error: true,
|
||||
isLoadingGraph: false,
|
||||
isLoadingInstances: false
|
||||
isLoadingGraph: false
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
@ -46,29 +35,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 +70,54 @@ const currentInstance = (state = initialCurrentInstanceState, action: IAction):
|
|||
}
|
||||
};
|
||||
|
||||
export const rootReducer = combineReducers({
|
||||
currentInstance,
|
||||
data
|
||||
});
|
||||
const initialSearchState: ISearchState = {
|
||||
error: false,
|
||||
isLoadingResults: false,
|
||||
next: "",
|
||||
query: "",
|
||||
results: []
|
||||
};
|
||||
const search = (state = initialSearchState, action: IAction): ISearchState => {
|
||||
switch (action.type) {
|
||||
case ActionType.REQUEST_SEARCH_RESULTS:
|
||||
const query = action.payload;
|
||||
const isNewQuery = state.query !== query;
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
isLoadingResults: true,
|
||||
query,
|
||||
results: isNewQuery ? [] : state.results
|
||||
};
|
||||
case ActionType.RECEIVE_SEARCH_RESULTS:
|
||||
return {
|
||||
...state,
|
||||
error: false,
|
||||
isLoadingResults: false,
|
||||
next: action.payload.next,
|
||||
results: state.results.concat(action.payload.results)
|
||||
};
|
||||
case ActionType.SEARCH_RESULTS_ERROR:
|
||||
return {
|
||||
...state,
|
||||
error: true,
|
||||
isLoadingResults: false,
|
||||
next: "",
|
||||
query: "",
|
||||
results: []
|
||||
};
|
||||
case ActionType.RESET_SEARCH:
|
||||
return initialSearchState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export default (history: History) =>
|
||||
combineReducers({
|
||||
router: connectRouter(history),
|
||||
// tslint:disable-next-line:object-literal-sort-keys
|
||||
currentInstance,
|
||||
data,
|
||||
search
|
||||
});
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
import { RouterState } from "connected-react-router";
|
||||
|
||||
export enum ActionType {
|
||||
SELECT_INSTANCE = "SELECT_INSTANCE",
|
||||
REQUEST_INSTANCES = "REQUEST_INSTANCES",
|
||||
RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
|
||||
// Instance details
|
||||
REQUEST_INSTANCE_DETAILS = "REQUEST_INSTANCE_DETAILS",
|
||||
RECEIVE_INSTANCE_DETAILS = "RECEIVE_INSTANCE_DETAILS",
|
||||
INSTANCE_LOAD_ERROR = "INSTANCE_LOAD_ERROR",
|
||||
// Graph
|
||||
REQUEST_GRAPH = "REQUEST_GRAPH",
|
||||
RECEIVE_GRAPH = "RECEIVE_GRAPH",
|
||||
RECEIVE_INSTANCE_DETAILS = "RECEIVE_INSTANCE_DETAILS",
|
||||
DESELECT_INSTANCE = "DESELECT_INSTANCE",
|
||||
GRAPH_LOAD_ERROR = "GRAPH_LOAD_ERROR",
|
||||
INSTANCE_LOAD_ERROR = "INSTANCE_LOAD_ERROR"
|
||||
// Nav
|
||||
DESELECT_INSTANCE = "DESELECT_INSTANCE",
|
||||
// Search
|
||||
REQUEST_SEARCH_RESULTS = "REQUEST_SEARCH_RESULTS",
|
||||
RECEIVE_SEARCH_RESULTS = "RECEIVE_SEARCH_RESULTS",
|
||||
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
|
||||
RESET_SEARCH = "RESET_SEARCH"
|
||||
// REQUEST_INSTANCES = "REQUEST_INSTANCES",
|
||||
// RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
|
||||
}
|
||||
|
||||
export interface IAction {
|
||||
|
@ -19,6 +29,12 @@ export interface IInstance {
|
|||
name: string;
|
||||
}
|
||||
|
||||
export interface ISearchResultInstance {
|
||||
name: string;
|
||||
description?: string;
|
||||
userCount?: number;
|
||||
}
|
||||
|
||||
export interface IInstanceDetails {
|
||||
name: string;
|
||||
description?: string;
|
||||
|
@ -58,24 +74,37 @@ export interface IGraph {
|
|||
edges: IGraphEdge[];
|
||||
}
|
||||
|
||||
export interface ISearchResponse {
|
||||
results: ISearchResultInstance[];
|
||||
next: string | null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface IDataState {
|
||||
instances?: IInstance[];
|
||||
graph?: IGraph;
|
||||
isLoadingInstances: boolean;
|
||||
isLoadingGraph: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export interface ISearchState {
|
||||
error: boolean;
|
||||
isLoadingResults: boolean;
|
||||
next: string;
|
||||
query: string;
|
||||
results: ISearchResultInstance[];
|
||||
}
|
||||
|
||||
export interface IAppState {
|
||||
router: RouterState;
|
||||
currentInstance: ICurrentInstanceState;
|
||||
data: IDataState;
|
||||
search: ISearchState;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { createMatchSelector } from "connected-react-router";
|
||||
import fetch from "cross-fetch";
|
||||
import { DESKTOP_WIDTH_THRESHOLD, 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) {
|
||||
|
@ -9,6 +12,9 @@ if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
|
|||
|
||||
export const getFromApi = (path: string): Promise<any> => {
|
||||
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
|
||||
path = path.endsWith("/") ? path : path + "/";
|
||||
return fetch(domain + path).then(response => response.json());
|
||||
return fetch(encodeURI(domain + path)).then(response => response.json());
|
||||
};
|
||||
|
||||
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
|
||||
|
||||
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
|
||||
|
|
|
@ -1318,10 +1318,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.9.tgz#d868b6febb02666330410fe7f58f3c4b8258be7b"
|
||||
integrity sha512-MNl+rT5UmZeilaPxAVs6YaPC2m6aA8rofviZbhbxpPpl61uKodfdQVsBtgJGTqGizEf02oW3tsVe7FYB8kK14A==
|
||||
|
||||
"@types/cytoscape@^3.4.3":
|
||||
version "3.4.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.4.3.tgz#8b9353154dc895231cd344ed1c7eff2d1391c103"
|
||||
integrity sha512-uADb/vBj/xTeNNRvtYlzPz1rftMR4Jf6ipq4jqKfYibMZ173sAbdFM3Fl2fPbGfP28CWJpqhcpHp4+NUq3Ma4g==
|
||||
"@types/cytoscape@^3.8.0":
|
||||
version "3.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.8.0.tgz#334006612fc6285dac83ee3665132743d7651f58"
|
||||
integrity sha512-8TJL7HuMEgjQRCcUC3xKenb7Y6Ra3ZJ3LvYDlpxlt5LlAatzRyTBtIuE1JOdRelqAla8r87XJUzTgi92mlUlQQ==
|
||||
|
||||
"@types/dom4@^2.0.1":
|
||||
version "2.0.1"
|
||||
|
@ -1376,6 +1376,11 @@
|
|||
"@types/domutils" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/inflection@^1.5.28":
|
||||
version "1.5.28"
|
||||
resolved "https://registry.yarnpkg.com/@types/inflection/-/inflection-1.5.28.tgz#43d55e0d72cf333a2dffd9c4ec0407455a1b0931"
|
||||
integrity sha1-Q9VeDXLPMzot/9nE7AQHRVobCTE=
|
||||
|
||||
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0":
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff"
|
||||
|
@ -1423,10 +1428,10 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/node/-/node-11.13.4.tgz#f83ec3c3e05b174b7241fadeb6688267fe5b22ca"
|
||||
integrity sha512-+rabAZZ3Yn7tF/XPGHupKIL5EcAbrLxnTr/hgQICxbeuAfWtT0UZSfULE+ndusckBItcv4o6ZeOJplQikVcLvQ==
|
||||
|
||||
"@types/node@^12.6.2":
|
||||
version "12.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.2.tgz#a5ccec6abb6060d5f20d256fb03ed743e9774999"
|
||||
integrity sha512-gojym4tX0FWeV2gsW4Xmzo5wxGjXGm550oVUII7f7G5o4BV6c7DBdiG1RRQd+y1bvqRyYtPfMK85UM95vsapqQ==
|
||||
"@types/node@^12.6.8":
|
||||
version "12.6.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.8.tgz#e469b4bf9d1c9832aee4907ba8a051494357c12c"
|
||||
integrity sha512-aX+gFgA5GHcDi89KG5keey2zf0WfZk/HAQotEamsK2kbey+8yGKcson0hbK8E+v0NArlCJQCqMP161YhV6ZXLg==
|
||||
|
||||
"@types/normalize-package-data@^2.4.0":
|
||||
version "2.4.0"
|
||||
|
@ -1490,10 +1495,10 @@
|
|||
"@types/history" "*"
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/react-virtualized@^9.21.2":
|
||||
version "9.21.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.2.tgz#c5e4293409593814c35466913e83fb856e2053d0"
|
||||
integrity sha512-Q6geJaDd8FlBw3ilD4ODferTyVtYAmDE3d7+GacfwN0jPt9rD9XkeuPjcHmyIwTrMXuLv1VIJmRxU9WQoQFBJw==
|
||||
"@types/react-virtualized@^9.21.3":
|
||||
version "9.21.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.3.tgz#79a44b870a4848cbc7cc04ff4bc06e5a10955262"
|
||||
integrity sha512-QhXeiVwXrshVAoq2Cy3SGZEDiFdeFfup2ciQya5RTgr5uycQ2alIKzLfy4X38UCrxonwxe8byk5q8fYV0U87Zg==
|
||||
dependencies:
|
||||
"@types/prop-types" "*"
|
||||
"@types/react" "*"
|
||||
|
@ -2093,6 +2098,11 @@ aws4@^1.8.0:
|
|||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
|
||||
integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
|
||||
|
||||
axe-core@^3.0.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-3.3.0.tgz#3b32d7e54390d89ff4891b20394d33ad7a192776"
|
||||
integrity sha512-54XaTd2VB7A6iBnXMUG2LnBOI7aRbnrVxC5Tz+rVUwYl9MX/cIJc/Ll32YUoFIE/e9UKWMZoQenQu9dFrQyZCg==
|
||||
|
||||
axobject-query@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.0.2.tgz#ea187abe5b9002b377f925d8bf7d1c561adf38f9"
|
||||
|
@ -2961,6 +2971,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"
|
||||
|
@ -5159,11 +5178,12 @@ https-browserify@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
|
||||
integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=
|
||||
|
||||
husky@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.0.tgz#de63821a7049dc412b1afd753c259e2f6e227562"
|
||||
integrity sha512-lKMEn7bRK+7f5eWPNGclDVciYNQt0GIkAQmhKl+uHP1qFzoN0h92kmH9HZ8PCwyVA2EQPD8KHf0FYWqnTxau+Q==
|
||||
husky@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/husky/-/husky-3.0.1.tgz#06152c28e129622b05fa09c494209de8cf2dfb59"
|
||||
integrity sha512-PXBv+iGKw23GHUlgELRlVX9932feFL407/wHFwtsGeArp0dDM4u+/QusSQwPKxmNgjpSL+ustbOdQ2jetgAZbA==
|
||||
dependencies:
|
||||
chalk "^2.4.2"
|
||||
cosmiconfig "^5.2.1"
|
||||
execa "^1.0.0"
|
||||
get-stdin "^7.0.0"
|
||||
|
@ -5240,6 +5260,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"
|
||||
|
@ -5298,6 +5323,11 @@ indexof@0.0.1:
|
|||
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
|
||||
integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
|
||||
|
||||
inflection@^1.12.0:
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/inflection/-/inflection-1.12.0.tgz#a200935656d6f5f6bc4dc7502e1aecb703228416"
|
||||
integrity sha1-ogCTVlbW9fa8TcdQLhrstwMihBY=
|
||||
|
||||
inflight@^1.0.4:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
|
||||
|
@ -6657,11 +6687,16 @@ lodash.uniq@^4.5.0:
|
|||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d"
|
||||
integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==
|
||||
|
||||
lodash@^4.17.12, lodash@^4.17.14:
|
||||
lodash@^4.17.12:
|
||||
version "4.17.14"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
|
||||
integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==
|
||||
|
||||
lodash@^4.17.15:
|
||||
version "4.17.15"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
|
||||
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
|
||||
|
||||
log-symbols@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
|
||||
|
@ -8831,6 +8866,14 @@ react-app-polyfill@^1.0.1:
|
|||
regenerator-runtime "0.13.2"
|
||||
whatwg-fetch "3.0.0"
|
||||
|
||||
react-axe@^3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-axe/-/react-axe-3.2.0.tgz#d17427e5d54d6c4561e74ad9cf8c1839e411bde3"
|
||||
integrity sha512-2KlO2wZq58+GSFP4oWA2ZjU1ggbXdDLJc7tMUXUXkE4NVQ3FftdYtb7qNR+x1nTLeuVYiH4nH4hzIz9vQZ/Chw==
|
||||
dependencies:
|
||||
axe-core "^3.0.0"
|
||||
requestidlecallback "^0.3.0"
|
||||
|
||||
react-dev-utils@^9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-9.0.1.tgz#5c03d85a0b2537d0c46af7165c24a7dfb274bef2"
|
||||
|
@ -8862,7 +8905,7 @@ react-dev-utils@^9.0.1:
|
|||
strip-ansi "5.2.0"
|
||||
text-table "0.2.0"
|
||||
|
||||
react-dom@^16.4.2:
|
||||
react-dom@^16.8.0:
|
||||
version "16.8.6"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f"
|
||||
integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==
|
||||
|
@ -9028,7 +9071,7 @@ react-virtualized@^9.21.1:
|
|||
prop-types "^15.6.0"
|
||||
react-lifecycles-compat "^3.0.4"
|
||||
|
||||
react@^16.4.2:
|
||||
react@^16.8.0:
|
||||
version "16.8.6"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
|
||||
integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==
|
||||
|
@ -9306,6 +9349,11 @@ request@^2.87.0, request@^2.88.0:
|
|||
tunnel-agent "^0.6.0"
|
||||
uuid "^3.3.2"
|
||||
|
||||
requestidlecallback@^0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/requestidlecallback/-/requestidlecallback-0.3.0.tgz#6fb74e0733f90df3faa4838f9f6a2a5f9b742ac5"
|
||||
integrity sha1-b7dOBzP5DfP6pIOPn2oqX5t0KsU=
|
||||
|
||||
require-directory@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
|
||||
|
@ -9538,6 +9586,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"
|
||||
|
|
|
@ -9,4 +9,10 @@
|
|||
REACT_APP_STAGING = "true"
|
||||
|
||||
[context.deploy-preview.environment]
|
||||
REACT_APP_STAGING = "true"
|
||||
REACT_APP_STAGING = "true"
|
||||
|
||||
[[redirects]]
|
||||
from = "/*"
|
||||
to = "/index.html"
|
||||
status = 200
|
||||
|
||||
|
|
Loading…
Reference in New Issue