kopia lustrzana https://github.com/Stopka/fedisearch
Grapql api extracted to Fedistore, improved architecture, config using convict, nextjs app api, bugfixes
rodzic
1cd12fc7d3
commit
82e84c09fe
|
@ -1,11 +1,4 @@
|
||||||
FROM node:18-bullseye AS prebuild
|
FROM node:18-bullseye AS prebuild
|
||||||
ENV ELASTIC_URL='http://elastic:9200' \
|
|
||||||
ELASTIC_USER='elastic' \
|
|
||||||
ELASTIC_PASSWORD='' \
|
|
||||||
MATOMO_URL='' \
|
|
||||||
MATOMO_SITE_ID='' \
|
|
||||||
STATS_CACHE_MINUTES=60 \
|
|
||||||
TZ='UTC'
|
|
||||||
FROM prebuild AS build
|
FROM prebuild AS build
|
||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
COPY application/package*.json ./
|
COPY application/package*.json ./
|
||||||
|
|
19
README.md
19
README.md
|
@ -2,27 +2,22 @@
|
||||||
|
|
||||||
Search accounts and channels to follow on Fediverse
|
Search accounts and channels to follow on Fediverse
|
||||||
|
|
||||||
App makes queries to database of collected Fediverse feeds and nodes.
|
App makes queries to Fedistore app using graphql api.
|
||||||
|
|
||||||
Only fulltext search is currently supported. More precise filtering is planned for one of the future releases.
|
Only fulltext search is currently supported. More precise filtering is planned for one of the future releases.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Configuration is done using environmental variables:
|
Configuration is done using environmental variables or command flags
|
||||||
|
|
||||||
| Variable | Description | Value example |
|
| Env variable | Command argument | Description | Value example | Default value |
|
||||||
|-----------------------|--------------------------------------------------------------------------------------------------------------------|-------------------------------|
|
|------------------|--------------------|----------------------------------------------------------------------------------------------------------------------|---------------------------------|----------------|
|
||||||
| `ELASTIC_URL` | Url address of ElasticSearch server | `http://elastic:9200` |
|
| `MATOMO_URL` | `--matomo-url` | _Optional_ url of Matomo server for collecting usage statistics. Leaving it empty disables collecting analytics. | `https://matomo.myserver.tld` | empty |
|
||||||
| `ELASTIC_USER` | Username for EalsticSearch server | `elastic` |
|
| `MATOMO_SITE_ID` | `--matomo-site-id` | _Optional_ Matomo site id parameter for collecting usage statistics. Leaving it empty disables collecting analytics. | `8` | `0` |
|
||||||
| `ELASTIC_PASSWORD` | Username for EalsticSearch server | empty |
|
| `GRAPHQL_URL` | `--graphql-url` | *Required* Fedistore graphql api url | `https://fedistore.example/api` | `/api/graphql` |
|
||||||
| `MATOMO_URL` | Optional url of Matomo server for collecting usage statistics. Leaving it empty disables collecting analytics. | `https://matomo.myserver.tld` |
|
|
||||||
| `MATOMO_SITE_ID` | Optional Matomo site id parameter for collecting usage statistics. Leaving it empty disables collecting analytics. | `8` |
|
|
||||||
| `STATS_CACHE_MINUTES` | Optional number of minutes to cache heavily calculated stats data | `60` |
|
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
App is designed to be run in docker container and deployed using docker-compose. More info can be found
|
App is designed to be run in docker container and deployed using docker-compose. More info can be found
|
||||||
in [FediSearch example docker-compose](https://github.com/Stopka/fedisearch-compose) project
|
in [FediSearch example docker-compose](https://github.com/Stopka/fedisearch-compose) project
|
||||||
|
|
||||||
For crawling Fediverse network and collecting feeds to database there is a companion
|
|
||||||
app [FediCrawl](https://github.com/Stopka/fedicrawl)
|
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
overwrite: true
|
||||||
|
schema:
|
||||||
|
- './src/graphql/generated/schema.graphql'
|
||||||
|
documents:
|
||||||
|
- './src/**/*.gql'
|
||||||
|
generates:
|
||||||
|
src/graphql/generated/types.ts:
|
||||||
|
plugins:
|
||||||
|
- 'typescript'
|
||||||
|
- 'typescript-operations'
|
||||||
|
- 'typed-document-node'
|
|
@ -1,4 +1,8 @@
|
||||||
module.exports = {
|
const config = {
|
||||||
|
experimental: {
|
||||||
|
appDir: true,
|
||||||
|
esmExternals: true
|
||||||
|
},
|
||||||
webpack (config) {
|
webpack (config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
test: /\.svg$/,
|
test: /\.svg$/,
|
||||||
|
@ -20,3 +24,5 @@ module.exports = {
|
||||||
tsconfigPath: '../tsconfig.json'
|
tsconfigPath: '../tsconfig.json'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
|
|
@ -6,16 +6,17 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"author": "Štěpán Škorpil",
|
"author": "Štěpán Škorpil",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev ./src --hostname 0.0.0.0",
|
"dev": "next dev ./src --hostname 0.0.0.0",
|
||||||
"build": "next build ./src",
|
"build": "next build ./src",
|
||||||
"start": "next start ./src",
|
"start": "next start ./src",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"generate:graphql-types": "graphql-codegen-esm --config graphql-codegen.yml"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.6.9",
|
"@apollo/client": "^3.6.9",
|
||||||
"@datapunt/matomo-tracker-js": "^0.5.1",
|
"@datapunt/matomo-tracker-js": "^0.5.1",
|
||||||
"@elastic/elasticsearch": "^8.2.1",
|
|
||||||
"@fortawesome/fontawesome-common-types": "^6.2.0",
|
"@fortawesome/fontawesome-common-types": "^6.2.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.2.0",
|
"@fortawesome/fontawesome-svg-core": "^6.2.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.2.0",
|
"@fortawesome/free-brands-svg-icons": "^6.2.0",
|
||||||
|
@ -25,27 +26,32 @@
|
||||||
"@hookform/resolvers": "^2.9.10",
|
"@hookform/resolvers": "^2.9.10",
|
||||||
"@popperjs/core": "^2.11.6",
|
"@popperjs/core": "^2.11.6",
|
||||||
"@svgr/webpack": "^6.2.1",
|
"@svgr/webpack": "^6.2.1",
|
||||||
"apollo-server-micro": "^3.10.1",
|
|
||||||
"axios": "^0.21.1",
|
|
||||||
"bootstrap": "^5.1.3",
|
"bootstrap": "^5.1.3",
|
||||||
|
"convict": "~6.1.0",
|
||||||
"graphql": "^16.5.0",
|
"graphql": "^16.5.0",
|
||||||
"micro": "^9.4.1",
|
"next": "^13.0.6",
|
||||||
"micro-cors": "^0.1.1",
|
|
||||||
"next": "^12.2.5",
|
|
||||||
"nexus": "^1.3.0",
|
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"npmlog": "^6.0.0",
|
"npmlog": "^6.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-smooth-collapse": "^2.1.2",
|
||||||
"sass": "^1.45.1",
|
"sass": "^1.45.1",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"striptags": "^3.2.0",
|
"striptags": "^3.2.0",
|
||||||
"typescript-collections": "^1.3.3",
|
"typescript-collections": "^1.3.3",
|
||||||
|
"yargs-parser": "^20.2.7",
|
||||||
"zod": "^3.11.6"
|
"zod": "^3.11.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@graphql-codegen/cli": "^2.15.0",
|
||||||
|
"@graphql-codegen/introspection": "^2.2.1",
|
||||||
|
"@graphql-codegen/typed-document-node": "^2.3.8",
|
||||||
|
"@graphql-codegen/typescript": "^2.8.3",
|
||||||
|
"@graphql-codegen/typescript-operations": "^2.5.8",
|
||||||
|
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||||
"@next/eslint-plugin-next": "^13.0.0",
|
"@next/eslint-plugin-next": "^13.0.0",
|
||||||
|
"@types/convict": "^6.1.1",
|
||||||
"@types/jest": "^29.2.0",
|
"@types/jest": "^29.2.0",
|
||||||
"@types/micro-cors": "^0.1.2",
|
|
||||||
"@types/node": "^18.7.18",
|
"@types/node": "^18.7.18",
|
||||||
"@types/npmlog": "^4.1.3",
|
"@types/npmlog": "^4.1.3",
|
||||||
"@types/react": "^17.0.14",
|
"@types/react": "^17.0.14",
|
||||||
|
@ -60,7 +66,7 @@
|
||||||
"eslint-plugin-react": "^7.31.8",
|
"eslint-plugin-react": "^7.31.8",
|
||||||
"jest": "^29.2.2",
|
"jest": "^29.2.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"typescript": "^4.3.5"
|
"typescript": "^4.9.4"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"env": {
|
"env": {
|
||||||
|
@ -76,6 +82,9 @@
|
||||||
"tsconfig.json"
|
"tsconfig.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"src/next-env.d.ts"
|
||||||
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"@typescript-eslint/no-misused-promises": [
|
"@typescript-eslint/no-misused-promises": [
|
||||||
"error",
|
"error",
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import FeedSearch from '../../components/feed/FeedSearch'
|
||||||
|
import Layout from '../../components/server/Layout'
|
||||||
|
import createConfig from '../../config/createConfig'
|
||||||
|
|
||||||
|
export default async function Page (): Promise<ReactElement> {
|
||||||
|
const clientConfig = createConfig().get('client')
|
||||||
|
return (
|
||||||
|
<Layout title={'People'} description={'Search people on Fediverse'} config={clientConfig}>
|
||||||
|
<FeedSearch />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { ReactElement } from 'react'
|
||||||
|
|
||||||
|
export default function Head (): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<title></title>
|
||||||
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
|
export default function RootLayout ({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<head />
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import NodeSearch from '../../components/node/NodeSearch'
|
||||||
|
import Layout from '../../components/server/Layout'
|
||||||
|
import createConfig from '../../config/createConfig'
|
||||||
|
|
||||||
|
export default async function Page (): Promise<ReactElement> {
|
||||||
|
const clientConfig = createConfig().get('client')
|
||||||
|
return (
|
||||||
|
<Layout title={'Servers'} description={'Search Fediverse servers'} config={clientConfig}>
|
||||||
|
<NodeSearch />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import Accordion from '../../components/accordion/Accordion'
|
||||||
|
import AccordionItem from '../../components/accordion/AccordionItem'
|
||||||
|
import MastodonNoindexOptout from '../../components/optout/MastodonNoindexOptout'
|
||||||
|
import MastodonSuggestingOptout from '../../components/optout/MastodonSuggestingOptout'
|
||||||
|
import RobotsTxtOptout from '../../components/optout/RobotsTxtOptout'
|
||||||
|
import TagNobotOptout from '../../components/optout/TagNobotOptout'
|
||||||
|
import Layout from '../../components/server/Layout'
|
||||||
|
import createConfig from '../../config/createConfig'
|
||||||
|
|
||||||
|
export default async function Page (): Promise<ReactElement> {
|
||||||
|
const clientConfig = createConfig().get('client')
|
||||||
|
return (
|
||||||
|
<Layout title={'Opt out'} description={'What to do to opt out from the index'} config={clientConfig}>
|
||||||
|
<p>You don't want to be listed here? There are several ways to opt-out from our index:</p>
|
||||||
|
<Accordion>
|
||||||
|
<MastodonNoindexOptout/>
|
||||||
|
<MastodonSuggestingOptout/>
|
||||||
|
<TagNobotOptout/>
|
||||||
|
<RobotsTxtOptout/>
|
||||||
|
</Accordion>
|
||||||
|
<p>It can take up to <strong>3 weeks</strong> for the change to be processed and to records be deleted from
|
||||||
|
the index.</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import NodeSearch from '../../components/node/NodeSearch'
|
||||||
|
import Layout from '../../components/server/Layout'
|
||||||
|
import Stats from "../../components/stats/Stats";
|
||||||
|
import createConfig from '../../config/createConfig'
|
||||||
|
|
||||||
|
export default async function Page (): Promise<ReactElement> {
|
||||||
|
const clientConfig = createConfig().get('client')
|
||||||
|
return (
|
||||||
|
<Layout title={'Stats'} description={'Fediverse stats'} config={clientConfig}>
|
||||||
|
<Stats />
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,14 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import FallbackImage from './FallbackImage'
|
|
||||||
|
|
||||||
const Avatar: React.FC<{ url: string | null | undefined }> = ({ url }) => {
|
|
||||||
return (
|
|
||||||
<FallbackImage
|
|
||||||
className={'avatar'}
|
|
||||||
src={url ?? undefined}
|
|
||||||
fallbackSrc={'/avatar.svg'}
|
|
||||||
alt={'Avatar'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
export default Avatar
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
'use client'
|
||||||
|
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
|
export default function ErrorMessage ({ message }: { message?: string }): ReactElement {
|
||||||
|
if (message === undefined) {
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={'d-flex justify-content-center'}>
|
||||||
|
<FontAwesomeIcon icon={faExclamationTriangle} className={'margin-right'}/>
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
'use client'
|
||||||
import React, { ImgHTMLAttributes, ReactElement, useEffect, useState } from 'react'
|
import React, { ImgHTMLAttributes, ReactElement, useEffect, useState } from 'react'
|
||||||
|
|
||||||
export default function FallbackImage ({
|
export default function FallbackImage ({
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
import React, { useEffect } from 'react'
|
|
||||||
import Head from 'next/head'
|
|
||||||
import Footer from './Footer'
|
|
||||||
import getMatomo from '../lib/getMatomo'
|
|
||||||
import { UserOptions } from '@datapunt/matomo-tracker-js/es/types'
|
|
||||||
import NavBar from './NavBar'
|
|
||||||
|
|
||||||
export const siteTitle = 'FediSearch'
|
|
||||||
export const siteDescription = 'Search people on Fediverse'
|
|
||||||
|
|
||||||
const Layout: React.FC<{ matomoConfig: UserOptions, children: React.ReactNode }> = ({ matomoConfig, children }) => {
|
|
||||||
useEffect(() => {
|
|
||||||
getMatomo(matomoConfig).trackPageView()
|
|
||||||
}, [])
|
|
||||||
return (
|
|
||||||
<div className={'container'}>
|
|
||||||
<Head>
|
|
||||||
<title>{siteTitle}</title>
|
|
||||||
<link rel="icon" href="/fedisearch.png"/>
|
|
||||||
<meta name="description" content={siteDescription}/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<meta property="og:title" content={siteTitle}/>
|
|
||||||
<meta property="og:description" content={siteDescription}/>
|
|
||||||
<meta property="og:image" content="/fedisearch.png"/>
|
|
||||||
<meta property="og:type" content="website"/>
|
|
||||||
</Head>
|
|
||||||
<div className="container">
|
|
||||||
<NavBar />
|
|
||||||
<main>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
<Footer/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Layout
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
'use client'
|
||||||
|
import { faAngleDoubleDown } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import React, { MouseEventHandler, ReactElement } from 'react'
|
||||||
|
|
||||||
|
export default function LoadMoreButton (
|
||||||
|
{ onClick, show }: {
|
||||||
|
onClick: () => void
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
): ReactElement {
|
||||||
|
const handleClick: MouseEventHandler = (event): void => {
|
||||||
|
event.preventDefault()
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'d-flex justify-content-center'}>
|
||||||
|
<button className={'btn btn-secondary'} onClick={handleClick}>
|
||||||
|
<FontAwesomeIcon icon={faAngleDoubleDown} className={'margin-right'}/>
|
||||||
|
<span>Load more</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,53 +1,26 @@
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactElement, ReactNode } from 'react'
|
||||||
import Spinner from './Spinner'
|
import Spinner from './Spinner'
|
||||||
|
|
||||||
const Loader: React.FC<{ children: ReactNode, loading: boolean, hideContent?: boolean, table?: number, showTop?: boolean, showBottom?: boolean }> = ({
|
export default function Loader ({
|
||||||
showTop,
|
showTop,
|
||||||
showBottom,
|
showBottom,
|
||||||
hideContent,
|
hideContent,
|
||||||
children,
|
children,
|
||||||
table,
|
loading,
|
||||||
loading
|
placeholder
|
||||||
}) => {
|
}: {
|
||||||
const className = 'loader' + (loading ? ' -loading' : '')
|
children: ReactNode
|
||||||
|
loading: boolean
|
||||||
const spinner = (
|
hideContent?: boolean
|
||||||
<div className={'d-flex justify-content-center'}>
|
showTop?: boolean
|
||||||
<Spinner/>
|
showBottom?: boolean
|
||||||
</div>
|
placeholder?: ReactNode
|
||||||
|
}): ReactElement {
|
||||||
|
const spinner = placeholder ?? (
|
||||||
|
<div className={'d-flex justify-content-center'}>
|
||||||
|
<Spinner/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (table !== undefined || table !== 0) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{(showTop ?? false) && loading
|
|
||||||
? (
|
|
||||||
<tbody>
|
|
||||||
<tr className={className}>
|
|
||||||
<td colSpan={table}>
|
|
||||||
{spinner}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
)
|
|
||||||
: ''}
|
|
||||||
{(hideContent ?? false) && loading ? '' : children}
|
|
||||||
{(showBottom ?? false) && loading
|
|
||||||
? (
|
|
||||||
<tbody>
|
|
||||||
<tr className={className}>
|
|
||||||
<td colSpan={table}>
|
|
||||||
<div className={'d-flex justify-content-center'}>
|
|
||||||
<Spinner/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
)
|
|
||||||
: ''}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(showTop ?? false) && loading ? spinner : ''}
|
{(showTop ?? false) && loading ? spinner : ''}
|
||||||
|
@ -56,5 +29,3 @@ const Loader: React.FC<{ children: ReactNode, loading: boolean, hideContent?: bo
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Loader
|
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React, {ReactElement, ReactNode} from "react";
|
||||||
|
|
||||||
|
export default function ResponsiveTable({children, className}: {
|
||||||
|
children: ReactNode,
|
||||||
|
className?: string
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className={'table-responsive'}>
|
||||||
|
<table className={`table table-dark table-striped table-bordered nodes ${className??''}`}>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import FallbackImage from '../FallbackImage'
|
import FallbackImage from './FallbackImage'
|
||||||
|
|
||||||
const SoftwareBadge: React.FC<{ softwareName: string | null }> = ({ softwareName }) => {
|
export default function SoftwareBadge ({ softwareName }: { softwareName: string | null }): ReactElement {
|
||||||
const fallbackImage = '/software/fediverse.svg'
|
const fallbackImage = '/software/fediverse.svg'
|
||||||
|
|
||||||
return (<div className={'software-name'} title={'Software name'}>
|
return (<div className={'software-name'} title={'Software name'}>
|
||||||
|
@ -14,5 +14,3 @@ const SoftwareBadge: React.FC<{ softwareName: string | null }> = ({ softwareName
|
||||||
<span className={'value'}>{softwareName}</span>
|
<span className={'value'}>{softwareName}</span>
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SoftwareBadge
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
'use client'
|
||||||
|
import {faCircle} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import React, {ReactElement} from "react";
|
||||||
|
|
||||||
|
export default function SoftwareBadgePlaceholder():ReactElement{
|
||||||
|
return <div className={'software-name placeholder-glow'}>
|
||||||
|
<FontAwesomeIcon icon={faCircle} className={'icon'}/>
|
||||||
|
<span className={'value placeholder col-6'}/>
|
||||||
|
</div>
|
||||||
|
}
|
|
@ -1,23 +1,29 @@
|
||||||
import React from 'react'
|
import React, { MouseEventHandler, ReactElement, ReactNode } from 'react'
|
||||||
|
import { SortingWayEnum } from '../graphql/generated/types'
|
||||||
import { Sort } from '../types/Sort'
|
import { Sort } from '../types/Sort'
|
||||||
import { faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons'
|
import { faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
|
||||||
const SortToggle: React.FC<{
|
export default function SortToggle ({ onToggle, field, sort, children }: {
|
||||||
onToggle: (StatsRequestSortBy) => void
|
onToggle: (StatsRequestSortBy) => void
|
||||||
field: string
|
field: string
|
||||||
sort: Sort
|
sort: Sort
|
||||||
}> = ({ onToggle, field, sort, children }) => {
|
children: ReactNode
|
||||||
|
}): ReactElement {
|
||||||
|
const handleToggle: MouseEventHandler = (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onToggle(field)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<a className={'sort-toggle'} href={'#'} onClick={() => onToggle(field)}>
|
<a className={'sort-toggle'} href={''} onClick={handleToggle}>
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
{sort.sortBy === field && sort.sortWay === 'asc'
|
{sort.sortBy === field && sort.sortWay === SortingWayEnum.Asc
|
||||||
? (
|
? (
|
||||||
<FontAwesomeIcon icon={faSortUp} className={'margin-left'} />
|
<FontAwesomeIcon icon={faSortUp} className={'margin-left'} />
|
||||||
)
|
)
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
{sort.sortBy === field && sort.sortWay === 'desc'
|
{sort.sortBy === field && sort.sortWay === SortingWayEnum.Desc
|
||||||
? (
|
? (
|
||||||
<FontAwesomeIcon icon={faSortDown} className={'margin-left'} />
|
<FontAwesomeIcon icon={faSortDown} className={'margin-left'} />
|
||||||
)
|
)
|
||||||
|
@ -26,5 +32,3 @@ const SortToggle: React.FC<{
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SortToggle
|
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
'use client'
|
||||||
|
import React, {ReactElement, ReactNode, useContext, useState} from "react";
|
||||||
|
|
||||||
|
const AccordionContext = React.createContext<{
|
||||||
|
expandedId: string | undefined,
|
||||||
|
setExpandedId: (id: string | undefined) => void
|
||||||
|
} | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useAccordion = (id: string): [boolean, () => void] => {
|
||||||
|
const context = useContext(AccordionContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('Hook useAccordion needs to be used in Accordion element')
|
||||||
|
}
|
||||||
|
const {expandedId, setExpandedId} = context;
|
||||||
|
return [
|
||||||
|
expandedId === id,
|
||||||
|
() => {
|
||||||
|
setExpandedId(expandedId === id ? undefined : id)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Accordion({children}: {
|
||||||
|
children: ReactNode
|
||||||
|
}): ReactElement {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | undefined>(undefined)
|
||||||
|
return <AccordionContext.Provider value={{expandedId, setExpandedId}}>
|
||||||
|
<div className="accordion" id="accordionExample">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionContext.Provider>
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client'
|
||||||
|
import React, { ReactElement, ReactNode } from 'react'
|
||||||
|
import SmoothCollapse from 'react-smooth-collapse'
|
||||||
|
import { useAccordion } from './Accordion'
|
||||||
|
|
||||||
|
export default function AccordionItem ({ label, children, id }: {
|
||||||
|
label: string | ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
id: string
|
||||||
|
}): ReactElement {
|
||||||
|
const [expanded, toggle] = useAccordion(id)
|
||||||
|
return <div className="accordion-item">
|
||||||
|
<h2 className="accordion-header" id={id}>
|
||||||
|
<button
|
||||||
|
className={`accordion-button ${expanded ? '' : 'collapsed'}`}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={expanded ? 'true' : 'false'}
|
||||||
|
aria-controls={`${id}_body`}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<SmoothCollapse expanded={expanded} id={`${id}_body`} aria-labelledby={id}>
|
||||||
|
<div className="accordion-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SmoothCollapse>
|
||||||
|
</div>
|
||||||
|
}
|
|
@ -1,17 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
|
||||||
import { IconProp } from '@fortawesome/fontawesome-svg-core'
|
|
||||||
|
|
||||||
const Badge: React.FC<{ faIcon: IconProp, label: string, value: string | number | null, className?: string, showUnknown?: boolean }> = ({ faIcon, label, value, className, showUnknown }) => {
|
|
||||||
if (value === null && showUnknown !== true) {
|
|
||||||
return (<></>)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={`badge bg-secondary ${className ?? ''}`} title={label}>
|
|
||||||
<FontAwesomeIcon icon={faIcon} className={'margin-right'}/>
|
|
||||||
<span className="visually-hidden">{label}:</span>
|
|
||||||
<span className={'value'}>{value === null ? '?' : value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
export default Badge
|
|
|
@ -1,14 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { faRobot } from '@fortawesome/free-solid-svg-icons'
|
|
||||||
import Badge from './Badge'
|
|
||||||
|
|
||||||
const BotBadge: React.FC<{ bot: boolean | null }> = ({ bot }) => {
|
|
||||||
return (
|
|
||||||
<Badge faIcon={faRobot}
|
|
||||||
label={'Bot'}
|
|
||||||
value={bot !== null ? (bot ? 'Yes' : 'No') : null}
|
|
||||||
className={'bot'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
export default BotBadge
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
'use client'
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import FallbackImage from '../FallbackImage'
|
||||||
|
|
||||||
|
export default function Avatar ({ url }: { url?: string | null | undefined }): ReactElement {
|
||||||
|
return (
|
||||||
|
<FallbackImage
|
||||||
|
className={'avatar'}
|
||||||
|
src={url ?? '/avatar.svg'}
|
||||||
|
fallbackSrc={'/avatar.svg'}
|
||||||
|
alt={'Avatar'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
'use client'
|
||||||
|
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import { FeedQueryInput } from '../../graphql/generated/types'
|
||||||
|
import SearchInput from "../form/SearchInput";
|
||||||
|
import SubmitButton from "../form/SubmitButton";
|
||||||
|
|
||||||
|
export default function FeedForm (
|
||||||
|
{ onSubmit, onQueryChange, query }: {
|
||||||
|
onSubmit: () => void
|
||||||
|
onQueryChange: (query: FeedQueryInput) => void
|
||||||
|
query: FeedQueryInput
|
||||||
|
}
|
||||||
|
): ReactElement {
|
||||||
|
const handleQueryChange = (event): void => {
|
||||||
|
const inputElement = event.target
|
||||||
|
const value = inputElement.value
|
||||||
|
const name = inputElement.name
|
||||||
|
const newQuery = {
|
||||||
|
...query
|
||||||
|
}
|
||||||
|
newQuery[name] = value
|
||||||
|
onQueryChange(newQuery)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (event): void => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="input-group mb-3">
|
||||||
|
<SearchInput
|
||||||
|
label={'Search people on Fediverse'}
|
||||||
|
onChange={handleQueryChange}
|
||||||
|
value={query.search ?? ''}
|
||||||
|
describedBy="search-feeds-button"
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
faIcon={faSearch}
|
||||||
|
label={'Search'}
|
||||||
|
id={"search-feeds-button"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import {ReactElement, ReactNode} from "react";
|
||||||
|
|
||||||
|
export default function FeedInfo({children, show}: { children?: ReactNode, show?: boolean }): ReactElement {
|
||||||
|
if (show === false) {
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
return <></>
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { faCircle } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import SoftwareBadge from "../SoftwareBadge";
|
||||||
|
import SoftwareBadgePlaceholder from "../SoftwareBadgePlaceholder";
|
||||||
|
import Avatar from './Avatar'
|
||||||
|
import Badge from './badges/Badge'
|
||||||
|
|
||||||
|
export default function FeedPlaceholder (): ReactElement {
|
||||||
|
const greyDotBlob = 'data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='
|
||||||
|
return (
|
||||||
|
<section className="card feed g-col-12 mb-3" aria-hidden="true">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className={'card-title with-emoji display-name placeholder-glow'}>
|
||||||
|
<a><span className="placeholder col-4"></span></a>
|
||||||
|
</h3>
|
||||||
|
<Avatar url={greyDotBlob} />
|
||||||
|
<div className={'address placeholder-glow'}>
|
||||||
|
<span className="placeholder col-6"></span>
|
||||||
|
</div>
|
||||||
|
<SoftwareBadgePlaceholder />
|
||||||
|
<div className={'badges placeholder-glow'}>
|
||||||
|
<Badge faIcon={faCircle}
|
||||||
|
label={''}
|
||||||
|
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
|
||||||
|
/>
|
||||||
|
<Badge faIcon={faCircle}
|
||||||
|
label={''}
|
||||||
|
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
|
||||||
|
/>
|
||||||
|
<Badge faIcon={faCircle}
|
||||||
|
label={''}
|
||||||
|
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
|
||||||
|
/>
|
||||||
|
<Badge faIcon={faCircle}
|
||||||
|
label={''}
|
||||||
|
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
|
||||||
|
/>
|
||||||
|
<Badge faIcon={faCircle}
|
||||||
|
label={''}
|
||||||
|
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
|
||||||
|
/>
|
||||||
|
<Badge faIcon={faCircle}
|
||||||
|
label={''}
|
||||||
|
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
|
||||||
|
/>
|
||||||
|
<Badge faIcon={faCircle}
|
||||||
|
label={''}
|
||||||
|
value={<span className={'placeholder col-4'} style={{ minWidth: '40px' }}/>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={'table-responsive fields'}>
|
||||||
|
<table className={'table'}>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th className={'with-emoji table-active placeholder-glow'} style={{ width: '30%' }}>
|
||||||
|
<span className={'placeholder col-8'}/>
|
||||||
|
</th>
|
||||||
|
<td className={'with-emoji placeholder-glow'}>
|
||||||
|
<span className={'placeholder col-10'}/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th className={'with-emoji table-active placeholder-glow'} style={{ width: '30%' }}>
|
||||||
|
<span className={'placeholder col-8'}/>
|
||||||
|
</th>
|
||||||
|
<td className={'with-emoji placeholder-glow'}>
|
||||||
|
<span className={'placeholder col-10'}/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className={'description placeholder-glow'}>
|
||||||
|
<p>
|
||||||
|
<span className={'placeholder col-4'}/>
|
||||||
|
<span className={'placeholder col-4'}/>
|
||||||
|
<span className={'placeholder col-2'}/>
|
||||||
|
<span className={'placeholder col-3'}/>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className={'placeholder col-4'}/>
|
||||||
|
<span className={'placeholder col-4'}/>
|
||||||
|
<span className={'placeholder col-2'}/>
|
||||||
|
<span className={'placeholder col-3'}/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { ReactElement, useEffect } from 'react'
|
import React, { ReactElement, useEffect } from 'react'
|
||||||
import striptags from 'striptags'
|
import striptags from 'striptags'
|
||||||
|
import { ListFeedsItemFragment } from '../../graphql/generated/types'
|
||||||
import Avatar from './Avatar'
|
import Avatar from './Avatar'
|
||||||
import SoftwareBadge from './badges/SoftwareBadge'
|
import SoftwareBadge from '../SoftwareBadge'
|
||||||
import FeedTypeBadge from './badges/FeedTypeBadge'
|
import FeedTypeBadge from './badges/FeedTypeBadge'
|
||||||
import CreatedAtBadge from './badges/CreatedAtBadge'
|
import CreatedAtBadge from './badges/CreatedAtBadge'
|
||||||
import LastPostAtBadge from './badges/LastPostAtBadge'
|
import LastPostAtBadge from './badges/LastPostAtBadge'
|
||||||
|
@ -10,11 +11,8 @@ import ParentFeed from './ParentFeed'
|
||||||
import StatusesCountBadge from './badges/StatusesCountBadge'
|
import StatusesCountBadge from './badges/StatusesCountBadge'
|
||||||
import FollowersBadge from './badges/FollowersBadge'
|
import FollowersBadge from './badges/FollowersBadge'
|
||||||
import FollowingBadge from './badges/FollowingBadge'
|
import FollowingBadge from './badges/FollowingBadge'
|
||||||
import { FeedResultItem } from '../graphql/client/queries/ListFeedsQuery'
|
|
||||||
|
|
||||||
const FeedResult = ({
|
export default function FeedResult ({ feed }: { feed: ListFeedsItemFragment }): ReactElement {
|
||||||
feed
|
|
||||||
}: { feed: FeedResultItem }): ReactElement => {
|
|
||||||
const fallbackEmojiImage = '/emoji.svg'
|
const fallbackEmojiImage = '/emoji.svg'
|
||||||
|
|
||||||
const handleEmojiImageError = (event): void => {
|
const handleEmojiImageError = (event): void => {
|
||||||
|
@ -44,7 +42,7 @@ const FeedResult = ({
|
||||||
<span>{feed.id}</span>
|
<span>{feed.id}</span>
|
||||||
<ParentFeed feed={feed.parent}/>
|
<ParentFeed feed={feed.parent}/>
|
||||||
</div>
|
</div>
|
||||||
<SoftwareBadge softwareName={feed.node.softwareName}/>
|
<SoftwareBadge softwareName={feed.node.softwareName ?? ''}/>
|
||||||
<div className={'badges'}>
|
<div className={'badges'}>
|
||||||
<FeedTypeBadge type={feed.type}/>
|
<FeedTypeBadge type={feed.type}/>
|
||||||
<FollowersBadge followers={feed.followersCount}/>
|
<FollowersBadge followers={feed.followersCount}/>
|
||||||
|
@ -79,7 +77,6 @@ const FeedResult = ({
|
||||||
<div className={'description with-emoji'}
|
<div className={'description with-emoji'}
|
||||||
dangerouslySetInnerHTML={{ __html: striptags(feed.description, ['img', 'p', 'strong', 'em', 'br', 'a']) }}/>
|
dangerouslySetInnerHTML={{ __html: striptags(feed.description, ['img', 'p', 'strong', 'em', 'br', 'a']) }}/>
|
||||||
</div>
|
</div>
|
||||||
</section>)
|
</section>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FeedResult
|
|
|
@ -1,10 +1,11 @@
|
||||||
import React, { ReactElement } from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
import { ListFeedsItemFragment } from '../../graphql/generated/types'
|
||||||
import FeedResult from './FeedResult'
|
import FeedResult from './FeedResult'
|
||||||
import { FeedResultItem } from '../graphql/client/queries/ListFeedsQuery'
|
|
||||||
|
|
||||||
const FeedResults = ({
|
export default function FeedResults ({ feeds }: { feeds: ListFeedsItemFragment[] | undefined }): ReactElement {
|
||||||
feeds
|
if (feeds === undefined) {
|
||||||
}: { feeds: FeedResultItem[] }): ReactElement => {
|
return <></>
|
||||||
|
}
|
||||||
if (feeds.length === 0) {
|
if (feeds.length === 0) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -21,5 +22,3 @@ const FeedResults = ({
|
||||||
}
|
}
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FeedResults
|
|
|
@ -0,0 +1,110 @@
|
||||||
|
'use client'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { FeedQueryInput, ListFeedsDocument } from '../../graphql/generated/types'
|
||||||
|
import { useMatomo } from '../../hooks/MatomoHook'
|
||||||
|
import createUrlSearchParams from '../../utils/createUrlSearchParams'
|
||||||
|
import { stringTrimmed, transform } from '../../utils/transform'
|
||||||
|
import FeedInfo from './FeedInfo'
|
||||||
|
import FeedResults from './FeedResults'
|
||||||
|
import Loader from '../Loader'
|
||||||
|
import ErrorMessage from '../ErrorMessage'
|
||||||
|
import LoadMoreButton from '../LoadMoreButton'
|
||||||
|
import FeedForm from './FeedForm'
|
||||||
|
import FeedPlaceholder from './FeedPlaceholder'
|
||||||
|
|
||||||
|
export const feedQueryInputSchema = z.object({
|
||||||
|
search: transform(
|
||||||
|
z.string().optional(),
|
||||||
|
stringTrimmed,
|
||||||
|
z.string()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function FeedSearch (): ReactElement {
|
||||||
|
const matomo = useMatomo()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const routerQuery = feedQueryInputSchema.parse(Object.fromEntries(searchParams))
|
||||||
|
const [page, setPage] = useState<number>(0)
|
||||||
|
const [query, setQuery] = useState<FeedQueryInput>(routerQuery)
|
||||||
|
const [pageLoading, setPageLoading] = useState<boolean>(false)
|
||||||
|
const { loading, data, error, fetchMore, refetch } = useQuery(ListFeedsDocument, {
|
||||||
|
variables: {
|
||||||
|
paging: { page: 0 },
|
||||||
|
query
|
||||||
|
}
|
||||||
|
})
|
||||||
|
useEffect((): void => {
|
||||||
|
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
||||||
|
matomo.trackEvent({
|
||||||
|
category: 'feeds',
|
||||||
|
action: 'new-search'
|
||||||
|
})
|
||||||
|
}, [query])
|
||||||
|
useEffect(() => {
|
||||||
|
matomo.trackEvent({
|
||||||
|
category: 'feeds',
|
||||||
|
action: 'next-page',
|
||||||
|
customDimensions: [
|
||||||
|
{
|
||||||
|
value: page.toString(),
|
||||||
|
id: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
const handleQueryChange = (query: FeedQueryInput): void => {
|
||||||
|
setQuery(query)
|
||||||
|
setPage(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchSubmit = async (): Promise<void> => {
|
||||||
|
setPageLoading(true)
|
||||||
|
setPage(0)
|
||||||
|
await refetch({ paging: { page: 0 } })
|
||||||
|
setPageLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadMore = async (): Promise<void> => {
|
||||||
|
setPageLoading(true)
|
||||||
|
await fetchMore({
|
||||||
|
variables: {
|
||||||
|
paging: { page: page + 1 }
|
||||||
|
},
|
||||||
|
updateQuery: (previousData, { fetchMoreResult }) => {
|
||||||
|
if (undefined === fetchMoreResult?.listFeeds?.items) {
|
||||||
|
return previousData
|
||||||
|
}
|
||||||
|
if (undefined === previousData?.listFeeds?.items) {
|
||||||
|
return fetchMoreResult
|
||||||
|
}
|
||||||
|
fetchMoreResult.listFeeds.items = [
|
||||||
|
...previousData.listFeeds.items,
|
||||||
|
...fetchMoreResult.listFeeds.items
|
||||||
|
]
|
||||||
|
return fetchMoreResult
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setPageLoading(false)
|
||||||
|
setPage(page + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<FeedForm query={query} onQueryChange={handleQueryChange} onSubmit={handleSearchSubmit}/>
|
||||||
|
<FeedInfo show={query.search === ''}>
|
||||||
|
<Loader loading={loading || pageLoading} showBottom={true} placeholder={(<FeedPlaceholder/>)}>
|
||||||
|
<FeedResults feeds={data?.listFeeds?.items}/>
|
||||||
|
</Loader>
|
||||||
|
<LoadMoreButton
|
||||||
|
show={!loading && !pageLoading && data?.listFeeds?.paging?.hasNext === true}
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
/>
|
||||||
|
<ErrorMessage message={error?.message}/>
|
||||||
|
</FeedInfo>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
import { Maybe, ParentFeedFragment } from '../../graphql/generated/types'
|
||||||
import Avatar from './Avatar'
|
import Avatar from './Avatar'
|
||||||
import { ParentFeedItem } from '../graphql/client/queries/ListFeedsQuery'
|
|
||||||
|
|
||||||
const ParentFeed: React.FC<{ feed: ParentFeedItem | null }> = ({ feed }) => {
|
export default function ParentFeed ({ feed }: { feed: Maybe<ParentFeedFragment> | undefined }): ReactElement {
|
||||||
if (feed == null) {
|
if (feed === null || feed === undefined) {
|
||||||
return (<></>)
|
return (<></>)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
@ -16,5 +16,3 @@ const ParentFeed: React.FC<{ feed: ParentFeedItem | null }> = ({ feed }) => {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ParentFeed
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||||
|
|
||||||
|
export default function Badge ({ faIcon, label, value, className, showUnknown }: {
|
||||||
|
faIcon: IconProp
|
||||||
|
label: string
|
||||||
|
value: string | number | null | undefined | ReactElement
|
||||||
|
className?: string
|
||||||
|
showUnknown?: boolean
|
||||||
|
}): ReactElement {
|
||||||
|
if ((value === null || value === undefined) && showUnknown !== true) {
|
||||||
|
return (<></>)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`badge bg-secondary ${className ?? ''}`} title={label}>
|
||||||
|
<FontAwesomeIcon icon={faIcon} className={'margin-right'}/>
|
||||||
|
<span className="visually-hidden">{label}:</span>
|
||||||
|
<span className={'value'}>{value === null || value === undefined ? '?' : value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import { faRobot } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import Badge from './Badge'
|
||||||
|
|
||||||
|
export default function BotBadge ({ bot }: { bot: boolean | null | undefined }): ReactElement {
|
||||||
|
return (
|
||||||
|
<Badge faIcon={faRobot}
|
||||||
|
label={'Bot'}
|
||||||
|
value={bot !== null && bot !== undefined ? (bot ? 'Yes' : 'No') : null}
|
||||||
|
className={'bot'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import { faCalendarPlus } from '@fortawesome/free-solid-svg-icons'
|
import { faCalendarPlus } from '@fortawesome/free-solid-svg-icons'
|
||||||
import Badge from './Badge'
|
import Badge from './Badge'
|
||||||
|
|
||||||
const CreatedAtBadge: React.FC<{ createdAt: string | null }> = ({ createdAt }) => {
|
export default function CreatedAtBadge ({ createdAt }: { createdAt: string | null }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Badge faIcon={faCalendarPlus}
|
<Badge faIcon={faCalendarPlus}
|
||||||
label={'Created at'}
|
label={'Created at'}
|
||||||
|
@ -11,4 +11,3 @@ const CreatedAtBadge: React.FC<{ createdAt: string | null }> = ({ createdAt }) =
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default CreatedAtBadge
|
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import { faRss, faUser } from '@fortawesome/free-solid-svg-icons'
|
import { faRss, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||||
import Badge from './Badge'
|
import Badge from './Badge'
|
||||||
|
|
||||||
const FeedTypeBadge: React.FC<{ type: 'account' | 'channel' }> = ({ type }) => {
|
export default function FeedTypeBadge ({ type }: { type: 'account' | 'channel' }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Badge faIcon={type === 'channel' ? faRss : faUser}
|
<Badge faIcon={type === 'channel' ? faRss : faUser}
|
||||||
label={'Feed type'}
|
label={'Feed type'}
|
||||||
|
@ -11,5 +11,3 @@ const FeedTypeBadge: React.FC<{ type: 'account' | 'channel' }> = ({ type }) => {
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FeedTypeBadge
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { faUserFriends } from '@fortawesome/free-solid-svg-icons'
|
import { faUserFriends } from '@fortawesome/free-solid-svg-icons'
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import Badge from './Badge'
|
import Badge from './Badge'
|
||||||
|
|
||||||
const FollowersBadge: React.FC<{ followers: number | null }> = ({ followers }) => {
|
export default function FollowersBadge ({ followers }: { followers: number | null | undefined }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Badge faIcon={faUserFriends}
|
<Badge faIcon={faUserFriends}
|
||||||
label={'Followers'}
|
label={'Followers'}
|
||||||
|
@ -11,4 +11,3 @@ const FollowersBadge: React.FC<{ followers: number | null }> = ({ followers }) =
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default FollowersBadge
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { faEye } from '@fortawesome/free-solid-svg-icons'
|
import { faEye } from '@fortawesome/free-solid-svg-icons'
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import Badge from './Badge'
|
import Badge from './Badge'
|
||||||
|
|
||||||
const FollowingBadge: React.FC<{ following: number | null }> = ({ following }) => {
|
export default function FollowingBadge ({ following }: { following: number | null | undefined }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Badge faIcon={faEye}
|
<Badge faIcon={faEye}
|
||||||
label={'Following'}
|
label={'Following'}
|
||||||
|
@ -11,4 +11,3 @@ const FollowingBadge: React.FC<{ following: number | null }> = ({ following }) =
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default FollowingBadge
|
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import Badge from './Badge'
|
import Badge from './Badge'
|
||||||
import { faCalendarCheck } from '@fortawesome/free-solid-svg-icons'
|
import { faCalendarCheck } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
const LastPostAtBadge: React.FC<{ lastStatusAt: string | null }> = ({ lastStatusAt }) => {
|
export default function LastPostAtBadge ({ lastStatusAt }: { lastStatusAt: string | null }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Badge faIcon={faCalendarCheck}
|
<Badge faIcon={faCalendarCheck}
|
||||||
label={'Last status at'}
|
label={'Last status at'}
|
||||||
|
@ -11,5 +11,3 @@ const LastPostAtBadge: React.FC<{ lastStatusAt: string | null }> = ({ lastStatus
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LastPostAtBadge
|
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
import { faCommentAlt } from '@fortawesome/free-solid-svg-icons'
|
import { faCommentAlt } from '@fortawesome/free-solid-svg-icons'
|
||||||
import Badge from './Badge'
|
import Badge from './Badge'
|
||||||
|
|
||||||
const StatusesCountBadge: React.FC<{ statusesCount: number | null }> = ({ statusesCount }) => {
|
export default function StatusesCountBadge ({ statusesCount }: { statusesCount: number | null | undefined }): ReactElement {
|
||||||
return (
|
return (
|
||||||
<Badge faIcon={faCommentAlt}
|
<Badge faIcon={faCommentAlt}
|
||||||
label={'Status count'}
|
label={'Status count'}
|
||||||
|
@ -11,5 +11,3 @@ const StatusesCountBadge: React.FC<{ statusesCount: number | null }> = ({ status
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default StatusesCountBadge
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
import React, {ChangeEventHandler, ReactElement} from "react";
|
||||||
|
|
||||||
|
export default function SearchInput({label, onChange, value, describedBy}: {
|
||||||
|
label: string,
|
||||||
|
onChange?: ChangeEventHandler,
|
||||||
|
value?: string,
|
||||||
|
describedBy?: string
|
||||||
|
}): ReactElement {
|
||||||
|
return <input
|
||||||
|
name={'search'}
|
||||||
|
id={'search'}
|
||||||
|
type={'search'}
|
||||||
|
className={'form-control'}
|
||||||
|
onChange={onChange}
|
||||||
|
value={value}
|
||||||
|
placeholder={label}
|
||||||
|
autoFocus={true}
|
||||||
|
aria-label={label}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
/>
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import {IconProp} from "@fortawesome/fontawesome-svg-core";
|
||||||
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
|
import React, {ReactElement} from "react";
|
||||||
|
|
||||||
|
export default function SubmitButton({faIcon, label, id}: {
|
||||||
|
faIcon: IconProp,
|
||||||
|
label: string,
|
||||||
|
id?: string
|
||||||
|
}): ReactElement {
|
||||||
|
return <button type={'submit'} className={'btn btn-primary'} id={id}>
|
||||||
|
<FontAwesomeIcon icon={faIcon}/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
'use client'
|
||||||
|
import React, { ReactElement, ReactNode, useEffect } from 'react'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import { useMatomo } from '../../hooks/MatomoHook'
|
||||||
|
import Footer from './Footer'
|
||||||
|
import NavBar from './NavBar'
|
||||||
|
|
||||||
|
export default function ClientLayout ({
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
children?: ReactNode
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}): ReactElement {
|
||||||
|
const matomo = useMatomo()
|
||||||
|
useEffect(() => {
|
||||||
|
matomo.trackPageView()
|
||||||
|
}, [])
|
||||||
|
return (
|
||||||
|
<div className={'container'}>
|
||||||
|
<Head>
|
||||||
|
<title>{title}</title>
|
||||||
|
<link rel="icon" href="/fedisearch.png"/>
|
||||||
|
<meta name="description" content={description}/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<meta property="og:title" content={title}/>
|
||||||
|
<meta property="og:description" content={description}/>
|
||||||
|
<meta property="og:image" content="/fedisearch.png"/>
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
|
</Head>
|
||||||
|
<div className="container">
|
||||||
|
<NavBar />
|
||||||
|
<main>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ApolloProvider } from '@apollo/client'
|
||||||
|
import React, { ReactElement, ReactNode } from 'react'
|
||||||
|
import ClientConfig from '../../config/ClientConfig'
|
||||||
|
import createGraphqlClient from '../../graphql/client/createGraphqlClient'
|
||||||
|
import { MatomoProvider } from '../../hooks/MatomoHook'
|
||||||
|
import createMatomo from '../../matomo/createMatomo'
|
||||||
|
|
||||||
|
export default function ClientProviders ({
|
||||||
|
children, config
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
config: ClientConfig
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<ApolloProvider client={createGraphqlClient(config.graphql)}>
|
||||||
|
<MatomoProvider matomo={createMatomo(config.matomo)}>
|
||||||
|
{children}
|
||||||
|
</MatomoProvider>
|
||||||
|
</ApolloProvider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react'
|
import React, { ReactElement } from 'react'
|
||||||
|
|
||||||
const Footer: React.FC = () => {
|
export default function Footer (): ReactElement {
|
||||||
return (
|
return (
|
||||||
<footer className={'text-center mt-5'}>
|
<footer className={'text-center mt-5'}>
|
||||||
<p><a href={'/optout'}>How to opt-out</a></p>
|
<p><a href={'/optout'}>How to opt-out</a></p>
|
||||||
|
@ -8,5 +8,3 @@ const Footer: React.FC = () => {
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Footer
|
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState } from 'react'
|
'use client'
|
||||||
|
import React, { ReactElement, useState } from 'react'
|
||||||
import NavItem from './NavItem'
|
import NavItem from './NavItem'
|
||||||
import { faUser, faServer, faChartPie } from '@fortawesome/free-solid-svg-icons'
|
import { faUser, faServer, faChartPie } from '@fortawesome/free-solid-svg-icons'
|
||||||
import FallbackImage from './FallbackImage'
|
import FallbackImage from '../FallbackImage'
|
||||||
|
|
||||||
const NavBar: React.FC = () => {
|
export default function NavBar (): ReactElement {
|
||||||
const [showMenu, setShowMenu] = useState<boolean>(false)
|
const [showMenu, setShowMenu] = useState<boolean>(false)
|
||||||
return (
|
return (
|
||||||
<nav className="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
<nav className="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||||
|
@ -31,5 +32,3 @@ const NavBar: React.FC = () => {
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NavBar
|
|
|
@ -1,19 +1,21 @@
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import React, { FC } from 'react'
|
import React, { FC } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/router'
|
|
||||||
import { IconProp } from '@fortawesome/fontawesome-svg-core'
|
import { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
|
||||||
const NavItem: FC<{ path: string, label: string, icon: IconProp }> = ({ path, label, icon }) => {
|
const NavItem: FC<{ path: string, label: string, icon: IconProp }> = ({ path, label, icon }) => {
|
||||||
const router = useRouter()
|
const currentPath = usePathname()
|
||||||
const active = router.pathname === path
|
const active = currentPath === path
|
||||||
return (
|
return (
|
||||||
<li className={'nav-item'}>
|
<li className={'nav-item'}>
|
||||||
<Link href={path}>
|
<Link
|
||||||
<a className={'nav-link' + (active ? ' active' : '')} aria-current={active ? 'page' : undefined}>
|
href={path}
|
||||||
|
className={'nav-link' + (active ? ' active' : '')}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
>
|
||||||
<FontAwesomeIcon icon={icon} className={'margin-right'} />
|
<FontAwesomeIcon icon={icon} className={'margin-right'} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</a>
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
|
@ -0,0 +1,49 @@
|
||||||
|
'use client'
|
||||||
|
import {faSearch} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import React, {ReactElement} from 'react'
|
||||||
|
import {NodeQueryInput} from '../../graphql/generated/types'
|
||||||
|
import SearchInput from "../form/SearchInput";
|
||||||
|
import SubmitButton from "../form/SubmitButton";
|
||||||
|
|
||||||
|
export default function NodeForm(
|
||||||
|
{onSubmit, onQueryChange, query}: {
|
||||||
|
onSubmit: () => void
|
||||||
|
onQueryChange: (query: NodeQueryInput) => void
|
||||||
|
query: NodeQueryInput
|
||||||
|
}
|
||||||
|
): ReactElement {
|
||||||
|
const handleQueryChange = (event): void => {
|
||||||
|
const inputElement = event.target
|
||||||
|
const value = inputElement.value
|
||||||
|
const name = inputElement.name
|
||||||
|
const newQuery = {
|
||||||
|
...query
|
||||||
|
}
|
||||||
|
newQuery[name] = value
|
||||||
|
onQueryChange(newQuery)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (event): void => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className={'input-group mb-3'}>
|
||||||
|
<SearchInput
|
||||||
|
label={'Search Fediverse servers'}
|
||||||
|
value={query.search}
|
||||||
|
onChange={handleQueryChange}
|
||||||
|
describedBy={'search-nodes-button'}
|
||||||
|
/>
|
||||||
|
<SubmitButton
|
||||||
|
label={'Search'}
|
||||||
|
faIcon={faSearch}
|
||||||
|
id={'search-nodes-button'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React, {ReactElement} from "react";
|
||||||
|
import {NodeQueryInput, NodeSortingByEnum} from "../../graphql/generated/types";
|
||||||
|
import SortToggle from "../SortToggle";
|
||||||
|
|
||||||
|
export default function NodeHeader({query,onSortToggle}:{
|
||||||
|
query: NodeQueryInput
|
||||||
|
onSortToggle: (sortBy: NodeSortingByEnum)=> void
|
||||||
|
}):ReactElement{
|
||||||
|
return (
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowSpan={2}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'domain'} sort={query}>
|
||||||
|
Domain
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'softwareName'} sort={query}>
|
||||||
|
Software
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th colSpan={4}>User count</th>
|
||||||
|
<th rowSpan={2} className={'number-cell'}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'statusesCount'} sort={query}>
|
||||||
|
Statuses
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'openRegistrations'} sort={query}>
|
||||||
|
Registrations
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'refreshedAt'} sort={query}>
|
||||||
|
Last refreshed
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th className={'text-end'}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'totalUserCount'} sort={query}>
|
||||||
|
Total
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th className={'text-end'}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'accountFeedCount'} sort={query}>
|
||||||
|
Indexed
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th className={'text-end'}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'monthActiveUserCount'} sort={query}>
|
||||||
|
Month active
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th className={'text-end'}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'halfYearActiveUserCount'} sort={query}>
|
||||||
|
Half year active
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import SoftwareBadgePlaceholder from '../SoftwareBadgePlaceholder'
|
||||||
|
|
||||||
|
export default function NodePlaceholder (): ReactElement {
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className={'placeholder-glow'}><span className={'placeholder col-10'}/></td>
|
||||||
|
<td>
|
||||||
|
<div><SoftwareBadgePlaceholder /></div>
|
||||||
|
<div className={'placeholder-glow'}><span className={'placeholder col-5'}/></div>
|
||||||
|
</td>
|
||||||
|
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
|
||||||
|
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
|
||||||
|
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
|
||||||
|
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
|
||||||
|
<td className={'text-end placeholder-glow'}><span className={'placeholder col-3'}/></td>
|
||||||
|
<td className={' placeholder-glow'}><span className={'placeholder col-6'}/></td>
|
||||||
|
<td className={' placeholder-glow'}><span className={'placeholder col-6'}/></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import { ListNodesItemFragment } from '../../graphql/generated/types'
|
||||||
|
import SoftwareBadge from '../SoftwareBadge'
|
||||||
|
|
||||||
|
export default function NodeResult ({ node }: { node: ListNodesItemFragment }): ReactElement {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>{node.domain}</td>
|
||||||
|
<td>
|
||||||
|
<div title={'Name'}>
|
||||||
|
<SoftwareBadge softwareName={node.softwareName ?? null}/>
|
||||||
|
</div>
|
||||||
|
<div title={`Version: ${node.softwareVersion ?? '?'}`}>{node.standardizedSoftwareVersion ?? ''}</div>
|
||||||
|
</td>
|
||||||
|
<td className={'text-end'}>{node.totalUserCount ?? '?'}</td>
|
||||||
|
<td className={'text-end'}>{node.accountFeedCount ?? '0'}</td>
|
||||||
|
<td className={'text-end'}>{node.monthActiveUserCount ?? '?'}</td>
|
||||||
|
<td className={'text-end'}>{node.halfYearActiveUserCount ?? '?'}</td>
|
||||||
|
<td className={'text-end'}>{node.statusesCount ?? '?'}</td>
|
||||||
|
<td>{
|
||||||
|
node.openRegistrations === null || node.openRegistrations === undefined
|
||||||
|
? '?'
|
||||||
|
: (node.openRegistrations ? 'Opened' : 'Closed')
|
||||||
|
}</td>
|
||||||
|
<td>{node.refreshedAt !== '' ? (new Date(node.refreshedAt)).toLocaleDateString() : 'Never'}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import React, {ReactElement} from "react";
|
||||||
|
import {ListNodesItemFragment} from "../../graphql/generated/types";
|
||||||
|
import NodeResult from "./NodeResult";
|
||||||
|
|
||||||
|
export default function NodeResults({nodes}:{
|
||||||
|
nodes:ListNodesItemFragment[]|undefined,
|
||||||
|
}):ReactElement{
|
||||||
|
if(nodes === undefined){
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{(nodes.length > 0)
|
||||||
|
? nodes.map((node, index) => {
|
||||||
|
return (
|
||||||
|
<NodeResult node={node} key={index}/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9}>No servers found</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,160 @@
|
||||||
|
'use client'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import React, { ReactElement, useEffect, useState } from 'react'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
FeedQueryInput,
|
||||||
|
ListNodesDocument,
|
||||||
|
NodeQueryInput,
|
||||||
|
NodeSortingByEnum,
|
||||||
|
SortingWayEnum
|
||||||
|
} from '../../graphql/generated/types'
|
||||||
|
import { useMatomo } from '../../hooks/MatomoHook'
|
||||||
|
import createUrlSearchParams from '../../utils/createUrlSearchParams'
|
||||||
|
import { stringTrimmed, transform } from '../../utils/transform'
|
||||||
|
import createSortingInputSchema from '../../schema/createSortingInputSchema'
|
||||||
|
import ErrorMessage from '../ErrorMessage'
|
||||||
|
import Loader from '../Loader'
|
||||||
|
import LoadMoreButton from '../LoadMoreButton'
|
||||||
|
import ResponsiveTable from '../ResponsiveTable'
|
||||||
|
import NodeForm from './NodeForm'
|
||||||
|
import NodeHeader from './NodeHeader'
|
||||||
|
import NodePlaceholder from './NodePlaceholder'
|
||||||
|
import NodeResults from './NodeResults'
|
||||||
|
|
||||||
|
export const nodeQueryInputSchema = createSortingInputSchema(NodeSortingByEnum)
|
||||||
|
.extend(
|
||||||
|
{
|
||||||
|
search: transform(
|
||||||
|
z.string().optional(),
|
||||||
|
stringTrimmed,
|
||||||
|
z.string()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default function NodeSearch (): ReactElement {
|
||||||
|
const matomo = useMatomo()
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
let routerQuery: NodeQueryInput
|
||||||
|
try {
|
||||||
|
routerQuery = nodeQueryInputSchema.parse(Object.fromEntries(searchParams))
|
||||||
|
} catch (e) {
|
||||||
|
routerQuery = {
|
||||||
|
search: '',
|
||||||
|
sortBy: NodeSortingByEnum.RefreshedAt,
|
||||||
|
sortWay: SortingWayEnum.Desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Router query', routerQuery)
|
||||||
|
const [query, setQuery] = useState<NodeQueryInput>(routerQuery)
|
||||||
|
const [page, setPage] = useState<number>(0)
|
||||||
|
const [pageLoading, setPageLoading] = useState<boolean>(false)
|
||||||
|
const { loading, error, data, fetchMore, refetch } = useQuery(ListNodesDocument, {
|
||||||
|
variables: {
|
||||||
|
query,
|
||||||
|
paging: {
|
||||||
|
page: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
matomo.trackEvent({
|
||||||
|
category: 'nodes',
|
||||||
|
action: 'next-page',
|
||||||
|
customDimensions: [
|
||||||
|
{
|
||||||
|
value: page.toString(),
|
||||||
|
id: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}, [page])
|
||||||
|
useEffect((): void => {
|
||||||
|
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
||||||
|
matomo.trackEvent({
|
||||||
|
category: 'nodes',
|
||||||
|
action: 'new-search'
|
||||||
|
})
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
const handleQueryChange = (query: FeedQueryInput): void => {
|
||||||
|
console.info('Query changed', { query })
|
||||||
|
setQuery(query)
|
||||||
|
setPage(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchSubmit = async (): Promise<void> => {
|
||||||
|
setPageLoading(true)
|
||||||
|
setQuery(query)
|
||||||
|
setPage(0)
|
||||||
|
await refetch({ paging: { page: 0 } })
|
||||||
|
setPageLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLoadMore = async (): Promise<void> => {
|
||||||
|
setPage(page + 1)
|
||||||
|
console.info('Loading next page', { query, page })
|
||||||
|
setPageLoading(true)
|
||||||
|
await fetchMore({
|
||||||
|
variables: {
|
||||||
|
paging: { page: page + 1 }
|
||||||
|
},
|
||||||
|
updateQuery: (previousData, { fetchMoreResult }) => {
|
||||||
|
console.log('more', {
|
||||||
|
previousData, fetchMoreResult
|
||||||
|
})
|
||||||
|
if (undefined === fetchMoreResult?.listNodes?.items) {
|
||||||
|
return previousData
|
||||||
|
}
|
||||||
|
if (undefined === previousData?.listNodes?.items) {
|
||||||
|
return fetchMoreResult
|
||||||
|
}
|
||||||
|
fetchMoreResult.listNodes.items = [
|
||||||
|
...previousData.listNodes.items,
|
||||||
|
...fetchMoreResult.listNodes.items
|
||||||
|
]
|
||||||
|
return fetchMoreResult
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setPageLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSort = (sortBy: NodeSortingByEnum): void => {
|
||||||
|
const sortWay = query.sortBy === sortBy && query.sortWay === SortingWayEnum.Asc ? SortingWayEnum.Desc : SortingWayEnum.Asc
|
||||||
|
matomo.trackEvent({
|
||||||
|
category: 'nodes',
|
||||||
|
action: 'sort',
|
||||||
|
customDimensions: [
|
||||||
|
{
|
||||||
|
value: `${sortBy} ${sortWay}`,
|
||||||
|
id: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
setQuery({
|
||||||
|
...query,
|
||||||
|
sortBy,
|
||||||
|
sortWay
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NodeForm query={query} onQueryChange={handleQueryChange} onSubmit={handleSearchSubmit}/>
|
||||||
|
<ResponsiveTable>
|
||||||
|
<NodeHeader onSortToggle={toggleSort} query={query}/>
|
||||||
|
<Loader loading={loading || pageLoading} showBottom={true} placeholder={(<NodePlaceholder/>)}>
|
||||||
|
<NodeResults nodes={data?.listNodes?.items}/>
|
||||||
|
</Loader>
|
||||||
|
</ResponsiveTable>
|
||||||
|
<LoadMoreButton onClick={handleLoadMore}
|
||||||
|
show={!loading && !pageLoading && data?.listNodes?.paging?.hasNext === true}/>
|
||||||
|
<ErrorMessage message={error?.message}/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import AccordionItem from '../accordion/AccordionItem'
|
||||||
|
|
||||||
|
export default function MastodonNoindexOptout (): ReactElement {
|
||||||
|
return <AccordionItem
|
||||||
|
label={(<>Mastodon no index option</>)}
|
||||||
|
id={'mastodon-noindex'}
|
||||||
|
>
|
||||||
|
<p className={'lead'}>On Mastodon you can set noindex option in your profile.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Head to <code>Preferences</code> ➡ <code>Other</code></li>
|
||||||
|
<li>Check the option labeled as <code>Opt-out of search engine indexing</code></li>
|
||||||
|
<li>Confirm the change by clicking on the button labeled as <code>Save changes</code></li>
|
||||||
|
</ol>
|
||||||
|
</AccordionItem>
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import AccordionItem from '../accordion/AccordionItem'
|
||||||
|
|
||||||
|
export default function MastodonSuggestingOptout (): ReactElement {
|
||||||
|
return <AccordionItem
|
||||||
|
label={<>Mastodon profile suggesting</>}
|
||||||
|
id={'mastodon-suggesting'}
|
||||||
|
>
|
||||||
|
<p className={'lead'}>On Mastodon you can remove yourself from data offered by your instance's API.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Head to <code>Preferences</code> ➡ <code>Profile</code> ➡ <code>Appereance</code></li>
|
||||||
|
<li>Uncheck the option labeled as <code>Suggest account to others</code></li>
|
||||||
|
<li>Confirm the change by clicking on the button labeled as <code>Save changes</code></li>
|
||||||
|
</ol>
|
||||||
|
</AccordionItem>
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import AccordionItem from '../accordion/AccordionItem'
|
||||||
|
|
||||||
|
export default function RobotsTxtOptout (): ReactElement {
|
||||||
|
return <AccordionItem
|
||||||
|
label={<>Server robots.txt</>}
|
||||||
|
id={'robots-txt'}
|
||||||
|
>
|
||||||
|
<p className={'lead'}>If you are a server maintainer, you can disable crawling of your instance using
|
||||||
|
<strong>robots.txt</strong>.</p>
|
||||||
|
<p>This method will remove all users on your instance from our index.
|
||||||
|
Your users can't bypass your decision.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Create a text file with following content:
|
||||||
|
<pre><code>
|
||||||
|
User-agent: FediCrawl/1.0<br/>
|
||||||
|
Disallow: /
|
||||||
|
</code></pre></li>
|
||||||
|
<li>Expose the file on your instance's domain, on path:<br/>
|
||||||
|
<code>https://<your instace's domain>/robots.txt</code>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</AccordionItem>
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import AccordionItem from '../accordion/AccordionItem'
|
||||||
|
|
||||||
|
export default function TagNobotOptout (): ReactElement {
|
||||||
|
return <AccordionItem
|
||||||
|
label={<>#nobot in profile description</>}
|
||||||
|
id={'tag-nobot'}
|
||||||
|
>
|
||||||
|
<p className={'lead'}>On any platform you can add <strong>#nobot</strong> tag to your profile description.</p>
|
||||||
|
<p>Depending on your platform:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Open profile editing</li>
|
||||||
|
<li>Enter the word <code>#nobot</code> to de description field (including the hash symbol).</li>
|
||||||
|
<li>Save changes</li>
|
||||||
|
</ol>
|
||||||
|
</AccordionItem>
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import React, { ReactElement, ReactNode } from 'react'
|
||||||
|
import ClientConfig from '../../config/ClientConfig'
|
||||||
|
import 'server-only'
|
||||||
|
import '../../styles/global.scss'
|
||||||
|
import ClientLayout from '../layout/ClientLayout'
|
||||||
|
import ClientProviders from '../layout/ClientProviders'
|
||||||
|
|
||||||
|
export default function Layout ({
|
||||||
|
children,
|
||||||
|
config,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
children?: ReactNode
|
||||||
|
config: ClientConfig
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}): ReactElement {
|
||||||
|
console.log('Layout')
|
||||||
|
return (
|
||||||
|
<ClientProviders config={config}>
|
||||||
|
<ClientLayout title={title} description={description}>
|
||||||
|
{children}
|
||||||
|
</ClientLayout>
|
||||||
|
</ClientProviders>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,100 @@
|
||||||
|
'use client'
|
||||||
|
import { useQuery } from '@apollo/client'
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import { ReactElement, useEffect, useState } from 'react'
|
||||||
|
import {
|
||||||
|
ListStatsDocument,
|
||||||
|
SortingWayEnum, StatsAggregationFragment,
|
||||||
|
StatsQueryInput,
|
||||||
|
StatsSortingByEnum
|
||||||
|
} from '../../graphql/generated/types'
|
||||||
|
import { useMatomo } from '../../hooks/MatomoHook'
|
||||||
|
import createSortingInputSchema from '../../schema/createSortingInputSchema'
|
||||||
|
import createUrlSearchParams from '../../utils/createUrlSearchParams'
|
||||||
|
import ErrorMessage from '../ErrorMessage'
|
||||||
|
import Loader from '../Loader'
|
||||||
|
import ResponsiveTable from '../ResponsiveTable'
|
||||||
|
import StatsFooter from './StatsFooter'
|
||||||
|
import StatsHeader from './StatsHeader'
|
||||||
|
import StatsPlaceholder from './StatsPlaceholder'
|
||||||
|
import StatsResults from './StatsResults'
|
||||||
|
|
||||||
|
const statsQueryInputSchema = createSortingInputSchema(StatsSortingByEnum)
|
||||||
|
|
||||||
|
export default function Stats (): ReactElement {
|
||||||
|
const [lastRowCount, setLastRowCount] = useState<number>(1)
|
||||||
|
const [lastSum, setLastSum] = useState<StatsAggregationFragment>({
|
||||||
|
nodeCount: 0,
|
||||||
|
accountFeedCount: 0,
|
||||||
|
channelFeedCount: 0
|
||||||
|
})
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const matomo = useMatomo()
|
||||||
|
let routerQuery: StatsQueryInput
|
||||||
|
try {
|
||||||
|
routerQuery = statsQueryInputSchema.parse(Object.fromEntries(searchParams))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e)
|
||||||
|
routerQuery = {
|
||||||
|
sortBy: StatsSortingByEnum.NodeCount,
|
||||||
|
sortWay: SortingWayEnum.Desc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Router query', routerQuery)
|
||||||
|
const [query, setQuery] = useState<StatsQueryInput>(routerQuery)
|
||||||
|
const { loading, error, data } = useQuery(ListStatsDocument, {
|
||||||
|
variables: {
|
||||||
|
query
|
||||||
|
}
|
||||||
|
})
|
||||||
|
useEffect(() => {
|
||||||
|
const items = data?.listStats?.items
|
||||||
|
const sum = data?.listStats?.aggregations.sum
|
||||||
|
if (items === undefined || sum === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLastRowCount(items.length)
|
||||||
|
setLastSum(sum)
|
||||||
|
}, [data])
|
||||||
|
useEffect(() => {
|
||||||
|
router.push(`${pathname ?? ''}?${createUrlSearchParams(query).toString()}`)
|
||||||
|
matomo.trackEvent({
|
||||||
|
category: 'stats',
|
||||||
|
action: 'new-search'
|
||||||
|
})
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
const toggleSort = (sortBy: StatsSortingByEnum): void => {
|
||||||
|
const sortWay = query.sortBy === sortBy && query.sortWay === SortingWayEnum.Asc ? SortingWayEnum.Desc : SortingWayEnum.Asc
|
||||||
|
matomo.trackEvent({
|
||||||
|
category: 'stats',
|
||||||
|
action: 'sort',
|
||||||
|
customDimensions: [
|
||||||
|
{
|
||||||
|
value: `${sortBy} ${sortWay}`,
|
||||||
|
id: 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
setQuery({
|
||||||
|
...query,
|
||||||
|
sortBy,
|
||||||
|
sortWay
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (<>
|
||||||
|
<ResponsiveTable className={'stats'}>
|
||||||
|
<StatsHeader query={query} onSortToggle={toggleSort}/>
|
||||||
|
<Loader loading={loading} showTop={true} hideContent={true}
|
||||||
|
placeholder={(<StatsPlaceholder rowCount={lastRowCount}/>)}>
|
||||||
|
<StatsResults items={data?.listStats?.items} maxAggregation={data?.listStats?.aggregations.max}/>
|
||||||
|
</Loader>
|
||||||
|
<StatsFooter sumAggregation={lastSum}/>
|
||||||
|
</ResponsiveTable>
|
||||||
|
<ErrorMessage message={error?.message}/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import {ReactElement} from "react";
|
||||||
|
import {StatsAggregationFragment} from "../../graphql/generated/types";
|
||||||
|
|
||||||
|
export default function StatsFooter({sumAggregation}: { sumAggregation: StatsAggregationFragment | undefined }): ReactElement {
|
||||||
|
return (
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th>Summary</th>
|
||||||
|
<th className={'text-end'}>{sumAggregation?.nodeCount??0}</th>
|
||||||
|
<th className={'text-end'}>{sumAggregation?.accountFeedCount??0}</th>
|
||||||
|
<th className={'text-end'}>{sumAggregation?.channelFeedCount??0}</th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
'use client'
|
||||||
|
import {ReactElement} from "react";
|
||||||
|
import {NodeSortingByEnum, StatsQueryInput, StatsSortingByEnum} from "../../graphql/generated/types";
|
||||||
|
import SortToggle from "../SortToggle";
|
||||||
|
|
||||||
|
export default function StatsHeader({query,onSortToggle}: {
|
||||||
|
query: StatsQueryInput,
|
||||||
|
onSortToggle: (sortBy: StatsSortingByEnum) => void
|
||||||
|
}): ReactElement {
|
||||||
|
return <thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'softwareName'} sort={query}>
|
||||||
|
Software name
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th className={'text-end'}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'nodeCount'} sort={query}>
|
||||||
|
Instance count
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th className={'text-end'}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'accountFeedCount'} sort={query}>
|
||||||
|
Account count
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
<th className={'text-end'}>
|
||||||
|
<SortToggle onToggle={onSortToggle} field={'channelFeedCount'} sort={query}>
|
||||||
|
Channel count
|
||||||
|
</SortToggle>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import ProgressBar from '../ProgressBar'
|
||||||
|
import SoftwareBadgePlaceholder from '../SoftwareBadgePlaceholder'
|
||||||
|
|
||||||
|
const Row = (): ReactElement => <tr>
|
||||||
|
<td>
|
||||||
|
<SoftwareBadgePlaceholder/>
|
||||||
|
</td>
|
||||||
|
<td className={'text-end placeholder-glow'}>
|
||||||
|
<span className={'placeholder col-5'}></span>
|
||||||
|
<ProgressBar way={'left'}
|
||||||
|
percents={0}/>
|
||||||
|
</td>
|
||||||
|
<td className={'text-end placeholder-glow'}>
|
||||||
|
<span className={'placeholder col-5'}></span>
|
||||||
|
<ProgressBar way={'left'}
|
||||||
|
percents={0}/>
|
||||||
|
</td>
|
||||||
|
<td className={'text-end placeholder-glow'}>
|
||||||
|
<span className={'placeholder col-5'}></span>
|
||||||
|
<ProgressBar way={'left'}
|
||||||
|
percents={0}/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
export default function StatsPlaceholder ({ rowCount }: { rowCount?: number }): ReactElement {
|
||||||
|
return <tbody>
|
||||||
|
{[...Array(rowCount ?? 1).keys()].map(key => {
|
||||||
|
return <Row key={key}/>
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import { Stats, StatsAggregationFragment } from '../../graphql/generated/types'
|
||||||
|
import SoftwareBadge from '../SoftwareBadge'
|
||||||
|
import ProgressBar from '../ProgressBar'
|
||||||
|
|
||||||
|
export default function StatsResult ({ software, maxAggregation }: {
|
||||||
|
software: Stats
|
||||||
|
maxAggregation: StatsAggregationFragment
|
||||||
|
}): ReactElement {
|
||||||
|
return <tr>
|
||||||
|
<td>
|
||||||
|
<SoftwareBadge softwareName={software.softwareName}/>
|
||||||
|
</td>
|
||||||
|
<td className={'text-end'}>
|
||||||
|
<span>{software.nodeCount}</span>
|
||||||
|
<ProgressBar way={'left'}
|
||||||
|
percents={100 * software.nodeCount / maxAggregation.nodeCount}/>
|
||||||
|
</td>
|
||||||
|
<td className={'text-end'}>
|
||||||
|
<span>{software.accountFeedCount}</span>
|
||||||
|
<ProgressBar way={'left'}
|
||||||
|
percents={100 * software.accountFeedCount / maxAggregation.accountFeedCount}/>
|
||||||
|
</td>
|
||||||
|
<td className={'text-end'}>
|
||||||
|
<span>{software.channelFeedCount}</span>
|
||||||
|
<ProgressBar way={'left'}
|
||||||
|
percents={100 * software.channelFeedCount / maxAggregation.channelFeedCount}/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { ReactElement } from 'react'
|
||||||
|
import { StatsAggregationFragment, StatsItemFragment } from '../../graphql/generated/types'
|
||||||
|
import StatsResult from './StatsResult'
|
||||||
|
|
||||||
|
export default function StatsResults ({ items, maxAggregation }: {
|
||||||
|
items: StatsItemFragment[] | undefined
|
||||||
|
maxAggregation: StatsAggregationFragment | undefined
|
||||||
|
}): ReactElement {
|
||||||
|
if (items === undefined || maxAggregation === undefined || items.length === 0) {
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4}><em>No stats found.</em></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
items.map((software, index) => {
|
||||||
|
return <StatsResult key={index} software={software} maxAggregation={maxAggregation} />
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { Config } from 'convict'
|
||||||
|
import ClientConfig from './ClientConfig'
|
||||||
|
|
||||||
|
type AppConfig = Config<{
|
||||||
|
client: ClientConfig
|
||||||
|
}>
|
||||||
|
|
||||||
|
export default AppConfig
|
|
@ -0,0 +1,7 @@
|
||||||
|
import GraphqlConfig from './GraphqlConfig'
|
||||||
|
import MatomoConfig from './MatomoConfig'
|
||||||
|
|
||||||
|
export default interface ClientConfig {
|
||||||
|
graphql: GraphqlConfig
|
||||||
|
matomo: MatomoConfig
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default interface GraphqlConfig {
|
||||||
|
url: string
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export default interface MatomoConfig {
|
||||||
|
url: string
|
||||||
|
siteId: number
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
import convict from 'convict'
|
||||||
|
import AppConfig from './AppConfig'
|
||||||
|
import 'server-only'
|
||||||
|
|
||||||
|
export default function createConfig (): AppConfig {
|
||||||
|
console.info('Creating config')
|
||||||
|
|
||||||
|
return convict({
|
||||||
|
client: {
|
||||||
|
graphql: {
|
||||||
|
url: {
|
||||||
|
doc: 'Storage graphql endpoint url',
|
||||||
|
format: '*',
|
||||||
|
env: 'GRAPHQL_URL',
|
||||||
|
arg: 'graphql-url',
|
||||||
|
default: '/api/graphql'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
matomo: {
|
||||||
|
url: {
|
||||||
|
doc: 'Matomo endpoint url',
|
||||||
|
env: 'MATOMO_URL',
|
||||||
|
arg: 'matomo-url',
|
||||||
|
format: '*',
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
siteId: {
|
||||||
|
doc: 'Matomo site identificator',
|
||||||
|
env: 'MATOMO_SITE_ID',
|
||||||
|
arg: 'matomo-site-id',
|
||||||
|
format: 'int',
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client'
|
import { ApolloClient, InMemoryCache, NormalizedCacheObject } from '@apollo/client'
|
||||||
|
import GraphqlConfig from '../../config/GraphqlConfig.js'
|
||||||
|
|
||||||
export default function createGraphqlClient (): ApolloClient<NormalizedCacheObject> {
|
export default function createGraphqlClient (config: GraphqlConfig): ApolloClient<NormalizedCacheObject> {
|
||||||
return new ApolloClient({
|
return new ApolloClient({
|
||||||
uri: '/api/graphql',
|
uri: config.url,
|
||||||
cache: new InMemoryCache()
|
cache: new InMemoryCache()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,113 +0,0 @@
|
||||||
import { gql } from '@apollo/client'
|
|
||||||
import { List } from '../types/List'
|
|
||||||
|
|
||||||
export const ListFeedsQuery = gql`
|
|
||||||
query ListFeeds($paging: PagingInput, $query: FeedQueryInput) {
|
|
||||||
listFeeds(paging: $paging,query: $query){
|
|
||||||
paging {
|
|
||||||
hasNext
|
|
||||||
},
|
|
||||||
items {
|
|
||||||
id,
|
|
||||||
avatar,
|
|
||||||
displayName,
|
|
||||||
foundAt,
|
|
||||||
bot,
|
|
||||||
createdAt,
|
|
||||||
description,
|
|
||||||
displayName,
|
|
||||||
followersCount,
|
|
||||||
followingCount,
|
|
||||||
lastStatusAt,
|
|
||||||
locked,
|
|
||||||
name,
|
|
||||||
refreshedAt,
|
|
||||||
statusesCount,
|
|
||||||
type,
|
|
||||||
url,
|
|
||||||
fields {
|
|
||||||
name,value
|
|
||||||
}
|
|
||||||
node {
|
|
||||||
domain,
|
|
||||||
foundAt,
|
|
||||||
geoip {
|
|
||||||
city_name,
|
|
||||||
country_iso_code,
|
|
||||||
},
|
|
||||||
halfYearActiveUserCount,
|
|
||||||
id,
|
|
||||||
monthActiveUserCount,
|
|
||||||
name,
|
|
||||||
openRegistrations,
|
|
||||||
refreshAttemptedAt,
|
|
||||||
refreshedAt,
|
|
||||||
softwareName
|
|
||||||
},
|
|
||||||
parent {
|
|
||||||
id,
|
|
||||||
avatar,
|
|
||||||
displayName
|
|
||||||
name,
|
|
||||||
domain,
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export interface ParentFeedItem {
|
|
||||||
id: string
|
|
||||||
avatar: string
|
|
||||||
displayName: string
|
|
||||||
name: string
|
|
||||||
domain: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FeedResultItem {
|
|
||||||
id: string
|
|
||||||
avatar: string
|
|
||||||
displayName: string
|
|
||||||
foundAt: string
|
|
||||||
bot: boolean
|
|
||||||
createdAt: string
|
|
||||||
description: string
|
|
||||||
followersCount: number
|
|
||||||
followingCount: number
|
|
||||||
lastStatusAt: string
|
|
||||||
locked: boolean
|
|
||||||
name: string
|
|
||||||
refreshedAt: string
|
|
||||||
statusesCount: number
|
|
||||||
type: 'account' | 'channel'
|
|
||||||
url: string
|
|
||||||
fields: Array<{
|
|
||||||
name: string
|
|
||||||
value: string
|
|
||||||
}>
|
|
||||||
node: {
|
|
||||||
domain: string
|
|
||||||
foundAt: string
|
|
||||||
geoip: {
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
city_name: string
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
country_iso_code: string
|
|
||||||
}
|
|
||||||
halfYearActiveUserCount: number
|
|
||||||
id: string
|
|
||||||
monthActiveUserCount: number
|
|
||||||
name: string
|
|
||||||
openRegistrations: boolean
|
|
||||||
refreshAttemptedAt: string
|
|
||||||
refreshedAt: string
|
|
||||||
softwareName: string
|
|
||||||
}
|
|
||||||
parent: ParentFeedItem | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListFeedsResult {
|
|
||||||
listFeeds: List<FeedResultItem>
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
import { gql } from '@apollo/client'
|
|
||||||
import { List } from '../types/List'
|
|
||||||
|
|
||||||
export const ListNodesQuery = gql`
|
|
||||||
query ListNodes($paging: PagingInput, $query: NodeQueryInput) {
|
|
||||||
listNodes(paging: $paging,query: $query){
|
|
||||||
paging {
|
|
||||||
hasNext
|
|
||||||
}
|
|
||||||
items {
|
|
||||||
domain,
|
|
||||||
foundAt,
|
|
||||||
geoip {
|
|
||||||
city_name,country_iso_code
|
|
||||||
},
|
|
||||||
halfYearActiveUserCount,
|
|
||||||
id,
|
|
||||||
monthActiveUserCount,
|
|
||||||
accountFeedCount,
|
|
||||||
name,
|
|
||||||
openRegistrations,
|
|
||||||
refreshAttemptedAt,
|
|
||||||
refreshedAt,
|
|
||||||
serverIps,
|
|
||||||
softwareName,
|
|
||||||
softwareVersion,
|
|
||||||
standardizedSoftwareVersion,
|
|
||||||
statusesCount,
|
|
||||||
totalUserCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export interface NodeResultItem {
|
|
||||||
domain: string
|
|
||||||
foundAt: string
|
|
||||||
geoip: {
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
city_name: string
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
country_iso_code: string
|
|
||||||
}
|
|
||||||
halfYearActiveUserCount: number
|
|
||||||
id: string
|
|
||||||
monthActiveUserCount: number
|
|
||||||
name: string
|
|
||||||
openRegistrations: boolean
|
|
||||||
refreshAttemptedAt: string
|
|
||||||
refreshedAt: string
|
|
||||||
softwareName: string
|
|
||||||
softwareVersion: string
|
|
||||||
standardizedSoftwareVersion: string
|
|
||||||
totalUserCount: number
|
|
||||||
statusesCount: number
|
|
||||||
accountFeedCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListNodesResult {
|
|
||||||
listNodes: List<NodeResultItem>
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
import { gql } from '@apollo/client'
|
|
||||||
import { List } from '../types/List'
|
|
||||||
|
|
||||||
export const ListStatsQuery = gql`
|
|
||||||
query ListStats($query: StatsQueryInput) {
|
|
||||||
listStats(query:$query) {
|
|
||||||
items {
|
|
||||||
softwareName
|
|
||||||
nodeCount
|
|
||||||
accountFeedCount
|
|
||||||
channelFeedCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export interface StatsResultItem {
|
|
||||||
softwareName: string
|
|
||||||
nodeCount: number
|
|
||||||
accountFeedCount: number
|
|
||||||
channelFeedCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ListStatsResult {
|
|
||||||
listStats: List<StatsResultItem>
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { PagingType } from '../../server/schema/types'
|
|
||||||
|
|
||||||
export interface List<TItem> {
|
|
||||||
paging: PagingType
|
|
||||||
items: TItem[]
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { FeedQueryInputType } from '../types/FeedQueryInput'
|
|
||||||
import { PagingInputType } from '../types/PagingInput'
|
|
||||||
|
|
||||||
export interface ListFeedsVariables {
|
|
||||||
paging: PagingInputType
|
|
||||||
query: FeedQueryInputType
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { PagingInputType } from '../types/PagingInput'
|
|
||||||
import { NodeQueryInputType } from '../types/NodeQueryInput'
|
|
||||||
|
|
||||||
export interface ListNodesVariables {
|
|
||||||
paging: PagingInputType
|
|
||||||
query: NodeQueryInputType
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
import { StatsQueryInputType } from '../types/StatsQueryInput'
|
|
||||||
|
|
||||||
export interface ListStatsVariables {
|
|
||||||
query: StatsQueryInputType
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
import { z } from 'zod'
|
|
||||||
import { stringTrimmed, transform } from '../../../lib/transform'
|
|
||||||
|
|
||||||
export const feedQueryInputSchema = z.object({
|
|
||||||
search: transform(
|
|
||||||
z.string().optional(),
|
|
||||||
stringTrimmed,
|
|
||||||
z.string()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export type FeedQueryInputType = z.infer<typeof feedQueryInputSchema>
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { stringTrimmed, transform } from '../../../lib/transform'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { createSortingInputSchema } from './SortingInput'
|
|
||||||
import { nodeSortingBySchema } from './NodeSortingByEnum'
|
|
||||||
|
|
||||||
export const nodeQueryInputSchema = createSortingInputSchema(nodeSortingBySchema)
|
|
||||||
.extend(
|
|
||||||
{
|
|
||||||
search: transform(
|
|
||||||
z.string().optional(),
|
|
||||||
stringTrimmed,
|
|
||||||
z.string()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export type NodeQueryInputType = z.infer<typeof nodeQueryInputSchema>
|
|
|
@ -1,17 +0,0 @@
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
export const NodeSortingByValues: readonly [string, ...string[]] = [
|
|
||||||
'domain',
|
|
||||||
'softwareName',
|
|
||||||
'totalUserCount',
|
|
||||||
'monthActiveUserCount',
|
|
||||||
'halfYearActiveUserCount',
|
|
||||||
'statusesCount',
|
|
||||||
'accountFeedCount',
|
|
||||||
'openRegistrations',
|
|
||||||
'refreshedAt'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const nodeSortingBySchema = z.enum(NodeSortingByValues)
|
|
||||||
|
|
||||||
export type NodeSoringByEnumType = z.infer<typeof nodeSortingBySchema>
|
|
|
@ -1,3 +0,0 @@
|
||||||
export interface PagingInputType {
|
|
||||||
page: number
|
|
||||||
}
|
|
|
@ -1,14 +0,0 @@
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
|
||||||
export const createSortingInputSchema = (members: z.ZodEnum<[string, ...string[]]>) => {
|
|
||||||
return z.object({
|
|
||||||
sortBy: members,
|
|
||||||
sortWay: z.enum(['asc', 'desc'])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SortingInputType<TMembers> {
|
|
||||||
sortBy: TMembers
|
|
||||||
sortWay: 'asc' | 'desc'
|
|
||||||
}
|
|
|
@ -1,7 +0,0 @@
|
||||||
import { z } from 'zod'
|
|
||||||
import { createSortingInputSchema } from './SortingInput'
|
|
||||||
import { statsSortingBySchema } from './StatsSortingByEnum'
|
|
||||||
|
|
||||||
export const statsQueryInputSchema = createSortingInputSchema(statsSortingBySchema)
|
|
||||||
|
|
||||||
export type StatsQueryInputType = z.infer<typeof statsQueryInputSchema>
|
|
|
@ -1,12 +0,0 @@
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
export const StatsSortingByValues: readonly [string, ...string[]] = [
|
|
||||||
'softwareName',
|
|
||||||
'nodeCount',
|
|
||||||
'accountFeedCount',
|
|
||||||
'channelFeedCount'
|
|
||||||
]
|
|
||||||
|
|
||||||
export const statsSortingBySchema = z.enum(StatsSortingByValues)
|
|
||||||
|
|
||||||
export type StatsSoringByEnumType = z.infer<typeof statsSortingBySchema>
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
fragment ListFeedsItem on Feed{
|
||||||
|
id,
|
||||||
|
avatar,
|
||||||
|
displayName,
|
||||||
|
foundAt,
|
||||||
|
bot,
|
||||||
|
createdAt,
|
||||||
|
description,
|
||||||
|
displayName,
|
||||||
|
followersCount,
|
||||||
|
followingCount,
|
||||||
|
lastStatusAt,
|
||||||
|
locked,
|
||||||
|
name,
|
||||||
|
refreshedAt,
|
||||||
|
statusesCount,
|
||||||
|
type,
|
||||||
|
url,
|
||||||
|
fields {
|
||||||
|
name,value
|
||||||
|
}
|
||||||
|
node {
|
||||||
|
...ListFeedsNode
|
||||||
|
},
|
||||||
|
parent {
|
||||||
|
...ParentFeed
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
fragment ListFeedsNode on Node{
|
||||||
|
domain,
|
||||||
|
foundAt,
|
||||||
|
geoip {
|
||||||
|
...ListNodesGeoIp
|
||||||
|
},
|
||||||
|
halfYearActiveUserCount,
|
||||||
|
id,
|
||||||
|
monthActiveUserCount,
|
||||||
|
name,
|
||||||
|
openRegistrations,
|
||||||
|
refreshAttemptedAt,
|
||||||
|
refreshedAt,
|
||||||
|
softwareName
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
fragment ListNodesGeoIp on GeoIp{
|
||||||
|
city_name,
|
||||||
|
country_iso_code
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
fragment ListNodesItem on Node{
|
||||||
|
domain,
|
||||||
|
foundAt,
|
||||||
|
geoip {
|
||||||
|
...ListNodesGeoIp
|
||||||
|
},
|
||||||
|
halfYearActiveUserCount,
|
||||||
|
id,
|
||||||
|
monthActiveUserCount,
|
||||||
|
accountFeedCount,
|
||||||
|
name,
|
||||||
|
openRegistrations,
|
||||||
|
refreshAttemptedAt,
|
||||||
|
refreshedAt,
|
||||||
|
serverIps,
|
||||||
|
softwareName,
|
||||||
|
softwareVersion,
|
||||||
|
standardizedSoftwareVersion,
|
||||||
|
statusesCount,
|
||||||
|
totalUserCount
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
fragment Paging on Paging{
|
||||||
|
hasNext
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
fragment ParentFeed on Feed{
|
||||||
|
id,
|
||||||
|
avatar,
|
||||||
|
displayName
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
url
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
fragment StatsAggregation on StatsAggregation{
|
||||||
|
accountFeedCount
|
||||||
|
channelFeedCount
|
||||||
|
nodeCount
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
fragment StatsAggregations on StatsAggregations{
|
||||||
|
sum {
|
||||||
|
...StatsAggregation
|
||||||
|
}
|
||||||
|
max {
|
||||||
|
...StatsAggregation
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
fragment StatsItem on Stats{
|
||||||
|
softwareName
|
||||||
|
nodeCount
|
||||||
|
accountFeedCount
|
||||||
|
channelFeedCount
|
||||||
|
}
|
|
@ -0,0 +1,192 @@
|
||||||
|
scalar DateTime
|
||||||
|
|
||||||
|
type Feed {
|
||||||
|
id: ID!
|
||||||
|
domain: String!
|
||||||
|
foundAt: DateTime!
|
||||||
|
refreshedAt: DateTime
|
||||||
|
name: String!
|
||||||
|
displayName: String!
|
||||||
|
description: String!
|
||||||
|
followersCount: Int
|
||||||
|
followingCount: Int
|
||||||
|
statusesCount: Int
|
||||||
|
lastStatusAt: DateTime
|
||||||
|
createdAt: DateTime
|
||||||
|
bot: Boolean
|
||||||
|
locked: Boolean!
|
||||||
|
url: String!
|
||||||
|
avatar: String
|
||||||
|
type: FeedTypeEnum!
|
||||||
|
parent: Feed
|
||||||
|
fields: [Field!]!
|
||||||
|
node: Node!
|
||||||
|
}
|
||||||
|
|
||||||
|
input FeedIdentityInput {
|
||||||
|
name: String!
|
||||||
|
nodeDomain: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input FeedInput {
|
||||||
|
name: String!
|
||||||
|
displayName: String!
|
||||||
|
description: String!
|
||||||
|
followersCount: Int!
|
||||||
|
followingCount: Int!
|
||||||
|
statusesCount: Int
|
||||||
|
bot: Boolean
|
||||||
|
url: String!
|
||||||
|
avatar: String
|
||||||
|
locked: Boolean!
|
||||||
|
lastStatusAt: DateTime
|
||||||
|
createdAt: DateTime!
|
||||||
|
fields: [FieldInput!]!
|
||||||
|
type: FeedTypeEnum!
|
||||||
|
parentFeed: FeedIdentityInput
|
||||||
|
tags: [String!]!
|
||||||
|
emails: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeedList {
|
||||||
|
paging: Paging!
|
||||||
|
items: [Feed!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input FeedQueryInput {
|
||||||
|
search: String! = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FeedTypeEnum {
|
||||||
|
account
|
||||||
|
channel
|
||||||
|
}
|
||||||
|
|
||||||
|
type Field {
|
||||||
|
name: String!
|
||||||
|
value: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input FieldInput {
|
||||||
|
name: String!
|
||||||
|
value: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type GeoIp {
|
||||||
|
city_name: String
|
||||||
|
continent_name: String
|
||||||
|
country_iso_code: String
|
||||||
|
country_name: String
|
||||||
|
location: String
|
||||||
|
region_iso_code: String
|
||||||
|
region_name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
type Node {
|
||||||
|
id: ID!
|
||||||
|
name: String
|
||||||
|
foundAt: DateTime!
|
||||||
|
refreshAttemptedAt: DateTime
|
||||||
|
refreshedAt: DateTime
|
||||||
|
openRegistrations: Boolean
|
||||||
|
domain: String!
|
||||||
|
serverIps: [String!]
|
||||||
|
geoip: GeoIp
|
||||||
|
softwareName: String
|
||||||
|
accountFeedCount: Int
|
||||||
|
channelFeedCount: Int
|
||||||
|
softwareVersion: String
|
||||||
|
standardizedSoftwareVersion: String
|
||||||
|
halfYearActiveUserCount: Int
|
||||||
|
monthActiveUserCount: Int
|
||||||
|
statusesCount: Int
|
||||||
|
totalUserCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeList {
|
||||||
|
paging: Paging!
|
||||||
|
items: [Node!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
input NodeQueryInput {
|
||||||
|
sortBy: NodeSortingByEnum = refreshedAt
|
||||||
|
sortWay: SortingWayEnum = desc
|
||||||
|
search: String! = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NodeSortingByEnum {
|
||||||
|
domain
|
||||||
|
softwareName
|
||||||
|
totalUserCount
|
||||||
|
monthActiveUserCount
|
||||||
|
halfYearActiveUserCount
|
||||||
|
statusesCount
|
||||||
|
accountFeedCount
|
||||||
|
openRegistrations
|
||||||
|
refreshedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeStats {
|
||||||
|
channel: Int!
|
||||||
|
account: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Paging {
|
||||||
|
hasNext: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
input PagingInput {
|
||||||
|
page: Int! = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sorting {
|
||||||
|
by: String!
|
||||||
|
way: SortingWayEnum!
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SortingWayEnum {
|
||||||
|
asc
|
||||||
|
desc
|
||||||
|
}
|
||||||
|
|
||||||
|
type Stats {
|
||||||
|
softwareName: String!
|
||||||
|
nodeCount: Int!
|
||||||
|
accountFeedCount: Int!
|
||||||
|
channelFeedCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsList {
|
||||||
|
items: [Stats!]!
|
||||||
|
aggregations: StatsAggregations!
|
||||||
|
}
|
||||||
|
|
||||||
|
input StatsQueryInput {
|
||||||
|
sortBy: StatsSortingByEnum = nodeCount
|
||||||
|
sortWay: SortingWayEnum = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StatsSortingByEnum {
|
||||||
|
softwareName
|
||||||
|
nodeCount
|
||||||
|
accountFeedCount
|
||||||
|
channelFeedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsAggregations {
|
||||||
|
sum: StatsAggregation!
|
||||||
|
max: StatsAggregation!
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatsAggregation {
|
||||||
|
nodeCount: Int!
|
||||||
|
accountFeedCount: Int!
|
||||||
|
channelFeedCount: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
countNodeFeeds(nodeDomain: String!): NodeStats
|
||||||
|
listFeeds(paging: PagingInput! = {page: 0}, query: FeedQueryInput! = {search: ""}): FeedList
|
||||||
|
listNodes(paging: PagingInput! = {page: 0}, query: NodeQueryInput! = {sortBy: refreshedAt, sortWay: desc, search: ""}): NodeList
|
||||||
|
listStats(query: StatsQueryInput! = {sortBy: nodeCount, sortWay: desc}): StatsList
|
||||||
|
}
|
|
@ -0,0 +1,289 @@
|
||||||
|
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'
|
||||||
|
export type Maybe<T> = T | null
|
||||||
|
export type InputMaybe<T> = Maybe<T>
|
||||||
|
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] }
|
||||||
|
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> }
|
||||||
|
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> }
|
||||||
|
/** All built-in and custom scalars, mapped to their actual values */
|
||||||
|
export interface Scalars {
|
||||||
|
ID: string
|
||||||
|
String: string
|
||||||
|
Boolean: boolean
|
||||||
|
Int: number
|
||||||
|
Float: number
|
||||||
|
DateTime: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Feed {
|
||||||
|
__typename?: 'Feed'
|
||||||
|
avatar?: Maybe<Scalars['String']>
|
||||||
|
bot?: Maybe<Scalars['Boolean']>
|
||||||
|
createdAt?: Maybe<Scalars['DateTime']>
|
||||||
|
description: Scalars['String']
|
||||||
|
displayName: Scalars['String']
|
||||||
|
domain: Scalars['String']
|
||||||
|
fields: Field[]
|
||||||
|
followersCount?: Maybe<Scalars['Int']>
|
||||||
|
followingCount?: Maybe<Scalars['Int']>
|
||||||
|
foundAt: Scalars['DateTime']
|
||||||
|
id: Scalars['ID']
|
||||||
|
lastStatusAt?: Maybe<Scalars['DateTime']>
|
||||||
|
locked: Scalars['Boolean']
|
||||||
|
name: Scalars['String']
|
||||||
|
node: Node
|
||||||
|
parent?: Maybe<Feed>
|
||||||
|
refreshedAt?: Maybe<Scalars['DateTime']>
|
||||||
|
statusesCount?: Maybe<Scalars['Int']>
|
||||||
|
type: FeedTypeEnum
|
||||||
|
url: Scalars['String']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedIdentityInput {
|
||||||
|
name: Scalars['String']
|
||||||
|
nodeDomain: Scalars['String']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedInput {
|
||||||
|
avatar?: InputMaybe<Scalars['String']>
|
||||||
|
bot?: InputMaybe<Scalars['Boolean']>
|
||||||
|
createdAt: Scalars['DateTime']
|
||||||
|
description: Scalars['String']
|
||||||
|
displayName: Scalars['String']
|
||||||
|
emails: Array<Scalars['String']>
|
||||||
|
fields: FieldInput[]
|
||||||
|
followersCount: Scalars['Int']
|
||||||
|
followingCount: Scalars['Int']
|
||||||
|
lastStatusAt?: InputMaybe<Scalars['DateTime']>
|
||||||
|
locked: Scalars['Boolean']
|
||||||
|
name: Scalars['String']
|
||||||
|
parentFeed?: InputMaybe<FeedIdentityInput>
|
||||||
|
statusesCount?: InputMaybe<Scalars['Int']>
|
||||||
|
tags: Array<Scalars['String']>
|
||||||
|
type: FeedTypeEnum
|
||||||
|
url: Scalars['String']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedList {
|
||||||
|
__typename?: 'FeedList'
|
||||||
|
items: Feed[]
|
||||||
|
paging: Paging
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedQueryInput {
|
||||||
|
search?: Scalars['String']
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum FeedTypeEnum {
|
||||||
|
Account = 'account',
|
||||||
|
Channel = 'channel'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Field {
|
||||||
|
__typename?: 'Field'
|
||||||
|
name: Scalars['String']
|
||||||
|
value: Scalars['String']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldInput {
|
||||||
|
name: Scalars['String']
|
||||||
|
value: Scalars['String']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeoIp {
|
||||||
|
__typename?: 'GeoIp'
|
||||||
|
city_name?: Maybe<Scalars['String']>
|
||||||
|
continent_name?: Maybe<Scalars['String']>
|
||||||
|
country_iso_code?: Maybe<Scalars['String']>
|
||||||
|
country_name?: Maybe<Scalars['String']>
|
||||||
|
location?: Maybe<Scalars['String']>
|
||||||
|
region_iso_code?: Maybe<Scalars['String']>
|
||||||
|
region_name?: Maybe<Scalars['String']>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Node {
|
||||||
|
__typename?: 'Node'
|
||||||
|
accountFeedCount?: Maybe<Scalars['Int']>
|
||||||
|
channelFeedCount?: Maybe<Scalars['Int']>
|
||||||
|
domain: Scalars['String']
|
||||||
|
foundAt: Scalars['DateTime']
|
||||||
|
geoip?: Maybe<GeoIp>
|
||||||
|
halfYearActiveUserCount?: Maybe<Scalars['Int']>
|
||||||
|
id: Scalars['ID']
|
||||||
|
monthActiveUserCount?: Maybe<Scalars['Int']>
|
||||||
|
name?: Maybe<Scalars['String']>
|
||||||
|
openRegistrations?: Maybe<Scalars['Boolean']>
|
||||||
|
refreshAttemptedAt?: Maybe<Scalars['DateTime']>
|
||||||
|
refreshedAt?: Maybe<Scalars['DateTime']>
|
||||||
|
serverIps?: Maybe<Array<Scalars['String']>>
|
||||||
|
softwareName?: Maybe<Scalars['String']>
|
||||||
|
softwareVersion?: Maybe<Scalars['String']>
|
||||||
|
standardizedSoftwareVersion?: Maybe<Scalars['String']>
|
||||||
|
statusesCount?: Maybe<Scalars['Int']>
|
||||||
|
totalUserCount?: Maybe<Scalars['Int']>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeList {
|
||||||
|
__typename?: 'NodeList'
|
||||||
|
items: Node[]
|
||||||
|
paging: Paging
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeQueryInput {
|
||||||
|
search?: Scalars['String']
|
||||||
|
sortBy?: InputMaybe<NodeSortingByEnum>
|
||||||
|
sortWay?: InputMaybe<SortingWayEnum>
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NodeSortingByEnum {
|
||||||
|
AccountFeedCount = 'accountFeedCount',
|
||||||
|
Domain = 'domain',
|
||||||
|
HalfYearActiveUserCount = 'halfYearActiveUserCount',
|
||||||
|
MonthActiveUserCount = 'monthActiveUserCount',
|
||||||
|
OpenRegistrations = 'openRegistrations',
|
||||||
|
RefreshedAt = 'refreshedAt',
|
||||||
|
SoftwareName = 'softwareName',
|
||||||
|
StatusesCount = 'statusesCount',
|
||||||
|
TotalUserCount = 'totalUserCount'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeStats {
|
||||||
|
__typename?: 'NodeStats'
|
||||||
|
account: Scalars['Int']
|
||||||
|
channel: Scalars['Int']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paging {
|
||||||
|
__typename?: 'Paging'
|
||||||
|
hasNext: Scalars['Boolean']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PagingInput {
|
||||||
|
page?: Scalars['Int']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Query {
|
||||||
|
__typename?: 'Query'
|
||||||
|
countNodeFeeds?: Maybe<NodeStats>
|
||||||
|
listFeeds?: Maybe<FeedList>
|
||||||
|
listNodes?: Maybe<NodeList>
|
||||||
|
listStats?: Maybe<StatsList>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryCountNodeFeedsArgs {
|
||||||
|
nodeDomain: Scalars['String']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryListFeedsArgs {
|
||||||
|
paging?: PagingInput
|
||||||
|
query?: FeedQueryInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryListNodesArgs {
|
||||||
|
paging?: PagingInput
|
||||||
|
query?: NodeQueryInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryListStatsArgs {
|
||||||
|
query?: StatsQueryInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Sorting {
|
||||||
|
__typename?: 'Sorting'
|
||||||
|
by: Scalars['String']
|
||||||
|
way: SortingWayEnum
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum SortingWayEnum {
|
||||||
|
Asc = 'asc',
|
||||||
|
Desc = 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
__typename?: 'Stats'
|
||||||
|
accountFeedCount: Scalars['Int']
|
||||||
|
channelFeedCount: Scalars['Int']
|
||||||
|
nodeCount: Scalars['Int']
|
||||||
|
softwareName: Scalars['String']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsAggregation {
|
||||||
|
__typename?: 'StatsAggregation'
|
||||||
|
accountFeedCount: Scalars['Int']
|
||||||
|
channelFeedCount: Scalars['Int']
|
||||||
|
nodeCount: Scalars['Int']
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsAggregations {
|
||||||
|
__typename?: 'StatsAggregations'
|
||||||
|
max: StatsAggregation
|
||||||
|
sum: StatsAggregation
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsList {
|
||||||
|
__typename?: 'StatsList'
|
||||||
|
aggregations: StatsAggregations
|
||||||
|
items: Stats[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsQueryInput {
|
||||||
|
sortBy?: InputMaybe<StatsSortingByEnum>
|
||||||
|
sortWay?: InputMaybe<SortingWayEnum>
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum StatsSortingByEnum {
|
||||||
|
AccountFeedCount = 'accountFeedCount',
|
||||||
|
ChannelFeedCount = 'channelFeedCount',
|
||||||
|
NodeCount = 'nodeCount',
|
||||||
|
SoftwareName = 'softwareName'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListFeedsItemFragment { __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, foundAt: any, bot?: boolean | null, createdAt?: any | null, description: string, followersCount?: number | null, followingCount?: number | null, lastStatusAt?: any | null, locked: boolean, name: string, refreshedAt?: any | null, statusesCount?: number | null, type: FeedTypeEnum, url: string, fields: Array<{ __typename?: 'Field', name: string, value: string }>, node: { __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, softwareName?: string | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }, parent?: { __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, name: string, domain: string, url: string } | null }
|
||||||
|
|
||||||
|
export interface ListFeedsNodeFragment { __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, softwareName?: string | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }
|
||||||
|
|
||||||
|
export interface ListNodesGeoIpFragment { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null }
|
||||||
|
|
||||||
|
export interface ListNodesItemFragment { __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, accountFeedCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, serverIps?: string[] | null, softwareName?: string | null, softwareVersion?: string | null, standardizedSoftwareVersion?: string | null, statusesCount?: number | null, totalUserCount?: number | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }
|
||||||
|
|
||||||
|
export interface PagingFragment { __typename?: 'Paging', hasNext: boolean }
|
||||||
|
|
||||||
|
export interface ParentFeedFragment { __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, name: string, domain: string, url: string }
|
||||||
|
|
||||||
|
export interface StatsAggregationFragment { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number }
|
||||||
|
|
||||||
|
export interface StatsAggregationsFragment { __typename?: 'StatsAggregations', sum: { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number }, max: { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number } }
|
||||||
|
|
||||||
|
export interface StatsItemFragment { __typename?: 'Stats', softwareName: string, nodeCount: number, accountFeedCount: number, channelFeedCount: number }
|
||||||
|
|
||||||
|
export type ListFeedsQueryVariables = Exact<{
|
||||||
|
paging: PagingInput
|
||||||
|
query: FeedQueryInput
|
||||||
|
}>
|
||||||
|
|
||||||
|
export interface ListFeedsQuery { __typename?: 'Query', listFeeds?: { __typename?: 'FeedList', paging: { __typename?: 'Paging', hasNext: boolean }, items: Array<{ __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, foundAt: any, bot?: boolean | null, createdAt?: any | null, description: string, followersCount?: number | null, followingCount?: number | null, lastStatusAt?: any | null, locked: boolean, name: string, refreshedAt?: any | null, statusesCount?: number | null, type: FeedTypeEnum, url: string, fields: Array<{ __typename?: 'Field', name: string, value: string }>, node: { __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, softwareName?: string | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }, parent?: { __typename?: 'Feed', id: string, avatar?: string | null, displayName: string, name: string, domain: string, url: string } | null }> } | null }
|
||||||
|
|
||||||
|
export type ListNodesQueryVariables = Exact<{
|
||||||
|
paging: PagingInput
|
||||||
|
query: NodeQueryInput
|
||||||
|
}>
|
||||||
|
|
||||||
|
export interface ListNodesQuery { __typename?: 'Query', listNodes?: { __typename?: 'NodeList', paging: { __typename?: 'Paging', hasNext: boolean }, items: Array<{ __typename?: 'Node', domain: string, foundAt: any, halfYearActiveUserCount?: number | null, id: string, monthActiveUserCount?: number | null, accountFeedCount?: number | null, name?: string | null, openRegistrations?: boolean | null, refreshAttemptedAt?: any | null, refreshedAt?: any | null, serverIps?: string[] | null, softwareName?: string | null, softwareVersion?: string | null, standardizedSoftwareVersion?: string | null, statusesCount?: number | null, totalUserCount?: number | null, geoip?: { __typename?: 'GeoIp', city_name?: string | null, country_iso_code?: string | null } | null }> } | null }
|
||||||
|
|
||||||
|
export type ListStatsQueryVariables = Exact<{
|
||||||
|
query: StatsQueryInput
|
||||||
|
}>
|
||||||
|
|
||||||
|
export interface ListStatsQuery { __typename?: 'Query', listStats?: { __typename?: 'StatsList', items: Array<{ __typename?: 'Stats', softwareName: string, nodeCount: number, accountFeedCount: number, channelFeedCount: number }>, aggregations: { __typename?: 'StatsAggregations', sum: { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number }, max: { __typename?: 'StatsAggregation', accountFeedCount: number, channelFeedCount: number, nodeCount: number } } } | null }
|
||||||
|
|
||||||
|
export const ListNodesGeoIpFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ListNodesGeoIp' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'GeoIp' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'city_name' } }, { kind: 'Field', name: { kind: 'Name', value: 'country_iso_code' } }] } }] } as unknown as DocumentNode<ListNodesGeoIpFragment, unknown>
|
||||||
|
export const ListFeedsNodeFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ListFeedsNode' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Node' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'domain' } }, { kind: 'Field', name: { kind: 'Name', value: 'foundAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'geoip' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListNodesGeoIp' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'halfYearActiveUserCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'monthActiveUserCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'openRegistrations' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshAttemptedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'softwareName' } }] } }, ...ListNodesGeoIpFragmentDoc.definitions] } as unknown as DocumentNode<ListFeedsNodeFragment, unknown>
|
||||||
|
export const ParentFeedFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ParentFeed' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Feed' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'avatar' } }, { kind: 'Field', name: { kind: 'Name', value: 'displayName' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'domain' } }, { kind: 'Field', name: { kind: 'Name', value: 'url' } }] } }] } as unknown as DocumentNode<ParentFeedFragment, unknown>
|
||||||
|
export const ListFeedsItemFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ListFeedsItem' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Feed' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'avatar' } }, { kind: 'Field', name: { kind: 'Name', value: 'displayName' } }, { kind: 'Field', name: { kind: 'Name', value: 'foundAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'bot' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'displayName' } }, { kind: 'Field', name: { kind: 'Name', value: 'followersCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'followingCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'lastStatusAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'locked' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusesCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'type' } }, { kind: 'Field', name: { kind: 'Name', value: 'url' } }, { kind: 'Field', name: { kind: 'Name', value: 'fields' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'value' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'node' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListFeedsNode' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'parent' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ParentFeed' } }] } }] } }, ...ListFeedsNodeFragmentDoc.definitions, ...ParentFeedFragmentDoc.definitions] } as unknown as DocumentNode<ListFeedsItemFragment, unknown>
|
||||||
|
export const ListNodesItemFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'ListNodesItem' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Node' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'domain' } }, { kind: 'Field', name: { kind: 'Name', value: 'foundAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'geoip' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListNodesGeoIp' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'halfYearActiveUserCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'monthActiveUserCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'accountFeedCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'openRegistrations' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshAttemptedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'refreshedAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'serverIps' } }, { kind: 'Field', name: { kind: 'Name', value: 'softwareName' } }, { kind: 'Field', name: { kind: 'Name', value: 'softwareVersion' } }, { kind: 'Field', name: { kind: 'Name', value: 'standardizedSoftwareVersion' } }, { kind: 'Field', name: { kind: 'Name', value: 'statusesCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'totalUserCount' } }] } }, ...ListNodesGeoIpFragmentDoc.definitions] } as unknown as DocumentNode<ListNodesItemFragment, unknown>
|
||||||
|
export const PagingFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'Paging' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Paging' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'hasNext' } }] } }] } as unknown as DocumentNode<PagingFragment, unknown>
|
||||||
|
export const StatsAggregationFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'StatsAggregation' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'StatsAggregation' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'accountFeedCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'channelFeedCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'nodeCount' } }] } }] } as unknown as DocumentNode<StatsAggregationFragment, unknown>
|
||||||
|
export const StatsAggregationsFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'StatsAggregations' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'StatsAggregations' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'sum' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'StatsAggregation' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'max' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'StatsAggregation' } }] } }] } }, ...StatsAggregationFragmentDoc.definitions] } as unknown as DocumentNode<StatsAggregationsFragment, unknown>
|
||||||
|
export const StatsItemFragmentDoc = { kind: 'Document', definitions: [{ kind: 'FragmentDefinition', name: { kind: 'Name', value: 'StatsItem' }, typeCondition: { kind: 'NamedType', name: { kind: 'Name', value: 'Stats' } }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'softwareName' } }, { kind: 'Field', name: { kind: 'Name', value: 'nodeCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'accountFeedCount' } }, { kind: 'Field', name: { kind: 'Name', value: 'channelFeedCount' } }] } }] } as unknown as DocumentNode<StatsItemFragment, unknown>
|
||||||
|
export const ListFeedsDocument = { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'query', name: { kind: 'Name', value: 'ListFeeds' }, variableDefinitions: [{ kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'paging' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'PagingInput' } } } }, { kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'query' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'FeedQueryInput' } } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'listFeeds' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'paging' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'paging' } } }, { kind: 'Argument', name: { kind: 'Name', value: 'query' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'query' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'paging' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'Paging' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'items' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListFeedsItem' } }] } }] } }] } }, ...PagingFragmentDoc.definitions, ...ListFeedsItemFragmentDoc.definitions] } as unknown as DocumentNode<ListFeedsQuery, ListFeedsQueryVariables>
|
||||||
|
export const ListNodesDocument = { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'query', name: { kind: 'Name', value: 'ListNodes' }, variableDefinitions: [{ kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'paging' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'PagingInput' } } } }, { kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'query' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'NodeQueryInput' } } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'listNodes' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'paging' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'paging' } } }, { kind: 'Argument', name: { kind: 'Name', value: 'query' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'query' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'paging' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'Paging' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'items' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'ListNodesItem' } }] } }] } }] } }, ...PagingFragmentDoc.definitions, ...ListNodesItemFragmentDoc.definitions] } as unknown as DocumentNode<ListNodesQuery, ListNodesQueryVariables>
|
||||||
|
export const ListStatsDocument = { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'query', name: { kind: 'Name', value: 'ListStats' }, variableDefinitions: [{ kind: 'VariableDefinition', variable: { kind: 'Variable', name: { kind: 'Name', value: 'query' } }, type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'StatsQueryInput' } } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'listStats' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'query' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'query' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'items' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'StatsItem' } }] } }, { kind: 'Field', name: { kind: 'Name', value: 'aggregations' }, selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'FragmentSpread', name: { kind: 'Name', value: 'StatsAggregations' } }] } }] } }] } }, ...StatsItemFragmentDoc.definitions, ...StatsAggregationsFragmentDoc.definitions] } as unknown as DocumentNode<ListStatsQuery, ListStatsQueryVariables>
|
|
@ -0,0 +1,10 @@
|
||||||
|
query ListFeeds($paging: PagingInput!, $query: FeedQueryInput!) {
|
||||||
|
listFeeds(paging: $paging,query: $query){
|
||||||
|
paging {
|
||||||
|
...Paging
|
||||||
|
},
|
||||||
|
items {
|
||||||
|
...ListFeedsItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
query ListNodes($paging: PagingInput!, $query: NodeQueryInput!) {
|
||||||
|
listNodes(paging: $paging,query: $query){
|
||||||
|
paging {
|
||||||
|
...Paging
|
||||||
|
}
|
||||||
|
items {
|
||||||
|
...ListNodesItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
query ListStats($query: StatsQueryInput!) {
|
||||||
|
listStats(query:$query) {
|
||||||
|
items {
|
||||||
|
...StatsItem
|
||||||
|
}
|
||||||
|
aggregations {
|
||||||
|
...StatsAggregations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue