Compare commits

..

No commits in common. "master" and "v2.8.6" have entirely different histories.

80 changed files with 5260 additions and 42124 deletions

View File

@ -1,43 +0,0 @@
name: Elixir CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Elixir
uses: erlef/setup-elixir@885971a72ed1f9240973bd92ab57af8c1aa68f24
with:
elixir-version: '1.12.2' # Define the elixir version [required]
otp-version: '24.0.4' # Define the OTP version [required]
- name: Restore dependencies cache
uses: actions/cache@v2
with:
working-directory: ./backend
path: deps
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- name: Install dependencies
working-directory: ./backend
run: |
mix local.hex --force
mix local.rebar --force
mix deps.get
- name: Compile dependencies
working-directory: ./backend
run: mix deps.compile
- name: Run Credo
working-directory: ./backend
run: mix credo --strict
- name: Run sobelow
working-directory: ./backend
run: mix sobelow --config

View File

@ -1,37 +0,0 @@
# This is a basic workflow to help you get started with Actions
name: CI
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the master branch
push:
branches: [ master ]
pull_request:
branches: [ master ]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: Setup Node.js environment
uses: actions/setup-node@v2.3.0
- name: Setup deps
working-directory: ./frontend
run: yarn install
- name: Lint
working-directory: ./frontend
run: yarn lint

View File

@ -1,3 +1,10 @@
include:
template: Dependency-Scanning.gitlab-ci.yml
dependency_scanning:
only:
- schedules
test-frontend:
image: node:lts-alpine
stage: test
@ -13,16 +20,16 @@ test-frontend:
- frontend/.yarn
only:
changes:
- frontend/**/*
- frontend/*
test-backend:
stage: test
image: elixir:1.10
image: elixir:1.9
variables:
MIX_ENV: test
only:
changes:
- backend/**/*
- backend/*
before_script:
- cd backend
script:
@ -60,4 +67,4 @@ deploy-gephi-production:
except:
- schedules
script:
- git-push dokku@api.fediverse.space:gephi master
- git-push dokku@api.fediverse.space:gephi master

View File

@ -1,6 +1,7 @@
{
"recommendations": [
"jakebecker.elixir-ls",
"ms-vscode.vscode-typescript-tslint-plugin",
"kevinmcgowan.typescriptimport",
"msjsdiag.debugger-for-chrome"
]

17
.vscode/launch.json vendored
View File

@ -5,15 +5,12 @@
"version": "0.2.0",
"configurations": [
{
"type": "mix_task",
"type": "chrome",
"request": "launch",
"name": "phx.server",
"task": "phx.server",
"taskArgs": [],
"projectDir": "${workspaceRoot}/backend",
"env": {
"SKIP_CRAWL": "1"
}
},
"name": "Launch Chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}/frontend/src",
"runtimeExecutable": "/usr/bin/chromium-browser"
}
]
}
}

View File

@ -19,70 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
## [2.9.6 - 2020-10-13]
### Added
- Added link to personal website on About page.
### Fixed
- Allow `data:` images in Netlify CSP.
- Fix inability to DM login links in releases (#104).
## [2.9.5 - 2020-10-11]
### Fixed
- Fixed crawler not finding API in some cases
## [2.9.4 - 2020-10-09]
### Fixed
- Fix CSP issues for Plausible analytics
## [2.9.3 - 2020-10-09]
### Added
- Allow Plausible privacy-preserving analytics in CSP
### Changed
- Update dependencies
- Update to Elixir 1.10
### Fixed
- Fixed CSP headers for data: images
## [2.9.2 - 2020-08-31]
### Removed
- Remove staging server
## [2.9.1 - 2020-08-31]
### Fixed
- Added AppSignal logo to "Special thanks" section
## [2.9.0 - 2020-06-19]
### Changed
- Bring back `develop` staging backup (now managed in DNS)
- Increase default number of concurrent crawlers to 100
- Accessibility improvements (according to axe review)
- Update dependencies
### Security
- Add rate limiting of auth endpoints
- Added security headers to netlify frontend
- Sanitize crawled HTML in the backend
## [2.8.6 - 2020-01-16]
### Changed

View File

@ -1,12 +1,12 @@
# index.community 🌐
# fediverse.space 🌐
The map of the fediverse that you always wanted.
Read the latest updates on Mastodon: [@indexCommunity](https://social.inex.rocks/@indexCommunity)
Read the latest updates on Mastodon: [@fediversespace](https://mastodon.social/@fediversespace)
![A screenshot of fediverse.space](screenshot.png)
- [index.community 🌐](#indexcommunity-%f0%9f%8c%90)
- [fediverse.space 🌐](#fediversespace-%f0%9f%8c%90)
- [Requirements](#requirements)
- [Running it](#running-it)
- [Backend](#backend)
@ -20,11 +20,9 @@ Read the latest updates on Mastodon: [@indexCommunity](https://social.inex.rocks
## Requirements
Note: examples here use `podman`. In most cases you should be able to replace `podman` with `docker`.
Though dockerized, backend development is easiest if you have the following installed.
Though containerized, backend development is easiest if you have the following installed.
- For the crawler + API:
- For the scraper + API:
- Elixir
- Postgres
- For laying out the graph:
@ -38,11 +36,9 @@ Though containerized, backend development is easiest if you have the following i
### Backend
- `cp example.env .env` and modify environment variables as required
- `podman build gephi && podman build phoenix`
- `podman run --name elasticsearch -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:6.8.9`
- If you've `run` this container previously, use `podman start elasticsearch`
- `podman run --name postgres -e "POSTGRES_USER=postgres" -e "POSTGRES_PASSWORD=postgres" -p 5432:5432 postgres:12`
- `podman-compose -f compose.backend-services.yml -f compose.phoenix.yml`
- `docker-compose build`
- `docker-compose up -d phoenix`
- if you don't specify `phoenix`, it'll also start `gephi` which should only be run as a regular one-off job
- Create the elasticsearch index:
- `iex -S mix app.start`
- `Elasticsearch.Index.hot_swap(Backend.Elasticsearch.Cluster, :instances)`
@ -57,6 +53,10 @@ Though containerized, backend development is easiest if you have the following i
### Backend
`./gradlew shadowJar` compiles the graph layout program. `java -Xmx1g -jar build/libs/graphBuilder.jar` runs it.
If running in docker, this means you run
- `docker-compose build gephi`
- `docker-compose run gephi java -Xmx1g -jar build/libs/graphBuilder.jar` lays out the graph
### Frontend
@ -102,6 +102,8 @@ SHELL=/bin/bash
0 2 * * * /usr/bin/dokku run gephi java -Xmx1g -jar build/libs/graphBuilder.jar
```
10. (Optional) Set up caching with something like [dokku-nginx-cache](https://github.com/Aluxian/dokku-nginx-cache)
Before the app starts running, make sure that the Elasticsearch index exists -- otherwise it'll create one called
`instances`, which should be the name of the alias. Then it won't be able to hot swap if you reindex in the future.

View File

@ -1,7 +1,7 @@
FROM elixir:1.12-alpine as build
FROM elixir:1.9.0-alpine as build
# install build dependencies
RUN apk add --update git build-base
RUN apk add --update git build-base
# prepare build dir
RUN mkdir /app
@ -37,7 +37,7 @@ RUN mix release
# prepare release image
FROM alpine:3.9 AS app
RUN apk add --update bash openssl libstdc++ build-base
RUN apk add --update bash openssl
RUN mkdir /app
WORKDIR /app

View File

@ -2,7 +2,7 @@
## Notes
- This project requires Elixir >= 1.10.
- This project requires Elixir >= 1.9.
- Run with `SKIP_CRAWL=true` to just run the server (useful for working on the API without also crawling)
- This project is automatically scanned for potential vulnerabilities with [Sobelow](https://sobelow.io/).
@ -24,8 +24,6 @@ There are several environment variables you can set to configure how the crawler
- `FRONTEND_DOMAIN` (required). Used to generate login links for instance admins.
- Don't enter `https://`, this is added automatically.
- `SENDGRID_API_KEY`. Needed to send emails to the admin, or to instance admins who want to opt in/out.
- `MASTODON_DOMAIN`. The domain (e.g. `mastodon.social`) that your bot login account is hosted on.
- `MASTODON_TOKEN`. The access token for the bot login account.
## Deployment

View File

@ -13,13 +13,15 @@ config :backend,
# Configures the endpoint
config :backend, BackendWeb.Endpoint,
url: [host: "localhost"],
secret_key_base: System.get_env("SECRET_KEY_BASE"),
render_errors: [view: BackendWeb.ErrorView, accepts: ~w(json)]
secret_key_base: "XL4NKGBN9lZMrQbMEI1KJOlwAt8S7younVJl90TdAgzmwyapr3g7BRYSNYvX0sZ9",
render_errors: [view: BackendWeb.ErrorView, accepts: ~w(json)],
pubsub: [name: Backend.PubSub, adapter: Phoenix.PubSub.PG2],
instrumenters: [Appsignal.Phoenix.Instrumenter]
config :backend, Backend.Repo, queue_target: 5000
config :backend, Backend.Elasticsearch.Cluster,
url: "http://elastic:9200",
url: "http://localhost:9200",
api: Elasticsearch.API.HTTP,
json_library: Jason
@ -35,22 +37,19 @@ config :gollum,
# 24 hrs
refresh_secs: 86_400,
lazy_refresh: true,
user_agent: "index.community crawler"
user_agent: "fediverse.space crawler"
config :backend, Graph.Cache,
# 1 hour
gc_interval: 3600
config :ex_twilio,
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
config :backend, Backend.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("MAILER_RELAY"),
username: System.get_env("MAILER_USERNAME"),
password: System.get_env("MAILER_PASSWORD"),
ssl: true,
tls: :always,
auth: :always,
port: 465
adapter: Swoosh.Adapters.Sendgrid,
api_key: System.get_env("SENDGRID_API_KEY")
config :backend, Mastodon.Messenger,
domain: System.get_env("MASTODON_DOMAIN"),
@ -61,7 +60,7 @@ config :backend, :crawler,
status_count_limit: 5000,
personal_instance_threshold: 10,
crawl_interval_mins: 30,
crawl_workers: 100,
crawl_workers: 50,
blacklist: [
# spam
"gab.best",
@ -72,9 +71,10 @@ config :backend, :crawler,
# dummy instances used for pleroma CI
"pleroma.online"
],
user_agent: "index.community crawler",
user_agent: "fediverse.space crawler",
require_bidirectional_mentions: false,
admin_phone: System.get_env("ADMIN_PHONE"),
twilio_phone: System.get_env("TWILIO_PHONE"),
admin_email: System.get_env("ADMIN_EMAIL")
config :backend, Backend.Scheduler,
@ -91,10 +91,6 @@ config :backend, Backend.Scheduler,
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
]
config :phoenix, :template_engines,
eex: Appsignal.Phoenix.Template.EExEngine,
exs: Appsignal.Phoenix.Template.ExsEngine
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"

View File

@ -7,7 +7,7 @@ import Config
# watchers to your application. For example, we use it
# with webpack to recompile .js and .css sources.
config :backend, BackendWeb.Endpoint,
http: [port: 4001],
http: [port: 4000],
debug_errors: true,
code_reloader: true,
check_origin: false,
@ -53,7 +53,7 @@ config :backend, Backend.Repo,
username: "postgres",
password: "postgres",
database: "backend_dev",
hostname: "127.0.0.1:5435",
hostname: "localhost",
pool_size: 10
config :backend, :crawler,

View File

@ -19,7 +19,7 @@ config :backend, Backend.Elasticsearch.Cluster,
config :appsignal, :config, revision: System.get_env("GIT_REV")
port = String.to_integer(System.get_env("PORT") || "4001")
port = String.to_integer(System.get_env("PORT") || "4000")
config :backend, BackendWeb.Endpoint,
http: [:inet6, port: port],
@ -28,20 +28,16 @@ config :backend, BackendWeb.Endpoint,
secret_key_base: System.get_env("SECRET_KEY_BASE"),
server: true
config :ex_twilio,
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
config :backend, :crawler,
admin_phone: System.get_env("ADMIN_PHONE"),
twilio_phone: System.get_env("TWILIO_PHONE"),
admin_email: System.get_env("ADMIN_EMAIL"),
frontend_domain: System.get_env("FRONTEND_DOMAIN")
config :backend, Backend.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("MAILER_RELAY"),
username: System.get_env("MAILER_USERNAME"),
password: System.get_env("MAILER_PASSWORD"),
ssl: true,
auth: :always,
port: 465
config :backend, Mastodon.Messenger,
domain: System.get_env("MASTODON_DOMAIN"),
token: System.get_env("MASTODON_TOKEN")
adapter: Swoosh.Adapters.Sendgrid,
api_key: System.get_env("SENDGRID_API_KEY")

View File

@ -1,37 +0,0 @@
version: "2"
networks:
space:
external: false
services:
server:
build: .
restart: unless-stopped
networks:
- space
volumes:
- /home/gitea/data:/data
depends_on:
- db
db:
image: postgres:12-alpine
restart: unless-stopped
environment:
- POSTGRES_PASSWORD: postgres
- POSTGRES_USER: postgres
networks:
- space
volumes:
- /var/lib/postgresql/data
elastic:
image: elasticsearch:6.8.9
restart: unless-stopped
environment:
- discovery.type: single-node
networks:
- space

View File

@ -7,7 +7,6 @@ defmodule Backend.Application do
import Backend.Util
def start(_type, _args) do
:telemetry.attach(
"appsignal-ecto",
[:backend, :repo, :query],

View File

@ -18,7 +18,7 @@ defmodule Backend.Crawler.ApiCrawler do
# {domain, type} e.g. {"gab.com", "reject"}
@type federation_restriction :: {String.t(), String.t()}
@type instance_type :: :mastodon | :pleroma | :gab | :misskey | :gnusocial | :smithereen
@type instance_type :: :mastodon | :pleroma | :gab | :misskey | :gnusocial
defstruct [
:version,

View File

@ -159,8 +159,8 @@ defmodule Backend.Crawler do
## Update the instance we crawled ##
instance = %Instance{
domain: domain,
description: HtmlSanitizeEx.basic_html(result.description),
version: HtmlSanitizeEx.basic_html(result.version),
description: result.description,
version: result.version,
user_count: result.user_count,
status_count: result.status_count,
type: instance_type,

View File

@ -12,7 +12,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
@impl ApiCrawler
def is_instance_type?(domain, result) do
# We might already know that this is a Pleroma instance from nodeinfo
if result != nil and (Map.get(result, :instance_type) == :pleroma or Map.get(result, :instance_type) == :smithereen) do
if result != nil and Map.get(result, :instance_type) == :pleroma do
true
else
case get_and_decode("https://#{domain}/api/v1/instance") do
@ -230,7 +230,6 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
defp get_instance_type(instance_stats) do
cond do
Map.get(instance_stats, "version") |> String.downcase() =~ "pleroma" -> :pleroma
Map.get(instance_stats, "version") |> String.downcase() =~ "smithereen" -> :smithereen
is_gab?(instance_stats) -> :gab
true -> :mastodon
end

View File

@ -26,7 +26,7 @@ defmodule Backend.Crawler.StaleInstanceManager do
case instance_count do
# Add m.s. as the seed and schedule the next add
0 ->
add_to_queue("mastodon.ml")
add_to_queue("mastodon.social")
schedule_add()
# Start immediately

View File

@ -14,7 +14,7 @@ defmodule Backend.Release do
]
# Ecto repos to start, if any
@repos Application.compile_env(:backend, :ecto_repos, [])
@repos Application.get_env(:backend, :ecto_repos, [])
# Elasticsearch clusters to start
@clusters [Backend.Elasticsearch.Cluster]
# Elasticsearch indexes to build

View File

@ -3,7 +3,7 @@ defmodule Backend.Scheduler do
This module runs recurring tasks.
"""
use Quantum, otp_app: :backend
use Quantum.Scheduler, otp_app: :backend
alias Backend.{Crawl, CrawlInteraction, Edge, FederationRestriction, Instance, Repo}
alias Backend.Mailer.AdminEmail
@ -280,6 +280,7 @@ defmodule Backend.Scheduler do
end).()
Logger.info(message)
send_admin_sms(message)
AdminEmail.send("Potential spam", message)
else
Logger.debug("Did not find potential spam instances.")

View File

@ -113,6 +113,21 @@ defmodule Backend.Util do
end)
end
@doc """
Sends an SMS to the admin phone number if configured.
"""
def send_admin_sms(body) do
if get_config(:admin_phone) != nil and get_config(:twilio_phone) != nil do
ExTwilio.Message.create(
to: get_config(:admin_phone),
from: get_config(:twilio_phone),
body: body
)
else
Logger.info("Could not send SMS to admin; not configured.")
end
end
@spec clean_domain(String.t()) :: String.t()
def clean_domain(domain) do
cleaned =
@ -155,9 +170,7 @@ defmodule Backend.Util do
timeout: timeout
) do
{:ok, %{status_code: 200, body: body}} -> Jason.decode(body)
{:ok, %{status_code: 401}} -> Jason.decode("[]")
{:ok, %{status_code: 404}} -> Jason.decode("[]")
{:ok, %{body: body}} -> {:error, %HTTPoison.Error{reason: "Non-200 response. Body: #{body}"}}
{:ok, _} -> {:error, %HTTPoison.Error{reason: "Non-200 response"}}
{:error, err} -> {:error, err}
end
end

View File

@ -46,7 +46,7 @@ defmodule BackendWeb.Endpoint do
)
plug(Corsica,
origins: ["http://localhost:3001", ~r{^https://(.*\.?)index\.community$}, ~r{^https://(.*\.?)fediverse\.space$}],
origins: ["http://localhost:3000", ~r{^https?://(.*\.?)fediverse\.space$}],
allow_headers: ["content-type", "token"]
)

View File

@ -1,53 +0,0 @@
defmodule BackendWeb.RateLimiter do
@moduledoc """
Functions used to rate limit:
* all endpoints by IP/endpoint
* authentication endpoints by domain
"""
import Phoenix.Controller, only: [json: 2]
import Plug.Conn, only: [put_status: 2]
use Plug.Builder
def rate_limit(conn, options \\ []) do
case check_rate(conn, options) do
{:ok, _count} -> conn # Do nothing, allow execution to continue
{:error, _count} -> render_error(conn)
end
end
def rate_limit_authentication(conn, options \\ []) do
domain =
if Map.has_key?(conn.params, "id") do
Map.get(conn.params, "id")
else
Map.get(conn.params, "domain")
end
options = Keyword.put(options, :bucket_name, "authorization: #{domain}")
rate_limit(conn, options)
end
defp check_rate(conn, options) do
interval_milliseconds = options[:interval_seconds] * 1000
max_requests = options[:max_requests]
bucket_name = options[:bucket_name] || bucket_name(conn)
ExRated.check_rate(bucket_name, interval_milliseconds, max_requests)
end
# Bucket name should be a combination of ip address and request path, like so:
#
# "127.0.0.1:/api/v1/authorizations"
defp bucket_name(conn) do
path = Enum.join(conn.path_info, "/")
ip = conn.remote_ip |> Tuple.to_list |> Enum.join(".")
"#{ip}:#{path}"
end
defp render_error(conn) do
conn
|> put_status(:forbidden)
|> json(%{error: "Rate limit exceeded."})
|> halt # Stop execution of further plugs, return response now
end
end

View File

@ -1,14 +1,8 @@
defmodule BackendWeb.Router do
use BackendWeb, :router
import BackendWeb.RateLimiter
pipeline :api do
plug(:accepts, ["json"])
plug(:rate_limit, max_requests: 5, interval_seconds: 10) # requests to the same endpoint
end
pipeline :api_admin do
plug(:rate_limit_authentication, max_requests: 5, interval_seconds: 60)
end
scope "/api", BackendWeb do
@ -18,12 +12,8 @@ defmodule BackendWeb.Router do
resources("/graph", GraphController, only: [:index, :show])
resources("/search", SearchController, only: [:index])
scope "/admin" do
pipe_through :api_admin
resources("/login", AdminLoginController, only: [:show, :create])
get "/", AdminController, :show
post "/", AdminController, :update
end
resources("/admin/login", AdminLoginController, only: [:show, :create])
get "/admin", AdminController, :show
post "/admin", AdminController, :update
end
end

View File

@ -12,7 +12,7 @@ defmodule Backend.Mailer.AdminEmail do
if admin_email != nil do
new()
|> to(admin_email)
|> from("noreply@index.community")
|> from("noreply@fediverse.space")
|> subject(subject)
|> text_body(body)
|> Backend.Mailer.deliver!()

View File

@ -16,8 +16,8 @@ defmodule Backend.Mailer.UserEmail do
new()
|> to(address)
|> from("noreply@index.community")
|> subject("Login to index.community")
|> from("noreply@fediverse.space")
|> subject("Login to fediverse.space")
|> text_body(body)
|> Backend.Mailer.deliver()
end

View File

@ -23,11 +23,11 @@ defmodule Backend.MixProject do
extra_applications: [
:logger,
:runtime_tools,
:mnesia,
:gollum,
:ex_twilio,
:elasticsearch,
:appsignal,
:swoosh,
:gen_smtp
:appsignal
]
]
end
@ -41,33 +41,33 @@ defmodule Backend.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.5"},
{:phoenix_pubsub, "~> 2.0"},
{:phoenix, "~> 1.4.3"},
{:phoenix_pubsub, "~> 1.1"},
{:phoenix_ecto, "~> 4.0"},
{:ecto_sql, "~> 3.0"},
{:postgrex, ">= 0.0.0"},
{:gettext, "~> 0.11"},
{:jason, "~> 1.0"},
{:plug_cowboy, "~> 2.1"},
{:httpoison, "~> 1.7", override: true},
{:plug_cowboy, "~> 2.0"},
{:httpoison, "~> 1.5"},
{:timex, "~> 3.5"},
{:honeydew, "~> 1.5.0"},
{:quantum, "~> 3.3"},
{:honeydew, "~> 1.4.3"},
{:quantum, "~> 2.3"},
{:corsica, "~> 1.1.2"},
{:sobelow, "~> 0.8", only: [:dev, :test]},
{:gollum, "~> 0.3.2"},
{:public_suffix, git: "https://github.com/axelson/publicsuffix-elixir"},
{:swoosh, "~> 1.0"},
{:gen_smtp, "~> 1.1"},
{:public_suffix, "~> 0.6.0"},
{:idna, "~> 5.1.2", override: true},
{:swoosh, "~> 0.23.3"},
{:ex_twilio, "~> 0.7.0"},
{:elasticsearch, "~> 1.0"},
{:appsignal, "~> 1.0"},
{:appsignal, "~> 1.10.1"},
{:credo, "~> 1.1", only: [:dev, :test], runtime: false},
{:nebulex, "~> 1.1"},
{:hunter, "~> 0.5.1"},
{:poison, "~> 4.0", override: true},
{:scrivener_ecto, "~> 2.2"},
{:recase, "~> 0.7"},
{:ex_rated, "~> 2.0"},
{:html_sanitize_ex, "~> 1.4"}
{:recase, "~> 0.6.0"}
]
end

View File

@ -1,66 +1,66 @@
%{
"appsignal": {:hex, :appsignal, "1.13.5", "153ebe929fae8f637d43bf66058efecbb4affc4037caa466d31a236cb3f2e788", [:make, :mix], [{:decorator, "~> 1.2.3 or ~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.9", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b8b6847c0d7f8ad03523be0fa6fdd670679ad42d62e2a8b74e599eba0247096c"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
"corsica": {:hex, :corsica, "1.1.3", "5f1de40bc9285753aa03afbdd10c364dac79b2ddbf2ba9c5c9c47b397ec06f40", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "8156b3a14a114a346262871333a931a1766b2597b56bf994fcfcb65443a348ad"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"credo": {:hex, :credo, "1.5.6", "e04cc0fdc236fefbb578e0c04bd01a471081616e741d386909e527ac146016c6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "4b52a3e558bd64e30de62a648518a5ea2b6e3e5d2b164ef5296244753fc7eb17"},
"crontab": {:hex, :crontab, "1.1.10", "dc9bb1f4299138d47bce38341f5dcbee0aa6c205e864fba7bc847f3b5cb48241", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1347d889d1a0eda997990876b4894359e34bfbbd688acbb0ba28a2795ca40685"},
"db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"decorator": {:hex, :decorator, "1.4.0", "a57ac32c823ea7e4e67f5af56412d12b33274661bb7640ec7fc882f8d23ac419", [:mix], [], "hexpm", "0a07cedd9083da875c7418dea95b78361197cf2bf3211d743f6f7ce39656597f"},
"ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"},
"ecto_sql": {:hex, :ecto_sql, "3.6.2", "9526b5f691701a5181427634c30655ac33d11e17e4069eff3ae1176c764e0ba3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ec9d7e6f742ea39b63aceaea9ac1d1773d574ea40df5a53ef8afbd9242fdb6b"},
"elasticsearch": {:hex, :elasticsearch, "1.0.1", "8339538d90af6b280f10ecd02b1eae372f09373e629b336a13461babf7366495", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm", "83e7d8b8bee3e7e19a06ab4d357d24845ac1da894e79678227fd52c0b7f71867"},
"ex2ms": {:hex, :ex2ms, "1.6.0", "f39bbd9ff1b0f27b3f707bab2d167066dd8965e7df1149b962d94c74615d0e09", [:mix], [], "hexpm", "0d1ab5e08421af5cd69146efb408dbb1ff77f38a2f4df5f086f2512dc8cf65bf"},
"ex_rated": {:hex, :ex_rated, "2.0.1", "49b4c170039fc62fa93ea28df16e3586e98c2fe0aec10f75e6717fba8039637f", [:mix], [{:ex2ms, "~> 1.5", [hex: :ex2ms, repo: "hexpm", optional: false]}], "hexpm", "2f675b649f74028842ae3d1f0c5090f8a664682df98c82836db6f1d321eaa42a"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"gen_smtp": {:hex, :gen_smtp, "1.1.1", "bf9303c31735100631b1d708d629e4c65944319d1143b5c9952054f4a1311d85", [:rebar3], [{:hut, "1.3.0", [hex: :hut, repo: "hexpm", optional: false]}, {:ranch, ">= 1.7.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "51bc50cc017efd4a4248cbc39ea30fb60efa7d4a49688986fafad84434ff9ab7"},
"gen_stage": {:hex, :gen_stage, "1.1.0", "dd0c0f8d2f3b993fdbd3d58e94abbe65380f4e78bdee3fa93d5618d7d14abe60", [:mix], [], "hexpm", "7f2b36a6d02f7ef2ba410733b540ec423af65ec9c99f3d1083da508aca3b9305"},
"gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"},
"gollum": {:hex, :gollum, "0.3.3", "25ebb47700b9236bc4e5382bf91b72e4cdaf9bae3556172eff27e770735a198f", [:mix], [{:httpoison, "~> 1.5.1", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm", "39268eeaf4f0adb6fdebe4f8c36b10a277881ab2eee3419c9b6727759e2f5a5d"},
"hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"},
"honeydew": {:hex, :honeydew, "1.5.0", "53088c1d87399efa5c0939adc8d32a9713b8fe6ce00a77c6769d2d363abac6bc", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "f71669e25f6a972e970ecbd79c34c4ad4b28369be78e4f8164fe8d0c5a674907"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.1", "e8a67da405fe9f0d1be121a40a60f70811192033a5b8d00a95dddd807f5e053e", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "68d92656f47cd73598c45ad2394561f025c8c65d146001b955fd7b517858962a"},
"httpoison": {:hex, :httpoison, "1.8.0", "6b85dea15820b7804ef607ff78406ab449dd78bed923a49c7160e1886e987a3d", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "28089eaa98cf90c66265b6b5ad87c59a3729bea2e74e9d08f9b51eb9729b3c3a"},
"hunter": {:hex, :hunter, "0.5.1", "374dc4a800e2c340659657f8875e466075c7ea532e0d7a7787665f272b410150", [:mix], [{:httpoison, "~> 1.5", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "209b2cca7e4d51d5ff7ee4a0ab6cdc4c6ad23ddd61c9e12ceeee6f7ffbeae9c8"},
"hut": {:hex, :hut, "1.3.0", "71f2f054e657c03f959cf1acc43f436ea87580696528ca2a55c8afb1b06c85e7", [:"erlang.mk", :rebar, :rebar3], [], "hexpm", "7e15d28555d8a1f2b5a3a931ec120af0753e4853a4c66053db354f35bf9ab563"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
"jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"},
"joken": {:hex, :joken, "2.3.0", "62a979c46f2c81dcb8ddc9150453b60d3757d1ac393c72bb20fc50a7b0827dc6", [:mix], [{:jose, "~> 1.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "57b263a79c0ec5d536ac02d569c01e6b4de91bd1cb825625fe90eab4feb7bc1e"},
"jose": {:hex, :jose, "1.11.1", "59da64010c69aad6cde2f5b9248b896b84472e99bd18f246085b7b9fe435dcdb", [:mix, :rebar3], [], "hexpm", "078f6c9fb3cd2f4cfafc972c814261a7d1e8d2b3685c0a76eb87e158efff1ac5"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mochiweb": {:hex, :mochiweb, "2.21.0", "3fe5c3403606726d7bc6dabbf36f9d634d5364ce7f33ce73442937fa54feec37", [:rebar3], [], "hexpm", "f848bfa1b75c32d56da9d2730245e34df4b39079c5d45d7b966b072ba53f8a13"},
"nebulex": {:hex, :nebulex, "1.2.2", "5b2bb7420a103b2a4278f354c9bd239bc77cd3bbdeddcebc4cc1d6ee656f126c", [:mix], [{:decorator, "~> 1.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:shards, "~> 0.6", [hex: :shards, repo: "hexpm", optional: false]}], "hexpm", "6804ddd7660fd4010a5af5957316ab7471c2db003189dba79dc3dd7b3f0aabf6"},
"parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
"phoenix": {:hex, :phoenix, "1.5.9", "a6368d36cfd59d917b37c44386e01315bc89f7609a10a45a22f47c007edf2597", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e4bce20a67c012f1fbb0af90e5da49fa7bf0d34e3a067795703b74aef75427d"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.3.0", "2c69a452c2e0ee8c93345ae1cdc1696ef4877ff9cbb15c305def41960c3c4ebf", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "0ac491924217550c8f42c81c1f390b5d81517d12ceaf9abf3e701156760a848e"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
"plug": {:hex, :plug, "1.12.0", "39dc7f1ef8c46bb1bf6dd8f6a49f526c45b4b92ce553687fd885b559a46d0230", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5282c76e89efdf43f2e04bd268ca99d738039f9518137f02ff468cee3ba78096"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.1", "7cc96ff645158a94cf3ec9744464414f02287f832d6847079adfe0b58761cbd0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "107d0a5865fa92bcb48e631cc0729ae9ccfa0a9f9a1bd8f01acb513abf1c2d64"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"},
"postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"},
"public_suffix": {:git, "https://github.com/axelson/publicsuffix-elixir", "89372422ab8b433de508519ef474e39699fd11ca", []},
"quantum": {:hex, :quantum, "3.3.0", "e8f6b9479728774288c5f426b11a6e3e8f619f3c226163a7e18bccfe543b714d", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3b83ef137ab3887e783b013418b5ce3e847d66b71c4ef0f233b0321c84b72f67"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"recase": {:hex, :recase, "0.7.0", "3f2f719f0886c7a3b7fe469058ec539cb7bbe0023604ae3bce920e186305e5ae", [:mix], [], "hexpm", "36f5756a9f552f4a94b54a695870e32f4e72d5fad9c25e61bc4a3151c08a4e0c"},
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
"shards": {:hex, :shards, "0.6.2", "e05d05537883220c3b8a8f9d40d5c8ba7ff6064c63ebb6b23046972f6863b2d1", [:make, :rebar3], [], "hexpm", "58afa3712f1f1256a2a15e39fa95b7cd758087aaa7a25beaf786daabd87890f0"},
"sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"swoosh": {:hex, :swoosh, "1.5.0", "2be4cfc1be10f2203d1854c85b18d8c7be0321445a782efd53ef0b2b88f03ce4", [:mix], [{:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b53891359e3ddca263ece784051243de84c9244c421a0dee1bff1d52fc5ca420"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
"timex": {:hex, :timex, "3.7.5", "3eca56e23bfa4e0848f0b0a29a92fa20af251a975116c6d504966e8a90516dfd", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "a15608dca680f2ef663d71c95842c67f0af08a0f3b1d00e17bbd22872e2874e4"},
"tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
"vex": {:hex, :vex, "0.9.0", "613ea5eb3055662e7178b83e25b2df0975f68c3d8bb67c1645f0573e1a78d606", [:mix], [], "hexpm", "c69fff44d5c8aa3f1faee71bba1dcab05dd36364c5a629df8bb11751240c857f"},
"appsignal": {:hex, :appsignal, "1.10.13", "d5df34ac7dc2d937510716f2089cc5f1d45b3f10f38225d19c35f31810b9266d", [:make, :mix], [{:decorator, "~> 1.2.3", [hex: :decorator, repo: "hexpm", optional: false]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.2.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, ">= 1.1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, ">= 1.3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"artificery": {:hex, :artificery, "0.4.2", "3ded6e29e13113af52811c72f414d1e88f711410cac1b619ab3a2666bbd7efd4", [:mix], [], "hexpm"},
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"corsica": {:hex, :corsica, "1.1.2", "5ad8b9dcbeeda4762d78a57c0c8c2f88e1eef8741508517c98cb79e0db1f107d", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
"cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm"},
"credo": {:hex, :credo, "1.1.5", "caec7a3cadd2e58609d7ee25b3931b129e739e070539ad1a0cd7efeeb47014f4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
"db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
"decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"},
"decorator": {:hex, :decorator, "1.2.4", "31dfff6143d37f0b68d0bffb3b9f18ace14fea54d4f1b5e4f86ead6f00d9ff6e", [:mix], [], "hexpm"},
"distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"},
"ecto": {:hex, :ecto, "3.2.2", "bb6d1dbcd7ef975b60637e63182e56f3d7d0b5dd9c46d4b9d6183a5c455d65d1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ecto_sql": {:hex, :ecto_sql, "3.2.0", "751cea597e8deb616084894dd75cbabfdbe7255ff01e8c058ca13f0353a3921b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"elasticsearch": {:hex, :elasticsearch, "1.0.0", "626d3fb8e7554d9c93eb18817ae2a3d22c2a4191cc903c4644b1334469b15374", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6.0", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm"},
"ex_twilio": {:hex, :ex_twilio, "0.7.0", "d7ce624ef4661311ae28c3e3aa060ecb66a9f4843184d7400c29072f7d3f5a4a", [:mix], [{:httpoison, ">= 0.9.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:inflex, "~> 1.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"},
"gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
"gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"},
"gollum": {:hex, :gollum, "0.3.3", "25ebb47700b9236bc4e5382bf91b72e4cdaf9bae3556172eff27e770735a198f", [:mix], [{:httpoison, "~> 1.5.1", [hex: :httpoison, repo: "hexpm", optional: false]}], "hexpm"},
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"honeydew": {:hex, :honeydew, "1.4.5", "03818730602274ef0119652d664b92ddf733256e857d29899ce6841e01345bd1", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
"httpoison": {:hex, :httpoison, "1.5.1", "0f55b5b673b03c5c327dac7015a67cb571b99b631acc0bc1b0b98dcd6b9f2104", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"hunter": {:hex, :hunter, "0.5.1", "374dc4a800e2c340659657f8875e466075c7ea532e0d7a7787665f272b410150", [:mix], [{:httpoison, "~> 1.5", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 4.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"idna": {:hex, :idna, "5.1.2", "e21cb58a09f0228a9e0b95eaa1217f1bcfc31a1aaa6e1fdf2f53a33f7dbd9494", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"inflex": {:hex, :inflex, "1.10.0", "8366a7696e70e1813aca102e61274addf85d99f4a072b2f9c7984054ea1b9d29", [:mix], [], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"joken": {:hex, :joken, "2.1.0", "bf21a73105d82649f617c5e59a7f8919aa47013d2519ebcc39d998d8d12adda9", [:mix], [{:jose, "~> 1.9", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm"},
"jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"},
"libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"},
"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"},
"nebulex": {:hex, :nebulex, "1.1.0", "be45cc3a2b7d01eb7da05747d38072d336187d05796ad9ef2d9dad9be430f915", [:mix], [{:shards, "~> 0.6", [hex: :shards, repo: "hexpm", optional: false]}], "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.10", "619e4a545505f562cd294df52294372d012823f4fd9d34a6657a8b242898c255", [: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"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
"plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
"plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"public_suffix": {:hex, :public_suffix, "0.6.0", "100cfe86f13f9f6f0cf67e743b1b83c78dd1223a2c422fa03ebf4adff514cbc3", [:mix], [{:idna, ">= 1.2.0 and < 6.0.0", [hex: :idna, repo: "hexpm", optional: false]}], "hexpm"},
"quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"},
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
"recase": {:hex, :recase, "0.6.0", "1dd2dd2f4e06603b74977630e739f08b7fedbb9420cc14de353666c2fc8b99f4", [:mix], [], "hexpm"},
"scrivener": {:hex, :scrivener, "2.7.0", "fa94cdea21fad0649921d8066b1833d18d296217bfdf4a5389a2f45ee857b773", [:mix], [], "hexpm"},
"scrivener_ecto": {:hex, :scrivener_ecto, "2.2.0", "53d5f1ba28f35f17891cf526ee102f8f225b7024d1cdaf8984875467158c9c5e", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm"},
"shards": {:hex, :shards, "0.6.0", "678d292ad74a4598a872930f9b12251f43e97f6050287f1fb712fbfd3d282f75", [:make, :rebar3], [], "hexpm"},
"sobelow": {:hex, :sobelow, "0.9.1", "0e3baeb03c2f98364a11dfb20bdad0790a2153aac2f07d3f8cdf7997c09dd649", [:mix], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
"swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm"},
"swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"},
"telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
"tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
"vex": {:hex, :vex, "0.6.0", "4e79b396b2ec18cd909eed0450b19108d9631842598d46552dc05031100b7a56", [:mix], [], "hexpm"},
}

65
docker-compose.yml Normal file
View File

@ -0,0 +1,65 @@
version: "3"
services:
db:
image: postgres
environment:
- DATABASE_URL
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- database_network
elasticsearch:
image: elasticsearch:6.8.1
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
networks:
- phoenix_network
- es_network
# Kibana is just for development, really
kibana:
image: kibana:6.8.1
networks:
- es_network
ports:
- "5601:5601"
# This is for running the occasional graph layout task. It's in docker-compose.yml so that it's built at the same time
# as everything else, but it should be run regularly with a cron job or similar.
gephi:
environment:
- DATABASE_URL
build: ./gephi
volumes:
- gradle-cache:/code/.gradle
depends_on:
- db
networks:
- database_network
phoenix:
build: ./backend
networks:
- database_network
- phoenix_network
depends_on:
- db
ports:
- "${PORT}:${PORT}"
environment:
- DATABASE_URL
- SECRET_KEY_BASE
- PORT
- BACKEND_HOSTNAME
volumes:
pgdata:
esdata:
gradle-cache:
networks:
database_network:
driver: bridge
phoenix_network:
driver: bridge
es_network:
driver: bridge

View File

@ -1,4 +0,0 @@
node_modules
dist
build
coverage

View File

@ -1,24 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
},
plugins: ["@typescript-eslint", "prettier"],
extends: [
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier/@typescript-eslint",
"prettier",
],
rules: {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/explicit-function-return-type": 0,
"react/prop-types": 0,
"@typescript-eslint/no-non-null-assertion": 0
},
};

View File

@ -1,3 +0,0 @@
module.exports = {
printWidth: 100
}

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +0,0 @@
server {
listen 80;
listen [::]:80;
server_name fediverse.space;
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/javascript
application/vnd.geo+json
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
font/opentype
image/bmp
image/svg+xml
image/x-icon
text/cache-manifest
text/css
text/plain
text/vcard
text/vnd.rim.location.xloc
text/vtt
text/x-component
text/x-cross-domain-policy;
root /website;
index index.html;
location / {
try_files $uri /index.html;
}
}

25236
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,11 @@
"start": "NODE_ENV=development react-scripts start",
"build": "react-scripts build",
"typecheck": "tsc --noemit",
"lint": "yarn typecheck && yarn eslint src/ --ext .js,.jsx,.ts,.tsx",
"lint": "yarn typecheck && tslint -p tsconfig.json -c tslint.json \"src/**/*.{ts,tsx}\"",
"lint:fix": "yarn lint --fix",
"pretty": "prettier --write \"src/**/*.{ts,tsx}\"",
"test": "yarn lint && react-scripts test --ci",
"eject": "react-scripts eject",
"snyk-protect": "snyk protect",
"prepare": "yarn run snyk-protect"
"eject": "react-scripts eject"
},
"husky": {
"hooks": {
@ -22,74 +20,67 @@
"lint-staged": {
"src/**/*.{ts,tsx}": [
"yarn pretty",
"yarn lint:fix"
"yarn lint:fix",
"git add"
]
},
"prettier": {
"printWidth": 120
},
"dependencies": {
"@blueprintjs/core": "^3.33.0",
"@blueprintjs/icons": "^3.22.0",
"@blueprintjs/select": "^3.14.2",
"@blueprintjs/core": "^3.19.1",
"@blueprintjs/icons": "^3.11.0",
"@blueprintjs/select": "^3.11.1",
"classnames": "^2.2.6",
"connected-react-router": "^6.5.2",
"cross-fetch": "^3.0.6",
"cytoscape": "^3.16.1",
"cytoscape-popper": "^1.0.7",
"cross-fetch": "^3.0.4",
"cytoscape": "^3.11.0",
"cytoscape-popper": "^1.0.4",
"inflection": "^1.12.0",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"lodash": "^4.17.15",
"moment": "^2.22.2",
"normalize.css": "^8.0.0",
"numeral": "^2.0.6",
"react": "^16.10.2",
"react-dom": "^16.10.2",
"react-redux": "^7.2.1",
"react-router-dom": "^5.2.0",
"react-sigma": "^1.2.35",
"react-virtualized": "^9.22.2",
"react-redux": "^7.1.1",
"react-router-dom": "^5.1.2",
"react-scripts": "^3.2.0",
"react-sigma": "^1.2.30",
"react-virtualized": "^9.21.1",
"redux": "^4.0.4",
"redux-thunk": "^2.3.0",
"sanitize-html": "^2.0.0",
"snyk": "^1.410.1",
"styled-components": "^5.2.0",
"sanitize-html": "^1.20.1",
"styled-components": "^4.4.0",
"tippy.js": "^4.3.5"
},
"devDependencies": {
"@blueprintjs/tslint-config": "^1.9.0",
"@types/classnames": "^2.2.9",
"@types/cytoscape": "^3.14.7",
"@types/cytoscape": "^3.8.3",
"@types/inflection": "^1.5.28",
"@types/jest": "^26.0.14",
"@types/lodash": "^4.14.161",
"@types/node": "^14.11.5",
"@types/numeral": "^0.0.28",
"@types/react": "^16.9.51",
"@types/react-axe": "^3.1.0",
"@types/react-dom": "^16.9.8",
"@types/react-redux": "^7.1.9",
"@types/react-router-dom": "^5.1.6",
"@types/sanitize-html": "^1.27.0",
"@types/styled-components": "5.1.3",
"@typescript-eslint/eslint-plugin": "^2.24.0",
"@typescript-eslint/parser": "^2.34.0",
"eslint-config-airbnb-typescript": "^7.2.1",
"eslint-config-prettier": "^6.12.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsx-a11y": "^6.3.1",
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-react": "^7.21.3",
"eslint-plugin-react-hooks": "^4.1.2",
"husky": "^4.3.0",
"lint-staged": "^10.4.0",
"prettier": "^2.1.2",
"react-scripts": "3.4.3",
"typescript": "^3.9.2"
"@types/jest": "^24.0.19",
"@types/lodash": "^4.14.144",
"@types/node": "^12.7.12",
"@types/numeral": "^0.0.26",
"@types/react": "^16.9.6",
"@types/react-dom": "^16.9.2",
"@types/react-redux": "^7.1.4",
"@types/react-router-dom": "^5.1.0",
"@types/sanitize-html": "^1.20.2",
"@types/styled-components": "4.1.19",
"husky": "^3.0.9",
"lint-staged": "^9.4.2",
"react-axe": "^3.3.0",
"tslint": "^5.20.0",
"tslint-config-security": "^1.16.0",
"tslint-eslint-rules": "^5.4.0",
"typescript": "^3.6.4"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"snyk": true
}
]
}

View File

@ -4,30 +4,28 @@ import { Classes } from "@blueprintjs/core";
import { ConnectedRouter } from "connected-react-router";
import { Route } from "react-router-dom";
import { Nav } from "./components/organisms";
import { Nav } from "./components/organisms/";
import {
AboutScreen,
AdminScreen,
GraphScreen,
LoginScreen,
TableScreen,
VerifyLoginScreen,
} from "./components/screens";
VerifyLoginScreen
} from "./components/screens/";
import { history } from "./index";
const AppRouter: React.FC = () => (
<ConnectedRouter history={history}>
<div className={`${Classes.DARK} App`}>
<Nav />
<main role="main">
<Route path="/instances" exact component={TableScreen} />
<Route path="/about" exact component={AboutScreen} />
<Route path="/admin/login" exact component={LoginScreen} />
<Route path="/admin/verify" exact component={VerifyLoginScreen} />
<Route path="/admin" exact component={AdminScreen} />
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
<GraphScreen />
</main>
<Route path="/instances" exact={true} component={TableScreen} />
<Route path="/about" exact={true} component={AboutScreen} />
<Route path="/admin/login" exact={true} component={LoginScreen} />
<Route path="/admin/verify" exact={true} component={VerifyLoginScreen} />
<Route path="/admin" exact={true} component={AdminScreen} />
{/* We always want the GraphScreen to be rendered (since un- and re-mounting it is expensive */}
<GraphScreen />
</div>
</ConnectedRouter>
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -1 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 170.08 170.08"><defs><style>.cls-1{fill:#04246e;}.cls-2{fill:#fff;}</style></defs><title>square-mark-white</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><rect class="cls-1" width="170.08" height="170.08"/><path class="cls-2" d="M127.34,58c-8.4,0-14.41,7.4-20.6,15.65-3.84-17.51-8.11-35-21.7-35s-17.86,17.5-21.71,35C57.15,65.37,51.13,58,42.74,58c-5.35,0-14.39,3.63-14.39,17.25L28.41,100c0,13.36,7.14,16.64,11.42,17.76h0c7.39,1.91,25.17,3.69,45.18,3.69s37.8-1.78,45.18-3.69h0c4.28-1.12,11.42-4.4,11.42-17.76l.06-24.79c0-13.62-9-17.25-14.39-17.25M85,46.33c8.15,0,11.24,16.06,15.57,35.33C95.86,87.5,90.83,92.21,85,92.21S74.21,87.5,69.47,81.66c4-18,7.17-35.33,15.57-35.33M36.13,100l-.06-24.79c0-8.6,4.67-9.53,6.67-9.53,5.92,0,12.28,9.88,18.36,17.83-4.29,18-8.83,29-19.56,26.72-2.78-.77-5.41-2.53-5.41-10.23m21,12.53c4.75-5.16,7.78-13.21,10.08-21.73,5,5.19,10.69,9.13,17.8,9.13S97.86,96,102.83,90.81c2.31,8.52,5.33,16.57,10.07,21.73-8.29.75-18.28,1.21-27.86,1.21s-19.59-.46-27.88-1.21M134,100c0,7.7-2.63,9.46-5.42,10.23-10.73,2.27-15.26-8.72-19.56-26.72,6.08-7.94,12.45-17.83,18.37-17.83,2,0,6.67.93,6.67,9.53Z"/></g></g></svg>
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 18.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 603.8 104.9" enable-background="new 0 0 603.8 104.9" xml:space="preserve">
<g>
<polygon fill="#4988A2" points="481.8,16.9 481.8,80.7 561.3,80.7 "/>
<polygon fill="#7A4198" points="538.3,80.7 602.1,7.4 602.1,80.7 "/>
<polygon fill="#478B60" points="481.9,80.7 513.5,1.1 561,80.7 "/>
<polygon fill="#135F66" points="488.3,80.7 515.3,43.9 547.7,69.9 538.3,80.7 "/>
<polygon fill="#2B7B82" points="488.3,80.7 481.9,80.7 501.1,32.4 515.3,43.9 "/>
<polygon fill="#194A7F" points="547.7,69.9 538.3,80.7 561.1,80.7 "/>
<polyline fill="#5E2B7C" points="551.7,65.3 547.7,69.9 561.1,80.7 551.7,65.3 "/>
<polygon fill="#7A2980" points="602.1,80.7 561.1,80.7 551.7,65.3 575.6,37.8 "/>
<polygon fill="#456630" points="515.3,43.9 528.6,26.4 551.7,65.3 547.7,69.9 "/>
<polygon fill="#E77A45" points="528.6,26.4 535.4,16.9 551.3,39.2 566.9,23.9 575.6,37.8 551.7,65.3 "/>
</g>
<g>
<path fill="#6F8087" d="M61.7,80.7H49.5l-6.9-22.6H19.1l-6.6,22.6H0.5L24,4h14.2L61.7,80.7z M40.6,49.2l-6-20 c-1.4-5-2.7-10.4-3.8-15.1h-0.2c-1.1,4.7-2.3,10.3-3.6,15l-6.1,20.1H40.6z"/>
<path fill="#6F8087" d="M82.3,33.9c3.8-6.2,9.8-9.6,17.5-9.6c12.4,0,21.8,11,21.8,28.2c0,20.3-11.7,29.4-23.4,29.4 c-6.6,0-12-3.1-14.7-7.6h-0.2v28.9H71.7V43.3c0-7.2-0.2-12.9-0.4-17.8h10.2l0.6,8.4H82.3z M83.3,58.4c0,9.9,6.8,14.1,12.5,14.1 c9,0,13.9-8.3,13.9-19.8c0-10.5-4.7-19.2-13.6-19.2c-6.9,0-12.8,6.5-12.8,14.6V58.4z"/>
<path fill="#6F8087" d="M143.7,33.9c3.8-6.2,9.8-9.6,17.5-9.6c12.4,0,21.8,11,21.8,28.2c0,20.3-11.7,29.4-23.4,29.4 c-6.6,0-12-3.1-14.7-7.6h-0.2v28.9h-11.6V43.3c0-7.2-0.2-12.9-0.4-17.8h10.2l0.6,8.4H143.7z M144.7,58.4c0,9.9,6.8,14.1,12.5,14.1 c9,0,13.9-8.3,13.9-19.8c0-10.5-4.7-19.2-13.6-19.2c-6.9,0-12.8,6.5-12.8,14.6V58.4z"/>
<g>
<path fill="#404D5C" d="M230.9,15.9c-2.5-1.4-7.3-3.4-13.6-3.4c-9.1,0-12.9,5.3-12.9,10.2c0,6.5,4.3,9.7,14,13.9 c12.3,5.4,18.2,12.1,18.2,22.7c0,12.8-9.5,22.5-26.8,22.5c-7.2,0-14.8-2.1-18.5-4.6l2.6-9.7C198,70,204.3,72,210.6,72 c9.1,0,14.2-4.7,14.2-11.6c0-6.5-3.9-10.5-13-14.3c-11.4-4.6-19.1-11.5-19.1-22c0-12.1,9.7-21.3,25-21.3c7.3,0,12.8,1.8,16.1,3.5 L230.9,15.9z"/>
<path fill="#404D5C" d="M254.3,17.7c-4,0-6.7-3-6.7-6.7c0-3.9,2.8-6.8,6.8-6.8c4,0,6.7,2.9,6.7,6.8 C261.1,14.7,258.5,17.7,254.3,17.7z M260.1,80.7h-11.6V25.4h11.6V80.7z"/>
<path fill="#404D5C" d="M320.4,25.5c-0.3,3.9-0.5,8.5-0.5,15.9v31.5c0,11-0.9,31.4-27.1,31.4c-6.4,0-13.1-1.4-17.4-4l2.6-9 c3.4,2,8.7,3.9,14.9,3.9c9,0,15.4-5,15.4-17.5v-5.3h-0.2c-2.8,4.7-8.2,8.1-15.3,8.1c-13,0-22-11.5-22-27 c0-18.7,11.3-29.2,23.4-29.2c8.2,0,12.8,4.2,15.1,8.6h0.2l0.5-7.4H320.4z M308.2,46.8c0-7.3-4.8-13.3-11.9-13.3 c-8,0-13.6,7.6-13.6,19.4c0,10.8,4.9,18.5,13.5,18.5c6,0,12-4.6,12-13.8V46.8z"/>
<path fill="#404D5C" d="M379.5,80.7h-11.6V48.3c0-7.8-2.6-14.4-10.5-14.4c-5.7,0-11.8,4.7-11.8,13.5v33.3h-11.6V41 c0-6.1-0.2-10.8-0.4-15.5h10.1l0.6,8.2h0.3c2.6-4.7,8.4-9.4,16.7-9.4c8.6,0,18.2,5.6,18.2,22.7V80.7z"/>
<path fill="#404D5C" d="M432,67.6c0,4.7,0.1,9.5,0.8,13.1h-10.5l-0.8-6.4h-0.3c-3.1,4.3-8.5,7.5-15.4,7.5 c-10.3,0-16.2-7.6-16.2-16.1c0-13.7,11.9-20.6,30.9-20.6c0-4.1,0-12.3-11.1-12.3c-4.9,0-9.9,1.5-13.4,3.8l-2.4-7.7 c3.9-2.5,10.4-4.6,17.8-4.6c16.2,0,20.8,10.7,20.8,22.5V67.6z M420.7,52.9c-9.1,0-19.6,1.7-19.6,11.5c0,6.1,3.8,8.8,8.1,8.8 c6.3,0,11.5-4.8,11.5-11.2V52.9z"/>
<path fill="#404D5C" d="M457.8,80.7h-11.1V7.4l11.1-6.3V80.7z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -11,7 +11,7 @@ const FloatingCardElement = styled(Card)`
z-index: 2;
`;
const FloatingCard: React.FC<ICardProps> = (props) => (
const FloatingCard: React.FC<ICardProps> = props => (
<FloatingCardRow>
<FloatingCardElement elevation={Elevation.ONE} {...props} />
</FloatingCardRow>

View File

@ -7,13 +7,13 @@ const StyledSwitch = styled(Switch)`
margin: 0;
`;
interface GraphHideEdgesButtonProps {
interface IGraphHideEdgesButtonProps {
isShowingEdges: boolean;
toggleEdges: () => void;
}
const GraphHideEdgesButton: React.FC<GraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => (
const GraphHideEdgesButton: React.FC<IGraphHideEdgesButtonProps> = ({ isShowingEdges, toggleEdges }) => (
<FloatingCard>
<StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} tabIndex={-1} />
<StyledSwitch checked={isShowingEdges} label="Show connections" onChange={toggleEdges} />
</FloatingCard>
);
export default GraphHideEdgesButton;

View File

@ -6,9 +6,9 @@ import React from "react";
import styled from "styled-components";
import { FloatingCard, InstanceType } from ".";
import { QUANTITATIVE_COLOR_SCHEME } from "../../constants";
import { ColorScheme } from "../../types";
import { IColorScheme } from "../../types";
const ColorSchemeSelect = Select.ofType<ColorScheme>();
const ColorSchemeSelect = Select.ofType<IColorScheme>();
const StyledLi = styled.li`
margin-top: 2px;
@ -27,12 +27,12 @@ const ColorBarContainer = styled.div`
flex-direction: column;
margin-right: 10px;
`;
interface ColorBarProps {
interface IColorBarProps {
color: string;
}
const ColorBar = styled.div<ColorBarProps>`
const ColorBar = styled.div<IColorBarProps>`
width: 10px;
background-color: ${(props) => props.color};
background-color: ${props => props.color};
flex: 1;
`;
const TextContainer = styled.div`
@ -41,46 +41,13 @@ const TextContainer = styled.div`
justify-content: space-between;
`;
const renderItem: ItemRenderer<ColorScheme> = (colorScheme, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map((v) => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color) => (
<ColorBar color={color} key={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
interface GraphKeyProps {
current?: ColorScheme;
colorSchemes: ColorScheme[];
interface IGraphKeyProps {
current?: IColorScheme;
colorSchemes: IColorScheme[];
ranges?: { [key: string]: [number, number] };
onItemSelect: (colorScheme?: ColorScheme) => void;
onItemSelect: (colorScheme?: IColorScheme) => void;
}
const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => {
const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => {
const unsetColorScheme = () => {
onItemSelect(undefined);
};
@ -107,9 +74,8 @@ const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onIt
text={(current && current.name) || "Select..."}
icon={IconNames.TINT}
rightIcon={IconNames.CARET_DOWN}
tabIndex={-1}
/>
<Button icon={IconNames.SMALL_CROSS} minimal onClick={unsetColorScheme} disabled={!current} tabIndex={-1} />
<Button icon={IconNames.SMALL_CROSS} minimal={true} onClick={unsetColorScheme} disabled={!current} />
</ColorSchemeSelect>
<br />
{!!current && !!key && (
@ -122,4 +88,38 @@ const GraphKey: React.FC<GraphKeyProps> = ({ current, colorSchemes, ranges, onIt
);
};
const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map(v => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color, idx) => (
<ColorBar color={color} key={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
export default GraphKey;

View File

@ -2,12 +2,12 @@ import { Button } from "@blueprintjs/core";
import * as React from "react";
import FloatingCard from "./FloatingCard";
interface GraphResetButtonProps {
interface IGraphResetButtonProps {
onClick: () => void;
}
const GraphResetButton: React.FC<GraphResetButtonProps> = ({ onClick }) => (
const GraphResetButton: React.FC<IGraphResetButtonProps> = ({ onClick }) => (
<FloatingCard>
<Button icon="compass" title="Reset graph view" onClick={onClick} tabIndex={-1} />
<Button icon="compass" title="Reset graph view" onClick={onClick} />
</FloatingCard>
);
export default GraphResetButton;

View File

@ -5,7 +5,7 @@ import { QUALITATIVE_COLOR_SCHEME } from "../../constants";
import { typeColorScheme } from "../../types";
import { getTypeDisplayString } from "../../util";
interface InstanceTypeProps {
interface IInstanceTypeProps {
type: string;
colorAfterName?: boolean;
}
@ -13,9 +13,9 @@ interface InstanceTypeProps {
* By default, renders the color followed by the name of the instance type.
* You can change this by passing `colorAfterName={true}`.
*/
const InstanceType: React.FC<InstanceTypeProps> = ({ type, colorAfterName }) => {
const InstanceType: React.FC<IInstanceTypeProps> = ({ type, colorAfterName }) => {
const idx = typeColorScheme.values.indexOf(type);
const name = ` ${getTypeDisplayString(type)}`;
const name = " " + getTypeDisplayString(type);
return (
<>
{!!colorAfterName && name}

View File

@ -11,19 +11,19 @@ const Backdrop = styled.div`
z-index: 3;
`;
interface ContainerProps {
interface IContainerProps {
fullWidth?: boolean;
}
const Container = styled.div<ContainerProps>`
max-width: ${(props) => (props.fullWidth ? "100%" : "800px")};
const Container = styled.div<IContainerProps>`
max-width: ${props => (props.fullWidth ? "100%" : "800px")};
margin: auto;
padding: 2em;
`;
interface PageProps {
interface IPageProps {
fullWidth?: boolean;
}
const Page: React.FC<PageProps> = ({ children, fullWidth }) => (
const Page: React.FC<IPageProps> = ({ children, fullWidth }) => (
<Backdrop>
<Container fullWidth={fullWidth}>{children}</Container>
</Backdrop>

View File

@ -10,9 +10,9 @@ import {
QUALITATIVE_COLOR_SCHEME,
QUANTITATIVE_COLOR_SCHEME,
SEARCH_RESULT_COLOR,
SELECTED_NODE_COLOR,
SELECTED_NODE_COLOR
} from "../../constants";
import { ColorScheme } from "../../types";
import { IColorScheme } from "../../types";
import { getBuckets, getTypeDisplayString } from "../../util";
const CytoscapeContainer = styled.div`
@ -21,8 +21,8 @@ const CytoscapeContainer = styled.div`
flex: 1;
`;
interface CytoscapeProps {
colorScheme?: ColorScheme;
interface ICytoscapeProps {
colorScheme?: IColorScheme;
currentNodeId: string | null;
elements: cytoscape.ElementsDefinition;
hoveringOver?: string;
@ -32,11 +32,10 @@ interface CytoscapeProps {
navigateToInstancePath?: (domain: string) => void;
navigateToRoot?: () => void;
}
class Cytoscape extends React.PureComponent<CytoscapeProps> {
class Cytoscape extends React.PureComponent<ICytoscapeProps> {
private cy?: cytoscape.Core;
public componentDidMount() {
// eslint-disable-next-line react/no-find-dom-node
const container = ReactDOM.findDOMNode(this);
this.cy = cytoscape({
autoungrabify: true,
@ -45,16 +44,16 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
hideEdgesOnViewport: true,
hideLabelsOnViewport: true,
layout: {
name: "preset",
name: "preset"
},
maxZoom: 2,
minZoom: 0.01,
pixelRatio: 1.0,
selectionType: "single",
selectionType: "single"
});
// Setup node tooltip on hover
this.cy.nodes().forEach((n) => {
this.cy.nodes().forEach(n => {
const tooltipContent = `${n.data("id")} (${getTypeDisplayString(n.data("type"))})`;
const ref = (n as any).popperRef();
const t = tippy(ref, {
@ -62,12 +61,12 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
animation: "fade",
content: tooltipContent,
duration: 100,
trigger: "manual",
trigger: "manual"
});
n.on("mouseover", () => {
n.on("mouseover", e => {
(t as Instance).show();
});
n.on("mouseout", () => {
n.on("mouseout", e => {
(t as Instance).hide();
});
});
@ -79,25 +78,25 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
.style({
"curve-style": "haystack", // fast edges
"line-color": DEFAULT_NODE_COLOR,
width: "mapData(weight, 0, 0.5, 1, 20)",
width: "mapData(weight, 0, 0.5, 1, 20)"
})
.selector("node[label]")
.style({
color: DEFAULT_NODE_COLOR,
"font-size": "mapData(size, 1, 6, 10, 100)",
"min-zoomed-font-size": 16,
"min-zoomed-font-size": 16
})
.selector(".hidden") // used to hide nodes not in the neighborhood of the selected, or to hide edges
.style({
display: "none",
display: "none"
})
.selector(".thickEdge") // when a node is selected, make edges thicker so you can actually see them
.style({
width: 2,
width: 2
});
this.resetNodeColorScheme(style); // this function also called `update()`
this.cy.nodes().on("select", (e) => {
this.cy.nodes().on("select", e => {
const instanceId = e.target.data("id");
if (instanceId && instanceId !== this.props.currentNodeId) {
if (this.props.navigateToInstancePath) {
@ -111,19 +110,21 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge");
// Then hide everything except neighborhood
this.cy!.nodes().diff(neighborhood).left.addClass("hidden");
this.cy!.nodes()
.diff(neighborhood)
.left.addClass("hidden");
neighborhood.connectedEdges().addClass("thickEdge");
});
});
this.cy.nodes().on("unselect", () => {
this.cy.nodes().on("unselect", e => {
this.cy!.batch(() => {
this.cy!.nodes().removeClass("hidden");
this.cy!.edges().removeClass("thickEdge");
});
});
this.cy.on("click", (e) => {
this.cy.on("click", e => {
// Clicking on the background should also deselect
const { target } = e;
const target = e.target;
if (!target || target === this.cy || target.isEdge()) {
if (this.props.navigateToRoot) {
// Go to the URL "/"
@ -135,7 +136,7 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
this.setNodeSelection();
}
public componentDidUpdate(prevProps: CytoscapeProps) {
public componentDidUpdate(prevProps: ICytoscapeProps) {
this.setNodeSelection(prevProps.currentNodeId);
if (prevProps.colorScheme !== this.props.colorScheme) {
this.updateColorScheme();
@ -173,12 +174,12 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
if (currentNodeId) {
this.cy.zoom({
level: 0.2,
position: this.cy.$id(currentNodeId).position(),
position: this.cy.$id(currentNodeId).position()
});
} else {
this.cy.zoom({
level: 0.2,
position: { x: 0, y: 0 },
position: { x: 0, y: 0 }
});
}
}
@ -220,7 +221,7 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
// quite good as it is, so...
height: "mapData(size, 1, 6, 20, 200)",
label: "data(id)",
width: "mapData(size, 1, 6, 20, 200)",
width: "mapData(size, 1, 6, 20, 200)"
});
this.setNodeSearchColorScheme(style);
@ -239,16 +240,16 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
"background-color": SEARCH_RESULT_COLOR,
"border-color": SEARCH_RESULT_COLOR,
"border-opacity": 0.7,
"border-width": 250,
"border-width": 250
})
.selector("node.hovered")
.style({
"border-color": HOVERED_NODE_COLOR,
"border-width": 1000,
"border-width": 1000
})
.selector("node:selected")
.style({
"background-color": SELECTED_NODE_COLOR,
"background-color": SELECTED_NODE_COLOR
})
.update();
};
@ -262,11 +263,10 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
if (!colorScheme) {
this.resetNodeColorScheme();
return;
}
if (colorScheme.type === "qualitative") {
} else if (colorScheme.type === "qualitative") {
colorScheme.values.forEach((v, idx) => {
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
"background-color": QUALITATIVE_COLOR_SCHEME[idx],
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
});
});
} else if (colorScheme.type === "quantitative") {
@ -284,7 +284,7 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
style = style.selector(selector).style({
"background-color": color,
"background-color": color
});
});
}
@ -304,10 +304,10 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
}
const { hoveringOver } = this.props;
if (prevHoveredId) {
if (!!prevHoveredId) {
this.cy.$id(prevHoveredId).removeClass("hovered");
}
if (hoveringOver) {
if (!!hoveringOver) {
this.cy.$id(hoveringOver).addClass("hovered");
}
};
@ -322,7 +322,7 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
this.cy!.nodes().removeClass("searchResult");
if (!!searchResultIds && searchResultIds.length > 0) {
const currentResultSelector = searchResultIds.map((id) => `node[id = "${id}"]`).join(", ");
const currentResultSelector = searchResultIds.map(id => `node[id = "${id}"]`).join(", ");
this.cy!.$(currentResultSelector).addClass("searchResult");
}
});
@ -344,11 +344,11 @@ class Cytoscape extends React.PureComponent<CytoscapeProps> {
/* Helper function to remove edges if source or target node is missing */
private cleanElements = (elements: cytoscape.ElementsDefinition): cytoscape.ElementsDefinition => {
const domains = new Set(elements.nodes.map((n) => n.data.id));
const edges = elements.edges.filter((e) => domains.has(e.data.source) && domains.has(e.data.target));
const domains = new Set(elements.nodes.map(n => n.data.id));
const edges = elements.edges.filter(e => domains.has(e.data.source) && domains.has(e.data.target));
return {
edges,
nodes: elements.nodes,
nodes: elements.nodes
};
};
}

View File

@ -2,11 +2,11 @@ import { NonIdealState } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import * as React from "react";
interface ErrorStateProps {
interface IErrorStateProps {
description?: string;
}
const ErrorState: React.FC<ErrorStateProps> = ({ description }) => (
<NonIdealState icon={IconNames.ERROR} title="Something went wrong." description={description} />
const ErrorState: React.FC<IErrorStateProps> = ({ description }) => (
<NonIdealState icon={IconNames.ERROR} title={"Something went wrong."} description={description} />
);
export default ErrorState;

View File

@ -1,6 +1,6 @@
import React from "react";
import styled from "styled-components";
import { ColorScheme } from "../../types";
import { IColorScheme } from "../../types";
import { GraphHideEdgesButton, GraphKey, GraphResetButton } from "../atoms";
const GraphToolsContainer = styled.div`
@ -11,33 +11,35 @@ const GraphToolsContainer = styled.div`
flex-direction: column;
`;
interface GraphToolsProps {
currentColorScheme?: ColorScheme;
colorSchemes: ColorScheme[];
interface IGraphToolsProps {
currentColorScheme?: IColorScheme;
colorSchemes: IColorScheme[];
isShowingEdges: boolean;
ranges?: { [key: string]: [number, number] };
onColorSchemeSelect: (colorScheme?: ColorScheme) => void;
onColorSchemeSelect: (colorScheme?: IColorScheme) => void;
onResetButtonClick: () => void;
toggleEdges: () => void;
}
const GraphTools: React.FC<GraphToolsProps> = ({
const GraphTools: React.FC<IGraphToolsProps> = ({
currentColorScheme,
colorSchemes,
isShowingEdges,
ranges,
onColorSchemeSelect,
onResetButtonClick,
toggleEdges,
}) => (
<GraphToolsContainer>
<GraphResetButton onClick={onResetButtonClick} />
<GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} />
<GraphKey
current={currentColorScheme}
colorSchemes={colorSchemes}
onItemSelect={onColorSchemeSelect}
ranges={ranges}
/>
</GraphToolsContainer>
);
toggleEdges
}) => {
return (
<GraphToolsContainer>
<GraphResetButton onClick={onResetButtonClick} />
<GraphHideEdgesButton isShowingEdges={isShowingEdges} toggleEdges={toggleEdges} />
<GraphKey
current={currentColorScheme}
colorSchemes={colorSchemes}
onItemSelect={onColorSchemeSelect}
ranges={ranges}
/>
</GraphToolsContainer>
);
};
export default GraphTools;

View File

@ -4,7 +4,7 @@ import * as numeral from "numeral";
import React from "react";
import sanitize from "sanitize-html";
import styled from "styled-components";
import { SearchResultInstance } from "../../redux/types";
import { ISearchResultInstance } from "../../redux/types";
import { InstanceType } from "../atoms";
const StyledCard = styled(Card)`
@ -32,18 +32,18 @@ const StyledUserCount = styled.div`
const StyledDescription = styled.div`
margin-top: 10px;
`;
interface SearchResultProps {
result: SearchResultInstance;
interface ISearchResultProps {
result: ISearchResultInstance;
onClick: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
const SearchResult: React.FC<SearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
let shortenedDescription;
if (result.description) {
shortenedDescription = result.description && sanitize(result.description);
if (shortenedDescription.length > 100) {
shortenedDescription = `${shortenedDescription.substring(0, 100)}...`;
shortenedDescription = shortenedDescription.substring(0, 100) + "...";
}
}
@ -59,7 +59,7 @@ const SearchResult: React.FC<SearchResultProps> = ({ result, onClick, onMouseEnt
return (
<StyledCard
elevation={Elevation.ONE}
interactive
interactive={true}
key={result.name}
onClick={onClick}
onMouseEnter={onMouseEnter}

View File

@ -1,12 +1,12 @@
import { Classes, H3 } from "@blueprintjs/core";
import React from "react";
import { Link } from "react-router-dom";
import { FederationRestrictions } from "../../redux/types";
import { IFederationRestrictions } from "../../redux/types";
const maybeGetList = (domains?: string[]) =>
domains && (
<ul>
{domains.sort().map((domain) => (
{domains.sort().map(domain => (
<li key={domain}>
<Link to={`/instance/${domain}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
{domain}
@ -16,10 +16,10 @@ const maybeGetList = (domains?: string[]) =>
</ul>
);
interface FederationTabProps {
restrictions?: FederationRestrictions;
interface IFederationTabProps {
restrictions?: IFederationRestrictions;
}
const FederationTab: React.FC<FederationTabProps> = ({ restrictions }) => {
const FederationTab: React.FC<IFederationTabProps> = ({ restrictions }) => {
if (!restrictions) {
return null;
}

View File

@ -6,33 +6,33 @@ import { push } from "connected-react-router";
import { Dispatch } from "redux";
import styled from "styled-components";
import { fetchGraph } from "../../redux/actions";
import { AppState, GraphResponse } from "../../redux/types";
import { colorSchemes, ColorScheme } from "../../types";
import { IAppState, IGraphResponse } from "../../redux/types";
import { colorSchemes, IColorScheme } from "../../types";
import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, GraphTools } from "../molecules";
import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
const GraphDiv = styled.div`
flex: 2;
`;
interface GraphProps {
interface IGraphProps {
currentInstanceName: string | null;
fetchGraph: () => void;
graphResponse?: GraphResponse;
graphResponse?: IGraphResponse;
graphLoadError: boolean;
hoveringOverResult?: string;
isLoadingGraph: boolean;
searchResultDomains: string[];
navigate: (path: string) => void;
}
interface GraphState {
colorScheme?: ColorScheme;
interface IGraphState {
colorScheme?: IColorScheme;
isShowingEdges: boolean;
}
class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
private cytoscapeComponent: React.RefObject<Cytoscape>;
public constructor(props: GraphProps) {
public constructor(props: IGraphProps) {
super(props);
this.cytoscapeComponent = React.createRef();
this.state = { colorScheme: undefined, isShowingEdges: true };
@ -76,7 +76,7 @@ class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
);
}
return <GraphDiv aria-hidden>{content}</GraphDiv>;
return <GraphDiv>{content}</GraphDiv>;
}
private loadGraph = () => {
@ -95,7 +95,7 @@ class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
this.setState({ isShowingEdges: !this.state.isShowingEdges });
};
private setColorScheme = (colorScheme?: ColorScheme) => {
private setColorScheme = (colorScheme?: IColorScheme) => {
this.setState({ colorScheme });
};
@ -107,7 +107,7 @@ class GraphImpl extends React.PureComponent<GraphProps, GraphState> {
this.props.navigate("/");
};
}
const mapStateToProps = (state: AppState) => {
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
@ -115,12 +115,15 @@ const mapStateToProps = (state: AppState) => {
graphResponse: state.data.graphResponse,
hoveringOverResult: state.search.hoveringOverResult,
isLoadingGraph: state.data.isLoadingGraph,
searchResultDomains: state.search.results.map((r) => r.name),
searchResultDomains: state.search.results.map(r => r.name)
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
fetchGraph: () => dispatch(fetchGraph() as any),
navigate: (path: string) => dispatch(push(path)),
navigate: (path: string) => dispatch(push(path))
});
const Graph = connect(mapStateToProps, mapDispatchToProps)(GraphImpl);
const Graph = connect(
mapStateToProps,
mapDispatchToProps
)(GraphImpl);
export default Graph;

View File

@ -8,7 +8,7 @@ import { connect } from "react-redux";
import { Dispatch } from "redux";
import styled from "styled-components";
import { loadInstanceList } from "../../redux/actions";
import { AppState, InstanceListResponse, InstanceSort, SortField, InstanceDetails } from "../../redux/types";
import { IAppState, IInstanceListResponse, IInstanceSort, SortField } from "../../redux/types";
import { InstanceType } from "../atoms";
import { ErrorState } from "../molecules";
@ -41,15 +41,15 @@ const InsularityColumn = styled.th`
width: 15%;
`;
interface InstanceTableProps {
interface IInstanceTableProps {
loadError: boolean;
instancesResponse?: InstanceListResponse;
instanceListSort: InstanceSort;
instancesResponse?: IInstanceListResponse;
instanceListSort: IInstanceSort;
isLoading: boolean;
loadInstanceList: (page?: number, sort?: InstanceSort) => void;
loadInstanceList: (page?: number, sort?: IInstanceSort) => void;
navigate: (path: string) => void;
}
class InstanceTable extends React.PureComponent<InstanceTableProps> {
class InstanceTable extends React.PureComponent<IInstanceTableProps> {
public componentDidMount() {
const { isLoading, instancesResponse, loadError } = this.props;
if (!isLoading && !instancesResponse && !loadError) {
@ -61,23 +61,22 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
const { isLoading, instancesResponse, loadError } = this.props;
if (loadError) {
return <ErrorState />;
}
if (isLoading || !instancesResponse) {
} else if (isLoading || !instancesResponse) {
return <NonIdealState icon={<Spinner />} />;
}
const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse;
const { instances, pageNumber: currentPage, totalPages, totalEntries, pageSize } = instancesResponse!;
const pagesToDisplay = this.getPagesToDisplay(totalPages, currentPage);
return (
<>
<StyledTable striped bordered interactive>
<StyledTable striped={true} bordered={true} interactive={true}>
<thead>
<tr>
<InstanceColumn>
Instance
<Button
minimal
minimal={true}
icon={this.getSortIcon("domain")}
onClick={this.sortByFactory("domain")}
intent={this.getSortIntent("domain")}
@ -88,7 +87,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
<UserCountColumn>
Users
<Button
minimal
minimal={true}
icon={this.getSortIcon("userCount")}
onClick={this.sortByFactory("userCount")}
intent={this.getSortIntent("userCount")}
@ -97,7 +96,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
<StatusCountColumn>
Statuses
<Button
minimal
minimal={true}
icon={this.getSortIcon("statusCount")}
onClick={this.sortByFactory("statusCount")}
intent={this.getSortIntent("statusCount")}
@ -106,7 +105,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
<InsularityColumn>
Insularity
<Button
minimal
minimal={true}
icon={this.getSortIcon("insularity")}
onClick={this.sortByFactory("insularity")}
intent={this.getSortIntent("insularity")}
@ -115,7 +114,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
</tr>
</thead>
<tbody>
{instances.map((i: InstanceDetails) => (
{instances.map(i => (
<tr key={i.name} onClick={this.goToInstanceFactory(i.name)}>
<td>{i.name}</td>
<td>{i.type && <InstanceType type={i.type} />}</td>
@ -135,7 +134,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
</p>
<ButtonGroup>
{zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage]) => {
{zip(pagesToDisplay, pagesToDisplay.slice(1)).map(([page, nextPage], idx) => {
if (page === undefined) {
return null;
}
@ -143,7 +142,7 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
const isEndOfSection = nextPage !== undefined && page + 1 !== nextPage && page !== totalPages;
return (
<React.Fragment key={page}>
<>
<Button
key={page}
onClick={this.loadPageFactory(page)}
@ -153,11 +152,11 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
{page}
</Button>
{isEndOfSection && (
<Button disabled key="...">
...
<Button disabled={true} key={"..."}>
{"..."}
</Button>
)}
</React.Fragment>
</>
);
})}
</ButtonGroup>
@ -188,19 +187,20 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
const { instanceListSort } = this.props;
if (instanceListSort.field !== field) {
return IconNames.SORT;
}
if (instanceListSort.direction === "asc") {
} else if (instanceListSort.direction === "asc") {
return IconNames.SORT_ASC;
} else {
return IconNames.SORT_DESC;
}
return IconNames.SORT_DESC;
};
private getSortIntent = (field: SortField) => {
const { instanceListSort } = this.props;
if (instanceListSort.field === field) {
return Intent.PRIMARY;
} else {
return Intent.NONE;
}
return Intent.NONE;
};
private getPagesToDisplay = (totalPages: number, currentPage: number) => {
@ -214,19 +214,24 @@ class InstanceTable extends React.PureComponent<InstanceTableProps> {
const pagesToDisplay = firstPages.concat(surroundingPages).concat(lastPages);
return sortedUniq(sortBy(pagesToDisplay, (n) => n));
return sortedUniq(sortBy(pagesToDisplay, n => n));
};
}
const mapStateToProps = (state: AppState) => ({
instanceListSort: state.data.instanceListSort,
instancesResponse: state.data.instancesResponse,
isLoading: state.data.isLoadingInstanceList,
loadError: state.data.instanceListLoadError,
});
const mapStateToProps = (state: IAppState) => {
return {
instanceListSort: state.data.instanceListSort,
instancesResponse: state.data.instancesResponse,
isLoading: state.data.isLoadingInstanceList,
loadError: state.data.instanceListLoadError
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
loadInstanceList: (page?: number, sort?: InstanceSort) => dispatch(loadInstanceList(page, sort) as any),
navigate: (path: string) => dispatch(push(path)),
loadInstanceList: (page?: number, sort?: IInstanceSort) => dispatch(loadInstanceList(page, sort) as any),
navigate: (path: string) => dispatch(push(path))
});
export default connect(mapStateToProps, mapDispatchToProps)(InstanceTable);
export default connect(
mapStateToProps,
mapDispatchToProps
)(InstanceTable);

View File

@ -1,19 +1,21 @@
import * as React from "react";
import { Alignment, Navbar, Classes } from "@blueprintjs/core";
import { Alignment, Navbar } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { Classes } from "@blueprintjs/core";
import { match, NavLink } from "react-router-dom";
import { InstanceDomainPath } from "../../constants";
import { IInstanceDomainPath } from "../../constants";
interface NavState {
interface INavState {
aboutIsOpen: boolean;
}
const graphIsActive = (currMatch: match<InstanceDomainPath>, location: Location) =>
location.pathname === "/" || location.pathname.startsWith("/instance/");
const graphIsActive = (currMatch: match<IInstanceDomainPath>, location: Location) => {
return location.pathname === "/" || location.pathname.startsWith("/instance/");
};
class Nav extends React.Component<{}, NavState> {
class Nav extends React.Component<{}, INavState> {
constructor(props: any) {
super(props);
this.state = { aboutIsOpen: false };
@ -21,46 +23,44 @@ class Nav extends React.Component<{}, NavState> {
public render() {
return (
<nav role="navigation">
<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}`}
isActive={graphIsActive as any}
activeClassName="current-navbar-item"
>
Home
</NavLink>
<NavLink
to="/instances"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.TH}`}
activeClassName="current-navbar-item"
>
Instances
</NavLink>
<NavLink
to="/about"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
activeClassName="current-navbar-item"
exact={true}
>
About
</NavLink>
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<NavLink
to="/admin"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.COG}`}
activeClassName="current-navbar-item"
>
Administration
</NavLink>
</Navbar.Group>
</Navbar>
</nav>
<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={graphIsActive as any}
>
Home
</NavLink>
<NavLink
to="/instances"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.TH}`}
activeClassName={Classes.INTENT_PRIMARY}
>
Instances
</NavLink>
<NavLink
to="/about"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.INFO_SIGN}`}
activeClassName={Classes.INTENT_PRIMARY}
exact={true}
>
About
</NavLink>
</Navbar.Group>
<Navbar.Group align={Alignment.RIGHT}>
<NavLink
to="/admin"
className={`${Classes.BUTTON} ${Classes.MINIMAL} bp3-icon-${IconNames.COG}`}
activeClassName={Classes.INTENT_PRIMARY}
>
Administration
</NavLink>
</Navbar.Group>
</Navbar>
);
}
}

View File

@ -3,7 +3,7 @@ import { IconNames } from "@blueprintjs/icons";
import React, { MouseEvent } from "react";
import styled from "styled-components";
import { INSTANCE_TYPES } from "../../constants";
import { getSearchFilterDisplayValue, SearchFilter } from "../../searchFilters";
import { getSearchFilterDisplayValue, ISearchFilter } from "../../searchFilters";
import { getTypeDisplayString } from "../../util";
const SearchFilterContainer = styled.div`
@ -19,30 +19,30 @@ const StyledTag = styled(Tag)`
margin-left: 5px;
`;
interface SearchFiltersProps {
selectedFilters: SearchFilter[];
selectFilter: (filter: SearchFilter) => void;
interface ISearchFiltersProps {
selectedFilters: ISearchFilter[];
selectFilter: (filter: ISearchFilter) => void;
deselectFilter: (e: MouseEvent<HTMLButtonElement>, props: ITagProps) => void;
}
const SearchFilters: React.FC<SearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => {
const hasInstanceTypeFilter = selectedFilters.some((sf) => sf.field === "type");
const SearchFilters: React.FC<ISearchFiltersProps> = ({ selectedFilters, selectFilter, deselectFilter }) => {
const hasInstanceTypeFilter = selectedFilters.some(sf => sf.field === "type");
const handleSelectInstanceType = (e: MouseEvent<HTMLElement>) => {
const field = "type";
const relation = "eq";
const value = e.currentTarget.innerText.toLowerCase().replace(" ", "");
const filter: SearchFilter = {
const filter: ISearchFilter = {
displayValue: getSearchFilterDisplayValue(field, relation, value),
field,
relation,
value,
value
};
selectFilter(filter);
};
const renderMenu = () => (
<Menu>
<MenuItem icon={IconNames.SYMBOL_CIRCLE} text="Instance type" disabled={hasInstanceTypeFilter}>
{INSTANCE_TYPES.map((t) => (
{INSTANCE_TYPES.map(t => (
<MenuItem key={t} text={getTypeDisplayString(t)} onClick={handleSelectInstanceType} />
))}
</MenuItem>
@ -51,15 +51,15 @@ const SearchFilters: React.FC<SearchFiltersProps> = ({ selectedFilters, selectFi
return (
<SearchFilterContainer>
<TagContainer>
{selectedFilters.map((filter) => (
<StyledTag key={filter.displayValue} minimal onRemove={deselectFilter}>
{selectedFilters.map(filter => (
<StyledTag key={filter.displayValue} minimal={true} onRemove={deselectFilter}>
{filter.displayValue}
</StyledTag>
))}
</TagContainer>
<Popover autoFocus={false} content={renderMenu()} position={Position.BOTTOM}>
<Button minimal icon={IconNames.FILTER}>
Add filter
<Button minimal={true} icon={IconNames.FILTER}>
{"Add filter"}
</Button>
</Popover>
</SearchFilterContainer>

View File

@ -17,9 +17,11 @@ const StyledCard = styled(Card)`
display: flex;
flex-direction: column;
`;
const SidebarContainer: React.FC = ({ children }) => (
<RightDiv>
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
</RightDiv>
);
const SidebarContainer: React.FC = ({ children }) => {
return (
<RightDiv>
<StyledCard elevation={Elevation.TWO}>{children}</StyledCard>
</RightDiv>
);
};
export default SidebarContainer;

View File

@ -1,14 +1,16 @@
import { Classes, Code, H1, H2, H3 } from "@blueprintjs/core";
import { Classes, Code, H1, H2, H4 } from "@blueprintjs/core";
import * as React from "react";
import styled from "styled-components";
import * as nlnetLogo from "../../assets/nlnet.png";
import { Page } from "../atoms";
// import appsignalLogo from "../../assets/appsignal.svg";
import gitlabLogo from "../../assets/gitlab.png";
import nlnetLogo from "../../assets/nlnet.png";
import { Page } from "../atoms/";
const SponsorContainer = styled.div`
margin-bottom: 20px;
`;
const Sponsor = styled.div`
margin: 10px 40px 10px 0;
margin: 10px;
display: inline-block;
`;
@ -25,41 +27,36 @@ const AboutScreen: React.FC = () => (
<p>
You can follow the project on{" "}
<a href="https://social.inex.rocks/@indexCommunity" target="_blank" rel="noopener noreferrer">
<a href="https://mastodon.social/@fediversespace" target="_blank" rel="noopener noreferrer">
Mastodon
</a>
.
</p>
<p>
This is a fork of the original fediverse.space by{" "}
<a href="https://www.btao.org" target="_blank" rel="noopener noreferrer">
Tao Bojlén
</a>
.
</p>
<br />
<H2>FAQ</H2>
<H3>Why can&apos;t I see details about my instance?</H3>
<H4>Why can't I see details about my instance?</H4>
<p className={Classes.RUNNING_TEXT}>
fediverse.space only supports servers using the Mastodon API, the Misskey API, the GNU Social API, or Nodeinfo.
Instances with 10 or fewer users won&apos;t be crawled -- it&apos;s a tool for understanding communities, not
individuals.
Instances with 10 or fewer users won't be scraped -- it's a tool for understanding communities, not individuals.
</p>
<H3>
<H4>
When is <Code>$OTHER_FEDIVERSE_SERVER</Code> going to be added?
</H3>
</H4>
<p className={Classes.RUNNING_TEXT}>
We are in the early forking-out phase, and don&apos;t provide any support yet. Check back later.
Check out{" "}
<a href="https://gitlab.com/taobojlen/fediverse.space/issues/24" target="_blank" rel="noopener noreferrer">
this GitLab issue
</a>
.
</p>
<H3>How do I add my personal instance?</H3>
<H4>How do I add my personal instance?</H4>
<p className={Classes.RUNNING_TEXT}>Click on the Administration link in the top right to opt-in.</p>
<H3>How do you calculate the strength of relationships between instances?</H3>
<H4>How do you calculate the strength of relationships between instances?</H4>
<p className={Classes.RUNNING_TEXT}>
fediverse.space looks at public statuses from within the last month on the public timeline of each instance. It
calculates at the ratio of
@ -67,23 +64,6 @@ const AboutScreen: React.FC = () => (
to reflect that smaller instances can play a large role in a community.
</p>
<H3>Who maintains this instance?</H3>
<p className={Classes.RUNNING_TEXT}>
index.community (fork domain) is an{" "}
<a href="https://innereq.org" target="_blank" rel="noopener noreferrer">
InnerEq.org
</a>{" "}
project maintained by Inex Code. You can help cover the cost of the server by becoming a{" "}
<a href="https://www.patreon.com/inexcode" target="_blank" rel="noopener noreferrer">
patron
</a>{" "}
or donating some{" "}
<a href="https://inex.rocks/#donate" target="_blank" rel="noopener noreferrer">
crypto
</a>
.
</p>
<br />
<H2>Special thanks</H2>
@ -94,6 +74,16 @@ const AboutScreen: React.FC = () => (
</a>
</Sponsor>
<br />
{/* <Sponsor>
<a href="https://appsignal.com" target="_blank" rel="noopener noreferrer">
<img src={appsignalLogo} alt="Appsignal logo" height={40} />
</a>
</Sponsor> */}
<Sponsor>
<a href="https://gitlab.com" target="_blank" rel="noopener noreferrer">
<img src={gitlabLogo} alt="GitLab logo" height={40} />
</a>
</Sponsor>
</SponsorContainer>
<p className={Classes.RUNNING_TEXT}>Inspiration for this site comes from several places:</p>
@ -124,7 +114,7 @@ const AboutScreen: React.FC = () => (
</ul>
<p>
The source code for fediverse.space is available on{" "}
<a href="https://gitlab.com/fediverse.space/fediverse.space" target="_blank" rel="noopener noreferrer">
<a href="https://gitlab.com/taobojlen/fediverse.space" target="_blank" rel="noopener noreferrer">
GitLab
</a>
; issues and pull requests are welcome!

View File

@ -17,7 +17,7 @@ const ButtonContainer = styled.div`
justify-content: space-between;
`;
interface AdminSettings {
interface IAdminSettings {
domain: string;
optIn: boolean;
optOut: boolean;
@ -25,26 +25,26 @@ interface AdminSettings {
statusCount: number;
}
interface AdminScreenProps {
interface IAdminScreenProps {
navigate: (path: string) => void;
}
interface AdminScreenState {
settings?: AdminSettings;
interface IAdminScreenState {
settings?: IAdminSettings;
isUpdating: boolean;
}
class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState> {
class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenState> {
private authToken = getAuthToken();
public constructor(props: AdminScreenProps) {
public constructor(props: IAdminScreenProps) {
super(props);
this.state = { isUpdating: false };
}
public componentDidMount() {
// Load instance settings from server
if (this.authToken) {
getFromApi(`admin`, this.authToken)
.then((response) => {
if (!!this.authToken) {
getFromApi(`admin`, this.authToken!)
.then(response => {
this.setState({ settings: response });
})
.catch(() => {
@ -52,7 +52,7 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
icon: IconNames.ERROR,
intent: Intent.DANGER,
message: "Failed to load settings.",
timeout: 0,
timeout: 0
});
unsetAuthToken();
});
@ -78,7 +78,7 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
<Switch
id="opt-in-switch"
checked={!!settings.optIn}
large
large={true}
label="Opt in"
disabled={!!isUpdating}
onChange={this.updateOptIn}
@ -89,7 +89,7 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
<Switch
id="opt-out-switch"
checked={!!settings.optOut}
large
large={true}
label="Opt out"
disabled={!!isUpdating}
onChange={this.updateOptOut}
@ -116,9 +116,9 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
}
private updateOptIn = (e: React.FormEvent<HTMLInputElement>) => {
const settings = this.state.settings as AdminSettings;
const settings = this.state.settings as IAdminSettings;
const optIn = e.currentTarget.checked;
let { optOut } = settings;
let optOut = settings.optOut;
if (optIn) {
optOut = false;
}
@ -126,9 +126,9 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
};
private updateOptOut = (e: React.FormEvent<HTMLInputElement>) => {
const settings = this.state.settings as AdminSettings;
const settings = this.state.settings as IAdminSettings;
const optOut = e.currentTarget.checked;
let { optIn } = settings;
let optIn = settings.optIn;
if (optOut) {
optIn = false;
}
@ -140,15 +140,15 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
this.setState({ isUpdating: true });
const body = {
optIn: this.state.settings!.optIn,
optOut: this.state.settings!.optOut,
optOut: this.state.settings!.optOut
};
postToApi(`admin`, body, this.authToken!)
.then((response) => {
.then(response => {
this.setState({ settings: response, isUpdating: false });
AppToaster.show({
icon: IconNames.TICK,
intent: Intent.SUCCESS,
message: "Successfully updated settings.",
message: "Successfully updated settings."
});
})
.catch(() => {
@ -161,13 +161,16 @@ class AdminScreen extends React.PureComponent<AdminScreenProps, AdminScreenState
unsetAuthToken();
AppToaster.show({
icon: IconNames.LOG_OUT,
message: "Logged out.",
message: "Logged out."
});
this.props.navigate("/admin/login");
};
}
const mapDispatchToProps = (dispatch: Dispatch) => ({
navigate: (path: string) => dispatch(push(path)),
navigate: (path: string) => dispatch(push(path))
});
export default connect(undefined, mapDispatchToProps)(AdminScreen);
export default connect(
undefined,
mapDispatchToProps
)(AdminScreen);

View File

@ -7,9 +7,9 @@ import { Route, RouteComponentProps, Switch, withRouter } from "react-router";
import { InstanceScreen, SearchScreen } from ".";
import { INSTANCE_DOMAIN_PATH } from "../../constants";
import { loadInstance } from "../../redux/actions";
import { AppState } from "../../redux/types";
import { IAppState } from "../../redux/types";
import { domainMatchSelector, isSmallScreen } from "../../util";
import { Graph, SidebarContainer } from "../organisms";
import { Graph, SidebarContainer } from "../organisms/";
const GraphContainer = styled.div`
display: flex;
@ -24,13 +24,13 @@ const FullDiv = styled.div`
right: 0;
`;
interface GraphScreenProps extends RouteComponentProps {
interface IGraphScreenProps extends RouteComponentProps {
currentInstanceName: string | null;
pathname: string;
graphLoadError: boolean;
loadInstance: (domain: string | null) => void;
}
interface GraphScreenState {
interface IGraphScreenState {
hasBeenViewed: boolean;
}
/**
@ -41,8 +41,8 @@ interface GraphScreenState {
* However, if it's not the first page viewed (e.g. if someone opens directly on /about) we don't want to render the
* graph since it slows down everything else!
*/
class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState> {
public constructor(props: GraphScreenProps) {
class GraphScreenImpl extends React.Component<IGraphScreenProps, IGraphScreenState> {
public constructor(props: IGraphScreenProps) {
super(props);
this.state = { hasBeenViewed: false };
}
@ -56,7 +56,7 @@ class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState
this.loadCurrentInstance();
}
public componentDidUpdate(prevProps: GraphScreenProps) {
public componentDidUpdate(prevProps: IGraphScreenProps) {
this.setHasBeenViewed();
this.loadCurrentInstance(prevProps.currentInstanceName);
}
@ -72,7 +72,7 @@ class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState
}
};
private renderRoutes = () => (
private renderRoutes = ({ location }: RouteComponentProps) => (
<FullDiv>
<GraphContainer>
{/* Smaller screens never load the entire graph. Instead, `InstanceScreen` shows only the neighborhood. */}
@ -80,7 +80,7 @@ class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState
<SidebarContainer>
<Switch>
<Route path={INSTANCE_DOMAIN_PATH} component={InstanceScreen} />
<Route exact path="/" component={SearchScreen} />
<Route exact={true} path="/" component={SearchScreen} />
</Switch>
</SidebarContainer>
</GraphContainer>
@ -94,16 +94,19 @@ class GraphScreenImpl extends React.Component<GraphScreenProps, GraphScreenState
};
}
const mapStateToProps = (state: AppState) => {
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
graphLoadError: state.data.graphLoadError,
pathname: state.router.location.pathname,
pathname: state.router.location.pathname
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any),
loadInstance: (domain: string | null) => dispatch(loadInstance(domain) as any)
});
const GraphScreen = connect(mapStateToProps, mapDispatchToProps)(GraphScreenImpl);
const GraphScreen = connect(
mapStateToProps,
mapDispatchToProps
)(GraphScreenImpl);
export default withRouter(GraphScreen);

View File

@ -20,7 +20,7 @@ import {
Spinner,
Tab,
Tabs,
Tooltip,
Tooltip
} from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
@ -28,10 +28,10 @@ import { push } from "connected-react-router";
import { Link } from "react-router-dom";
import { Dispatch } from "redux";
import styled from "styled-components";
import { AppState, Graph, GraphResponse, InstanceDetails, Peer } from "../../redux/types";
import { IAppState, IGraph, IGraphResponse, IInstanceDetails } from "../../redux/types";
import { domainMatchSelector, getFromApi, isSmallScreen } from "../../util";
import { InstanceType } from "../atoms";
import { Cytoscape, ErrorState } from "../molecules";
import { Cytoscape, ErrorState } from "../molecules/";
import { FederationTab } from "../organisms";
const InstanceScreenContainer = styled.div`
@ -82,25 +82,25 @@ const StyledGraphContainer = styled.div`
flex-direction: column;
margin-bottom: 10px;
`;
interface InstanceScreenProps {
graph?: Graph;
interface IInstanceScreenProps {
graph?: IGraph;
instanceName: string | null;
instanceLoadError: boolean;
instanceDetails: InstanceDetails | null;
instanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean;
navigateToRoot: () => void;
navigateToInstance: (domain: string) => void;
}
interface InstanceScreenState {
interface IInstanceScreenState {
neighbors?: string[];
isProcessingNeighbors: boolean;
// Local (neighborhood) graph. Used only on small screens (mobile devices).
isLoadingLocalGraph: boolean;
localGraph?: Graph;
localGraph?: IGraph;
localGraphLoadError?: boolean;
}
class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, InstanceScreenState> {
public constructor(props: InstanceScreenProps) {
class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInstanceScreenState> {
public constructor(props: IInstanceScreenProps) {
super(props);
this.state = { isProcessingNeighbors: false, isLoadingLocalGraph: false, localGraphLoadError: false };
}
@ -116,9 +116,9 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
!this.props.instanceDetails.status
) {
content = <ErrorState />;
} else if (this.props.instanceDetails.status.toLowerCase().includes("personal instance")) {
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("personal instance") > -1) {
content = this.renderPersonalInstanceErrorState();
} else if (this.props.instanceDetails.status.toLowerCase().includes("robots.txt")) {
} else if (this.props.instanceDetails.status.toLowerCase().indexOf("robots.txt") > -1) {
content = this.renderRobotsTxtState();
} else if (this.props.instanceDetails.status !== "success") {
content = this.renderMissingDataState();
@ -130,7 +130,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
<HeadingContainer>
<StyledHeadingH2>{this.props.instanceName}</StyledHeadingH2>
<StyledHeadingTooltip content="Open link in new tab" position={Position.TOP} className={Classes.DARK}>
<AnchorButton icon={IconNames.LINK} minimal onClick={this.openInstanceLink} />
<AnchorButton icon={IconNames.LINK} minimal={true} onClick={this.openInstanceLink} />
</StyledHeadingTooltip>
<StyledCloseButton icon={IconNames.CROSS} onClick={this.props.navigateToRoot} />
</HeadingContainer>
@ -145,7 +145,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
this.processEdgesToFindNeighbors();
}
public componentDidUpdate(prevProps: InstanceScreenProps, prevState: InstanceScreenState) {
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;
@ -164,13 +164,10 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
}
this.setState({ isProcessingNeighbors: true });
const graphToUse = graph || localGraph;
if (!graphToUse) {
return;
}
const edges = graphToUse.edges.filter((e) => [e.data.source, e.data.target].includes(instanceName));
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) => {
edges.forEach(e => {
if (e.data.source === instanceName) {
neighbors.push({ neighbor: e.data.target, weight: e.data.weight });
} else {
@ -186,38 +183,39 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
}
this.setState({ isLoadingLocalGraph: true });
getFromApi(`graph/${this.props.instanceName}`)
.then((response: GraphResponse) => {
.then((response: IGraphResponse) => {
// 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 { graph } = response;
const nodeIds = new Set(graph.nodes.map((n) => n.data.id));
const edges = graph.edges.filter((e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
const graph = response.graph;
const nodeIds = new Set(graph.nodes.map(n => n.data.id));
const edges = graph.edges.filter(e => nodeIds.has(e.data.source) && nodeIds.has(e.data.target));
this.setState({ isLoadingLocalGraph: false, localGraph: { ...graph, edges } });
})
.catch(() => this.setState({ isLoadingLocalGraph: false, localGraphLoadError: true }));
};
private renderTabs = () => {
const { instanceDetails } = this.props;
const hasNeighbors = this.state.neighbors && this.state.neighbors.length > 0;
const federationRestrictions = instanceDetails && instanceDetails.federationRestrictions;
const federationRestrictions = this.props.instanceDetails && this.props.instanceDetails.federationRestrictions;
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&apos;t have any neighbors that we know of, so it&apos;s hidden from the graph.</p>
<p>This instance doesn't have any neighbors that we know of, so it's hidden from the graph.</p>
</StyledCallout>
) : undefined;
) : (
undefined
);
return (
<>
{insularCallout}
{this.maybeRenderLocalGraph()}
<StyledTabs>
{instanceDetails && instanceDetails.description && (
{this.props.instanceDetails!.description && (
<Tab id="description" title="Description" panel={this.renderDescription()} />
)}
{this.shouldRenderStats() && <Tab id="stats" title="Details" panel={this.renderVersionAndCounts()} />}
@ -233,11 +231,11 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</StyledTabs>
<StyledLinkToFdNetwork>
<AnchorButton
href={`https://fedidb.org/network/instance?domain=${this.props.instanceName}`}
minimal
href={`https://fediverse.network/${this.props.instanceName}`}
minimal={true}
rightIcon={IconNames.SHARE}
target="_blank"
text="See more statistics at fedidb.org"
text="See more statistics at fediverse.network"
/>
</StyledLinkToFdNetwork>
</>
@ -246,17 +244,18 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
private maybeRenderLocalGraph = () => {
const { localGraph } = this.state;
const hasLocalGraph = !!localGraph && localGraph.nodes.length > 0 && localGraph.edges.length > 0;
if (!hasLocalGraph || !localGraph) {
const hasLocalGraph =
!!this.state.localGraph && this.state.localGraph.nodes.length > 0 && this.state.localGraph.edges.length > 0;
if (!hasLocalGraph) {
return;
}
return (
<StyledGraphContainer aria-hidden>
<StyledGraphContainer>
<Cytoscape
elements={localGraph}
elements={localGraph!}
currentNodeId={this.props.instanceName}
navigateToInstancePath={this.props.navigateToInstance}
showEdges
showEdges={true}
/>
<Divider />
</StyledGraphContainer>
@ -269,11 +268,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
};
private renderDescription = () => {
const { instanceDetails } = this.props;
if (!instanceDetails) {
return;
}
const { description } = instanceDetails;
const description = this.props.instanceDetails!.description;
if (!description) {
return;
}
@ -293,10 +288,10 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
insularity,
type,
statusesPerDay,
statusesPerUserPerDay,
statusesPerUserPerDay
} = this.props.instanceDetails;
return (
<StyledHTMLTable small striped>
<StyledHTMLTable small={true} striped={true}>
<tbody>
<tr>
<td>Version</td>
@ -304,7 +299,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr>
<tr>
<td>Instance type</td>
<td>{(type && <InstanceType type={type} colorAfterName />) || "Unknown"}</td>
<td>{(type && <InstanceType type={type} colorAfterName={true} />) || "Unknown"}</td>
</tr>
<tr>
<td>Users</td>
@ -316,8 +311,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr>
<tr>
<td>
Insularity
{" "}
Insularity{" "}
<Tooltip
content={
<span>
@ -336,8 +330,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr>
<tr>
<td>
Statuses / day
{" "}
Statuses / day{" "}
<Tooltip
content={
<span>
@ -356,8 +349,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr>
<tr>
<td>
Statuses / person / day
{" "}
Statuses / person / day{" "}
<Tooltip
content={
<span>
@ -380,7 +372,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
</tr>
<tr>
<td>Last updated</td>
<td>{moment(`${lastUpdated}Z`).fromNow() || "Unknown"}</td>
<td>{moment(lastUpdated + "Z").fromNow() || "Unknown"}</td>
</tr>
</tbody>
</StyledHTMLTable>
@ -420,7 +412,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
would mean that every single status on {this.props.instanceName} contained a mention of someone on the other
instance, and vice versa.
</p>
<StyledHTMLTable small striped interactive={false}>
<StyledHTMLTable small={true} striped={true} interactive={false}>
<thead>
<tr>
<th>Instance</th>
@ -434,15 +426,11 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
};
private renderPeers = () => {
const { instanceDetails } = this.props;
if (!instanceDetails) {
return;
}
const { peers } = instanceDetails;
const peers = this.props.instanceDetails!.peers;
if (!peers || peers.length === 0) {
return;
}
const peerRows = peers.map((instance: Peer) => (
const peerRows = peers.map(instance => (
<tr key={instance.name}>
<td>
<Link to={`/instance/${instance.name}`} className={`${Classes.BUTTON} ${Classes.MINIMAL}`} role="button">
@ -456,7 +444,7 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
<p className={Classes.TEXT_MUTED}>
All the instances, past and present, that {this.props.instanceName} knows about.
</p>
<StyledHTMLTable small striped interactive={false} className="fediverse-sidebar-table">
<StyledHTMLTable small={true} striped={true} interactive={false} className="fediverse-sidebar-table">
<tbody>{peerRows}</tbody>
</StyledHTMLTable>
</div>
@ -465,62 +453,71 @@ class InstanceScreenImpl extends React.PureComponent<InstanceScreenProps, Instan
private renderLoadingState = () => <NonIdealState icon={<Spinner />} />;
private renderPersonalInstanceErrorState = () => (
<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={
<Link to="/admin" className={Classes.BUTTON} role="button">
Opt in
</Link>
}
/>
);
private renderMissingDataState = () => (
<>
private renderPersonalInstanceErrorState = () => {
return (
<NonIdealState
icon={IconNames.ERROR}
icon={IconNames.BLOCKED_PERSON}
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."
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={
<Link to={"/admin"} className={Classes.BUTTON} role="button">
{"Opt in"}
</Link>
}
/>
<span className="sidebar-hidden-instance-status" style={{ display: "none" }}>
{this.props.instanceDetails && this.props.instanceDetails.status}
</span>
</>
);
);
};
private renderRobotsTxtState = () => (
<NonIdealState
icon={
<span role="img" aria-label="robot">
🤖
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>
}
title="No data"
description="This instance was not crawled because its robots.txt did not allow us to."
/>
);
</>
);
};
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");
window.open("https://" + this.props.instanceName, "_blank");
};
}
const mapStateToProps = (state: AppState) => {
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
graph: state.data.graphResponse && state.data.graphResponse.graph,
instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error,
instanceName: match && match.params.domain,
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails,
isLoadingInstanceDetails: state.currentInstance.isLoadingInstanceDetails
};
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
navigateToRoot: () => dispatch(push("/")),
navigateToRoot: () => dispatch(push("/"))
});
const InstanceScreen = connect(mapStateToProps, mapDispatchToProps)(InstanceScreenImpl);
const InstanceScreen = connect(
mapStateToProps,
mapDispatchToProps
)(InstanceScreenImpl);
export default InstanceScreen;

View File

@ -1,4 +1,4 @@
import { Button, Classes, FormGroup, H1, H2, Icon, InputGroup, Intent } from "@blueprintjs/core";
import { Button, Classes, FormGroup, H1, H4, Icon, InputGroup, Intent } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import React from "react";
import { Redirect } from "react-router";
@ -8,11 +8,11 @@ import { getAuthToken, getFromApi, postToApi } from "../../util";
import { Page } from "../atoms";
import { ErrorState } from "../molecules";
interface FormContainerProps {
interface IFormContainerProps {
error: boolean;
}
const FormContainer = styled.div<FormContainerProps>`
${(props) => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")}
const FormContainer = styled.div<IFormContainerProps>`
${props => (props.error ? "margin: 20px auto 0 auto;" : "margin-top: 20px;")}
`;
const LoginTypeContainer = styled.div`
display: flex;
@ -31,28 +31,23 @@ const StyledIcon = styled(Icon)`
margin-bottom: 10px;
`;
interface LoginTypes {
interface ILoginTypes {
domain: string;
email?: string;
fediverseAccount?: string;
}
interface LoginScreenState {
interface ILoginScreenState {
domain: string;
isGettingLoginTypes: boolean;
isSendingLoginRequest: boolean;
loginTypes?: LoginTypes;
loginTypes?: ILoginTypes;
selectedLoginType?: "email" | "fediverseAccount";
error: boolean;
}
class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
class LoginScreen extends React.PureComponent<{}, ILoginScreenState> {
public constructor(props: any) {
super(props);
this.state = {
domain: "",
error: false,
isGettingLoginTypes: false,
isSendingLoginRequest: false,
};
this.state = { domain: "", error: false, isGettingLoginTypes: false, isSendingLoginRequest: false };
}
public render() {
@ -64,13 +59,13 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
const { error, loginTypes, isSendingLoginRequest, selectedLoginType } = this.state;
let content;
if (error) {
if (!!error) {
content = (
<ErrorState description="This could be because the instance is down. If not, please reload the page and try again." />
);
} else if (!!selectedLoginType && !isSendingLoginRequest) {
content = this.renderPostLogin();
} else if (loginTypes) {
} else if (!!loginTypes) {
content = this.renderChooseLoginType();
} else {
content = this.renderChooseInstance();
@ -79,14 +74,16 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
return (
<Page>
<H1>Login</H1>
<p className={Classes.RUNNING_TEXT}>You must be the instance admin to manage how fediverse.space</p>
<p className={Classes.RUNNING_TEXT}>
It&apos;s currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a
direct message, your instance must federate with social.inex.rocks and vice versa.
You must be the instance admin to manage how fediverse.space interacts with your instance.
</p>
<p className={Classes.RUNNING_TEXT}>
It's currently only possible to administrate Mastodon and Pleroma instances. If you want to login with a
direct message, your instance must federate with mastodon.social and vice versa.
</p>
<p className={Classes.RUNNING_TEXT}>
If you run another server type, you can manually opt in or out by writing to{" "}
<a href="https://social.inex.rocks/@indexCommunity">@indexCommunity</a>.
<a href="https://mastodon.social/@fediversespace">@fediversespace</a>.
</p>
<FormContainer error={this.state.error}>{content}</FormContainer>
</Page>
@ -98,7 +95,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
const onButtonClick = () => this.getLoginTypes();
return (
<form onSubmit={this.getLoginTypes}>
<FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline>
<FormGroup label="Instance domain" labelFor="domain-input" disabled={isGettingLoginTypes} inline={true}>
<InputGroup
disabled={isGettingLoginTypes}
id="domain-input"
@ -107,7 +104,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
rightElement={
<Button
intent={Intent.PRIMARY}
minimal
minimal={true}
rightIcon={IconNames.ARROW_RIGHT}
title="submit"
loading={isGettingLoginTypes}
@ -130,16 +127,21 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
const loginWithDm = () => this.login("fediverseAccount");
return (
<>
<H2>Choose an authentication method</H2>
<H4>Choose an authentication method</H4>
<LoginTypeContainer>
{loginTypes.email && (
<LoginTypeButton large icon={IconNames.ENVELOPE} onClick={loginWithEmail} loading={!!isSendingLoginRequest}>
<LoginTypeButton
large={true}
icon={IconNames.ENVELOPE}
onClick={loginWithEmail}
loading={!!isSendingLoginRequest}
>
{`Email ${loginTypes.email}`}
</LoginTypeButton>
)}
{loginTypes.fediverseAccount && (
<LoginTypeButton
large
large={true}
icon={IconNames.GLOBE_NETWORK}
onClick={loginWithDm}
loading={!!isSendingLoginRequest}
@ -173,7 +175,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
};
private getLoginTypes = (e?: React.FormEvent<HTMLFormElement>) => {
if (e) {
if (!!e) {
e.preventDefault();
}
this.setState({ isGettingLoginTypes: true });
@ -182,8 +184,8 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
domain = domain.slice(8);
}
getFromApi(`admin/login/${domain.trim()}`)
.then((response) => {
if (response.error) {
.then(response => {
if (!!response.error) {
// Go to catch() below
throw new Error(response.error);
} else {
@ -194,7 +196,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
AppToaster.show({
icon: IconNames.ERROR,
intent: Intent.DANGER,
message: err.message,
message: err.message
});
this.setState({ isGettingLoginTypes: false });
});
@ -203,7 +205,7 @@ class LoginScreen extends React.PureComponent<{}, LoginScreenState> {
private login = (type: "email" | "fediverseAccount") => {
this.setState({ isSendingLoginRequest: true, selectedLoginType: type });
postToApi("admin/login", { domain: this.state.loginTypes!.domain, type })
.then((response) => {
.then(response => {
if ("error" in response || "errors" in response) {
// Go to catch() below
throw new Error();

View File

@ -1,4 +1,4 @@
import { Button, Callout, H1, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
import { Button, Callout, H2, InputGroup, Intent, NonIdealState, Spinner } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { push } from "connected-react-router";
import { get, isEqual } from "lodash";
@ -7,20 +7,20 @@ import { connect } from "react-redux";
import { Dispatch } from "redux";
import styled from "styled-components";
import { setResultHover, updateSearch } from "../../redux/actions";
import { AppState, SearchResultInstance } from "../../redux/types";
import { SearchFilter } from "../../searchFilters";
import { IAppState, ISearchResultInstance } from "../../redux/types";
import { ISearchFilter } from "../../searchFilters";
import { isSmallScreen } from "../../util";
import { SearchResult } from "../molecules";
import { SearchFilters } from "../organisms";
interface SearchBarContainerProps {
interface ISearchBarContainerProps {
hasSearchResults: boolean;
hasError: boolean;
}
const SearchBarContainer = styled.div<SearchBarContainerProps>`
const SearchBarContainer = styled.div<ISearchBarContainerProps>`
width: 80%;
text-align: center;
margin: ${(props) => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
margin: ${props => (props.hasSearchResults || props.hasError ? "0 auto" : "auto")};
align-self: center;
`;
const SearchResults = styled.div`
@ -38,22 +38,22 @@ const CalloutContainer = styled.div`
text-align: left;
`;
interface SearchScreenProps {
interface ISearchScreenProps {
error: boolean;
isLoadingResults: boolean;
query: string;
hasMoreResults: boolean;
results: SearchResultInstance[];
handleSearch: (query: string, filters: SearchFilter[]) => void;
results: ISearchResultInstance[];
handleSearch: (query: string, filters: ISearchFilter[]) => void;
navigateToInstance: (domain: string) => void;
setIsHoveringOver: (domain?: string) => void;
}
interface SearchScreenState {
interface ISearchScreenState {
currentQuery: string;
searchFilters: SearchFilter[];
searchFilters: ISearchFilter[];
}
class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenState> {
public constructor(props: SearchScreenProps) {
class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreenState> {
public constructor(props: ISearchScreenProps) {
super(props);
this.state = { currentQuery: "", searchFilters: [] };
}
@ -81,7 +81,7 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
} else if (!!results && results.length > 0) {
content = (
<SearchResults>
{results.map((result) => (
{results.map(result => (
<SearchResult
result={result}
key={result.name}
@ -92,7 +92,7 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
))}
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
{!isLoadingResults && hasMoreResults && (
<Button onClick={this.search} minimal>
<Button onClick={this.search} minimal={true}>
Load more results
</Button>
)}
@ -104,11 +104,11 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
if (isLoadingResults) {
rightSearchBarElement = <Spinner size={Spinner.SIZE_SMALL} />;
} else if (query || error) {
rightSearchBarElement = <Button minimal icon={IconNames.CROSS} onClick={this.clearQuery} aria-label="Search" />;
rightSearchBarElement = <Button minimal={true} icon={IconNames.CROSS} onClick={this.clearQuery} />;
} else {
rightSearchBarElement = (
<Button
minimal
minimal={true}
icon={IconNames.ARROW_RIGHT}
intent={Intent.PRIMARY}
onClick={this.search}
@ -121,13 +121,12 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
<>
{isSmallScreen && results.length === 0 && this.renderMobileWarning()}
<SearchBarContainer hasSearchResults={!!query && !!results} hasError={!!error}>
<H1>Find an instance</H1>
<H2>Find an instance</H2>
<InputGroup
leftIcon={IconNames.SEARCH}
rightElement={rightSearchBarElement}
large
large={true}
placeholder="Search instance names and descriptions"
aria-label="Search instance names and descriptions"
type="search"
value={this.state.currentQuery}
onChange={this.handleInputChange}
@ -162,10 +161,10 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
this.setState({ currentQuery: "" }, () => this.props.handleSearch("", []));
};
private selectSearchFilter = (filter: SearchFilter) => {
private selectSearchFilter = (filter: ISearchFilter) => {
const { searchFilters } = this.state;
// Don't add the same filters twice
if (searchFilters.some((sf) => isEqual(sf, filter))) {
if (searchFilters.some(sf => isEqual(sf, filter))) {
return;
}
this.setState({ searchFilters: [...searchFilters, filter] }, this.search);
@ -174,9 +173,9 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
private deselectSearchFilter = (e: MouseEvent<HTMLButtonElement>) => {
const { searchFilters } = this.state;
const displayValueToRemove = get(e, "currentTarget.parentElement.innerText", "");
if (displayValueToRemove) {
if (!!displayValueToRemove) {
this.setState(
{ searchFilters: searchFilters.filter((sf) => sf.displayValue !== displayValueToRemove) },
{ searchFilters: searchFilters.filter(sf => sf.displayValue !== displayValueToRemove) },
this.search
);
}
@ -205,16 +204,19 @@ class SearchScreen extends React.PureComponent<SearchScreenProps, SearchScreenSt
);
}
const mapStateToProps = (state: AppState) => ({
const mapStateToProps = (state: IAppState) => ({
error: state.search.error,
hasMoreResults: !!state.search.next,
isLoadingResults: state.search.isLoadingResults,
query: state.search.query,
results: state.search.results,
results: state.search.results
});
const mapDispatchToProps = (dispatch: Dispatch) => ({
handleSearch: (query: string, filters: SearchFilter[]) => dispatch(updateSearch(query, filters) as any),
handleSearch: (query: string, filters: ISearchFilter[]) => dispatch(updateSearch(query, filters) as any),
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain)),
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain))
});
export default connect(mapStateToProps, mapDispatchToProps)(SearchScreen);
export default connect(
mapStateToProps,
mapDispatchToProps
)(SearchScreen);

View File

@ -6,8 +6,8 @@ import { InstanceTable } from "../organisms";
class TableScreen extends React.PureComponent {
public render() {
return (
<Page fullWidth>
<H1>Instances</H1>
<Page fullWidth={true}>
<H1>{"Instances"}</H1>
<InstanceTable />
</Page>
);

View File

@ -1,20 +1,20 @@
import React, { useEffect, useState } from "react";
import { connect } from "react-redux";
import { Redirect } from "react-router";
import { AppState } from "../../redux/types";
import { IAppState } from "../../redux/types";
import { setAuthToken } from "../../util";
import { Page } from "../atoms";
interface VerifyLoginScreenProps {
interface IVerifyLoginScreenProps {
search: string;
}
const VerifyLoginScreen: React.FC<VerifyLoginScreenProps> = ({ search }) => {
const VerifyLoginScreen: React.FC<IVerifyLoginScreenProps> = ({ search }) => {
const [didSaveToken, setDidSaveToken] = useState(false);
const token = new URLSearchParams(search).get("token");
useEffect(() => {
// Save the auth token
if (token) {
if (!!token) {
setAuthToken(token);
setDidSaveToken(true);
}
@ -22,14 +22,15 @@ const VerifyLoginScreen: React.FC<VerifyLoginScreenProps> = ({ search }) => {
if (!token) {
return <Redirect to="/admin/login" />;
}
if (!didSaveToken) {
} else if (!didSaveToken) {
return <Page />;
}
return <Redirect to="/admin" />;
};
const mapStateToProps = (state: AppState) => ({
search: state.router.location.search,
});
const mapStateToProps = (state: IAppState) => {
return {
search: state.router.location.search
};
};
export default connect(mapStateToProps)(VerifyLoginScreen);

View File

@ -20,9 +20,8 @@ export const QUALITATIVE_COLOR_SCHEME = [
"#AD99FF",
"#0E5A8A",
"#0A6640",
"#AAB42F",
"#A66321",
"#A82A2A",
"#A82A2A"
];
// From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes
@ -36,11 +35,11 @@ export const QUANTITATIVE_COLOR_SCHEME = [
"#C15B3F",
"#B64C2F",
"#AA3C1F",
"#9E2B0E",
"#9E2B0E"
];
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface InstanceDomainPath {
export interface IInstanceDomainPath {
domain: string;
}
@ -56,6 +55,5 @@ export const INSTANCE_TYPES = [
"friendica",
"hubzilla",
"plume",
"wordpress",
"smithereen",
"wordpress"
];

View File

@ -12,7 +12,3 @@ body {
.app-toaster {
z-index: 1000;
}
.current-navbar-item {
background-color: #293742 !important;
}

View File

@ -25,7 +25,8 @@ FocusStyleManager.onlyShowFocusOnTabs();
export const history = createBrowserHistory();
// Initialize redux
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
// @ts-ignore
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
createRootReducer(history),
composeEnhancers(applyMiddleware(routerMiddleware(history), thunk))

View File

@ -1 +1 @@
// / <reference types="react-scripts" />
/// <reference types="react-scripts" />

View File

@ -2,145 +2,171 @@ import { isEqual } from "lodash";
import { Dispatch } from "redux";
import { push } from "connected-react-router";
import { SearchFilter } from "../searchFilters";
import { ISearchFilter } from "../searchFilters";
import { getFromApi } from "../util";
import { ActionType, AppState, Graph, InstanceDetails, InstanceSort, SearchResponse } from "./types";
import { ActionType, IAppState, IGraph, IInstanceDetails, IInstanceSort, ISearchResponse } from "./types";
// Instance details
const requestInstanceDetails = (instanceName: string) => ({
payload: instanceName,
type: ActionType.REQUEST_INSTANCE_DETAILS,
});
const receiveInstanceDetails = (instanceDetails: InstanceDetails) => ({
payload: instanceDetails,
type: ActionType.RECEIVE_INSTANCE_DETAILS,
});
const instanceLoadFailed = () => ({
type: ActionType.INSTANCE_LOAD_ERROR,
});
const deselectInstance = () => ({
type: ActionType.DESELECT_INSTANCE,
});
const requestInstanceDetails = (instanceName: string) => {
return {
payload: instanceName,
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 = () => {
return {
type: ActionType.DESELECT_INSTANCE
};
};
// Graph
const requestGraph = () => ({
type: ActionType.REQUEST_GRAPH,
});
const receiveGraph = (graph: Graph) => ({
payload: graph,
type: ActionType.RECEIVE_GRAPH,
});
const graphLoadFailed = () => ({
type: ActionType.GRAPH_LOAD_ERROR,
});
const requestGraph = () => {
return {
type: ActionType.REQUEST_GRAPH
};
};
const receiveGraph = (graph: IGraph) => {
return {
payload: graph,
type: ActionType.RECEIVE_GRAPH
};
};
const graphLoadFailed = () => {
return {
type: ActionType.GRAPH_LOAD_ERROR
};
};
// Instance list
const requestInstanceList = (sort?: InstanceSort) => ({
const requestInstanceList = (sort?: IInstanceSort) => ({
payload: sort,
type: ActionType.REQUEST_INSTANCES,
type: ActionType.REQUEST_INSTANCES
});
const receiveInstanceList = (instances: InstanceDetails[]) => ({
const receiveInstanceList = (instances: IInstanceDetails[]) => ({
payload: instances,
type: ActionType.RECEIVE_INSTANCES,
type: ActionType.RECEIVE_INSTANCES
});
const instanceListLoadFailed = () => ({
type: ActionType.INSTANCE_LIST_LOAD_ERROR,
type: ActionType.INSTANCE_LIST_LOAD_ERROR
});
// Search
const requestSearchResult = (query: string, filters: SearchFilter[]) => ({
payload: { query, filters },
type: ActionType.REQUEST_SEARCH_RESULTS,
});
const receiveSearchResults = (result: SearchResponse) => ({
payload: result,
type: ActionType.RECEIVE_SEARCH_RESULTS,
});
const searchFailed = () => ({
type: ActionType.SEARCH_RESULTS_ERROR,
});
const requestSearchResult = (query: string, filters: ISearchFilter[]) => {
return {
payload: { query, filters },
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
};
};
const resetSearch = () => ({
type: ActionType.RESET_SEARCH,
});
const resetSearch = () => {
return {
type: ActionType.RESET_SEARCH
};
};
export const setResultHover = (domain?: string) => ({
payload: domain,
type: ActionType.SET_SEARCH_RESULT_HOVER,
});
export const setResultHover = (domain?: string) => {
return {
payload: domain,
type: ActionType.SET_SEARCH_RESULT_HOVER
};
};
/** Async actions: https://redux.js.org/advanced/asyncactions */
export const loadInstance = (instanceName: string | null) => (dispatch: Dispatch, getState: () => AppState) => {
if (!instanceName) {
dispatch(deselectInstance());
if (getState().router.location.pathname.startsWith("/instance/")) {
dispatch(push("/"));
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;
}
return;
}
dispatch(requestInstanceDetails(instanceName));
return getFromApi(`instances/${instanceName}`)
.then((details) => dispatch(receiveInstanceDetails(details)))
.catch(() => dispatch(instanceLoadFailed()));
dispatch(requestInstanceDetails(instanceName));
return getFromApi("instances/" + instanceName)
.then(details => dispatch(receiveInstanceDetails(details)))
.catch(() => dispatch(instanceLoadFailed()));
};
};
export const updateSearch = (query: string, filters: SearchFilter[]) => (
dispatch: Dispatch,
getState: () => AppState
) => {
query = query.trim();
export const updateSearch = (query: string, filters: ISearchFilter[]) => {
return (dispatch: Dispatch, getState: () => IAppState) => {
query = query.trim();
if (!query) {
dispatch(resetSearch());
return;
}
if (!query) {
dispatch(resetSearch());
return;
}
const prevQuery = getState().search.query;
const prevFilters = getState().search.filters;
const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters);
const prevQuery = getState().search.query;
const prevFilters = getState().search.filters;
const isNewQuery = prevQuery !== query || !isEqual(prevFilters, filters);
const { next } = getState().search;
let url = `search/?query=${query}`;
if (!isNewQuery && next) {
url += `&after=${next}`;
}
const next = getState().search.next;
let url = `search/?query=${query}`;
if (!isNewQuery && next) {
url += `&after=${next}`;
}
// Add filters
// The format is e.g. type_eq=mastodon or user_count_gt=1000
filters.forEach((filter) => {
url += `&${filter.field}_${filter.relation}=${filter.value}`;
});
// Add filters
// The format is e.g. type_eq=mastodon or user_count_gt=1000
filters.forEach(filter => {
url += `&${filter.field}_${filter.relation}=${filter.value}`;
});
dispatch(requestSearchResult(query, filters));
return getFromApi(url)
.then((result) => dispatch(receiveSearchResults(result)))
.catch(() => dispatch(searchFailed()));
dispatch(requestSearchResult(query, filters));
return getFromApi(url)
.then(result => dispatch(receiveSearchResults(result)))
.catch(() => dispatch(searchFailed()));
};
};
export const fetchGraph = () => (dispatch: Dispatch) => {
dispatch(requestGraph());
return getFromApi("graph")
.then((graph) => dispatch(receiveGraph(graph)))
.catch(() => dispatch(graphLoadFailed()));
export const fetchGraph = () => {
return (dispatch: Dispatch) => {
dispatch(requestGraph());
return getFromApi("graph")
.then(graph => dispatch(receiveGraph(graph)))
.catch(() => dispatch(graphLoadFailed()));
};
};
export const loadInstanceList = (page?: number, sort?: InstanceSort) => (
dispatch: Dispatch,
getState: () => AppState
) => {
sort = sort || getState().data.instanceListSort;
dispatch(requestInstanceList(sort));
const params: string[] = [];
if (page) {
params.push(`page=${page}`);
}
if (sort) {
params.push(`sortField=${sort.field}`);
params.push(`sortDirection=${sort.direction}`);
}
const path = params ? `instances?${params.join("&")}` : "instances";
return getFromApi(path)
.then((instancesListResponse) => dispatch(receiveInstanceList(instancesListResponse)))
.catch(() => dispatch(instanceListLoadFailed()));
export const loadInstanceList = (page?: number, sort?: IInstanceSort) => {
return (dispatch: Dispatch, getState: () => IAppState) => {
sort = sort ? sort : getState().data.instanceListSort;
dispatch(requestInstanceList(sort));
const params: string[] = [];
if (!!page) {
params.push(`page=${page}`);
}
if (!!sort) {
params.push(`sortField=${sort.field}`);
params.push(`sortDirection=${sort.direction}`);
}
const path = !!params ? `instances?${params.join("&")}` : "instances";
return getFromApi(path)
.then(instancesListResponse => dispatch(receiveInstanceList(instancesListResponse)))
.catch(() => dispatch(instanceListLoadFailed()));
};
};

View File

@ -3,34 +3,34 @@ import { isEqual } from "lodash";
import { combineReducers } from "redux";
import { History } from "history";
import { ActionType, Action, CurrentInstanceState, DataState, SearchState } from "./types";
import { ActionType, IAction, ICurrentInstanceState, IDataState, ISearchState } from "./types";
const initialDataState: DataState = {
const initialDataState: IDataState = {
graphLoadError: false,
instanceListLoadError: false,
instanceListSort: { field: "userCount", direction: "desc" },
isLoadingGraph: false,
isLoadingInstanceList: false,
isLoadingInstanceList: false
};
const data = (state: DataState = initialDataState, action: Action): DataState => {
const data = (state: IDataState = initialDataState, action: IAction): IDataState => {
switch (action.type) {
case ActionType.REQUEST_GRAPH:
return {
...state,
graphResponse: undefined,
isLoadingGraph: true,
isLoadingGraph: true
};
case ActionType.RECEIVE_GRAPH:
return {
...state,
graphResponse: action.payload,
isLoadingGraph: false,
isLoadingGraph: false
};
case ActionType.GRAPH_LOAD_ERROR:
return {
...state,
graphLoadError: true,
isLoadingGraph: false,
isLoadingGraph: false
};
case ActionType.REQUEST_INSTANCES:
return {
@ -38,71 +38,71 @@ const data = (state: DataState = initialDataState, action: Action): DataState =>
instanceListLoadError: false,
instanceListSort: action.payload,
instancesResponse: undefined,
isLoadingInstanceList: true,
isLoadingInstanceList: true
};
case ActionType.RECEIVE_INSTANCES:
return {
...state,
instancesResponse: action.payload,
isLoadingInstanceList: false,
isLoadingInstanceList: false
};
case ActionType.INSTANCE_LIST_LOAD_ERROR:
return {
...state,
instanceListLoadError: true,
isLoadingInstanceList: false,
isLoadingInstanceList: false
};
default:
return state;
}
};
const initialCurrentInstanceState: CurrentInstanceState = {
const initialCurrentInstanceState: ICurrentInstanceState = {
currentInstanceDetails: null,
error: false,
isLoadingInstanceDetails: false,
isLoadingInstanceDetails: false
};
const currentInstance = (state = initialCurrentInstanceState, action: Action): CurrentInstanceState => {
const currentInstance = (state = initialCurrentInstanceState, action: IAction): ICurrentInstanceState => {
switch (action.type) {
case ActionType.REQUEST_INSTANCE_DETAILS:
return {
...state,
error: false,
isLoadingInstanceDetails: true,
isLoadingInstanceDetails: true
};
case ActionType.RECEIVE_INSTANCE_DETAILS:
return {
...state,
currentInstanceDetails: action.payload,
error: false,
isLoadingInstanceDetails: false,
isLoadingInstanceDetails: false
};
case ActionType.DESELECT_INSTANCE:
return {
...state,
currentInstanceDetails: null,
error: false,
error: false
};
case ActionType.INSTANCE_LOAD_ERROR:
return {
...state,
error: true,
isLoadingInstanceDetails: false,
isLoadingInstanceDetails: false
};
default:
return state;
}
};
const initialSearchState: SearchState = {
const initialSearchState: ISearchState = {
error: false,
filters: [],
isLoadingResults: false,
next: "",
query: "",
results: [],
results: []
};
const search = (state = initialSearchState, action: Action): SearchState => {
const search = (state = initialSearchState, action: IAction): ISearchState => {
switch (action.type) {
case ActionType.REQUEST_SEARCH_RESULTS:
const { query, filters } = action.payload;
@ -114,7 +114,7 @@ const search = (state = initialSearchState, action: Action): SearchState => {
isLoadingResults: true,
next: isNewQuery ? "" : state.next,
query,
results: isNewQuery ? [] : state.results,
results: isNewQuery ? [] : state.results
};
case ActionType.RECEIVE_SEARCH_RESULTS:
return {
@ -122,7 +122,7 @@ const search = (state = initialSearchState, action: Action): SearchState => {
error: false,
isLoadingResults: false,
next: action.payload.next,
results: state.results.concat(action.payload.results),
results: state.results.concat(action.payload.results)
};
case ActionType.SEARCH_RESULTS_ERROR:
return { ...initialSearchState, error: true };
@ -131,7 +131,7 @@ const search = (state = initialSearchState, action: Action): SearchState => {
case ActionType.SET_SEARCH_RESULT_HOVER:
return {
...state,
hoveringOverResult: action.payload,
hoveringOverResult: action.payload
};
default:
return state;
@ -141,7 +141,8 @@ const search = (state = initialSearchState, action: Action): SearchState => {
export default (history: History) =>
combineReducers({
router: connectRouter(history),
// tslint:disable-next-line:object-literal-sort-keys
currentInstance,
data,
search,
search
});

View File

@ -1,5 +1,5 @@
import { RouterState } from "connected-react-router";
import { SearchFilter } from "../searchFilters";
import { ISearchFilter } from "../searchFilters";
export enum ActionType {
// Instance details
@ -22,33 +22,33 @@ export enum ActionType {
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
RESET_SEARCH = "RESET_SEARCH",
// Search -- hovering over results
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER",
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER"
}
export interface Action {
export interface IAction {
type: ActionType;
payload: any;
}
export type SortField = "domain" | "userCount" | "statusCount" | "insularity";
export type SortDirection = "asc" | "desc";
export interface InstanceSort {
export interface IInstanceSort {
field: SortField;
direction: SortDirection;
}
export interface Peer {
export interface IPeer {
name: string;
}
export interface SearchResultInstance {
export interface ISearchResultInstance {
name: string;
description?: string;
userCount?: number;
type?: string;
}
export interface FederationRestrictions {
export interface IFederationRestrictions {
reportRemoval?: string[];
reject?: string[];
mediaRemoval?: string[];
@ -59,7 +59,7 @@ export interface FederationRestrictions {
accept?: string[];
}
export interface InstanceDetails {
export interface IInstanceDetails {
name: string;
description?: string;
version?: string;
@ -67,8 +67,8 @@ export interface InstanceDetails {
insularity?: number;
statusCount?: number;
domainCount?: number;
peers?: Peer[];
federationRestrictions: FederationRestrictions;
peers?: IPeer[];
federationRestrictions: IFederationRestrictions;
lastUpdated?: string;
status: string;
type?: string;
@ -76,7 +76,7 @@ export interface InstanceDetails {
statusesPerUserPerDay?: number;
}
interface GraphNode {
interface IGraphNode {
data: {
id: string;
label: string;
@ -88,7 +88,7 @@ interface GraphNode {
};
}
interface GraphEdge {
interface IGraphEdge {
data: {
source: string;
target: string;
@ -97,65 +97,65 @@ interface GraphEdge {
};
}
interface GraphMetadata {
interface IGraphMetadata {
ranges: { [key: string]: [number, number] };
}
export interface Graph {
nodes: GraphNode[];
edges: GraphEdge[];
export interface IGraph {
nodes: IGraphNode[];
edges: IGraphEdge[];
}
export interface GraphResponse {
graph: Graph;
metadata: GraphMetadata;
export interface IGraphResponse {
graph: IGraph;
metadata: IGraphMetadata;
}
export interface SearchResponse {
results: SearchResultInstance[];
export interface ISearchResponse {
results: ISearchResultInstance[];
next: string | null;
}
export interface InstanceListResponse {
export interface IInstanceListResponse {
pageNumber: number;
totalPages: number;
totalEntries: number;
pageSize: number;
instances: InstanceDetails[];
instances: IInstanceDetails[];
}
// Redux state
// The current instance name is stored in the URL. See state -> router -> location
export interface CurrentInstanceState {
currentInstanceDetails: InstanceDetails | null;
export interface ICurrentInstanceState {
currentInstanceDetails: IInstanceDetails | null;
isLoadingInstanceDetails: boolean;
error: boolean;
}
export interface DataState {
graphResponse?: GraphResponse;
instancesResponse?: InstanceListResponse;
instanceListSort: InstanceSort;
export interface IDataState {
graphResponse?: IGraphResponse;
instancesResponse?: IInstanceListResponse;
instanceListSort: IInstanceSort;
isLoadingGraph: boolean;
isLoadingInstanceList: boolean;
graphLoadError: boolean;
instanceListLoadError: boolean;
}
export interface SearchState {
export interface ISearchState {
error: boolean;
isLoadingResults: boolean;
next: string;
query: string;
results: SearchResultInstance[];
filters: SearchFilter[];
results: ISearchResultInstance[];
filters: ISearchFilter[];
hoveringOverResult?: string;
}
export interface AppState {
export interface IAppState {
router: RouterState;
currentInstance: CurrentInstanceState;
data: DataState;
search: SearchState;
currentInstance: ICurrentInstanceState;
data: IDataState;
search: ISearchState;
}

View File

@ -1,8 +1,8 @@
type SearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte";
export interface SearchFilter {
type ISearchFilterRelation = "eq" | "gt" | "gte" | "lt" | "lte";
export interface ISearchFilter {
// The ES field to filter on
field: string;
relation: SearchFilterRelation;
relation: ISearchFilterRelation;
// The value we want to filter to
value: string;
// Human-meaningful text that we're showing in the UI
@ -10,19 +10,17 @@ export interface SearchFilter {
}
// Maps to translate this to user-friendly text
type SearchFilterField = "type" | "user_count";
const searchFilterFieldTranslations = {
type: "Instance type",
// eslint-disable-next-line @typescript-eslint/camelcase
user_count: "User count",
user_count: "User count"
};
const searchFilterRelationTranslations = {
eq: "=",
gt: ">",
gte: ">=",
lt: "<",
lte: "<=",
lte: "<="
};
export const getSearchFilterDisplayValue = (field: SearchFilterField, relation: SearchFilterRelation, value: string) =>
export const getSearchFilterDisplayValue = (field: string, relation: ISearchFilterRelation, value: string) =>
`${searchFilterFieldTranslations[field]} ${searchFilterRelationTranslations[relation]} ${value}`;

View File

@ -2,5 +2,5 @@ import { Position, Toaster } from "@blueprintjs/core";
export default Toaster.create({
className: "app-toaster",
position: Position.TOP,
position: Position.TOP
});

View File

@ -1,6 +1,6 @@
import { INSTANCE_TYPES } from "./constants";
interface ColorSchemeBase {
interface IColorSchemeBase {
// The name of the coloring, e.g. "Instance type"
name: string;
// The name of the key in a cytoscape node's `data` field to color by.
@ -9,30 +9,30 @@ interface ColorSchemeBase {
description?: string;
type: "qualitative" | "quantitative";
}
interface QualitativeColorScheme extends ColorSchemeBase {
interface IQualitativeColorScheme extends IColorSchemeBase {
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
values: string[];
type: "qualitative";
}
interface QuantitativeColorScheme extends ColorSchemeBase {
interface IQuantitativeColorScheme extends IColorSchemeBase {
type: "quantitative";
exponential: boolean;
}
export type ColorScheme = QualitativeColorScheme | QuantitativeColorScheme;
export type IColorScheme = IQualitativeColorScheme | IQuantitativeColorScheme;
export const typeColorScheme: QualitativeColorScheme = {
export const typeColorScheme: IQualitativeColorScheme = {
cytoscapeDataKey: "type",
name: "Instance type",
type: "qualitative",
values: INSTANCE_TYPES,
values: INSTANCE_TYPES
};
export const activityColorScheme: QuantitativeColorScheme = {
export const activityColorScheme: IQuantitativeColorScheme = {
cytoscapeDataKey: "statusesPerDay",
description: "The average number of statuses posted per day. This is an exponential scale.",
exponential: true,
name: "Activity",
type: "quantitative",
type: "quantitative"
};
export const colorSchemes: ColorScheme[] = [typeColorScheme, activityColorScheme];
export const colorSchemes: IColorScheme[] = [typeColorScheme, activityColorScheme];

View File

@ -1 +0,0 @@
declare module "*.png";

View File

@ -1,39 +1,41 @@
import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch";
import { range } from "lodash";
import { DESKTOP_WIDTH_THRESHOLD, InstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { AppState } from "./redux/types";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { IAppState } from "./redux/types";
let API_ROOT = "https://api.index.community/api/";
if (["true", true, 1, "1"].includes(process.env.REACT_APP_STAGING || "")) {
API_ROOT = "https://api.index.community/api/";
let API_ROOT = "http://localhost:4000/api/";
if (["true", true, 1, "1"].indexOf(process.env.REACT_APP_STAGING || "") > -1) {
API_ROOT = "https://phoenix.api.fediverse.space/api/";
} else if (process.env.NODE_ENV === "production") {
API_ROOT = "https://api.index.community/api/";
API_ROOT = "https://phoenix.api.fediverse.space/api/";
}
export const getFromApi = (path: string, token?: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`;
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
const headers = token ? { token } : undefined;
return fetch(encodeURI(domain + path), {
headers,
}).then((response) => response.json());
headers
}).then(response => response.json());
};
export const postToApi = (path: string, body: any, token?: string): Promise<any> => {
const domain = API_ROOT.endsWith("/") ? API_ROOT : `${API_ROOT}/`;
const domain = API_ROOT.endsWith("/") ? API_ROOT : API_ROOT + "/";
const defaultHeaders = {
Accept: "application/json",
"Content-Type": "application/json",
"Content-Type": "application/json"
};
const headers = token ? { ...defaultHeaders, token } : defaultHeaders;
return fetch(encodeURI(domain + path), {
body: JSON.stringify(body),
headers,
method: "POST",
}).then((response) => response.json());
method: "POST"
}).then(response => {
return response.json();
});
};
export const domainMatchSelector = createMatchSelector<AppState, InstanceDomainPath>(INSTANCE_DOMAIN_PATH);
export const domainMatchSelector = createMatchSelector<IAppState, IInstanceDomainPath>(INSTANCE_DOMAIN_PATH);
export const isSmallScreen = window.innerWidth < DESKTOP_WIDTH_THRESHOLD;
@ -47,25 +49,28 @@ export const unsetAuthToken = () => {
sessionStorage.removeItem("adminToken");
};
export const getAuthToken = () => sessionStorage.getItem("adminToken");
export const getAuthToken = () => {
return sessionStorage.getItem("adminToken");
};
export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => {
if (exponential) {
const logSpace = range(steps).map((i) => Math.E ** i);
const logSpace = range(steps).map(i => Math.E ** i);
// Scale the log space to the linear range
const logRange = logSpace[logSpace.length - 1] - logSpace[0];
const linearRange = max - min;
const scalingFactor = linearRange / logRange;
const translation = min - logSpace[0];
return logSpace.map((i) => (i + translation) * scalingFactor);
return logSpace.map(i => (i + translation) * scalingFactor);
} else {
// Linear
const bucketSize = (max - min) / steps;
return range(min, max, bucketSize);
}
// Linear
const bucketSize = (max - min) / steps;
return range(min, max, bucketSize);
};
const typeToDisplay: { [field: string]: string } = {
gnusocial: "GNU Social",
const typeToDisplay = {
gnusocial: "GNU Social"
};
export const getTypeDisplayString = (key: string) => {
if (key in typeToDisplay) {

View File

@ -1,32 +1,40 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"sourceMap": true,
"forceConsistentCasingInFileNames": true,
"jsx": "preserve",
"lib": [
"es2015",
"dom"
],
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"noImplicitAny": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"jsx": "react",
"outDir": "build",
"rootDir": "src",
"sourceMap": true,
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"target": "es5",
"skipLibCheck": true,
"esModuleInterop": true,
"strict": true,
"typeRoots": [
"./node_modules/@types",
"./src/typings"
],
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"exclude": [
"node_modules",
"build"
],
"include": [
"src"
]

11
frontend/tslint.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": [
"tslint:recommended",
"tslint-eslint-rules",
"tslint-react",
"@blueprintjs/tslint-config/blueprint-rules",
"tslint-config-prettier",
"tslint-config-security"
],
"exclude": ["**/*.css"]
}

File diff suppressed because it is too large Load Diff

View File

@ -2,9 +2,6 @@
base = "frontend/"
publish = "frontend/build/"
[build.environment]
INLINE_RUNTIME_CHUNK = "false"
[context.develop.environment]
REACT_APP_STAGING = "true"
@ -19,11 +16,3 @@
to = "/index.html"
status = 200
[[headers]]
for = "/*"
[headers.values]
X-Content-Type-Options = "nosniff"
X-Frame-Options = "DENY"
X-XSS-Protection = "1"
Content-Security-Policy = "default-src 'self' https://*.fediverse.space https://plausible.cursed.technology; style-src 'self' 'unsafe-inline'; img-src 'self' data:"