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
|
||||
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
|
||||
WORKDIR /srv
|
||||
COPY application/package*.json ./
|
||||
|
|
19
README.md
19
README.md
|
@ -2,27 +2,22 @@
|
|||
|
||||
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.
|
||||
|
||||
## Config
|
||||
|
||||
Configuration is done using environmental variables:
|
||||
Configuration is done using environmental variables or command flags
|
||||
|
||||
| Variable | Description | Value example |
|
||||
|-----------------------|--------------------------------------------------------------------------------------------------------------------|-------------------------------|
|
||||
| `ELASTIC_URL` | Url address of ElasticSearch server | `http://elastic:9200` |
|
||||
| `ELASTIC_USER` | Username for EalsticSearch server | `elastic` |
|
||||
| `ELASTIC_PASSWORD` | Username for EalsticSearch server | empty |
|
||||
| `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` |
|
||||
| Env variable | Command argument | Description | Value example | Default value |
|
||||
|------------------|--------------------|----------------------------------------------------------------------------------------------------------------------|---------------------------------|----------------|
|
||||
| `MATOMO_URL` | `--matomo-url` | _Optional_ url of Matomo server for collecting usage statistics. Leaving it empty disables collecting analytics. | `https://matomo.myserver.tld` | empty |
|
||||
| `MATOMO_SITE_ID` | `--matomo-site-id` | _Optional_ Matomo site id parameter for collecting usage statistics. Leaving it empty disables collecting analytics. | `8` | `0` |
|
||||
| `GRAPHQL_URL` | `--graphql-url` | *Required* Fedistore graphql api url | `https://fedistore.example/api` | `/api/graphql` |
|
||||
|
||||
## Deploy
|
||||
|
||||
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
|
||||
|
||||
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) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
|
@ -20,3 +24,5 @@ module.exports = {
|
|||
tsconfigPath: '../tsconfig.json'
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
|
|
|
@ -6,16 +6,17 @@
|
|||
"private": true,
|
||||
"author": "Štěpán Škorpil",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev ./src --hostname 0.0.0.0",
|
||||
"build": "next build ./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": {
|
||||
"@apollo/client": "^3.6.9",
|
||||
"@datapunt/matomo-tracker-js": "^0.5.1",
|
||||
"@elastic/elasticsearch": "^8.2.1",
|
||||
"@fortawesome/fontawesome-common-types": "^6.2.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.2.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.2.0",
|
||||
|
@ -25,27 +26,32 @@
|
|||
"@hookform/resolvers": "^2.9.10",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
"apollo-server-micro": "^3.10.1",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap": "^5.1.3",
|
||||
"convict": "~6.1.0",
|
||||
"graphql": "^16.5.0",
|
||||
"micro": "^9.4.1",
|
||||
"micro-cors": "^0.1.1",
|
||||
"next": "^12.2.5",
|
||||
"nexus": "^1.3.0",
|
||||
"next": "^13.0.6",
|
||||
"node-cache": "^5.1.2",
|
||||
"npmlog": "^6.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-smooth-collapse": "^2.1.2",
|
||||
"sass": "^1.45.1",
|
||||
"server-only": "^0.0.1",
|
||||
"striptags": "^3.2.0",
|
||||
"typescript-collections": "^1.3.3",
|
||||
"yargs-parser": "^20.2.7",
|
||||
"zod": "^3.11.6"
|
||||
},
|
||||
"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",
|
||||
"@types/convict": "^6.1.1",
|
||||
"@types/jest": "^29.2.0",
|
||||
"@types/micro-cors": "^0.1.2",
|
||||
"@types/node": "^18.7.18",
|
||||
"@types/npmlog": "^4.1.3",
|
||||
"@types/react": "^17.0.14",
|
||||
|
@ -60,7 +66,7 @@
|
|||
"eslint-plugin-react": "^7.31.8",
|
||||
"jest": "^29.2.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.3.5"
|
||||
"typescript": "^4.9.4"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"env": {
|
||||
|
@ -76,6 +82,9 @@
|
|||
"tsconfig.json"
|
||||
]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"src/next-env.d.ts"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-misused-promises": [
|
||||
"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'
|
||||
|
||||
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'
|
||||
|
||||
const Loader: React.FC<{ children: ReactNode, loading: boolean, hideContent?: boolean, table?: number, showTop?: boolean, showBottom?: boolean }> = ({
|
||||
export default function Loader ({
|
||||
showTop,
|
||||
showBottom,
|
||||
hideContent,
|
||||
children,
|
||||
table,
|
||||
loading
|
||||
}) => {
|
||||
const className = 'loader' + (loading ? ' -loading' : '')
|
||||
|
||||
const spinner = (
|
||||
<div className={'d-flex justify-content-center'}>
|
||||
<Spinner/>
|
||||
</div>
|
||||
loading,
|
||||
placeholder
|
||||
}: {
|
||||
children: ReactNode
|
||||
loading: boolean
|
||||
hideContent?: boolean
|
||||
showTop?: boolean
|
||||
showBottom?: boolean
|
||||
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 (
|
||||
<>
|
||||
{(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 FallbackImage from '../FallbackImage'
|
||||
import React, { ReactElement } from 'react'
|
||||
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'
|
||||
|
||||
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>
|
||||
</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 { faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
|
||||
const SortToggle: React.FC<{
|
||||
export default function SortToggle ({ onToggle, field, sort, children }: {
|
||||
onToggle: (StatsRequestSortBy) => void
|
||||
field: string
|
||||
sort: Sort
|
||||
}> = ({ onToggle, field, sort, children }) => {
|
||||
children: ReactNode
|
||||
}): ReactElement {
|
||||
const handleToggle: MouseEventHandler = (event) => {
|
||||
event.preventDefault()
|
||||
onToggle(field)
|
||||
}
|
||||
return (
|
||||
<a className={'sort-toggle'} href={'#'} onClick={() => onToggle(field)}>
|
||||
<a className={'sort-toggle'} href={''} onClick={handleToggle}>
|
||||
<span>{children}</span>
|
||||
{sort.sortBy === field && sort.sortWay === 'asc'
|
||||
{sort.sortBy === field && sort.sortWay === SortingWayEnum.Asc
|
||||
? (
|
||||
<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'} />
|
||||
)
|
||||
|
@ -26,5 +32,3 @@ const SortToggle: React.FC<{
|
|||
</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 striptags from 'striptags'
|
||||
import { ListFeedsItemFragment } from '../../graphql/generated/types'
|
||||
import Avatar from './Avatar'
|
||||
import SoftwareBadge from './badges/SoftwareBadge'
|
||||
import SoftwareBadge from '../SoftwareBadge'
|
||||
import FeedTypeBadge from './badges/FeedTypeBadge'
|
||||
import CreatedAtBadge from './badges/CreatedAtBadge'
|
||||
import LastPostAtBadge from './badges/LastPostAtBadge'
|
||||
|
@ -10,11 +11,8 @@ import ParentFeed from './ParentFeed'
|
|||
import StatusesCountBadge from './badges/StatusesCountBadge'
|
||||
import FollowersBadge from './badges/FollowersBadge'
|
||||
import FollowingBadge from './badges/FollowingBadge'
|
||||
import { FeedResultItem } from '../graphql/client/queries/ListFeedsQuery'
|
||||
|
||||
const FeedResult = ({
|
||||
feed
|
||||
}: { feed: FeedResultItem }): ReactElement => {
|
||||
export default function FeedResult ({ feed }: { feed: ListFeedsItemFragment }): ReactElement {
|
||||
const fallbackEmojiImage = '/emoji.svg'
|
||||
|
||||
const handleEmojiImageError = (event): void => {
|
||||
|
@ -44,7 +42,7 @@ const FeedResult = ({
|
|||
<span>{feed.id}</span>
|
||||
<ParentFeed feed={feed.parent}/>
|
||||
</div>
|
||||
<SoftwareBadge softwareName={feed.node.softwareName}/>
|
||||
<SoftwareBadge softwareName={feed.node.softwareName ?? ''}/>
|
||||
<div className={'badges'}>
|
||||
<FeedTypeBadge type={feed.type}/>
|
||||
<FollowersBadge followers={feed.followersCount}/>
|
||||
|
@ -79,7 +77,6 @@ const FeedResult = ({
|
|||
<div className={'description with-emoji'}
|
||||
dangerouslySetInnerHTML={{ __html: striptags(feed.description, ['img', 'p', 'strong', 'em', 'br', 'a']) }}/>
|
||||
</div>
|
||||
</section>)
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeedResult
|
|
@ -1,10 +1,11 @@
|
|||
import React, { ReactElement } from 'react'
|
||||
import { ListFeedsItemFragment } from '../../graphql/generated/types'
|
||||
import FeedResult from './FeedResult'
|
||||
import { FeedResultItem } from '../graphql/client/queries/ListFeedsQuery'
|
||||
|
||||
const FeedResults = ({
|
||||
feeds
|
||||
}: { feeds: FeedResultItem[] }): ReactElement => {
|
||||
export default function FeedResults ({ feeds }: { feeds: ListFeedsItemFragment[] | undefined }): ReactElement {
|
||||
if (feeds === undefined) {
|
||||
return <></>
|
||||
}
|
||||
if (feeds.length === 0) {
|
||||
return (
|
||||
<>
|
||||
|
@ -21,5 +22,3 @@ const FeedResults = ({
|
|||
}
|
||||
</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 { ParentFeedItem } from '../graphql/client/queries/ListFeedsQuery'
|
||||
|
||||
const ParentFeed: React.FC<{ feed: ParentFeedItem | null }> = ({ feed }) => {
|
||||
if (feed == null) {
|
||||
export default function ParentFeed ({ feed }: { feed: Maybe<ParentFeedFragment> | undefined }): ReactElement {
|
||||
if (feed === null || feed === undefined) {
|
||||
return (<></>)
|
||||
}
|
||||
return (
|
||||
|
@ -16,5 +16,3 @@ const ParentFeed: React.FC<{ feed: ParentFeedItem | null }> = ({ feed }) => {
|
|||
</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 Badge from './Badge'
|
||||
|
||||
const CreatedAtBadge: React.FC<{ createdAt: string | null }> = ({ createdAt }) => {
|
||||
export default function CreatedAtBadge ({ createdAt }: { createdAt: string | null }): ReactElement {
|
||||
return (
|
||||
<Badge faIcon={faCalendarPlus}
|
||||
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 Badge from './Badge'
|
||||
|
||||
const FeedTypeBadge: React.FC<{ type: 'account' | 'channel' }> = ({ type }) => {
|
||||
export default function FeedTypeBadge ({ type }: { type: 'account' | 'channel' }): ReactElement {
|
||||
return (
|
||||
<Badge faIcon={type === 'channel' ? faRss : faUser}
|
||||
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 React from 'react'
|
||||
import React, { ReactElement } from 'react'
|
||||
import Badge from './Badge'
|
||||
|
||||
const FollowersBadge: React.FC<{ followers: number | null }> = ({ followers }) => {
|
||||
export default function FollowersBadge ({ followers }: { followers: number | null | undefined }): ReactElement {
|
||||
return (
|
||||
<Badge faIcon={faUserFriends}
|
||||
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 React from 'react'
|
||||
import React, { ReactElement } from 'react'
|
||||
import Badge from './Badge'
|
||||
|
||||
const FollowingBadge: React.FC<{ following: number | null }> = ({ following }) => {
|
||||
export default function FollowingBadge ({ following }: { following: number | null | undefined }): ReactElement {
|
||||
return (
|
||||
<Badge faIcon={faEye}
|
||||
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 { 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 (
|
||||
<Badge faIcon={faCalendarCheck}
|
||||
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 Badge from './Badge'
|
||||
|
||||
const StatusesCountBadge: React.FC<{ statusesCount: number | null }> = ({ statusesCount }) => {
|
||||
export default function StatusesCountBadge ({ statusesCount }: { statusesCount: number | null | undefined }): ReactElement {
|
||||
return (
|
||||
<Badge faIcon={faCommentAlt}
|
||||
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 (
|
||||
<footer className={'text-center mt-5'}>
|
||||
<p><a href={'/optout'}>How to opt-out</a></p>
|
||||
|
@ -8,5 +8,3 @@ const Footer: React.FC = () => {
|
|||
</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 { 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)
|
||||
return (
|
||||
<nav className="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
|
@ -31,5 +32,3 @@ const NavBar: React.FC = () => {
|
|||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
export default NavBar
|
|
@ -1,19 +1,21 @@
|
|||
import { usePathname } from 'next/navigation'
|
||||
import React, { FC } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { IconProp } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
|
||||
const NavItem: FC<{ path: string, label: string, icon: IconProp }> = ({ path, label, icon }) => {
|
||||
const router = useRouter()
|
||||
const active = router.pathname === path
|
||||
const currentPath = usePathname()
|
||||
const active = currentPath === path
|
||||
return (
|
||||
<li className={'nav-item'}>
|
||||
<Link href={path}>
|
||||
<a className={'nav-link' + (active ? ' active' : '')} aria-current={active ? 'page' : undefined}>
|
||||
<Link
|
||||
href={path}
|
||||
className={'nav-link' + (active ? ' active' : '')}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className={'margin-right'} />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
</Link>
|
||||
</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 GraphqlConfig from '../../config/GraphqlConfig.js'
|
||||
|
||||
export default function createGraphqlClient (): ApolloClient<NormalizedCacheObject> {
|
||||
export default function createGraphqlClient (config: GraphqlConfig): ApolloClient<NormalizedCacheObject> {
|
||||
return new ApolloClient({
|
||||
uri: '/api/graphql',
|
||||
uri: config.url,
|
||||
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